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,664 @@
import { useMemo, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import type { Wall, WallOpening, ElectricalItem, FurnitureItem, DoorOpenDirection, FloorType } from '@house-plan-maker/shared';
import { DOOR_OPEN_DIRECTIONS, FLOOR_TYPES } from '@house-plan-maker/shared';
import { useEditor } from './context/EditorContext';
import { useUndoRedo } from './context/UndoRedoContext';
import { wallLength } from './utils/wallUtils';
import { polygonArea, polygonPerimeter, generateLocalId } from './utils/geometry';
import { getElectricalVariant, ELECTRICAL_SYMBOL_DEFS } from './symbols/electrical';
import type { EditorCommand } from './types';
import styles from './properties-panel.module.css';
export function PropertiesPanel() {
const { t } = useTranslation();
const { state, dispatch, updateOpening, updateElectrical, updateFurniture, updateWall, addAnnotation } = useEditor();
const { execute } = useUndoRedo();
const { selectedIds, walls, openings, electricalItems, furnitureItems, room } = state;
const roomArea = useMemo(
() => room.shape.length >= 3 ? polygonArea(room.shape) : 0,
[room.shape],
);
const roomPerimeter = useMemo(
() => room.shape.length >= 2 ? polygonPerimeter(room.shape) : 0,
[room.shape],
);
// Find selected elements
const selected = useMemo(() => {
const items: {
type: 'wall' | 'opening' | 'electrical' | 'furniture';
data: Wall | WallOpening | ElectricalItem | FurnitureItem;
}[] = [];
for (const id of selectedIds) {
const wall = walls.find((w) => w.id === id);
if (wall) {
items.push({ type: 'wall', data: wall });
continue;
}
const opening = openings.find((o) => o.id === id);
if (opening) {
items.push({ type: 'opening', data: opening });
continue;
}
const elec = electricalItems.find((e) => e.id === id);
if (elec) {
items.push({ type: 'electrical', data: elec });
continue;
}
const furn = furnitureItems.find((f) => f.id === id);
if (furn) {
items.push({ type: 'furniture', data: furn });
}
}
return items;
}, [selectedIds, walls, openings, electricalItems, furnitureItems]);
if (selected.length === 0) {
return (
<div className={styles.panel}>
<div className={styles.header}>{t('properties.title')}</div>
<div className={styles.empty}>
<p className={styles.emptyText}>{t('properties.noSelection')}</p>
<p className={styles.emptyHint}>{t('properties.selectHint')}</p>
</div>
<div className={styles.section}>
<div className={styles.sectionTitle}>{t('properties.roomInfo')}</div>
<PropertyRow label={t('properties.name')} value={room.name} />
{roomArea > 0 && (
<PropertyRow label={t('properties.area')} value={`${(Math.round(roomArea * 100) / 100).toFixed(2)} m\u00B2`} />
)}
{roomPerimeter > 0 && (
<PropertyRow label={t('properties.perimeter')} value={`${(Math.round(roomPerimeter * 100) / 100).toFixed(2)} m`} />
)}
<PropertyRow label={t('properties.wallHeight')} value={`${room.wallHeight}m`} />
<PropertyRow label={t('properties.plinthHeight')} value={`${Math.round(room.plinthHeight * 1000) / 10}cm`} />
<SelectPropertyRow<FloorType>
label={t('properties.floorType')}
value={room.floorType}
options={FLOOR_TYPES.map((ft) => ({
value: ft,
label: t(`floor.${ft}`),
}))}
onChange={(v) => dispatch({ type: 'UPDATE_ROOM_PROPS', props: { floorType: v } })}
/>
<div className={styles.row}>
<span className={styles.rowLabel}>{t('properties.wallColor')}</span>
<input
type="color"
value={room.wallColor ?? '#f5f0eb'}
onChange={(e) => dispatch({ type: 'UPDATE_ROOM_PROPS', props: { wallColor: e.target.value } })}
style={{ width: 32, height: 24, border: '1px solid var(--color-border)', borderRadius: 4, cursor: 'pointer', padding: 0 }}
/>
</div>
<PropertyRow label={t('properties.walls')} value={String(walls.length)} />
<PropertyRow label={t('properties.openings')} value={String(openings.length)} />
</div>
</div>
);
}
if (selected.length > 1) {
return (
<div className={styles.panel}>
<div className={styles.header}>{t('properties.title')}</div>
<div className={styles.empty}>
<p className={styles.emptyText}>{t('properties.multipleSelected', { count: selected.length })}</p>
</div>
</div>
);
}
const item = selected[0];
return (
<div className={styles.panel}>
<div className={styles.header}>{t('properties.title')}</div>
{item.type === 'wall' && (
<WallProperties
wall={item.data as Wall}
onUpdate={(updated) => {
const original = item.data as Wall;
const cmd: EditorCommand = {
description: 'Update wall thickness',
execute: () => updateWall(updated),
undo: () => updateWall(original),
};
execute(cmd);
}}
/>
)}
{item.type === 'opening' && (
<OpeningProperties
opening={item.data as WallOpening}
walls={walls}
onUpdate={(updated) => {
const original = item.data as WallOpening;
const cmd: EditorCommand = {
description: `Update ${updated.type.toLowerCase()}`,
execute: () => updateOpening(updated),
undo: () => updateOpening(original),
};
execute(cmd);
}}
/>
)}
{item.type === 'electrical' && (
<ElectricalProperties
item={item.data as ElectricalItem}
onUpdate={(updated) => {
const original = item.data as ElectricalItem;
const cmd: EditorCommand = {
description: 'Update electrical item',
execute: () => updateElectrical(updated),
undo: () => updateElectrical(original),
};
execute(cmd);
}}
/>
)}
{item.type === 'furniture' && (
<FurnitureProperties
item={item.data as FurnitureItem}
onUpdate={(updated) => {
const original = item.data as FurnitureItem;
const cmd: EditorCommand = {
description: 'Update furniture item',
execute: () => updateFurniture(updated),
undo: () => updateFurniture(original),
};
execute(cmd);
}}
/>
)}
{/* Add note button for any item */}
{(item.type === 'electrical' || item.type === 'furniture') && (
<div style={{ padding: '4px 8px' }}>
<button
style={{
width: '100%',
padding: '4px 8px',
fontSize: '12px',
border: '1px solid var(--color-border)',
borderRadius: '4px',
background: 'var(--color-bg)',
color: 'var(--color-text-secondary)',
cursor: 'pointer',
}}
onClick={() => {
const text = window.prompt(t('annotation.editPrompt'), '');
if (text) {
addAnnotation({
id: generateLocalId(),
roomId: room.id,
x: 0.3,
y: -0.2,
text,
fontSize: 12,
attachedToId: item.data.id,
});
}
}}
>
{t('properties.addNote')}
</button>
</div>
)}
</div>
);
}
// ── Wall Properties ──
interface WallPropertiesProps {
readonly wall: Wall;
readonly onUpdate: (wall: Wall) => void;
}
function WallProperties({ wall, onUpdate }: WallPropertiesProps) {
const { t } = useTranslation();
const len = wallLength(wall);
const handleThicknessChange = useCallback(
(value: string) => {
const cm = parseFloat(value);
if (!isNaN(cm) && cm > 0 && cm <= 100) {
onUpdate({ ...wall, thickness: cm / 100 });
}
},
[wall, onUpdate],
);
return (
<div className={styles.section}>
<div className={styles.sectionTitle}>{t('properties.wall')}</div>
<PropertyRow label={t('properties.length')} value={formatM(len)} />
<EditablePropertyRow
label={t('properties.thickness')}
value={String(Math.round(wall.thickness * 100))}
unit="cm"
onCommit={handleThicknessChange}
/>
<PropertyRow label={t('properties.startX')} value={formatM(wall.startX)} />
<PropertyRow label={t('properties.startY')} value={formatM(wall.startY)} />
<PropertyRow label={t('properties.endX')} value={formatM(wall.endX)} />
<PropertyRow label={t('properties.endY')} value={formatM(wall.endY)} />
<PropertyRow label={t('properties.direction')} value={wall.direction} />
</div>
);
}
// ── Opening Properties ──
interface OpeningPropertiesProps {
readonly opening: WallOpening;
readonly walls: readonly Wall[];
readonly onUpdate: (opening: WallOpening) => void;
}
function OpeningProperties({ opening, walls, onUpdate }: OpeningPropertiesProps) {
const { t } = useTranslation();
const wall = walls.find((w) => w.id === opening.wallId);
const wLen = wall ? wallLength(wall) : 0;
const handleWidthChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num) && num > 0 && num < wLen) {
onUpdate({ ...opening, width: num });
}
},
[opening, onUpdate, wLen],
);
const handleHeightChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num) && num > 0) {
onUpdate({ ...opening, height: num });
}
},
[opening, onUpdate],
);
// Position displayed as left edge offset, stored as center
const displayPosition = Math.round((opening.positionAlongWall - opening.width / 2) * 1000) / 1000;
const handlePositionChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num) && num >= 0) {
// Convert left edge offset back to center position
const centerPos = num + opening.width / 2;
if (centerPos <= wLen) {
onUpdate({ ...opening, positionAlongWall: centerPos });
}
}
},
[opening, onUpdate, wLen],
);
const handleElevationChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num) && num >= 0) {
onUpdate({ ...opening, elevationFromFloor: num });
}
},
[opening, onUpdate],
);
const handleOpenDirectionChange = useCallback(
(direction: DoorOpenDirection) => {
onUpdate({ ...opening, openDirection: direction });
},
[opening, onUpdate],
);
return (
<div className={styles.section}>
<div className={styles.sectionTitle}>
{opening.type === 'DOOR' ? t('properties.door') : t('properties.window')}
</div>
<EditablePropertyRow
label={t('properties.width')}
value={String(opening.width)}
unit="m"
onCommit={handleWidthChange}
/>
<EditablePropertyRow
label={t('properties.height')}
value={String(opening.height)}
unit="m"
onCommit={handleHeightChange}
/>
<EditablePropertyRow
label={t('properties.position')}
value={String(Math.max(0, displayPosition))}
unit="m"
onCommit={handlePositionChange}
/>
{opening.type === 'DOOR' && (
<SelectPropertyRow
label={t('properties.openDirection')}
value={opening.openDirection}
options={DOOR_OPEN_DIRECTIONS.map((dir) => ({
value: dir,
label: t(`properties.openDir.${dir}`),
}))}
onChange={handleOpenDirectionChange}
/>
)}
{opening.type === 'WINDOW' && (
<EditablePropertyRow
label={t('properties.elevation')}
value={String(opening.elevationFromFloor)}
unit="m"
onCommit={handleElevationChange}
/>
)}
{wall && (
<PropertyRow label={t('properties.wallLength')} value={formatM(wLen)} />
)}
</div>
);
}
// ── Property Row Components ──
interface PropertyRowProps {
readonly label: string;
readonly value: string;
}
function PropertyRow({ label, value }: PropertyRowProps) {
return (
<div className={styles.row}>
<span className={styles.rowLabel}>{label}</span>
<span className={styles.rowValue}>{value}</span>
</div>
);
}
interface EditablePropertyRowProps {
readonly label: string;
readonly value: string;
readonly unit?: string;
readonly onCommit: (value: string) => void;
}
function EditablePropertyRow({ label, value, unit, onCommit }: EditablePropertyRowProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const handleBlur = useCallback(() => {
setEditing(false);
if (draft !== value) {
onCommit(draft);
}
}, [draft, value, onCommit]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleBlur();
} else if (e.key === 'Escape') {
setDraft(value);
setEditing(false);
}
},
[handleBlur, value],
);
if (editing) {
return (
<div className={styles.row}>
<span className={styles.rowLabel}>{label}</span>
<div className={styles.editRow}>
<input
className={styles.editInput}
type="text"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
autoFocus
/>
{unit && <span className={styles.editUnit}>{unit}</span>}
</div>
</div>
);
}
return (
<div className={styles.row}>
<span className={styles.rowLabel}>{label}</span>
<button
className={styles.editableValue}
onClick={() => {
setDraft(value);
setEditing(true);
}}
>
{value}{unit ? ` ${unit}` : ''}
</button>
</div>
);
}
// ── Select Property Row ──
interface SelectPropertyRowProps<T extends string> {
readonly label: string;
readonly value: T;
readonly options: readonly { readonly value: T; readonly label: string }[];
readonly onChange: (value: T) => void;
}
function SelectPropertyRow<T extends string>({ label, value, options, onChange }: SelectPropertyRowProps<T>) {
return (
<div className={styles.row}>
<span className={styles.rowLabel}>{label}</span>
<select
className={styles.selectInput}
value={value}
onChange={(e) => onChange(e.target.value as T)}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
}
// ── Electrical Properties ──
interface ElectricalPropertiesProps {
readonly item: ElectricalItem;
readonly onUpdate: (item: ElectricalItem) => void;
}
function ElectricalProperties({ item, onUpdate }: ElectricalPropertiesProps) {
const { t } = useTranslation();
const variant = getElectricalVariant(item.metadata);
const def = ELECTRICAL_SYMBOL_DEFS.find(
(d) => d.type === item.type && (d.variant ?? 'single') === variant,
);
const isWallMounted = item.wallId !== null;
const handleXChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num)) onUpdate({ ...item, x: num });
},
[item, onUpdate],
);
const handleYChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num)) onUpdate({ ...item, y: num });
},
[item, onUpdate],
);
const handleRotationChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num)) onUpdate({ ...item, rotation: num % 360 });
},
[item, onUpdate],
);
const handleElevationChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num) && num >= 0) {
onUpdate({ ...item, elevationFromFloor: num });
}
},
[item, onUpdate],
);
return (
<div className={styles.section}>
<div className={styles.sectionTitle}>
{def?.label ?? item.type}
</div>
<PropertyRow label={t('properties.type')} value={item.type} />
{variant !== 'single' && <PropertyRow label={t('properties.variant')} value={variant} />}
<EditablePropertyRow label={t('properties.x')} value={String(Math.round(item.x * 1000) / 1000)} unit="m" onCommit={handleXChange} />
<EditablePropertyRow label={t('properties.y')} value={String(Math.round(item.y * 1000) / 1000)} unit="m" onCommit={handleYChange} />
<EditablePropertyRow label={t('properties.rotation')} value={String(Math.round(item.rotation))} unit={"\u00b0"} onCommit={handleRotationChange} />
{isWallMounted && (
<>
<PropertyRow label={t('properties.wallMounted')} value={t('properties.yes')} />
<EditablePropertyRow
label={t('properties.elevation')}
value={String(Math.round((item.elevationFromFloor ?? 0) * 1000) / 1000)}
unit="m"
onCommit={handleElevationChange}
/>
</>
)}
</div>
);
}
// ── Furniture Properties ──
interface FurniturePropertiesProps {
readonly item: FurnitureItem;
readonly onUpdate: (item: FurnitureItem) => void;
}
function FurnitureProperties({ item, onUpdate }: FurniturePropertiesProps) {
const { t } = useTranslation();
const handleXChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num)) onUpdate({ ...item, x: num });
},
[item, onUpdate],
);
const handleYChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num)) onUpdate({ ...item, y: num });
},
[item, onUpdate],
);
const handleWidthChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num) && num > 0) onUpdate({ ...item, width: num });
},
[item, onUpdate],
);
const handleDepthChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num) && num > 0) onUpdate({ ...item, depth: num });
},
[item, onUpdate],
);
const handleHeightChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num) && num > 0) onUpdate({ ...item, height: num });
},
[item, onUpdate],
);
const handleElevationChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num) && num >= 0) onUpdate({ ...item, elevationFromFloor: num });
},
[item, onUpdate],
);
const handleRotationChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num)) onUpdate({ ...item, rotation: num % 360 });
},
[item, onUpdate],
);
return (
<div className={styles.section}>
<div className={styles.sectionTitle}>
{item.label ?? item.type}
</div>
<PropertyRow label={t('properties.type')} value={item.type} />
<EditablePropertyRow label={t('properties.x')} value={String(Math.round(item.x * 1000) / 1000)} unit="m" onCommit={handleXChange} />
<EditablePropertyRow label={t('properties.y')} value={String(Math.round(item.y * 1000) / 1000)} unit="m" onCommit={handleYChange} />
<EditablePropertyRow label={t('properties.width')} value={String(item.width)} unit="m" onCommit={handleWidthChange} />
<EditablePropertyRow label={t('properties.depth')} value={String(item.depth)} unit="m" onCommit={handleDepthChange} />
<EditablePropertyRow label={t('properties.height')} value={String(item.height)} unit="m" onCommit={handleHeightChange} />
<EditablePropertyRow label={t('properties.elevation')} value={String(Math.round(item.elevationFromFloor * 1000) / 1000)} unit="m" onCommit={handleElevationChange} />
<EditablePropertyRow label={t('properties.rotation')} value={String(Math.round(item.rotation))} unit={"\u00b0"} onCommit={handleRotationChange} />
{item.type === 'TV' && (
<div className={styles.row}>
<span className={styles.rowLabel}>{t('properties.stand')}</span>
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={!item.label?.includes('[no-stand]')}
onChange={(e) => {
const hasStand = e.target.checked;
const cleanLabel = (item.label ?? '').replace('[no-stand]', '').trim();
onUpdate({
...item,
label: hasStand ? (cleanLabel || null) : `${cleanLabel} [no-stand]`.trim(),
});
}}
/>
{t('properties.yes')}
</label>
</div>
)}
</div>
);
}
function formatM(meters: number): string {
return `${Math.round(meters * 1000) / 1000}m`;
}
function formatCm(meters: number): string {
return `${Math.round(meters * 100)}cm`;
}