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,175 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Layer, Group, Rect, Line } from 'react-konva';
|
||||
import type { Point, FurnitureItem } from '@house-plan-maker/shared';
|
||||
import { BedSilhouette } from '../symbols/furniture/BedSilhouette';
|
||||
import { DeskSilhouette } from '../symbols/furniture/DeskSilhouette';
|
||||
import { WardrobeSilhouette } from '../symbols/furniture/WardrobeSilhouette';
|
||||
import { SofaSilhouette } from '../symbols/furniture/SofaSilhouette';
|
||||
import { TableSilhouette } from '../symbols/furniture/TableSilhouette';
|
||||
import { ChairSilhouette } from '../symbols/furniture/ChairSilhouette';
|
||||
import { ShelfSilhouette } from '../symbols/furniture/ShelfSilhouette';
|
||||
import { TvSilhouette } from '../symbols/furniture/TvSilhouette';
|
||||
import { findCollidingFurniture } from '../utils/collisionDetection';
|
||||
|
||||
interface FurnitureLayerProps {
|
||||
readonly items: readonly FurnitureItem[];
|
||||
readonly zoom: number;
|
||||
readonly panOffset: Point;
|
||||
readonly selectedIds: ReadonlySet<string>;
|
||||
readonly visible?: boolean;
|
||||
}
|
||||
|
||||
const FURNITURE_COLOR = '#495057';
|
||||
const FURNITURE_FILL = 'rgba(222, 226, 230, 0.5)';
|
||||
const SELECTED_COLOR = '#4c6ef5';
|
||||
const SELECTED_FILL = 'rgba(76, 110, 245, 0.1)';
|
||||
const COLLISION_COLOR = '#e03131';
|
||||
const COLLISION_FILL = 'rgba(224, 49, 49, 0.1)';
|
||||
|
||||
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 FurnitureLayer = memo(function FurnitureLayer({
|
||||
items,
|
||||
zoom,
|
||||
panOffset,
|
||||
selectedIds,
|
||||
visible = true,
|
||||
}: FurnitureLayerProps) {
|
||||
const collidingIds = useMemo(() => findCollidingFurniture(items), [items]);
|
||||
|
||||
return (
|
||||
<Layer listening={false} visible={visible}>
|
||||
{items.map((item) => {
|
||||
// x,y is the top-left corner; compute center for silhouette rendering
|
||||
const centerX = item.x + item.width / 2;
|
||||
const centerY = item.y + item.depth / 2;
|
||||
const screenCenter = toScreen({ x: centerX, y: centerY }, zoom, panOffset);
|
||||
const isSelected = selectedIds.has(item.id);
|
||||
const isColliding = collidingIds.has(item.id);
|
||||
const widthPx = item.width * zoom;
|
||||
const depthPx = item.depth * zoom;
|
||||
|
||||
const color = isColliding ? COLLISION_COLOR : isSelected ? SELECTED_COLOR : FURNITURE_COLOR;
|
||||
const fillColor = isColliding ? COLLISION_FILL : isSelected ? SELECTED_FILL : FURNITURE_FILL;
|
||||
|
||||
return (
|
||||
<Group key={item.id}>
|
||||
{renderFurnitureSilhouette(
|
||||
item.type,
|
||||
screenCenter.x,
|
||||
screenCenter.y,
|
||||
widthPx,
|
||||
depthPx,
|
||||
item.rotation,
|
||||
color,
|
||||
fillColor,
|
||||
)}
|
||||
{/* Rotation handle indicator for selected furniture */}
|
||||
{isSelected && (
|
||||
<RotationHandle
|
||||
x={screenCenter.x}
|
||||
y={screenCenter.y}
|
||||
depthPx={depthPx}
|
||||
rotation={item.rotation}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Layer>
|
||||
);
|
||||
});
|
||||
|
||||
interface RotationHandleProps {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly depthPx: number;
|
||||
readonly rotation: number;
|
||||
}
|
||||
|
||||
function RotationHandle({ x, y, depthPx, rotation }: RotationHandleProps) {
|
||||
const handleOffset = depthPx / 2 + 12;
|
||||
const rad = (rotation * Math.PI) / 180;
|
||||
const hx = x - Math.sin(rad) * handleOffset;
|
||||
const hy = y - Math.cos(rad) * handleOffset;
|
||||
|
||||
return (
|
||||
<Group>
|
||||
{/* Line from center to handle */}
|
||||
<Line
|
||||
points={[x, y, hx, hy]}
|
||||
stroke={SELECTED_COLOR}
|
||||
strokeWidth={1}
|
||||
dash={[3, 3]}
|
||||
listening={false}
|
||||
/>
|
||||
{/* Handle circle */}
|
||||
<Rect
|
||||
x={hx - 4}
|
||||
y={hy - 4}
|
||||
width={8}
|
||||
height={8}
|
||||
fill={SELECTED_COLOR}
|
||||
cornerRadius={4}
|
||||
listening={false}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFurnitureSilhouette(
|
||||
type: string,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
depth: number,
|
||||
rotation: number,
|
||||
color: string,
|
||||
fillColor: string,
|
||||
): React.ReactNode {
|
||||
const props = { x, y, width, depth, rotation, color, fillColor };
|
||||
|
||||
switch (type) {
|
||||
case 'BED':
|
||||
return <BedSilhouette {...props} />;
|
||||
case 'DESK':
|
||||
return <DeskSilhouette {...props} />;
|
||||
case 'WARDROBE':
|
||||
return <WardrobeSilhouette {...props} />;
|
||||
case 'SOFA':
|
||||
return <SofaSilhouette {...props} />;
|
||||
case 'TABLE':
|
||||
return <TableSilhouette {...props} />;
|
||||
case 'CHAIR':
|
||||
return <ChairSilhouette {...props} />;
|
||||
case 'SHELF':
|
||||
case 'BOOKCASE':
|
||||
return <ShelfSilhouette {...props} />;
|
||||
case 'NIGHTSTAND':
|
||||
return <DeskSilhouette {...props} />;
|
||||
case 'DRESSER':
|
||||
return <WardrobeSilhouette {...props} />;
|
||||
case 'TV':
|
||||
return <TvSilhouette {...props} />;
|
||||
default:
|
||||
// Generic rectangle for OTHER / unknown
|
||||
return (
|
||||
<Rect
|
||||
x={x - width / 2}
|
||||
y={y - depth / 2}
|
||||
width={width}
|
||||
height={depth}
|
||||
rotation={rotation}
|
||||
stroke={color}
|
||||
strokeWidth={1.5}
|
||||
fill={fillColor}
|
||||
listening={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user