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" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>furniture-app</title> <title>furniture-app</title>
<script type="module" crossorigin src="/assets/index-xKTFqHjg.js"></script> <script type="module" crossorigin src="/assets/index-BkulQhND.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DfR9GACM.css"> <link rel="stylesheet" crossorigin href="/assets/index-67F-Ik0K.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+34 -67
View File
@@ -1,9 +1,9 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { RoomCanvas } from './components/RoomCanvas'; import { RoomCanvas } from './components/RoomCanvas';
import { ControlPanel } from './components/ControlPanel'; import { ControlPanel } from './components/ControlPanel';
import type { FurnitureConfig, FurniturePreset, LayoutState } from './types'; import type { LayoutState } from './types';
import { runRandomSearch } from './optimization/randomSearch'; import { runRandomSearch } from './optimization/randomSearch';
import { optimizeWithNN } from './ai/nn'; import { prepareForOptimize } from './optimization/prepare';
import { DEFAULT_PRESETS } from './furniture/presets'; import { DEFAULT_PRESETS } from './furniture/presets';
const initialState: LayoutState = { const initialState: LayoutState = {
@@ -13,84 +13,50 @@ const initialState: LayoutState = {
presets: DEFAULT_PRESETS, 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() { function App() {
const [state, setState] = useState<LayoutState>(initialState); const [state, setState] = useState<LayoutState>(initialState);
const [mode, setMode] = useState<'layout' | 'optimize'>('layout'); const [mode, setMode] = useState<'layout' | 'optimize'>('layout');
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [optimizing, setOptimizing] = useState(false); const [optimizing, setOptimizing] = useState(false);
const [timeLeft, setTimeLeft] = useState(15); const [lastScore, setLastScore] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
setState((s) => syncFurniture(s)); const onKey = (e: KeyboardEvent) => {
}, [state.presets]); 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(() => { useEffect(() => {
if (!optimizing) return; if (!optimizing) return;
if (timeLeft <= 0) { const tick = () => {
const res = optimizeWithNN(state, { gridSize: 10 }); const { bestState, bestEval } = runRandomSearch(state, {
setState(res.bestState); iterations: 40,
setOptimizing(false); roomWidth: state.room.width,
setTimeLeft(15); roomHeight: state.room.height,
return; gridSize: state.room.gridSize,
} });
const t = setTimeout(() => setTimeLeft((v) => v - 1), 1000); setState(bestState);
const { bestState } = runRandomSearch(state, { setLastScore(bestEval.totalScore);
iterations: 5, };
roomWidth: state.room.width, const t = setTimeout(tick, 30);
roomHeight: state.room.height,
gridSize: state.room.gridSize,
});
setState(bestState);
return () => clearTimeout(t); return () => clearTimeout(t);
}, [optimizing, timeLeft]); }, [optimizing, state, state.room.gridSize, state.room.width, state.room.height]);
const handleOptimize = () => { const handleOptimize = () => {
setState((s) => ({ if (optimizing) {
...s, setOptimizing(false);
furniture: s.furniture.map((f) => ({ ...f, lockedForOptimization: f.placed ? true : false })), return;
})); }
setState((s) => prepareForOptimize(s));
setLastScore(null);
setOptimizing(true); setOptimizing(true);
setTimeLeft(15);
}; };
return ( return (
@@ -104,7 +70,7 @@ function App() {
onSelect={setSelectedId} onSelect={setSelectedId}
onOptimize={handleOptimize} onOptimize={handleOptimize}
optimizing={optimizing} optimizing={optimizing}
timeLeft={timeLeft} lastScore={lastScore}
/> />
<div className="stage"> <div className="stage">
<div className="canvas-frame"> <div className="canvas-frame">
@@ -114,6 +80,7 @@ function App() {
mode={mode} mode={mode}
selectedId={selectedId} selectedId={selectedId}
onSelect={setSelectedId} onSelect={setSelectedId}
showPaths={mode === 'optimize'}
/> />
</div> </div>
</div> </div>
+31 -5
View File
@@ -1,5 +1,7 @@
import type { LayoutEvaluation, LayoutState } from '../types'; import type { LayoutEvaluation, LayoutState } from '../types';
import { evaluateLayout } from '../layout/evaluator'; import { evaluateLayout } from '../layout/evaluator';
import { entranceCollider, findFreeSpot, furnitureCollider, overlapsAny, resolveMove } from '../geometry/bounds';
import type { Collider } from '../geometry/collision';
export interface NNOptions { export interface NNOptions {
gridSize?: number; gridSize?: number;
@@ -14,6 +16,11 @@ export function optimizeWithNN(state: LayoutState, options: NNOptions = {}): { b
...f, ...f,
shape: { ...f.shape, points: f.shape.points?.map((p) => ({ ...p })) }, 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; const entrance = candidate.entrance;
@@ -32,7 +39,10 @@ export function optimizeWithNN(state: LayoutState, options: NNOptions = {}): { b
targetY = entrance.y + (Math.random() - 0.5) * 40; targetY = entrance.y + (Math.random() - 0.5) * 40;
} else if (needsNearObj) { } else if (needsNearObj) {
const tid = (needsNearObj.params as any)?.targetId; 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) { if (t) {
targetX = t.shape.x + t.shape.width / 2 - f.shape.width / 2 + (Math.random() - 0.5) * 30; 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; 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 (targetX < 0) targetX = 0;
if (targetY < 0) targetY = 0; if (targetY < 0) targetY = 0;
if (targetX + f.shape.width > candidate.room.width) targetX = candidate.room.width - f.shape.width; const room = candidate.room;
if (targetY + f.shape.height > candidate.room.height) targetY = candidate.room.height - f.shape.height; const others: Collider[] = candidate.furniture
f.shape.x = targetX; .filter((o) => o.id !== f.id)
f.shape.y = targetY; .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); const bestEval = evaluateLayout(candidate, options.gridSize || 10);
+409 -279
View File
@@ -1,6 +1,8 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import type { ConditionKind, Entrance, FurnitureConfig, FurniturePreset, LayoutState, RoomConfig } from '../types'; import type { ConditionKind, Entrance, FurniturePreset, LayoutState, RoomConfig } from '../types';
import { NumberField } from './NumberField'; import { NumberField } from './NumberField';
import { clampStateToRoom } from '../geometry/bounds';
import { rectanglePoints } from '../geometry/polygon';
interface ControlPanelProps { interface ControlPanelProps {
state: LayoutState; state: LayoutState;
@@ -11,7 +13,7 @@ interface ControlPanelProps {
onSelect: (id: string | null) => void; onSelect: (id: string | null) => void;
onOptimize: () => void; onOptimize: () => void;
optimizing: boolean; optimizing: boolean;
timeLeft: number; lastScore: number | null;
} }
const CONDITION_KINDS: ConditionKind[] = [ const CONDITION_KINDS: ConditionKind[] = [
@@ -22,6 +24,7 @@ const CONDITION_KINDS: ConditionKind[] = [
'accessible', 'accessible',
'not_blocking_path', 'not_blocking_path',
'open_circulation', 'open_circulation',
'line_of_sight',
]; ];
const CONDITION_LABELS: Record<ConditionKind, string> = { const CONDITION_LABELS: Record<ConditionKind, string> = {
@@ -32,8 +35,11 @@ const CONDITION_LABELS: Record<ConditionKind, string> = {
accessible: 'Accessible', accessible: 'Accessible',
not_blocking_path: 'Not blocking path', not_blocking_path: 'Not blocking path',
open_circulation: 'Open circulation', open_circulation: 'Open circulation',
line_of_sight: 'Clear line of sight',
}; };
const SWATCHES = ['#a1887f', '#d7ccc5', '#90caf9', '#a5d6a7', '#ef9a9a', '#ce93d8', '#ffcc80'];
export function ControlPanel({ export function ControlPanel({
state, state,
onStateChange, onStateChange,
@@ -43,269 +49,344 @@ export function ControlPanel({
onSelect, onSelect,
onOptimize, onOptimize,
optimizing, optimizing,
timeLeft, lastScore,
}: ControlPanelProps) { }: ControlPanelProps) {
const [openId, setOpenId] = useState<string | null>(null); 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>) => { 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>) => { const setEntrance = (entrance: Partial<Entrance>) => {
if (!state.entrance) return; if (!state.entrance) return;
onStateChange({ ...state, entrance: { ...state.entrance, ...entrance } }); onStateChange(clampStateToRoom({ ...state, entrance: { ...state.entrance, ...entrance } }));
}; };
const placeEntrance = () => { const placeEntrance = () =>
onStateChange({ ...state, entrance: { x: 20, y: 20, width: 20, height: 20 } }); 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 createObject = () => {
const id = 'obj_' + Date.now(); const id = 'obj_' + Date.now();
const newItem: FurnitureConfig = { const newObject: FurniturePreset = {
id, id,
name: 'Object ' + (state.furniture.length + 1), name: 'Object ' + (objects.filter((o) => !o.isPreset).length + 1),
type: 'custom', color: SWATCHES[objects.length % SWATCHES.length],
shape: { type: 'rectangle', x: 100, y: 100, width: 50, height: 50 }, amount: 1,
isPreset: false,
shape: { type: 'rectangle', width: 50, height: 50 },
conditions: [ conditions: [
{ kind: 'walk_to_and_sit', weight: 1 }, { kind: 'walk_to_and_sit', weight: 1 },
{ kind: 'accessible', 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); onSelect(id);
setOpenId(id); setOpenId(id);
}; };
const updatePreset = (id: string, patch: Partial<FurniturePreset>) => { const toggleObjectCondition = (id: string, k: ConditionKind, enabled: boolean) => {
const presets = (state.presets || []).map((p) => (p.id === id ? { ...p, ...patch } : p)); const presets = objects.map((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) => {
if (p.id !== id) return p; if (p.id !== id) return p;
const has = p.conditions.some((c) => c.kind === k); const has = p.conditions.some((c) => c.kind === k);
if (enabled && !has) { 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) { if (!enabled && has) {
return { ...p, conditions: p.conditions.filter((c) => c.kind !== k) }; return { ...p, conditions: p.conditions.filter((c) => c.kind !== k) };
} }
return p; 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) => { const toggleOpen = (id: string) => {
onSelect(id); onSelect(id === '__entrance__' ? '__entrance__' : id);
setOpenId((cur) => (cur === id ? null : id)); setOpenId((cur) => (cur === id ? null : id));
}; };
const conditionsEditor = ( const renderObjectCard = (p: FurniturePreset) => {
conditions: FurnitureConfig['conditions'], const open = openId === p.id;
onToggle: (k: ConditionKind, enabled: boolean) => void, const placedCount = state.furniture.filter((f) => f.type === p.id && f.placed).length;
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;
return ( return (
<div className={`obj-card ${open ? 'open' : ''}`} key={p.id}> <div key={p.id} className={`obj-card ${open ? 'open' : ''}`}>
<div className="obj-head" onClick={() => toggleOpen('preset_' + p.id)}> <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-caret ${open ? 'open' : ''}`}>▶</span>
<span className="obj-swatch" style={{ background: p.color }} /> <span className="obj-swatch" style={{ background: p.color }} />
<span className="obj-name">{p.name}</span> <span className="obj-name">{p.name}</span>
{placedCount > 0 && <span className="obj-badge">{placedCount} placed</span>}
<span className="obj-count" onClick={(e) => e.stopPropagation()}> <span className="obj-count" onClick={(e) => e.stopPropagation()}>
<NumberField <NumberField value={p.amount ?? 0} min={0} width={44} onChange={(v) => setAmount(p.id, v)} />
value={p.amount ?? 0}
min={0}
width={44}
onChange={(v) => setPresetAmount(p.id, v)}
/>
</span> </span>
</div> </div>
{open && ( {open && (
<div className="obj-body"> <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"> <div className="grid-2">
<label className="field"> <label className="field">
<span>Width</span> <span>Width (cm)</span>
<NumberField <NumberField
value={p.shape.width} value={p.shape.width}
min={10} min={10}
width="100%" width="100%"
onChange={(v) => updatePreset(p.id, { shape: { ...p.shape, width: v } })} onChange={(v) => updateObject(p.id, { shape: { ...p.shape, width: v } })}
/> />
</label> </label>
<label className="field"> <label className="field">
<span>Height</span> <span>Height (cm)</span>
<NumberField <NumberField
value={p.shape.height} value={p.shape.height}
min={10} min={10}
width="100%" width="100%"
onChange={(v) => updatePreset(p.id, { shape: { ...p.shape, height: v } })} onChange={(v) => updateObject(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 });
}}
/> />
</label> </label>
<label className="field"> <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 <NumberField
value={f.shape.height} value={p.shape.scale ?? 1}
min={10} min={0.1}
step={0.05}
width="100%" width="100%"
onChange={(v) => { onChange={(v) => updateObject(p.id, { shape: { ...p.shape, scale: v } })}
const updated = state.furniture.map((x) =>
x.id === f.id ? { ...x, shape: { ...x.shape, height: v } } : x,
);
onStateChange({ ...state, furniture: updated });
}}
/> />
</label> </label>
</div> </div>
<div className="row" style={{ marginTop: 10 }}> <div className="row" style={{ marginTop: 10 }}>
<label> <span className="hint" style={{ margin: 0 }}>
<input Color
type="checkbox" </span>
checked={f.lockedForOptimization} <div className="swatch-row">
onChange={() => toggleLock(f.id)} {SWATCHES.map((c) => (
/> <button
Lock position key={c}
</label> className={`swatch-btn ${p.color === c ? 'active' : ''}`}
style={{ background: c }}
onClick={(e) => {
e.stopPropagation();
updateObject(p.id, { color: c });
}}
/>
))}
</div>
</div> </div>
<div className="section-title" style={{ marginTop: 12 }}> <div className="section-title" style={{ marginTop: 12 }}>
Conditions Conditions
</div> </div>
{conditionsEditor( <div className="cond-list">
f.conditions, {CONDITION_KINDS.map((k) => {
(k, enabled) => toggleFurnitureCondition(f.id, k, enabled), const active = p.conditions.some((c) => c.kind === k);
{ const cond = p.conditions.find((c) => c.kind === k);
value: const targetId = (cond?.params as { targetId?: string } | undefined)?.targetId;
(f.conditions.find((c) => c.kind === 'near_object')?.params as { targetId?: string } | undefined) const leniency = cond?.leniency ?? 0;
?.targetId || '', return (
onChange: (v) => setNearObjectTarget(f.id, v), <div className="cond" key={k}>
options: state.furniture.filter((o) => o.id !== f.id).map((o) => ({ id: o.id, name: o.name })), <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> </div>
)} )}
@@ -316,9 +397,19 @@ export function ControlPanel({
return ( return (
<aside className="sidebar"> <aside className="sidebar">
<div className="sidebar-header"> <div className="sidebar-header">
<div className="sidebar-title"> <div className="sidebar-top">
<span className="dot" /> <div className="sidebar-title">
Venue Planner <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>
<div className="segmented" style={{ marginTop: 12 }}> <div className="segmented" style={{ marginTop: 12 }}>
<button className={mode === 'layout' ? 'active' : ''} onClick={() => onModeChange('layout')}> <button className={mode === 'layout' ? 'active' : ''} onClick={() => onModeChange('layout')}>
@@ -338,101 +429,134 @@ export function ControlPanel({
<div className="grid-2"> <div className="grid-2">
<label className="field"> <label className="field">
<span>Width</span> <span>Width</span>
<NumberField <NumberField value={state.room.width} min={100} width="100%" onChange={(v) => setRoom({ width: v })} />
value={state.room.width}
min={100}
width="100%"
onChange={(v) => setRoom({ width: v })}
/>
</label> </label>
<label className="field"> <label className="field">
<span>Height</span> <span>Height</span>
<NumberField <NumberField value={state.room.height} min={100} width="100%" onChange={(v) => setRoom({ height: v })} />
value={state.room.height}
min={100}
width="100%"
onChange={(v) => setRoom({ height: v })}
/>
</label> </label>
</div> </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>
<div className="section"> <div className="section">
<div className="section-title">Scene</div> <div className="section-title">Scene</div>
<div className="row"> <button onClick={createObject} style={{ width: '100%' }}>
<button onClick={placeEntrance} style={{ flex: 1 }}> Create Object
Place Entrance </button>
</button>
<button onClick={createObject} style={{ flex: 1 }}>
Create Object
</button>
</div>
</div> </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">
<div className="section-title">Presets</div> <div className="section-title">Entrance</div>
<div className="obj-list">{(state.presets || []).map(renderPresetCard)}</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>
<div className="section"> <div className="section">
<div className="section-title">Objects</div> <div className="section-title">Objects</div>
{state.entrance && ( <div className="hint">
<div Count is the maximum number for each object. Drag a card onto the room to pin one in place (locked);
className={`obj-card ${selectedId === '__entrance__' ? 'selected' : ''}`} the optimizer arranges the rest.
style={{ marginBottom: 6 }} </div>
> <div className="obj-list">{objects.map(renderObjectCard)}</div>
<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> </div>
</> </>
)} )}
@@ -441,15 +565,21 @@ export function ControlPanel({
<div className="section" style={{ marginTop: 0 }}> <div className="section" style={{ marginTop: 0 }}>
<div className="section-title">Optimization</div> <div className="section-title">Optimization</div>
<p className="hint"> <p className="hint">
Placed objects are locked in position. Unplaced objects are randomized across the room for 15 The count is the maximum for each object. Optimizing adds only what's missing and randomizes those for
seconds; the best-scoring layout is kept. 15 seconds; everything already on the room stays locked in place.
</p> </p>
<button className="primary" onClick={onOptimize} disabled={optimizing} style={{ width: '100%' }}> <button className="primary" onClick={onOptimize} style={{ width: '100%' }}>
{optimizing ? `Optimizing… ${timeLeft}s` : 'Optimize Layout'} {optimizing ? 'Stop Optimization' : 'Optimize Layout'}
</button> </button>
<div className="score-row">
<span className="score-label">Score</span>
<span className="score-value">
{lastScore === null ? '—' : lastScore.toFixed(1)}
</span>
</div>
{optimizing && ( {optimizing && (
<div className="progress"> <div className="progress">
<div style={{ width: `${((15 - timeLeft) / 15) * 100}%` }} /> <div style={{ width: `$0%` }} />
</div> </div>
)} )}
</div> </div>
+271 -24
View File
@@ -1,5 +1,9 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { LayoutState, Point } from '../types'; 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 { interface RoomCanvasProps {
state: LayoutState; state: LayoutState;
@@ -7,6 +11,7 @@ interface RoomCanvasProps {
mode: 'layout' | 'optimize'; mode: 'layout' | 'optimize';
selectedId: string | null; selectedId: string | null;
onSelect: (id: string | null) => void; onSelect: (id: string | null) => void;
showPaths?: boolean;
} }
interface DragState { interface DragState {
@@ -14,16 +19,25 @@ interface DragState {
offsetX: number; offsetX: number;
offsetY: number; offsetY: number;
resizing?: boolean; 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 canvasRef = useRef<HTMLCanvasElement>(null);
const [drag, setDrag] = useState<DragState | null>(null); const [drag, setDrag] = useState<DragState | null>(null);
const [zoom] = useState(1); 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(() => { useEffect(() => {
draw(); draw();
}, [state, selectedId, mode, zoom]); }, [state, selectedId, mode, zoom, showPaths, phase]);
const draw = () => { const draw = () => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
@@ -35,7 +49,19 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }:
ctx.scale(zoom, zoom); ctx.scale(zoom, zoom);
ctx.fillStyle = '#12151b'; 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 // subtle grid
ctx.strokeStyle = 'rgba(255,255,255,0.035)'; 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.lineTo(state.room.width, y);
ctx.stroke(); ctx.stroke();
} }
ctx.restore();
ctx.strokeStyle = '#2c3342'; ctx.strokeStyle = '#2c3342';
ctx.lineWidth = 2; 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) { if (state.entrance) {
const e = state.entrance; const e = state.entrance;
@@ -78,14 +143,30 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }:
} }
for (const f of state.furniture) { 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 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.globalAlpha = f.lockedForOptimization && mode === 'optimize' ? 0.55 : 1;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(rot);
ctx.beginPath(); 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.fillStyle = f.color || '#9aa0a6';
ctx.fill(); ctx.fill();
@@ -101,26 +182,52 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }:
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.stroke(); ctx.stroke();
} }
ctx.restore();
ctx.fillStyle = 'rgba(0, 0, 0, 0.72)'; ctx.fillStyle = 'rgba(0, 0, 0, 0.72)';
ctx.font = '600 11px Inter, sans-serif'; ctx.font = '600 11px Inter, sans-serif';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
const label = b.w > 44 ? f.name.substring(0, 14) : ''; const label = f.shape.width > 44 ? f.name.substring(0, 14) : '';
if (label) ctx.fillText(label, b.x + 7, b.y + b.h / 2); if (label) ctx.fillText(label, f.shape.x + 7, cy);
if (f.lockedForOptimization) { if (f.lockedForOptimization) {
ctx.font = '10px Inter, sans-serif'; ctx.font = '10px Inter, sans-serif';
ctx.fillStyle = 'rgba(0, 0, 0, 0.55)'; 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; 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(); ctx.restore();
}; };
const getMousePos = (e: React.MouseEvent<HTMLCanvasElement>): Point => { const getMousePos = (e: { clientX: number; clientY: number }): Point => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return { x: 0, y: 0 }; if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
@@ -131,6 +238,16 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }:
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => { const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
const pos = getMousePos(e); 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) { if (state.entrance) {
const en = state.entrance; const en = state.entrance;
const w = en.width ?? 20; 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--) { for (let i = state.furniture.length - 1; i >= 0; i--) {
const f = state.furniture[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); onSelect(f.id);
setDrag({ id: f.id, offsetX: pos.x - f.shape.x, offsetY: pos.y - f.shape.y }); setDrag({ id: f.id, offsetX: pos.x - f.shape.x, offsetY: pos.y - f.shape.y });
return; return;
@@ -163,28 +295,75 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }:
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => { const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!drag) return; if (!drag) return;
const pos = getMousePos(e); 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) { if (drag.id === '__entrance__' && state.entrance) {
const en = state.entrance; const en = state.entrance;
const others = state.furniture.map(furnitureCollider);
if (drag.resizing) { if (drag.resizing) {
const newW = Math.max(10, pos.x - en.x); const b = resolveMove(
const newH = Math.max(10, pos.y - en.y); { x: en.x, y: en.y, width: pos.x - en.x, height: pos.y - en.y },
onStateChange({ ...state, entrance: { ...en, width: newW, height: newH } }); others,
state.room,
10,
);
onStateChange({ ...state, entrance: { ...en, ...b } });
} else { } else {
onStateChange({ const b = resolveMove(
...state, { x: pos.x - drag.offsetX, y: pos.y - drag.offsetY, width: en.width ?? 20, height: en.height ?? 20 },
entrance: { ...en, x: pos.x - drag.offsetX, y: pos.y - drag.offsetY }, others,
}); state.room,
10,
);
onStateChange({ ...state, entrance: { ...en, x: b.x, y: b.y } });
} }
return; return;
} }
const updated = state.furniture.map((f) => { const updated = state.furniture.map((f) => {
if (f.id !== drag.id) return 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 { return {
...f, ...f,
shape: { shape: {
...f.shape, ...f.shape,
x: pos.x - drag.offsetX, x: b.x,
y: pos.y - drag.offsetY, y: b.y,
}, },
}; };
}); });
@@ -192,22 +371,80 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }:
}; };
const handleMouseUp = () => { 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)); const updated = state.furniture.map((f) => (f.id === drag.id ? { ...f, placed: true } : f));
onStateChange({ ...state, furniture: updated }); onStateChange({ ...state, furniture: updated });
} }
setDrag(null); 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 ( return (
<canvas <canvas
ref={canvasRef} ref={canvasRef}
width={Math.max(state.room.width, 400)} 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, 300)} 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' }} style={{ maxWidth: '100%', height: 'auto' }}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp} onMouseUp={handleMouseUp}
onDragOver={handleDragOver}
onDrop={handleDrop}
onContextMenu={handleContextMenu}
/> />
); );
} }
@@ -231,3 +468,13 @@ function roundRect(
ctx.arcTo(x, y, x + w, y, r); ctx.arcTo(x, y, x + w, y, r);
ctx.closePath(); 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 { export class Furniture {
id: string; id: string;
name: string; name: string;
type?: string;
shape: Shape; shape: Shape;
conditions: ConditionSpec[]; conditions: ConditionSpec[];
color: string; color: string;
@@ -13,6 +14,7 @@ export class Furniture {
constructor(config: FurnitureConfig) { constructor(config: FurnitureConfig) {
this.id = config.id; this.id = config.id;
this.name = config.name; this.name = config.name;
this.type = config.type;
this.shape = new Shape(config.shape); this.shape = new Shape(config.shape);
this.conditions = config.conditions.map((c) => ({ ...c, params: { ...(c.params || {}) } })); this.conditions = config.conditions.map((c) => ({ ...c, params: { ...(c.params || {}) } }));
this.color = config.color ?? '#9aa0a6'; this.color = config.color ?? '#9aa0a6';
@@ -51,6 +53,7 @@ export class Furniture {
return { return {
id: this.id, id: this.id,
name: this.name, name: this.name,
type: this.type,
shape: { shape: {
type: this.shape.type, type: this.shape.type,
x: this.shape.x, x: this.shape.x,
+2
View File
@@ -6,6 +6,7 @@ export const DEFAULT_PRESETS: FurniturePreset[] = [
name: 'Table', name: 'Table',
color: '#a1887f', color: '#a1887f',
amount: 0, amount: 0,
isPreset: true,
shape: { type: 'rectangle', width: 80, height: 60 }, shape: { type: 'rectangle', width: 80, height: 60 },
conditions: [ conditions: [
{ kind: 'walk_to_and_sit', weight: 1 }, { kind: 'walk_to_and_sit', weight: 1 },
@@ -17,6 +18,7 @@ export const DEFAULT_PRESETS: FurniturePreset[] = [
name: 'Chair', name: 'Chair',
color: '#d7ccc5', color: '#d7ccc5',
amount: 0, amount: 0,
isPreset: true,
shape: { type: 'rectangle', width: 40, height: 40 }, shape: { type: 'rectangle', width: 40, height: 40 },
conditions: [ conditions: [
{ kind: 'walk_to_and_sit', weight: 1 }, { 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 type { Point, Rect, ShapeData } from '../types';
import { colliderCorners, containsWorldPoint } from './collision';
export class Shape { export class Shape {
type: ShapeData['type']; type: ShapeData['type'];
@@ -10,7 +11,7 @@ export class Shape {
points: Point[]; points: Point[];
constructor(data: ShapeData) { constructor(data: ShapeData) {
this.type = data.type; this.type = data.type === 'circle' ? 'oval' : data.type;
this.x = data.x; this.x = data.x;
this.y = data.y; this.y = data.y;
this.width = Math.max(1, data.width); 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 { getBounds(): Rect {
if (this.type === 'rectangle') { if (this.type === 'polygon' && this.points.length > 0) {
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) {
const xs = this.points.map((p) => p.x); const xs = this.points.map((p) => p.x);
const ys = this.points.map((p) => p.y); const ys = this.points.map((p) => p.y);
const minX = Math.min(...xs); const minX = Math.min(...xs);
@@ -48,22 +46,26 @@ export class Shape {
const maxY = Math.max(...ys); const maxY = Math.max(...ys);
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; 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 { 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) { if (this.type === 'polygon' && this.points.length > 0) {
let inside = false; let inside = false;
for (let i = 0, j = this.points.length - 1; i < this.points.length; j = i++) { 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 inside;
} }
return p.x >= b.x && p.x <= b.x + b.width && p.y >= b.y && p.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();
intersectsRect(r: Rect): boolean { const rad = (-this.rotation * Math.PI) / 180;
const b = this.getBounds(); const cos = Math.cos(rad);
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 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); 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 { .sidebar-body {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@@ -387,11 +425,29 @@ label {
text-align: center; text-align: center;
background: var(--bg-panel); background: var(--bg-panel);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 6px; border-radius: var(--radius-sm);
padding: 4px 6px; padding: 5px 6px;
font-weight: 600; 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 { .obj-count input:focus {
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft); box-shadow: 0 0 0 3px var(--accent-soft);
@@ -436,6 +492,32 @@ label {
width: 100%; 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 { .hint {
color: var(--text-mute); color: var(--text-mute);
font-size: 12px; font-size: 12px;
@@ -485,6 +567,31 @@ label {
transition: width 0.9s linear; 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 { .empty {
color: var(--text-mute); color: var(--text-mute);
font-size: 12px; font-size: 12px;
+131 -15
View File
@@ -1,6 +1,16 @@
import type { AccessibilityResult, FurnitureConfig, LayoutEvaluation, LayoutState, Point, Rect } from '../types'; import type {
import { buildGrid } from '../pathfinding/grid'; AccessibilityResult,
FurnitureConfig,
GridCell,
LayoutEvaluation,
LayoutState,
Point,
Rect,
} from '../types';
import { buildGrid, gridToWorld, nearestWalkable } from '../pathfinding/grid';
import { aStar } from '../pathfinding/aStar'; 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 { export function evaluateLayout(state: LayoutState, gridSize: number = 10): LayoutEvaluation {
const obstacles: Rect[] = state.furniture const obstacles: Rect[] = state.furniture
@@ -18,13 +28,29 @@ export function evaluateLayout(state: LayoutState, gridSize: number = 10): Layou
let totalConditionScore = 0; let totalConditionScore = 0;
const conditionScores: Record<string, number> = {}; 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 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) { for (const f of state.furniture) {
const cx = f.shape.x + f.shape.width / 2; const cx = f.shape.x + f.shape.width / 2;
const cy = f.shape.y + f.shape.height / 2; const cy = f.shape.y + f.shape.height / 2;
const target: Point = { x: cx, y: cy }; const targetCell = nearestWalkable(grid, { x: cx, y: cy }, gridSize);
const path = aStar(entrancePoint, target, grid, 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) { if (path === null) {
accessibility.push({ furnitureId: f.id, distanceFromEntrance: null, reachable: false, pathLength: null }); accessibility.push({ furnitureId: f.id, distanceFromEntrance: null, reachable: false, pathLength: null });
penalty += 100; penalty += 100;
@@ -40,24 +66,90 @@ export function evaluateLayout(state: LayoutState, gridSize: number = 10): Layou
let score = 0; let score = 0;
for (const c of f.conditions) { 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); score += (s * (c.weight || 1)) / (f.conditions.length || 1);
} }
conditionScores[f.id] = score; conditionScores[f.id] = score;
totalConditionScore += 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 }; 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( function scoreCondition(
kind: string, kind: string,
c: any, c: any,
f: FurnitureConfig, f: FurnitureConfig,
state: LayoutState, state: LayoutState,
acc: AccessibilityResult | null, acc: AccessibilityResult | null,
path: Point[] | null, grid: GridCell[][],
gridSize: number,
): number { ): number {
switch (kind) { switch (kind) {
case 'walk_to_and_sit': { case 'walk_to_and_sit': {
@@ -88,20 +180,44 @@ function scoreCondition(
} }
case 'near_object': { case 'near_object': {
const tid = (c.params as any)?.targetId; const tid = (c.params as any)?.targetId;
if (!tid) return 0.3; if (!tid) return 0.2;
const t = state.furniture.find((x) => x.id === tid); const targets = state.furniture.filter((x) => (x.id === tid || x.type === tid) && x.id !== f.id);
if (!t) return 0.3; if (targets.length === 0) return 0.2;
const cx = f.shape.x + f.shape.width / 2; const cx = f.shape.x + f.shape.width / 2;
const cy = f.shape.y + f.shape.height / 2; const cy = f.shape.y + f.shape.height / 2;
const tx = t.shape.x + t.shape.width / 2; let best = 0;
const ty = t.shape.y + t.shape.height / 2; for (const t of targets) {
const d = Math.sqrt((cx - tx) ** 2 + (cy - ty) ** 2); const tx = t.shape.x + t.shape.width / 2;
return Math.max(0, 1 - d / 80); 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': { 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: default:
return 0.5; 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 type { LayoutEvaluation, LayoutState } from '../types';
import { evaluateLayout } from '../layout/evaluator'; import { evaluateLayout } from '../layout/evaluator';
import { Furniture } from '../furniture/Furniture'; 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 { export interface RandomSearchOptions {
iterations: number; iterations: number;
@@ -18,14 +21,44 @@ export function runRandomSearch(
for (let i = 0; i < options.iterations; i++) { for (let i = 0; i < options.iterations; i++) {
const candidate = cloneState(bestState); const candidate = cloneState(bestState);
const room = { width: options.roomWidth, height: options.roomHeight };
const entrance = entranceCollider(candidate);
for (const f of candidate.furniture) { for (const f of candidate.furniture) {
if (f.lockedForOptimization) continue; if (f.lockedForOptimization) continue;
f.shape.x = Math.random() * (options.roomWidth - f.shape.width); const others: Collider[] = candidate.furniture
f.shape.y = Math.random() * (options.roomHeight - f.shape.height); .filter((o) => o.id !== f.id)
if (f.shape.x < 0) f.shape.x = 0; .map(furnitureCollider);
if (f.shape.y < 0) f.shape.y = 0; if (entrance) others.push(entrance);
if (f.shape.x + f.shape.width > options.roomWidth) f.shape.x = options.roomWidth - f.shape.width; const hasNear = f.conditions.some((cc) => cc.kind === 'near_object' && (cc.params as any)?.targetId);
if (f.shape.y + f.shape.height > options.roomHeight) f.shape.y = options.roomHeight - f.shape.height; 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); const evalRes = evaluateLayout(candidate, options.gridSize || 10);
if (evalRes.totalScore > bestEval.totalScore + 1e-9) { if (evalRes.totalScore > bestEval.totalScore + 1e-9) {
@@ -41,5 +74,6 @@ function cloneState(state: LayoutState): LayoutState {
room: { ...state.room }, room: { ...state.room },
entrance: state.entrance ? { ...state.entrance } : null, entrance: state.entrance ? { ...state.entrance } : null,
furniture: state.furniture.map((f) => new Furniture(f).toConfig()), 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 type { GridCell, Point, Rect, RoomConfig } from '../types';
import { pointInPolygon } from '../geometry/polygon';
export function buildGrid(room: RoomConfig, obstacles: Rect[], gridSize: number = 10): GridCell[][] { export function buildGrid(room: RoomConfig, obstacles: Rect[], gridSize: number = 10): GridCell[][] {
const cols = Math.ceil(room.width / gridSize); const cols = Math.ceil(room.width / gridSize);
const rows = Math.ceil(room.height / gridSize); const rows = Math.ceil(room.height / gridSize);
const poly = room.points && room.points.length >= 3 ? room.points : null;
const grid: GridCell[][] = []; const grid: GridCell[][] = [];
for (let r = 0; r < rows; r++) { for (let r = 0; r < rows; r++) {
grid[r] = []; grid[r] = [];
@@ -10,10 +12,14 @@ export function buildGrid(room: RoomConfig, obstacles: Rect[], gridSize: number
const x = c * gridSize + gridSize / 2; const x = c * gridSize + gridSize / 2;
const y = r * gridSize + gridSize / 2; const y = r * gridSize + gridSize / 2;
let walkable = true; let walkable = true;
for (const obs of obstacles) { if (poly && !pointInPolygon({ x, y }, poly)) {
if (pointInRect(x, y, obs)) { walkable = false;
walkable = false; } else {
break; for (const obs of obstacles) {
if (pointInRect(x, y, obs)) {
walkable = false;
break;
}
} }
} }
grid[r][c] = { x, y, walkable }; 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 { export function gridToWorld(col: number, row: number, gridSize: number): Point {
return { x: col * gridSize + gridSize / 2, y: row * gridSize + gridSize / 2 }; 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 { export interface Point {
x: number; x: number;
@@ -19,6 +19,7 @@ export interface ShapeData {
width: number; width: number;
height: number; height: number;
rotation?: number; rotation?: number;
scale?: number;
points?: Point[]; points?: Point[];
} }
@@ -29,11 +30,13 @@ export type ConditionKind =
| 'near_object' | 'near_object'
| 'not_blocking_path' | 'not_blocking_path'
| 'accessible' | 'accessible'
| 'open_circulation'; | 'open_circulation'
| 'line_of_sight';
export interface ConditionSpec { export interface ConditionSpec {
kind: ConditionKind; kind: ConditionKind;
weight: number; weight: number;
leniency?: number;
params?: Record<string, unknown>; params?: Record<string, unknown>;
} }
@@ -60,6 +63,7 @@ export interface RoomConfig {
width: number; width: number;
height: number; height: number;
gridSize?: number; gridSize?: number;
points?: Point[];
} }
export interface GridCell { export interface GridCell {
@@ -96,6 +100,7 @@ export interface FurniturePreset {
conditions: ConditionSpec[]; conditions: ConditionSpec[];
color?: string; color?: string;
amount?: number; amount?: number;
isPreset?: boolean;
} }
export interface LayoutState { export interface LayoutState {