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
+2343
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@house-plan-maker/shared",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsc --build",
"dev": "tsc --build --watch",
"test": "vitest run",
"lint": "eslint src/"
},
"devDependencies": {
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"dependencies": {
"zod": "^4.3.6"
}
}
@@ -0,0 +1,104 @@
import { describe, it, expect } from 'vitest';
import { createApartmentSchema, updateApartmentSchema } from '../schemas/apartment.schema.js';
import { createRoomSchema, updateRoomSchema } from '../schemas/room.schema.js';
describe('createApartmentSchema', () => {
it('validates a valid apartment', () => {
const result = createApartmentSchema.parse({
name: 'Test Apartment',
address: '123 Main St',
totalArea: 75.5,
});
expect(result.name).toBe('Test Apartment');
expect(result.address).toBe('123 Main St');
expect(result.totalArea).toBe(75.5);
});
it('requires name', () => {
expect(() => createApartmentSchema.parse({})).toThrow();
});
it('rejects empty name', () => {
expect(() => createApartmentSchema.parse({ name: '' })).toThrow();
});
it('allows optional address and totalArea', () => {
const result = createApartmentSchema.parse({ name: 'Test' });
expect(result.name).toBe('Test');
});
it('rejects negative totalArea', () => {
expect(() => createApartmentSchema.parse({ name: 'Test', totalArea: -10 })).toThrow();
});
});
describe('updateApartmentSchema', () => {
it('allows partial update', () => {
const result = updateApartmentSchema.parse({ name: 'Updated' });
expect(result.name).toBe('Updated');
});
it('allows empty object', () => {
const result = updateApartmentSchema.parse({});
expect(result).toBeDefined();
});
});
describe('createRoomSchema', () => {
it('validates a valid room', () => {
const result = createRoomSchema.parse({
name: 'Bedroom',
shape: [{ x: 0, y: 0 }, { x: 4, y: 0 }, { x: 4, y: 3 }, { x: 0, y: 3 }],
width: 4,
height: 3,
wallHeight: 2.7,
});
expect(result.name).toBe('Bedroom');
expect(result.shape).toHaveLength(4);
});
it('requires name', () => {
expect(() => createRoomSchema.parse({})).toThrow();
});
it('rejects empty name', () => {
expect(() => createRoomSchema.parse({ name: '' })).toThrow();
});
it('allows optional fields', () => {
const result = createRoomSchema.parse({ name: 'Room' });
expect(result.name).toBe('Room');
});
it('rejects negative width', () => {
expect(() => createRoomSchema.parse({ name: 'Room', width: -1 })).toThrow();
});
it('rejects negative wallHeight', () => {
expect(() => createRoomSchema.parse({ name: 'Room', wallHeight: -1 })).toThrow();
});
it('allows zero plinthHeight', () => {
const result = createRoomSchema.parse({ name: 'Room', plinthHeight: 0 });
expect(result.plinthHeight).toBe(0);
});
it('validates order as non-negative integer', () => {
const result = createRoomSchema.parse({ name: 'Room', order: 0 });
expect(result.order).toBe(0);
expect(() => createRoomSchema.parse({ name: 'Room', order: -1 })).toThrow();
expect(() => createRoomSchema.parse({ name: 'Room', order: 1.5 })).toThrow();
});
});
describe('updateRoomSchema', () => {
it('allows partial update', () => {
const result = updateRoomSchema.parse({ name: 'Updated Room' });
expect(result.name).toBe('Updated Room');
});
it('allows empty object', () => {
const result = updateRoomSchema.parse({});
expect(result).toBeDefined();
});
});
+103
View File
@@ -0,0 +1,103 @@
/**
* Shared types and utilities for the House Plan Maker application.
*/
export interface HealthCheckResponse {
status: 'ok' | 'error';
timestamp: string;
}
// Types
export type {
Apartment,
ApartmentWithRooms,
ApartmentRoom,
CreateApartmentDto,
UpdateApartmentDto,
} from './types/apartment.js';
export type {
Point,
FloorType,
Room,
RoomFull,
CreateRoomDto,
UpdateRoomDto,
} from './types/room.js';
export { FLOOR_TYPES } from './types/room.js';
export type {
WallDirection,
Wall,
CreateWallDto,
OpeningType,
DoorOpenDirection,
WallOpening,
CreateWallOpeningDto,
UpdateWallOpeningDto,
ElectricalType,
ElectricalItem,
CreateElectricalItemDto,
UpdateElectricalItemDto,
FurnitureType,
FurnitureItem,
CreateFurnitureItemDto,
UpdateFurnitureItemDto,
Annotation,
BatchSyncOpeningsDto,
BatchSyncElectricalDto,
BatchSyncFurnitureDto,
} from './types/elements.js';
export {
WALL_DIRECTIONS,
OPENING_TYPES,
DOOR_OPEN_DIRECTIONS,
ELECTRICAL_TYPES,
FURNITURE_TYPES,
} from './types/elements.js';
export type {
ApiResponse,
ApiListResponse,
ApiErrorResponse,
} from './types/api.js';
// Zod schemas
export {
createApartmentSchema,
updateApartmentSchema,
type CreateApartmentInput,
type UpdateApartmentInput,
} from './schemas/apartment.schema.js';
export {
createRoomSchema,
updateRoomSchema,
type CreateRoomInput,
type UpdateRoomInput,
} from './schemas/room.schema.js';
export {
bulkUpdateWallsSchema,
createWallOpeningSchema,
updateWallOpeningSchema,
createElectricalItemSchema,
updateElectricalItemSchema,
createFurnitureItemSchema,
updateFurnitureItemSchema,
batchSyncOpeningsSchema,
batchSyncElectricalSchema,
batchSyncFurnitureSchema,
type BulkUpdateWallsInput,
type CreateWallOpeningInput,
type UpdateWallOpeningInput,
type CreateElectricalItemInput,
type UpdateElectricalItemInput,
type CreateFurnitureItemInput,
type UpdateFurnitureItemInput,
type BatchSyncOpeningsInput,
type BatchSyncElectricalInput,
type BatchSyncFurnitureInput,
} from './schemas/elements.schema.js';
@@ -0,0 +1,16 @@
import { z } from 'zod';
export const createApartmentSchema = z.object({
name: z.string().min(1, 'Name is required').max(255),
address: z.string().max(500).nullish(),
totalArea: z.number().positive('Total area must be positive').nullish(),
});
export const updateApartmentSchema = z.object({
name: z.string().min(1, 'Name is required').max(255).optional(),
address: z.string().max(500).nullish(),
totalArea: z.number().positive('Total area must be positive').nullish(),
});
export type CreateApartmentInput = z.infer<typeof createApartmentSchema>;
export type UpdateApartmentInput = z.infer<typeof updateApartmentSchema>;
@@ -0,0 +1,148 @@
import { z } from 'zod';
import { WALL_DIRECTIONS, OPENING_TYPES, DOOR_OPEN_DIRECTIONS, ELECTRICAL_TYPES, FURNITURE_TYPES } from '../types/elements.js';
// ── Wall schemas ──
const wallDirectionEnum = z.enum(WALL_DIRECTIONS);
const createWallItemSchema = z.object({
startX: z.number(),
startY: z.number(),
endX: z.number(),
endY: z.number(),
thickness: z.number().positive('Thickness must be positive').optional(),
direction: wallDirectionEnum.optional(),
});
/** Bulk update: replace all walls in a room. */
export const bulkUpdateWallsSchema = z.object({
walls: z.array(createWallItemSchema),
});
export type BulkUpdateWallsInput = z.infer<typeof bulkUpdateWallsSchema>;
// ── WallOpening schemas ──
const openingTypeEnum = z.enum(OPENING_TYPES);
const doorOpenDirectionEnum = z.enum(DOOR_OPEN_DIRECTIONS);
export const createWallOpeningSchema = z.object({
wallId: z.string().min(1, 'Wall ID is required'),
type: openingTypeEnum,
positionAlongWall: z.number().min(0, 'Position must be non-negative'),
width: z.number().positive('Width must be positive'),
height: z.number().positive('Height must be positive'),
elevationFromFloor: z.number().min(0).optional(),
openDirection: doorOpenDirectionEnum.optional(),
});
export const updateWallOpeningSchema = z.object({
type: openingTypeEnum.optional(),
positionAlongWall: z.number().min(0, 'Position must be non-negative').optional(),
width: z.number().positive('Width must be positive').optional(),
height: z.number().positive('Height must be positive').optional(),
elevationFromFloor: z.number().min(0).optional(),
openDirection: doorOpenDirectionEnum.optional(),
});
export type CreateWallOpeningInput = z.infer<typeof createWallOpeningSchema>;
export type UpdateWallOpeningInput = z.infer<typeof updateWallOpeningSchema>;
// ── ElectricalItem schemas ──
const electricalTypeEnum = z.enum(ELECTRICAL_TYPES);
export const createElectricalItemSchema = z.object({
type: electricalTypeEnum,
x: z.number(),
y: z.number(),
wallId: z.string().nullish(),
elevationFromFloor: z.number().min(0).nullish(),
rotation: z.number().min(0).max(360).optional(),
metadata: z.record(z.string(), z.unknown()).nullish(),
});
export const updateElectricalItemSchema = z.object({
type: electricalTypeEnum.optional(),
x: z.number().optional(),
y: z.number().optional(),
wallId: z.string().nullish(),
elevationFromFloor: z.number().min(0).nullish(),
rotation: z.number().min(0).max(360).optional(),
metadata: z.record(z.string(), z.unknown()).nullish(),
});
export type CreateElectricalItemInput = z.infer<typeof createElectricalItemSchema>;
export type UpdateElectricalItemInput = z.infer<typeof updateElectricalItemSchema>;
// ── FurnitureItem schemas ──
const furnitureTypeEnum = z.enum(FURNITURE_TYPES);
export const createFurnitureItemSchema = z.object({
type: furnitureTypeEnum,
x: z.number(),
y: z.number(),
width: z.number().positive('Width must be positive'),
depth: z.number().positive('Depth must be positive'),
height: z.number().positive('Height must be positive'),
rotation: z.number().min(0).max(360).optional(),
elevationFromFloor: z.number().min(0).optional(),
label: z.string().max(255).nullish(),
});
export const updateFurnitureItemSchema = z.object({
type: furnitureTypeEnum.optional(),
x: z.number().optional(),
y: z.number().optional(),
width: z.number().positive('Width must be positive').optional(),
depth: z.number().positive('Depth must be positive').optional(),
height: z.number().positive('Height must be positive').optional(),
rotation: z.number().min(0).max(360).optional(),
elevationFromFloor: z.number().min(0).optional(),
label: z.string().max(255).nullish(),
});
export type CreateFurnitureItemInput = z.infer<typeof createFurnitureItemSchema>;
export type UpdateFurnitureItemInput = z.infer<typeof updateFurnitureItemSchema>;
// ── Batch sync schemas ──
export const batchSyncOpeningsSchema = z.object({
create: z.array(createWallOpeningSchema),
update: z.array(
z.object({
id: z.string().min(1),
data: updateWallOpeningSchema,
}),
),
delete: z.array(z.string().min(1)),
});
export type BatchSyncOpeningsInput = z.infer<typeof batchSyncOpeningsSchema>;
export const batchSyncElectricalSchema = z.object({
create: z.array(createElectricalItemSchema),
update: z.array(
z.object({
id: z.string().min(1),
data: updateElectricalItemSchema,
}),
),
delete: z.array(z.string().min(1)),
});
export type BatchSyncElectricalInput = z.infer<typeof batchSyncElectricalSchema>;
export const batchSyncFurnitureSchema = z.object({
create: z.array(createFurnitureItemSchema),
update: z.array(
z.object({
id: z.string().min(1),
data: updateFurnitureItemSchema,
}),
),
delete: z.array(z.string().min(1)),
});
export type BatchSyncFurnitureInput = z.infer<typeof batchSyncFurnitureSchema>;
@@ -0,0 +1,42 @@
import { z } from 'zod';
import { FLOOR_TYPES } from '../types/room.js';
const floorTypeEnum = z.enum(FLOOR_TYPES);
const pointSchema = z.object({
x: z.number(),
y: z.number(),
});
export const createRoomSchema = z.object({
name: z.string().min(1, 'Name is required').max(255),
shape: z.array(pointSchema).optional(),
width: z.number().positive('Width must be positive').nullish(),
height: z.number().positive('Height must be positive').nullish(),
wallHeight: z.number().positive('Wall height must be positive').optional(),
plinthHeight: z.number().min(0, 'Plinth height must be non-negative').optional(),
plinthThickness: z.number().min(0, 'Plinth thickness must be non-negative').optional(),
order: z.number().int().min(0).optional(),
posX: z.number().optional(),
posY: z.number().optional(),
floorType: floorTypeEnum.optional(),
wallColor: z.string().max(20).optional(),
});
export const updateRoomSchema = z.object({
name: z.string().min(1, 'Name is required').max(255).optional(),
shape: z.array(pointSchema).optional(),
width: z.number().positive('Width must be positive').nullish(),
height: z.number().positive('Height must be positive').nullish(),
wallHeight: z.number().positive('Wall height must be positive').optional(),
plinthHeight: z.number().min(0, 'Plinth height must be non-negative').optional(),
plinthThickness: z.number().min(0, 'Plinth thickness must be non-negative').optional(),
order: z.number().int().min(0).optional(),
posX: z.number().optional(),
posY: z.number().optional(),
floorType: floorTypeEnum.optional(),
wallColor: z.string().max(20).optional(),
});
export type CreateRoomInput = z.infer<typeof createRoomSchema>;
export type UpdateRoomInput = z.infer<typeof updateRoomSchema>;
+32
View File
@@ -0,0 +1,32 @@
export interface Apartment {
readonly id: string;
readonly name: string;
readonly address: string | null;
readonly totalArea: number | null;
readonly roomCount?: number;
readonly createdAt: string;
readonly updatedAt: string;
}
export interface ApartmentWithRooms extends Apartment {
readonly rooms: readonly ApartmentRoom[];
}
/** Lightweight room info when nested inside an apartment response. */
export interface ApartmentRoom {
readonly id: string;
readonly name: string;
readonly order: number;
}
export interface CreateApartmentDto {
readonly name: string;
readonly address?: string | null;
readonly totalArea?: number | null;
}
export interface UpdateApartmentDto {
readonly name?: string;
readonly address?: string | null;
readonly totalArea?: number | null;
}
+15
View File
@@ -0,0 +1,15 @@
/** Standard success response wrapper. */
export interface ApiResponse<T> {
readonly data: T;
}
/** Standard list response wrapper. */
export interface ApiListResponse<T> {
readonly data: readonly T[];
}
/** Standard error response. */
export interface ApiErrorResponse {
readonly error: string;
readonly statusCode: number;
}
+198
View File
@@ -0,0 +1,198 @@
// ── Wall ──
export const WALL_DIRECTIONS = ['NORTH', 'SOUTH', 'EAST', 'WEST', 'OTHER'] as const;
export type WallDirection = (typeof WALL_DIRECTIONS)[number];
export interface Wall {
readonly id: string;
readonly roomId: string;
readonly startX: number;
readonly startY: number;
readonly endX: number;
readonly endY: number;
readonly thickness: number;
readonly direction: WallDirection;
}
export interface CreateWallDto {
readonly startX: number;
readonly startY: number;
readonly endX: number;
readonly endY: number;
readonly thickness?: number;
readonly direction?: WallDirection;
}
// ── WallOpening ──
export const OPENING_TYPES = ['DOOR', 'WINDOW'] as const;
export type OpeningType = (typeof OPENING_TYPES)[number];
export const DOOR_OPEN_DIRECTIONS = ['LEFT', 'RIGHT', 'INWARD', 'OUTWARD'] as const;
export type DoorOpenDirection = (typeof DOOR_OPEN_DIRECTIONS)[number];
export interface WallOpening {
readonly id: string;
readonly roomId: string;
readonly wallId: string;
readonly type: OpeningType;
readonly positionAlongWall: number;
readonly width: number;
readonly height: number;
readonly elevationFromFloor: number;
readonly openDirection: DoorOpenDirection;
}
export interface CreateWallOpeningDto {
readonly wallId: string;
readonly type: OpeningType;
readonly positionAlongWall: number;
readonly width: number;
readonly height: number;
readonly elevationFromFloor?: number;
readonly openDirection?: DoorOpenDirection;
}
export interface UpdateWallOpeningDto {
readonly type?: OpeningType;
readonly positionAlongWall?: number;
readonly width?: number;
readonly height?: number;
readonly elevationFromFloor?: number;
readonly openDirection?: DoorOpenDirection;
}
// ── ElectricalItem ──
export const ELECTRICAL_TYPES = [
'OUTLET',
'SWITCH',
'JUNCTION_BOX',
'LIGHT_CEILING',
'LIGHT_WALL',
'CABLE_ROUTE',
] as const;
export type ElectricalType = (typeof ELECTRICAL_TYPES)[number];
export interface ElectricalItem {
readonly id: string;
readonly roomId: string;
readonly type: ElectricalType;
readonly x: number;
readonly y: number;
readonly wallId: string | null;
readonly elevationFromFloor: number | null;
readonly rotation: number;
readonly metadata: Record<string, unknown> | null;
}
export interface CreateElectricalItemDto {
readonly type: ElectricalType;
readonly x: number;
readonly y: number;
readonly wallId?: string | null;
readonly elevationFromFloor?: number | null;
readonly rotation?: number;
readonly metadata?: Record<string, unknown> | null;
}
export interface UpdateElectricalItemDto {
readonly type?: ElectricalType;
readonly x?: number;
readonly y?: number;
readonly wallId?: string | null;
readonly elevationFromFloor?: number | null;
readonly rotation?: number;
readonly metadata?: Record<string, unknown> | null;
}
// ── FurnitureItem ──
export const FURNITURE_TYPES = [
'BED',
'DESK',
'WARDROBE',
'SOFA',
'TABLE',
'CHAIR',
'SHELF',
'NIGHTSTAND',
'DRESSER',
'BOOKCASE',
'TV',
'AC_UNIT',
'OTHER',
] as const;
export type FurnitureType = (typeof FURNITURE_TYPES)[number];
export interface FurnitureItem {
readonly id: string;
readonly roomId: string;
readonly type: FurnitureType;
readonly x: number;
readonly y: number;
readonly width: number;
readonly depth: number;
readonly height: number;
readonly rotation: number;
readonly elevationFromFloor: number;
readonly label: string | null;
}
export interface CreateFurnitureItemDto {
readonly type: FurnitureType;
readonly x: number;
readonly y: number;
readonly width: number;
readonly depth: number;
readonly height: number;
readonly rotation?: number;
readonly elevationFromFloor?: number;
readonly label?: string | null;
}
export interface UpdateFurnitureItemDto {
readonly type?: FurnitureType;
readonly x?: number;
readonly y?: number;
readonly width?: number;
readonly depth?: number;
readonly height?: number;
readonly rotation?: number;
readonly elevationFromFloor?: number;
readonly label?: string | null;
}
// ── Annotation ──
export interface Annotation {
readonly id: string;
readonly roomId: string;
readonly x: number;
readonly y: number;
readonly text: string;
readonly fontSize?: number;
readonly color?: string;
/** If set, this annotation follows the item with this ID. x,y become offsets from the item. */
readonly attachedToId?: string;
}
// ── Batch sync DTOs ──
export interface BatchSyncOpeningsDto {
readonly create: readonly CreateWallOpeningDto[];
readonly update: readonly { readonly id: string; readonly data: UpdateWallOpeningDto }[];
readonly delete: readonly string[];
}
export interface BatchSyncElectricalDto {
readonly create: readonly CreateElectricalItemDto[];
readonly update: readonly { readonly id: string; readonly data: UpdateElectricalItemDto }[];
readonly delete: readonly string[];
}
export interface BatchSyncFurnitureDto {
readonly create: readonly CreateFurnitureItemDto[];
readonly update: readonly { readonly id: string; readonly data: UpdateFurnitureItemDto }[];
readonly delete: readonly string[];
}
+74
View File
@@ -0,0 +1,74 @@
import type { Wall, WallOpening, ElectricalItem, FurnitureItem } from './elements.js';
export interface Point {
readonly x: number;
readonly y: number;
}
export const FLOOR_TYPES = [
'CONCRETE',
'WOOD_LIGHT',
'WOOD_MEDIUM',
'WOOD_DARK',
'WOOD_HERRINGBONE',
'TILE_WHITE',
'TILE_GRAY',
'LAMINATE',
] as const;
export type FloorType = (typeof FLOOR_TYPES)[number];
export interface Room {
readonly id: string;
readonly apartmentId: string;
readonly name: string;
readonly shape: readonly Point[];
readonly width: number | null;
readonly height: number | null;
readonly wallHeight: number;
readonly plinthHeight: number;
readonly plinthThickness: number;
readonly order: number;
readonly posX: number;
readonly posY: number;
readonly floorType: FloorType;
readonly wallColor: string;
readonly createdAt: string;
readonly updatedAt: string;
}
export interface RoomFull extends Room {
readonly walls: readonly Wall[];
readonly openings: readonly WallOpening[];
readonly electricalItems: readonly ElectricalItem[];
readonly furnitureItems: readonly FurnitureItem[];
}
export interface CreateRoomDto {
readonly name: string;
readonly shape?: readonly Point[];
readonly width?: number | null;
readonly height?: number | null;
readonly wallHeight?: number;
readonly plinthHeight?: number;
readonly plinthThickness?: number;
readonly order?: number;
readonly posX?: number;
readonly posY?: number;
readonly floorType?: FloorType;
readonly wallColor?: string;
}
export interface UpdateRoomDto {
readonly name?: string;
readonly shape?: readonly Point[];
readonly width?: number | null;
readonly height?: number | null;
readonly wallHeight?: number;
readonly plinthHeight?: number;
readonly plinthThickness?: number;
readonly order?: number;
readonly posX?: number;
readonly posY?: number;
readonly floorType?: FloorType;
readonly wallColor?: string;
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
passWithNoTests: true,
},
});