feat: complete house plan maker application

Full-featured house/apartment floor plan editor with:

- Turborepo monorepo (React/Vite client, Fastify/Prisma server, shared Zod schemas)
- 2D room editor with walls, doors, windows, furniture, electrical elements
- 3D room preview with Three.js (auto-hide nearest walls, bird's eye default)
- Wall projection views with interactive drag (elevation, position)
- Apartment floor plan view with room positioning
- Copy/paste, alignment tools, measurement tool, annotations
- Item-attached annotations with leader lines (visible on projections)
- Door open direction (LEFT/RIGHT/INWARD/OUTWARD) with swing arc
- Floor type textures (wood, tile, concrete, laminate, herringbone)
- Wall color picker for 3D view
- Furniture: bed, desk, wardrobe, sofa, table, chair, shelf, nightstand, dresser, bookcase, TV (with stand toggle), AC unit
- Furniture elevation support (wall-mounted items)
- Auto-save with dirty state tracking, batch save API
- Rotation-aware collision detection (SAT/OBB) with 3D elevation check
- Rotation-aware hit testing
- i18n (English/Russian) with locale-aware number formatting
- Dark mode with system preference detection
- Undo/redo, keyboard shortcuts, scale bar
- PDF/PNG/JSON export and JSON import
- Focus trap modal, toast notifications, tooltips
- Responsive layout with overlay palettes
This commit is contained in:
2026-04-05 22:34:03 +03:00
parent b84807bbdb
commit af8b9fe00f
188 changed files with 35795 additions and 0 deletions
@@ -0,0 +1,105 @@
import type { FurnitureItem } from '@house-plan-maker/shared';
interface OBB {
readonly id: string;
readonly cx: number;
readonly cy: number;
readonly halfW: number;
readonly halfD: number;
readonly cos: number;
readonly sin: number;
}
function computeOBB(item: FurnitureItem): OBB {
const rad = (item.rotation * Math.PI) / 180;
return {
id: item.id,
cx: item.x + item.width / 2,
cy: item.y + item.depth / 2,
halfW: item.width / 2,
halfD: item.depth / 2,
cos: Math.cos(rad),
sin: Math.sin(rad),
};
}
/** Get the 4 corners of an OBB. */
function getCorners(obb: OBB): [number, number][] {
const { cx, cy, halfW, halfD, cos, sin } = obb;
// Local corners at (±halfW, ±halfD), rotated and translated
return [
[cx + halfW * cos - halfD * sin, cy + halfW * sin + halfD * cos],
[cx - halfW * cos - halfD * sin, cy - halfW * sin + halfD * cos],
[cx - halfW * cos + halfD * sin, cy - halfW * sin - halfD * cos],
[cx + halfW * cos + halfD * sin, cy + halfW * sin - halfD * cos],
];
}
/** Project corners onto an axis and return [min, max]. */
function projectOntoAxis(corners: [number, number][], ax: number, ay: number): [number, number] {
let min = Infinity;
let max = -Infinity;
for (const [x, y] of corners) {
const p = x * ax + y * ay;
if (p < min) min = p;
if (p > max) max = p;
}
return [min, max];
}
/** SAT overlap test for two OBBs. */
function obbOverlap(a: OBB, b: OBB): boolean {
const cornersA = getCorners(a);
const cornersB = getCorners(b);
// 4 potential separating axes: 2 from each OBB's edges
const axes: [number, number][] = [
[a.cos, a.sin],
[-a.sin, a.cos],
[b.cos, b.sin],
[-b.sin, b.cos],
];
for (const [ax, ay] of axes) {
const [minA, maxA] = projectOntoAxis(cornersA, ax, ay);
const [minB, maxB] = projectOntoAxis(cornersB, ax, ay);
if (maxA <= minB || maxB <= minA) {
return false; // Separating axis found — no overlap
}
}
return true; // No separating axis — overlapping
}
/**
* Find all furniture IDs that collide using proper OBB (rotation-aware) overlap.
*/
export function findCollidingFurniture(
items: readonly FurnitureItem[],
): ReadonlySet<string> {
if (items.length < 2) return new Set();
const obbs = items.map(computeOBB);
const colliding = new Set<string>();
for (let i = 0; i < obbs.length; i++) {
for (let j = i + 1; j < obbs.length; j++) {
// Check vertical overlap first (elevation + height)
const a = items[i];
const b = items[j];
const aBottom = a.elevationFromFloor;
const aTop = a.elevationFromFloor + a.height;
const bBottom = b.elevationFromFloor;
const bTop = b.elevationFromFloor + b.height;
if (aTop <= bBottom || bTop <= aBottom) continue; // no vertical overlap
// Then check 2D footprint overlap
if (obbOverlap(obbs[i], obbs[j])) {
colliding.add(obbs[i].id);
colliding.add(obbs[j].id);
}
}
}
return colliding;
}