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" />
<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
View File
@@ -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
View File
@@ -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>
+143 -6
View File
@@ -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 };
}
+3
View File
@@ -4,6 +4,7 @@ import { Shape } from '../geometry/shapes';
export class Furniture {
id: string;
name: string;
type?: string;
shape: Shape;
conditions: ConditionSpec[];
color: string;
@@ -13,6 +14,7 @@ export class Furniture {
constructor(config: FurnitureConfig) {
this.id = config.id;
this.name = config.name;
this.type = config.type;
this.shape = new Shape(config.shape);
this.conditions = config.conditions.map((c) => ({ ...c, params: { ...(c.params || {}) } }));
this.color = config.color ?? '#9aa0a6';
@@ -51,6 +53,7 @@ export class Furniture {
return {
id: this.id,
name: this.name,
type: this.type,
shape: {
type: this.shape.type,
x: this.shape.x,
+42 -2
View File
@@ -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;
}
+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);
}
.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
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 { 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;
}
+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];
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 });
+27 -1
View File
@@ -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
View File
@@ -1,8 +1,10 @@
import type { GridCell, Point, Rect, RoomConfig } from '../types';
import { pointInPolygon } from '../geometry/polygon';
export function buildGrid(room: RoomConfig, obstacles: Rect[], gridSize: number = 10): GridCell[][] {
const cols = Math.ceil(room.width / gridSize);
const rows = Math.ceil(room.height / gridSize);
const poly = room.points && room.points.length >= 3 ? room.points : null;
const grid: GridCell[][] = [];
for (let r = 0; r < rows; r++) {
grid[r] = [];
@@ -10,10 +12,14 @@ export function buildGrid(room: RoomConfig, obstacles: Rect[], gridSize: number
const x = c * gridSize + gridSize / 2;
const y = r * gridSize + gridSize / 2;
let walkable = true;
for (const obs of obstacles) {
if (pointInRect(x, y, obs)) {
walkable = false;
break;
if (poly && !pointInPolygon({ x, y }, poly)) {
walkable = false;
} else {
for (const obs of obstacles) {
if (pointInRect(x, y, obs)) {
walkable = false;
break;
}
}
}
grid[r][c] = { x, y, walkable };
+5 -1
View File
@@ -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 {