Add venue planner: OOP furniture, shapes, conditions, room/birds-eye UI, layout+optimize modes, pathfinding, random search + NN stub

This commit is contained in:
2026-10-17 19:41:09 -04:00
parent 767d9502d6
commit a708a0cfe5
14 changed files with 985 additions and 118 deletions
+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-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>
+1
View File
@@ -0,0 +1 @@
/* minimal */
+68 -116
View File
@@ -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;
+49
View File
@@ -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 };
}
+209
View File
@@ -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>
);
}
+147
View File
@@ -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;
}
+73
View File
@@ -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());
}
}
+86
View File
@@ -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);
}
}
+5
View File
@@ -0,0 +1,5 @@
html, body, #root {
margin: 0;
padding: 0;
height: 100%;
}
+94
View File
@@ -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;
}
}
+45
View File
@@ -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()),
};
}
+74
View File
@@ -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);
}
+38
View File
@@ -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 };
}
+94
View File
@@ -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[];
}