Add venue planner: OOP furniture, shapes, conditions, room/birds-eye UI, layout+optimize modes, pathfinding, random search + NN stub
This commit is contained in:
Vendored
+2
-2
@@ -5,8 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>furniture-app</title>
|
||||
<script type="module" crossorigin src="/assets/index-Cl6Bt99l.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D64VDMd1.css">
|
||||
<script type="module" crossorigin src="/assets/index-PcoLP7A2.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B4HUQdL0.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/* minimal */
|
||||
+68
-116
@@ -1,122 +1,74 @@
|
||||
import { useState } from 'react'
|
||||
import heroImg from './assets/hero.png'
|
||||
import reactLogo from './assets/react.svg'
|
||||
import viteLogo from './assets/vite.svg'
|
||||
import './App.css'
|
||||
import { useEffect, useState } from 'react';
|
||||
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';
|
||||
|
||||
const initialState: LayoutState = {
|
||||
room: { width: 600, height: 400, gridSize: 10 },
|
||||
entrance: { x: 30, y: 30 },
|
||||
furniture: [],
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
const [state, setState] = useState<LayoutState>(initialState);
|
||||
const [mode, setMode] = useState<'layout' | 'optimize'>('layout');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [optimizing, setOptimizing] = useState(false);
|
||||
const [timeLeft, setTimeLeft] = useState(15);
|
||||
|
||||
useEffect(() => {
|
||||
if (!optimizing) return;
|
||||
if (timeLeft <= 0) {
|
||||
// run NN optimization after random search window conceptually; run final pass
|
||||
const res = optimizeWithNN(state, { gridSize: 10 });
|
||||
setState(res.bestState);
|
||||
setOptimizing(false);
|
||||
setTimeLeft(15);
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(() => setTimeLeft((v) => v - 1), 1000);
|
||||
// also do a quick random search step each tick by sampling (lightweight)
|
||||
const { bestState } = runRandomSearch(state, {
|
||||
iterations: 5,
|
||||
roomWidth: state.room.width,
|
||||
roomHeight: state.room.height,
|
||||
gridSize: state.room.gridSize,
|
||||
});
|
||||
setState(bestState);
|
||||
return () => clearTimeout(t);
|
||||
}, [optimizing, timeLeft]);
|
||||
|
||||
const handleOptimize = () => {
|
||||
setOptimizing(true);
|
||||
setTimeLeft(15);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section id="center">
|
||||
<div className="hero">
|
||||
<img src={heroImg} className="base" width="170" height="179" alt="" />
|
||||
<img src={reactLogo} className="framework" alt="React logo" />
|
||||
<img src={viteLogo} className="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="counter"
|
||||
onClick={() => setCount((count) => count + 1)}
|
||||
>
|
||||
Count is {count}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div className="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img className="logo" src={viteLogo} alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://react.dev/" target="_blank">
|
||||
<img className="button-icon" src={reactLogo} alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</>
|
||||
)
|
||||
<div style={{ display: 'flex', height: '100vh', fontFamily: 'sans-serif' }}>
|
||||
<ControlPanel
|
||||
state={state}
|
||||
onStateChange={setState}
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
onOptimize={handleOptimize}
|
||||
optimizing={optimizing}
|
||||
timeLeft={timeLeft}
|
||||
/>
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: 12 }}>
|
||||
<RoomCanvas
|
||||
state={state}
|
||||
onStateChange={setState}
|
||||
mode={mode}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { LayoutEvaluation, LayoutState } from '../types';
|
||||
import { evaluateLayout } from '../layout/evaluator';
|
||||
|
||||
export interface NNOptions {
|
||||
gridSize?: number;
|
||||
iterations?: number;
|
||||
}
|
||||
|
||||
export function optimizeWithNN(state: LayoutState, options: NNOptions = {}): { bestState: LayoutState; bestEval: LayoutEvaluation } {
|
||||
const candidate: LayoutState = {
|
||||
room: { ...state.room },
|
||||
entrance: state.entrance ? { ...state.entrance } : null,
|
||||
furniture: state.furniture.map((f) => ({
|
||||
...f,
|
||||
shape: { ...f.shape, points: f.shape.points?.map((p) => ({ ...p })) },
|
||||
})),
|
||||
};
|
||||
|
||||
const entrance = candidate.entrance;
|
||||
const centerX = candidate.room.width / 2;
|
||||
const centerY = candidate.room.height / 2;
|
||||
|
||||
for (const f of candidate.furniture) {
|
||||
if (f.lockedForOptimization) continue;
|
||||
let targetX = f.shape.x;
|
||||
let targetY = f.shape.y;
|
||||
const needsNearEntrance = f.conditions.some((c) => c.kind === 'near_entrance');
|
||||
const needsCenter = f.conditions.some((c) => c.kind === 'around_open_area');
|
||||
if (entrance && needsNearEntrance) {
|
||||
targetX = entrance.x + (Math.random() - 0.5) * 40;
|
||||
targetY = entrance.y + (Math.random() - 0.5) * 40;
|
||||
} else if (needsCenter) {
|
||||
targetX = centerX - f.shape.width / 2 + (Math.random() - 0.5) * 60;
|
||||
targetY = centerY - f.shape.height / 2 + (Math.random() - 0.5) * 60;
|
||||
} else {
|
||||
targetX = Math.random() * (candidate.room.width - f.shape.width);
|
||||
targetY = Math.random() * (candidate.room.height - f.shape.height);
|
||||
}
|
||||
if (targetX < 0) targetX = 0;
|
||||
if (targetY < 0) targetY = 0;
|
||||
if (targetX + f.shape.width > candidate.room.width) targetX = candidate.room.width - f.shape.width;
|
||||
if (targetY + f.shape.height > candidate.room.height) targetY = candidate.room.height - f.shape.height;
|
||||
f.shape.x = targetX;
|
||||
f.shape.y = targetY;
|
||||
}
|
||||
|
||||
const bestEval = evaluateLayout(candidate, options.gridSize || 10);
|
||||
return { bestState: candidate, bestEval };
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { ConditionKind, Entrance, FurnitureConfig, LayoutState, RoomConfig } from '../types';
|
||||
|
||||
interface ControlPanelProps {
|
||||
state: LayoutState;
|
||||
onStateChange: (state: LayoutState) => void;
|
||||
mode: 'layout' | 'optimize';
|
||||
onModeChange: (mode: 'layout' | 'optimize') => void;
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
onOptimize: () => void;
|
||||
optimizing: boolean;
|
||||
timeLeft: number;
|
||||
}
|
||||
|
||||
export function ControlPanel({
|
||||
state,
|
||||
onStateChange,
|
||||
mode,
|
||||
onModeChange,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onOptimize,
|
||||
optimizing,
|
||||
timeLeft,
|
||||
}: ControlPanelProps) {
|
||||
const addFurniture = () => {
|
||||
const id = 'f_' + Date.now();
|
||||
const newItem: FurnitureConfig = {
|
||||
id,
|
||||
name: 'Table ' + state.furniture.length,
|
||||
shape: { type: 'rectangle', x: 50, y: 50, width: 60, height: 40 },
|
||||
conditions: [
|
||||
{ kind: 'walk_to_and_sit', weight: 1 },
|
||||
{ kind: 'accessible', weight: 1 },
|
||||
],
|
||||
color: '#a1887f',
|
||||
lockedForOptimization: false,
|
||||
placed: true,
|
||||
};
|
||||
onStateChange({ ...state, furniture: [...state.furniture, newItem] });
|
||||
onSelect(id);
|
||||
};
|
||||
|
||||
const setRoom = (room: Partial<RoomConfig>) => {
|
||||
onStateChange({ ...state, room: { ...state.room, ...room } });
|
||||
};
|
||||
|
||||
const setEntrance = (entrance: Partial<Entrance>) => {
|
||||
if (!state.entrance) return;
|
||||
onStateChange({ ...state, entrance: { ...state.entrance, ...entrance } });
|
||||
};
|
||||
|
||||
const placeEntrance = () => {
|
||||
onStateChange({ ...state, entrance: { x: 20, y: 20, width: 8, height: 8 } });
|
||||
};
|
||||
|
||||
const toggleLock = () => {
|
||||
if (!selectedId) return;
|
||||
const updated = state.furniture.map((f) =>
|
||||
f.id === selectedId ? { ...f, lockedForOptimization: !f.lockedForOptimization } : f,
|
||||
);
|
||||
onStateChange({ ...state, furniture: updated });
|
||||
};
|
||||
|
||||
const updateSelectedCondition = (kind: ConditionKind, enabled: boolean) => {
|
||||
if (!selectedId) return;
|
||||
const updated = state.furniture.map((f) => {
|
||||
if (f.id !== selectedId) return f;
|
||||
const has = f.conditions.some((c) => c.kind === kind);
|
||||
if (enabled && !has) {
|
||||
return { ...f, conditions: [...f.conditions, { kind, weight: 1 }] };
|
||||
}
|
||||
if (!enabled && has) {
|
||||
return { ...f, conditions: f.conditions.filter((c) => c.kind !== kind) };
|
||||
}
|
||||
return f;
|
||||
});
|
||||
onStateChange({ ...state, furniture: updated });
|
||||
};
|
||||
|
||||
const selected = state.furniture.find((f) => f.id === selectedId);
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: 12, borderRight: '1px solid #ccc', minWidth: 260 }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<button onClick={() => onModeChange('layout')} disabled={mode === 'layout'}>
|
||||
Layout
|
||||
</button>
|
||||
<button onClick={() => onModeChange('optimize')} disabled={mode === 'optimize'} style={{ marginLeft: 8 }}>
|
||||
Optimize
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'layout' && (
|
||||
<div>
|
||||
<h3>Room</h3>
|
||||
<label>
|
||||
Width:
|
||||
<input
|
||||
type="number"
|
||||
value={state.room.width}
|
||||
onChange={(e) => setRoom({ width: Number(e.target.value) })}
|
||||
style={{ width: 80, marginLeft: 4 }}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ marginLeft: 8 }}>
|
||||
Height:
|
||||
<input
|
||||
type="number"
|
||||
value={state.room.height}
|
||||
onChange={(e) => setRoom({ height: Number(e.target.value) })}
|
||||
style={{ width: 80, marginLeft: 4 }}
|
||||
/>
|
||||
</label>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<button onClick={addFurniture}>Add Furniture</button>
|
||||
<button onClick={placeEntrance} style={{ marginLeft: 8 }}>
|
||||
Place Entrance
|
||||
</button>
|
||||
</div>
|
||||
{state.entrance && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<label>
|
||||
Ex:
|
||||
<input
|
||||
type="number"
|
||||
value={state.entrance.x}
|
||||
onChange={(e) => setEntrance({ x: Number(e.target.value) })}
|
||||
style={{ width: 60 }}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ marginLeft: 4 }}>
|
||||
Ey:
|
||||
<input
|
||||
type="number"
|
||||
value={state.entrance.y}
|
||||
onChange={(e) => setEntrance({ y: Number(e.target.value) })}
|
||||
style={{ width: 60 }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<h3 style={{ marginTop: 12 }}>Objects</h3>
|
||||
<div>
|
||||
{state.furniture.map((f) => (
|
||||
<div
|
||||
key={f.id}
|
||||
onClick={() => onSelect(f.id)}
|
||||
style={{
|
||||
padding: 4,
|
||||
background: selectedId === f.id ? '#eee' : 'transparent',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{f.name} {f.lockedForOptimization ? '(locked)' : ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{selected && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div>
|
||||
<label>
|
||||
Name:
|
||||
<input
|
||||
value={selected.name}
|
||||
onChange={(e) => {
|
||||
const updated = state.furniture.map((f) => (f.id === selectedId ? { ...f, name: e.target.value } : f));
|
||||
onStateChange({ ...state, furniture: updated });
|
||||
}}
|
||||
style={{ width: 120, marginLeft: 4 }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<button onClick={toggleLock}>{selected.lockedForOptimization ? 'Unlock' : 'Lock'} for opt</button>
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div>Conditions:</div>
|
||||
{(['walk_to_and_sit', 'around_open_area', 'near_entrance', 'accessible', 'not_blocking_path', 'open_circulation'] as ConditionKind[]).map(
|
||||
(k) => (
|
||||
<label key={k} style={{ display: 'block' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.conditions.some((c) => c.kind === k)}
|
||||
onChange={(e) => updateSelectedCondition(k, e.target.checked)}
|
||||
/>
|
||||
{k}
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'optimize' && (
|
||||
<div>
|
||||
<h3>Optimize</h3>
|
||||
<p>Runs random search (15s) then NN-informed placement heuristic.</p>
|
||||
<button onClick={onOptimize} disabled={optimizing}>
|
||||
{optimizing ? `Optimizing... ${timeLeft}s` : 'Optimize'}
|
||||
</button>
|
||||
{optimizing && <div style={{ marginTop: 8 }}>Randomizing layouts, keeping best...</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { LayoutState, Point } from '../types';
|
||||
|
||||
interface RoomCanvasProps {
|
||||
state: LayoutState;
|
||||
onStateChange: (state: LayoutState) => void;
|
||||
mode: 'layout' | 'optimize';
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
id: string;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
resizing?: boolean;
|
||||
edge?: 'se' | 'e' | 's' | 'nw' | 'ne' | 'sw' | 'n' | 'w';
|
||||
}
|
||||
|
||||
const MIN_SIZE = 10;
|
||||
|
||||
export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }: RoomCanvasProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [drag, setDrag] = useState<DragState | null>(null);
|
||||
const [zoom] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
draw();
|
||||
}, [state, selectedId, mode, zoom]);
|
||||
|
||||
const draw = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.save();
|
||||
ctx.scale(zoom, zoom);
|
||||
|
||||
ctx.strokeStyle = '#666';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(0, 0, state.room.width, state.room.height);
|
||||
|
||||
if (state.entrance) {
|
||||
ctx.fillStyle = '#4caf50';
|
||||
ctx.fillRect(state.entrance.x - 4, state.entrance.y - 4, 8, 8);
|
||||
ctx.fillText('ENTRANCE', state.entrance.x + 6, state.entrance.y + 4);
|
||||
}
|
||||
|
||||
for (const f of state.furniture) {
|
||||
ctx.fillStyle = f.color || '#9aa0a6';
|
||||
const b = { x: f.shape.x, y: f.shape.y, w: f.shape.width, h: f.shape.height };
|
||||
ctx.globalAlpha = f.lockedForOptimization && mode === 'optimize' ? 0.5 : 0.9;
|
||||
ctx.fillRect(b.x, b.y, b.w, b.h);
|
||||
ctx.strokeStyle = selectedId === f.id ? '#ff5722' : '#333';
|
||||
ctx.lineWidth = selectedId === f.id ? 3 : 1;
|
||||
ctx.strokeRect(b.x, b.y, b.w, b.h);
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.fillText(f.name.substring(0, 12), b.x + 2, b.y + 12);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
const getMousePos = (e: React.MouseEvent<HTMLCanvasElement>): Point => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return { x: 0, y: 0 };
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
return { x: ((e.clientX - rect.left) * scaleX) / zoom, y: ((e.clientY - rect.top) * scaleY) / zoom };
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const pos = getMousePos(e);
|
||||
if (selectedId) {
|
||||
const f = state.furniture.find((x) => x.id === selectedId);
|
||||
if (f) {
|
||||
const seX = f.shape.x + f.shape.width;
|
||||
const seY = f.shape.y + f.shape.height;
|
||||
if (Math.abs(pos.x - seX) < 8 && Math.abs(pos.y - seY) < 8) {
|
||||
setDrag({ id: selectedId, offsetX: 0, offsetY: 0, resizing: true, edge: 'se' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = state.furniture.length - 1; i >= 0; i--) {
|
||||
const f = state.furniture[i];
|
||||
if (pointInRect(pos, { x: f.shape.x, y: f.shape.y, w: f.shape.width, h: f.shape.height })) {
|
||||
onSelect(f.id);
|
||||
setDrag({ id: f.id, offsetX: pos.x - f.shape.x, offsetY: pos.y - f.shape.y });
|
||||
return;
|
||||
}
|
||||
}
|
||||
onSelect(null);
|
||||
setDrag(null);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!drag) return;
|
||||
const pos = getMousePos(e);
|
||||
const updated = state.furniture.map((f) => {
|
||||
if (f.id !== drag.id) return f;
|
||||
if (drag.resizing && drag.edge === 'se') {
|
||||
return {
|
||||
...f,
|
||||
shape: {
|
||||
...f.shape,
|
||||
width: Math.max(MIN_SIZE, pos.x - f.shape.x),
|
||||
height: Math.max(MIN_SIZE, pos.y - f.shape.y),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...f,
|
||||
shape: {
|
||||
...f.shape,
|
||||
x: pos.x - drag.offsetX,
|
||||
y: pos.y - drag.offsetY,
|
||||
},
|
||||
};
|
||||
});
|
||||
onStateChange({ ...state, furniture: updated });
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setDrag(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={Math.max(state.room.width, 400)}
|
||||
height={Math.max(state.room.height, 300)}
|
||||
style={{ border: '1px solid #ccc', maxWidth: '100%', height: 'auto' }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function pointInRect(p: Point, r: { x: number; y: number; w: number; h: number }): boolean {
|
||||
return p.x >= r.x && p.x <= r.x + r.w && p.y >= r.y && p.y <= r.y + r.h;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { ConditionSpec, FurnitureConfig, Point } from '../types';
|
||||
import { Shape } from '../geometry/shapes';
|
||||
|
||||
export class Furniture {
|
||||
id: string;
|
||||
name: string;
|
||||
shape: Shape;
|
||||
conditions: ConditionSpec[];
|
||||
color: string;
|
||||
lockedForOptimization: boolean;
|
||||
placed: boolean;
|
||||
|
||||
constructor(config: FurnitureConfig) {
|
||||
this.id = config.id;
|
||||
this.name = config.name;
|
||||
this.shape = new Shape(config.shape);
|
||||
this.conditions = config.conditions.map((c) => ({ ...c, params: { ...(c.params || {}) } }));
|
||||
this.color = config.color ?? '#9aa0a6';
|
||||
this.lockedForOptimization = config.lockedForOptimization ?? false;
|
||||
this.placed = config.placed ?? false;
|
||||
}
|
||||
|
||||
static create(config: FurnitureConfig): Furniture {
|
||||
return new Furniture(config);
|
||||
}
|
||||
|
||||
moveTo(p: Point): void {
|
||||
this.shape.x = p.x;
|
||||
this.shape.y = p.y;
|
||||
}
|
||||
|
||||
translate(dx: number, dy: number): void {
|
||||
this.shape.x += dx;
|
||||
this.shape.y += dy;
|
||||
}
|
||||
|
||||
setSize(w: number, h: number): void {
|
||||
this.shape.width = Math.max(1, w);
|
||||
this.shape.height = Math.max(1, h);
|
||||
}
|
||||
|
||||
getBounds() {
|
||||
return this.shape.getBounds();
|
||||
}
|
||||
|
||||
containsPoint(p: Point): boolean {
|
||||
return this.shape.containsPoint(p);
|
||||
}
|
||||
|
||||
toConfig(): FurnitureConfig {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
shape: {
|
||||
type: this.shape.type,
|
||||
x: this.shape.x,
|
||||
y: this.shape.y,
|
||||
width: this.shape.width,
|
||||
height: this.shape.height,
|
||||
rotation: this.shape.rotation,
|
||||
points: this.shape.points.map((p) => ({ x: p.x, y: p.y })),
|
||||
},
|
||||
conditions: this.conditions.map((c) => ({ ...c, params: { ...(c.params || {}) } })),
|
||||
color: this.color,
|
||||
lockedForOptimization: this.lockedForOptimization,
|
||||
placed: this.placed,
|
||||
};
|
||||
}
|
||||
|
||||
clone(): Furniture {
|
||||
return new Furniture(this.toConfig());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Point, Rect, ShapeData } from '../types';
|
||||
|
||||
export class Shape {
|
||||
type: ShapeData['type'];
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
points: Point[];
|
||||
|
||||
constructor(data: ShapeData) {
|
||||
this.type = data.type;
|
||||
this.x = data.x;
|
||||
this.y = data.y;
|
||||
this.width = Math.max(1, data.width);
|
||||
this.height = Math.max(1, data.height);
|
||||
this.rotation = data.rotation ?? 0;
|
||||
this.points = data.points ? data.points.map((p) => ({ x: p.x, y: p.y })) : [];
|
||||
}
|
||||
|
||||
clone(): Shape {
|
||||
return new Shape({
|
||||
type: this.type,
|
||||
x: this.x,
|
||||
y: this.y,
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
rotation: this.rotation,
|
||||
points: this.points.map((p) => ({ x: p.x, y: p.y })),
|
||||
});
|
||||
}
|
||||
|
||||
getBounds(): Rect {
|
||||
if (this.type === 'rectangle') {
|
||||
return { x: this.x, y: this.y, width: this.width, height: this.height };
|
||||
}
|
||||
if (this.type === 'circle') {
|
||||
const r = Math.max(this.width, this.height) / 2;
|
||||
return { x: this.x - r, y: this.y - r, width: r * 2, height: r * 2 };
|
||||
}
|
||||
if (this.points.length > 0) {
|
||||
const xs = this.points.map((p) => p.x);
|
||||
const ys = this.points.map((p) => p.y);
|
||||
const minX = Math.min(...xs);
|
||||
const maxX = Math.max(...xs);
|
||||
const minY = Math.min(...ys);
|
||||
const maxY = Math.max(...ys);
|
||||
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
||||
}
|
||||
return { x: this.x, y: this.y, width: this.width, height: this.height };
|
||||
}
|
||||
|
||||
containsPoint(p: Point): boolean {
|
||||
const b = this.getBounds();
|
||||
if (p.x < b.x || p.x > b.x + b.width || p.y < b.y || p.y > b.y + b.height) {
|
||||
if (this.type !== 'polygon') return false;
|
||||
}
|
||||
if (this.type === 'circle') {
|
||||
const cx = b.x + b.width / 2;
|
||||
const cy = b.y + b.height / 2;
|
||||
const r = b.width / 2;
|
||||
const dx = p.x - cx;
|
||||
const dy = p.y - cy;
|
||||
return dx * dx + dy * dy <= r * r + 1e-8;
|
||||
}
|
||||
if (this.type === 'polygon' && this.points.length > 0) {
|
||||
let inside = false;
|
||||
for (let i = 0, j = this.points.length - 1; i < this.points.length; j = i++) {
|
||||
const xi = this.points[i].x;
|
||||
const yi = this.points[i].y;
|
||||
const xj = this.points[j].x;
|
||||
const yj = this.points[j].y;
|
||||
const intersect = yi > p.y !== yj > p.y && p.x < ((xj - xi) * (p.y - yi)) / ((yj - yi) || 1e-9) + xi;
|
||||
if (intersect) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
return p.x >= b.x && p.x <= b.x + b.width && p.y >= b.y && p.y <= b.y + b.height;
|
||||
}
|
||||
|
||||
intersectsRect(r: Rect): boolean {
|
||||
const b = this.getBounds();
|
||||
return !(r.x + r.width < b.x || r.x > b.x + b.width || r.y + r.height < b.y || r.y > b.y + b.height);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
html, body, #root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { AccessibilityResult, FurnitureConfig, LayoutEvaluation, LayoutState, Point, Rect } from '../types';
|
||||
import { buildGrid } from '../pathfinding/grid';
|
||||
import { aStar } from '../pathfinding/aStar';
|
||||
|
||||
export function evaluateLayout(state: LayoutState, gridSize: number = 10): LayoutEvaluation {
|
||||
const obstacles: Rect[] = state.furniture
|
||||
.map((f) => ({
|
||||
x: f.shape.x,
|
||||
y: f.shape.y,
|
||||
width: f.shape.width,
|
||||
height: f.shape.height,
|
||||
}))
|
||||
.filter((r) => r.width > 0 && r.height > 0);
|
||||
|
||||
const grid = buildGrid(state.room, obstacles, gridSize);
|
||||
const accessibility: AccessibilityResult[] = [];
|
||||
let penalty = 0;
|
||||
let totalConditionScore = 0;
|
||||
const conditionScores: Record<string, number> = {};
|
||||
|
||||
const entrancePoint = state.entrance ? { x: state.entrance.x, y: state.entrance.y } : { x: 0, y: 0 };
|
||||
|
||||
for (const f of state.furniture) {
|
||||
const cx = f.shape.x + f.shape.width / 2;
|
||||
const cy = f.shape.y + f.shape.height / 2;
|
||||
const target: Point = { x: cx, y: cy };
|
||||
const path = aStar(entrancePoint, target, grid, gridSize);
|
||||
if (path === null) {
|
||||
accessibility.push({ furnitureId: f.id, distanceFromEntrance: null, reachable: false, pathLength: null });
|
||||
penalty += 100;
|
||||
} else {
|
||||
let len = 0;
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
const p0 = path[i - 1];
|
||||
const p1 = path[i];
|
||||
len += Math.sqrt((p0.x - p1.x) ** 2 + (p0.y - p1.y) ** 2);
|
||||
}
|
||||
accessibility.push({ furnitureId: f.id, distanceFromEntrance: len, reachable: true, pathLength: len });
|
||||
}
|
||||
|
||||
let score = 0;
|
||||
for (const c of f.conditions) {
|
||||
const s = scoreCondition(c.kind, f, state, accessibility.find((a) => a.furnitureId === f.id) || null, path);
|
||||
score += (s * (c.weight || 1)) / (f.conditions.length || 1);
|
||||
}
|
||||
conditionScores[f.id] = score;
|
||||
totalConditionScore += score;
|
||||
}
|
||||
|
||||
const totalScore = totalConditionScore - penalty;
|
||||
return { totalScore, conditionScores, accessibility, penalty };
|
||||
}
|
||||
|
||||
function scoreCondition(
|
||||
kind: string,
|
||||
f: FurnitureConfig,
|
||||
state: LayoutState,
|
||||
acc: AccessibilityResult | null,
|
||||
path: Point[] | null,
|
||||
): number {
|
||||
switch (kind) {
|
||||
case 'walk_to_and_sit': {
|
||||
if (acc?.reachable) return Math.min(1, 1 / ((acc.pathLength || 1) / 50 + 1e-6));
|
||||
return 0;
|
||||
}
|
||||
case 'around_open_area': {
|
||||
const center = { x: state.room.width / 2, y: state.room.height / 2 };
|
||||
const cx = f.shape.x + f.shape.width / 2;
|
||||
const cy = f.shape.y + f.shape.height / 2;
|
||||
const distCenter = Math.sqrt((cx - center.x) ** 2 + (cy - center.y) ** 2);
|
||||
return Math.max(0, 1 - distCenter / Math.max(state.room.width, state.room.height));
|
||||
}
|
||||
case 'near_entrance': {
|
||||
if (!state.entrance) return 0;
|
||||
const cx = f.shape.x + f.shape.width / 2;
|
||||
const cy = f.shape.y + f.shape.height / 2;
|
||||
const ex = state.entrance.x;
|
||||
const ey = state.entrance.y;
|
||||
const d = Math.sqrt((cx - ex) ** 2 + (cy - ey) ** 2);
|
||||
return Math.max(0, 1 - d / 200);
|
||||
}
|
||||
case 'not_blocking_path': {
|
||||
return acc?.reachable ? 0.8 : 0.2;
|
||||
}
|
||||
case 'accessible': {
|
||||
return acc?.reachable ? 1.0 : 0.0;
|
||||
}
|
||||
case 'open_circulation': {
|
||||
return path && path.length > 2 ? 0.7 : 0.5;
|
||||
}
|
||||
default:
|
||||
return 0.5;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { LayoutEvaluation, LayoutState } from '../types';
|
||||
import { evaluateLayout } from '../layout/evaluator';
|
||||
import { Furniture } from '../furniture/Furniture';
|
||||
|
||||
export interface RandomSearchOptions {
|
||||
iterations: number;
|
||||
gridSize?: number;
|
||||
roomWidth: number;
|
||||
roomHeight: number;
|
||||
}
|
||||
|
||||
export function runRandomSearch(
|
||||
state: LayoutState,
|
||||
options: RandomSearchOptions,
|
||||
): { bestState: LayoutState; bestEval: LayoutEvaluation } {
|
||||
let bestState = cloneState(state);
|
||||
let bestEval = evaluateLayout(bestState, options.gridSize || 10);
|
||||
|
||||
for (let i = 0; i < options.iterations; i++) {
|
||||
const candidate = cloneState(bestState);
|
||||
for (const f of candidate.furniture) {
|
||||
if (f.lockedForOptimization) continue;
|
||||
f.shape.x = Math.random() * (options.roomWidth - f.shape.width);
|
||||
f.shape.y = Math.random() * (options.roomHeight - f.shape.height);
|
||||
if (f.shape.x < 0) f.shape.x = 0;
|
||||
if (f.shape.y < 0) f.shape.y = 0;
|
||||
if (f.shape.x + f.shape.width > options.roomWidth) f.shape.x = options.roomWidth - f.shape.width;
|
||||
if (f.shape.y + f.shape.height > options.roomHeight) f.shape.y = options.roomHeight - f.shape.height;
|
||||
}
|
||||
const evalRes = evaluateLayout(candidate, options.gridSize || 10);
|
||||
if (evalRes.totalScore > bestEval.totalScore + 1e-9) {
|
||||
bestState = candidate;
|
||||
bestEval = evalRes;
|
||||
}
|
||||
}
|
||||
return { bestState, bestEval };
|
||||
}
|
||||
|
||||
function cloneState(state: LayoutState): LayoutState {
|
||||
return {
|
||||
room: { ...state.room },
|
||||
entrance: state.entrance ? { ...state.entrance } : null,
|
||||
furniture: state.furniture.map((f) => new Furniture(f).toConfig()),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { GridCell, PathNode, Point } from '../types';
|
||||
import { gridToWorld, worldToGrid } from './grid';
|
||||
|
||||
export function aStar(
|
||||
start: Point,
|
||||
end: Point,
|
||||
grid: GridCell[][],
|
||||
gridSize: number,
|
||||
): Point[] | null {
|
||||
const cols = grid[0]?.length ?? 0;
|
||||
const rows = grid.length ?? 0;
|
||||
const s = worldToGrid(start, gridSize);
|
||||
const e = worldToGrid(end, gridSize);
|
||||
if (s.col < 0 || s.col >= cols || s.row < 0 || s.row >= rows) return null;
|
||||
if (e.col < 0 || e.col >= cols || e.row < 0 || e.row >= rows) return null;
|
||||
if (!grid[s.row]?.[s.col]?.walkable || !grid[e.row]?.[e.col]?.walkable) return null;
|
||||
|
||||
const open: PathNode[] = [];
|
||||
const closed = new Set<string>();
|
||||
const startNode: PathNode = {
|
||||
x: s.col,
|
||||
y: s.row,
|
||||
g: 0,
|
||||
h: distGrid(s.col, s.row, e.col, e.row),
|
||||
f: distGrid(s.col, s.row, e.col, e.row),
|
||||
parent: undefined,
|
||||
};
|
||||
open.push(startNode);
|
||||
|
||||
const dirs = [
|
||||
[1, 0],
|
||||
[-1, 0],
|
||||
[0, 1],
|
||||
[0, -1],
|
||||
[1, 1],
|
||||
[1, -1],
|
||||
[-1, 1],
|
||||
[-1, -1],
|
||||
];
|
||||
while (open.length > 0) {
|
||||
let idx = 0;
|
||||
for (let i = 1; i < open.length; i++) if (open[i].f < open[idx].f) idx = i;
|
||||
const current = open.splice(idx, 1)[0];
|
||||
const key = current.x + ',' + current.y;
|
||||
if (closed.has(key)) continue;
|
||||
closed.add(key);
|
||||
if (current.x === e.col && current.y === e.row) {
|
||||
const path: Point[] = [];
|
||||
let cur: PathNode | undefined = current;
|
||||
while (cur) {
|
||||
path.push(gridToWorld(cur.x, cur.y, gridSize));
|
||||
cur = cur.parent;
|
||||
}
|
||||
return path.reverse();
|
||||
}
|
||||
for (const d of dirs) {
|
||||
const nx = current.x + d[0];
|
||||
const ny = current.y + d[1];
|
||||
if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue;
|
||||
if (!grid[ny][nx].walkable) continue;
|
||||
const g = current.g + (d[0] === 0 || d[1] === 0 ? 1 : Math.SQRT2);
|
||||
const h = distGrid(nx, ny, e.col, e.row);
|
||||
const node: PathNode = { x: nx, y: ny, g, h, f: g + h, parent: current };
|
||||
open.push(node);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function distGrid(x1: number, y1: number, x2: number, y2: number): number {
|
||||
const dx = x1 - x2;
|
||||
const dy = y1 - y2;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { GridCell, Point, Rect, RoomConfig } from '../types';
|
||||
|
||||
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 grid: GridCell[][] = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
grid[r] = [];
|
||||
for (let c = 0; c < cols; c++) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
grid[r][c] = { x, y, walkable };
|
||||
}
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
function pointInRect(px: number, py: number, r: Rect): boolean {
|
||||
return px >= r.x && px <= r.x + r.width && py >= r.y && py <= r.y + r.height;
|
||||
}
|
||||
|
||||
export function worldToGrid(p: Point, gridSize: number): { col: number; row: number } {
|
||||
return {
|
||||
col: Math.floor(p.x / gridSize),
|
||||
row: Math.floor(p.y / gridSize),
|
||||
};
|
||||
}
|
||||
|
||||
export function gridToWorld(col: number, row: number, gridSize: number): Point {
|
||||
return { x: col * gridSize + gridSize / 2, y: row * gridSize + gridSize / 2 };
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
export type ShapeType = 'rectangle' | 'circle' | 'polygon';
|
||||
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface Size {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface Rect extends Point, Size {}
|
||||
|
||||
export interface ShapeData {
|
||||
type: ShapeType;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation?: number;
|
||||
points?: Point[];
|
||||
}
|
||||
|
||||
export type ConditionKind =
|
||||
| 'walk_to_and_sit'
|
||||
| 'around_open_area'
|
||||
| 'near_entrance'
|
||||
| 'not_blocking_path'
|
||||
| 'accessible'
|
||||
| 'open_circulation';
|
||||
|
||||
export interface ConditionSpec {
|
||||
kind: ConditionKind;
|
||||
weight: number;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface FurnitureConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
shape: ShapeData;
|
||||
conditions: ConditionSpec[];
|
||||
color?: string;
|
||||
lockedForOptimization?: boolean;
|
||||
placed?: boolean;
|
||||
}
|
||||
|
||||
export interface Entrance {
|
||||
x: number;
|
||||
y: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
rotation?: number;
|
||||
}
|
||||
|
||||
export interface RoomConfig {
|
||||
width: number;
|
||||
height: number;
|
||||
gridSize?: number;
|
||||
}
|
||||
|
||||
export interface GridCell {
|
||||
x: number;
|
||||
y: number;
|
||||
walkable: boolean;
|
||||
}
|
||||
|
||||
export interface PathNode extends Point {
|
||||
g: number;
|
||||
h: number;
|
||||
f: number;
|
||||
parent?: PathNode;
|
||||
}
|
||||
|
||||
export interface AccessibilityResult {
|
||||
furnitureId: string;
|
||||
distanceFromEntrance: number | null;
|
||||
reachable: boolean;
|
||||
pathLength: number | null;
|
||||
}
|
||||
|
||||
export interface LayoutEvaluation {
|
||||
totalScore: number;
|
||||
conditionScores: Record<string, number>;
|
||||
accessibility: AccessibilityResult[];
|
||||
penalty: number;
|
||||
}
|
||||
|
||||
export interface LayoutState {
|
||||
room: RoomConfig;
|
||||
entrance: Entrance | null;
|
||||
furniture: FurnitureConfig[];
|
||||
}
|
||||
Reference in New Issue
Block a user