Add dynamic SAT collision, oval/circle shapes, rotation support, and score display
This commit is contained in:
Vendored
+1
-1
@@ -5,7 +5,7 @@
|
||||
<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-BNpMmn-1.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-z6fd8Sn7.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-9k24Ci9h.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+14
-6
@@ -1,6 +1,7 @@
|
||||
import type { LayoutEvaluation, LayoutState } from '../types';
|
||||
import { evaluateLayout } from '../layout/evaluator';
|
||||
import { entranceRect, findFreeSpot, overlapsAny, resolveMove } from '../geometry/bounds';
|
||||
import { entranceCollider, findFreeSpot, furnitureCollider, overlapsAny, resolveMove } from '../geometry/bounds';
|
||||
import type { Collider } from '../geometry/collision';
|
||||
|
||||
export interface NNOptions {
|
||||
gridSize?: number;
|
||||
@@ -57,14 +58,21 @@ export function optimizeWithNN(state: LayoutState, options: NNOptions = {}): { b
|
||||
if (targetX < 0) targetX = 0;
|
||||
if (targetY < 0) targetY = 0;
|
||||
const room = candidate.room;
|
||||
const others: any[] = candidate.furniture
|
||||
const others: Collider[] = candidate.furniture
|
||||
.filter((o) => o.id !== f.id)
|
||||
.map((o) => ({ x: o.shape.x, y: o.shape.y, width: o.shape.width, height: o.shape.height }));
|
||||
const ent = entranceRect(candidate);
|
||||
.map(furnitureCollider);
|
||||
const ent = entranceCollider(candidate);
|
||||
if (ent) others.push(ent);
|
||||
const b = overlapsAny({ x: targetX, y: targetY, width: f.shape.width, height: f.shape.height }, others)
|
||||
const moving: Collider = {
|
||||
x: targetX,
|
||||
y: targetY,
|
||||
width: f.shape.width,
|
||||
height: f.shape.height,
|
||||
rotation: f.shape.rotation ?? 0,
|
||||
};
|
||||
const b = overlapsAny(moving, others)
|
||||
? findFreeSpot(room, { width: f.shape.width, height: f.shape.height }, others)
|
||||
: resolveMove({ x: targetX, y: targetY, width: f.shape.width, height: f.shape.height }, others, room);
|
||||
: resolveMove(moving, others, room);
|
||||
f.shape.x = b.x;
|
||||
f.shape.y = b.y;
|
||||
f.shape.width = b.width;
|
||||
|
||||
@@ -189,6 +189,26 @@ export function ControlPanel({
|
||||
onChange={(v) => updateObject(p.id, { shape: { ...p.shape, height: v } })}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Shape</span>
|
||||
<select
|
||||
value={p.shape.type === 'circle' ? 'oval' : p.shape.type}
|
||||
onChange={(e) =>
|
||||
updateObject(p.id, { shape: { ...p.shape, type: e.target.value as 'rectangle' | 'oval' } })
|
||||
}
|
||||
>
|
||||
<option value="rectangle">Rectangle</option>
|
||||
<option value="oval">Oval</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Rotation</span>
|
||||
<NumberField
|
||||
value={p.shape.rotation ?? 0}
|
||||
width="100%"
|
||||
onChange={(v) => updateObject(p.id, { shape: { ...p.shape, rotation: v } })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="row" style={{ marginTop: 10 }}>
|
||||
<span className="hint" style={{ margin: 0 }}>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { LayoutState, Point } from '../types';
|
||||
import { computePaths } from '../pathfinding/paths';
|
||||
import { resolveMove } from '../geometry/bounds';
|
||||
import { furnitureCollider, resolveMove } from '../geometry/bounds';
|
||||
import { makePlaced } from '../optimization/prepare';
|
||||
import { Shape } from '../geometry/shapes';
|
||||
|
||||
interface RoomCanvasProps {
|
||||
state: LayoutState;
|
||||
@@ -115,14 +116,30 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, s
|
||||
}
|
||||
|
||||
for (const f of state.furniture) {
|
||||
const b = { x: f.shape.x, y: f.shape.y, w: f.shape.width, h: f.shape.height };
|
||||
const r = Math.min(8, b.w / 4, b.h / 4);
|
||||
const selected = selectedId === f.id;
|
||||
const cx = f.shape.x + f.shape.width / 2;
|
||||
const cy = f.shape.y + f.shape.height / 2;
|
||||
const rot = ((f.shape.rotation ?? 0) * Math.PI) / 180;
|
||||
const isOval = f.shape.type === 'oval' || f.shape.type === 'circle';
|
||||
|
||||
ctx.globalAlpha = f.lockedForOptimization && mode === 'optimize' ? 0.55 : 1;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
ctx.rotate(rot);
|
||||
ctx.beginPath();
|
||||
roundRect(ctx, b.x, b.y, b.w, b.h, r);
|
||||
if (isOval) {
|
||||
ctx.ellipse(0, 0, f.shape.width / 2, f.shape.height / 2, 0, 0, Math.PI * 2);
|
||||
} else {
|
||||
roundRect(
|
||||
ctx,
|
||||
-f.shape.width / 2,
|
||||
-f.shape.height / 2,
|
||||
f.shape.width,
|
||||
f.shape.height,
|
||||
Math.min(6, f.shape.width / 2, f.shape.height / 2),
|
||||
);
|
||||
}
|
||||
ctx.fillStyle = f.color || '#9aa0a6';
|
||||
ctx.fill();
|
||||
|
||||
@@ -138,17 +155,18 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, s
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.72)';
|
||||
ctx.font = '600 11px Inter, sans-serif';
|
||||
ctx.textBaseline = 'middle';
|
||||
const label = b.w > 44 ? f.name.substring(0, 14) : '';
|
||||
if (label) ctx.fillText(label, b.x + 7, b.y + b.h / 2);
|
||||
const label = f.shape.width > 44 ? f.name.substring(0, 14) : '';
|
||||
if (label) ctx.fillText(label, f.shape.x + 7, cy);
|
||||
|
||||
if (f.lockedForOptimization) {
|
||||
ctx.font = '10px Inter, sans-serif';
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.55)';
|
||||
ctx.fillText('🔒', b.x + b.w - 15, b.y + 9);
|
||||
ctx.fillText('🔒', f.shape.x + f.shape.width - 15, f.shape.y + 9);
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
@@ -187,7 +205,8 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, s
|
||||
}
|
||||
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 })) {
|
||||
const shape = new Shape(f.shape);
|
||||
if (shape.containsPoint(pos)) {
|
||||
onSelect(f.id);
|
||||
setDrag({ id: f.id, offsetX: pos.x - f.shape.x, offsetY: pos.y - f.shape.y });
|
||||
return;
|
||||
@@ -202,12 +221,7 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, s
|
||||
const pos = getMousePos(e);
|
||||
if (drag.id === '__entrance__' && state.entrance) {
|
||||
const en = state.entrance;
|
||||
const others = state.furniture.map((o) => ({
|
||||
x: o.shape.x,
|
||||
y: o.shape.y,
|
||||
width: o.shape.width,
|
||||
height: o.shape.height,
|
||||
}));
|
||||
const others = state.furniture.map(furnitureCollider);
|
||||
if (drag.resizing) {
|
||||
const b = resolveMove(
|
||||
{ x: en.x, y: en.y, width: pos.x - en.x, height: pos.y - en.y },
|
||||
@@ -229,9 +243,7 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, s
|
||||
}
|
||||
const updated = state.furniture.map((f) => {
|
||||
if (f.id !== drag.id) return f;
|
||||
const others: any[] = state.furniture
|
||||
.filter((o) => o.id !== drag.id)
|
||||
.map((o) => ({ x: o.shape.x, y: o.shape.y, width: o.shape.width, height: o.shape.height }));
|
||||
const others = state.furniture.filter((o) => o.id !== drag.id).map(furnitureCollider);
|
||||
if (state.entrance) {
|
||||
const e = state.entrance;
|
||||
others.push({ x: e.x, y: e.y, width: e.width ?? 20, height: e.height ?? 20 });
|
||||
@@ -242,6 +254,7 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, s
|
||||
y: pos.y - drag.offsetY,
|
||||
width: f.shape.width,
|
||||
height: f.shape.height,
|
||||
rotation: f.shape.rotation ?? 0,
|
||||
},
|
||||
others,
|
||||
state.room,
|
||||
@@ -278,7 +291,18 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect, s
|
||||
const preset = (state.presets || []).find((p) => p.id === presetId);
|
||||
if (!preset) return;
|
||||
const pos = getMousePos(e);
|
||||
const f = makePlaced(preset, pos.x - preset.shape.width / 2, pos.y - preset.shape.height / 2, state.room);
|
||||
const others: import('../geometry/collision').Collider[] = state.furniture.map(furnitureCollider);
|
||||
if (state.entrance) {
|
||||
const en = state.entrance;
|
||||
others.push({ x: en.x, y: en.y, width: en.width ?? 20, height: en.height ?? 20 });
|
||||
}
|
||||
const f = makePlaced(
|
||||
preset,
|
||||
pos.x - preset.shape.width / 2,
|
||||
pos.y - preset.shape.height / 2,
|
||||
state.room,
|
||||
others,
|
||||
);
|
||||
onStateChange({ ...state, furniture: [...state.furniture, f] });
|
||||
onSelect(f.id);
|
||||
};
|
||||
|
||||
+29
-34
@@ -1,4 +1,8 @@
|
||||
import type { Entrance, FurnitureConfig, LayoutState, Rect, RoomConfig, Size } from '../types';
|
||||
import type { LayoutState, RoomConfig } from '../types';
|
||||
import { colliderCenter, satMTV, type Collider } from './collision';
|
||||
import { overlapsAny } from './collision';
|
||||
|
||||
export { overlapsAny };
|
||||
|
||||
export interface Bounds {
|
||||
x: number;
|
||||
@@ -7,24 +11,24 @@ export interface Bounds {
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function clampRectToRoom(rect: Bounds, room: RoomConfig, minSize = 10): Bounds {
|
||||
export function clampRectToRoom(rect: Collider, room: RoomConfig, minSize = 10): Collider {
|
||||
const width = Math.min(Math.max(minSize, rect.width), room.width);
|
||||
const height = Math.min(Math.max(minSize, rect.height), room.height);
|
||||
const x = Math.min(Math.max(0, rect.x), Math.max(0, room.width - width));
|
||||
const y = Math.min(Math.max(0, rect.y), Math.max(0, room.height - height));
|
||||
return { x, y, width, height };
|
||||
return { ...rect, x, y, width, height };
|
||||
}
|
||||
|
||||
export function clampStateToRoom(state: LayoutState): LayoutState {
|
||||
const room = state.room;
|
||||
const furniture: FurnitureConfig[] = state.furniture.map((f) => {
|
||||
const furniture = state.furniture.map((f) => {
|
||||
const b = clampRectToRoom(
|
||||
{ x: f.shape.x, y: f.shape.y, width: f.shape.width, height: f.shape.height },
|
||||
room,
|
||||
);
|
||||
return { ...f, shape: { ...f.shape, ...b } };
|
||||
return { ...f, shape: { ...f.shape, x: b.x, y: b.y, width: b.width, height: b.height } };
|
||||
});
|
||||
let entrance: Entrance | null = state.entrance;
|
||||
let entrance = state.entrance;
|
||||
if (entrance) {
|
||||
const ev = clampRectToRoom(
|
||||
{ x: entrance.x, y: entrance.y, width: entrance.width ?? 20, height: entrance.height ?? 20 },
|
||||
@@ -36,32 +40,22 @@ export function clampStateToRoom(state: LayoutState): LayoutState {
|
||||
return { ...state, furniture, entrance };
|
||||
}
|
||||
|
||||
export function rectsOverlap(a: Rect, b: Rect): boolean {
|
||||
return !(a.x + a.width <= b.x || b.x + b.width <= a.x || a.y + a.height <= b.y || b.y + b.height <= a.y);
|
||||
}
|
||||
|
||||
export function overlapsAny(rect: Rect, others: Rect[]): boolean {
|
||||
return others.some((o) => rectsOverlap(rect, o));
|
||||
}
|
||||
|
||||
export function resolveMove(moving: Bounds, others: Rect[], room: RoomConfig, minSize = 10): Bounds {
|
||||
export function resolveMove(moving: Collider, others: Collider[], room: RoomConfig, minSize = 10): Collider {
|
||||
let r = clampRectToRoom(moving, room, minSize);
|
||||
for (let iter = 0; iter < 12; iter++) {
|
||||
let pushed = false;
|
||||
for (const o of others) {
|
||||
if (!rectsOverlap(r, o)) continue;
|
||||
const overlapLeft = r.x + r.width - o.x;
|
||||
const overlapRight = o.x + o.width - r.x;
|
||||
const overlapTop = r.y + r.height - o.y;
|
||||
const overlapBottom = o.y + o.height - r.y;
|
||||
const minX = Math.min(overlapLeft, overlapRight);
|
||||
const minY = Math.min(overlapTop, overlapBottom);
|
||||
if (minX < minY) {
|
||||
r.x = overlapLeft < overlapRight ? o.x - r.width : o.x + o.width;
|
||||
} else {
|
||||
r.y = overlapTop < overlapBottom ? o.y - r.height : o.y + o.height;
|
||||
}
|
||||
r = clampRectToRoom(r, room, minSize);
|
||||
const mtv = satMTV(r, o);
|
||||
if (!mtv) continue;
|
||||
const cM = colliderCenter(r);
|
||||
const cO = colliderCenter(o);
|
||||
const dir = (cM.x - cO.x) * mtv.axis.x + (cM.y - cO.y) * mtv.axis.y;
|
||||
const sign = dir >= 0 ? 1 : -1;
|
||||
r = clampRectToRoom(
|
||||
{ ...r, x: r.x + sign * mtv.axis.x * mtv.depth, y: r.y + sign * mtv.axis.y * mtv.depth },
|
||||
room,
|
||||
minSize,
|
||||
);
|
||||
pushed = true;
|
||||
}
|
||||
if (!pushed) break;
|
||||
@@ -69,7 +63,7 @@ export function resolveMove(moving: Bounds, others: Rect[], room: RoomConfig, mi
|
||||
return r;
|
||||
}
|
||||
|
||||
export function findFreeSpot(room: RoomConfig, size: Size, others: Rect[], tries = 40): Bounds {
|
||||
export function findFreeSpot(room: RoomConfig, size: { width: number; height: number }, others: Collider[], tries = 40): Collider {
|
||||
for (let i = 0; i < tries; i++) {
|
||||
const candidate = clampRectToRoom(
|
||||
{
|
||||
@@ -84,13 +78,10 @@ export function findFreeSpot(room: RoomConfig, size: Size, others: Rect[], tries
|
||||
const resolved = resolveMove(candidate, others, room);
|
||||
if (!overlapsAny(resolved, others)) return resolved;
|
||||
}
|
||||
return clampRectToRoom(
|
||||
{ x: 0, y: 0, width: size.width, height: size.height },
|
||||
room,
|
||||
);
|
||||
return clampRectToRoom({ x: 0, y: 0, width: size.width, height: size.height }, room);
|
||||
}
|
||||
|
||||
export function entranceRect(state: LayoutState): Rect | null {
|
||||
export function entranceCollider(state: LayoutState): Collider | null {
|
||||
if (!state.entrance) return null;
|
||||
return {
|
||||
x: state.entrance.x,
|
||||
@@ -99,3 +90,7 @@ export function entranceRect(state: LayoutState): Rect | null {
|
||||
height: state.entrance.height ?? 20,
|
||||
};
|
||||
}
|
||||
|
||||
export function furnitureCollider(f: { shape: { x: number; y: number; width: number; height: number; rotation?: number } }): Collider {
|
||||
return { x: f.shape.x, y: f.shape.y, width: f.shape.width, height: f.shape.height, rotation: f.shape.rotation };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Point } from '../types';
|
||||
|
||||
export interface Collider {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation?: number;
|
||||
}
|
||||
|
||||
export function colliderCenter(c: Collider): Point {
|
||||
return { x: c.x + c.width / 2, y: c.y + c.height / 2 };
|
||||
}
|
||||
|
||||
export function colliderCorners(c: Collider): Point[] {
|
||||
const cx = c.x + c.width / 2;
|
||||
const cy = c.y + c.height / 2;
|
||||
const rad = ((c.rotation ?? 0) * Math.PI) / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
const hw = c.width / 2;
|
||||
const hh = c.height / 2;
|
||||
const local: Point[] = [
|
||||
{ x: -hw, y: -hh },
|
||||
{ x: hw, y: -hh },
|
||||
{ x: hw, y: hh },
|
||||
{ x: -hw, y: hh },
|
||||
];
|
||||
return local.map((p) => ({
|
||||
x: cx + p.x * cos - p.y * sin,
|
||||
y: cy + p.x * sin + p.y * cos,
|
||||
}));
|
||||
}
|
||||
|
||||
function project(points: Point[], axis: Point): [number, number] {
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const p of points) {
|
||||
const d = p.x * axis.x + p.y * axis.y;
|
||||
if (d < min) min = d;
|
||||
if (d > max) max = d;
|
||||
}
|
||||
return [min, max];
|
||||
}
|
||||
|
||||
function axesOf(corners: Point[]): Point[] {
|
||||
const edges: Point[] = [
|
||||
{ x: corners[1].x - corners[0].x, y: corners[1].y - corners[0].y },
|
||||
{ x: corners[3].x - corners[0].x, y: corners[3].y - corners[0].y },
|
||||
];
|
||||
return edges.map((e) => {
|
||||
const len = Math.hypot(e.x, e.y) || 1;
|
||||
return { x: -e.y / len, y: e.x / len };
|
||||
});
|
||||
}
|
||||
|
||||
export function satMTV(a: Collider, b: Collider): { axis: Point; depth: number } | null {
|
||||
const ca = colliderCorners(a);
|
||||
const cb = colliderCorners(b);
|
||||
const axes = [...axesOf(ca), ...axesOf(cb)];
|
||||
let minOverlap = Infinity;
|
||||
let axis: Point = { x: 0, y: 0 };
|
||||
for (const ax of axes) {
|
||||
const [amin, amax] = project(ca, ax);
|
||||
const [bmin, bmax] = project(cb, ax);
|
||||
const overlap = Math.min(amax, bmax) - Math.max(amin, bmin);
|
||||
if (overlap <= 0) return null;
|
||||
if (overlap < minOverlap) {
|
||||
minOverlap = overlap;
|
||||
axis = ax;
|
||||
}
|
||||
}
|
||||
return { axis, depth: minOverlap };
|
||||
}
|
||||
|
||||
export function collidersOverlap(a: Collider, b: Collider): boolean {
|
||||
return satMTV(a, b) !== null;
|
||||
}
|
||||
|
||||
export function overlapsAny(c: Collider, others: Collider[]): boolean {
|
||||
return others.some((o) => collidersOverlap(c, o));
|
||||
}
|
||||
|
||||
export function containsWorldPoint(c: Collider, p: Point): boolean {
|
||||
const center = colliderCenter(c);
|
||||
const rad = (-(c.rotation ?? 0) * Math.PI) / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
const dx = p.x - center.x;
|
||||
const dy = p.y - center.y;
|
||||
const lx = dx * cos - dy * sin;
|
||||
const ly = dx * sin + dy * cos;
|
||||
return Math.abs(lx) <= c.width / 2 && Math.abs(ly) <= c.height / 2;
|
||||
}
|
||||
+39
-28
@@ -1,4 +1,5 @@
|
||||
import type { Point, Rect, ShapeData } from '../types';
|
||||
import { colliderCorners, containsWorldPoint } from './collision';
|
||||
|
||||
export class Shape {
|
||||
type: ShapeData['type'];
|
||||
@@ -10,7 +11,7 @@ export class Shape {
|
||||
points: Point[];
|
||||
|
||||
constructor(data: ShapeData) {
|
||||
this.type = data.type;
|
||||
this.type = data.type === 'circle' ? 'oval' : data.type;
|
||||
this.x = data.x;
|
||||
this.y = data.y;
|
||||
this.width = Math.max(1, data.width);
|
||||
@@ -31,15 +32,12 @@ export class Shape {
|
||||
});
|
||||
}
|
||||
|
||||
getCenter(): Point {
|
||||
return { x: this.x + this.width / 2, y: this.y + this.height / 2 };
|
||||
}
|
||||
|
||||
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) {
|
||||
if (this.type === 'polygon' && 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);
|
||||
@@ -48,22 +46,26 @@ export class Shape {
|
||||
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 };
|
||||
if (this.rotation % 360 === 0) {
|
||||
return { x: this.x, y: this.y, width: this.width, height: this.height };
|
||||
}
|
||||
const corners = colliderCorners({
|
||||
x: this.x,
|
||||
y: this.y,
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
rotation: this.rotation,
|
||||
});
|
||||
const xs = corners.map((p) => p.x);
|
||||
const ys = corners.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 };
|
||||
}
|
||||
|
||||
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++) {
|
||||
@@ -76,11 +78,20 @@ export class Shape {
|
||||
}
|
||||
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);
|
||||
const c = { x: this.x, y: this.y, width: this.width, height: this.height, rotation: this.rotation };
|
||||
if (this.type === 'oval') {
|
||||
const center = this.getCenter();
|
||||
const rad = (-this.rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
const dx = p.x - center.x;
|
||||
const dy = p.y - center.y;
|
||||
const lx = dx * cos - dy * sin;
|
||||
const ly = dx * sin + dy * cos;
|
||||
const rx = this.width / 2;
|
||||
const ry = this.height / 2;
|
||||
return (lx * lx) / (rx * rx) + (ly * ly) / (ry * ry) <= 1 + 1e-8;
|
||||
}
|
||||
return containsWorldPoint(c, p);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { AccessibilityResult, FurnitureConfig, LayoutEvaluation, LayoutState, Point, Rect } from '../types';
|
||||
import { buildGrid, gridToWorld, nearestWalkable } from '../pathfinding/grid';
|
||||
import { aStar } from '../pathfinding/aStar';
|
||||
import { entranceRect, rectsOverlap } from '../geometry/bounds';
|
||||
import { entranceCollider, furnitureCollider } from '../geometry/bounds';
|
||||
import { collidersOverlap } from '../geometry/collision';
|
||||
|
||||
export function evaluateLayout(state: LayoutState, gridSize: number = 10): LayoutEvaluation {
|
||||
const obstacles: Rect[] = state.furniture
|
||||
@@ -19,15 +20,16 @@ export function evaluateLayout(state: LayoutState, gridSize: number = 10): Layou
|
||||
let totalConditionScore = 0;
|
||||
const conditionScores: Record<string, number> = {};
|
||||
|
||||
for (let i = 0; i < obstacles.length; i++) {
|
||||
for (let j = i + 1; j < obstacles.length; j++) {
|
||||
if (rectsOverlap(obstacles[i], obstacles[j])) penalty += 20;
|
||||
const colliders = state.furniture.map(furnitureCollider);
|
||||
for (let i = 0; i < colliders.length; i++) {
|
||||
for (let j = i + 1; j < colliders.length; j++) {
|
||||
if (collidersOverlap(colliders[i], colliders[j])) penalty += 20;
|
||||
}
|
||||
}
|
||||
const entrance = entranceRect(state);
|
||||
const entrance = entranceCollider(state);
|
||||
if (entrance) {
|
||||
for (const o of obstacles) {
|
||||
if (rectsOverlap(o, entrance)) penalty += 20;
|
||||
for (const c of colliders) {
|
||||
if (collidersOverlap(c, entrance)) penalty += 20;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-12
@@ -1,18 +1,19 @@
|
||||
import type { FurnitureConfig, FurniturePreset, LayoutState, Rect, RoomConfig } from '../types';
|
||||
import { clampStateToRoom, entranceRect, findFreeSpot, resolveMove } from '../geometry/bounds';
|
||||
import type { FurnitureConfig, FurniturePreset, LayoutState, RoomConfig } from '../types';
|
||||
import { clampStateToRoom, entranceCollider, findFreeSpot, furnitureCollider, resolveMove } from '../geometry/bounds';
|
||||
import type { Collider } from '../geometry/collision';
|
||||
|
||||
export function makeGenerated(
|
||||
preset: FurniturePreset,
|
||||
index: number,
|
||||
room: RoomConfig,
|
||||
others: Rect[] = [],
|
||||
others: Collider[] = [],
|
||||
): FurnitureConfig {
|
||||
const b = findFreeSpot(room, { width: preset.shape.width, height: preset.shape.height }, others);
|
||||
return {
|
||||
id: `${preset.id}_gen_${Date.now()}_${index}`,
|
||||
name: preset.name,
|
||||
type: preset.id,
|
||||
shape: { ...preset.shape, ...b },
|
||||
shape: { ...preset.shape, x: b.x, y: b.y, width: b.width, height: b.height },
|
||||
conditions: preset.conditions.map((c) => ({ ...c, params: { ...(c.params || {}) } })),
|
||||
color: preset.color,
|
||||
placed: false,
|
||||
@@ -24,14 +25,18 @@ export function makePlaced(
|
||||
x: number,
|
||||
y: number,
|
||||
room: RoomConfig,
|
||||
others: Rect[] = [],
|
||||
others: Collider[] = [],
|
||||
): FurnitureConfig {
|
||||
const b = resolveMove({ x, y, width: preset.shape.width, height: preset.shape.height }, others, room);
|
||||
const b = resolveMove(
|
||||
{ x, y, width: preset.shape.width, height: preset.shape.height, rotation: preset.shape.rotation },
|
||||
others,
|
||||
room,
|
||||
);
|
||||
return {
|
||||
id: `${preset.id}_placed_${Date.now()}_${Math.round(Math.random() * 1e6)}`,
|
||||
name: preset.name,
|
||||
type: preset.id,
|
||||
shape: { ...preset.shape, ...b },
|
||||
shape: { ...preset.shape, x: b.x, y: b.y, width: b.width, height: b.height },
|
||||
conditions: preset.conditions.map((c) => ({ ...c, params: { ...(c.params || {}) } })),
|
||||
color: preset.color,
|
||||
placed: true,
|
||||
@@ -41,7 +46,7 @@ export function makePlaced(
|
||||
|
||||
export function prepareForOptimize(state: LayoutState): LayoutState {
|
||||
const presets = state.presets || [];
|
||||
const entrance = entranceRect(state);
|
||||
const entrance = entranceCollider(state);
|
||||
let furniture = [...state.furniture];
|
||||
|
||||
for (const preset of presets) {
|
||||
@@ -55,12 +60,12 @@ export function prepareForOptimize(state: LayoutState): LayoutState {
|
||||
generated = generated.slice(0, need);
|
||||
}
|
||||
while (generated.length < need) {
|
||||
const others: Rect[] = [
|
||||
const others: Collider[] = [
|
||||
...placed,
|
||||
...generated,
|
||||
...furniture.filter((f) => f.type !== preset.id),
|
||||
...(entrance ? [entrance] : []),
|
||||
].map((f: any) => ({ x: f.shape.x, y: f.shape.y, width: f.shape.width, height: f.shape.height }));
|
||||
].map(furnitureCollider);
|
||||
if (entrance) others.push(entrance);
|
||||
generated.push(makeGenerated(preset, generated.length, state.room, others));
|
||||
}
|
||||
|
||||
@@ -68,7 +73,13 @@ export function prepareForOptimize(state: LayoutState): LayoutState {
|
||||
...f,
|
||||
name: preset.name,
|
||||
color: preset.color,
|
||||
shape: { ...f.shape, width: preset.shape.width, height: preset.shape.height },
|
||||
shape: {
|
||||
...f.shape,
|
||||
type: preset.shape.type,
|
||||
width: preset.shape.width,
|
||||
height: preset.shape.height,
|
||||
rotation: preset.shape.rotation ?? 0,
|
||||
},
|
||||
conditions: preset.conditions.map((c) => ({ ...c, params: { ...(c.params || {}) } })),
|
||||
lockedForOptimization: !!f.placed,
|
||||
}));
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { LayoutEvaluation, LayoutState, Rect } from '../types';
|
||||
import type { LayoutEvaluation, LayoutState } from '../types';
|
||||
import { evaluateLayout } from '../layout/evaluator';
|
||||
import { Furniture } from '../furniture/Furniture';
|
||||
import { entranceRect, findFreeSpot } from '../geometry/bounds';
|
||||
import { entranceCollider, findFreeSpot, furnitureCollider } from '../geometry/bounds';
|
||||
import type { Collider } from '../geometry/collision';
|
||||
|
||||
export interface RandomSearchOptions {
|
||||
iterations: number;
|
||||
@@ -20,12 +21,12 @@ export function runRandomSearch(
|
||||
for (let i = 0; i < options.iterations; i++) {
|
||||
const candidate = cloneState(bestState);
|
||||
const room = { width: options.roomWidth, height: options.roomHeight };
|
||||
const entrance = entranceRect(candidate);
|
||||
const entrance = entranceCollider(candidate);
|
||||
for (const f of candidate.furniture) {
|
||||
if (f.lockedForOptimization) continue;
|
||||
const others: Rect[] = candidate.furniture
|
||||
const others: Collider[] = candidate.furniture
|
||||
.filter((o) => o.id !== f.id)
|
||||
.map((o) => ({ x: o.shape.x, y: o.shape.y, width: o.shape.width, height: o.shape.height }));
|
||||
.map(furnitureCollider);
|
||||
if (entrance) others.push(entrance);
|
||||
const b = findFreeSpot(room, { width: f.shape.width, height: f.shape.height }, others);
|
||||
f.shape.x = b.x;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
export type ShapeType = 'rectangle' | 'circle' | 'polygon';
|
||||
export type ShapeType = 'rectangle' | 'oval' | 'circle' | 'polygon';
|
||||
|
||||
export interface Point {
|
||||
x: number;
|
||||
|
||||
Reference in New Issue
Block a user