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,148 @@
import { memo, useMemo } from 'react';
import { Layer, Text, Rect, Group, Line } from 'react-konva';
import type { Point, Annotation, ElectricalItem, FurnitureItem } from '@house-plan-maker/shared';
interface AnnotationLayerProps {
readonly annotations: readonly Annotation[];
readonly electricalItems: readonly ElectricalItem[];
readonly furnitureItems: readonly FurnitureItem[];
readonly zoom: number;
readonly panOffset: Point;
readonly selectedIds: ReadonlySet<string>;
readonly visible?: boolean;
readonly onDragEnd?: (id: string, x: number, y: number) => void;
readonly onDoubleClick?: (id: string) => void;
readonly onSelect?: (id: string) => void;
}
const DEFAULT_FONT_SIZE = 14;
const DEFAULT_COLOR = '#333333';
const SELECTED_COLOR = '#4c6ef5';
const SELECTION_PADDING = 4;
function toScreen(point: Point, zoom: number, panOffset: Point): { x: number; y: number } {
return {
x: point.x * zoom + panOffset.x,
y: point.y * zoom + panOffset.y,
};
}
export const AnnotationLayer = memo(function AnnotationLayer({
annotations,
electricalItems,
furnitureItems,
zoom,
panOffset,
selectedIds,
visible = true,
onDragEnd,
onDoubleClick,
onSelect,
}: AnnotationLayerProps) {
// Build item position lookup for attached annotations
const itemPositions = useMemo(() => {
const map = new Map<string, { x: number; y: number }>();
for (const item of electricalItems) {
map.set(item.id, { x: item.x, y: item.y });
}
for (const item of furnitureItems) {
map.set(item.id, { x: item.x + item.width / 2, y: item.y + item.depth / 2 });
}
return map;
}, [electricalItems, furnitureItems]);
const renderedAnnotations = useMemo(() => {
if (!visible) return [];
return annotations;
}, [annotations, visible]);
return (
<Layer visible={visible}>
{renderedAnnotations.map((annotation) => {
// Resolve position: if attached, offset from parent item
let worldX = annotation.x;
let worldY = annotation.y;
let parentScreen: { x: number; y: number } | null = null;
if (annotation.attachedToId) {
const parentPos = itemPositions.get(annotation.attachedToId);
if (parentPos) {
worldX = parentPos.x + annotation.x;
worldY = parentPos.y + annotation.y;
parentScreen = toScreen(parentPos, zoom, panOffset);
}
}
const screen = toScreen({ x: worldX, y: worldY }, zoom, panOffset);
const isSelected = selectedIds.has(annotation.id);
const fontSize = annotation.fontSize ?? DEFAULT_FONT_SIZE;
const color = isSelected ? SELECTED_COLOR : (annotation.color ?? DEFAULT_COLOR);
return (
<Group key={annotation.id}>
{/* Leader line from item to annotation */}
{parentScreen && (
<Line
points={[parentScreen.x, parentScreen.y, screen.x, screen.y]}
stroke={annotation.color ?? '#94a3b8'}
strokeWidth={1}
dash={[3, 3]}
listening={false}
/>
)}
<Group
x={screen.x}
y={screen.y}
draggable
onDragEnd={(e) => {
const node = e.target;
let newX = (node.x() - panOffset.x) / zoom;
let newY = (node.y() - panOffset.y) / zoom;
// For attached annotations, store as offset from parent
if (annotation.attachedToId) {
const parentPos = itemPositions.get(annotation.attachedToId);
if (parentPos) {
newX -= parentPos.x;
newY -= parentPos.y;
}
}
onDragEnd?.(annotation.id, newX, newY);
}}
onClick={() => onSelect?.(annotation.id)}
onDblClick={() => onDoubleClick?.(annotation.id)}
>
{/* Background */}
<Rect
x={-2}
y={-1}
width={annotation.text.length * fontSize * 0.6 + 4}
height={fontSize + 2}
fill="rgba(255,255,255,0.85)"
cornerRadius={2}
listening={false}
/>
{isSelected && (
<Rect
x={-SELECTION_PADDING}
y={-SELECTION_PADDING}
width={annotation.text.length * fontSize * 0.6 + SELECTION_PADDING * 2}
height={fontSize + SELECTION_PADDING * 2}
stroke={SELECTED_COLOR}
strokeWidth={1}
dash={[4, 2]}
fill="rgba(76, 110, 245, 0.05)"
/>
)}
<Text
text={annotation.text}
fontSize={fontSize}
fill={color}
fontFamily="sans-serif"
/>
</Group>
</Group>
);
})}
</Layer>
);
});