This commit is contained in:
2026-10-18 08:41:47 -04:00
parent fa42856ccd
commit 3ba7b0e89f
16 changed files with 872 additions and 107 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-DE7tP3Fy.js"></script> <script type="module" crossorigin src="/assets/index-BkulQhND.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-9k24Ci9h.css"> <link rel="stylesheet" crossorigin href="/assets/index-67F-Ik0K.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+16 -22
View File
@@ -3,7 +3,6 @@ import { RoomCanvas } from './components/RoomCanvas';
import { ControlPanel } from './components/ControlPanel'; import { ControlPanel } from './components/ControlPanel';
import type { 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 { prepareForOptimize } from './optimization/prepare';
import { DEFAULT_PRESETS } from './furniture/presets'; import { DEFAULT_PRESETS } from './furniture/presets';
@@ -19,7 +18,6 @@ function App() {
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); const [lastScore, setLastScore] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
@@ -37,31 +35,28 @@ function App() {
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,
setLastScore(res.bestEval.totalScore); roomWidth: state.room.width,
setOptimizing(false); roomHeight: state.room.height,
setTimeLeft(15); gridSize: state.room.gridSize,
return; });
} setState(bestState);
const t = setTimeout(() => setTimeLeft((v) => v - 1), 1000); setLastScore(bestEval.totalScore);
const { bestState, bestEval } = runRandomSearch(state, { };
iterations: 5, const t = setTimeout(tick, 30);
roomWidth: state.room.width,
roomHeight: state.room.height,
gridSize: state.room.gridSize,
});
setState(bestState);
setLastScore(bestEval.totalScore);
return () => clearTimeout(t); return () => clearTimeout(t);
}, [optimizing, timeLeft]); }, [optimizing, state, state.room.gridSize, state.room.width, state.room.height]);
const handleOptimize = () => { const handleOptimize = () => {
if (optimizing) {
setOptimizing(false);
return;
}
setState((s) => prepareForOptimize(s)); setState((s) => prepareForOptimize(s));
setLastScore(null); setLastScore(null);
setOptimizing(true); setOptimizing(true);
setTimeLeft(15);
}; };
return ( return (
@@ -75,7 +70,6 @@ function App() {
onSelect={setSelectedId} onSelect={setSelectedId}
onOptimize={handleOptimize} onOptimize={handleOptimize}
optimizing={optimizing} optimizing={optimizing}
timeLeft={timeLeft}
lastScore={lastScore} lastScore={lastScore}
/> />
<div className="stage"> <div className="stage">
+207 -27
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import type { ConditionKind, Entrance, 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 { clampStateToRoom } from '../geometry/bounds';
import { rectanglePoints } from '../geometry/polygon';
interface ControlPanelProps { interface ControlPanelProps {
state: LayoutState; state: LayoutState;
@@ -12,7 +13,6 @@ 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; lastScore: number | null;
} }
@@ -24,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> = {
@@ -34,6 +35,7 @@ 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']; const SWATCHES = ['#a1887f', '#d7ccc5', '#90caf9', '#a5d6a7', '#ef9a9a', '#ce93d8', '#ffcc80'];
@@ -47,7 +49,6 @@ export function ControlPanel({
onSelect, onSelect,
onOptimize, onOptimize,
optimizing, optimizing,
timeLeft,
lastScore, lastScore,
}: ControlPanelProps) { }: ControlPanelProps) {
const [openId, setOpenId] = useState<string | null>(null); 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) ? state.furniture.find((f) => f.id === selectedId)?.type ?? (selectedId === '__entrance__' ? '__entrance__' : null)
: null; : null;
const selectedFurniture = selectedId ? state.furniture.find((f) => f.id === selectedId) ?? null : null;
useEffect(() => { useEffect(() => {
if (selectedTypeId) setOpenId(selectedTypeId); if (selectedTypeId) setOpenId(selectedTypeId);
}, [selectedTypeId]); }, [selectedTypeId]);
const setRoom = (room: Partial<RoomConfig>) => const setRoom = (room: Partial<RoomConfig>) => {
onStateChange(clampStateToRoom({ ...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;
@@ -72,13 +113,53 @@ export function ControlPanel({
const placeEntrance = () => const placeEntrance = () =>
onStateChange(clampStateToRoom({ ...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 updateObject = (id: string, patch: Partial<FurniturePreset>) => {
const presets = objects.map((p) => (p.id === id ? { ...p, ...patch } : p)); 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 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) => { const removeObject = (id: string) => {
onStateChange({ onStateChange({
...state, ...state,
@@ -112,14 +193,14 @@ export function ControlPanel({
const has = p.conditions.some((c) => c.kind === k); const has = p.conditions.some((c) => c.kind === k);
if (enabled && !has) { if (enabled && !has) {
const params = k === 'near_object' ? { targetId: '' } : {}; 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) { 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 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) => { const toggleOpen = (id: string) => {
@@ -172,7 +264,7 @@ export function ControlPanel({
)} )}
<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}
@@ -181,7 +273,7 @@ export function ControlPanel({
/> />
</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}
@@ -202,11 +294,13 @@ export function ControlPanel({
</select> </select>
</label> </label>
<label className="field"> <label className="field">
<span>Rotation</span> <span>Scale</span>
<NumberField <NumberField
value={p.shape.rotation ?? 0} value={p.shape.scale ?? 1}
min={0.1}
step={0.05}
width="100%" width="100%"
onChange={(v) => updateObject(p.id, { shape: { ...p.shape, rotation: v } })} onChange={(v) => updateObject(p.id, { shape: { ...p.shape, scale: v } })}
/> />
</label> </label>
</div> </div>
@@ -234,8 +328,9 @@ export function ControlPanel({
<div className="cond-list"> <div className="cond-list">
{CONDITION_KINDS.map((k) => { {CONDITION_KINDS.map((k) => {
const active = p.conditions.some((c) => c.kind === k); const active = p.conditions.some((c) => c.kind === k);
const targetId = (p.conditions.find((c) => c.kind === k)?.params as { targetId?: string } | undefined) const cond = p.conditions.find((c) => c.kind === k);
?.targetId; const targetId = (cond?.params as { targetId?: string } | undefined)?.targetId;
const leniency = cond?.leniency ?? 0;
return ( return (
<div className="cond" key={k}> <div className="cond" key={k}>
<label> <label>
@@ -246,6 +341,22 @@ export function ControlPanel({
/> />
{CONDITION_LABELS[k]} {CONDITION_LABELS[k]}
</label> </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 && ( {k === 'near_object' && active && (
<select <select
value={targetId || ''} value={targetId || ''}
@@ -286,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')}>
@@ -315,6 +436,28 @@ export function ControlPanel({
<NumberField value={state.room.height} min={100} width="100%" onChange={(v) => setRoom({ height: v })} /> <NumberField value={state.room.height} min={100} width="100%" onChange={(v) => setRoom({ height: v })} />
</label> </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">
@@ -324,17 +467,54 @@ export function ControlPanel({
</button> </button>
</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">Entrance</div> <div className="section-title">Entrance</div>
{state.entrance ? ( {state.entrance ? (
<> <>
<div className="grid-2"> <div className="grid-2">
<label className="field"> <label className="field">
<span>X</span> <span>X (cm)</span>
<NumberField value={state.entrance.x} width="100%" onChange={(v) => setEntrance({ x: v })} /> <NumberField value={state.entrance.x} width="100%" onChange={(v) => setEntrance({ x: v })} />
</label> </label>
<label className="field"> <label className="field">
<span>Y</span> <span>Y (cm)</span>
<NumberField value={state.entrance.y} width="100%" onChange={(v) => setEntrance({ y: v })} /> <NumberField value={state.entrance.y} width="100%" onChange={(v) => setEntrance({ y: v })} />
</label> </label>
<label className="field"> <label className="field">
@@ -373,8 +553,8 @@ export function ControlPanel({
<div className="section"> <div className="section">
<div className="section-title">Objects</div> <div className="section-title">Objects</div>
<div className="hint"> <div className="hint">
Count tells the optimizer how many are needed. Drag a card onto the room to pin one in place Count is the maximum number for each object. Drag a card onto the room to pin one in place (locked);
(locked); the optimizer arranges the rest. the optimizer arranges the rest.
</div> </div>
<div className="obj-list">{objects.map(renderObjectCard)}</div> <div className="obj-list">{objects.map(renderObjectCard)}</div>
</div> </div>
@@ -385,11 +565,11 @@ 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"> <div className="score-row">
<span className="score-label">Score</span> <span className="score-label">Score</span>
@@ -399,7 +579,7 @@ export function ControlPanel({
</div> </div>
{optimizing && ( {optimizing && (
<div className="progress"> <div className="progress">
<div style={{ width: `${((15 - timeLeft) / 15) * 100}%` }} /> <div style={{ width: `$0%` }} />
</div> </div>
)} )}
</div> </div>
+143 -6
View File
@@ -1,7 +1,7 @@
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 { computePaths } from '../pathfinding/paths';
import { furnitureCollider, resolveMove } from '../geometry/bounds'; import { clampStateToRoom, furnitureCollider, resolveMove } from '../geometry/bounds';
import { makePlaced } from '../optimization/prepare'; import { makePlaced } from '../optimization/prepare';
import { Shape } from '../geometry/shapes'; import { Shape } from '../geometry/shapes';
@@ -19,6 +19,8 @@ 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, showPaths }: RoomCanvasProps) { 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.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)';
@@ -65,10 +79,23 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, s
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) { if (showPaths && state.entrance) {
const paths = computePaths(state, state.room.gridSize || 10); const paths = computePaths(state, state.room.gridSize || 10);
@@ -172,6 +199,31 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, s
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();
}; };
@@ -186,6 +238,16 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, s
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;
@@ -203,6 +265,20 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, s
return; 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--) { for (let i = state.furniture.length - 1; i >= 0; i--) {
const f = state.furniture[i]; const f = state.furniture[i];
const shape = new Shape(f.shape); 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>) => { 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); const others = state.furniture.map(furnitureCollider);
@@ -272,13 +371,38 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, s
}; };
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>) => { const handleDragOver = (e: React.DragEvent<HTMLCanvasElement>) => {
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = 'copy'; e.dataTransfer.dropEffect = 'copy';
@@ -290,6 +414,8 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, s
if (!presetId) return; if (!presetId) return;
const preset = (state.presets || []).find((p) => p.id === presetId); const preset = (state.presets || []).find((p) => p.id === presetId);
if (!preset) return; if (!preset) return;
const placedCount = state.furniture.filter((f) => f.type === presetId).length;
if (placedCount >= (preset.amount ?? 0)) return;
const pos = getMousePos(e); const pos = getMousePos(e);
const others: import('../geometry/collision').Collider[] = state.furniture.map(furnitureCollider); const others: import('../geometry/collision').Collider[] = state.furniture.map(furnitureCollider);
if (state.entrance) { if (state.entrance) {
@@ -310,14 +436,15 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, s
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} onDragOver={handleDragOver}
onDrop={handleDrop} onDrop={handleDrop}
onContextMenu={handleContextMenu}
/> />
); );
} }
@@ -341,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,
+42 -2
View File
@@ -1,6 +1,7 @@
import type { LayoutState, RoomConfig } from '../types'; import type { LayoutState, RoomConfig } from '../types';
import { colliderCenter, satMTV, type Collider } from './collision'; import { colliderCenter, satMTV, type Collider } from './collision';
import { overlapsAny } from './collision'; import { overlapsAny } from './collision';
import { nearestPointInPolygon, pointInPolygon } from './polygon';
export { overlapsAny }; export { overlapsAny };
@@ -14,8 +15,26 @@ export interface Bounds {
export function clampRectToRoom(rect: Collider, room: RoomConfig, minSize = 10): Collider { export function clampRectToRoom(rect: Collider, room: RoomConfig, minSize = 10): Collider {
const width = Math.min(Math.max(minSize, rect.width), room.width); const width = Math.min(Math.max(minSize, rect.width), room.width);
const height = Math.min(Math.max(minSize, rect.height), room.height); 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)); let x = rect.x;
const y = Math.min(Math.max(0, rect.y), Math.max(0, room.height - height)); 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 }; 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 { 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 }; 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;
}
+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 };
}
+64
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;
@@ -454,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;
+103 -10
View File
@@ -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 { buildGrid, gridToWorld, nearestWalkable } from '../pathfinding/grid';
import { aStar } from '../pathfinding/aStar'; import { aStar } from '../pathfinding/aStar';
import { entranceCollider, furnitureCollider } from '../geometry/bounds'; import { entranceCollider, furnitureCollider } from '../geometry/bounds';
@@ -58,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': {
@@ -106,9 +180,9 @@ 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 targets = state.furniture.filter((x) => x.id === tid || (x.type === tid && x.id !== f.id)); const targets = state.furniture.filter((x) => (x.id === tid || x.type === tid) && x.id !== f.id);
if (targets.length === 0) 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;
let best = 0; let best = 0;
@@ -116,15 +190,34 @@ function scoreCondition(
const tx = t.shape.x + t.shape.width / 2; const tx = t.shape.x + t.shape.width / 2;
const ty = t.shape.y + t.shape.height / 2; const ty = t.shape.y + t.shape.height / 2;
const d = Math.sqrt((cx - tx) ** 2 + (cy - ty) ** 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; if (s > best) best = s;
} }
return best; 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;
}
+37 -32
View File
@@ -50,42 +50,47 @@ export function prepareForOptimize(state: LayoutState): LayoutState {
let furniture = [...state.furniture]; let furniture = [...state.furniture];
for (const preset of presets) { for (const preset of presets) {
const wanted = preset.amount ?? 0; const target = preset.amount ?? 0;
const ofType = furniture.filter((f) => f.type === preset.id); const existing = furniture.filter((f) => f.type === preset.id);
const placed = ofType.filter((f) => f.placed);
let generated = ofType.filter((f) => !f.placed);
const need = Math.max(0, wanted - placed.length); if (existing.length > target) {
if (generated.length > need) { const removeIds = new Set(
generated = generated.slice(0, need); 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[] = [ furniture = furniture.map((f) =>
...placed, f.type === preset.id
...generated, ? {
...furniture.filter((f) => f.type !== preset.id), ...f,
].map(furnitureCollider); 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); if (entrance) others.push(entrance);
generated.push(makeGenerated(preset, generated.length, state.room, others)); added.push(makeGenerated(preset, i, state.room, others));
} }
furniture = [...furniture, ...added];
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];
} }
return clampStateToRoom({ ...state, furniture }); return clampStateToRoom({ ...state, furniture });
+27 -1
View File
@@ -1,6 +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 { Furniture } from '../furniture/Furniture'; import { Furniture } from '../furniture/Furniture';
import { clampRectToRoom, overlapsAny, resolveMove } from '../geometry/bounds';
import { entranceCollider, findFreeSpot, furnitureCollider } from '../geometry/bounds'; import { entranceCollider, findFreeSpot, furnitureCollider } from '../geometry/bounds';
import type { Collider } from '../geometry/collision'; import type { Collider } from '../geometry/collision';
@@ -28,11 +29,36 @@ export function runRandomSearch(
.filter((o) => o.id !== f.id) .filter((o) => o.id !== f.id)
.map(furnitureCollider); .map(furnitureCollider);
if (entrance) others.push(entrance); 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.x = b.x;
f.shape.y = b.y; f.shape.y = b.y;
f.shape.width = b.width; f.shape.width = b.width;
f.shape.height = b.height; 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) {
+10 -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 };
+5 -1
View File
@@ -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 {