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,341 @@
import { useMemo } from 'react';
import * as THREE from 'three';
import type { FurnitureItem, FurnitureType } from '@house-plan-maker/shared';
interface FurnitureMeshProps {
readonly item: FurnitureItem;
readonly isSelected: boolean;
readonly onSelect?: (id: string) => void;
}
const FURNITURE_COLORS: Record<FurnitureType, string> = {
BED: '#8fa5b2',
DESK: '#b08968',
WARDROBE: '#7a6652',
SOFA: '#9b8e7e',
TABLE: '#c4a882',
CHAIR: '#a0937d',
SHELF: '#b09e8a',
NIGHTSTAND: '#9b8b7a',
DRESSER: '#8a7a6a',
BOOKCASE: '#7a6a5a',
TV: '#2a2a3a',
AC_UNIT: '#e8e8e8',
OTHER: '#a0a0a0',
};
const SELECTED_COLOR = '#6fa8dc';
const LEG_COLOR = '#4a3728';
const LEG_RADIUS = 0.02;
const LEG_SEGMENTS = 6;
// ── Shared materials (module-level singletons) ──
const legMaterial = new THREE.MeshStandardMaterial({ color: LEG_COLOR, roughness: 0.6 });
const legMaterialSmooth = new THREE.MeshStandardMaterial({ color: LEG_COLOR, roughness: 0.5 });
const furnitureMaterials: Record<string, THREE.MeshStandardMaterial> = {};
function getFurnitureMaterial(color: string, roughness: number): THREE.MeshStandardMaterial {
const key = `${color}_${roughness}`;
const existing = furnitureMaterials[key];
if (existing) return existing;
const mat = new THREE.MeshStandardMaterial({ color, roughness });
furnitureMaterials[key] = mat;
return mat;
}
// ── Shared geometries for common shapes ──
const legGeometry = new THREE.CylinderGeometry(LEG_RADIUS, LEG_RADIUS, 1, LEG_SEGMENTS);
const dividerLineGeometry = new THREE.BoxGeometry(0.005, 1, 0.002);
function degToRad(degrees: number): number {
return (degrees * Math.PI) / 180;
}
/** Simple bed: mattress box + headboard */
function BedMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) {
const mattressHeight = item.height * 0.4;
const frameHeight = item.height * 0.3;
const headboardHeight = item.height;
const mattressMaterial = useMemo(() => getFurnitureMaterial(color, 0.9), [color]);
return (
<group>
{/* Frame */}
<mesh position={[0, frameHeight / 2, 0]} castShadow material={legMaterial}>
<boxGeometry args={[item.width, frameHeight, item.depth]} />
</mesh>
{/* Mattress */}
<mesh position={[0, frameHeight + mattressHeight / 2, 0]} castShadow material={mattressMaterial}>
<boxGeometry args={[item.width * 0.95, mattressHeight, item.depth * 0.95]} />
</mesh>
{/* Headboard */}
<mesh position={[0, headboardHeight / 2, -item.depth / 2 + 0.02]} castShadow material={legMaterialSmooth}>
<boxGeometry args={[item.width, headboardHeight, 0.04]} />
</mesh>
</group>
);
}
/** Desk: top slab + 4 legs */
function DeskMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) {
const topThickness = 0.04;
const legHeight = item.height - topThickness;
const inset = 0.05;
const topMaterial = useMemo(() => getFurnitureMaterial(color, 0.5), [color]);
return (
<group>
{/* Top */}
<mesh position={[0, item.height - topThickness / 2, 0]} castShadow material={topMaterial}>
<boxGeometry args={[item.width, topThickness, item.depth]} />
</mesh>
{/* Legs */}
{[
[-item.width / 2 + inset, -item.depth / 2 + inset],
[item.width / 2 - inset, -item.depth / 2 + inset],
[-item.width / 2 + inset, item.depth / 2 - inset],
[item.width / 2 - inset, item.depth / 2 - inset],
].map(([x, z], i) => (
<mesh key={i} position={[x, legHeight / 2, z]} castShadow material={legMaterial} scale={[1, legHeight, 1]}>
<primitive object={legGeometry} attach="geometry" />
</mesh>
))}
</group>
);
}
/** Wardrobe: tall box with slight door line */
function WardrobeMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) {
const bodyMaterial = useMemo(() => getFurnitureMaterial(color, 0.6), [color]);
return (
<group>
<mesh position={[0, item.height / 2, 0]} castShadow material={bodyMaterial}>
<boxGeometry args={[item.width, item.height, item.depth]} />
</mesh>
{/* Door divider line */}
<mesh
position={[0, item.height / 2, item.depth / 2 + 0.001]}
castShadow
material={legMaterialSmooth}
scale={[1, item.height * 0.9, 1]}
>
<primitive object={dividerLineGeometry} attach="geometry" />
</mesh>
</group>
);
}
/** Sofa: seat + back (L-shape profile) */
function SofaMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) {
const seatHeight = item.height * 0.45;
const backHeight = item.height;
const backDepth = item.depth * 0.25;
const sofaMaterial = useMemo(() => getFurnitureMaterial(color, 0.9), [color]);
return (
<group>
{/* Seat */}
<mesh position={[0, seatHeight / 2, backDepth / 2]} castShadow material={sofaMaterial}>
<boxGeometry args={[item.width, seatHeight, item.depth - backDepth]} />
</mesh>
{/* Backrest */}
<mesh position={[0, backHeight / 2, -item.depth / 2 + backDepth / 2]} castShadow material={sofaMaterial}>
<boxGeometry args={[item.width, backHeight, backDepth]} />
</mesh>
{/* Armrests */}
{[-1, 1].map((side) => (
<mesh
key={side}
position={[side * (item.width / 2 - 0.04), seatHeight + 0.1, 0]}
castShadow
material={sofaMaterial}
>
<boxGeometry args={[0.08, 0.2, item.depth * 0.8]} />
</mesh>
))}
</group>
);
}
/** Table: top slab + 4 legs */
function TableMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) {
const topThickness = 0.03;
const legHeight = item.height - topThickness;
const inset = 0.05;
const topMaterial = useMemo(() => getFurnitureMaterial(color, 0.5), [color]);
return (
<group>
<mesh position={[0, item.height - topThickness / 2, 0]} castShadow material={topMaterial}>
<boxGeometry args={[item.width, topThickness, item.depth]} />
</mesh>
{[
[-item.width / 2 + inset, -item.depth / 2 + inset],
[item.width / 2 - inset, -item.depth / 2 + inset],
[-item.width / 2 + inset, item.depth / 2 - inset],
[item.width / 2 - inset, item.depth / 2 - inset],
].map(([x, z], i) => (
<mesh key={i} position={[x, legHeight / 2, z]} castShadow material={legMaterial} scale={[1, legHeight, 1]}>
<primitive object={legGeometry} attach="geometry" />
</mesh>
))}
</group>
);
}
/** Chair: seat + back + 4 legs */
function ChairMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) {
const seatHeight = item.height * 0.5;
const seatThickness = 0.03;
const legHeight = seatHeight - seatThickness;
const inset = 0.03;
const chairMaterial = useMemo(() => getFurnitureMaterial(color, 0.6), [color]);
return (
<group>
{/* Seat */}
<mesh position={[0, seatHeight - seatThickness / 2, 0]} castShadow material={chairMaterial}>
<boxGeometry args={[item.width, seatThickness, item.depth]} />
</mesh>
{/* Backrest */}
<mesh position={[0, (seatHeight + item.height) / 2, -item.depth / 2 + 0.015]} castShadow material={chairMaterial}>
<boxGeometry args={[item.width * 0.9, item.height - seatHeight, 0.03]} />
</mesh>
{/* Legs */}
{[
[-item.width / 2 + inset, -item.depth / 2 + inset],
[item.width / 2 - inset, -item.depth / 2 + inset],
[-item.width / 2 + inset, item.depth / 2 - inset],
[item.width / 2 - inset, item.depth / 2 - inset],
].map(([x, z], i) => (
<mesh key={i} position={[x, legHeight / 2, z]} castShadow material={legMaterial} scale={[1, legHeight, 1]}>
<primitive object={legGeometry} attach="geometry" />
</mesh>
))}
</group>
);
}
/** Shelf / Bookcase / Nightstand / Dresser / Other: simple box */
function SimpleBoxMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) {
const material = useMemo(() => getFurnitureMaterial(color, 0.6), [color]);
return (
<mesh position={[0, item.height / 2, 0]} castShadow material={material}>
<boxGeometry args={[item.width, item.height, item.depth]} />
</mesh>
);
}
/** Bookcase: open shelves */
function BookcaseMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) {
const shelfCount = Math.max(2, Math.round(item.height / 0.35));
const panelThickness = 0.02;
const material = useMemo(() => getFurnitureMaterial(color, 0.6), [color]);
return (
<group>
{/* Back panel */}
<mesh position={[0, item.height / 2, -item.depth / 2 + panelThickness / 2]} castShadow material={material}>
<boxGeometry args={[item.width, item.height, panelThickness]} />
</mesh>
{/* Side panels */}
{[-1, 1].map((side) => (
<mesh
key={side}
position={[side * (item.width / 2 - panelThickness / 2), item.height / 2, 0]}
castShadow
material={material}
>
<boxGeometry args={[panelThickness, item.height, item.depth]} />
</mesh>
))}
{/* Shelves */}
{Array.from({ length: shelfCount + 1 }).map((_, i) => {
const y = (i / shelfCount) * item.height;
return (
<mesh key={i} position={[0, y, 0]} castShadow material={material}>
<boxGeometry args={[item.width - panelThickness * 2, panelThickness, item.depth]} />
</mesh>
);
})}
</group>
);
}
/** TV: thin screen panel, optionally on a stand */
function TvMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) {
const screenMaterial = useMemo(() => getFurnitureMaterial('#1a1a2e', 0.1), []);
const frameMaterial = useMemo(() => getFurnitureMaterial(color, 0.4), [color]);
const screenThickness = 0.03;
const hasStand = !item.label?.includes('[no-stand]');
const standHeight = hasStand ? item.height * 0.15 : 0;
const screenHeight = item.height - standHeight;
return (
<group>
{/* Screen */}
<mesh position={[0, standHeight + screenHeight / 2, 0]} castShadow material={screenMaterial}>
<boxGeometry args={[item.width, screenHeight, screenThickness]} />
</mesh>
{/* Frame border */}
<mesh position={[0, standHeight + screenHeight / 2, screenThickness / 2 + 0.001]} material={frameMaterial}>
<boxGeometry args={[item.width + 0.02, screenHeight + 0.02, 0.005]} />
</mesh>
{hasStand && (
<>
{/* Stand */}
<mesh position={[0, standHeight / 2, 0]} castShadow material={frameMaterial}>
<boxGeometry args={[0.04, standHeight, item.depth]} />
</mesh>
{/* Stand base */}
<mesh position={[0, 0.005, 0]} castShadow material={frameMaterial}>
<boxGeometry args={[item.width * 0.4, 0.01, item.depth]} />
</mesh>
</>
)}
</group>
);
}
function getFurnitureComponent(type: FurnitureType) {
switch (type) {
case 'BED': return BedMesh;
case 'DESK': return DeskMesh;
case 'WARDROBE': return WardrobeMesh;
case 'SOFA': return SofaMesh;
case 'TABLE': return TableMesh;
case 'CHAIR': return ChairMesh;
case 'BOOKCASE': return BookcaseMesh;
case 'TV': return TvMesh;
case 'AC_UNIT': return SimpleBoxMesh;
case 'SHELF':
case 'NIGHTSTAND':
case 'DRESSER':
case 'OTHER':
default:
return SimpleBoxMesh;
}
}
export function FurnitureMesh({ item, isSelected, onSelect }: FurnitureMeshProps) {
const Component = useMemo(() => getFurnitureComponent(item.type), [item.type]);
const color = isSelected ? SELECTED_COLOR : FURNITURE_COLORS[item.type];
// 2D coords: x,y is top-left corner → compute center for 3D positioning
// 3D: (centerX, 0, centerY), rotation around Y axis
const centerX = item.x + item.width / 2;
const centerY = item.y + item.depth / 2;
return (
<group
position={[centerX, item.elevationFromFloor, centerY]}
rotation={[0, -degToRad(item.rotation), 0]}
onClick={onSelect ? (e) => { e.stopPropagation(); onSelect(item.id); } : undefined}
>
<Component item={item} color={color} />
</group>
);
}