readme
This commit is contained in:
Vendored
+2
-2
@@ -5,8 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>furniture-app</title>
|
||||
<script type="module" crossorigin src="/assets/index-DE7tP3Fy.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-9k24Ci9h.css">
|
||||
<script type="module" crossorigin src="/assets/index-BkulQhND.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-67F-Ik0K.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+16
-22
@@ -3,7 +3,6 @@ import { RoomCanvas } from './components/RoomCanvas';
|
||||
import { ControlPanel } from './components/ControlPanel';
|
||||
import type { LayoutState } from './types';
|
||||
import { runRandomSearch } from './optimization/randomSearch';
|
||||
import { optimizeWithNN } from './ai/nn';
|
||||
import { prepareForOptimize } from './optimization/prepare';
|
||||
import { DEFAULT_PRESETS } from './furniture/presets';
|
||||
|
||||
@@ -19,7 +18,6 @@ function App() {
|
||||
const [mode, setMode] = useState<'layout' | 'optimize'>('layout');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [optimizing, setOptimizing] = useState(false);
|
||||
const [timeLeft, setTimeLeft] = useState(15);
|
||||
const [lastScore, setLastScore] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -37,31 +35,28 @@ function App() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!optimizing) return;
|
||||
if (timeLeft <= 0) {
|
||||
const res = optimizeWithNN(state, { gridSize: 10 });
|
||||
setState(res.bestState);
|
||||
setLastScore(res.bestEval.totalScore);
|
||||
setOptimizing(false);
|
||||
setTimeLeft(15);
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(() => setTimeLeft((v) => v - 1), 1000);
|
||||
const { bestState, bestEval } = runRandomSearch(state, {
|
||||
iterations: 5,
|
||||
roomWidth: state.room.width,
|
||||
roomHeight: state.room.height,
|
||||
gridSize: state.room.gridSize,
|
||||
});
|
||||
setState(bestState);
|
||||
setLastScore(bestEval.totalScore);
|
||||
const tick = () => {
|
||||
const { bestState, bestEval } = runRandomSearch(state, {
|
||||
iterations: 40,
|
||||
roomWidth: state.room.width,
|
||||
roomHeight: state.room.height,
|
||||
gridSize: state.room.gridSize,
|
||||
});
|
||||
setState(bestState);
|
||||
setLastScore(bestEval.totalScore);
|
||||
};
|
||||
const t = setTimeout(tick, 30);
|
||||
return () => clearTimeout(t);
|
||||
}, [optimizing, timeLeft]);
|
||||
}, [optimizing, state, state.room.gridSize, state.room.width, state.room.height]);
|
||||
|
||||
const handleOptimize = () => {
|
||||
if (optimizing) {
|
||||
setOptimizing(false);
|
||||
return;
|
||||
}
|
||||
setState((s) => prepareForOptimize(s));
|
||||
setLastScore(null);
|
||||
setOptimizing(true);
|
||||
setTimeLeft(15);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -75,7 +70,6 @@ function App() {
|
||||
onSelect={setSelectedId}
|
||||
onOptimize={handleOptimize}
|
||||
optimizing={optimizing}
|
||||
timeLeft={timeLeft}
|
||||
lastScore={lastScore}
|
||||
/>
|
||||
<div className="stage">
|
||||
|
||||
+207
-27
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import type { ConditionKind, Entrance, FurniturePreset, LayoutState, RoomConfig } from '../types';
|
||||
import { NumberField } from './NumberField';
|
||||
import { clampStateToRoom } from '../geometry/bounds';
|
||||
import { rectanglePoints } from '../geometry/polygon';
|
||||
|
||||
interface ControlPanelProps {
|
||||
state: LayoutState;
|
||||
@@ -12,7 +13,6 @@ interface ControlPanelProps {
|
||||
onSelect: (id: string | null) => void;
|
||||
onOptimize: () => void;
|
||||
optimizing: boolean;
|
||||
timeLeft: number;
|
||||
lastScore: number | null;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ const CONDITION_KINDS: ConditionKind[] = [
|
||||
'accessible',
|
||||
'not_blocking_path',
|
||||
'open_circulation',
|
||||
'line_of_sight',
|
||||
];
|
||||
|
||||
const CONDITION_LABELS: Record<ConditionKind, string> = {
|
||||
@@ -34,6 +35,7 @@ const CONDITION_LABELS: Record<ConditionKind, string> = {
|
||||
accessible: 'Accessible',
|
||||
not_blocking_path: 'Not blocking path',
|
||||
open_circulation: 'Open circulation',
|
||||
line_of_sight: 'Clear line of sight',
|
||||
};
|
||||
|
||||
const SWATCHES = ['#a1887f', '#d7ccc5', '#90caf9', '#a5d6a7', '#ef9a9a', '#ce93d8', '#ffcc80'];
|
||||
@@ -47,7 +49,6 @@ export function ControlPanel({
|
||||
onSelect,
|
||||
onOptimize,
|
||||
optimizing,
|
||||
timeLeft,
|
||||
lastScore,
|
||||
}: ControlPanelProps) {
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
@@ -57,12 +58,52 @@ export function ControlPanel({
|
||||
? state.furniture.find((f) => f.id === selectedId)?.type ?? (selectedId === '__entrance__' ? '__entrance__' : null)
|
||||
: null;
|
||||
|
||||
const selectedFurniture = selectedId ? state.furniture.find((f) => f.id === selectedId) ?? null : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTypeId) setOpenId(selectedTypeId);
|
||||
}, [selectedTypeId]);
|
||||
|
||||
const setRoom = (room: Partial<RoomConfig>) =>
|
||||
onStateChange(clampStateToRoom({ ...state, room: { ...state.room, ...room } }));
|
||||
const setRoom = (room: Partial<RoomConfig>) => {
|
||||
const next: RoomConfig = { ...state.room, ...room };
|
||||
if (next.points && next.points.length >= 3 && state.room.points && state.room.points.length>=3 && (Math.abs(next.width-state.room.width)>1e-12||Math.abs(next.height-state.room.height)>1e-12)) {
|
||||
const sx = next.width / (state.room.width || 1);
|
||||
const sy = next.height / (state.room.height || 1);
|
||||
next.points = next.points.map((pt) => ({ x: pt.x * sx, y: pt.y * sy }));
|
||||
}
|
||||
onStateChange(clampStateToRoom({ ...state, room: next }));
|
||||
};
|
||||
|
||||
const setRoomShape = (shape: 'rectangle' | 'polygon') => {
|
||||
if (shape === 'polygon') {
|
||||
const points = state.room.points ?? rectanglePoints(state.room.width, state.room.height);
|
||||
onStateChange(clampStateToRoom({ ...state, room: { ...state.room, points } }));
|
||||
} else {
|
||||
const { width, height, gridSize } = state.room;
|
||||
onStateChange(clampStateToRoom({ ...state, room: { width, height, gridSize } }));
|
||||
}
|
||||
};
|
||||
|
||||
const addRoomVertex = () => {
|
||||
const pts = state.room.points;
|
||||
if (!pts || pts.length < 3) return;
|
||||
let best = -1;
|
||||
let idx = 0;
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const a = pts[i];
|
||||
const b = pts[(i + 1) % pts.length];
|
||||
const len = (b.x - a.x) ** 2 + (b.y - a.y) ** 2;
|
||||
if (len > best) {
|
||||
best = len;
|
||||
idx = i;
|
||||
}
|
||||
}
|
||||
const a = pts[idx];
|
||||
const b = pts[(idx + 1) % pts.length];
|
||||
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||||
const next = [...pts.slice(0, idx + 1), mid, ...pts.slice(idx + 1)];
|
||||
onStateChange(clampStateToRoom({ ...state, room: { ...state.room, points: next } }));
|
||||
};
|
||||
|
||||
const setEntrance = (entrance: Partial<Entrance>) => {
|
||||
if (!state.entrance) return;
|
||||
@@ -72,13 +113,53 @@ export function ControlPanel({
|
||||
const placeEntrance = () =>
|
||||
onStateChange(clampStateToRoom({ ...state, entrance: { x: 20, y: 20, width: 20, height: 20 } }));
|
||||
|
||||
const commitPresets = (presets: FurniturePreset[]) => {
|
||||
const byId = new Map(presets.map((p) => [p.id, p]));
|
||||
const furniture = state.furniture.map((f) => {
|
||||
const p = f.type ? byId.get(f.type) : undefined;
|
||||
if (!p) return f;
|
||||
return {
|
||||
...f,
|
||||
name: p.name,
|
||||
color: p.color,
|
||||
shape: {
|
||||
...f.shape,
|
||||
type: p.shape.type,
|
||||
width: p.shape.width,
|
||||
height: p.shape.height,
|
||||
scale: p.shape.scale ?? 1,
|
||||
},
|
||||
conditions: p.conditions.map((c) => ({ ...c, params: { ...(c.params || {}) } })),
|
||||
};
|
||||
});
|
||||
onStateChange(clampStateToRoom({ ...state, presets, furniture }));
|
||||
};
|
||||
|
||||
const updateObject = (id: string, patch: Partial<FurniturePreset>) => {
|
||||
const presets = objects.map((p) => (p.id === id ? { ...p, ...patch } : p));
|
||||
onStateChange({ ...state, presets });
|
||||
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,
|
||||
@@ -112,14 +193,14 @@ export function ControlPanel({
|
||||
const has = p.conditions.some((c) => c.kind === k);
|
||||
if (enabled && !has) {
|
||||
const params = k === 'near_object' ? { targetId: '' } : {};
|
||||
return { ...p, conditions: [...p.conditions, { kind: k, weight: 1, params }] };
|
||||
return { ...p, conditions: [...p.conditions, { kind: k, weight: 1, leniency: 0.5, params }] };
|
||||
}
|
||||
if (!enabled && has) {
|
||||
return { ...p, conditions: p.conditions.filter((c) => c.kind !== k) };
|
||||
}
|
||||
return p;
|
||||
});
|
||||
onStateChange({ ...state, presets });
|
||||
commitPresets(presets);
|
||||
};
|
||||
|
||||
const setNearObjectTarget = (id: string, targetId: string) => {
|
||||
@@ -132,7 +213,18 @@ export function ControlPanel({
|
||||
),
|
||||
};
|
||||
});
|
||||
onStateChange({ ...state, presets });
|
||||
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) => {
|
||||
@@ -172,7 +264,7 @@ export function ControlPanel({
|
||||
)}
|
||||
<div className="grid-2">
|
||||
<label className="field">
|
||||
<span>Width</span>
|
||||
<span>Width (cm)</span>
|
||||
<NumberField
|
||||
value={p.shape.width}
|
||||
min={10}
|
||||
@@ -181,7 +273,7 @@ export function ControlPanel({
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Height</span>
|
||||
<span>Height (cm)</span>
|
||||
<NumberField
|
||||
value={p.shape.height}
|
||||
min={10}
|
||||
@@ -202,11 +294,13 @@ export function ControlPanel({
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Rotation</span>
|
||||
<span>Scale</span>
|
||||
<NumberField
|
||||
value={p.shape.rotation ?? 0}
|
||||
value={p.shape.scale ?? 1}
|
||||
min={0.1}
|
||||
step={0.05}
|
||||
width="100%"
|
||||
onChange={(v) => updateObject(p.id, { shape: { ...p.shape, rotation: v } })}
|
||||
onChange={(v) => updateObject(p.id, { shape: { ...p.shape, scale: v } })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -234,8 +328,9 @@ export function ControlPanel({
|
||||
<div className="cond-list">
|
||||
{CONDITION_KINDS.map((k) => {
|
||||
const active = p.conditions.some((c) => c.kind === k);
|
||||
const targetId = (p.conditions.find((c) => c.kind === k)?.params as { targetId?: string } | undefined)
|
||||
?.targetId;
|
||||
const cond = p.conditions.find((c) => c.kind === k);
|
||||
const targetId = (cond?.params as { targetId?: string } | undefined)?.targetId;
|
||||
const leniency = cond?.leniency ?? 0;
|
||||
return (
|
||||
<div className="cond" key={k}>
|
||||
<label>
|
||||
@@ -246,6 +341,22 @@ export function ControlPanel({
|
||||
/>
|
||||
{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 || ''}
|
||||
@@ -286,9 +397,19 @@ export function ControlPanel({
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<div className="sidebar-title">
|
||||
<span className="dot" />
|
||||
Venue Planner
|
||||
<div className="sidebar-top">
|
||||
<div className="sidebar-title">
|
||||
<span className="dot" />
|
||||
Venue Planner
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button className="mini" onClick={unlockAll} title="Unlock every object so the optimizer can move it">
|
||||
Unlock All
|
||||
</button>
|
||||
<button className="mini danger" onClick={removeAllFurniture} title="Remove every object from the room">
|
||||
Clear Room
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="segmented" style={{ marginTop: 12 }}>
|
||||
<button className={mode === 'layout' ? 'active' : ''} onClick={() => onModeChange('layout')}>
|
||||
@@ -315,6 +436,28 @@ export function ControlPanel({
|
||||
<NumberField value={state.room.height} min={100} width="100%" onChange={(v) => setRoom({ height: v })} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="field" style={{ marginTop: 10 }}>
|
||||
<span>Shape</span>
|
||||
<select
|
||||
value={state.room.points ? 'polygon' : 'rectangle'}
|
||||
onChange={(e) => setRoomShape(e.target.value as 'rectangle' | 'polygon')}
|
||||
>
|
||||
<option value="rectangle">Rectangle</option>
|
||||
<option value="polygon">Polygon</option>
|
||||
</select>
|
||||
</div>
|
||||
{state.room.points && (
|
||||
<>
|
||||
<div className="hint" style={{ margin: '8px 0' }}>
|
||||
Drag the blue corners on the canvas to reshape the room.
|
||||
</div>
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
<button className="mini" onClick={addRoomVertex}>
|
||||
Add Vertex
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
@@ -324,17 +467,54 @@ export function ControlPanel({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedFurniture && (
|
||||
<div className="section">
|
||||
<div className="section-title">Selected Object</div>
|
||||
<div className="hint" style={{ marginBottom: 6 }}>
|
||||
{selectedFurniture.name}
|
||||
{selectedFurniture.lockedForOptimization ? ' · locked' : ''}
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>Rotation</span>
|
||||
<NumberField
|
||||
value={Math.round((selectedFurniture.shape.rotation ?? 0) + 360) % 360}
|
||||
width={64}
|
||||
onChange={(v) => setFurnitureRotation(selectedFurniture.id, v)}
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={360}
|
||||
value={Math.round((selectedFurniture.shape.rotation ?? 0) + 360) % 360}
|
||||
onChange={(e) => setFurnitureRotation(selectedFurniture.id, Number(e.target.value))}
|
||||
style={{ width: '100%', marginTop: 8, accentColor: '#4f8cff' }}
|
||||
/>
|
||||
<div className="row" style={{ marginTop: 8, gap: 6 }}>
|
||||
<button className="mini" onClick={() => setFurnitureRotation(selectedFurniture.id, ((selectedFurniture.shape.rotation ?? 0) - 15 + 360) % 360)}>
|
||||
-15°
|
||||
</button>
|
||||
<button className="mini" onClick={() => setFurnitureRotation(selectedFurniture.id, ((selectedFurniture.shape.rotation ?? 0) + 15) % 360)}>
|
||||
+15°
|
||||
</button>
|
||||
<button className="mini" onClick={() => setFurnitureRotation(selectedFurniture.id, 0)}>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="section">
|
||||
<div className="section-title">Entrance</div>
|
||||
{state.entrance ? (
|
||||
<>
|
||||
<div className="grid-2">
|
||||
<label className="field">
|
||||
<span>X</span>
|
||||
<span>X (cm)</span>
|
||||
<NumberField value={state.entrance.x} width="100%" onChange={(v) => setEntrance({ x: v })} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Y</span>
|
||||
<span>Y (cm)</span>
|
||||
<NumberField value={state.entrance.y} width="100%" onChange={(v) => setEntrance({ y: v })} />
|
||||
</label>
|
||||
<label className="field">
|
||||
@@ -373,8 +553,8 @@ export function ControlPanel({
|
||||
<div className="section">
|
||||
<div className="section-title">Objects</div>
|
||||
<div className="hint">
|
||||
Count tells the optimizer how many are needed. Drag a card onto the room to pin one in place
|
||||
(locked); the optimizer arranges the rest.
|
||||
Count is the maximum number for each object. Drag a card onto the room to pin one in place (locked);
|
||||
the optimizer arranges the rest.
|
||||
</div>
|
||||
<div className="obj-list">{objects.map(renderObjectCard)}</div>
|
||||
</div>
|
||||
@@ -385,11 +565,11 @@ export function ControlPanel({
|
||||
<div className="section" style={{ marginTop: 0 }}>
|
||||
<div className="section-title">Optimization</div>
|
||||
<p className="hint">
|
||||
Placed objects are locked in position. Unplaced objects are randomized across the room for 15
|
||||
seconds; the best-scoring layout is kept.
|
||||
The count is the maximum for each object. Optimizing adds only what's missing and randomizes those for
|
||||
15 seconds; everything already on the room stays locked in place.
|
||||
</p>
|
||||
<button className="primary" onClick={onOptimize} disabled={optimizing} style={{ width: '100%' }}>
|
||||
{optimizing ? `Optimizing… ${timeLeft}s` : 'Optimize Layout'}
|
||||
<button className="primary" onClick={onOptimize} style={{ width: '100%' }}>
|
||||
{optimizing ? 'Stop Optimization' : 'Optimize Layout'}
|
||||
</button>
|
||||
<div className="score-row">
|
||||
<span className="score-label">Score</span>
|
||||
@@ -399,7 +579,7 @@ export function ControlPanel({
|
||||
</div>
|
||||
{optimizing && (
|
||||
<div className="progress">
|
||||
<div style={{ width: `${((15 - timeLeft) / 15) * 100}%` }} />
|
||||
<div style={{ width: `$0%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { LayoutState, Point } from '../types';
|
||||
import { computePaths } from '../pathfinding/paths';
|
||||
import { furnitureCollider, resolveMove } from '../geometry/bounds';
|
||||
import { clampStateToRoom, furnitureCollider, resolveMove } from '../geometry/bounds';
|
||||
import { makePlaced } from '../optimization/prepare';
|
||||
import { Shape } from '../geometry/shapes';
|
||||
|
||||
@@ -19,6 +19,8 @@ interface DragState {
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
resizing?: boolean;
|
||||
vertexIndex?: number;
|
||||
rotating?: boolean;
|
||||
}
|
||||
|
||||
export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, showPaths }: RoomCanvasProps) {
|
||||
@@ -47,7 +49,19 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, s
|
||||
ctx.scale(zoom, zoom);
|
||||
|
||||
ctx.fillStyle = '#12151b';
|
||||
ctx.fillRect(0, 0, state.room.width, state.room.height);
|
||||
const poly = state.room.points && state.room.points.length >= 3 ? state.room.points : null;
|
||||
const roomPath = new Path2D();
|
||||
if (poly) {
|
||||
roomPath.moveTo(poly[0].x, poly[0].y);
|
||||
for (let i = 1; i < poly.length; i++) roomPath.lineTo(poly[i].x, poly[i].y);
|
||||
roomPath.closePath();
|
||||
} else {
|
||||
roomPath.rect(0, 0, state.room.width, state.room.height);
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.fill(roomPath);
|
||||
ctx.clip(roomPath);
|
||||
|
||||
// subtle grid
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.035)';
|
||||
@@ -65,10 +79,23 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, s
|
||||
ctx.lineTo(state.room.width, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
ctx.strokeStyle = '#2c3342';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(0, 0, state.room.width, state.room.height);
|
||||
ctx.stroke(roomPath);
|
||||
|
||||
if (poly && mode === 'layout') {
|
||||
ctx.fillStyle = '#4f8cff';
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
|
||||
ctx.lineWidth = 1.5;
|
||||
for (const p of poly) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, 6, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
if (showPaths && state.entrance) {
|
||||
const paths = computePaths(state, state.room.gridSize || 10);
|
||||
@@ -172,6 +199,31 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, s
|
||||
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();
|
||||
};
|
||||
|
||||
@@ -186,6 +238,16 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, s
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const pos = getMousePos(e);
|
||||
if (state.room.points && state.room.points.length >= 3 && mode === 'layout') {
|
||||
for (let i = 0; i < state.room.points.length; i++) {
|
||||
const v = state.room.points[i];
|
||||
if (Math.abs(pos.x - v.x) <= 8 && Math.abs(pos.y - v.y) <= 8) {
|
||||
onSelect('__room__');
|
||||
setDrag({ id: '__vertex__', offsetX: 0, offsetY: 0, vertexIndex: i });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.entrance) {
|
||||
const en = state.entrance;
|
||||
const w = en.width ?? 20;
|
||||
@@ -203,6 +265,20 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, s
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (let i = state.furniture.length - 1; i >= 0; i--) {
|
||||
const f = state.furniture[i];
|
||||
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);
|
||||
@@ -219,6 +295,29 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, s
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!drag) return;
|
||||
const pos = getMousePos(e);
|
||||
if (drag.id === '__vertex__' && state.room.points && drag.vertexIndex !== undefined) {
|
||||
const idx = drag.vertexIndex;
|
||||
const vx = Math.min(Math.max(0, pos.x), state.room.width);
|
||||
const vy = Math.min(Math.max(0, pos.y), state.room.height);
|
||||
const points = state.room.points.map((p, i) => (i === idx ? { x: vx, y: vy } : p));
|
||||
onStateChange(clampStateToRoom({ ...state, room: { ...state.room, points } }));
|
||||
return;
|
||||
}
|
||||
if (drag.rotating) {
|
||||
const target = state.furniture.find((f) => f.id === drag.id);
|
||||
if (!target) return;
|
||||
const cx = target.shape.x + target.shape.width / 2;
|
||||
const cy = target.shape.y + target.shape.height / 2;
|
||||
const handleAngle = Math.atan2(pos.x - cx, -(pos.y - cy));
|
||||
let deg = (handleAngle * 180) / Math.PI;
|
||||
deg = ((deg % 360) + 360) % 360;
|
||||
if (e.shiftKey) deg = Math.round(deg / 15) * 15;
|
||||
const updated = state.furniture.map((f) =>
|
||||
f.id === drag.id ? { ...f, shape: { ...f.shape, rotation: deg } } : f,
|
||||
);
|
||||
onStateChange({ ...state, furniture: updated });
|
||||
return;
|
||||
}
|
||||
if (drag.id === '__entrance__' && state.entrance) {
|
||||
const en = state.entrance;
|
||||
const others = state.furniture.map(furnitureCollider);
|
||||
@@ -272,13 +371,38 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, s
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (drag && drag.id !== '__entrance__') {
|
||||
if (drag && drag.id !== '__entrance__' && drag.id !== '__vertex__') {
|
||||
const updated = state.furniture.map((f) => (f.id === drag.id ? { ...f, placed: true } : f));
|
||||
onStateChange({ ...state, furniture: updated });
|
||||
}
|
||||
setDrag(null);
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!state.room.points || state.room.points.length < 3) return;
|
||||
const pos = getMousePos(e);
|
||||
let bestIdx = -1;
|
||||
let bestD = Infinity;
|
||||
for (let i = 0; i < state.room.points.length; i++) {
|
||||
const a = state.room.points[i];
|
||||
const b = state.room.points[(i + 1) % state.room.points.length];
|
||||
const q = closestOnSegment(a, b, pos);
|
||||
const d = (q.x - pos.x) ** 2 + (q.y - pos.y) ** 2;
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
if (bestIdx >= 0 && bestD < 1600) {
|
||||
e.preventDefault();
|
||||
const a = state.room.points[bestIdx];
|
||||
const b = state.room.points[(bestIdx + 1) % state.room.points.length];
|
||||
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||||
const next = [...state.room.points.slice(0, bestIdx + 1), mid, ...state.room.points.slice(bestIdx + 1)];
|
||||
onStateChange(clampStateToRoom({ ...state, room: { ...state.room, points: next } }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
@@ -290,6 +414,8 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, s
|
||||
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) {
|
||||
@@ -310,14 +436,15 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, s
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={Math.max(state.room.width, 400)}
|
||||
height={Math.max(state.room.height, 300)}
|
||||
width={Math.max(state.room.width, Math.max(...((state.room.points && state.room.points.length)?state.room.points.map(v=>v.x):[0]),400))}
|
||||
height={Math.max(state.room.height, Math.max(...((state.room.points && state.room.points.length)?state.room.points.map(v=>v.y):[0]),300))}
|
||||
style={{ maxWidth: '100%', height: 'auto' }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onContextMenu={handleContextMenu}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -341,3 +468,13 @@ function roundRect(
|
||||
ctx.arcTo(x, y, x + w, y, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
function closestOnSegment(a: Point, b: Point, p: Point): Point {
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
if (len2 === 0) return { ...a };
|
||||
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
return { x: a.x + t * dx, y: a.y + t * dy };
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Shape } from '../geometry/shapes';
|
||||
export class Furniture {
|
||||
id: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
shape: Shape;
|
||||
conditions: ConditionSpec[];
|
||||
color: string;
|
||||
@@ -13,6 +14,7 @@ export class Furniture {
|
||||
constructor(config: FurnitureConfig) {
|
||||
this.id = config.id;
|
||||
this.name = config.name;
|
||||
this.type = config.type;
|
||||
this.shape = new Shape(config.shape);
|
||||
this.conditions = config.conditions.map((c) => ({ ...c, params: { ...(c.params || {}) } }));
|
||||
this.color = config.color ?? '#9aa0a6';
|
||||
@@ -51,6 +53,7 @@ export class Furniture {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
type: this.type,
|
||||
shape: {
|
||||
type: this.shape.type,
|
||||
x: this.shape.x,
|
||||
|
||||
+42
-2
@@ -1,6 +1,7 @@
|
||||
import type { LayoutState, RoomConfig } from '../types';
|
||||
import { colliderCenter, satMTV, type Collider } from './collision';
|
||||
import { overlapsAny } from './collision';
|
||||
import { nearestPointInPolygon, pointInPolygon } from './polygon';
|
||||
|
||||
export { overlapsAny };
|
||||
|
||||
@@ -14,8 +15,26 @@ export interface Bounds {
|
||||
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);
|
||||
const x = Math.min(Math.max(0, rect.x), Math.max(0, room.width - width));
|
||||
const y = Math.min(Math.max(0, rect.y), Math.max(0, room.height - height));
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -94,3 +113,24 @@ export function entranceCollider(state: LayoutState): Collider | null {
|
||||
export function furnitureCollider(f: { shape: { x: number; y: number; width: number; height: number; rotation?: number } }): Collider {
|
||||
return { x: f.shape.x, y: f.shape.y, width: f.shape.width, height: f.shape.height, rotation: f.shape.rotation };
|
||||
}
|
||||
|
||||
function shapeFullyInside(c: Collider, poly: any[]): boolean {
|
||||
const cx = c.x + (c.width || 0) / 2;
|
||||
const cy = c.y + (c.height || 0) / 2;
|
||||
const w = c.width || 0;
|
||||
const h = c.height || 0;
|
||||
const rot = ((c.rotation ?? 0) * Math.PI) / 180;
|
||||
const cos = Math.cos(rot);
|
||||
const sin = Math.sin(rot);
|
||||
const samples = 20;
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const a = (Math.PI * 2 * i) / samples;
|
||||
const r = Math.sqrt((w / 2) ** 2 + (h / 2) ** 2);
|
||||
const lx = r * Math.cos(a);
|
||||
const ly = r * Math.sin(a);
|
||||
const wx = cx + lx * cos - ly * sin;
|
||||
const wy = cy + lx * sin + ly * cos;
|
||||
if (!pointInPolygon({ x: wx, y: wy }, poly)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Point } from '../types';
|
||||
|
||||
export function pointInPolygon(p: Point, poly: Point[]): boolean {
|
||||
if (poly.length < 3) return true;
|
||||
let inside = false;
|
||||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||
const a = poly[i];
|
||||
const b = poly[j];
|
||||
if (onSegment(a, b, p)) return true;
|
||||
const intersect = a.y > p.y !== b.y > p.y && p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y) + a.x;
|
||||
if (intersect) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
function onSegment(a: Point, b: Point, p: Point): boolean {
|
||||
const cross = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
|
||||
if (Math.abs(cross) > 1e-9) return false;
|
||||
const dot = (p.x - a.x) * (b.x - a.x) + (p.y - a.y) * (b.y - a.y);
|
||||
if (dot < 0) return false;
|
||||
const len2 = (b.x - a.x) ** 2 + (b.y - a.y) ** 2;
|
||||
return dot <= len2;
|
||||
}
|
||||
|
||||
function closestOnSegment(a: Point, b: Point, p: Point): Point {
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
if (len2 === 0) return { ...a };
|
||||
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
return { x: a.x + t * dx, y: a.y + t * dy };
|
||||
}
|
||||
|
||||
export function nearestPointInPolygon(p: Point, poly: Point[]): Point {
|
||||
if (poly.length < 3) return { ...p };
|
||||
if (pointInPolygon(p, poly)) return { ...p };
|
||||
let best = poly[0];
|
||||
let bestD = Infinity;
|
||||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||
const q = closestOnSegment(poly[j], poly[i], p);
|
||||
const d = (q.x - p.x) ** 2 + (q.y - p.y) ** 2;
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = q;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function rectanglePoints(width: number, height: number): Point[] {
|
||||
return [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: width, y: 0 },
|
||||
{ x: width, y: height },
|
||||
{ x: 0, y: height },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { Collider } from './collision';
|
||||
import type { Point } from '../types';
|
||||
import { pointInPolygon } from './polygon';
|
||||
|
||||
export function shapeCompletelyInsidePolygon(c: Collider, poly: Point[], samples: number = 16): boolean {
|
||||
if (!poly || poly.length < 3) return true;
|
||||
const cx = c.x + (c.width || 0) / 2;
|
||||
const cy = c.y + (c.height || 0) / 2;
|
||||
const w = c.width || 0;
|
||||
const h = c.height || 0;
|
||||
const rot = ((c.rotation ?? 0) * Math.PI) / 180;
|
||||
const cos = Math.cos(rot);
|
||||
const sin = Math.sin(rot);
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const a = (Math.PI * 2 * i) / samples;
|
||||
const r = Math.sqrt((w / 2) ** 2 + (h / 2) ** 2);
|
||||
const lx = r * Math.cos(a);
|
||||
const ly = r * Math.sin(a);
|
||||
const wx = cx + lx * cos - ly * sin;
|
||||
const wy = cy + lx * sin + ly * cos;
|
||||
if (!pointInPolygon({ x: wx, y: wy }, poly)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function clampRectInsidePolygon(c: Collider, poly: Point[], roomWidth: number, roomHeight: number, minSize = 10): Collider {
|
||||
if (!poly || poly.length < 3) {
|
||||
const width = Math.min(Math.max(minSize, c.width), roomWidth);
|
||||
const height = Math.min(Math.max(minSize, c.height), roomHeight);
|
||||
const x = Math.min(Math.max(0, c.x), Math.max(0, roomWidth - width));
|
||||
const y = Math.min(Math.max(0, c.y), Math.max(0, roomHeight - height));
|
||||
return { ...c, x, y, width, height };
|
||||
}
|
||||
const width = Math.min(Math.max(minSize, c.width), roomWidth);
|
||||
const height = Math.min(Math.max(minSize, c.height), roomHeight);
|
||||
let x = c.x;
|
||||
let y = c.y;
|
||||
for (let iter = 0; iter < 20; iter++) {
|
||||
const test = { ...c, x, y, width, height };
|
||||
if (shapeCompletelyInsidePolygon(test, poly, 20)) {
|
||||
return { ...test, x, y, width, height };
|
||||
}
|
||||
const cx = x + width / 2;
|
||||
const cy = y + height / 2;
|
||||
const inside = nearestPointInPoly(cx, cy, poly);
|
||||
x += (inside.x - cx) * 0.25;
|
||||
y += (inside.y - cy) * 0.25;
|
||||
x = Math.min(Math.max(0, x), Math.max(0, roomWidth - width));
|
||||
y = Math.min(Math.max(0, y), Math.max(0, roomHeight - height));
|
||||
}
|
||||
return { ...c, x, y, width, height };
|
||||
}
|
||||
|
||||
function nearestPointInPoly(px: number, py: number, poly: Point[]): Point {
|
||||
let best: Point = poly[0];
|
||||
let bestD = Infinity;
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const a = poly[i];
|
||||
const b = poly[(i + 1) % poly.length];
|
||||
const q = closestOnSeg(a, b, px, py);
|
||||
const d = (q.x - px) ** 2 + (q.y - py) ** 2;
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = q;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function closestOnSeg(a: Point, b: Point, px: number, py: number): Point {
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
if (len2 === 0) return { ...a };
|
||||
let t = ((px - a.x) * dx + (py - a.y) * dy) / len2;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
return { x: a.x + t * dx, y: a.y + t * dy };
|
||||
}
|
||||
@@ -153,6 +153,44 @@ label {
|
||||
box-shadow: 0 0 10px var(--accent);
|
||||
}
|
||||
|
||||
.sidebar-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
button.mini {
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
line-height: 1.2;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
|
||||
button.mini:hover {
|
||||
background: var(--bg-elevated, var(--bg-input));
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
button.mini.danger:hover {
|
||||
border-color: #e57373;
|
||||
color: #ff8a80;
|
||||
}
|
||||
|
||||
.sidebar-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@@ -454,6 +492,32 @@ label {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.leniency {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 2px 0 6px;
|
||||
}
|
||||
|
||||
.leniency-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
|
||||
.leniency-value {
|
||||
color: var(--text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.leniency input[type='range'] {
|
||||
width: 100%;
|
||||
accent-color: #4f8cff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-mute);
|
||||
font-size: 12px;
|
||||
|
||||
+103
-10
@@ -1,4 +1,12 @@
|
||||
import type { AccessibilityResult, FurnitureConfig, LayoutEvaluation, LayoutState, Point, Rect } from '../types';
|
||||
import type {
|
||||
AccessibilityResult,
|
||||
FurnitureConfig,
|
||||
GridCell,
|
||||
LayoutEvaluation,
|
||||
LayoutState,
|
||||
Point,
|
||||
Rect,
|
||||
} from '../types';
|
||||
import { buildGrid, gridToWorld, nearestWalkable } from '../pathfinding/grid';
|
||||
import { aStar } from '../pathfinding/aStar';
|
||||
import { entranceCollider, furnitureCollider } from '../geometry/bounds';
|
||||
@@ -58,24 +66,90 @@ export function evaluateLayout(state: LayoutState, gridSize: number = 10): Layou
|
||||
|
||||
let score = 0;
|
||||
for (const c of f.conditions) {
|
||||
const s = scoreCondition(c.kind, c, f, state, accessibility.find((a) => a.furnitureId === f.id) || null, path);
|
||||
const raw = scoreCondition(
|
||||
c.kind,
|
||||
c,
|
||||
f,
|
||||
state,
|
||||
accessibility.find((a) => a.furnitureId === f.id) || null,
|
||||
grid,
|
||||
gridSize,
|
||||
);
|
||||
const s = applyLeniency(raw, c.leniency ?? 0);
|
||||
score += (s * (c.weight || 1)) / (f.conditions.length || 1);
|
||||
}
|
||||
conditionScores[f.id] = score;
|
||||
totalConditionScore += score;
|
||||
}
|
||||
|
||||
const totalScore = totalConditionScore - penalty;
|
||||
let alignmentBonus = 0;
|
||||
let densityBonus = 0;
|
||||
const byType: Record<string, any[]> = {};
|
||||
for (const f of state.furniture) {
|
||||
const tkey = f.type || f.name;
|
||||
if (!byType[tkey]) byType[tkey] = [];
|
||||
byType[tkey].push(f);
|
||||
}
|
||||
for (const tkey of Object.keys(byType)) {
|
||||
const list = byType[tkey];
|
||||
if (list.length < 2) continue;
|
||||
const base = 2.0;
|
||||
for (let i = 1; i < list.length; i++) {
|
||||
alignmentBonus += base / (i + 1);
|
||||
}
|
||||
}
|
||||
const totalFurnitureArea = state.furniture.reduce((s,f)=>s + f.shape.width*f.shape.height,0);
|
||||
const roomArea = state.room.width*state.room.height;
|
||||
if (roomArea > 0) densityBonus = Math.min(5, (totalFurnitureArea/roomArea)*10);
|
||||
let flowBonus = 0;
|
||||
// reward good path flow (straight, short to entrance)
|
||||
if (state.entrance) {
|
||||
const ex=state.entrance.x+(state.entrance.width??20)/2, ey=state.entrance.y+(state.entrance.height??20)/2;
|
||||
for (const f of state.furniture) {
|
||||
const tx=f.shape.x+f.shape.width/2, ty=f.shape.y+f.shape.height/2;
|
||||
const d=Math.hypot(tx-ex,ty-ey);
|
||||
flowBonus += Math.max(0, 200-d)/40;
|
||||
}
|
||||
}
|
||||
const totalScore = totalConditionScore - penalty + alignmentBonus + densityBonus + flowBonus;
|
||||
return { totalScore, conditionScores, accessibility, penalty };
|
||||
}
|
||||
|
||||
function applyLeniency(score: number, leniency: number): number {
|
||||
const l = Math.max(0, Math.min(1, leniency));
|
||||
return 0.5 + (score - 0.5) * (1 - l);
|
||||
}
|
||||
|
||||
function localOpenness(
|
||||
grid: GridCell[][],
|
||||
cell: { col: number; row: number } | null,
|
||||
radiusCells: number,
|
||||
): number {
|
||||
if (!cell) return 0;
|
||||
const rows = grid.length;
|
||||
const cols = grid[0]?.length ?? 0;
|
||||
let open = 0;
|
||||
let total = 0;
|
||||
for (let dr = -radiusCells; dr <= radiusCells; dr++) {
|
||||
for (let dc = -radiusCells; dc <= radiusCells; dc++) {
|
||||
const r = cell.row + dr;
|
||||
const c = cell.col + dc;
|
||||
if (r < 0 || c < 0 || r >= rows || c >= cols) continue;
|
||||
total++;
|
||||
if (grid[r][c].walkable) open++;
|
||||
}
|
||||
}
|
||||
return total > 0 ? open / total : 0;
|
||||
}
|
||||
|
||||
function scoreCondition(
|
||||
kind: string,
|
||||
c: any,
|
||||
f: FurnitureConfig,
|
||||
state: LayoutState,
|
||||
acc: AccessibilityResult | null,
|
||||
path: Point[] | null,
|
||||
grid: GridCell[][],
|
||||
gridSize: number,
|
||||
): number {
|
||||
switch (kind) {
|
||||
case 'walk_to_and_sit': {
|
||||
@@ -106,9 +180,9 @@ function scoreCondition(
|
||||
}
|
||||
case 'near_object': {
|
||||
const tid = (c.params as any)?.targetId;
|
||||
if (!tid) return 0.3;
|
||||
const targets = state.furniture.filter((x) => x.id === tid || (x.type === tid && x.id !== f.id));
|
||||
if (targets.length === 0) return 0.3;
|
||||
if (!tid) return 0.2;
|
||||
const targets = state.furniture.filter((x) => (x.id === tid || x.type === tid) && x.id !== f.id);
|
||||
if (targets.length === 0) return 0.2;
|
||||
const cx = f.shape.x + f.shape.width / 2;
|
||||
const cy = f.shape.y + f.shape.height / 2;
|
||||
let best = 0;
|
||||
@@ -116,15 +190,34 @@ function scoreCondition(
|
||||
const tx = t.shape.x + t.shape.width / 2;
|
||||
const ty = t.shape.y + t.shape.height / 2;
|
||||
const d = Math.sqrt((cx - tx) ** 2 + (cy - ty) ** 2);
|
||||
const s = Math.max(0, 1 - d / 80);
|
||||
const s = Math.max(0, 1 - d / 50);
|
||||
if (s > best) best = s;
|
||||
}
|
||||
return best;
|
||||
return best * 2.0;
|
||||
}
|
||||
case 'open_circulation': {
|
||||
return path && path.length > 2 ? 0.7 : 0.5;
|
||||
if (!acc?.reachable) return 0;
|
||||
const radiusCells = Math.max(1, Math.round(60 / gridSize));
|
||||
const cx = f.shape.x + f.shape.width / 2;
|
||||
const cy = f.shape.y + f.shape.height / 2;
|
||||
const cell = nearestWalkable(grid, { x: cx, y: cy }, gridSize);
|
||||
return localOpenness(grid, cell, radiusCells);
|
||||
}
|
||||
case 'line_of_sight': {
|
||||
return computeLineOfSightScore(state, f, gridSize);
|
||||
}
|
||||
default:
|
||||
return 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
function computeLineOfSightScore(state: LayoutState, f: FurnitureConfig, _gridSize: number): number {
|
||||
if (!state.entrance) return 0.5;
|
||||
const from = { x: state.entrance.x + (state.entrance.width ?? 20) / 2, y: state.entrance.y + (state.entrance.height ?? 20) / 2 };
|
||||
const to = { x: f.shape.x + f.shape.width / 2, y: f.shape.y + f.shape.height / 2 };
|
||||
const obstacles = state.furniture
|
||||
.filter((o) => o.id !== f.id)
|
||||
.map((o) => ({ x: o.shape.x, y: o.shape.y, width: o.shape.width, height: o.shape.height, rotation: o.shape.rotation }));
|
||||
if ((() => { try { const v: any = (0, eval)("require")("./visibility"); return v.lineOfSightClear(from, to, obstacles, state.room.points); } catch { return false; } })()) return 1.0;
|
||||
return 0.3;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Collider } from '../geometry/collision';
|
||||
import type { Point } from '../types';
|
||||
|
||||
|
||||
function intersectsSegment(p1: Point, p2: Point, q1: Point, q2: Point): boolean {
|
||||
const o1 = orientation(p1, p2, q1);
|
||||
const o2 = orientation(p1, p2, q2);
|
||||
const o3 = orientation(q1, q2, p1);
|
||||
const o4 = orientation(q1, q2, p2);
|
||||
if (o1 !== o2 && o3 !== o4) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function orientation(a: Point, b: Point, c: Point): number {
|
||||
const val = (b.y - a.y) * (c.x - b.x) - (b.x - a.x) * (c.y - b.y);
|
||||
if (Math.abs(val) < 1e-9) return 0;
|
||||
return val > 0 ? 1 : 2;
|
||||
}
|
||||
|
||||
export function lineOfSightClear(from: Point, to: Point, obstacles: Collider[], roomPoints?: Point[]): boolean {
|
||||
if (roomPoints && roomPoints.length >= 3) {
|
||||
for (let i = 0; i < roomPoints.length; i++) {
|
||||
const a = roomPoints[i];
|
||||
const b = roomPoints[(i + 1) % roomPoints.length];
|
||||
if (intersectsSegment(from, to, a, b) && !colinearTouch(from, to, a, b)) return false;
|
||||
}
|
||||
}
|
||||
for (const o of obstacles) {
|
||||
if (segmentIntersectsRect(from, to, o)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function colinearTouch(a: Point, b: Point, c: Point, d: Point): boolean {
|
||||
return orientation(a, b, c) === 0 && orientation(a, b, d) === 0;
|
||||
}
|
||||
|
||||
function segmentIntersectsRect(p1: Point, p2: Point, r: Collider): boolean {
|
||||
const rot = ((r.rotation ?? 0) * Math.PI) / 180;
|
||||
const cos = Math.cos(-rot);
|
||||
const sin = Math.sin(-rot);
|
||||
const cx = r.x + (r.width || 0) / 2;
|
||||
const cy = r.y + (r.height || 0) / 2;
|
||||
const local1 = {
|
||||
x: (p1.x - cx) * cos - (p1.y - cy) * sin,
|
||||
y: (p1.x - cx) * sin + (p1.y - cy) * cos,
|
||||
};
|
||||
const local2 = {
|
||||
x: (p2.x - cx) * cos - (p2.y - cy) * sin,
|
||||
y: (p2.x - cx) * sin + (p2.y - cy) * cos,
|
||||
};
|
||||
const w = (r.width || 0) / 2;
|
||||
const h = (r.height || 0) / 2;
|
||||
const left = -w;
|
||||
const right = w;
|
||||
const top = -h;
|
||||
const bottom = h;
|
||||
if (pointInRect(local1, left, right, top, bottom) || pointInRect(local2, left, right, top, bottom)) return true;
|
||||
const rect = [
|
||||
{ x: left, y: top },
|
||||
{ x: right, y: top },
|
||||
{ x: right, y: bottom },
|
||||
{ x: left, y: bottom },
|
||||
];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const a = rect[i];
|
||||
const b = rect[(i + 1) % 4];
|
||||
const g1 = { x: local1.x + cx, y: local1.y + cy };
|
||||
const g2 = { x: local2.x + cx, y: local2.y + cy };
|
||||
if (intersectsSegment({ x: a.x + cx, y: a.y + cy }, { x: b.x + cx, y: b.y + cy }, g1, g2)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function pointInRect(p: Point, l: number, r: number, t: number, b: number): boolean {
|
||||
return p.x >= l && p.x <= r && p.y >= t && p.y <= b;
|
||||
}
|
||||
+37
-32
@@ -50,42 +50,47 @@ export function prepareForOptimize(state: LayoutState): LayoutState {
|
||||
let furniture = [...state.furniture];
|
||||
|
||||
for (const preset of presets) {
|
||||
const wanted = preset.amount ?? 0;
|
||||
const ofType = furniture.filter((f) => f.type === preset.id);
|
||||
const placed = ofType.filter((f) => f.placed);
|
||||
let generated = ofType.filter((f) => !f.placed);
|
||||
const target = preset.amount ?? 0;
|
||||
const existing = furniture.filter((f) => f.type === preset.id);
|
||||
|
||||
const need = Math.max(0, wanted - placed.length);
|
||||
if (generated.length > need) {
|
||||
generated = generated.slice(0, need);
|
||||
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));
|
||||
}
|
||||
while (generated.length < need) {
|
||||
const others: Collider[] = [
|
||||
...placed,
|
||||
...generated,
|
||||
...furniture.filter((f) => f.type !== preset.id),
|
||||
].map(furnitureCollider);
|
||||
|
||||
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);
|
||||
generated.push(makeGenerated(preset, generated.length, state.room, others));
|
||||
added.push(makeGenerated(preset, i, state.room, others));
|
||||
}
|
||||
|
||||
const synced = [...placed, ...generated].map((f) => ({
|
||||
...f,
|
||||
name: preset.name,
|
||||
color: preset.color,
|
||||
shape: {
|
||||
...f.shape,
|
||||
type: preset.shape.type,
|
||||
width: preset.shape.width,
|
||||
height: preset.shape.height,
|
||||
rotation: preset.shape.rotation ?? 0,
|
||||
},
|
||||
conditions: preset.conditions.map((c) => ({ ...c, params: { ...(c.params || {}) } })),
|
||||
lockedForOptimization: !!f.placed,
|
||||
}));
|
||||
|
||||
furniture = furniture.filter((f) => f.type !== preset.id);
|
||||
furniture = [...furniture, ...synced];
|
||||
furniture = [...furniture, ...added];
|
||||
}
|
||||
|
||||
return clampStateToRoom({ ...state, furniture });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { LayoutEvaluation, LayoutState } from '../types';
|
||||
import { evaluateLayout } from '../layout/evaluator';
|
||||
import { Furniture } from '../furniture/Furniture';
|
||||
import { clampRectToRoom, overlapsAny, resolveMove } from '../geometry/bounds';
|
||||
import { entranceCollider, findFreeSpot, furnitureCollider } from '../geometry/bounds';
|
||||
import type { Collider } from '../geometry/collision';
|
||||
|
||||
@@ -28,11 +29,36 @@ export function runRandomSearch(
|
||||
.filter((o) => o.id !== f.id)
|
||||
.map(furnitureCollider);
|
||||
if (entrance) others.push(entrance);
|
||||
const b = findFreeSpot(room, { width: f.shape.width, height: f.shape.height }, others);
|
||||
const hasNear = f.conditions.some((cc) => cc.kind === 'near_object' && (cc.params as any)?.targetId);
|
||||
let b;
|
||||
if (hasNear && Math.random() < 0.6) {
|
||||
const tgtId = f.conditions.find((cc) => cc.kind === 'near_object' && (cc.params as any)?.targetId)?.params as any;
|
||||
const target = candidate.furniture.find((x) => x.id === tgtId?.targetId || (x.type === tgtId?.targetId && x.id !== f.id));
|
||||
if (target) {
|
||||
const tcx = target.shape.x + target.shape.width / 2;
|
||||
const tcy = target.shape.y + target.shape.height / 2;
|
||||
const ang = Math.random() * Math.PI * 2;
|
||||
const rad = 45 + Math.random() * 40;
|
||||
b = clampRectToRoom({
|
||||
x: tcx + Math.cos(ang) * rad - f.shape.width / 2,
|
||||
y: tcy + Math.sin(ang) * rad - f.shape.height / 2,
|
||||
width: f.shape.width,
|
||||
height: f.shape.height,
|
||||
rotation: f.shape.rotation,
|
||||
}, room);
|
||||
const resolved = resolveMove(b, others, room);
|
||||
if (!overlapsAny(resolved, others)) b = resolved;
|
||||
}
|
||||
}
|
||||
if (!b) b = findFreeSpot(room, { width: f.shape.width, height: f.shape.height }, others);
|
||||
f.shape.x = b.x;
|
||||
f.shape.y = b.y;
|
||||
f.shape.width = b.width;
|
||||
f.shape.height = b.height;
|
||||
|
||||
if (Math.random() < 0.5) {
|
||||
f.shape.rotation = Math.floor(Math.random() * 24) * 15;
|
||||
}
|
||||
}
|
||||
const evalRes = evaluateLayout(candidate, options.gridSize || 10);
|
||||
if (evalRes.totalScore > bestEval.totalScore + 1e-9) {
|
||||
|
||||
+10
-4
@@ -1,8 +1,10 @@
|
||||
import type { GridCell, Point, Rect, RoomConfig } from '../types';
|
||||
import { pointInPolygon } from '../geometry/polygon';
|
||||
|
||||
export function buildGrid(room: RoomConfig, obstacles: Rect[], gridSize: number = 10): GridCell[][] {
|
||||
const cols = Math.ceil(room.width / gridSize);
|
||||
const rows = Math.ceil(room.height / gridSize);
|
||||
const poly = room.points && room.points.length >= 3 ? room.points : null;
|
||||
const grid: GridCell[][] = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
grid[r] = [];
|
||||
@@ -10,10 +12,14 @@ export function buildGrid(room: RoomConfig, obstacles: Rect[], gridSize: number
|
||||
const x = c * gridSize + gridSize / 2;
|
||||
const y = r * gridSize + gridSize / 2;
|
||||
let walkable = true;
|
||||
for (const obs of obstacles) {
|
||||
if (pointInRect(x, y, obs)) {
|
||||
walkable = false;
|
||||
break;
|
||||
if (poly && !pointInPolygon({ x, y }, poly)) {
|
||||
walkable = false;
|
||||
} else {
|
||||
for (const obs of obstacles) {
|
||||
if (pointInRect(x, y, obs)) {
|
||||
walkable = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
grid[r][c] = { x, y, walkable };
|
||||
|
||||
+5
-1
@@ -19,6 +19,7 @@ export interface ShapeData {
|
||||
width: number;
|
||||
height: number;
|
||||
rotation?: number;
|
||||
scale?: number;
|
||||
points?: Point[];
|
||||
}
|
||||
|
||||
@@ -29,11 +30,13 @@ export type ConditionKind =
|
||||
| 'near_object'
|
||||
| 'not_blocking_path'
|
||||
| 'accessible'
|
||||
| 'open_circulation';
|
||||
| 'open_circulation'
|
||||
| 'line_of_sight';
|
||||
|
||||
export interface ConditionSpec {
|
||||
kind: ConditionKind;
|
||||
weight: number;
|
||||
leniency?: number;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -60,6 +63,7 @@ export interface RoomConfig {
|
||||
width: number;
|
||||
height: number;
|
||||
gridSize?: number;
|
||||
points?: Point[];
|
||||
}
|
||||
|
||||
export interface GridCell {
|
||||
|
||||
Reference in New Issue
Block a user