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,236 @@
import { memo, useMemo } from 'react';
import { Layer, Line, Group } from 'react-konva';
import type { Point, Wall } from '@house-plan-maker/shared';
import { polygonCentroid } from '../utils/geometry';
interface WallLayerProps {
readonly walls: readonly Wall[];
readonly roomShape: readonly Point[];
readonly zoom: number;
readonly panOffset: Point;
readonly selectedIds: ReadonlySet<string>;
readonly plinthThickness?: number;
}
/** Wall fill color. */
const WALL_FILL = '#495057';
/** Wall stroke color. */
const WALL_STROKE = '#343a40';
/** Room interior fill color. */
const ROOM_FILL = '#f8f9fa';
/** Room interior stroke. */
const ROOM_STROKE = '#dee2e6';
/** Selected wall highlight. */
const WALL_SELECTED_STROKE = '#4c6ef5';
/**
* Offset a polygon outward by a fixed distance.
* Uses the angle bisector at each vertex to push the point outward.
*/
function offsetPolygonOutward(shape: readonly Point[], thickness: number, centroid: Point): Point[] {
const n = shape.length;
if (n < 3) return [...shape];
const result: Point[] = [];
for (let i = 0; i < n; i++) {
const prev = shape[(i - 1 + n) % n];
const curr = shape[i];
const next = shape[(i + 1) % n];
// Edge vectors
const e1x = curr.x - prev.x;
const e1y = curr.y - prev.y;
const e2x = next.x - curr.x;
const e2y = next.y - curr.y;
// Outward normals for each edge (perpendicular, pointing away from centroid)
let n1x = -e1y;
let n1y = e1x;
// Normalize
const len1 = Math.sqrt(n1x * n1x + n1y * n1y) || 1;
n1x /= len1;
n1y /= len1;
let n2x = -e2y;
let n2y = e2x;
const len2 = Math.sqrt(n2x * n2x + n2y * n2y) || 1;
n2x /= len2;
n2y /= len2;
// Ensure normals point away from centroid
const midX = curr.x;
const midY = curr.y;
const toCenterX = centroid.x - midX;
const toCenterY = centroid.y - midY;
if (n1x * toCenterX + n1y * toCenterY > 0) {
n1x = -n1x;
n1y = -n1y;
}
if (n2x * toCenterX + n2y * toCenterY > 0) {
n2x = -n2x;
n2y = -n2y;
}
// Average bisector direction
let bx = n1x + n2x;
let by = n1y + n2y;
const bLen = Math.sqrt(bx * bx + by * by);
if (bLen < 0.001) {
// Parallel edges — just use one normal
bx = n1x;
by = n1y;
} else {
bx /= bLen;
by /= bLen;
// Scale by 1/cos(half-angle) to maintain thickness at the corner
const cosHalf = bx * n1x + by * n1y;
const scale = cosHalf > 0.1 ? 1 / cosHalf : 1;
bx *= Math.min(scale, 3); // cap to prevent extreme spikes
by *= Math.min(scale, 3);
}
result.push({
x: curr.x + bx * thickness,
y: curr.y + by * thickness,
});
}
return result;
}
/** Plinth color. */
const PLINTH_COLOR = '#8b7355';
export const WallLayer = memo(function WallLayer({
walls,
roomShape,
zoom,
panOffset,
selectedIds,
plinthThickness = 0.01,
}: WallLayerProps) {
// Get wall thickness (use first wall's thickness as representative)
const wallThickness = walls.length > 0 ? walls[0].thickness : 0.1;
// Convert room shape to screen coordinates
const roomShapeScreen = useMemo(() => {
if (roomShape.length < 3) return [];
return roomShape.flatMap((p) => [
p.x * zoom + panOffset.x,
p.y * zoom + panOffset.y,
]);
}, [roomShape, zoom, panOffset]);
// Compute outer wall boundary (room shape offset outward by wall thickness)
const outerWallScreen = useMemo(() => {
if (roomShape.length < 3) return [];
const centroid = polygonCentroid(roomShape);
const outer = offsetPolygonOutward(roomShape, wallThickness, centroid);
return outer.flatMap((p) => [
p.x * zoom + panOffset.x,
p.y * zoom + panOffset.y,
]);
}, [roomShape, wallThickness, zoom, panOffset]);
// Compute inner plinth boundary (room shape offset inward by plinth thickness)
const plinthScreen = useMemo(() => {
if (roomShape.length < 3 || plinthThickness <= 0) return [];
const centroid = polygonCentroid(roomShape);
// Offset inward (toward centroid) = negative outward offset
const inner = offsetPolygonOutward(roomShape, -plinthThickness, centroid);
return inner.flatMap((p) => [
p.x * zoom + panOffset.x,
p.y * zoom + panOffset.y,
]);
}, [roomShape, plinthThickness, zoom, panOffset]);
// Selected wall segments for highlighting
const selectedWallSegments = useMemo(() => {
return walls
.filter((w) => selectedIds.has(w.id))
.map((wall) => {
const points = [
wall.startX * zoom + panOffset.x,
wall.startY * zoom + panOffset.y,
wall.endX * zoom + panOffset.x,
wall.endY * zoom + panOffset.y,
];
return { wall, points };
});
}, [walls, selectedIds, zoom, panOffset]);
return (
<Layer>
{/* Room interior fill */}
{roomShapeScreen.length >= 6 && (
<Line
points={roomShapeScreen}
closed
fill={ROOM_FILL}
stroke={ROOM_STROKE}
strokeWidth={1}
listening={false}
/>
)}
{/* Outer wall boundary — single continuous polygon, no corner gaps */}
{outerWallScreen.length >= 6 && (
<Group listening={false}>
{/* Outer fill */}
<Line
points={outerWallScreen}
closed
fill={WALL_FILL}
stroke={WALL_STROKE}
strokeWidth={1}
listening={false}
/>
{/* Cut out room interior by drawing room shape on top with room fill */}
<Line
points={roomShapeScreen}
closed
fill={ROOM_FILL}
stroke={WALL_STROKE}
strokeWidth={1}
listening={false}
/>
</Group>
)}
{/* Plinth strip along inside of walls */}
{plinthScreen.length >= 6 && roomShapeScreen.length >= 6 && (
<Group listening={false}>
{/* Draw room shape filled with plinth color */}
<Line
points={roomShapeScreen}
closed
fill={PLINTH_COLOR}
listening={false}
/>
{/* Cut out inner area (room minus plinth) */}
<Line
points={plinthScreen}
closed
fill={ROOM_FILL}
listening={false}
/>
</Group>
)}
{/* Selected wall highlights */}
{selectedWallSegments.map(({ wall, points }) => (
<Line
key={`sel-${wall.id}`}
points={points}
stroke={WALL_SELECTED_STROKE}
strokeWidth={wallThickness * zoom + 2}
lineCap="square"
listening={false}
/>
))}
</Layer>
);
});