From a708a0cfe5b4fe14a0b931b06694f61916a082bc Mon Sep 17 00:00:00 2001 From: plus_one Date: Sat, 17 Oct 2026 19:41:09 -0400 Subject: [PATCH] Add venue planner: OOP furniture, shapes, conditions, room/birds-eye UI, layout+optimize modes, pathfinding, random search + NN stub --- dist/index.html | 4 +- src/App.css | 1 + src/App.tsx | 184 ++++++++++----------------- src/ai/nn.ts | 49 ++++++++ src/components/ControlPanel.tsx | 209 +++++++++++++++++++++++++++++++ src/components/RoomCanvas.tsx | 147 ++++++++++++++++++++++ src/furniture/Furniture.ts | 73 +++++++++++ src/geometry/shapes.ts | 86 +++++++++++++ src/index.css | 5 + src/layout/evaluator.ts | 94 ++++++++++++++ src/optimization/randomSearch.ts | 45 +++++++ src/pathfinding/aStar.ts | 74 +++++++++++ src/pathfinding/grid.ts | 38 ++++++ src/types/index.ts | 94 ++++++++++++++ 14 files changed, 985 insertions(+), 118 deletions(-) create mode 100644 src/App.css create mode 100644 src/ai/nn.ts create mode 100644 src/components/ControlPanel.tsx create mode 100644 src/components/RoomCanvas.tsx create mode 100644 src/furniture/Furniture.ts create mode 100644 src/geometry/shapes.ts create mode 100644 src/index.css create mode 100644 src/layout/evaluator.ts create mode 100644 src/optimization/randomSearch.ts create mode 100644 src/pathfinding/aStar.ts create mode 100644 src/pathfinding/grid.ts create mode 100644 src/types/index.ts diff --git a/dist/index.html b/dist/index.html index ae67e6d..ba48e80 100644 --- a/dist/index.html +++ b/dist/index.html @@ -5,8 +5,8 @@ furniture-app - - + +
diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..42db1b0 --- /dev/null +++ b/src/App.css @@ -0,0 +1 @@ +/* minimal */ diff --git a/src/App.tsx b/src/App.tsx index 9ca8e8d..7826559 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(initialState); + const [mode, setMode] = useState<'layout' | 'optimize'>('layout'); + const [selectedId, setSelectedId] = useState(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 ( - <> -
-
- - React logo - Vite logo -
-
-

Get started

-

- Edit src/App.tsx and save to test HMR -

-
- -
- -
- -
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

- -
-
- -
-
- - ) +
+ +
+ +
+
+ ); } -export default App +export default App; diff --git a/src/ai/nn.ts b/src/ai/nn.ts new file mode 100644 index 0000000..00e3e1a --- /dev/null +++ b/src/ai/nn.ts @@ -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 }; +} diff --git a/src/components/ControlPanel.tsx b/src/components/ControlPanel.tsx new file mode 100644 index 0000000..408385b --- /dev/null +++ b/src/components/ControlPanel.tsx @@ -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) => { + onStateChange({ ...state, room: { ...state.room, ...room } }); + }; + + const setEntrance = (entrance: Partial) => { + 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 ( +
+
+ + +
+ + {mode === 'layout' && ( +
+

Room

+ + +
+ + +
+ {state.entrance && ( +
+ + +
+ )} +

Objects

+
+ {state.furniture.map((f) => ( +
onSelect(f.id)} + style={{ + padding: 4, + background: selectedId === f.id ? '#eee' : 'transparent', + cursor: 'pointer', + }} + > + {f.name} {f.lockedForOptimization ? '(locked)' : ''} +
+ ))} +
+ {selected && ( +
+
+ +
+
+ +
+
+
Conditions:
+ {(['walk_to_and_sit', 'around_open_area', 'near_entrance', 'accessible', 'not_blocking_path', 'open_circulation'] as ConditionKind[]).map( + (k) => ( + + ), + )} +
+
+ )} +
+ )} + + {mode === 'optimize' && ( +
+

Optimize

+

Runs random search (15s) then NN-informed placement heuristic.

+ + {optimizing &&
Randomizing layouts, keeping best...
} +
+ )} +
+ ); +} diff --git a/src/components/RoomCanvas.tsx b/src/components/RoomCanvas.tsx new file mode 100644 index 0000000..0447e31 --- /dev/null +++ b/src/components/RoomCanvas.tsx @@ -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(null); + const [drag, setDrag] = useState(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): 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) => { + 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) => { + 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 ( + + ); +} + +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; +} diff --git a/src/furniture/Furniture.ts b/src/furniture/Furniture.ts new file mode 100644 index 0000000..76db6c0 --- /dev/null +++ b/src/furniture/Furniture.ts @@ -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()); + } +} diff --git a/src/geometry/shapes.ts b/src/geometry/shapes.ts new file mode 100644 index 0000000..debc564 --- /dev/null +++ b/src/geometry/shapes.ts @@ -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); + } +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..45df9f9 --- /dev/null +++ b/src/index.css @@ -0,0 +1,5 @@ +html, body, #root { + margin: 0; + padding: 0; + height: 100%; +} diff --git a/src/layout/evaluator.ts b/src/layout/evaluator.ts new file mode 100644 index 0000000..c22f096 --- /dev/null +++ b/src/layout/evaluator.ts @@ -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 = {}; + + 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; + } +} diff --git a/src/optimization/randomSearch.ts b/src/optimization/randomSearch.ts new file mode 100644 index 0000000..b3d4f47 --- /dev/null +++ b/src/optimization/randomSearch.ts @@ -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()), + }; +} diff --git a/src/pathfinding/aStar.ts b/src/pathfinding/aStar.ts new file mode 100644 index 0000000..bd93682 --- /dev/null +++ b/src/pathfinding/aStar.ts @@ -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(); + 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); +} diff --git a/src/pathfinding/grid.ts b/src/pathfinding/grid.ts new file mode 100644 index 0000000..5111e6b --- /dev/null +++ b/src/pathfinding/grid.ts @@ -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 }; +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..739d167 --- /dev/null +++ b/src/types/index.ts @@ -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; +} + +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; + accessibility: AccessibilityResult[]; + penalty: number; +} + +export interface LayoutState { + room: RoomConfig; + entrance: Entrance | null; + furniture: FurnitureConfig[]; +}