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