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