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:
@@ -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>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user