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,241 @@
import type { ReactNode } from 'react';
import { Group, Line, Text } from 'react-konva';
import type { ProjectedOpening, ProjectedElectrical } from '../utils/projectionMapping';
import { projectionToPixel } from '../utils/projectionMapping';
interface ProjectionMeasurementsProps {
readonly projectedOpenings: readonly ProjectedOpening[];
readonly projectedElectrical: readonly ProjectedElectrical[];
readonly wallLength: number;
readonly wallHeight: number;
readonly scale: number;
readonly padding: number;
}
/** Dimension line with arrows and text. */
function DimensionLine({
x1, y1, x2, y2, label, offset, horizontal,
}: {
readonly x1: number;
readonly y1: number;
readonly x2: number;
readonly y2: number;
readonly label: string;
readonly offset: number;
readonly horizontal: boolean;
}) {
const arrowSize = 4;
if (horizontal) {
const lineY = y1 + offset;
const midX = (x1 + x2) / 2;
return (
<Group>
{/* Extension lines */}
<Line points={[x1, y1, x1, lineY]} stroke="#94a3b8" strokeWidth={0.5} />
<Line points={[x2, y2, x2, lineY]} stroke="#94a3b8" strokeWidth={0.5} />
{/* Main line */}
<Line points={[x1, lineY, x2, lineY]} stroke="#94a3b8" strokeWidth={0.75} />
{/* Arrows */}
<Line
points={[x1, lineY, x1 + arrowSize, lineY - arrowSize / 2, x1 + arrowSize, lineY + arrowSize / 2]}
fill="#94a3b8"
closed
/>
<Line
points={[x2, lineY, x2 - arrowSize, lineY - arrowSize / 2, x2 - arrowSize, lineY + arrowSize / 2]}
fill="#94a3b8"
closed
/>
{/* Label */}
<Text
x={midX - 20}
y={lineY - 12}
width={40}
text={label}
align="center"
fontSize={9}
fill="#64748b"
/>
</Group>
);
}
// Vertical dimension
const lineX = x1 + offset;
const midY = (y1 + y2) / 2;
return (
<Group>
<Line points={[x1, y1, lineX, y1]} stroke="#94a3b8" strokeWidth={0.5} />
<Line points={[x1, y2, lineX, y2]} stroke="#94a3b8" strokeWidth={0.5} />
<Line points={[lineX, y1, lineX, y2]} stroke="#94a3b8" strokeWidth={0.75} />
<Line
points={[lineX, y1, lineX - arrowSize / 2, y1 + arrowSize, lineX + arrowSize / 2, y1 + arrowSize]}
fill="#94a3b8"
closed
/>
<Line
points={[lineX, y2, lineX - arrowSize / 2, y2 - arrowSize, lineX + arrowSize / 2, y2 - arrowSize]}
fill="#94a3b8"
closed
/>
<Text
x={lineX + 3}
y={midY - 5}
text={label}
fontSize={9}
fill="#64748b"
/>
</Group>
);
}
function formatM(meters: number): string {
return `${meters.toFixed(2)}m`;
}
/** Render measurement annotations on a wall projection view. */
export function ProjectionMeasurements({
projectedOpenings,
projectedElectrical,
wallLength: wallLen,
wallHeight,
scale,
padding,
}: ProjectionMeasurementsProps) {
const elements: ReactNode[] = [];
// Wall width dimension (along bottom)
const floorLeft = projectionToPixel(0, 0, wallHeight, scale, padding);
const floorRight = projectionToPixel(wallLen, 0, wallHeight, scale, padding);
elements.push(
<DimensionLine
key="wall-width"
x1={floorLeft.x}
y1={floorLeft.y}
x2={floorRight.x}
y2={floorRight.y}
label={formatM(wallLen)}
offset={18}
horizontal
/>,
);
// Wall height dimension (along right side)
const topRight = projectionToPixel(wallLen, wallHeight, wallHeight, scale, padding);
const bottomRight = projectionToPixel(wallLen, 0, wallHeight, scale, padding);
elements.push(
<DimensionLine
key="wall-height"
x1={topRight.x}
y1={topRight.y}
x2={bottomRight.x}
y2={bottomRight.y}
label={formatM(wallHeight)}
offset={18}
horizontal={false}
/>,
);
// Opening dimensions: sill height for windows, door height for doors
for (const po of projectedOpenings) {
const { rect, opening } = po;
const topLeft = projectionToPixel(rect.x, rect.y + rect.height, wallHeight, scale, padding);
const bottomLeft = projectionToPixel(rect.x, rect.y, wallHeight, scale, padding);
// Height annotation (vertical, left side of opening)
elements.push(
<DimensionLine
key={`opening-h-${opening.id}`}
x1={topLeft.x}
y1={topLeft.y}
x2={bottomLeft.x}
y2={bottomLeft.y}
label={formatM(rect.height)}
offset={-14}
horizontal={false}
/>,
);
// Sill height for windows
if (opening.type === 'WINDOW' && rect.y > 0.01) {
const floorBelow = projectionToPixel(rect.x, 0, wallHeight, scale, padding);
elements.push(
<DimensionLine
key={`sill-${opening.id}`}
x1={bottomLeft.x}
y1={bottomLeft.y}
x2={floorBelow.x}
y2={floorBelow.y}
label={formatM(rect.y)}
offset={-14}
horizontal={false}
/>,
);
}
// Width annotation (horizontal, above opening)
const topRight2 = projectionToPixel(rect.x + rect.width, rect.y + rect.height, wallHeight, scale, padding);
elements.push(
<DimensionLine
key={`opening-w-${opening.id}`}
x1={topLeft.x}
y1={topLeft.y}
x2={topRight2.x}
y2={topRight2.y}
label={formatM(rect.width)}
offset={-12}
horizontal
/>,
);
}
// Electrical item coordinate labels: (X; Y) near each item
for (const pe of projectedElectrical) {
const center = projectionToPixel(
pe.position.alongWall,
pe.position.fromFloor,
wallHeight,
scale,
padding,
);
const coordLabel = `(${pe.position.alongWall.toFixed(2)}; ${pe.elevation.toFixed(2)})`;
elements.push(
<Text
key={`elec-coord-${pe.item.id}`}
x={center.x + 10}
y={center.y - 4}
text={coordLabel}
fontSize={9}
fill="#64748b"
/>,
);
}
// Opening horizontal position labels
for (const po of projectedOpenings) {
const { rect, opening } = po;
const openingCenter = projectionToPixel(
rect.x + rect.width / 2,
rect.y + rect.height,
wallHeight,
scale,
padding,
);
elements.push(
<Text
key={`opening-x-${opening.id}`}
x={openingCenter.x - 16}
y={openingCenter.y - 22}
text={formatM(rect.x + rect.width / 2)}
fontSize={8}
fill="#94a3b8"
align="center"
width={32}
/>,
);
}
return <Group>{elements}</Group>;
}