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
+35
View File
@@ -0,0 +1,35 @@
# Dependencies
node_modules/
# Build output
dist/
*.tsbuildinfo
# Environment
.env
.env.local
.env.*.local
# Prisma SQLite
*.db
*.db-journal
# Turbo
.turbo/
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Test coverage
coverage/
# Logs
*.log
npm-debug.log*
+8
View File
@@ -0,0 +1,8 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"arrowParens": "always"
}
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>House Plan Maker</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@house-plan-maker/client",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --build && vite build",
"preview": "vite preview",
"test": "vitest run",
"lint": "eslint src/"
},
"dependencies": {
"@house-plan-maker/shared": "*",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"i18next": "^26.0.3",
"i18next-browser-languagedetector": "^8.2.1",
"jspdf": "^4.2.1",
"konva": "^9.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-i18next": "^17.0.2",
"react-konva": "^19.2.3",
"react-router": "^7.14.0",
"three": "^0.183.2"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/three": "^0.183.1",
"@vitejs/plugin-react": "^4.3.0",
"jsdom": "^26.0.0",
"typescript": "^5.7.0",
"vite": "^6.1.0",
"vitest": "^3.0.0"
}
}
@@ -0,0 +1,292 @@
{
"app.title": "House Plan Maker",
"wall.north": "North Wall",
"wall.south": "South Wall",
"wall.east": "East Wall",
"wall.west": "West Wall",
"wall.other": "Wall",
"furniture.bed": "Bed",
"furniture.desk": "Desk",
"furniture.wardrobe": "Wardrobe",
"furniture.sofa": "Sofa",
"furniture.table": "Table",
"furniture.chair": "Chair",
"furniture.shelf": "Shelf",
"furniture.nightstand": "Nightstand",
"furniture.dresser": "Dresser",
"furniture.bookcase": "Bookcase",
"furniture.tv": "TV",
"furniture.ac_unit": "AC Unit",
"furniture.other": "Other",
"nav.apartments": "Apartments",
"breadcrumb.apartments": "Apartments",
"breadcrumb.apartmentDetails": "Apartment Details",
"breadcrumb.roomEditor": "Room Editor",
"breadcrumb.floorPlan": "Floor Plan",
"apartments.title": "Apartments",
"apartments.new": "New Apartment",
"apartments.create": "Create Apartment",
"apartments.empty.title": "No apartments yet",
"apartments.empty.description": "Create your first apartment to start planning rooms and layouts.",
"apartments.delete.title": "Delete Apartment",
"apartments.delete.message": "Are you sure you want to delete \"{{name}}\"? This will also delete all rooms and their contents. This action cannot be undone.",
"apartments.error.load": "Failed to load apartments",
"apartments.error.operation": "Operation failed",
"apartments.error.delete": "Failed to delete apartment",
"apartment.notFound": "Apartment not found",
"apartmentForm.titleNew": "New Apartment",
"apartmentForm.titleEdit": "Edit Apartment",
"apartmentForm.name": "Name",
"apartmentForm.namePlaceholder": "e.g., Main Apartment",
"apartmentForm.address": "Address",
"apartmentForm.addressPlaceholder": "e.g., 123 Main St",
"apartmentForm.totalArea": "Total Area (m\u00b2)",
"apartmentForm.totalAreaPlaceholder": "e.g., 75",
"apartmentForm.saving": "Saving...",
"apartmentForm.creating": "Creating...",
"apartmentForm.saveChanges": "Save Changes",
"apartmentForm.createApartment": "Create Apartment",
"apartmentCard.area": "Area:",
"apartmentCard.rooms": "Rooms:",
"apartmentCard.edit": "Edit",
"apartmentCard.delete": "Delete",
"rooms.title": "Rooms",
"rooms.add": "Add Room",
"rooms.addFirst": "Add First Room",
"rooms.fromTemplate": "From Template",
"rooms.empty.title": "No rooms yet",
"rooms.empty.description": "Add rooms to this apartment to start designing floor plans.",
"rooms.delete.title": "Delete Room",
"rooms.delete.message": "Are you sure you want to delete \"{{name}}\"? All walls, openings, electrical items, and furniture in this room will be deleted. This action cannot be undone.",
"rooms.error.load": "Failed to load apartment",
"rooms.error.operation": "Operation failed",
"rooms.error.create": "Failed to create room",
"rooms.error.delete": "Failed to delete room",
"rooms.count": "{{count}} room",
"rooms.count_other": "{{count}} rooms",
"rooms.dimensions": "{{width}} x {{height}} m",
"rooms.vertices": "{{count}} vertices",
"rooms.wallHeight": "Wall height: {{value}} m",
"rooms.plinth": "Plinth: {{value}} m",
"roomForm.titleNew": "New Room",
"roomForm.titleEdit": "Edit Room",
"roomForm.name": "Room Name",
"roomForm.namePlaceholder": "e.g., Living Room",
"roomForm.shape": "Shape",
"roomForm.rectangular": "Rectangular",
"roomForm.customPolygon": "Custom Polygon",
"roomForm.width": "Width (m)",
"roomForm.widthPlaceholder": "e.g., 4.5",
"roomForm.height": "Height (m)",
"roomForm.heightPlaceholder": "e.g., 3.2",
"roomForm.customNote": "Custom polygon shapes can be drawn in the room editor.",
"roomForm.currentVertices": " Current shape has {{count}} vertices.",
"roomForm.defaultShape": " A default rectangular shape will be created.",
"roomForm.wallProperties": "Wall Properties",
"roomForm.wallHeight": "Wall Height (m)",
"roomForm.plinthProperties": "Plinth Properties",
"roomForm.plinthHeight": "Height (m)",
"roomForm.plinthThickness": "Thickness (m)",
"roomForm.plinthHeightHint": "Default: 0.06 m",
"roomForm.plinthThicknessHint": "Default: 0.01 m",
"roomForm.saving": "Saving...",
"roomForm.creating": "Creating...",
"roomForm.saveChanges": "Save Changes",
"roomForm.createRoom": "Create Room",
"roomForm.widthError": "Width must be positive",
"roomForm.heightError": "Height must be positive",
"roomCard.edit": "Edit",
"roomCard.delete": "Delete",
"common.cancel": "Cancel",
"common.delete": "Delete",
"common.deleting": "Deleting...",
"common.loading": "Loading...",
"common.dismissError": "Dismiss error",
"editor.save": "Save",
"editor.saving": "Saving...",
"editor.saveFailed": "Save failed: {{error}}",
"editor.error.load": "Failed to load room",
"editor.roomNotFound": "Room not found",
"editor.loading3D": "Loading 3D view...",
"editor.unsavedChanges": "You have unsaved changes. Are you sure you want to leave?",
"toolbar.select": "Select",
"toolbar.door": "Door",
"toolbar.window": "Window",
"toolbar.electrical": "Electrical",
"toolbar.furniture": "Furniture",
"toolbar.measure": "Measure",
"toolbar.annotate": "Annotate",
"toolbar.undo": "Undo (Ctrl+Z)",
"toolbar.redo": "Redo (Ctrl+Shift+Z)",
"toolbar.zoomIn": "Zoom in",
"toolbar.zoomOut": "Zoom out",
"toolbar.grid": "Grid",
"toolbar.snap": "Snap",
"toolbar.walls": "Walls",
"toolbar.elec": "Elec",
"toolbar.furn": "Furn",
"toolbar.meas": "Meas",
"toolbar.toggleGrid": "Toggle grid",
"toolbar.toggleSnap": "Toggle snap",
"toolbar.toggleWalls": "Toggle walls layer",
"toolbar.toggleElectrical": "Toggle electrical layer",
"toolbar.toggleFurniture": "Toggle furniture layer",
"toolbar.toggleMeasurements": "Toggle measurements layer",
"toolbar.import": "Import JSON",
"toolbar.export": "Export (PNG/PDF/JSON)",
"toolbar.saveShortcut": "Save (Ctrl+S)",
"toolbar.view2D": "2D",
"toolbar.view3D": "3D Preview",
"toolbar.viewProjections": "Projections",
"toolbar.alignLeft": "Align left",
"toolbar.alignCenterH": "Center horizontal",
"toolbar.alignRight": "Align right",
"toolbar.alignTop": "Align top",
"toolbar.alignCenterV": "Center vertical",
"toolbar.alignBottom": "Align bottom",
"toolbar.distributeH": "Distribute horizontal",
"toolbar.distributeV": "Distribute vertical",
"properties.title": "Properties",
"properties.area": "Area",
"properties.perimeter": "Perimeter",
"properties.noSelection": "No element selected",
"properties.selectHint": "Click an element to see its properties",
"properties.multipleSelected": "{{count}} elements selected",
"properties.roomInfo": "Room Info",
"properties.name": "Name",
"properties.wallHeight": "Wall height",
"properties.plinthHeight": "Plinth height",
"properties.walls": "Walls",
"properties.openings": "Openings",
"properties.wall": "Wall",
"properties.length": "Length",
"properties.thickness": "Thickness",
"properties.startX": "Start X",
"properties.startY": "Start Y",
"properties.endX": "End X",
"properties.endY": "End Y",
"properties.direction": "Direction",
"properties.door": "Door",
"properties.window": "Window",
"properties.width": "Width",
"properties.height": "Height",
"properties.position": "Position",
"properties.elevation": "Elevation",
"properties.wallLength": "Wall length",
"properties.type": "Type",
"properties.variant": "Variant",
"properties.x": "X",
"properties.y": "Y",
"properties.rotation": "Rotation",
"properties.wallMounted": "Wall-mounted",
"properties.yes": "Yes",
"properties.depth": "Depth",
"properties.wallColor": "Wall color",
"properties.floorType": "Floor",
"floor.CONCRETE": "Concrete",
"floor.WOOD_LIGHT": "Light Wood",
"floor.WOOD_MEDIUM": "Medium Wood",
"floor.WOOD_DARK": "Dark Wood",
"floor.WOOD_HERRINGBONE": "Herringbone",
"floor.TILE_WHITE": "White Tile",
"floor.TILE_GRAY": "Gray Tile",
"floor.LAMINATE": "Laminate",
"properties.addNote": "Add note",
"properties.stand": "Stand",
"properties.openDirection": "Open direction",
"properties.openDir.LEFT": "Left",
"properties.openDir.RIGHT": "Right",
"properties.openDir.INWARD": "Inward",
"properties.openDir.OUTWARD": "Outward",
"properties.openDir.LEFT": "Left",
"properties.openDir.RIGHT": "Right",
"properties.openDir.INWARD": "Inward",
"properties.openDir.OUTWARD": "Outward",
"electrical.title": "Electrical",
"electrical.outlets": "Outlets",
"electrical.switches": "Switches",
"electrical.junction": "Junction",
"electrical.lights": "Lights",
"electrical.cable": "Cable",
"furniture.title": "Furniture",
"cableLength.label": "Cable length:",
"projection.title": "Wall Projections",
"projection.grid": "Grid",
"projection.tabs": "Tabs",
"projection.noWall": "No wall selected",
"templates.title": "New Room from Template",
"templates.create": "Create Room",
"templates.roomName": "Room Name (optional)",
"templates.bedroom": "Bedroom",
"templates.bedroomDesc": "Standard bedroom (4m x 3.5m) with door and window",
"templates.kitchen": "Kitchen",
"templates.kitchenDesc": "Kitchen (3.5m x 3m) with door",
"templates.bathroom": "Bathroom",
"templates.bathroomDesc": "Bathroom (2.5m x 2m)",
"templates.livingRoom": "Living Room",
"templates.livingRoomDesc": "Spacious living room (5m x 4m) with window",
"templates.office": "Office",
"templates.officeDesc": "Home office (3m x 2.5m)",
"templates.emptyRoom": "Empty Room",
"templates.emptyRoomDesc": "Custom empty room (3m x 3m)",
"export.title": "Export",
"export.format": "Format",
"export.png": "PNG Image",
"export.pdf": "PDF Document",
"export.json": "JSON Data",
"export.scope": "Scope",
"export.currentView": "Current View",
"export.allRoomViews": "All Room Views",
"export.options": "Options",
"export.includeGrid": "Include grid",
"export.scaleFactor": "Scale factor:",
"export.exporting": "Exporting...",
"export.exportBtn": "Export",
"export.generating": "Generating export...",
"export.error.3dNotAvailable": "3D canvas not available",
"export.error.2dNotAvailable": "2D canvas not available",
"export.error.failed": "Export failed",
"floorPlan.back": "Back",
"floorPlan.reset": "Reset",
"floorPlan.dblClickToEdit": "Double-click to edit",
"annotation.editPrompt": "Edit annotation text:",
"editor.importFailed": "Import failed: {{error}}",
"projection.clickToPlace": "Click on wall to place item",
"export.pdfTitle": "House Plan",
"export.pdfGenerated": "Generated: {{date}}",
"export.pdfRoomCount": "{{count}} room",
"export.pdfRoomCount_other": "{{count}} rooms",
"export.pdfWallProjections": "{{name}} - Wall Projections",
"toast.notifications": "Notifications",
"toast.dismiss": "Dismiss notification",
"theme.toggle": "Toggle dark mode",
"language.toggle": "Switch language"
}
@@ -0,0 +1,298 @@
{
"app.title": "Планировщик квартир",
"wall.north": "Северная стена",
"wall.south": "Южная стена",
"wall.east": "Восточная стена",
"wall.west": "Западная стена",
"wall.other": "Стена",
"furniture.bed": "Кровать",
"furniture.desk": "Стол",
"furniture.wardrobe": "Шкаф",
"furniture.sofa": "Диван",
"furniture.table": "Стол обеденный",
"furniture.chair": "Стул",
"furniture.shelf": "Полка",
"furniture.nightstand": "Тумбочка",
"furniture.dresser": "Комод",
"furniture.bookcase": "Книжный шкаф",
"furniture.tv": "Телевизор",
"furniture.ac_unit": "Кондиционер",
"furniture.other": "Другое",
"nav.apartments": "Квартиры",
"breadcrumb.apartments": "Квартиры",
"breadcrumb.apartmentDetails": "Детали квартиры",
"breadcrumb.roomEditor": "Редактор комнаты",
"breadcrumb.floorPlan": "План этажа",
"apartments.title": "Квартиры",
"apartments.new": "Новая квартира",
"apartments.create": "Создать квартиру",
"apartments.empty.title": "Нет квартир",
"apartments.empty.description": "Создайте первую квартиру, чтобы начать планировку комнат.",
"apartments.delete.title": "Удалить квартиру",
"apartments.delete.message": "Вы уверены, что хотите удалить \"{{name}}\"? Все комнаты и их содержимое также будут удалены. Это действие нельзя отменить.",
"apartments.error.load": "Не удалось загрузить квартиры",
"apartments.error.operation": "Операция не удалась",
"apartments.error.delete": "Не удалось удалить квартиру",
"apartment.notFound": "Квартира не найдена",
"apartmentForm.titleNew": "Новая квартира",
"apartmentForm.titleEdit": "Редактировать квартиру",
"apartmentForm.name": "Название",
"apartmentForm.namePlaceholder": "напр., Основная квартира",
"apartmentForm.address": "Адрес",
"apartmentForm.addressPlaceholder": "напр., ул. Главная 123",
"apartmentForm.totalArea": "Общая площадь (м\u00b2)",
"apartmentForm.totalAreaPlaceholder": "напр., 75",
"apartmentForm.saving": "Сохранение...",
"apartmentForm.creating": "Создание...",
"apartmentForm.saveChanges": "Сохранить изменения",
"apartmentForm.createApartment": "Создать квартиру",
"apartmentCard.area": "Площадь:",
"apartmentCard.rooms": "Комнаты:",
"apartmentCard.edit": "Изменить",
"apartmentCard.delete": "Удалить",
"rooms.title": "Комнаты",
"rooms.add": "Добавить комнату",
"rooms.addFirst": "Добавить первую комнату",
"rooms.fromTemplate": "Из шаблона",
"rooms.empty.title": "Нет комнат",
"rooms.empty.description": "Добавьте комнаты для создания планировок.",
"rooms.delete.title": "Удалить комнату",
"rooms.delete.message": "Вы уверены, что хотите удалить \"{{name}}\"? Все стены, проёмы, электрика и мебель в этой комнате будут удалены. Это действие нельзя отменить.",
"rooms.error.load": "Не удалось загрузить квартиру",
"rooms.error.operation": "Операция не удалась",
"rooms.error.create": "Не удалось создать комнату",
"rooms.error.delete": "Не удалось удалить комнату",
"rooms.count": "{{count}} комната",
"rooms.count_one": "{{count}} комната",
"rooms.count_few": "{{count}} комнаты",
"rooms.count_many": "{{count}} комнат",
"rooms.count_other": "{{count}} комнат",
"rooms.dimensions": "{{width}} x {{height}} м",
"rooms.vertices": "{{count}} вершин",
"rooms.wallHeight": "Высота стен: {{value}} м",
"rooms.plinth": "Плинтус: {{value}} м",
"roomForm.titleNew": "Новая комната",
"roomForm.titleEdit": "Редактировать комнату",
"roomForm.name": "Название комнаты",
"roomForm.namePlaceholder": "напр., Гостиная",
"roomForm.shape": "Форма",
"roomForm.rectangular": "Прямоугольная",
"roomForm.customPolygon": "Произвольный многоугольник",
"roomForm.width": "Ширина (м)",
"roomForm.widthPlaceholder": "напр., 4,5",
"roomForm.height": "Длина (м)",
"roomForm.heightPlaceholder": "напр., 3,2",
"roomForm.customNote": "Произвольные формы можно нарисовать в редакторе комнаты.",
"roomForm.currentVertices": " Текущая форма имеет {{count}} вершин.",
"roomForm.defaultShape": " Будет создана прямоугольная форма по умолчанию.",
"roomForm.wallProperties": "Свойства стен",
"roomForm.wallHeight": "Высота стен (м)",
"roomForm.plinthProperties": "Свойства плинтуса",
"roomForm.plinthHeight": "Высота (м)",
"roomForm.plinthThickness": "Толщина (м)",
"roomForm.plinthHeightHint": "По умолчанию: 0,06 м",
"roomForm.plinthThicknessHint": "По умолчанию: 0,01 м",
"roomForm.saving": "Сохранение...",
"roomForm.creating": "Создание...",
"roomForm.saveChanges": "Сохранить изменения",
"roomForm.createRoom": "Создать комнату",
"roomForm.widthError": "Ширина должна быть положительной",
"roomForm.heightError": "Высота должна быть положительной",
"roomCard.edit": "Изменить",
"roomCard.delete": "Удалить",
"common.cancel": "Отмена",
"common.delete": "Удалить",
"common.deleting": "Удаление...",
"common.loading": "Загрузка...",
"common.dismissError": "Закрыть ошибку",
"editor.save": "Сохранить",
"editor.saving": "Сохранение...",
"editor.saveFailed": "Ошибка сохранения: {{error}}",
"editor.error.load": "Не удалось загрузить комнату",
"editor.roomNotFound": "Комната не найдена",
"editor.loading3D": "Загрузка 3D вида...",
"editor.unsavedChanges": "У вас есть несохранённые изменения. Вы уверены, что хотите уйти?",
"toolbar.select": "Выбрать",
"toolbar.door": "Дверь",
"toolbar.window": "Окно",
"toolbar.electrical": "Электрика",
"toolbar.furniture": "Мебель",
"toolbar.measure": "Измерить",
"toolbar.annotate": "Аннотация",
"toolbar.undo": "Отменить (Ctrl+Z)",
"toolbar.redo": "Повторить (Ctrl+Shift+Z)",
"toolbar.zoomIn": "Увеличить",
"toolbar.zoomOut": "Уменьшить",
"toolbar.grid": "Сетка",
"toolbar.snap": "Привязка",
"toolbar.walls": "Стены",
"toolbar.elec": "Элек",
"toolbar.furn": "Мебель",
"toolbar.meas": "Разм",
"toolbar.toggleGrid": "Переключить сетку",
"toolbar.toggleSnap": "Переключить привязку",
"toolbar.toggleWalls": "Переключить слой стен",
"toolbar.toggleElectrical": "Переключить слой электрики",
"toolbar.toggleFurniture": "Переключить слой мебели",
"toolbar.toggleMeasurements": "Переключить слой размеров",
"toolbar.import": "Импорт JSON",
"toolbar.export": "Экспорт (PNG/PDF/JSON)",
"toolbar.saveShortcut": "Сохранить (Ctrl+S)",
"toolbar.view2D": "2D",
"toolbar.view3D": "3D Просмотр",
"toolbar.viewProjections": "Проекции",
"toolbar.alignLeft": "Выровнять по левому краю",
"toolbar.alignCenterH": "Центрировать по горизонтали",
"toolbar.alignRight": "Выровнять по правому краю",
"toolbar.alignTop": "Выровнять по верхнему краю",
"toolbar.alignCenterV": "Центрировать по вертикали",
"toolbar.alignBottom": "Выровнять по нижнему краю",
"toolbar.distributeH": "Распределить по горизонтали",
"toolbar.distributeV": "Распределить по вертикали",
"properties.title": "Свойства",
"properties.area": "Площадь",
"properties.perimeter": "Периметр",
"properties.noSelection": "Элемент не выбран",
"properties.selectHint": "Нажмите на элемент, чтобы увидеть его свойства",
"properties.multipleSelected": "Выбрано элементов: {{count}}",
"properties.roomInfo": "Информация о комнате",
"properties.name": "Название",
"properties.wallHeight": "Высота стен",
"properties.plinthHeight": "Высота плинтуса",
"properties.walls": "Стены",
"properties.openings": "Проёмы",
"properties.wall": "Стена",
"properties.length": "Длина",
"properties.thickness": "Толщина",
"properties.startX": "Начало X",
"properties.startY": "Начало Y",
"properties.endX": "Конец X",
"properties.endY": "Конец Y",
"properties.direction": "Направление",
"properties.door": "Дверь",
"properties.window": "Окно",
"properties.width": "Ширина",
"properties.height": "Высота",
"properties.position": "Положение",
"properties.elevation": "Высота от пола",
"properties.wallLength": "Длина стены",
"properties.type": "Тип",
"properties.variant": "Вариант",
"properties.x": "X",
"properties.y": "Y",
"properties.rotation": "Поворот",
"properties.wallMounted": "На стене",
"properties.yes": "Да",
"properties.depth": "Глубина",
"properties.wallColor": "Цвет стен",
"properties.floorType": "Пол",
"floor.CONCRETE": "Бетон",
"floor.WOOD_LIGHT": "Светлое дерево",
"floor.WOOD_MEDIUM": "Среднее дерево",
"floor.WOOD_DARK": "Тёмное дерево",
"floor.WOOD_HERRINGBONE": "Ёлочка",
"floor.TILE_WHITE": "Белая плитка",
"floor.TILE_GRAY": "Серая плитка",
"floor.LAMINATE": "Ламинат",
"properties.addNote": "Добавить заметку",
"properties.stand": "Подставка",
"properties.openDirection": "Направление открытия",
"properties.openDir.LEFT": "Влево",
"properties.openDir.RIGHT": "Вправо",
"properties.openDir.INWARD": "Внутрь",
"properties.openDir.OUTWARD": "Наружу",
"properties.openDir.LEFT": "Влево",
"properties.openDir.RIGHT": "Вправо",
"properties.openDir.INWARD": "Внутрь",
"properties.openDir.OUTWARD": "Наружу",
"electrical.title": "Электрика",
"electrical.outlets": "Розетки",
"electrical.switches": "Выключатели",
"electrical.junction": "Распределительная коробка",
"electrical.lights": "Освещение",
"electrical.cable": "Кабель",
"furniture.title": "Мебель",
"cableLength.label": "Длина кабеля:",
"projection.title": "Проекции стен",
"projection.grid": "Сетка",
"projection.tabs": "Вкладки",
"projection.noWall": "Стена не выбрана",
"templates.title": "Новая комната из шаблона",
"templates.create": "Создать комнату",
"templates.roomName": "Название комнаты (необязательно)",
"templates.bedroom": "Спальня",
"templates.bedroomDesc": "Стандартная спальня (4м x 3,5м) с дверью и окном",
"templates.kitchen": "Кухня",
"templates.kitchenDesc": "Кухня (3,5м x 3м) с дверью",
"templates.bathroom": "Ванная",
"templates.bathroomDesc": "Ванная комната (2,5м x 2м)",
"templates.livingRoom": "Гостиная",
"templates.livingRoomDesc": "Просторная гостиная (5м x 4м) с окном",
"templates.office": "Кабинет",
"templates.officeDesc": "Домашний кабинет (3м x 2,5м)",
"templates.emptyRoom": "Пустая комната",
"templates.emptyRoomDesc": "Пустая комната (3м x 3м)",
"export.title": "Экспорт",
"export.format": "Формат",
"export.png": "PNG изображение",
"export.pdf": "PDF документ",
"export.json": "JSON данные",
"export.scope": "Область",
"export.currentView": "Текущий вид",
"export.allRoomViews": "Все виды комнаты",
"export.options": "Параметры",
"export.includeGrid": "Включить сетку",
"export.scaleFactor": "Масштаб:",
"export.exporting": "Экспорт...",
"export.exportBtn": "Экспорт",
"export.generating": "Создание экспорта...",
"export.error.3dNotAvailable": "3D холст недоступен",
"export.error.2dNotAvailable": "2D холст недоступен",
"export.error.failed": "Ошибка экспорта",
"floorPlan.back": "Назад",
"floorPlan.reset": "Сброс",
"floorPlan.dblClickToEdit": "Двойной клик для редактирования",
"annotation.editPrompt": "Редактировать текст аннотации:",
"editor.importFailed": "Ошибка импорта: {{error}}",
"projection.clickToPlace": "Нажмите на стену, чтобы разместить элемент",
"export.pdfTitle": "План дома",
"export.pdfGenerated": "Создано: {{date}}",
"export.pdfRoomCount": "{{count}} комната",
"export.pdfRoomCount_one": "{{count}} комната",
"export.pdfRoomCount_few": "{{count}} комнаты",
"export.pdfRoomCount_many": "{{count}} комнат",
"export.pdfRoomCount_other": "{{count}} комнат",
"export.pdfWallProjections": "{{name}} — Проекции стен",
"toast.notifications": "Уведомления",
"toast.dismiss": "Закрыть уведомление",
"theme.toggle": "Переключить тёмную тему",
"language.toggle": "Сменить язык"
}
+340
View File
@@ -0,0 +1,340 @@
import type {
Apartment,
ApartmentWithRooms,
Room,
RoomFull,
Wall,
WallOpening,
ElectricalItem,
FurnitureItem,
CreateApartmentDto,
UpdateApartmentDto,
CreateRoomDto,
UpdateRoomDto,
CreateWallDto,
CreateWallOpeningDto,
UpdateWallOpeningDto,
CreateElectricalItemDto,
UpdateElectricalItemDto,
CreateFurnitureItemDto,
UpdateFurnitureItemDto,
BatchSyncOpeningsDto,
BatchSyncElectricalDto,
BatchSyncFurnitureDto,
ApiResponse,
ApiListResponse,
ApiErrorResponse,
} from '@house-plan-maker/shared';
const BASE_URL = '/api';
class ApiError extends Error {
readonly statusCode: number;
constructor(message: string, statusCode: number) {
super(message);
this.name = 'ApiError';
this.statusCode = statusCode;
}
}
async function request<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const url = `${BASE_URL}${path}`;
const headers: Record<string, string> = {
...((options.headers as Record<string, string>) ?? {}),
};
// Only set Content-Type for requests with a body
if (options.body) {
headers['Content-Type'] = 'application/json';
}
const response = await fetch(url, { ...options, headers });
if (!response.ok) {
let errorMessage = `Request failed with status ${response.status}`;
try {
const errorBody = (await response.json()) as ApiErrorResponse;
errorMessage = errorBody.error ?? errorMessage;
} catch {
// If parsing fails, use the default message
}
throw new ApiError(errorMessage, response.status);
}
// Handle 204 No Content (e.g. DELETE responses)
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}
// ── Apartments ──
export async function getApartments(): Promise<readonly Apartment[]> {
const result = await request<ApiListResponse<Apartment>>('/apartments');
return result.data;
}
export async function getApartment(id: string): Promise<ApartmentWithRooms> {
const result = await request<ApiResponse<ApartmentWithRooms>>(
`/apartments/${id}`,
);
return result.data;
}
export async function createApartment(
data: CreateApartmentDto,
): Promise<Apartment> {
const result = await request<ApiResponse<Apartment>>('/apartments', {
method: 'POST',
body: JSON.stringify(data),
});
return result.data;
}
export async function updateApartment(
id: string,
data: UpdateApartmentDto,
): Promise<Apartment> {
const result = await request<ApiResponse<Apartment>>(`/apartments/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
return result.data;
}
export async function deleteApartment(id: string): Promise<void> {
await request<void>(`/apartments/${id}`, { method: 'DELETE' });
}
// ── Rooms ──
export async function getRooms(apartmentId: string): Promise<readonly Room[]> {
const result = await request<ApiListResponse<Room>>(
`/apartments/${apartmentId}/rooms`,
);
return result.data;
}
export async function getRoomFull(roomId: string): Promise<RoomFull> {
const result = await request<ApiResponse<RoomFull>>(
`/rooms/${roomId}/full`,
);
return result.data;
}
export async function createRoom(
apartmentId: string,
data: CreateRoomDto,
): Promise<Room> {
const result = await request<ApiResponse<Room>>(
`/apartments/${apartmentId}/rooms`,
{
method: 'POST',
body: JSON.stringify(data),
},
);
return result.data;
}
export async function updateRoom(
id: string,
data: UpdateRoomDto,
): Promise<Room> {
const result = await request<ApiResponse<Room>>(`/rooms/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
return result.data;
}
export async function deleteRoom(id: string): Promise<void> {
await request<void>(`/rooms/${id}`, { method: 'DELETE' });
}
// ── Walls ──
export async function bulkUpdateWalls(
roomId: string,
walls: readonly CreateWallDto[],
): Promise<readonly Wall[]> {
const result = await request<ApiResponse<readonly Wall[]>>(
`/rooms/${roomId}/walls`,
{
method: 'PUT',
body: JSON.stringify({ walls }),
},
);
return result.data;
}
// ── Wall Openings ──
export async function createWallOpening(
roomId: string,
data: CreateWallOpeningDto,
): Promise<WallOpening> {
const result = await request<ApiResponse<WallOpening>>(
`/rooms/${roomId}/openings`,
{
method: 'POST',
body: JSON.stringify(data),
},
);
return result.data;
}
export async function updateWallOpening(
roomId: string,
openingId: string,
data: UpdateWallOpeningDto,
): Promise<WallOpening> {
const result = await request<ApiResponse<WallOpening>>(
`/rooms/${roomId}/openings/${openingId}`,
{
method: 'PUT',
body: JSON.stringify(data),
},
);
return result.data;
}
export async function deleteWallOpening(
roomId: string,
openingId: string,
): Promise<void> {
await request<void>(`/rooms/${roomId}/openings/${openingId}`, {
method: 'DELETE',
});
}
// ── Electrical Items ──
export async function createElectricalItem(
roomId: string,
data: CreateElectricalItemDto,
): Promise<ElectricalItem> {
const result = await request<ApiResponse<ElectricalItem>>(
`/rooms/${roomId}/electrical`,
{
method: 'POST',
body: JSON.stringify(data),
},
);
return result.data;
}
export async function updateElectricalItem(
roomId: string,
itemId: string,
data: UpdateElectricalItemDto,
): Promise<ElectricalItem> {
const result = await request<ApiResponse<ElectricalItem>>(
`/electrical/${itemId}`,
{
method: 'PUT',
body: JSON.stringify(data),
},
);
return result.data;
}
export async function deleteElectricalItem(
roomId: string,
itemId: string,
): Promise<void> {
await request<void>(`/electrical/${itemId}`, {
method: 'DELETE',
});
}
// ── Furniture Items ──
export async function createFurnitureItem(
roomId: string,
data: CreateFurnitureItemDto,
): Promise<FurnitureItem> {
const result = await request<ApiResponse<FurnitureItem>>(
`/rooms/${roomId}/furniture`,
{
method: 'POST',
body: JSON.stringify(data),
},
);
return result.data;
}
export async function updateFurnitureItem(
roomId: string,
itemId: string,
data: UpdateFurnitureItemDto,
): Promise<FurnitureItem> {
const result = await request<ApiResponse<FurnitureItem>>(
`/furniture/${itemId}`,
{
method: 'PUT',
body: JSON.stringify(data),
},
);
return result.data;
}
export async function deleteFurnitureItem(
roomId: string,
itemId: string,
): Promise<void> {
await request<void>(`/furniture/${itemId}`, {
method: 'DELETE',
});
}
// ── Batch Sync ──
export async function batchSyncOpenings(
roomId: string,
data: BatchSyncOpeningsDto,
): Promise<readonly WallOpening[]> {
const result = await request<ApiListResponse<WallOpening>>(
`/rooms/${roomId}/openings/batch`,
{
method: 'PUT',
body: JSON.stringify(data),
},
);
return result.data;
}
export async function batchSyncElectrical(
roomId: string,
data: BatchSyncElectricalDto,
): Promise<readonly ElectricalItem[]> {
const result = await request<ApiListResponse<ElectricalItem>>(
`/rooms/${roomId}/electrical/batch`,
{
method: 'PUT',
body: JSON.stringify(data),
},
);
return result.data;
}
export async function batchSyncFurniture(
roomId: string,
data: BatchSyncFurnitureDto,
): Promise<readonly FurnitureItem[]> {
const result = await request<ApiListResponse<FurnitureItem>>(
`/rooms/${roomId}/furniture/batch`,
{
method: 'PUT',
body: JSON.stringify(data),
},
);
return result.data;
}
export { ApiError };
@@ -0,0 +1,72 @@
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import type { Apartment } from '@house-plan-maker/shared';
import { Card, CardHeader, CardBody } from '../ui/Card';
import { Button } from '../ui/Button';
import styles from './apartment-card.module.css';
interface ApartmentCardProps {
apartment: Apartment;
roomCount: number;
onEdit: (apartment: Apartment) => void;
onDelete: (apartment: Apartment) => void;
}
export function ApartmentCard({
apartment,
roomCount,
onEdit,
onDelete,
}: ApartmentCardProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const handleClick = () => {
navigate(`/apartments/${apartment.id}`);
};
const handleEdit = (event: React.MouseEvent) => {
event.stopPropagation();
onEdit(apartment);
};
const handleDelete = (event: React.MouseEvent) => {
event.stopPropagation();
onDelete(apartment);
};
return (
<Card interactive className={styles.card} onClick={handleClick}>
<CardHeader>
<div>
<h3 className={styles.name}>{apartment.name}</h3>
{apartment.address && (
<p className={styles.address}>{apartment.address}</p>
)}
</div>
<div className={styles.actions}>
<Button variant="ghost" size="sm" onClick={handleEdit} aria-label={t('apartmentCard.edit')}>
{t('apartmentCard.edit')}
</Button>
<Button variant="ghost" size="sm" onClick={handleDelete} aria-label={t('apartmentCard.delete')}>
{t('apartmentCard.delete')}
</Button>
</div>
</CardHeader>
<CardBody>
<div className={styles.meta}>
{apartment.totalArea != null && (
<span className={styles.metaItem}>
<span className={styles.metaLabel}>{t('apartmentCard.area')}</span>
{apartment.totalArea} m&sup2;
</span>
)}
<span className={styles.metaItem}>
<span className={styles.metaLabel}>{t('apartmentCard.rooms')}</span>
{roomCount}
</span>
</div>
</CardBody>
</Card>
);
}
@@ -0,0 +1,134 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import type { Apartment } from '@house-plan-maker/shared';
import { createApartmentSchema } from '@house-plan-maker/shared';
import { Modal } from '../ui/Modal';
import { Input } from '../ui/Input';
import { Button } from '../ui/Button';
import styles from './apartment-form-modal.module.css';
interface ApartmentFormModalProps {
open: boolean;
onClose: () => void;
onSubmit: (data: { name: string; address: string; totalArea: string }) => void;
apartment?: Apartment | null;
loading?: boolean;
}
interface FormErrors {
name?: string;
address?: string;
totalArea?: string;
}
export function ApartmentFormModal({
open,
onClose,
onSubmit,
apartment,
loading = false,
}: ApartmentFormModalProps) {
const { t } = useTranslation();
const [name, setName] = useState('');
const [address, setAddress] = useState('');
const [totalArea, setTotalArea] = useState('');
const [errors, setErrors] = useState<FormErrors>({});
const isEditing = apartment != null;
useEffect(() => {
if (open) {
setName(apartment?.name ?? '');
setAddress(apartment?.address ?? '');
setTotalArea(apartment?.totalArea != null ? String(apartment.totalArea) : '');
setErrors({});
}
}, [open, apartment]);
const validate = (): boolean => {
const result = createApartmentSchema.safeParse({
name: name.trim(),
address: address.trim() || null,
totalArea: totalArea.trim() ? Number(totalArea) : null,
});
if (!result.success) {
const fieldErrors: FormErrors = {};
for (const issue of result.error.issues) {
const field = issue.path[0] as keyof FormErrors;
if (!fieldErrors[field]) {
fieldErrors[field] = issue.message;
}
}
setErrors(fieldErrors);
return false;
}
setErrors({});
return true;
};
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
if (validate()) {
onSubmit({ name: name.trim(), address: address.trim(), totalArea: totalArea.trim() });
}
};
return (
<Modal
open={open}
onClose={onClose}
title={isEditing ? t('apartmentForm.titleEdit') : t('apartmentForm.titleNew')}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={loading}>
{t('common.cancel')}
</Button>
<Button
type="submit"
form="apartment-form"
disabled={loading}
>
{loading
? isEditing
? t('apartmentForm.saving')
: t('apartmentForm.creating')
: isEditing
? t('apartmentForm.saveChanges')
: t('apartmentForm.createApartment')}
</Button>
</>
}
>
<form id="apartment-form" className={styles.form} onSubmit={handleSubmit}>
<Input
label={t('apartmentForm.name')}
value={name}
onChange={(e) => setName(e.target.value)}
error={errors.name}
placeholder={t('apartmentForm.namePlaceholder')}
autoFocus
required
/>
<Input
label={t('apartmentForm.address')}
value={address}
onChange={(e) => setAddress(e.target.value)}
error={errors.address}
placeholder={t('apartmentForm.addressPlaceholder')}
/>
<Input
label={t('apartmentForm.totalArea')}
type="number"
value={totalArea}
onChange={(e) => setTotalArea(e.target.value)}
error={errors.totalArea}
placeholder={t('apartmentForm.totalAreaPlaceholder')}
min="0"
step="0.01"
/>
</form>
</Modal>
);
}
@@ -0,0 +1,40 @@
.card {
position: relative;
}
.name {
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
margin: 0;
}
.address {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
margin-top: var(--space-1);
}
.meta {
display: flex;
align-items: center;
gap: var(--space-4);
margin-top: var(--space-3);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.metaItem {
display: flex;
align-items: center;
gap: var(--space-1);
}
.metaLabel {
color: var(--color-text-muted);
}
.actions {
display: flex;
gap: var(--space-1);
}
@@ -0,0 +1,5 @@
.form {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
@@ -0,0 +1,621 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Stage } from 'react-konva';
import type Konva from 'konva';
import type { Point } from '@house-plan-maker/shared';
import { useZoomPan, useSelection, useSceneData } from './context/EditorContext';
import { useUndoRedo } from './context/UndoRedoContext';
import { useEditorZoom } from './hooks/useEditorZoom';
import { GridLayer } from './layers/GridLayer';
import { WallLayer } from './layers/WallLayer';
import { OpeningLayer, type OpeningPreview } from './layers/OpeningLayer';
import { ElectricalLayer } from './layers/ElectricalLayer';
import { FurnitureLayer } from './layers/FurnitureLayer';
import { SelectionLayer } from './layers/SelectionLayer';
import { MeasurementLayer } from './layers/MeasurementLayer';
import { RoomLabelLayer } from './layers/RoomLabelLayer';
import { ScaleBar } from './overlays/ScaleBar';
import {
hitTest,
selectionRect,
elementsInRect,
selectedBoundingBox,
} from './tools/SelectTool';
import { computeDoorPreview, createDoorOpening } from './tools/DoorTool';
import { computeWindowPreview, createWindowOpening } from './tools/WindowTool';
import { computeElectricalPreview, createElectricalItemFromPlacement } from './tools/ElectricalTool';
import { computeFurniturePreview, createFurnitureItemFromPlacement } from './tools/FurnitureTool';
import { startMeasurement, updateMeasurement, finishMeasurement } from './tools/MeasureTool';
import { ELECTRICAL_SYMBOL_DEFS } from './symbols/electrical';
import { FURNITURE_DEFS } from './symbols/furniture';
import { AnnotationLayer } from './layers/AnnotationLayer';
import { MeasureOverlayLayer } from './layers/MeasureOverlayLayer';
import { generateLocalId } from './utils/geometry';
import type { EditorCommand, MeasurementState } from './types';
interface EditorCanvasProps {
readonly width: number;
readonly height: number;
readonly onStageRef?: (stage: Konva.Stage | null) => void;
}
/** Cursor styles per tool. */
const TOOL_CURSORS: Record<string, string> = {
select: 'default',
door: 'crosshair',
window: 'crosshair',
electrical: 'crosshair',
furniture: 'crosshair',
measure: 'crosshair',
annotate: 'text',
};
export function EditorCanvas({ width, height, onStageRef }: EditorCanvasProps) {
const { t } = useTranslation();
const { zoom, panOffset, setZoom, setPanOffset } = useZoomPan();
const {
selectedIds,
activeTool,
dispatch: selectionDispatch,
selectElement,
clearSelection,
} = useSelection();
const {
walls,
openings,
electricalItems,
furnitureItems,
room,
gridSize,
gridVisible,
layerVisibility,
selectedElectricalIndex,
selectedFurnitureIndex,
annotations,
dispatch: sceneDispatch,
addOpening,
addElectrical,
addFurniture,
addAnnotation,
updateAnnotation,
} = useSceneData();
const { execute } = useUndoRedo();
// Use scene dispatch for actions that affect scene data
const dispatch = sceneDispatch;
const stageRef = useRef<Konva.Stage | null>(null);
// Task 5: Fix useEffect missing dependency array
useEffect(() => {
onStageRef?.(stageRef.current);
}, [onStageRef]);
const {
handleWheel,
screenToWorld,
isPanningRef,
handlePanStart,
startLeftMousePan,
handlePanMove,
handlePanEnd,
} = useEditorZoom({
zoom,
panOffset,
onZoomChange: (z) => setZoom(z),
onPanChange: (offset) => setPanOffset(offset),
});
// ── Selection drag state ──
const [dragRect, setDragRect] = useState<{
x: number;
y: number;
width: number;
height: number;
} | null>(null);
const dragStartRef = useRef<Point | null>(null);
const isDraggingSelectRef = useRef(false);
// ── Item drag state (moving selected items) ──
const isDraggingItemRef = useRef(false);
const dragItemStartRef = useRef<Point | null>(null);
const dragItemSnapshotRef = useRef<Map<string, { x: number; y: number }>>(new Map());
// ── Opening placement preview ──
const [openingPreview, setOpeningPreview] = useState<OpeningPreview | null>(null);
// ── Measurement tool state
const [measureState, setMeasureState] = useState<MeasurementState | null>(null);
const isMeasuringRef = useRef(false);
// ── Task 4: Memoize selection bounding box ──
const selBox = useMemo(
() =>
selectedIds.size > 0
? selectedBoundingBox(selectedIds, openings, walls, electricalItems, furnitureItems)
: null,
[selectedIds, openings, walls, electricalItems, furnitureItems],
);
// ── Get world coordinates from a Konva mouse event ──
const getWorldPoint = useCallback(
(e: Konva.KonvaEventObject<MouseEvent>): Point => {
const stage = e.target.getStage();
const pos = stage?.getPointerPosition();
if (!pos) return { x: 0, y: 0 };
return screenToWorld(pos.x, pos.y);
},
[screenToWorld],
);
// ── Mouse Down ──
const handleMouseDown = useCallback(
(e: Konva.KonvaEventObject<MouseEvent>) => {
// Handle panning first
handlePanStart(e);
if (e.evt.button === 1) return; // middle mouse = pan only
if (e.evt.button !== 0) return; // only left click
const worldPoint = getWorldPoint(e);
if (activeTool === 'select') {
const hit = hitTest(worldPoint, openings, walls, electricalItems, furnitureItems);
if (hit) {
if (e.evt.shiftKey) {
// Toggle selection with shift
if (selectedIds.has(hit.id)) {
selectionDispatch({ type: 'REMOVE_FROM_SELECTION', id: hit.id });
} else {
selectionDispatch({ type: 'ADD_TO_SELECTION', id: hit.id });
}
} else {
if (!selectedIds.has(hit.id)) {
selectElement(hit.id);
}
// Start item drag — snapshot starting positions
isDraggingItemRef.current = true;
dragItemStartRef.current = worldPoint;
const snap = new Map<string, { x: number; y: number }>();
for (const id of selectedIds) {
const elec = electricalItems.find((i) => i.id === id);
if (elec) { snap.set(id, { x: elec.x, y: elec.y }); continue; }
const furn = furnitureItems.find((i) => i.id === id);
if (furn) { snap.set(id, { x: furn.x, y: furn.y }); }
}
// Include the hit item if not yet in selection
if (!snap.has(hit.id)) {
const elec = electricalItems.find((i) => i.id === hit.id);
if (elec) snap.set(hit.id, { x: elec.x, y: elec.y });
const furn = furnitureItems.find((i) => i.id === hit.id);
if (furn) snap.set(hit.id, { x: furn.x, y: furn.y });
}
dragItemSnapshotRef.current = snap;
}
} else {
if (e.evt.ctrlKey || e.evt.metaKey) {
// Ctrl+click on empty space: start drag select rectangle
if (!e.evt.shiftKey) {
clearSelection();
}
dragStartRef.current = worldPoint;
isDraggingSelectRef.current = true;
} else {
// Left click on empty space: start panning
clearSelection();
startLeftMousePan(e);
}
}
} else if (activeTool === 'door') {
const preview = computeDoorPreview(worldPoint, walls, openings);
if (preview && preview.isValid) {
const newOpening = createDoorOpening(
room.id,
preview.wall.id,
preview.positionAlongWall,
preview.width,
);
const cmd: EditorCommand = {
description: 'Place door',
execute: () => addOpening(newOpening),
undo: () => dispatch({ type: 'REMOVE_OPENING', id: newOpening.id }),
};
execute(cmd);
}
} else if (activeTool === 'window') {
const preview = computeWindowPreview(worldPoint, walls, openings);
if (preview && preview.isValid) {
const newOpening = createWindowOpening(
room.id,
preview.wall.id,
preview.positionAlongWall,
preview.width,
);
const cmd: EditorCommand = {
description: 'Place window',
execute: () => addOpening(newOpening),
undo: () => dispatch({ type: 'REMOVE_OPENING', id: newOpening.id }),
};
execute(cmd);
}
} else if (activeTool === 'electrical' && selectedElectricalIndex != null) {
const symbolDef = ELECTRICAL_SYMBOL_DEFS[selectedElectricalIndex];
if (symbolDef) {
const preview = computeElectricalPreview(worldPoint, walls, symbolDef);
if (preview.isValid) {
const newItem = createElectricalItemFromPlacement(
room.id,
preview,
symbolDef.type,
symbolDef.variant,
room.wallHeight,
);
const cmd: EditorCommand = {
description: `Place ${symbolDef.label}`,
execute: () => addElectrical(newItem),
undo: () => dispatch({ type: 'REMOVE_ELECTRICAL', id: newItem.id }),
};
execute(cmd);
}
}
} else if (activeTool === 'furniture' && selectedFurnitureIndex != null) {
const furnitureDef = FURNITURE_DEFS[selectedFurnitureIndex];
if (furnitureDef) {
const preview = computeFurniturePreview(worldPoint, furnitureDef);
if (preview.isValid) {
const newItem = createFurnitureItemFromPlacement(room.id, preview, furnitureDef);
const cmd: EditorCommand = {
description: `Place ${furnitureDef.label}`,
execute: () => addFurniture(newItem),
undo: () => dispatch({ type: 'REMOVE_FURNITURE', id: newItem.id }),
};
execute(cmd);
}
}
} else if (activeTool === 'measure') {
const ms = startMeasurement(worldPoint);
setMeasureState(ms);
isMeasuringRef.current = true;
} else if (activeTool === 'annotate') {
const newAnnotation = {
id: generateLocalId(),
roomId: room.id,
x: worldPoint.x,
y: worldPoint.y,
text: 'Text',
fontSize: 14,
color: '#333333',
};
const cmd: EditorCommand = {
description: 'Place annotation',
execute: () => addAnnotation(newAnnotation),
undo: () => dispatch({ type: 'REMOVE_ANNOTATION', id: newAnnotation.id }),
};
execute(cmd);
selectionDispatch({ type: 'SET_SELECTED', ids: new Set([newAnnotation.id]) });
}
},
[
handlePanStart,
getWorldPoint,
activeTool,
openings,
walls,
electricalItems,
furnitureItems,
selectedIds,
selectElement,
clearSelection,
selectionDispatch,
dispatch,
room.id,
addOpening,
addElectrical,
addFurniture,
addAnnotation,
execute,
selectedElectricalIndex,
selectedFurnitureIndex,
],
);
// ── Task 3: RAF-throttled mouse move ──
const rafIdRef = useRef<number | null>(null);
const pendingMoveRef = useRef<Konva.KonvaEventObject<MouseEvent> | null>(null);
const processMouseMove = useCallback(
(e: Konva.KonvaEventObject<MouseEvent>) => {
handlePanMove(e);
if (isPanningRef.current) return;
const worldPoint = getWorldPoint(e);
// Move selected items by dragging — use snapshot + total delta from start
if (isDraggingItemRef.current && dragItemStartRef.current && selectedIds.size > 0) {
const totalDx = worldPoint.x - dragItemStartRef.current.x;
const totalDy = worldPoint.y - dragItemStartRef.current.y;
for (const id of selectedIds) {
const startPos = dragItemSnapshotRef.current.get(id);
if (!startPos) continue;
const elec = electricalItems.find((item) => item.id === id);
if (elec) {
dispatch({ type: 'UPDATE_ELECTRICAL', item: { ...elec, x: startPos.x + totalDx, y: startPos.y + totalDy } });
continue;
}
const furn = furnitureItems.find((item) => item.id === id);
if (furn) {
dispatch({ type: 'UPDATE_FURNITURE', item: { ...furn, x: startPos.x + totalDx, y: startPos.y + totalDy } });
}
}
return;
}
// Drag select rectangle
if (isDraggingSelectRef.current && dragStartRef.current) {
const rect = selectionRect(dragStartRef.current, worldPoint);
setDragRect(rect);
return;
}
// Opening placement preview
if (activeTool === 'door') {
const preview = computeDoorPreview(worldPoint, walls, openings);
setOpeningPreview(
preview
? {
wall: preview.wall,
positionAlongWall: preview.positionAlongWall,
width: preview.width,
type: 'DOOR',
isValid: preview.isValid,
}
: null,
);
} else if (activeTool === 'window') {
const preview = computeWindowPreview(worldPoint, walls, openings);
setOpeningPreview(
preview
? {
wall: preview.wall,
positionAlongWall: preview.positionAlongWall,
width: preview.width,
type: 'WINDOW',
isValid: preview.isValid,
}
: null,
);
} else {
setOpeningPreview(null);
}
// Measurement tool: update end point while dragging
if (activeTool === 'measure' && isMeasuringRef.current && measureState) {
setMeasureState(updateMeasurement(measureState, worldPoint));
}
},
[handlePanMove, isPanningRef, getWorldPoint, activeTool, walls, openings, measureState, selectedIds, electricalItems, furnitureItems, dispatch],
);
const handleMouseMove = useCallback(
(e: Konva.KonvaEventObject<MouseEvent>) => {
// Always forward pan and item drag moves immediately for responsiveness
if (isPanningRef.current) {
handlePanMove(e);
return;
}
if (isDraggingItemRef.current) {
processMouseMove(e);
return;
}
pendingMoveRef.current = e;
if (rafIdRef.current === null) {
rafIdRef.current = requestAnimationFrame(() => {
rafIdRef.current = null;
const pending = pendingMoveRef.current;
if (pending) {
pendingMoveRef.current = null;
processMouseMove(pending);
}
});
}
},
[isPanningRef, handlePanMove, processMouseMove],
);
// Cancel pending RAF on unmount
useEffect(() => {
return () => {
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
}
};
}, []);
// ── Mouse Up ──
const handleMouseUp = useCallback(
(e: Konva.KonvaEventObject<MouseEvent>) => {
handlePanEnd();
// Finish item drag
if (isDraggingItemRef.current) {
isDraggingItemRef.current = false;
dragItemStartRef.current = null;
}
// Finish drag select
if (isDraggingSelectRef.current && dragStartRef.current) {
const worldPoint = getWorldPoint(e);
const rect = selectionRect(dragStartRef.current, worldPoint);
// Only if the rectangle is non-trivial
if (rect.width > 0.01 || rect.height > 0.01) {
const ids = elementsInRect(rect, openings, walls, electricalItems, furnitureItems);
if (ids.size > 0) {
selectionDispatch({ type: 'SET_SELECTED', ids });
}
}
dragStartRef.current = null;
isDraggingSelectRef.current = false;
setDragRect(null);
}
// Finish measurement
if (isMeasuringRef.current && measureState) {
const worldPoint = getWorldPoint(e);
const result = finishMeasurement(updateMeasurement(measureState, worldPoint));
// Keep the measurement visible (don't clear it)
setMeasureState(result);
isMeasuringRef.current = false;
}
},
[handlePanEnd, getWorldPoint, openings, walls, electricalItems, furnitureItems, selectionDispatch, measureState],
);
// ── Prevent context menu on canvas ──
useEffect(() => {
const stage = stageRef.current;
if (!stage) return;
const container = stage.container();
const handler = (e: Event) => e.preventDefault();
container.addEventListener('contextmenu', handler);
return () => container.removeEventListener('contextmenu', handler);
}, []);
const cursor = TOOL_CURSORS[activeTool] ?? 'default';
return (
<div style={{ position: 'relative', width, height }}>
<Stage
ref={stageRef}
width={width}
height={height}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
style={{ cursor, background: '#ffffff' }}
>
{/* Layer 1: Grid + rulers */}
<GridLayer
zoom={zoom}
panOffset={panOffset}
stageWidth={width}
stageHeight={height}
gridSize={gridSize}
visible={gridVisible}
/>
{/* Layer 2: Walls + room fill */}
{layerVisibility.walls && (
<WallLayer
walls={walls}
roomShape={room.shape}
zoom={zoom}
panOffset={panOffset}
selectedIds={selectedIds}
plinthThickness={room.plinthThickness}
/>
)}
{/* Layer 3: Openings (doors + windows) */}
<OpeningLayer
openings={openings}
walls={walls}
roomShape={room.shape}
zoom={zoom}
panOffset={panOffset}
selectedIds={selectedIds}
preview={openingPreview}
/>
{/* Layer 4: Electrical */}
<ElectricalLayer
items={electricalItems}
zoom={zoom}
panOffset={panOffset}
selectedIds={selectedIds}
visible={layerVisibility.electrical}
/>
{/* Layer 5: Furniture */}
<FurnitureLayer
items={furnitureItems}
zoom={zoom}
panOffset={panOffset}
selectedIds={selectedIds}
visible={layerVisibility.furniture}
/>
{/* Layer 6: Measurements */}
{layerVisibility.measurements && (
<MeasurementLayer
walls={walls}
openings={openings}
zoom={zoom}
panOffset={panOffset}
selectedIds={selectedIds}
roomShape={room.shape}
/>
)}
{/* Layer 7: Room labels */}
{layerVisibility.measurements && (
<RoomLabelLayer
roomName={room.name}
roomShape={room.shape}
zoom={zoom}
panOffset={panOffset}
/>
)}
{/* Layer 8: Annotations */}
{layerVisibility.annotations && (
<AnnotationLayer
annotations={annotations}
electricalItems={electricalItems}
furnitureItems={furnitureItems}
zoom={zoom}
panOffset={panOffset}
selectedIds={selectedIds}
onSelect={(id) => selectElement(id)}
onDragEnd={(id, x, y) => {
const ann = annotations.find((a) => a.id === id);
if (ann) updateAnnotation({ ...ann, x, y });
}}
onDoubleClick={(id) => {
const ann = annotations.find((a) => a.id === id);
if (!ann) return;
const newText = window.prompt(t('annotation.editPrompt'), ann.text);
if (newText != null && newText !== ann.text) {
updateAnnotation({ ...ann, text: newText });
}
}}
/>
)}
{/* Layer 9: Measure overlay */}
<MeasureOverlayLayer
measurement={measureState}
zoom={zoom}
panOffset={panOffset}
/>
{/* Layer 10: Selection overlay */}
<SelectionLayer
zoom={zoom}
panOffset={panOffset}
selectionBox={selBox}
dragRect={dragRect}
/>
</Stage>
<ScaleBar zoom={zoom} />
</div>
);
}
@@ -0,0 +1,253 @@
import { useTranslation } from 'react-i18next';
import { useEditor } from './context/EditorContext';
import { useUndoRedo } from './context/UndoRedoContext';
import type { EditorToolType, AlignmentDirection } from './types';
import styles from './editor-toolbar.module.css';
interface EditorToolbarProps {
readonly onSave: () => void;
readonly isSaving: boolean;
readonly isDirty?: boolean;
readonly isAutoSaving?: boolean;
readonly onExport?: () => void;
readonly onImport?: () => void;
}
interface ToolDef {
readonly type: EditorToolType;
readonly labelKey: string;
readonly shortcut: string;
readonly icon: string;
}
const TOOLS: readonly ToolDef[] = [
{ type: 'select', labelKey: 'toolbar.select', shortcut: 'V', icon: '\u25B3' },
{ type: 'door', labelKey: 'toolbar.door', shortcut: 'D', icon: '\u25A1' },
{ type: 'window', labelKey: 'toolbar.window', shortcut: 'W', icon: '\u2550' },
{ type: 'electrical', labelKey: 'toolbar.electrical', shortcut: 'E', icon: '\u26A1' },
{ type: 'furniture', labelKey: 'toolbar.furniture', shortcut: 'F', icon: '\u{1FA91}' },
{ type: 'measure', labelKey: 'toolbar.measure', shortcut: 'M', icon: '\u{1F4CF}' },
{ type: 'annotate', labelKey: 'toolbar.annotate', shortcut: 'T', icon: '\u{1D5A0}' },
];
interface AlignDef {
readonly alignment: AlignmentDirection;
readonly label: string;
readonly titleKey: string;
readonly icon: string;
}
const ALIGNMENT_BUTTONS: readonly AlignDef[] = [
{ alignment: 'left', label: 'L', titleKey: 'toolbar.alignLeft', icon: '\u2590' },
{ alignment: 'center-h', label: 'CH', titleKey: 'toolbar.alignCenterH', icon: '\u2503' },
{ alignment: 'right', label: 'R', titleKey: 'toolbar.alignRight', icon: '\u258C' },
{ alignment: 'top', label: 'T', titleKey: 'toolbar.alignTop', icon: '\u2580' },
{ alignment: 'center-v', label: 'CV', titleKey: 'toolbar.alignCenterV', icon: '\u2501' },
{ alignment: 'bottom', label: 'B', titleKey: 'toolbar.alignBottom', icon: '\u2584' },
{ alignment: 'distribute-h', label: 'DH', titleKey: 'toolbar.distributeH', icon: '\u2506' },
{ alignment: 'distribute-v', label: 'DV', titleKey: 'toolbar.distributeV', icon: '\u2504' },
];
export function EditorToolbar({ onSave, isSaving, onExport, onImport }: EditorToolbarProps) {
const { t } = useTranslation();
const { state, setTool, setZoom, dispatch } = useEditor();
const { undo, redo, canUndo, canRedo } = useUndoRedo();
const { activeTool, zoom, gridVisible, snapEnabled, layerVisibility } = state;
const zoomPercent = Math.round((zoom / 100) * 100);
return (
<div className={styles.toolbar}>
{/* Tool buttons */}
<div className={styles.group}>
{TOOLS.map((tool) => {
const label = t(tool.labelKey);
return (
<button
key={tool.type}
className={[
styles.toolBtn,
activeTool === tool.type ? styles.toolBtnActive : '',
].join(' ')}
onClick={() => setTool(tool.type)}
title={`${label} (${tool.shortcut})`}
>
<span className={styles.toolIcon}>{tool.icon}</span>
<span className={styles.toolLabel}>{label}</span>
</button>
);
})}
</div>
<div className={styles.separator} />
{/* Undo / Redo */}
<div className={styles.group}>
<button
className={styles.actionBtn}
onClick={undo}
disabled={!canUndo}
title={t('toolbar.undo')}
>
&#x21A9;
</button>
<button
className={styles.actionBtn}
onClick={redo}
disabled={!canRedo}
title={t('toolbar.redo')}
>
&#x21AA;
</button>
</div>
<div className={styles.separator} />
{/* Zoom controls */}
<div className={styles.group}>
<button
className={styles.actionBtn}
onClick={() => setZoom(zoom / 1.2)}
title={t('toolbar.zoomOut')}
>
&#x2212;
</button>
<span className={styles.zoomLabel}>{zoomPercent}%</span>
<button
className={styles.actionBtn}
onClick={() => setZoom(zoom * 1.2)}
title={t('toolbar.zoomIn')}
>
&#x002B;
</button>
</div>
<div className={styles.separator} />
{/* Grid + Snap toggles */}
<div className={styles.group}>
<button
className={[
styles.toggleBtn,
gridVisible ? styles.toggleBtnActive : '',
].join(' ')}
onClick={() => dispatch({ type: 'TOGGLE_GRID' })}
title={t('toolbar.toggleGrid')}
>
{t('toolbar.grid')}
</button>
<button
className={[
styles.toggleBtn,
snapEnabled ? styles.toggleBtnActive : '',
].join(' ')}
onClick={() => dispatch({ type: 'TOGGLE_SNAP' })}
title={t('toolbar.toggleSnap')}
>
{t('toolbar.snap')}
</button>
</div>
<div className={styles.separator} />
{/* Layer visibility toggles */}
<div className={styles.group}>
<button
className={[
styles.toggleBtn,
layerVisibility.walls ? styles.toggleBtnActive : '',
].join(' ')}
onClick={() => dispatch({ type: 'TOGGLE_LAYER', layer: 'walls' })}
title={t('toolbar.toggleWalls')}
>
{t('toolbar.walls')}
</button>
<button
className={[
styles.toggleBtn,
layerVisibility.electrical ? styles.toggleBtnActive : '',
].join(' ')}
onClick={() => dispatch({ type: 'TOGGLE_LAYER', layer: 'electrical' })}
title={t('toolbar.toggleElectrical')}
>
{t('toolbar.elec')}
</button>
<button
className={[
styles.toggleBtn,
layerVisibility.furniture ? styles.toggleBtnActive : '',
].join(' ')}
onClick={() => dispatch({ type: 'TOGGLE_LAYER', layer: 'furniture' })}
title={t('toolbar.toggleFurniture')}
>
{t('toolbar.furn')}
</button>
<button
className={[
styles.toggleBtn,
layerVisibility.measurements ? styles.toggleBtnActive : '',
].join(' ')}
onClick={() => dispatch({ type: 'TOGGLE_LAYER', layer: 'measurements' })}
title={t('toolbar.toggleMeasurements')}
>
{t('toolbar.meas')}
</button>
</div>
{/* Alignment tools — visible when 2+ items selected */}
{state.selectedIds.size >= 2 && (
<>
<div className={styles.separator} />
<div className={styles.group}>
{ALIGNMENT_BUTTONS.map((btn) => (
<button
key={btn.alignment}
className={styles.actionBtn}
onClick={() =>
dispatch({ type: 'ALIGN_SELECTED', alignment: btn.alignment })
}
title={t(btn.titleKey)}
>
{btn.icon}
</button>
))}
</div>
</>
)}
{/* Spacer */}
<div className={styles.spacer} />
{/* Import + Export + Save buttons */}
<div className={styles.group}>
{onImport && (
<button
className={styles.actionBtn}
onClick={onImport}
title={t('toolbar.import')}
>
&#x2B06;
</button>
)}
{onExport && (
<button
className={styles.actionBtn}
onClick={onExport}
title={t('toolbar.export')}
>
&#x2B07;
</button>
)}
<button
className={styles.saveBtn}
onClick={onSave}
disabled={isSaving}
title={t('toolbar.saveShortcut')}
>
{isSaving ? t('editor.saving') : t('editor.save')}
</button>
</div>
</div>
);
}
@@ -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`;
}
@@ -0,0 +1,580 @@
import { useState, useCallback, useEffect, useRef, lazy, Suspense } from 'react';
import { useBlocker } from 'react-router';
import { useTranslation } from 'react-i18next';
import type Konva from 'konva';
import { useEditor } from './context/EditorContext';
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
import { boundingBox } from './utils/geometry';
import { EditorCanvas } from './EditorCanvas';
import { EditorToolbar } from './EditorToolbar';
import { PropertiesPanel } from './PropertiesPanel';
import { ElectricalPalette } from './panels/ElectricalPalette';
import { FurniturePalette } from './panels/FurniturePalette';
import { CableLengthStatus } from './panels/CableLengthStatus';
import { ProjectionPanel } from './projection/ProjectionPanel';
import { ExportDialog } from './export/ExportDialog';
import { importRoomFromJson } from './export/roomFormat';
const Room3DView = lazy(() =>
import('./three/Room3DView').then((m) => ({ default: m.Room3DView })),
);
import {
getRoomFull,
bulkUpdateWalls,
batchSyncOpenings,
batchSyncElectrical,
batchSyncFurniture,
} from '../../api/client';
import type {
CreateWallOpeningDto,
UpdateWallOpeningDto,
CreateElectricalItemDto,
UpdateElectricalItemDto,
CreateFurnitureItemDto,
UpdateFurnitureItemDto,
} from '@house-plan-maker/shared';
import styles from './room-editor-layout.module.css';
const AUTO_SAVE_DELAY_MS = 5000;
interface RoomEditorLayoutProps {
readonly roomId: string;
}
export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
const { t } = useTranslation();
const { state, dispatch } = useEditor();
const [isSaving, setIsSaving] = useState(false);
const [isAutoSaving, setIsAutoSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
type ViewMode = '2d' | '3d' | 'projections';
const [viewMode, setViewMode] = useState<ViewMode>('2d');
const [showExport, setShowExport] = useState(false);
const canvasContainerRef = useRef<HTMLDivElement>(null);
const [canvasSize, setCanvasSize] = useState({ width: 800, height: 600 });
// ── Dirty tracking ──
const [isDirty, setIsDirty] = useState(false);
const lastSavedRef = useRef({
walls: state.walls,
openings: state.openings,
electricalItems: state.electricalItems,
furnitureItems: state.furnitureItems,
});
// Mark dirty when state diverges from last saved snapshot
useEffect(() => {
const saved = lastSavedRef.current;
const dirty =
state.walls !== saved.walls ||
state.openings !== saved.openings ||
state.electricalItems !== saved.electricalItems ||
state.furnitureItems !== saved.furnitureItems;
setIsDirty(dirty);
}, [state.walls, state.openings, state.electricalItems, state.furnitureItems]);
// Warn on browser close / refresh
useEffect(() => {
if (!isDirty) return;
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
e.preventDefault();
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [isDirty]);
// Block in-app navigation via react-router
const blocker = useBlocker(isDirty);
useEffect(() => {
if (blocker.state === 'blocked') {
const leave = window.confirm(
t('editor.unsavedChanges'),
);
if (leave) {
blocker.proceed();
} else {
blocker.reset();
}
}
}, [blocker]);
// ── Export refs ──
const mainStageRef = useRef<Konva.Stage | null>(null);
const projectionStageMapRef = useRef<Map<string, Konva.Stage>>(new Map());
const threeCanvasRef = useRef<HTMLCanvasElement | null>(null);
const handleMainStageRef = useCallback((stage: Konva.Stage | null) => {
mainStageRef.current = stage;
}, []);
const handleProjectionStageRef = useCallback((wallId: string, stage: Konva.Stage | null) => {
if (stage) {
projectionStageMapRef.current.set(wallId, stage);
} else {
projectionStageMapRef.current.delete(wallId);
}
}, []);
// ── Resize observer for canvas ──
useEffect(() => {
const container = canvasContainerRef.current;
if (!container) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
setCanvasSize({ width: Math.floor(width), height: Math.floor(height) });
}
});
observer.observe(container);
// Initial size
const rect = container.getBoundingClientRect();
setCanvasSize({ width: Math.floor(rect.width), height: Math.floor(rect.height) });
return () => observer.disconnect();
}, []);
// ── Center room in canvas on first mount ──
const hasCenteredRef = useRef(false);
useEffect(() => {
if (hasCenteredRef.current) return;
if (canvasSize.width <= 100 || canvasSize.height <= 100) return;
if (state.room.shape.length === 0) return;
const bbox = boundingBox(state.room.shape);
const roomW = bbox.maxX - bbox.minX;
const roomH = bbox.maxY - bbox.minY;
if (roomW <= 0 || roomH <= 0) return;
// Fit room in canvas with some padding
const padding = 80;
const scaleX = (canvasSize.width - padding * 2) / roomW;
const scaleY = (canvasSize.height - padding * 2) / roomH;
const fitZoom = Math.min(scaleX, scaleY, 300);
const centerX = (bbox.minX + bbox.maxX) / 2;
const centerY = (bbox.minY + bbox.maxY) / 2;
const panX = canvasSize.width / 2 - centerX * fitZoom;
const panY = canvasSize.height / 2 - centerY * fitZoom;
dispatch({ type: 'SET_ZOOM', zoom: fitZoom });
dispatch({ type: 'SET_PAN_OFFSET', offset: { x: panX, y: panY } });
hasCenteredRef.current = true;
}, [canvasSize, state.room.shape, dispatch]);
// ── Re-measure canvas when switching back to 2D view ──
useEffect(() => {
if (viewMode !== '2d') return;
// Use requestAnimationFrame to measure after the style change is applied
const rafId = requestAnimationFrame(() => {
const container = canvasContainerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
setCanvasSize({ width: Math.floor(rect.width), height: Math.floor(rect.height) });
}
});
return () => cancelAnimationFrame(rafId);
}, [viewMode]);
// ── Save handler (batch-optimized) ──
const isSavingRef = useRef(false);
const handleSave = useCallback(async () => {
if (isSavingRef.current) return;
isSavingRef.current = true;
setIsSaving(true);
setSaveError(null);
try {
// 1. Save walls first (bulk replace) to get server-assigned wall IDs
const wallDtos = state.walls.map((w) => ({
startX: w.startX,
startY: w.startY,
endX: w.endX,
endY: w.endY,
thickness: w.thickness,
direction: w.direction,
}));
const serverWalls = await bulkUpdateWalls(roomId, wallDtos);
// Build a map from local wall to server wall by matching coordinates
const wallIdMap = new Map<string, string>();
for (const localWall of state.walls) {
const match = serverWalls.find(
(sw) =>
Math.abs(sw.startX - localWall.startX) < 0.001 &&
Math.abs(sw.startY - localWall.startY) < 0.001 &&
Math.abs(sw.endX - localWall.endX) < 0.001 &&
Math.abs(sw.endY - localWall.endY) < 0.001,
);
if (match) {
wallIdMap.set(localWall.id, match.id);
}
}
// 2. Fetch current server state once for diff computation
const freshRoom = await getRoomFull(roomId);
// 3. Compute openings — since bulkUpdateWalls CASCADE-deletes all openings,
// we must re-create ALL openings with the new server wall IDs
const openingsCreate: CreateWallOpeningDto[] = [];
const openingsUpdate: { id: string; data: UpdateWallOpeningDto }[] = [];
const openingsDelete: string[] = [];
for (const opening of state.openings) {
const serverWallId = wallIdMap.get(opening.wallId) ?? opening.wallId;
openingsCreate.push({
wallId: serverWallId,
type: opening.type,
positionAlongWall: opening.positionAlongWall,
width: opening.width,
height: opening.height,
elevationFromFloor: opening.elevationFromFloor,
openDirection: opening.openDirection,
});
}
// No updates or deletes needed — cascade already removed all server openings
// 4. Electrical items — since wall IDs changed after bulk replace,
// delete all existing and re-create with correct wall IDs
const elecCreate: CreateElectricalItemDto[] = [];
const elecUpdate: { id: string; data: UpdateElectricalItemDto }[] = [];
const elecDelete: string[] = freshRoom.electricalItems.map((e) => e.id);
for (const elec of state.electricalItems) {
const serverWallId = elec.wallId ? (wallIdMap.get(elec.wallId) ?? elec.wallId) : null;
elecCreate.push({
type: elec.type,
x: elec.x,
y: elec.y,
wallId: serverWallId,
elevationFromFloor: elec.elevationFromFloor,
rotation: elec.rotation,
metadata: elec.metadata,
});
}
// 5. Compute diffs for furniture
const serverFurnIds = new Set(freshRoom.furnitureItems.map((f) => f.id));
const localFurnIds = new Set(state.furnitureItems.map((f) => f.id));
const furnCreate: CreateFurnitureItemDto[] = [];
const furnUpdate: { id: string; data: UpdateFurnitureItemDto }[] = [];
const furnDelete: string[] = [];
for (const furn of state.furnitureItems) {
if (furn.id.startsWith('local-')) {
furnCreate.push({
type: furn.type,
x: furn.x,
y: furn.y,
width: furn.width,
depth: furn.depth,
height: furn.height,
rotation: furn.rotation,
elevationFromFloor: furn.elevationFromFloor,
label: furn.label,
});
} else if (serverFurnIds.has(furn.id)) {
const serverFurn = freshRoom.furnitureItems.find((f) => f.id === furn.id);
if (serverFurn) {
const hasChanges =
serverFurn.x !== furn.x ||
serverFurn.y !== furn.y ||
serverFurn.width !== furn.width ||
serverFurn.depth !== furn.depth ||
serverFurn.height !== furn.height ||
serverFurn.rotation !== furn.rotation ||
serverFurn.elevationFromFloor !== furn.elevationFromFloor ||
serverFurn.label !== furn.label;
if (hasChanges) {
furnUpdate.push({
id: furn.id,
data: {
type: furn.type,
x: furn.x,
y: furn.y,
width: furn.width,
depth: furn.depth,
height: furn.height,
rotation: furn.rotation,
elevationFromFloor: furn.elevationFromFloor,
label: furn.label,
},
});
}
}
}
}
for (const serverFurn of freshRoom.furnitureItems) {
if (!localFurnIds.has(serverFurn.id)) {
furnDelete.push(serverFurn.id);
}
}
// 6. Execute all 3 batch calls in parallel — responses contain final server state
const [syncedOpenings, syncedElectrical, syncedFurniture] = await Promise.all([
batchSyncOpenings(roomId, {
create: openingsCreate,
update: openingsUpdate,
delete: openingsDelete,
}),
batchSyncElectrical(roomId, {
create: elecCreate,
update: elecUpdate,
delete: elecDelete,
}),
batchSyncFurniture(roomId, {
create: furnCreate,
update: furnUpdate,
delete: furnDelete,
}),
]);
// 7. Sync state with server-assigned IDs (single dispatch, no flicker)
dispatch({
type: 'SYNC_SAVE',
walls: serverWalls,
openings: syncedOpenings,
electricalItems: syncedElectrical,
furnitureItems: syncedFurniture,
});
// Mark state as clean after successful save
lastSavedRef.current = {
walls: serverWalls,
openings: syncedOpenings,
electricalItems: syncedElectrical,
furnitureItems: syncedFurniture,
};
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('editor.error.load');
setSaveError(message);
} finally {
setIsSaving(false);
isSavingRef.current = false;
}
}, [roomId, state.walls, state.openings, state.electricalItems, state.furnitureItems, dispatch]);
// ── Auto-save with ref-based debounce ──
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
// Clear any existing timer when dirty state or saving state changes
if (autoSaveTimerRef.current !== null) {
clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}
// Only start the timer if dirty and not currently saving
if (!isDirty || isSavingRef.current) return;
autoSaveTimerRef.current = setTimeout(() => {
autoSaveTimerRef.current = null;
// Guard: don't auto-save if a manual save is in progress
if (isSavingRef.current) return;
setIsAutoSaving(true);
handleSave().finally(() => setIsAutoSaving(false));
}, AUTO_SAVE_DELAY_MS);
return () => {
if (autoSaveTimerRef.current !== null) {
clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}
};
}, [isDirty, handleSave]);
// ── Keyboard shortcuts ──
useKeyboardShortcuts({ onSave: handleSave });
// ── Import handler ──
const [importError, setImportError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleImportClick = useCallback(() => {
setImportError(null);
fileInputRef.current?.click();
}, []);
const handleImportFile = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// Reset the input so the same file can be re-selected
event.target.value = '';
const reader = new FileReader();
reader.onload = () => {
try {
const text = reader.result as string;
const imported = importRoomFromJson(text);
const confirmed = window.confirm(
`Import room "${imported.room.name}"? This will replace all current walls, openings, electrical items, and furniture.`,
);
if (!confirmed) return;
dispatch({
type: 'IMPORT_ROOM',
room: imported.room,
walls: imported.walls,
openings: imported.openings,
electricalItems: imported.electricalItems,
furnitureItems: imported.furnitureItems,
});
setImportError(null);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to import file';
setImportError(message);
}
};
reader.onerror = () => {
setImportError('Failed to read the selected file.');
};
reader.readAsText(file);
},
[dispatch],
);
return (
<div className={styles.layout}>
{/* Toolbar */}
{/* Hidden file input for JSON import */}
<input
ref={fileInputRef}
type="file"
accept=".json"
style={{ display: 'none' }}
onChange={handleImportFile}
/>
<EditorToolbar
onSave={handleSave}
isSaving={isSaving}
isDirty={isDirty}
isAutoSaving={isAutoSaving}
onExport={() => setShowExport(true)}
onImport={handleImportClick}
/>
{saveError && (
<div className={styles.saveError}>
{t('editor.saveFailed', { error: saveError })}
</div>
)}
{importError && (
<div className={styles.saveError}>
{t('editor.importFailed', { error: importError })}
</div>
)}
{/* Main area: palette + canvas + properties panel */}
<div className={styles.main}>
<div className={styles.canvasArea}>
{/* Overlay palettes (float over canvas, don't affect layout) */}
{state.activeTool === 'electrical' && (
<ElectricalPalette
selectedIndex={state.selectedElectricalIndex}
onSelect={(index) =>
dispatch({ type: 'SET_ELECTRICAL_INDEX', index })
}
/>
)}
{state.activeTool === 'furniture' && (
<FurniturePalette
selectedIndex={state.selectedFurnitureIndex}
onSelect={(index) =>
dispatch({ type: 'SET_FURNITURE_INDEX', index })
}
/>
)}
{/* View mode toggle: 2D / 3D / Projections */}
<div className={styles.viewToggle}>
<button
className={[styles.viewToggleBtn, viewMode === '2d' ? styles.viewToggleBtnActive : ''].join(' ')}
onClick={() => setViewMode('2d')}
>
{t('toolbar.view2D')}
</button>
<button
className={[styles.viewToggleBtn, viewMode === '3d' ? styles.viewToggleBtnActive : ''].join(' ')}
onClick={() => setViewMode('3d')}
>
{t('toolbar.view3D')}
</button>
<button
className={[styles.viewToggleBtn, viewMode === 'projections' ? styles.viewToggleBtnActive : ''].join(' ')}
onClick={() => setViewMode('projections')}
>
{t('toolbar.viewProjections')}
</button>
</div>
{viewMode === '3d' && (
<div
className={styles.canvasContainer}
ref={(el) => {
// Grab the R3F canvas element for 3D export
if (el) {
const canvas = el.querySelector('canvas');
threeCanvasRef.current = canvas;
} else {
threeCanvasRef.current = null;
}
}}
>
<Suspense fallback={<div className={styles.loading3D}>{t('editor.loading3D')}</div>}>
<Room3DView />
</Suspense>
</div>
)}
<div
ref={canvasContainerRef}
className={styles.canvasContainer}
style={viewMode !== '2d' ? { position: 'absolute', width: 0, height: 0, overflow: 'hidden', pointerEvents: 'none' } : undefined}
>
<EditorCanvas
width={canvasSize.width}
height={canvasSize.height}
onStageRef={handleMainStageRef}
/>
</div>
{viewMode === '2d' && (
<CableLengthStatus electricalItems={state.electricalItems} />
)}
{/* ProjectionPanel always mounted for export, hidden when not active */}
<div style={viewMode !== 'projections' ? { position: 'absolute', width: '800px', height: '400px', overflow: 'hidden', pointerEvents: 'none', opacity: 0, zIndex: -1 } : { display: 'contents' }}>
<ProjectionPanel
fullView={viewMode === 'projections'}
onStageRef={handleProjectionStageRef}
/>
</div>
</div>
<PropertiesPanel />
</div>
{/* Export Dialog */}
<ExportDialog
open={showExport}
onClose={() => setShowExport(false)}
mainStageRef={mainStageRef}
projectionStageRefs={projectionStageMapRef}
threeCanvasRef={threeCanvasRef}
is3DView={viewMode === '3d'}
viewMode={viewMode}
/>
</div>
);
}
@@ -0,0 +1,879 @@
import {
createContext,
useContext,
useReducer,
useCallback,
useMemo,
useRef,
type ReactNode,
} from 'react';
import type {
RoomFull,
Wall,
WallOpening,
ElectricalItem,
FurnitureItem,
Point,
Annotation,
} from '@house-plan-maker/shared';
import type { EditorState, EditorAction, EditorToolType, LayerVisibility, PastePayload } from '../types';
import { DEFAULT_ZOOM, DEFAULT_GRID_SIZE } from '../types';
import { wallsFromShape } from '../utils/wallUtils';
import { generateLocalId } from '../utils/geometry';
// ── Reducer ──
function assignWallDirection(w: { startX: number; startY: number; endX: number; endY: number }, roomWidth: number, roomHeight: number): Wall['direction'] {
const dx = w.endX - w.startX;
const dy = w.endY - w.startY;
const isHorizontal = Math.abs(dx) > Math.abs(dy);
if (isHorizontal) {
return w.startY < roomHeight / 2 ? 'NORTH' : 'SOUTH';
}
return w.startX < roomWidth / 2 ? 'WEST' : 'EAST';
}
function generateWallsFromShape(room: RoomFull): readonly Wall[] {
return wallsFromShape(room.shape).map((w) => ({
...w,
id: generateLocalId(),
roomId: room.id,
direction: assignWallDirection(w, room.width ?? 0, room.height ?? 0),
}));
}
function wallsMatchShape(existingWalls: readonly Wall[], shape: readonly Point[]): boolean {
const shapeWalls = wallsFromShape(shape);
if (existingWalls.length !== shapeWalls.length) return false;
return shapeWalls.every((sw, i) => {
const ew = existingWalls[i];
return ew &&
Math.abs(ew.startX - sw.startX) < 0.001 &&
Math.abs(ew.startY - sw.startY) < 0.001 &&
Math.abs(ew.endX - sw.endX) < 0.001 &&
Math.abs(ew.endY - sw.endY) < 0.001;
});
}
function createInitialState(room: RoomFull): EditorState {
// Auto-generate walls if none exist or if they don't match the room shape
const existingMatch = room.walls.length > 0 && wallsMatchShape(room.walls, room.shape);
const walls = existingMatch ? room.walls : generateWallsFromShape(room);
return {
room,
walls,
openings: existingMatch ? room.openings : [],
electricalItems: room.electricalItems,
furnitureItems: room.furnitureItems,
selectedIds: new Set(),
activeTool: 'select',
zoom: DEFAULT_ZOOM,
panOffset: { x: 0, y: 0 },
gridSize: DEFAULT_GRID_SIZE,
gridVisible: true,
snapEnabled: true,
snapGranularity: DEFAULT_GRID_SIZE,
layerVisibility: { walls: true, electrical: true, furniture: true, measurements: true, annotations: true },
selectedElectricalIndex: null,
selectedFurnitureIndex: null,
annotations: [],
};
}
function editorReducer(state: EditorState, action: EditorAction): EditorState {
switch (action.type) {
case 'SET_ROOM': {
const room = action.room;
const existingMatch = room.walls.length > 0 && wallsMatchShape(room.walls, room.shape);
const walls = existingMatch ? room.walls : generateWallsFromShape(room);
return {
...state,
room,
walls,
openings: existingMatch ? room.openings : [],
electricalItems: room.electricalItems,
furnitureItems: room.furnitureItems,
};
}
case 'UPDATE_ROOM_PROPS':
return { ...state, room: { ...state.room, ...action.props } };
case 'SET_WALLS':
return { ...state, walls: action.walls };
case 'UPDATE_WALL':
return {
...state,
walls: state.walls.map((w) =>
w.id === action.wall.id ? action.wall : w,
),
};
case 'ADD_OPENING':
return { ...state, openings: [...state.openings, action.opening] };
case 'UPDATE_OPENING':
return {
...state,
openings: state.openings.map((o) =>
o.id === action.opening.id ? action.opening : o,
),
};
case 'REMOVE_OPENING':
return {
...state,
openings: state.openings.filter((o) => o.id !== action.id),
selectedIds: removeFromSet(state.selectedIds, action.id),
};
case 'ADD_ELECTRICAL':
return {
...state,
electricalItems: [...state.electricalItems, action.item],
};
case 'UPDATE_ELECTRICAL':
return {
...state,
electricalItems: state.electricalItems.map((i) =>
i.id === action.item.id ? action.item : i,
),
};
case 'REMOVE_ELECTRICAL':
return {
...state,
electricalItems: state.electricalItems.filter((i) => i.id !== action.id),
selectedIds: removeFromSet(state.selectedIds, action.id),
};
case 'ADD_FURNITURE':
return {
...state,
furnitureItems: [...state.furnitureItems, action.item],
};
case 'UPDATE_FURNITURE':
return {
...state,
furnitureItems: state.furnitureItems.map((i) =>
i.id === action.item.id ? action.item : i,
),
};
case 'REMOVE_FURNITURE':
return {
...state,
furnitureItems: state.furnitureItems.filter((i) => i.id !== action.id),
selectedIds: removeFromSet(state.selectedIds, action.id),
};
case 'SET_SELECTED':
return { ...state, selectedIds: action.ids };
case 'ADD_TO_SELECTION':
return {
...state,
selectedIds: addToSet(state.selectedIds, action.id),
};
case 'REMOVE_FROM_SELECTION':
return {
...state,
selectedIds: removeFromSet(state.selectedIds, action.id),
};
case 'CLEAR_SELECTION':
return { ...state, selectedIds: new Set() };
case 'SET_TOOL':
return { ...state, activeTool: action.tool, selectedIds: new Set() };
case 'SET_ZOOM':
return { ...state, zoom: action.zoom };
case 'SET_PAN_OFFSET':
return { ...state, panOffset: action.offset };
case 'SET_GRID_SIZE':
return { ...state, gridSize: action.gridSize };
case 'TOGGLE_GRID':
return { ...state, gridVisible: !state.gridVisible };
case 'TOGGLE_SNAP':
return { ...state, snapEnabled: !state.snapEnabled };
case 'SET_SNAP_GRANULARITY':
return { ...state, snapGranularity: action.granularity };
case 'TOGGLE_LAYER':
return {
...state,
layerVisibility: {
...state.layerVisibility,
[action.layer]: !state.layerVisibility[action.layer],
},
};
case 'SET_ELECTRICAL_INDEX':
return { ...state, selectedElectricalIndex: action.index };
case 'SET_FURNITURE_INDEX':
return { ...state, selectedFurnitureIndex: action.index };
case 'DELETE_SELECTED': {
return {
...state,
openings: state.openings.filter((o) => !state.selectedIds.has(o.id)),
electricalItems: state.electricalItems.filter((e) => !state.selectedIds.has(e.id)),
furnitureItems: state.furnitureItems.filter((f) => !state.selectedIds.has(f.id)),
annotations: state.annotations.filter((a) => !state.selectedIds.has(a.id)),
selectedIds: new Set(),
};
}
case 'SELECT_ALL': {
const allIds = new Set<string>();
for (const o of state.openings) allIds.add(o.id);
for (const e of state.electricalItems) allIds.add(e.id);
for (const f of state.furnitureItems) allIds.add(f.id);
for (const a of state.annotations) allIds.add(a.id);
return { ...state, selectedIds: allIds };
}
// ── Clipboard ──
case 'COPY_SELECTED':
// No state change — clipboard is handled via ref in the provider
return state;
case 'PASTE_CLIPBOARD': {
const { items } = action;
return {
...state,
openings: [...state.openings, ...items.openings],
electricalItems: [...state.electricalItems, ...items.electricalItems],
furnitureItems: [...state.furnitureItems, ...items.furnitureItems],
annotations: [...state.annotations, ...items.annotations],
selectedIds: items.newSelectedIds,
};
}
// ── Alignment ──
case 'ALIGN_SELECTED': {
if (state.selectedIds.size < 2) return state;
return applyAlignment(state, action.alignment);
}
// ── Annotations ──
case 'ADD_ANNOTATION':
return { ...state, annotations: [...state.annotations, action.annotation] };
case 'UPDATE_ANNOTATION':
return {
...state,
annotations: state.annotations.map((a) =>
a.id === action.annotation.id ? action.annotation : a,
),
};
case 'REMOVE_ANNOTATION':
return {
...state,
annotations: state.annotations.filter((a) => a.id !== action.id),
selectedIds: removeFromSet(state.selectedIds, action.id),
};
// ── Import ──
case 'SYNC_SAVE': {
// Build set of all new IDs to prune stale selections
const newIds = new Set<string>();
for (const w of action.walls) newIds.add(w.id);
for (const o of action.openings) newIds.add(o.id);
for (const e of action.electricalItems) newIds.add(e.id);
for (const f of action.furnitureItems) newIds.add(f.id);
// Keep only selected IDs that still exist in the new data
const prunedSelection = new Set<string>();
for (const id of state.selectedIds) {
if (newIds.has(id)) prunedSelection.add(id);
}
return {
...state,
walls: action.walls,
openings: action.openings,
electricalItems: action.electricalItems,
furnitureItems: action.furnitureItems,
selectedIds: prunedSelection,
};
}
case 'IMPORT_ROOM':
return {
...state,
room: {
...state.room,
name: action.room.name,
shape: action.room.shape,
wallHeight: action.room.wallHeight,
plinthHeight: action.room.plinthHeight,
plinthThickness: action.room.plinthThickness,
},
walls: action.walls,
openings: action.openings,
electricalItems: action.electricalItems,
furnitureItems: action.furnitureItems,
annotations: [],
selectedIds: new Set(),
};
default:
return state;
}
}
function addToSet(set: ReadonlySet<string>, id: string): ReadonlySet<string> {
const next = new Set(set);
next.add(id);
return next;
}
function removeFromSet(set: ReadonlySet<string>, id: string): ReadonlySet<string> {
if (!set.has(id)) return set;
const next = new Set(set);
next.delete(id);
return next;
}
// ── Alignment helpers ──
interface PositionedItem {
readonly id: string;
readonly x: number;
readonly y: number;
}
function getSelectedPositionedItems(state: EditorState): readonly PositionedItem[] {
const items: PositionedItem[] = [];
for (const id of state.selectedIds) {
const elec = state.electricalItems.find((e) => e.id === id);
if (elec) { items.push({ id: elec.id, x: elec.x, y: elec.y }); continue; }
const furn = state.furnitureItems.find((f) => f.id === id);
if (furn) { items.push({ id: furn.id, x: furn.x, y: furn.y }); continue; }
const ann = state.annotations.find((a) => a.id === id);
if (ann) { items.push({ id: ann.id, x: ann.x, y: ann.y }); continue; }
}
return items;
}
function applyAlignment(state: EditorState, alignment: string): EditorState {
const items = getSelectedPositionedItems(state);
if (items.length < 2) return state;
const offsets = new Map<string, { dx: number; dy: number }>();
switch (alignment) {
case 'left': {
const minX = Math.min(...items.map((i) => i.x));
for (const item of items) offsets.set(item.id, { dx: minX - item.x, dy: 0 });
break;
}
case 'right': {
const maxX = Math.max(...items.map((i) => i.x));
for (const item of items) offsets.set(item.id, { dx: maxX - item.x, dy: 0 });
break;
}
case 'top': {
const minY = Math.min(...items.map((i) => i.y));
for (const item of items) offsets.set(item.id, { dx: 0, dy: minY - item.y });
break;
}
case 'bottom': {
const maxY = Math.max(...items.map((i) => i.y));
for (const item of items) offsets.set(item.id, { dx: 0, dy: maxY - item.y });
break;
}
case 'center-h': {
const avgX = items.reduce((s, i) => s + i.x, 0) / items.length;
for (const item of items) offsets.set(item.id, { dx: avgX - item.x, dy: 0 });
break;
}
case 'center-v': {
const avgY = items.reduce((s, i) => s + i.y, 0) / items.length;
for (const item of items) offsets.set(item.id, { dx: 0, dy: avgY - item.y });
break;
}
case 'distribute-h': {
const sorted = [...items].sort((a, b) => a.x - b.x);
if (sorted.length < 3) break;
const minX = sorted[0].x;
const maxX = sorted[sorted.length - 1].x;
const step = (maxX - minX) / (sorted.length - 1);
for (let i = 0; i < sorted.length; i++) {
offsets.set(sorted[i].id, { dx: minX + step * i - sorted[i].x, dy: 0 });
}
break;
}
case 'distribute-v': {
const sorted = [...items].sort((a, b) => a.y - b.y);
if (sorted.length < 3) break;
const minY = sorted[0].y;
const maxY = sorted[sorted.length - 1].y;
const step = (maxY - minY) / (sorted.length - 1);
for (let i = 0; i < sorted.length; i++) {
offsets.set(sorted[i].id, { dx: 0, dy: minY + step * i - sorted[i].y });
}
break;
}
}
if (offsets.size === 0) return state;
const applyOffset = <T extends { readonly id: string; readonly x: number; readonly y: number }>(
list: readonly T[],
): readonly T[] =>
list.map((item) => {
const o = offsets.get(item.id);
if (!o || (o.dx === 0 && o.dy === 0)) return item;
return { ...item, x: item.x + o.dx, y: item.y + o.dy };
});
return {
...state,
electricalItems: applyOffset(state.electricalItems),
furnitureItems: applyOffset(state.furnitureItems),
annotations: applyOffset(state.annotations),
};
}
// ── ZoomPan Context ──
interface ZoomPanContextValue {
readonly zoom: number;
readonly panOffset: Point;
setZoom(zoom: number): void;
setPanOffset(offset: Point): void;
}
const ZoomPanContext = createContext<ZoomPanContextValue | null>(null);
// ── Selection Context ──
interface SelectionContextValue {
readonly selectedIds: ReadonlySet<string>;
readonly activeTool: EditorToolType;
readonly dispatch: (action: EditorAction) => void;
setTool(tool: EditorToolType): void;
selectElement(id: string, addToSelection?: boolean): void;
clearSelection(): void;
deleteSelected(): void;
selectAll(): void;
}
const SelectionContext = createContext<SelectionContextValue | null>(null);
// ── SceneData Context ──
interface SceneDataContextValue {
readonly room: RoomFull;
readonly walls: readonly Wall[];
readonly openings: readonly WallOpening[];
readonly electricalItems: readonly ElectricalItem[];
readonly furnitureItems: readonly FurnitureItem[];
readonly annotations: readonly Annotation[];
readonly gridSize: number;
readonly gridVisible: boolean;
readonly snapEnabled: boolean;
readonly snapGranularity: number;
readonly layerVisibility: LayerVisibility;
readonly selectedElectricalIndex: number | null;
readonly selectedFurnitureIndex: number | null;
readonly dispatch: (action: EditorAction) => void;
setWalls(walls: readonly Wall[]): void;
updateWall(wall: Wall): void;
addOpening(opening: WallOpening): void;
updateOpening(opening: WallOpening): void;
removeOpening(id: string): void;
addElectrical(item: ElectricalItem): void;
updateElectrical(item: ElectricalItem): void;
removeElectrical(id: string): void;
addFurniture(item: FurnitureItem): void;
updateFurniture(item: FurnitureItem): void;
removeFurniture(id: string): void;
addAnnotation(annotation: Annotation): void;
updateAnnotation(annotation: Annotation): void;
removeAnnotation(id: string): void;
copySelected(): void;
pasteClipboard(): void;
}
const SceneDataContext = createContext<SceneDataContextValue | null>(null);
// ── Legacy combined context (for useEditor backward compat) ──
interface EditorContextValue {
readonly state: EditorState;
readonly dispatch: (action: EditorAction) => void;
setTool(tool: EditorToolType): void;
setZoom(zoom: number): void;
setPanOffset(offset: Point): void;
selectElement(id: string, addToSelection?: boolean): void;
clearSelection(): void;
deleteSelected(): void;
selectAll(): void;
addOpening(opening: WallOpening): void;
updateOpening(opening: WallOpening): void;
removeOpening(id: string): void;
setWalls(walls: readonly Wall[]): void;
updateWall(wall: Wall): void;
addElectrical(item: ElectricalItem): void;
updateElectrical(item: ElectricalItem): void;
removeElectrical(id: string): void;
addFurniture(item: FurnitureItem): void;
updateFurniture(item: FurnitureItem): void;
removeFurniture(id: string): void;
addAnnotation(annotation: Annotation): void;
updateAnnotation(annotation: Annotation): void;
removeAnnotation(id: string): void;
copySelected(): void;
pasteClipboard(): void;
}
const EditorContext = createContext<EditorContextValue | null>(null);
// ── Provider ──
interface EditorProviderProps {
readonly room: RoomFull;
readonly children: ReactNode;
}
export function EditorProvider({ room, children }: EditorProviderProps) {
const [state, dispatch] = useReducer(editorReducer, room, createInitialState);
// ── Stable callbacks (dispatch never changes) ──
const setTool = useCallback(
(tool: EditorToolType) => dispatch({ type: 'SET_TOOL', tool }),
[],
);
const setZoom = useCallback(
(zoom: number) => dispatch({ type: 'SET_ZOOM', zoom }),
[],
);
const setPanOffset = useCallback(
(offset: Point) => dispatch({ type: 'SET_PAN_OFFSET', offset }),
[],
);
const selectElement = useCallback(
(id: string, addToSelection = false) => {
if (addToSelection) {
dispatch({ type: 'ADD_TO_SELECTION', id });
} else {
dispatch({ type: 'SET_SELECTED', ids: new Set([id]) });
}
},
[],
);
const clearSelection = useCallback(
() => dispatch({ type: 'CLEAR_SELECTION' }),
[],
);
// Task 6: dispatch-only callbacks — no closure over state arrays
const deleteSelected = useCallback(
() => dispatch({ type: 'DELETE_SELECTED' }),
[],
);
const selectAll = useCallback(
() => dispatch({ type: 'SELECT_ALL' }),
[],
);
const addOpening = useCallback(
(opening: WallOpening) => dispatch({ type: 'ADD_OPENING', opening }),
[],
);
const updateOpening = useCallback(
(opening: WallOpening) => dispatch({ type: 'UPDATE_OPENING', opening }),
[],
);
const removeOpening = useCallback(
(id: string) => dispatch({ type: 'REMOVE_OPENING', id }),
[],
);
const setWalls = useCallback(
(walls: readonly Wall[]) => dispatch({ type: 'SET_WALLS', walls }),
[],
);
const updateWall = useCallback(
(wall: Wall) => dispatch({ type: 'UPDATE_WALL', wall }),
[],
);
const addElectrical = useCallback(
(item: ElectricalItem) => dispatch({ type: 'ADD_ELECTRICAL', item }),
[],
);
const updateElectrical = useCallback(
(item: ElectricalItem) => dispatch({ type: 'UPDATE_ELECTRICAL', item }),
[],
);
const removeElectrical = useCallback(
(id: string) => dispatch({ type: 'REMOVE_ELECTRICAL', id }),
[],
);
const addFurniture = useCallback(
(item: FurnitureItem) => dispatch({ type: 'ADD_FURNITURE', item }),
[],
);
const updateFurniture = useCallback(
(item: FurnitureItem) => dispatch({ type: 'UPDATE_FURNITURE', item }),
[],
);
const removeFurniture = useCallback(
(id: string) => dispatch({ type: 'REMOVE_FURNITURE', id }),
[],
);
const addAnnotation = useCallback(
(annotation: Annotation) => dispatch({ type: 'ADD_ANNOTATION', annotation }),
[],
);
const updateAnnotation = useCallback(
(annotation: Annotation) => dispatch({ type: 'UPDATE_ANNOTATION', annotation }),
[],
);
const removeAnnotation = useCallback(
(id: string) => dispatch({ type: 'REMOVE_ANNOTATION', id }),
[],
);
// ── Clipboard (ref-based so copy reads current state without closures) ──
const clipboardRef = useRef<{
openings: readonly WallOpening[];
electricalItems: readonly ElectricalItem[];
furnitureItems: readonly FurnitureItem[];
annotations: readonly Annotation[];
}>({ openings: [], electricalItems: [], furnitureItems: [], annotations: [] });
const stateRef = useRef(state);
stateRef.current = state;
const copySelected = useCallback(() => {
const s = stateRef.current;
const ids = s.selectedIds;
if (ids.size === 0) return;
clipboardRef.current = {
openings: s.openings.filter((o) => ids.has(o.id)).map((o) => ({ ...o })),
electricalItems: s.electricalItems.filter((e) => ids.has(e.id)).map((e) => ({ ...e })),
furnitureItems: s.furnitureItems.filter((f) => ids.has(f.id)).map((f) => ({ ...f })),
annotations: s.annotations.filter((a) => ids.has(a.id)).map((a) => ({ ...a })),
};
}, []);
const pasteClipboard = useCallback(() => {
const cb = clipboardRef.current;
const hasItems =
cb.openings.length > 0 ||
cb.electricalItems.length > 0 ||
cb.furnitureItems.length > 0 ||
cb.annotations.length > 0;
if (!hasItems) return;
const PASTE_OFFSET = 0.2;
const newSelectedIds = new Set<string>();
const newOpenings = cb.openings.map((o) => {
const newId = generateLocalId();
newSelectedIds.add(newId);
return { ...o, id: newId, positionAlongWall: o.positionAlongWall + PASTE_OFFSET };
});
const newElectrical = cb.electricalItems.map((e) => {
const newId = generateLocalId();
newSelectedIds.add(newId);
return { ...e, id: newId, x: e.x + PASTE_OFFSET, y: e.y + PASTE_OFFSET };
});
const newFurniture = cb.furnitureItems.map((f) => {
const newId = generateLocalId();
newSelectedIds.add(newId);
return { ...f, id: newId, x: f.x + PASTE_OFFSET, y: f.y + PASTE_OFFSET };
});
const newAnnotations = cb.annotations.map((a) => {
const newId = generateLocalId();
newSelectedIds.add(newId);
return { ...a, id: newId, x: a.x + PASTE_OFFSET, y: a.y + PASTE_OFFSET };
});
const items: PastePayload = {
openings: newOpenings,
electricalItems: newElectrical,
furnitureItems: newFurniture,
annotations: newAnnotations,
newSelectedIds,
};
dispatch({ type: 'PASTE_CLIPBOARD', items });
}, []);
// ── ZoomPan context value (changes only on zoom/panOffset) ──
const zoomPanValue = useMemo<ZoomPanContextValue>(
() => ({ zoom: state.zoom, panOffset: state.panOffset, setZoom, setPanOffset }),
[state.zoom, state.panOffset, setZoom, setPanOffset],
);
// ── Selection context value (changes only on selection/tool) ──
const selectionValue = useMemo<SelectionContextValue>(
() => ({
selectedIds: state.selectedIds,
activeTool: state.activeTool,
dispatch,
setTool,
selectElement,
clearSelection,
deleteSelected,
selectAll,
}),
[state.selectedIds, state.activeTool, setTool, selectElement, clearSelection, deleteSelected, selectAll],
);
// ── SceneData context value (changes only on data edits) ──
const sceneDataValue = useMemo<SceneDataContextValue>(
() => ({
room: state.room,
walls: state.walls,
openings: state.openings,
electricalItems: state.electricalItems,
furnitureItems: state.furnitureItems,
annotations: state.annotations,
gridSize: state.gridSize,
gridVisible: state.gridVisible,
snapEnabled: state.snapEnabled,
snapGranularity: state.snapGranularity,
layerVisibility: state.layerVisibility,
selectedElectricalIndex: state.selectedElectricalIndex,
selectedFurnitureIndex: state.selectedFurnitureIndex,
dispatch,
setWalls,
updateWall,
addOpening,
updateOpening,
removeOpening,
addElectrical,
updateElectrical,
removeElectrical,
addFurniture,
updateFurniture,
removeFurniture,
addAnnotation,
updateAnnotation,
removeAnnotation,
copySelected,
pasteClipboard,
}),
[
state.room,
state.walls,
state.openings,
state.electricalItems,
state.furnitureItems,
state.annotations,
state.gridSize,
state.gridVisible,
state.snapEnabled,
state.snapGranularity,
state.layerVisibility,
state.selectedElectricalIndex,
state.selectedFurnitureIndex,
setWalls,
updateWall,
addOpening,
updateOpening,
removeOpening,
addElectrical,
updateElectrical,
removeElectrical,
addFurniture,
updateFurniture,
removeFurniture,
addAnnotation,
updateAnnotation,
removeAnnotation,
copySelected,
pasteClipboard,
],
);
// ── Legacy combined value ──
const legacyValue = useMemo<EditorContextValue>(
() => ({
state,
dispatch,
setTool,
setZoom,
setPanOffset,
selectElement,
clearSelection,
deleteSelected,
selectAll,
addOpening,
updateOpening,
removeOpening,
setWalls,
updateWall,
addElectrical,
updateElectrical,
removeElectrical,
addFurniture,
updateFurniture,
removeFurniture,
addAnnotation,
updateAnnotation,
removeAnnotation,
copySelected,
pasteClipboard,
}),
[
state,
setTool,
setZoom,
setPanOffset,
selectElement,
clearSelection,
deleteSelected,
selectAll,
addOpening,
updateOpening,
removeOpening,
setWalls,
updateWall,
addElectrical,
updateElectrical,
removeElectrical,
addFurniture,
updateFurniture,
removeFurniture,
addAnnotation,
updateAnnotation,
removeAnnotation,
copySelected,
pasteClipboard,
],
);
return (
<EditorContext.Provider value={legacyValue}>
<ZoomPanContext.Provider value={zoomPanValue}>
<SelectionContext.Provider value={selectionValue}>
<SceneDataContext.Provider value={sceneDataValue}>
{children}
</SceneDataContext.Provider>
</SelectionContext.Provider>
</ZoomPanContext.Provider>
</EditorContext.Provider>
);
}
// ── Hooks ──
/** Legacy hook -- combines all contexts. Use granular hooks for optimal re-render perf. */
export function useEditor(): EditorContextValue {
const ctx = useContext(EditorContext);
if (!ctx) {
throw new Error('useEditor must be used within an EditorProvider');
}
return ctx;
}
/** Granular hook for zoom/pan state (60fps changes). */
export function useZoomPan(): ZoomPanContextValue {
const ctx = useContext(ZoomPanContext);
if (!ctx) {
throw new Error('useZoomPan must be used within an EditorProvider');
}
return ctx;
}
/** Granular hook for selection/tool state. */
export function useSelection(): SelectionContextValue {
const ctx = useContext(SelectionContext);
if (!ctx) {
throw new Error('useSelection must be used within an EditorProvider');
}
return ctx;
}
/** Granular hook for scene data (walls, openings, items, grid, layers). */
export function useSceneData(): SceneDataContextValue {
const ctx = useContext(SceneDataContext);
if (!ctx) {
throw new Error('useSceneData must be used within an EditorProvider');
}
return ctx;
}
@@ -0,0 +1,106 @@
import {
createContext,
useContext,
useCallback,
useRef,
useMemo,
useState,
type ReactNode,
} from 'react';
import type { EditorCommand } from '../types';
interface UndoRedoContextValue {
/** Execute a command and push it onto the undo stack. */
execute(command: EditorCommand): void;
/** Undo the last command. */
undo(): void;
/** Redo the last undone command. */
redo(): void;
/** Whether there are commands to undo. */
readonly canUndo: boolean;
/** Whether there are commands to redo. */
readonly canRedo: boolean;
}
const UndoRedoContext = createContext<UndoRedoContextValue | null>(null);
const MAX_UNDO_STACK = 100;
interface UndoRedoProviderProps {
readonly children: ReactNode;
/** Called whenever the stack changes so the parent can re-render. */
readonly onStackChange?: () => void;
}
export function UndoRedoProvider({ children, onStackChange }: UndoRedoProviderProps) {
const undoStackRef = useRef<EditorCommand[]>([]);
const redoStackRef = useRef<EditorCommand[]>([]);
// State counter to trigger re-renders when stack changes
const [version, setVersion] = useState(0);
const notifyChange = useCallback(() => {
setVersion((v) => v + 1);
onStackChange?.();
}, [onStackChange]);
const execute = useCallback(
(command: EditorCommand) => {
command.execute();
undoStackRef.current = [...undoStackRef.current, command].slice(
-MAX_UNDO_STACK,
);
redoStackRef.current = [];
notifyChange();
},
[notifyChange],
);
const undo = useCallback(() => {
const stack = undoStackRef.current;
if (stack.length === 0) return;
const command = stack[stack.length - 1];
command.undo();
undoStackRef.current = stack.slice(0, -1);
redoStackRef.current = [...redoStackRef.current, command];
notifyChange();
}, [notifyChange]);
const redo = useCallback(() => {
const stack = redoStackRef.current;
if (stack.length === 0) return;
const command = stack[stack.length - 1];
command.execute();
redoStackRef.current = stack.slice(0, -1);
undoStackRef.current = [...undoStackRef.current, command];
notifyChange();
}, [notifyChange]);
const canUndo = undoStackRef.current.length > 0;
const canRedo = redoStackRef.current.length > 0;
const value = useMemo<UndoRedoContextValue>(
() => ({
execute,
undo,
redo,
canUndo,
canRedo,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[execute, undo, redo, version],
);
return (
<UndoRedoContext.Provider value={value}>{children}</UndoRedoContext.Provider>
);
}
export function useUndoRedo(): UndoRedoContextValue {
const ctx = useContext(UndoRedoContext);
if (!ctx) {
throw new Error('useUndoRedo must be used within an UndoRedoProvider');
}
return ctx;
}
@@ -0,0 +1,167 @@
.toolbar {
display: flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-2) var(--space-3);
background-color: var(--color-bg-elevated);
border-bottom: 1px solid var(--color-border);
min-height: 44px;
flex-shrink: 0;
}
.group {
display: flex;
align-items: center;
gap: var(--space-1);
}
.separator {
width: 1px;
height: 24px;
background-color: var(--color-border);
margin: 0 var(--space-1);
}
.spacer {
flex: 1;
}
.toolBtn {
display: flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border: 1px solid transparent;
border-radius: var(--radius-md);
background: none;
cursor: pointer;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
transition: background var(--transition-fast), color var(--transition-fast);
font-family: var(--font-family);
}
.toolBtn:hover {
background-color: var(--color-bg-hover);
color: var(--color-text-primary);
}
.toolBtnActive {
background-color: var(--color-accent-50);
border-color: var(--color-accent-200);
color: var(--color-accent-700);
}
.toolBtnActive:hover {
background-color: var(--color-accent-100);
}
.toolIcon {
font-size: var(--font-size-base);
line-height: 1;
}
.toolLabel {
font-size: var(--font-size-xs);
}
.actionBtn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: var(--radius-md);
background: none;
cursor: pointer;
font-size: var(--font-size-md);
color: var(--color-text-secondary);
transition: background var(--transition-fast), color var(--transition-fast);
font-family: var(--font-family);
}
.actionBtn:hover:not(:disabled) {
background-color: var(--color-bg-hover);
color: var(--color-text-primary);
}
.actionBtn:disabled {
opacity: 0.35;
cursor: default;
}
.toggleBtn {
padding: var(--space-1) var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: none;
cursor: pointer;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
transition: all var(--transition-fast);
font-family: var(--font-family);
}
.toggleBtn:hover {
background-color: var(--color-bg-hover);
}
.toggleBtnActive {
background-color: var(--color-accent-50);
border-color: var(--color-accent-200);
color: var(--color-accent-700);
}
.zoomLabel {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
min-width: 40px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.saveBtn {
padding: var(--space-1) var(--space-3);
border: none;
border-radius: var(--radius-md);
background-color: var(--color-accent-600);
color: var(--color-text-on-accent);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: background var(--transition-fast);
font-family: var(--font-family);
}
.saveBtn:hover:not(:disabled) {
background-color: var(--color-accent-700);
}
.saveBtn:disabled {
opacity: 0.6;
cursor: default;
}
.unsavedLabel {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--color-warning-600, #ca8a04);
padding: var(--space-1) var(--space-2);
}
.autoSaveLabel {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
padding: var(--space-1) var(--space-2);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@@ -0,0 +1,313 @@
import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Modal } from '../../ui/Modal';
import { useEditor } from '../context/EditorContext';
import {
exportKonvaStageToDataUrl,
exportThreeCanvasToDataUrl,
downloadDataUrl,
downloadBlob,
createRoomPdf,
sanitizeFilename,
} from './exportUtils';
import { exportRoomToJson } from './roomFormat';
import { wallDirectionLabel } from '../utils/projectionMapping';
import type Konva from 'konva';
import styles from './export-dialog.module.css';
export type ExportFormat = 'png' | 'pdf' | 'json';
export type ExportScope = 'current-view' | 'room';
interface ExportDialogProps {
readonly open: boolean;
readonly onClose: () => void;
readonly mainStageRef: React.RefObject<Konva.Stage | null>;
readonly projectionStageRefs: React.RefObject<Map<string, Konva.Stage>>;
readonly threeCanvasRef: React.RefObject<HTMLCanvasElement | null>;
readonly is3DView: boolean;
readonly viewMode?: '2d' | '3d' | 'projections';
}
export function ExportDialog({
open,
onClose,
mainStageRef,
projectionStageRefs,
threeCanvasRef,
is3DView,
viewMode = '2d',
}: ExportDialogProps) {
const { t } = useTranslation();
const { state } = useEditor();
const { room, walls } = state;
const [format, setFormat] = useState<ExportFormat>('png');
const [scope, setScope] = useState<ExportScope>('current-view');
const [includeGrid, setIncludeGrid] = useState(false);
const [pixelRatio, setPixelRatio] = useState(2);
const [isExporting, setIsExporting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleExport = useCallback(async () => {
setIsExporting(true);
setError(null);
try {
const baseName = sanitizeFilename(room.name || 'room');
if (format === 'png') {
if (scope === 'current-view') {
// Export the currently visible view
if (viewMode === '3d') {
let canvas = threeCanvasRef.current;
if (!canvas) {
canvas = document.querySelector('canvas[data-engine]') as HTMLCanvasElement | null;
}
if (!canvas) {
setError(t('export.error.3dNotAvailable'));
return;
}
const dataUrl = exportThreeCanvasToDataUrl(canvas);
downloadDataUrl(dataUrl, `${baseName}_3d.png`);
} else if (viewMode === 'projections') {
const projMap = projectionStageRefs.current;
if (projMap && projMap.size > 0) {
for (const wall of walls) {
const projStage = projMap.get(wall.id);
if (projStage && projStage.width() > 0) {
const label = wallDirectionLabel(wall);
const safeName = sanitizeFilename(label);
const dataUrl = exportKonvaStageToDataUrl(projStage, { pixelRatio });
downloadDataUrl(dataUrl, `${baseName}_wall_${safeName}.png`);
}
}
} else {
setError(t('export.error.2dNotAvailable'));
}
} else {
const stage = mainStageRef.current;
if (!stage || stage.width() === 0) {
setError(t('export.error.2dNotAvailable'));
return;
}
const dataUrl = exportKonvaStageToDataUrl(stage, { pixelRatio, includeGrid });
downloadDataUrl(dataUrl, `${baseName}_2d.png`);
}
} else {
// Export all views for the room (2D + projections)
const stage = mainStageRef.current;
if (stage) {
const dataUrl = exportKonvaStageToDataUrl(stage, { pixelRatio, includeGrid });
downloadDataUrl(dataUrl, `${baseName}_2d.png`);
}
// Export projection views
const projMap = projectionStageRefs.current;
if (projMap) {
for (const wall of walls) {
const projStage = projMap.get(wall.id);
if (projStage) {
const label = wallDirectionLabel(wall);
const safeName = sanitizeFilename(label);
const dataUrl = exportKonvaStageToDataUrl(projStage, { pixelRatio });
downloadDataUrl(dataUrl, `${baseName}_wall_${safeName}.png`);
}
}
}
}
} else if (format === 'json') {
// JSON export
const jsonStr = exportRoomToJson(state);
const blob = new Blob([jsonStr], { type: 'application/json' });
downloadBlob(blob, `${baseName}.json`);
} else {
// PDF export
// Capture 2D view — temporarily restore size if hidden
let topDownDataUrl: string | null = null;
const stage = mainStageRef.current;
if (stage) {
const wasHidden = stage.width() === 0;
if (wasHidden) {
// Temporarily make the stage visible for capture
const container = stage.container();
const parent = container?.parentElement;
const origStyle = parent?.getAttribute('style') ?? '';
if (parent) {
parent.setAttribute('style', 'position:absolute;width:1200px;height:900px;overflow:hidden;pointer-events:none;opacity:0;');
}
stage.width(1200);
stage.height(900);
stage.batchDraw();
}
if (stage.width() > 0 && stage.height() > 0) {
topDownDataUrl = exportKonvaStageToDataUrl(stage, { pixelRatio, includeGrid });
}
if (wasHidden) {
// Restore hidden state
const container = stage.container();
const parent = container?.parentElement;
if (parent) {
parent.setAttribute('style', 'position:absolute;width:0;height:0;overflow:hidden;pointer-events:none;');
}
stage.width(0);
stage.height(0);
}
}
// Capture projection views — they may not be mounted if not in projection mode
const projectionDataUrls: { label: string; dataUrl: string }[] = [];
const projMap = projectionStageRefs.current;
if (projMap) {
for (const wall of walls) {
const projStage = projMap.get(wall.id);
if (projStage && projStage.width() > 0 && projStage.height() > 0) {
const label = wallDirectionLabel(wall);
const dataUrl = exportKonvaStageToDataUrl(projStage, { pixelRatio });
projectionDataUrls.push({ label, dataUrl });
}
}
}
let view3dDataUrl: string | null = null;
// Try the ref first, then fall back to querying the DOM
let canvas3d = threeCanvasRef.current;
if (!canvas3d) {
canvas3d = document.querySelector('canvas[data-engine]') as HTMLCanvasElement | null;
}
if (canvas3d && canvas3d.width > 0 && canvas3d.height > 0) {
view3dDataUrl = exportThreeCanvasToDataUrl(canvas3d);
}
const pdf = await createRoomPdf(room.name, topDownDataUrl, projectionDataUrls, view3dDataUrl);
const blob = pdf.output('blob');
downloadBlob(blob, `${baseName}.pdf`);
}
onClose();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('export.error.failed');
setError(message);
} finally {
setIsExporting(false);
}
}, [format, scope, includeGrid, pixelRatio, is3DView, state, room, walls, mainStageRef, projectionStageRefs, threeCanvasRef, onClose, t]);
const footer = (
<div className={styles.footerButtons}>
<button className={styles.cancelBtn} onClick={onClose} type="button">
{t('common.cancel')}
</button>
<button
className={styles.exportBtn}
onClick={handleExport}
disabled={isExporting}
type="button"
>
{isExporting ? t('export.exporting') : t('export.exportBtn')}
</button>
</div>
);
return (
<Modal open={open} onClose={onClose} title={t('export.title')} footer={footer}>
<div className={styles.content}>
{/* Format */}
<div className={styles.fieldGroup}>
<span className={styles.fieldLabel}>{t('export.format')}</span>
<div className={styles.radioGroup}>
<label className={styles.radioLabel}>
<input
type="radio"
name="format"
value="png"
checked={format === 'png'}
onChange={() => setFormat('png')}
/>
{t('export.png')}
</label>
<label className={styles.radioLabel}>
<input
type="radio"
name="format"
value="pdf"
checked={format === 'pdf'}
onChange={() => setFormat('pdf')}
/>
{t('export.pdf')}
</label>
<label className={styles.radioLabel}>
<input
type="radio"
name="format"
value="json"
checked={format === 'json'}
onChange={() => setFormat('json')}
/>
{t('export.json')}
</label>
</div>
</div>
{/* Scope (only for PNG) */}
{format === 'png' && (
<div className={styles.fieldGroup}>
<span className={styles.fieldLabel}>{t('export.scope')}</span>
<div className={styles.radioGroup}>
<label className={styles.radioLabel}>
<input
type="radio"
name="scope"
value="current-view"
checked={scope === 'current-view'}
onChange={() => setScope('current-view')}
/>
{t('export.currentView')}
</label>
<label className={styles.radioLabel}>
<input
type="radio"
name="scope"
value="room"
checked={scope === 'room'}
onChange={() => setScope('room')}
/>
{t('export.allRoomViews')}
</label>
</div>
</div>
)}
{/* Options */}
<div className={styles.fieldGroup}>
<span className={styles.fieldLabel}>{t('export.options')}</span>
{!is3DView && (
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={includeGrid}
onChange={(e) => setIncludeGrid(e.target.checked)}
/>
{t('export.includeGrid')}
</label>
)}
<label className={styles.checkboxLabel}>
{t('export.scaleFactor')}
<input
type="number"
className={styles.scaleInput}
min={1}
max={4}
step={1}
value={pixelRatio}
onChange={(e) => setPixelRatio(Number(e.target.value))}
/>
x
</label>
</div>
{error && <div className={styles.errorMessage}>{error}</div>}
{isExporting && <div className={styles.statusMessage}>{t('export.generating')}</div>}
</div>
</Modal>
);
}
@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { sanitizeFilename } from '../exportUtils';
describe('sanitizeFilename', () => {
it('removes special characters', () => {
expect(sanitizeFilename('Room #1 (Main)')).toBe('Room_1_Main');
});
it('replaces spaces with underscores', () => {
expect(sanitizeFilename('My Room')).toBe('My_Room');
});
it('collapses multiple spaces', () => {
expect(sanitizeFilename('Room Name')).toBe('Room_Name');
});
it('preserves hyphens and underscores', () => {
expect(sanitizeFilename('room-1_main')).toBe('room-1_main');
});
it('handles empty string', () => {
expect(sanitizeFilename('')).toBe('');
});
it('handles string with only special characters', () => {
expect(sanitizeFilename('!@#$%')).toBe('');
});
});
@@ -0,0 +1,121 @@
.content {
display: flex;
flex-direction: column;
gap: var(--space-4);
min-width: 380px;
}
.fieldGroup {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.fieldLabel {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
}
.radioGroup {
display: flex;
gap: var(--space-3);
}
.radioLabel {
display: flex;
align-items: center;
gap: var(--space-1);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
cursor: pointer;
}
.radioLabel input[type="radio"] {
accent-color: var(--color-accent-600);
}
.checkboxLabel {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
cursor: pointer;
}
.checkboxLabel input[type="checkbox"] {
accent-color: var(--color-accent-600);
}
.scaleInput {
width: 60px;
padding: var(--space-1) var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
font-family: var(--font-family);
background: var(--color-bg);
color: var(--color-text-primary);
}
.scaleInput:focus {
outline: 2px solid var(--color-accent-400);
outline-offset: -1px;
}
.footerButtons {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
}
.cancelBtn {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: none;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
cursor: pointer;
font-family: var(--font-family);
}
.cancelBtn:hover {
background-color: var(--color-bg-hover);
}
.exportBtn {
padding: var(--space-2) var(--space-4);
border: none;
border-radius: var(--radius-md);
background-color: var(--color-accent-600);
color: var(--color-text-on-accent);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
cursor: pointer;
font-family: var(--font-family);
transition: background var(--transition-fast);
}
.exportBtn:hover:not(:disabled) {
background-color: var(--color-accent-700);
}
.exportBtn:disabled {
opacity: 0.6;
cursor: default;
}
.statusMessage {
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
padding: var(--space-1) 0;
}
.errorMessage {
font-size: var(--font-size-xs);
color: var(--color-danger-600);
padding: var(--space-1) 0;
}
@@ -0,0 +1,246 @@
import type Konva from 'konva';
import type { jsPDF as JsPDFType } from 'jspdf';
// ── PNG Export Options ──
export interface PngExportOptions {
readonly pixelRatio?: number;
readonly mimeType?: 'image/png' | 'image/jpeg';
readonly quality?: number;
}
// ── Stage-based PNG export ──
/**
* Export a Konva stage to a data URL.
* Optionally hides the grid layer before exporting.
*/
export function exportKonvaStageToDataUrl(
stage: Konva.Stage,
options: PngExportOptions & { readonly includeGrid?: boolean } = {},
): string {
const { pixelRatio = 2, mimeType = 'image/png', quality = 1, includeGrid = false } = options;
// Temporarily hide grid layer if requested (only for multi-layer stages like the 2D editor)
const layers = stage.getLayers();
// Grid is the first layer only when there are multiple layers (2D editor).
// Single-layer stages (projections) should never have their layer hidden.
const gridLayer = layers.length > 1 ? layers[0] : null;
const gridWasVisible = gridLayer?.visible() ?? false;
if (!includeGrid && gridLayer) {
gridLayer.visible(false);
stage.draw();
}
// Force a synchronous redraw before capture
stage.draw();
const dataUrl = stage.toDataURL({
pixelRatio,
mimeType,
quality,
x: 0,
y: 0,
width: stage.width(),
height: stage.height(),
});
// Restore grid visibility
if (!includeGrid && gridLayer && gridWasVisible) {
gridLayer.visible(true);
stage.draw();
}
return dataUrl;
}
/**
* Export a Three.js canvas element to a data URL.
*/
export function exportThreeCanvasToDataUrl(
canvas: HTMLCanvasElement,
): string {
return canvas.toDataURL('image/png');
}
// ── Download helpers ──
/**
* Trigger browser download of a data URL as a file.
*/
export function downloadDataUrl(dataUrl: string, filename: string): void {
const link = document.createElement('a');
link.download = filename;
link.href = dataUrl;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
/**
* Trigger browser download of a Blob.
*/
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
downloadDataUrl(url, filename);
URL.revokeObjectURL(url);
}
// ── PDF Export ──
export interface PdfExportOptions {
readonly title: string;
readonly includeGrid?: boolean;
readonly pixelRatio?: number;
}
/**
* Lazy-load jsPDF for code splitting.
*/
async function loadJsPDF(): Promise<typeof import('jspdf')> {
return import('jspdf');
}
/**
* Create a single-room PDF with:
* - Room name header
* - 2D top-down view
* - Up to 4 wall projection views
*/
export async function createRoomPdf(
roomName: string,
topDownDataUrl: string | null,
projectionDataUrls: readonly { readonly label: string; readonly dataUrl: string }[],
view3dDataUrl: string | null,
): Promise<JsPDFType> {
const { jsPDF } = await loadJsPDF();
const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' });
const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight();
// ── Page 1: Room name + 2D view ──
pdf.setFontSize(18);
pdf.text(roomName, pageWidth / 2, 15, { align: 'center' });
if (topDownDataUrl) {
const imgWidth = pageWidth - 30;
const imgHeight = (pageHeight - 40) * 0.55;
pdf.addImage(topDownDataUrl, 'PNG', 15, 25, imgWidth, imgHeight);
}
// ── Page 2: Wall projections (if any) ──
if (projectionDataUrls.length > 0) {
pdf.addPage('a4', 'landscape');
pdf.setFontSize(14);
pdf.text(`${roomName} - Wall Projections`, pageWidth / 2, 15, { align: 'center' });
const cols = 2;
const rows = 2;
const cellW = (pageWidth - 30) / cols;
const cellH = (pageHeight - 35) / rows;
for (let i = 0; i < Math.min(projectionDataUrls.length, 4); i++) {
const col = i % cols;
const row = Math.floor(i / cols);
const x = 15 + col * cellW;
const y = 22 + row * cellH;
const proj = projectionDataUrls[i];
pdf.setFontSize(9);
pdf.text(proj.label, x + cellW / 2, y + 2, { align: 'center' });
pdf.addImage(proj.dataUrl, 'PNG', x + 2, y + 5, cellW - 4, cellH - 10);
}
}
// ── Page 3: 3D view (if available) ──
if (view3dDataUrl) {
pdf.addPage('a4', 'landscape');
pdf.setFontSize(14);
pdf.text(`${roomName} - 3D View`, pageWidth / 2, 15, { align: 'center' });
const imgWidth = pageWidth - 30;
const imgHeight = pageHeight - 30;
pdf.addImage(view3dDataUrl, 'PNG', 15, 20, imgWidth, imgHeight);
}
return pdf;
}
/**
* Create a multi-room apartment PDF with a cover page.
*/
export async function createApartmentPdf(
apartmentName: string,
rooms: readonly {
readonly roomName: string;
readonly topDownDataUrl: string | null;
readonly projectionDataUrls: readonly { readonly label: string; readonly dataUrl: string }[];
}[],
): Promise<JsPDFType> {
const { jsPDF } = await loadJsPDF();
const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' });
const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight();
// ── Cover page ──
pdf.setFontSize(28);
pdf.text(apartmentName, pageWidth / 2, pageHeight / 3, { align: 'center' });
pdf.setFontSize(14);
pdf.text('House Plan', pageWidth / 2, pageHeight / 3 + 12, { align: 'center' });
pdf.setFontSize(10);
pdf.text(`Generated: ${new Date().toLocaleDateString()}`, pageWidth / 2, pageHeight / 3 + 22, { align: 'center' });
pdf.setFontSize(11);
pdf.text(
`${rooms.length} room${rooms.length === 1 ? '' : 's'}`,
pageWidth / 2,
pageHeight / 3 + 30,
{ align: 'center' },
);
// ── Room pages ──
for (const room of rooms) {
pdf.addPage('a4', 'landscape');
pdf.setFontSize(18);
pdf.text(room.roomName, pageWidth / 2, 15, { align: 'center' });
if (room.topDownDataUrl) {
const imgWidth = pageWidth - 30;
const imgHeight = (pageHeight - 40) * 0.55;
pdf.addImage(room.topDownDataUrl, 'PNG', 15, 25, imgWidth, imgHeight);
}
// Projections page
if (room.projectionDataUrls.length > 0) {
pdf.addPage('a4', 'landscape');
pdf.setFontSize(14);
pdf.text(`${room.roomName} - Wall Projections`, pageWidth / 2, 15, { align: 'center' });
const cols = 2;
const rows = 2;
const cellW = (pageWidth - 30) / cols;
const cellH = (pageHeight - 35) / rows;
for (let i = 0; i < Math.min(room.projectionDataUrls.length, 4); i++) {
const col = i % cols;
const row = Math.floor(i / cols);
const x = 15 + col * cellW;
const y = 22 + row * cellH;
const proj = room.projectionDataUrls[i];
pdf.setFontSize(9);
pdf.text(proj.label, x + cellW / 2, y + 2, { align: 'center' });
pdf.addImage(proj.dataUrl, 'PNG', x + 2, y + 5, cellW - 4, cellH - 10);
}
}
}
return pdf;
}
/**
* Sanitize a filename for download.
*/
export function sanitizeFilename(name: string): string {
return name.replace(/[^a-zA-Z0-9_\-\s]/g, '').replace(/\s+/g, '_');
}
@@ -0,0 +1,405 @@
import type {
Point,
Wall,
WallOpening,
ElectricalItem,
FurnitureItem,
WallDirection,
OpeningType,
ElectricalType,
FurnitureType,
RoomFull,
} from '@house-plan-maker/shared';
import {
WALL_DIRECTIONS,
OPENING_TYPES,
ELECTRICAL_TYPES,
FURNITURE_TYPES,
} from '@house-plan-maker/shared';
import type { EditorState } from '../types';
import { generateLocalId } from '../utils/geometry';
// ── Export Format ──
interface RoomExportData {
readonly version: 1;
readonly room: {
readonly name: string;
readonly shape: readonly Point[];
readonly wallHeight: number;
readonly plinthHeight: number;
readonly plinthThickness: number;
};
readonly walls: readonly {
readonly startX: number;
readonly startY: number;
readonly endX: number;
readonly endY: number;
readonly thickness: number;
readonly direction: string;
}[];
readonly openings: readonly {
readonly wallIndex: number;
readonly type: string;
readonly positionAlongWall: number;
readonly width: number;
readonly height: number;
readonly elevationFromFloor: number;
}[];
readonly electricalItems: readonly {
readonly type: string;
readonly x: number;
readonly y: number;
readonly wallIndex: number | null;
readonly elevationFromFloor: number | null;
readonly rotation: number;
readonly metadata: Record<string, unknown> | null;
}[];
readonly furnitureItems: readonly {
readonly type: string;
readonly x: number;
readonly y: number;
readonly width: number;
readonly depth: number;
readonly height: number;
readonly rotation: number;
readonly label: string | null;
}[];
}
// ── Import Result ──
interface ImportResult {
readonly room: {
readonly name: string;
readonly shape: readonly Point[];
readonly wallHeight: number;
readonly plinthHeight: number;
readonly plinthThickness: number;
};
readonly walls: readonly Wall[];
readonly openings: readonly WallOpening[];
readonly electricalItems: readonly ElectricalItem[];
readonly furnitureItems: readonly FurnitureItem[];
}
// ── Export ──
export function exportRoomToJson(state: EditorState): string {
const { room, walls, openings, electricalItems, furnitureItems } = state;
// Build wall ID -> index map
const wallIdToIndex = new Map<string, number>();
for (let i = 0; i < walls.length; i++) {
wallIdToIndex.set(walls[i].id, i);
}
const exportData: RoomExportData = {
version: 1,
room: {
name: room.name,
shape: room.shape,
wallHeight: room.wallHeight,
plinthHeight: room.plinthHeight,
plinthThickness: room.plinthThickness,
},
walls: walls.map((w) => ({
startX: w.startX,
startY: w.startY,
endX: w.endX,
endY: w.endY,
thickness: w.thickness,
direction: w.direction,
})),
openings: openings.map((o) => ({
wallIndex: wallIdToIndex.get(o.wallId) ?? -1,
type: o.type,
positionAlongWall: o.positionAlongWall,
width: o.width,
height: o.height,
elevationFromFloor: o.elevationFromFloor,
})),
electricalItems: electricalItems.map((e) => ({
type: e.type,
x: e.x,
y: e.y,
wallIndex: e.wallId !== null ? (wallIdToIndex.get(e.wallId) ?? null) : null,
elevationFromFloor: e.elevationFromFloor,
rotation: e.rotation,
metadata: e.metadata,
})),
furnitureItems: furnitureItems.map((f) => ({
type: f.type,
x: f.x,
y: f.y,
width: f.width,
depth: f.depth,
height: f.height,
rotation: f.rotation,
label: f.label,
})),
};
return JSON.stringify(exportData, null, 2);
}
// ── Validation Helpers ──
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function isString(value: unknown): value is string {
return typeof value === 'string';
}
function isPoint(value: unknown): value is Point {
return isRecord(value) && isNumber(value.x) && isNumber(value.y);
}
function assertField<T>(
obj: Record<string, unknown>,
field: string,
check: (v: unknown) => v is T,
label: string,
): T {
const value = obj[field];
if (!check(value)) {
throw new Error(`Invalid or missing field "${field}" in ${label}`);
}
return value;
}
function validateWallDirection(value: string): WallDirection {
if (!(WALL_DIRECTIONS as readonly string[]).includes(value)) {
throw new Error(`Invalid wall direction: "${value}". Expected one of: ${WALL_DIRECTIONS.join(', ')}`);
}
return value as WallDirection;
}
function validateOpeningType(value: string): OpeningType {
if (!(OPENING_TYPES as readonly string[]).includes(value)) {
throw new Error(`Invalid opening type: "${value}". Expected one of: ${OPENING_TYPES.join(', ')}`);
}
return value as OpeningType;
}
function validateElectricalType(value: string): ElectricalType {
if (!(ELECTRICAL_TYPES as readonly string[]).includes(value)) {
throw new Error(`Invalid electrical type: "${value}". Expected one of: ${ELECTRICAL_TYPES.join(', ')}`);
}
return value as ElectricalType;
}
function validateFurnitureType(value: string): FurnitureType {
if (!(FURNITURE_TYPES as readonly string[]).includes(value)) {
throw new Error(`Invalid furniture type: "${value}". Expected one of: ${FURNITURE_TYPES.join(', ')}`);
}
return value as FurnitureType;
}
// ── Import ──
export function importRoomFromJson(json: string): ImportResult {
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch {
throw new Error('Invalid JSON: the file does not contain valid JSON data.');
}
if (!isRecord(parsed)) {
throw new Error('Invalid format: expected a JSON object at the top level.');
}
// Version check
if (parsed.version !== 1) {
throw new Error(
`Unsupported format version: ${String(parsed.version)}. Expected version 1.`,
);
}
// ── Room ──
if (!isRecord(parsed.room)) {
throw new Error('Missing or invalid "room" field.');
}
const roomData = parsed.room;
const roomName = assertField(roomData, 'name', isString, 'room');
const roomWallHeight = assertField(roomData, 'wallHeight', isNumber, 'room');
const roomPlinthHeight = assertField(roomData, 'plinthHeight', isNumber, 'room');
const roomPlinthThickness = assertField(roomData, 'plinthThickness', isNumber, 'room');
if (!Array.isArray(roomData.shape)) {
throw new Error('Missing or invalid "room.shape" field: expected an array of points.');
}
const shape: Point[] = [];
for (let i = 0; i < roomData.shape.length; i++) {
const pt = roomData.shape[i];
if (!isPoint(pt)) {
throw new Error(`Invalid point at room.shape[${i}]: expected { x: number, y: number }.`);
}
shape.push({ x: pt.x, y: pt.y });
}
// ── Walls ──
if (!Array.isArray(parsed.walls)) {
throw new Error('Missing or invalid "walls" field: expected an array.');
}
const roomId = generateLocalId();
const wallIds: string[] = [];
const walls: Wall[] = [];
for (let i = 0; i < parsed.walls.length; i++) {
const w = parsed.walls[i];
if (!isRecord(w)) {
throw new Error(`Invalid wall at index ${i}: expected an object.`);
}
const wallId = generateLocalId();
wallIds.push(wallId);
walls.push({
id: wallId,
roomId,
startX: assertField(w, 'startX', isNumber, `walls[${i}]`),
startY: assertField(w, 'startY', isNumber, `walls[${i}]`),
endX: assertField(w, 'endX', isNumber, `walls[${i}]`),
endY: assertField(w, 'endY', isNumber, `walls[${i}]`),
thickness: assertField(w, 'thickness', isNumber, `walls[${i}]`),
direction: validateWallDirection(
assertField(w, 'direction', isString, `walls[${i}]`),
),
});
}
// ── Openings ──
if (!Array.isArray(parsed.openings)) {
throw new Error('Missing or invalid "openings" field: expected an array.');
}
const openings: WallOpening[] = [];
for (let i = 0; i < parsed.openings.length; i++) {
const o = parsed.openings[i];
if (!isRecord(o)) {
throw new Error(`Invalid opening at index ${i}: expected an object.`);
}
const wallIndex = assertField(o, 'wallIndex', isNumber, `openings[${i}]`);
if (!Number.isInteger(wallIndex) || wallIndex < 0 || wallIndex >= walls.length) {
throw new Error(
`Invalid wallIndex ${wallIndex} in openings[${i}]: must be 0..${walls.length - 1}.`,
);
}
openings.push({
id: generateLocalId(),
roomId,
wallId: wallIds[wallIndex],
type: validateOpeningType(
assertField(o, 'type', isString, `openings[${i}]`),
),
positionAlongWall: assertField(o, 'positionAlongWall', isNumber, `openings[${i}]`),
width: assertField(o, 'width', isNumber, `openings[${i}]`),
height: assertField(o, 'height', isNumber, `openings[${i}]`),
elevationFromFloor: assertField(o, 'elevationFromFloor', isNumber, `openings[${i}]`),
openDirection: (isString((o as Record<string, unknown>).openDirection) ? (o as Record<string, unknown>).openDirection as string : 'LEFT') as WallOpening['openDirection'],
});
}
// ── Electrical Items ──
if (!Array.isArray(parsed.electricalItems)) {
throw new Error('Missing or invalid "electricalItems" field: expected an array.');
}
const electricalItems: ElectricalItem[] = [];
for (let i = 0; i < parsed.electricalItems.length; i++) {
const e = parsed.electricalItems[i];
if (!isRecord(e)) {
throw new Error(`Invalid electrical item at index ${i}: expected an object.`);
}
const wallIndex = e.wallIndex;
let wallId: string | null = null;
if (wallIndex !== null) {
if (!isNumber(wallIndex) || !Number.isInteger(wallIndex) || wallIndex < 0 || wallIndex >= walls.length) {
throw new Error(
`Invalid wallIndex ${String(wallIndex)} in electricalItems[${i}]: must be null or 0..${walls.length - 1}.`,
);
}
wallId = wallIds[wallIndex];
}
const metadata = e.metadata;
let validatedMetadata: Record<string, unknown> | null = null;
if (metadata !== null && metadata !== undefined) {
if (!isRecord(metadata)) {
throw new Error(`Invalid metadata in electricalItems[${i}]: expected an object or null.`);
}
validatedMetadata = metadata;
}
electricalItems.push({
id: generateLocalId(),
roomId,
type: validateElectricalType(
assertField(e, 'type', isString, `electricalItems[${i}]`),
),
x: assertField(e, 'x', isNumber, `electricalItems[${i}]`),
y: assertField(e, 'y', isNumber, `electricalItems[${i}]`),
wallId,
elevationFromFloor: e.elevationFromFloor === null ? null : assertField(e, 'elevationFromFloor', isNumber, `electricalItems[${i}]`),
rotation: assertField(e, 'rotation', isNumber, `electricalItems[${i}]`),
metadata: validatedMetadata,
});
}
// ── Furniture Items ──
if (!Array.isArray(parsed.furnitureItems)) {
throw new Error('Missing or invalid "furnitureItems" field: expected an array.');
}
const furnitureItems: FurnitureItem[] = [];
for (let i = 0; i < parsed.furnitureItems.length; i++) {
const f = parsed.furnitureItems[i];
if (!isRecord(f)) {
throw new Error(`Invalid furniture item at index ${i}: expected an object.`);
}
const label = f.label;
if (label !== null && !isString(label)) {
throw new Error(`Invalid label in furnitureItems[${i}]: expected a string or null.`);
}
furnitureItems.push({
id: generateLocalId(),
roomId,
type: validateFurnitureType(
assertField(f, 'type', isString, `furnitureItems[${i}]`),
),
x: assertField(f, 'x', isNumber, `furnitureItems[${i}]`),
y: assertField(f, 'y', isNumber, `furnitureItems[${i}]`),
width: assertField(f, 'width', isNumber, `furnitureItems[${i}]`),
depth: assertField(f, 'depth', isNumber, `furnitureItems[${i}]`),
height: assertField(f, 'height', isNumber, `furnitureItems[${i}]`),
rotation: assertField(f, 'rotation', isNumber, `furnitureItems[${i}]`),
elevationFromFloor: isNumber((f as Record<string, unknown>).elevationFromFloor) ? (f as Record<string, unknown>).elevationFromFloor as number : 0,
label: (label as string | null) ?? null,
});
}
return {
room: {
name: roomName,
shape,
wallHeight: roomWallHeight,
plinthHeight: roomPlinthHeight,
plinthThickness: roomPlinthThickness,
},
walls,
openings,
electricalItems,
furnitureItems,
};
}
@@ -0,0 +1,170 @@
import { useCallback, useRef } from 'react';
import type { Point } from '@house-plan-maker/shared';
import type Konva from 'konva';
import { clamp } from '../utils/geometry';
import { MIN_ZOOM, MAX_ZOOM, ZOOM_SENSITIVITY, DEFAULT_ZOOM } from '../types';
interface UseEditorZoomOptions {
readonly zoom: number;
readonly panOffset: Point;
readonly onZoomChange: (zoom: number) => void;
readonly onPanChange: (offset: Point) => void;
}
interface UseEditorZoomResult {
/** Handle mouse wheel for zooming. Attach to Stage. */
handleWheel(e: Konva.KonvaEventObject<WheelEvent>): void;
/** Convert screen pixel coordinates to world (meter) coordinates. */
screenToWorld(screenX: number, screenY: number): Point;
/** Convert world (meter) coordinates to screen pixel coordinates. */
worldToScreen(worldX: number, worldY: number): Point;
/** Zoom in by a fixed step. */
zoomIn(): void;
/** Zoom out by a fixed step. */
zoomOut(): void;
/** Reset zoom and pan to fit the stage. */
resetView(): void;
/** Whether middle mouse is currently panning. */
isPanningRef: React.RefObject<boolean>;
/** Handle mouse down for panning (middle mouse button). */
handlePanStart(e: Konva.KonvaEventObject<MouseEvent>): void;
/** Start panning from left mouse button (empty space click). */
startLeftMousePan(e: Konva.KonvaEventObject<MouseEvent>): void;
/** Handle mouse move for panning. */
handlePanMove(e: Konva.KonvaEventObject<MouseEvent>): void;
/** Handle mouse up to end panning. */
handlePanEnd(): void;
}
const ZOOM_STEP_FACTOR = 1.2;
export function useEditorZoom({
zoom,
panOffset,
onZoomChange,
onPanChange,
}: UseEditorZoomOptions): UseEditorZoomResult {
const isPanningRef = useRef(false);
const lastPanPointRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const panAccumRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const screenToWorld = useCallback(
(screenX: number, screenY: number): Point => ({
x: (screenX - panOffset.x) / zoom,
y: (screenY - panOffset.y) / zoom,
}),
[zoom, panOffset],
);
const worldToScreen = useCallback(
(worldX: number, worldY: number): Point => ({
x: worldX * zoom + panOffset.x,
y: worldY * zoom + panOffset.y,
}),
[zoom, panOffset],
);
const handleWheel = useCallback(
(e: Konva.KonvaEventObject<WheelEvent>) => {
e.evt.preventDefault();
const stage = e.target.getStage();
if (!stage) return;
const pointerPos = stage.getPointerPosition();
if (!pointerPos) return;
// Get world point under cursor before zoom
const worldBefore = screenToWorld(pointerPos.x, pointerPos.y);
// Compute new zoom
const delta = -e.evt.deltaY * ZOOM_SENSITIVITY;
const scale = Math.exp(delta);
const newZoom = clamp(zoom * scale, MIN_ZOOM, MAX_ZOOM);
// Adjust pan so the world point stays under the cursor
const newPanOffset: Point = {
x: pointerPos.x - worldBefore.x * newZoom,
y: pointerPos.y - worldBefore.y * newZoom,
};
onZoomChange(newZoom);
onPanChange(newPanOffset);
},
[zoom, screenToWorld, onZoomChange, onPanChange],
);
const zoomIn = useCallback(() => {
const newZoom = clamp(zoom * ZOOM_STEP_FACTOR, MIN_ZOOM, MAX_ZOOM);
onZoomChange(newZoom);
}, [zoom, onZoomChange]);
const zoomOut = useCallback(() => {
const newZoom = clamp(zoom / ZOOM_STEP_FACTOR, MIN_ZOOM, MAX_ZOOM);
onZoomChange(newZoom);
}, [zoom, onZoomChange]);
const resetView = useCallback(() => {
onZoomChange(DEFAULT_ZOOM);
onPanChange({ x: 50, y: 50 });
}, [onZoomChange, onPanChange]);
const handlePanStart = useCallback(
(e: Konva.KonvaEventObject<MouseEvent>) => {
// Middle mouse button (button === 1) — always pans
if (e.evt.button === 1) {
e.evt.preventDefault();
isPanningRef.current = true;
lastPanPointRef.current = { x: e.evt.clientX, y: e.evt.clientY };
panAccumRef.current = { x: panOffset.x, y: panOffset.y };
}
},
[panOffset],
);
/** Start panning from left mouse button (called by canvas on empty-space click). */
const startLeftMousePan = useCallback(
(e: Konva.KonvaEventObject<MouseEvent>) => {
isPanningRef.current = true;
lastPanPointRef.current = { x: e.evt.clientX, y: e.evt.clientY };
panAccumRef.current = { x: panOffset.x, y: panOffset.y };
},
[panOffset],
);
const handlePanMove = useCallback(
(e: Konva.KonvaEventObject<MouseEvent>) => {
if (!isPanningRef.current) return;
const dx = e.evt.clientX - lastPanPointRef.current.x;
const dy = e.evt.clientY - lastPanPointRef.current.y;
lastPanPointRef.current = { x: e.evt.clientX, y: e.evt.clientY };
panAccumRef.current = {
x: panAccumRef.current.x + dx,
y: panAccumRef.current.y + dy,
};
onPanChange(panAccumRef.current);
},
[onPanChange],
);
const handlePanEnd = useCallback(() => {
isPanningRef.current = false;
}, []);
return {
handleWheel,
screenToWorld,
worldToScreen,
zoomIn,
zoomOut,
resetView,
isPanningRef,
handlePanStart,
startLeftMousePan,
handlePanMove,
handlePanEnd,
};
}
@@ -0,0 +1,129 @@
import { useEffect } from 'react';
import { useEditor } from '../context/EditorContext';
import { useUndoRedo } from '../context/UndoRedoContext';
interface UseKeyboardShortcutsOptions {
/** Called when Ctrl+S is pressed. */
readonly onSave: () => void;
}
export function useKeyboardShortcuts({ onSave }: UseKeyboardShortcutsOptions): void {
const { deleteSelected, clearSelection, selectAll, setTool, copySelected, pasteClipboard } = useEditor();
const { undo, redo } = useUndoRedo();
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
const target = e.target as HTMLElement;
// Ignore shortcuts when typing in input fields
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable
) {
return;
}
const isCtrl = e.ctrlKey || e.metaKey;
// Ctrl+Z / Ctrl+Shift+Z — undo/redo
if (isCtrl && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
undo();
return;
}
if (isCtrl && e.key === 'z' && e.shiftKey) {
e.preventDefault();
redo();
return;
}
// Ctrl+Y — redo (alternative)
if (isCtrl && e.key === 'y') {
e.preventDefault();
redo();
return;
}
// Ctrl+S — save
if (isCtrl && e.key === 's') {
e.preventDefault();
onSave();
return;
}
// Ctrl+C — copy selected
if (isCtrl && e.key === 'c') {
e.preventDefault();
copySelected();
return;
}
// Ctrl+V — paste clipboard
if (isCtrl && e.key === 'v') {
e.preventDefault();
pasteClipboard();
return;
}
// Ctrl+D — duplicate (copy + paste)
if (isCtrl && e.key === 'd') {
e.preventDefault();
copySelected();
pasteClipboard();
return;
}
// Ctrl+A — select all
if (isCtrl && e.key === 'a') {
e.preventDefault();
selectAll();
return;
}
// Delete / Backspace — delete selected
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
deleteSelected();
return;
}
// Escape — deselect / cancel tool
if (e.key === 'Escape') {
e.preventDefault();
clearSelection();
setTool('select');
return;
}
// Tool shortcuts (single keys, no modifiers)
if (!isCtrl && !e.altKey) {
switch (e.key.toLowerCase()) {
case 'v':
setTool('select');
break;
case 'd':
setTool('door');
break;
case 'w':
setTool('window');
break;
case 'e':
setTool('electrical');
break;
case 'f':
setTool('furniture');
break;
case 'm':
setTool('measure');
break;
case 't':
setTool('annotate');
break;
}
}
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [undo, redo, onSave, selectAll, deleteSelected, clearSelection, setTool, copySelected, pasteClipboard]);
}
@@ -0,0 +1,79 @@
import { useCallback } from 'react';
import type { Point, Wall } from '@house-plan-maker/shared';
import { snapPointToGrid } from '../utils/geometry';
import { findNearestWall } from '../utils/wallUtils';
interface UseSnappingOptions {
readonly snapEnabled: boolean;
readonly snapGranularity: number;
readonly walls: readonly Wall[];
}
/** Maximum distance (in meters) to snap to a wall. */
const WALL_SNAP_DISTANCE = 0.3;
interface UseSnappingResult {
/** Snap a point to the grid. */
snapToGrid(point: Point): Point;
/**
* Snap a point to the nearest wall if close enough.
* Returns the snapped point and the wall it snapped to.
*/
snapToWall(point: Point): { snapped: Point; wall: Wall | null; positionAlongWall: number };
/**
* Apply all active snapping (grid + wall).
* Wall snapping takes priority when close enough.
*/
snapPoint(point: Point): Point;
}
export function useSnapping({
snapEnabled,
snapGranularity,
walls,
}: UseSnappingOptions): UseSnappingResult {
const snapToGridFn = useCallback(
(point: Point): Point => {
if (!snapEnabled) return point;
return snapPointToGrid(point, snapGranularity);
},
[snapEnabled, snapGranularity],
);
const snapToWallFn = useCallback(
(
point: Point,
): { snapped: Point; wall: Wall | null; positionAlongWall: number } => {
if (!snapEnabled || walls.length === 0) {
return { snapped: point, wall: null, positionAlongWall: 0 };
}
const nearest = findNearestWall(point, walls);
if (!nearest || nearest.distance > WALL_SNAP_DISTANCE) {
return { snapped: point, wall: null, positionAlongWall: 0 };
}
return {
snapped: nearest.projected,
wall: nearest.wall,
positionAlongWall: nearest.positionAlongWall,
};
},
[snapEnabled, walls],
);
const snapPointFn = useCallback(
(point: Point): Point => {
if (!snapEnabled) return point;
// Grid snapping only (wall snapping is used specifically by door/window tools)
return snapPointToGrid(point, snapGranularity);
},
[snapEnabled, snapGranularity],
);
return {
snapToGrid: snapToGridFn,
snapToWall: snapToWallFn,
snapPoint: snapPointFn,
};
}
@@ -0,0 +1,148 @@
import { memo, useMemo } from 'react';
import { Layer, Text, Rect, Group, Line } from 'react-konva';
import type { Point, Annotation, ElectricalItem, FurnitureItem } from '@house-plan-maker/shared';
interface AnnotationLayerProps {
readonly annotations: readonly Annotation[];
readonly electricalItems: readonly ElectricalItem[];
readonly furnitureItems: readonly FurnitureItem[];
readonly zoom: number;
readonly panOffset: Point;
readonly selectedIds: ReadonlySet<string>;
readonly visible?: boolean;
readonly onDragEnd?: (id: string, x: number, y: number) => void;
readonly onDoubleClick?: (id: string) => void;
readonly onSelect?: (id: string) => void;
}
const DEFAULT_FONT_SIZE = 14;
const DEFAULT_COLOR = '#333333';
const SELECTED_COLOR = '#4c6ef5';
const SELECTION_PADDING = 4;
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 AnnotationLayer = memo(function AnnotationLayer({
annotations,
electricalItems,
furnitureItems,
zoom,
panOffset,
selectedIds,
visible = true,
onDragEnd,
onDoubleClick,
onSelect,
}: AnnotationLayerProps) {
// Build item position lookup for attached annotations
const itemPositions = useMemo(() => {
const map = new Map<string, { x: number; y: number }>();
for (const item of electricalItems) {
map.set(item.id, { x: item.x, y: item.y });
}
for (const item of furnitureItems) {
map.set(item.id, { x: item.x + item.width / 2, y: item.y + item.depth / 2 });
}
return map;
}, [electricalItems, furnitureItems]);
const renderedAnnotations = useMemo(() => {
if (!visible) return [];
return annotations;
}, [annotations, visible]);
return (
<Layer visible={visible}>
{renderedAnnotations.map((annotation) => {
// Resolve position: if attached, offset from parent item
let worldX = annotation.x;
let worldY = annotation.y;
let parentScreen: { x: number; y: number } | null = null;
if (annotation.attachedToId) {
const parentPos = itemPositions.get(annotation.attachedToId);
if (parentPos) {
worldX = parentPos.x + annotation.x;
worldY = parentPos.y + annotation.y;
parentScreen = toScreen(parentPos, zoom, panOffset);
}
}
const screen = toScreen({ x: worldX, y: worldY }, zoom, panOffset);
const isSelected = selectedIds.has(annotation.id);
const fontSize = annotation.fontSize ?? DEFAULT_FONT_SIZE;
const color = isSelected ? SELECTED_COLOR : (annotation.color ?? DEFAULT_COLOR);
return (
<Group key={annotation.id}>
{/* Leader line from item to annotation */}
{parentScreen && (
<Line
points={[parentScreen.x, parentScreen.y, screen.x, screen.y]}
stroke={annotation.color ?? '#94a3b8'}
strokeWidth={1}
dash={[3, 3]}
listening={false}
/>
)}
<Group
x={screen.x}
y={screen.y}
draggable
onDragEnd={(e) => {
const node = e.target;
let newX = (node.x() - panOffset.x) / zoom;
let newY = (node.y() - panOffset.y) / zoom;
// For attached annotations, store as offset from parent
if (annotation.attachedToId) {
const parentPos = itemPositions.get(annotation.attachedToId);
if (parentPos) {
newX -= parentPos.x;
newY -= parentPos.y;
}
}
onDragEnd?.(annotation.id, newX, newY);
}}
onClick={() => onSelect?.(annotation.id)}
onDblClick={() => onDoubleClick?.(annotation.id)}
>
{/* Background */}
<Rect
x={-2}
y={-1}
width={annotation.text.length * fontSize * 0.6 + 4}
height={fontSize + 2}
fill="rgba(255,255,255,0.85)"
cornerRadius={2}
listening={false}
/>
{isSelected && (
<Rect
x={-SELECTION_PADDING}
y={-SELECTION_PADDING}
width={annotation.text.length * fontSize * 0.6 + SELECTION_PADDING * 2}
height={fontSize + SELECTION_PADDING * 2}
stroke={SELECTED_COLOR}
strokeWidth={1}
dash={[4, 2]}
fill="rgba(76, 110, 245, 0.05)"
/>
)}
<Text
text={annotation.text}
fontSize={fontSize}
fill={color}
fontFamily="sans-serif"
/>
</Group>
</Group>
);
})}
</Layer>
);
});
@@ -0,0 +1,170 @@
import { memo, useMemo } from 'react';
import { Layer, Group, Circle } from 'react-konva';
import type { Point, ElectricalItem } from '@house-plan-maker/shared';
import {
SingleOutletSymbol,
DoubleOutletSymbol,
GroundedOutletSymbol,
SingleSwitchSymbol,
DoubleSwitchSymbol,
DimmerSwitchSymbol,
JunctionBoxSymbol,
CeilingLightSymbol,
WallLightSymbol,
CableRouteSymbol,
getElectricalVariant,
} from '../symbols/electrical';
import { getCableWaypoints } from '../tools/ElectricalTool';
import { getLightCoverageRadius } from '../utils/lightCoverage';
interface ElectricalLayerProps {
readonly items: readonly ElectricalItem[];
readonly zoom: number;
readonly panOffset: Point;
readonly selectedIds: ReadonlySet<string>;
readonly visible?: boolean;
}
const ELECTRICAL_COLOR = '#d63384';
const SELECTED_COLOR = '#4c6ef5';
const LIGHT_COVERAGE_COLOR = 'rgba(255, 235, 59, 0.12)';
const LIGHT_COVERAGE_STROKE = 'rgba(255, 235, 59, 0.3)';
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 ElectricalLayer = memo(function ElectricalLayer({
items,
zoom,
panOffset,
selectedIds,
visible = true,
}: ElectricalLayerProps) {
const scale = Math.max(0.6, Math.min(1.5, zoom / 100));
const renderedItems = useMemo(() => {
if (!visible) return [];
return items.filter((item) => item.type !== 'CABLE_ROUTE');
}, [items, visible]);
const cableItems = useMemo(() => {
if (!visible) return [];
return items.filter((item) => item.type === 'CABLE_ROUTE');
}, [items, visible]);
return (
<Layer listening={false} visible={visible}>
{/* Cable routes first (below symbols) */}
{cableItems.map((item) => {
const waypoints = getCableWaypoints(item);
const screenPoints: number[] = [];
for (const wp of waypoints) {
const sp = toScreen(wp, zoom, panOffset);
screenPoints.push(sp.x, sp.y);
}
return (
<CableRouteSymbol
key={item.id}
points={screenPoints}
color={ELECTRICAL_COLOR}
isSelected={selectedIds.has(item.id)}
/>
);
})}
{/* Electrical symbols */}
{renderedItems.map((item) => {
const screen = toScreen({ x: item.x, y: item.y }, zoom, panOffset);
const isSelected = selectedIds.has(item.id);
const color = isSelected ? SELECTED_COLOR : ELECTRICAL_COLOR;
const variant = getElectricalVariant(item.metadata);
return (
<Group key={item.id}>
{/* Light coverage circle (only for selected light fixtures) */}
{isSelected && renderLightCoverage(item, zoom, panOffset)}
{/* Symbol */}
{renderElectricalSymbol(
item.type,
variant,
screen.x,
screen.y,
item.rotation,
color,
scale,
)}
</Group>
);
})}
</Layer>
);
});
function renderLightCoverage(
item: ElectricalItem,
zoom: number,
panOffset: Point,
): React.ReactNode {
const radius = getLightCoverageRadius(item);
if (radius == null) return null;
const screen = toScreen({ x: item.x, y: item.y }, zoom, panOffset);
const radiusPx = radius * zoom;
return (
<Circle
x={screen.x}
y={screen.y}
radius={radiusPx}
fill={LIGHT_COVERAGE_COLOR}
stroke={LIGHT_COVERAGE_STROKE}
strokeWidth={1}
dash={[6, 3]}
listening={false}
/>
);
}
function renderElectricalSymbol(
type: string,
variant: string,
x: number,
y: number,
rotation: number,
color: string,
scale: number,
): React.ReactNode {
switch (type) {
case 'OUTLET':
switch (variant) {
case 'double':
return <DoubleOutletSymbol x={x} y={y} rotation={rotation} color={color} scale={scale} />;
case 'grounded':
return <GroundedOutletSymbol x={x} y={y} rotation={rotation} color={color} scale={scale} />;
default:
return <SingleOutletSymbol x={x} y={y} rotation={rotation} color={color} scale={scale} />;
}
case 'SWITCH':
switch (variant) {
case 'double':
return <DoubleSwitchSymbol x={x} y={y} rotation={rotation} color={color} scale={scale} />;
case 'dimmer':
return <DimmerSwitchSymbol x={x} y={y} rotation={rotation} color={color} scale={scale} />;
default:
return <SingleSwitchSymbol x={x} y={y} rotation={rotation} color={color} scale={scale} />;
}
case 'JUNCTION_BOX':
return <JunctionBoxSymbol x={x} y={y} rotation={rotation} color={color} scale={scale} />;
case 'LIGHT_CEILING':
return <CeilingLightSymbol x={x} y={y} rotation={rotation} color={color} scale={scale} />;
case 'LIGHT_WALL':
return <WallLightSymbol x={x} y={y} rotation={rotation} color={color} scale={scale} />;
default:
return null;
}
}
@@ -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}
/>
);
}
}
@@ -0,0 +1,232 @@
import { memo, useMemo } from 'react';
import { Layer, Line, Text, Rect } from 'react-konva';
import type { Point } from '@house-plan-maker/shared';
interface GridLayerProps {
readonly zoom: number;
readonly panOffset: Point;
readonly stageWidth: number;
readonly stageHeight: number;
readonly gridSize: number;
readonly visible: boolean;
}
/** Color for thin grid lines. */
const GRID_LINE_COLOR = '#e0e0e0';
/** Color for major grid lines (every 1m). */
const MAJOR_GRID_LINE_COLOR = '#c0c0c0';
/** Color for ruler background. */
const RULER_BG_COLOR = '#f5f5f5';
/** Color for ruler text and ticks. */
const RULER_TEXT_COLOR = '#666';
/** Ruler size in pixels. */
const RULER_SIZE = 24;
export const GridLayer = memo(function GridLayer({
zoom,
panOffset,
stageWidth,
stageHeight,
gridSize,
visible,
}: GridLayerProps) {
const gridLines = useMemo(() => {
if (!visible) return { lines: [], majorLines: [] };
const gridPixels = gridSize * zoom;
// Skip grid lines if they would be too dense
if (gridPixels < 8) {
return { lines: [], majorLines: [] };
}
// Calculate visible world bounds
const worldLeft = -panOffset.x / zoom;
const worldTop = -panOffset.y / zoom;
const worldRight = (stageWidth - panOffset.x) / zoom;
const worldBottom = (stageHeight - panOffset.y) / zoom;
// Snap to grid boundaries
const startX = Math.floor(worldLeft / gridSize) * gridSize;
const endX = Math.ceil(worldRight / gridSize) * gridSize;
const startY = Math.floor(worldTop / gridSize) * gridSize;
const endY = Math.ceil(worldBottom / gridSize) * gridSize;
const lines: { points: number[]; isMajor: boolean }[] = [];
// Steps per meter
const stepsPerMeter = Math.round(1 / gridSize);
// Vertical lines
for (let x = startX; x <= endX; x += gridSize) {
const screenX = x * zoom + panOffset.x;
const roundedX = Math.round(x * 1000) / 1000; // avoid floating point noise
const isMajor = stepsPerMeter > 0 && Math.abs(roundedX % 1) < gridSize / 2;
lines.push({
points: [screenX, 0, screenX, stageHeight],
isMajor,
});
}
// Horizontal lines
for (let y = startY; y <= endY; y += gridSize) {
const screenY = y * zoom + panOffset.y;
const roundedY = Math.round(y * 1000) / 1000;
const isMajor = stepsPerMeter > 0 && Math.abs(roundedY % 1) < gridSize / 2;
lines.push({
points: [0, screenY, stageWidth, screenY],
isMajor,
});
}
const normal = lines.filter((l) => !l.isMajor);
const major = lines.filter((l) => l.isMajor);
return { lines: normal, majorLines: major };
}, [visible, zoom, panOffset, stageWidth, stageHeight, gridSize]);
const rulerMarks = useMemo(() => {
// Determine ruler step: aim for marks every ~60-120 pixels
let rulerStep = 1; // meters
const pixelsPerMeter = zoom;
if (pixelsPerMeter < 30) rulerStep = 5;
else if (pixelsPerMeter < 60) rulerStep = 2;
else if (pixelsPerMeter > 300) rulerStep = 0.5;
else if (pixelsPerMeter > 600) rulerStep = 0.1;
const worldLeft = -panOffset.x / zoom;
const worldTop = -panOffset.y / zoom;
const worldRight = (stageWidth - panOffset.x) / zoom;
const worldBottom = (stageHeight - panOffset.y) / zoom;
const hMarks: { screenX: number; label: string }[] = [];
const startX = Math.floor(worldLeft / rulerStep) * rulerStep;
const endX = Math.ceil(worldRight / rulerStep) * rulerStep;
for (let x = startX; x <= endX; x += rulerStep) {
const screenX = x * zoom + panOffset.x;
if (screenX >= RULER_SIZE && screenX <= stageWidth) {
hMarks.push({
screenX,
label: formatRulerLabel(x),
});
}
}
const vMarks: { screenY: number; label: string }[] = [];
const startY = Math.floor(worldTop / rulerStep) * rulerStep;
const endY = Math.ceil(worldBottom / rulerStep) * rulerStep;
for (let y = startY; y <= endY; y += rulerStep) {
const screenY = y * zoom + panOffset.y;
if (screenY >= RULER_SIZE && screenY <= stageHeight) {
vMarks.push({
screenY,
label: formatRulerLabel(y),
});
}
}
return { hMarks, vMarks };
}, [zoom, panOffset, stageWidth, stageHeight]);
return (
<Layer listening={false}>
{/* Grid lines */}
{visible &&
gridLines.lines.map((line, i) => (
<Line
key={`g-${i}`}
points={line.points}
stroke={GRID_LINE_COLOR}
strokeWidth={0.5}
listening={false}
/>
))}
{visible &&
gridLines.majorLines.map((line, i) => (
<Line
key={`gm-${i}`}
points={line.points}
stroke={MAJOR_GRID_LINE_COLOR}
strokeWidth={1}
listening={false}
/>
))}
{/* Horizontal ruler */}
<Rect
x={0}
y={0}
width={stageWidth}
height={RULER_SIZE}
fill={RULER_BG_COLOR}
listening={false}
/>
{rulerMarks.hMarks.map((mark, i) => (
<Text
key={`rh-${i}`}
x={mark.screenX - 12}
y={4}
text={mark.label}
fontSize={9}
fill={RULER_TEXT_COLOR}
listening={false}
/>
))}
{rulerMarks.hMarks.map((mark, i) => (
<Line
key={`rht-${i}`}
points={[mark.screenX, RULER_SIZE - 6, mark.screenX, RULER_SIZE]}
stroke={RULER_TEXT_COLOR}
strokeWidth={1}
listening={false}
/>
))}
{/* Vertical ruler */}
<Rect
x={0}
y={0}
width={RULER_SIZE}
height={stageHeight}
fill={RULER_BG_COLOR}
listening={false}
/>
{rulerMarks.vMarks.map((mark, i) => (
<Text
key={`rv-${i}`}
x={2}
y={mark.screenY - 4}
text={mark.label}
fontSize={9}
fill={RULER_TEXT_COLOR}
listening={false}
/>
))}
{rulerMarks.vMarks.map((mark, i) => (
<Line
key={`rvt-${i}`}
points={[RULER_SIZE - 6, mark.screenY, RULER_SIZE, mark.screenY]}
stroke={RULER_TEXT_COLOR}
strokeWidth={1}
listening={false}
/>
))}
{/* Corner square */}
<Rect
x={0}
y={0}
width={RULER_SIZE}
height={RULER_SIZE}
fill={RULER_BG_COLOR}
listening={false}
/>
</Layer>
);
});
function formatRulerLabel(meters: number): string {
const rounded = Math.round(meters * 100) / 100;
if (Number.isInteger(rounded)) return `${rounded}m`;
return `${rounded}`;
}
@@ -0,0 +1,74 @@
import { memo } from 'react';
import { Layer, Line, Text, Circle } from 'react-konva';
import type { Point } from '@house-plan-maker/shared';
import type { MeasurementState } from '../types';
interface MeasureOverlayLayerProps {
readonly measurement: MeasurementState | null;
readonly zoom: number;
readonly panOffset: Point;
}
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,
};
}
const MEASURE_COLOR = '#e74c3c';
const ENDPOINT_RADIUS = 4;
export const MeasureOverlayLayer = memo(function MeasureOverlayLayer({
measurement,
zoom,
panOffset,
}: MeasureOverlayLayerProps) {
if (!measurement) return <Layer listening={false} />;
const start = toScreen(measurement.startPoint, zoom, panOffset);
const end = toScreen(measurement.endPoint, zoom, panOffset);
const midX = (start.x + end.x) / 2;
const midY = (start.y + end.y) / 2;
const distanceM = measurement.distance;
const label =
distanceM >= 1
? `${distanceM.toFixed(2)} m`
: `${(distanceM * 100).toFixed(1)} cm`;
return (
<Layer listening={false}>
<Line
points={[start.x, start.y, end.x, end.y]}
stroke={MEASURE_COLOR}
strokeWidth={2}
dash={[8, 4]}
/>
<Circle
x={start.x}
y={start.y}
radius={ENDPOINT_RADIUS}
fill={MEASURE_COLOR}
/>
<Circle
x={end.x}
y={end.y}
radius={ENDPOINT_RADIUS}
fill={MEASURE_COLOR}
/>
{distanceM > 0.001 && (
<Text
x={midX + 8}
y={midY - 10}
text={label}
fontSize={13}
fontFamily="sans-serif"
fontStyle="bold"
fill={MEASURE_COLOR}
padding={2}
/>
)}
</Layer>
);
});
@@ -0,0 +1,300 @@
import { memo, useMemo } from 'react';
import { Layer, Line, Text, Group } from 'react-konva';
import type { Point, Wall, WallOpening } from '@house-plan-maker/shared';
import { wallLength, wallAngle, wallStartEnd } from '../utils/wallUtils';
interface MeasurementLayerProps {
readonly walls: readonly Wall[];
readonly openings: readonly WallOpening[];
readonly zoom: number;
readonly panOffset: Point;
readonly selectedIds: ReadonlySet<string>;
readonly roomShape: readonly Point[];
}
const MEASUREMENT_COLOR = '#868e96';
const MEASUREMENT_FONT_SIZE = 10;
const MEASUREMENT_OFFSET = 16; // pixels offset from wall
const MIN_ZOOM_FOR_MEASUREMENTS = 40; // hide at very low zoom
const SELECTED_MEASUREMENT_COLOR = '#4c6ef5';
export const MeasurementLayer = memo(function MeasurementLayer({
walls,
openings,
zoom,
panOffset,
selectedIds,
roomShape,
}: MeasurementLayerProps) {
// Hide measurements at very low zoom levels
if (zoom < MIN_ZOOM_FOR_MEASUREMENTS) {
return <Layer listening={false} />;
}
return (
<Layer listening={false}>
{/* Wall length annotations */}
{walls.map((wall) => (
<WallMeasurement
key={`wm-${wall.id}`}
wall={wall}
zoom={zoom}
panOffset={panOffset}
/>
))}
{/* Room overall dimensions */}
{roomShape.length >= 3 && (
<RoomDimensions
roomShape={roomShape}
zoom={zoom}
panOffset={panOffset}
/>
)}
{/* Distance from selected opening to wall ends */}
{openings
.filter((o) => selectedIds.has(o.id))
.map((opening) => {
const wall = walls.find((w) => w.id === opening.wallId);
if (!wall) return null;
return (
<OpeningDistances
key={`od-${opening.id}`}
opening={opening}
wall={wall}
zoom={zoom}
panOffset={panOffset}
/>
);
})}
</Layer>
);
});
// ── Wall length annotation ──
interface WallMeasurementProps {
readonly wall: Wall;
readonly zoom: number;
readonly panOffset: Point;
}
function WallMeasurement({ wall, zoom, panOffset }: WallMeasurementProps) {
const len = wallLength(wall);
if (len < 0.01) return null;
const { start, end } = wallStartEnd(wall);
const angle = wallAngle(wall);
// Midpoint of wall in screen coords
const midX = ((start.x + end.x) / 2) * zoom + panOffset.x;
const midY = ((start.y + end.y) / 2) * zoom + panOffset.y;
// Offset perpendicular to wall
const offsetX = -Math.sin(angle) * MEASUREMENT_OFFSET;
const offsetY = Math.cos(angle) * MEASUREMENT_OFFSET;
const label = formatMeasurement(len);
return (
<Text
x={midX + offsetX - 20}
y={midY + offsetY - 5}
text={label}
fontSize={MEASUREMENT_FONT_SIZE}
fill={MEASUREMENT_COLOR}
listening={false}
/>
);
}
// ── Room overall dimensions ──
interface RoomDimensionsProps {
readonly roomShape: readonly Point[];
readonly zoom: number;
readonly panOffset: Point;
}
function RoomDimensions({ roomShape, zoom, panOffset }: RoomDimensionsProps) {
const bounds = useMemo(() => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const p of roomShape) {
if (p.x < minX) minX = p.x;
if (p.y < minY) minY = p.y;
if (p.x > maxX) maxX = p.x;
if (p.y > maxY) maxY = p.y;
}
return { minX, minY, maxX, maxY };
}, [roomShape]);
const width = bounds.maxX - bounds.minX;
const height = bounds.maxY - bounds.minY;
if (width < 0.01 || height < 0.01) return null;
const topY = bounds.minY * zoom + panOffset.y - 30;
const leftX = bounds.minX * zoom + panOffset.x - 30;
// Horizontal dimension (width)
const hStartX = bounds.minX * zoom + panOffset.x;
const hEndX = bounds.maxX * zoom + panOffset.x;
const hMidX = (hStartX + hEndX) / 2;
// Vertical dimension (height)
const vStartY = bounds.minY * zoom + panOffset.y;
const vEndY = bounds.maxY * zoom + panOffset.y;
const vMidY = (vStartY + vEndY) / 2;
return (
<Group>
{/* Horizontal dimension line */}
<Line
points={[hStartX, topY, hEndX, topY]}
stroke={MEASUREMENT_COLOR}
strokeWidth={1}
listening={false}
/>
{/* End ticks */}
<Line
points={[hStartX, topY - 4, hStartX, topY + 4]}
stroke={MEASUREMENT_COLOR}
strokeWidth={1}
listening={false}
/>
<Line
points={[hEndX, topY - 4, hEndX, topY + 4]}
stroke={MEASUREMENT_COLOR}
strokeWidth={1}
listening={false}
/>
<Text
x={hMidX - 15}
y={topY - 14}
text={formatMeasurement(width)}
fontSize={MEASUREMENT_FONT_SIZE}
fill={MEASUREMENT_COLOR}
listening={false}
/>
{/* Vertical dimension line */}
<Line
points={[leftX, vStartY, leftX, vEndY]}
stroke={MEASUREMENT_COLOR}
strokeWidth={1}
listening={false}
/>
<Line
points={[leftX - 4, vStartY, leftX + 4, vStartY]}
stroke={MEASUREMENT_COLOR}
strokeWidth={1}
listening={false}
/>
<Line
points={[leftX - 4, vEndY, leftX + 4, vEndY]}
stroke={MEASUREMENT_COLOR}
strokeWidth={1}
listening={false}
/>
<Text
x={leftX - 30}
y={vMidY - 5}
text={formatMeasurement(height)}
fontSize={MEASUREMENT_FONT_SIZE}
fill={MEASUREMENT_COLOR}
rotation={-90}
listening={false}
/>
</Group>
);
}
// ── Opening distance annotations ──
interface OpeningDistancesProps {
readonly opening: WallOpening;
readonly wall: Wall;
readonly zoom: number;
readonly panOffset: Point;
}
function OpeningDistances({ opening, wall, zoom, panOffset }: OpeningDistancesProps) {
const wLen = wallLength(wall);
const halfWidth = opening.width / 2;
const distToStart = opening.positionAlongWall - halfWidth;
const distToEnd = wLen - opening.positionAlongWall - halfWidth;
const { start, end } = wallStartEnd(wall);
const angle = wallAngle(wall);
const dx = Math.cos(angle);
const dy = Math.sin(angle);
// Perpendicular offset for annotation
const ox = -Math.sin(angle) * (MEASUREMENT_OFFSET + 12);
const oy = Math.cos(angle) * (MEASUREMENT_OFFSET + 12);
const openingStartX = (start.x + dx * distToStart) * zoom + panOffset.x;
const openingStartY = (start.y + dy * distToStart) * zoom + panOffset.y;
const openingEndX = (start.x + dx * (opening.positionAlongWall + halfWidth)) * zoom + panOffset.x;
const openingEndY = (start.y + dy * (opening.positionAlongWall + halfWidth)) * zoom + panOffset.y;
const wallStartX = start.x * zoom + panOffset.x;
const wallStartY = start.y * zoom + panOffset.y;
const wallEndX = end.x * zoom + panOffset.x;
const wallEndY = end.y * zoom + panOffset.y;
return (
<Group>
{distToStart > 0.01 && (
<>
<Line
points={[wallStartX + ox, wallStartY + oy, openingStartX + ox, openingStartY + oy]}
stroke={SELECTED_MEASUREMENT_COLOR}
strokeWidth={1}
dash={[3, 3]}
listening={false}
/>
<Text
x={(wallStartX + openingStartX) / 2 + ox - 10}
y={(wallStartY + openingStartY) / 2 + oy - 5}
text={formatMeasurement(distToStart)}
fontSize={MEASUREMENT_FONT_SIZE}
fill={SELECTED_MEASUREMENT_COLOR}
listening={false}
/>
</>
)}
{distToEnd > 0.01 && (
<>
<Line
points={[openingEndX + ox, openingEndY + oy, wallEndX + ox, wallEndY + oy]}
stroke={SELECTED_MEASUREMENT_COLOR}
strokeWidth={1}
dash={[3, 3]}
listening={false}
/>
<Text
x={(openingEndX + wallEndX) / 2 + ox - 10}
y={(openingEndY + wallEndY) / 2 + oy - 5}
text={formatMeasurement(distToEnd)}
fontSize={MEASUREMENT_FONT_SIZE}
fill={SELECTED_MEASUREMENT_COLOR}
listening={false}
/>
</>
)}
</Group>
);
}
function formatMeasurement(meters: number): string {
if (meters >= 1) {
const rounded = Math.round(meters * 100) / 100;
return `${rounded}m`;
}
const cm = Math.round(meters * 100);
return `${cm}cm`;
}
@@ -0,0 +1,357 @@
import { memo, useMemo } from 'react';
import { Layer, Line, Arc, Group } from 'react-konva';
import type { Point, Wall, WallOpening, DoorOpenDirection } from '@house-plan-maker/shared';
import { openingWorldPosition, wallAngle } from '../utils/wallUtils';
import { polygonCentroid } from '../utils/geometry';
interface OpeningLayerProps {
readonly openings: readonly WallOpening[];
readonly walls: readonly Wall[];
readonly roomShape: readonly Point[];
readonly zoom: number;
readonly panOffset: Point;
readonly selectedIds: ReadonlySet<string>;
/** Optional preview for door/window placement. */
readonly preview?: OpeningPreview | null;
}
export interface OpeningPreview {
readonly wall: Wall;
readonly positionAlongWall: number;
readonly width: number;
readonly type: 'DOOR' | 'WINDOW';
readonly isValid: boolean;
}
const DOOR_COLOR = '#7048e8';
const DOOR_ARC_COLOR = 'rgba(112, 72, 232, 0.15)';
const WINDOW_COLOR = '#1c7ed6';
const SELECTED_COLOR = '#4c6ef5';
const PREVIEW_OPACITY = 0.5;
/** Gap in wall fill (background color) behind openings. */
const WALL_GAP_COLOR = '#f8f9fa';
export const OpeningLayer = memo(function OpeningLayer({
openings,
walls,
roomShape,
zoom,
panOffset,
selectedIds,
preview,
}: OpeningLayerProps) {
const wallMap = useMemo(() => {
const map = new Map<string, Wall>();
for (const wall of walls) {
map.set(wall.id, wall);
}
return map;
}, [walls]);
const renderedOpenings = useMemo(() => {
return openings
.map((opening) => {
const wall = wallMap.get(opening.wallId);
if (!wall) return null;
const pos = openingWorldPosition(opening, wall);
const isSelected = selectedIds.has(opening.id);
return {
opening,
wall,
pos,
isSelected,
};
})
.filter(Boolean) as {
opening: WallOpening;
wall: Wall;
pos: ReturnType<typeof openingWorldPosition>;
isSelected: boolean;
}[];
}, [openings, wallMap, selectedIds]);
// Compute room centroid to determine which side of each wall is "inward"
const centroid = useMemo(
() => (roomShape.length >= 3 ? polygonCentroid(roomShape) : { x: 0, y: 0 }),
[roomShape],
);
return (
<Layer>
{renderedOpenings.map(({ opening, wall, pos, isSelected }) => {
const screenCenter = toScreen(pos.center, zoom, panOffset);
const angle = wallAngle(wall);
const angleDeg = (angle * 180) / Math.PI;
const halfWidthPx = (opening.width / 2) * zoom;
if (opening.type === 'DOOR') {
// Determine if arc should flip: the perpendicular "negative Y" in local wall
// coords should point toward the room center. If it doesn't, flip.
const nx = -Math.sin(angle);
const ny = Math.cos(angle);
const wallMidX = (wall.startX + wall.endX) / 2;
const wallMidY = (wall.startY + wall.endY) / 2;
const toCenterX = centroid.x - wallMidX;
const toCenterY = centroid.y - wallMidY;
// dot > 0 means the normal points toward center (inward) — no flip needed
const dot = nx * toCenterX + ny * toCenterY;
const arcFlip = dot < 0;
return (
<DoorSymbol
key={opening.id}
x={screenCenter.x}
y={screenCenter.y}
angleDeg={angleDeg}
halfWidthPx={halfWidthPx}
wallThicknessPx={wall.thickness * zoom}
isSelected={isSelected}
openDirection={opening.openDirection}
flipInward={arcFlip}
/>
);
}
// WINDOW
return (
<WindowSymbol
key={opening.id}
x={screenCenter.x}
y={screenCenter.y}
angleDeg={angleDeg}
halfWidthPx={halfWidthPx}
wallThicknessPx={wall.thickness * zoom}
isSelected={isSelected}
/>
);
})}
{/* Placement preview */}
{preview && (
<PreviewSymbol
wall={preview.wall}
positionAlongWall={preview.positionAlongWall}
width={preview.width}
type={preview.type}
isValid={preview.isValid}
zoom={zoom}
panOffset={panOffset}
/>
)}
</Layer>
);
});
// ── Door Symbol (arc showing swing direction) ──
interface DoorSymbolProps {
readonly x: number;
readonly y: number;
readonly angleDeg: number;
readonly halfWidthPx: number;
readonly wallThicknessPx: number;
readonly isSelected: boolean;
readonly openDirection?: DoorOpenDirection;
/** Flip the arc direction when the default "inward" points outside the room. */
readonly flipInward?: boolean;
}
function DoorSymbol({ x, y, angleDeg, halfWidthPx, wallThicknessPx, isSelected, openDirection = 'LEFT', flipInward = false }: DoorSymbolProps) {
const color = isSelected ? SELECTED_COLOR : DOOR_COLOR;
const doorWidthPx = halfWidthPx * 2;
const isRight = openDirection === 'RIGHT';
const isOutward = openDirection === 'OUTWARD';
// Mirror the entire door group for RIGHT hinge
const groupScaleX = isRight ? -1 : 1;
// Hinge is always at -halfWidthPx in local coords (left edge); mirror flips it
const hingeX = -halfWidthPx;
// Determine inward direction: flipInward corrects for wall orientation
const inwardIsNegativeY = flipInward ? false : true;
const swingInward = !isOutward;
const leafGoesNegativeY = swingInward ? inwardIsNegativeY : !inwardIsNegativeY;
const leafEndY = leafGoesNegativeY ? -doorWidthPx : doorWidthPx;
const arcRotation = leafGoesNegativeY ? -90 : 0;
return (
<Group x={x} y={y} rotation={angleDeg} scaleX={groupScaleX}>
{/* Wall gap background */}
<Line
points={[-halfWidthPx, -wallThicknessPx / 2, halfWidthPx, -wallThicknessPx / 2]}
stroke={WALL_GAP_COLOR}
strokeWidth={wallThicknessPx}
listening={false}
/>
{/* Door leaf line */}
<Line
points={[hingeX, 0, hingeX, leafEndY]}
stroke={color}
strokeWidth={2}
listening={false}
/>
{/* Arc showing swing */}
<Arc
x={hingeX}
y={0}
innerRadius={0}
outerRadius={doorWidthPx}
angle={90}
rotation={arcRotation}
fill={DOOR_ARC_COLOR}
stroke={color}
strokeWidth={1}
dash={[4, 4]}
listening={false}
/>
{/* Door frame marks */}
<Line
points={[-halfWidthPx, -wallThicknessPx / 2, -halfWidthPx, wallThicknessPx / 2]}
stroke={color}
strokeWidth={2}
listening={false}
/>
<Line
points={[halfWidthPx, -wallThicknessPx / 2, halfWidthPx, wallThicknessPx / 2]}
stroke={color}
strokeWidth={2}
listening={false}
/>
</Group>
);
}
// ── Window Symbol (parallel lines in wall) ──
interface WindowSymbolProps {
readonly x: number;
readonly y: number;
readonly angleDeg: number;
readonly halfWidthPx: number;
readonly wallThicknessPx: number;
readonly isSelected: boolean;
}
function WindowSymbol({ x, y, angleDeg, halfWidthPx, wallThicknessPx, isSelected }: WindowSymbolProps) {
const color = isSelected ? SELECTED_COLOR : WINDOW_COLOR;
const halfThick = wallThicknessPx / 2;
const innerOffset = halfThick * 0.3;
return (
<Group x={x} y={y} rotation={angleDeg}>
{/* Wall gap background */}
<Line
points={[-halfWidthPx, -halfThick, halfWidthPx, -halfThick]}
stroke={WALL_GAP_COLOR}
strokeWidth={wallThicknessPx}
listening={false}
/>
{/* Outer parallel lines */}
<Line
points={[-halfWidthPx, -halfThick, halfWidthPx, -halfThick]}
stroke={color}
strokeWidth={2}
listening={false}
/>
<Line
points={[-halfWidthPx, halfThick, halfWidthPx, halfThick]}
stroke={color}
strokeWidth={2}
listening={false}
/>
{/* Inner parallel lines (glass representation) */}
<Line
points={[-halfWidthPx, -innerOffset, halfWidthPx, -innerOffset]}
stroke={color}
strokeWidth={1}
listening={false}
/>
<Line
points={[-halfWidthPx, innerOffset, halfWidthPx, innerOffset]}
stroke={color}
strokeWidth={1}
listening={false}
/>
{/* End caps */}
<Line
points={[-halfWidthPx, -halfThick, -halfWidthPx, halfThick]}
stroke={color}
strokeWidth={2}
listening={false}
/>
<Line
points={[halfWidthPx, -halfThick, halfWidthPx, halfThick]}
stroke={color}
strokeWidth={2}
listening={false}
/>
</Group>
);
}
// ── Preview Symbol ──
interface PreviewSymbolProps {
readonly wall: Wall;
readonly positionAlongWall: number;
readonly width: number;
readonly type: 'DOOR' | 'WINDOW';
readonly isValid: boolean;
readonly zoom: number;
readonly panOffset: Point;
}
function PreviewSymbol({ wall, positionAlongWall, width, type, isValid: _isValid, zoom, panOffset }: PreviewSymbolProps) {
const tempOpening: WallOpening = {
id: 'preview',
roomId: '',
wallId: wall.id,
type,
positionAlongWall,
width,
height: 0,
elevationFromFloor: 0,
openDirection: 'LEFT',
};
const pos = openingWorldPosition(tempOpening, wall);
const screenCenter = toScreen(pos.center, zoom, panOffset);
const angleDeg = (wallAngle(wall) * 180) / Math.PI;
const halfWidthPx = (width / 2) * zoom;
const wallThicknessPx = wall.thickness * zoom;
return (
<Group opacity={PREVIEW_OPACITY}>
{type === 'DOOR' ? (
<DoorSymbol
x={screenCenter.x}
y={screenCenter.y}
angleDeg={angleDeg}
halfWidthPx={halfWidthPx}
wallThicknessPx={wallThicknessPx}
isSelected={false}
/>
) : (
<WindowSymbol
x={screenCenter.x}
y={screenCenter.y}
angleDeg={angleDeg}
halfWidthPx={halfWidthPx}
wallThicknessPx={wallThicknessPx}
isSelected={false}
/>
)}
</Group>
);
}
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,
};
}
@@ -0,0 +1,97 @@
import { useMemo } from 'react';
import { Layer, Group, Rect, Text } from 'react-konva';
import type { Point } from '@house-plan-maker/shared';
import { polygonArea, polygonCentroid } from '../utils/geometry';
interface RoomLabelLayerProps {
readonly roomName: string;
readonly roomShape: readonly Point[];
readonly zoom: number;
readonly panOffset: Point;
}
const LABEL_FONT_SIZE = 13;
const AREA_FONT_SIZE = 11;
const LABEL_COLOR = '#343a40';
const AREA_COLOR = '#868e96';
const BG_COLOR = 'rgba(255, 255, 255, 0.75)';
const BG_PADDING_X = 8;
const BG_PADDING_Y = 4;
const LINE_SPACING = 4;
const MIN_ZOOM_FOR_LABELS = 30;
export function RoomLabelLayer({
roomName,
roomShape,
zoom,
panOffset,
}: RoomLabelLayerProps) {
const area = useMemo(
() => (roomShape.length >= 3 ? polygonArea(roomShape) : 0),
[roomShape],
);
const centroid = useMemo(
() => (roomShape.length >= 3 ? polygonCentroid(roomShape) : null),
[roomShape],
);
if (!centroid || zoom < MIN_ZOOM_FOR_LABELS) {
return <Layer listening={false} />;
}
const areaText = `${(Math.round(area * 100) / 100).toFixed(1)} m\u00B2`;
// Position in screen coordinates
const screenX = centroid.x * zoom + panOffset.x;
const screenY = centroid.y * zoom + panOffset.y;
// Estimate text widths (approximate: ~7px per char at font size 13, ~6px at 11)
const nameWidth = roomName.length * 7.5;
const areaWidth = areaText.length * 6.5;
const maxWidth = Math.max(nameWidth, areaWidth);
const totalHeight = LABEL_FONT_SIZE + LINE_SPACING + AREA_FONT_SIZE;
const bgWidth = maxWidth + BG_PADDING_X * 2;
const bgHeight = totalHeight + BG_PADDING_Y * 2;
return (
<Layer listening={false}>
<Group x={screenX} y={screenY}>
{/* Semi-transparent background */}
<Rect
x={-bgWidth / 2}
y={-bgHeight / 2}
width={bgWidth}
height={bgHeight}
fill={BG_COLOR}
cornerRadius={4}
listening={false}
/>
{/* Room name */}
<Text
x={-maxWidth / 2}
y={-bgHeight / 2 + BG_PADDING_Y}
width={maxWidth}
text={roomName}
fontSize={LABEL_FONT_SIZE}
fontStyle="bold"
fill={LABEL_COLOR}
align="center"
listening={false}
/>
{/* Area */}
<Text
x={-maxWidth / 2}
y={-bgHeight / 2 + BG_PADDING_Y + LABEL_FONT_SIZE + LINE_SPACING}
width={maxWidth}
text={areaText}
fontSize={AREA_FONT_SIZE}
fill={AREA_COLOR}
align="center"
listening={false}
/>
</Group>
</Layer>
);
}
@@ -0,0 +1,115 @@
import { memo } from 'react';
import { Layer, Rect } from 'react-konva';
import type { Point } from '@house-plan-maker/shared';
interface SelectionLayerProps {
readonly zoom: number;
readonly panOffset: Point;
/** Bounding box in world coordinates of current selection. */
readonly selectionBox: {
x: number;
y: number;
width: number;
height: number;
} | null;
/** Drag selection rectangle in world coordinates (while dragging). */
readonly dragRect: {
x: number;
y: number;
width: number;
height: number;
} | null;
}
const SELECTION_STROKE = '#4c6ef5';
const SELECTION_FILL = 'rgba(76, 110, 245, 0.08)';
const DRAG_RECT_STROKE = '#4c6ef5';
const DRAG_RECT_FILL = 'rgba(76, 110, 245, 0.1)';
/** Padding around selection box in meters. */
const PADDING = 0.05;
export const SelectionLayer = memo(function SelectionLayer({
zoom,
panOffset,
selectionBox,
dragRect,
}: SelectionLayerProps) {
return (
<Layer listening={false}>
{/* Selection bounding box with resize handles */}
{selectionBox && (
<SelectionBoundingBox
box={selectionBox}
zoom={zoom}
panOffset={panOffset}
/>
)}
{/* Drag selection rectangle */}
{dragRect && (
<DragSelectionRect
rect={dragRect}
zoom={zoom}
panOffset={panOffset}
/>
)}
</Layer>
);
});
interface SelectionBoundingBoxProps {
readonly box: { x: number; y: number; width: number; height: number };
readonly zoom: number;
readonly panOffset: Point;
}
function SelectionBoundingBox({ box, zoom, panOffset }: SelectionBoundingBoxProps) {
const x = (box.x - PADDING) * zoom + panOffset.x;
const y = (box.y - PADDING) * zoom + panOffset.y;
const w = (box.width + PADDING * 2) * zoom;
const h = (box.height + PADDING * 2) * zoom;
return (
<>
{/* Dashed bounding rectangle */}
<Rect
x={x}
y={y}
width={w}
height={h}
stroke={SELECTION_STROKE}
strokeWidth={1}
dash={[4, 4]}
fill={SELECTION_FILL}
listening={false}
/>
</>
);
}
interface DragSelectionRectProps {
readonly rect: { x: number; y: number; width: number; height: number };
readonly zoom: number;
readonly panOffset: Point;
}
function DragSelectionRect({ rect, zoom, panOffset }: DragSelectionRectProps) {
const x = rect.x * zoom + panOffset.x;
const y = rect.y * zoom + panOffset.y;
const w = rect.width * zoom;
const h = rect.height * zoom;
return (
<Rect
x={x}
y={y}
width={w}
height={h}
stroke={DRAG_RECT_STROKE}
strokeWidth={1}
dash={[6, 3]}
fill={DRAG_RECT_FILL}
listening={false}
/>
);
}
@@ -0,0 +1,236 @@
import { memo, useMemo } from 'react';
import { Layer, Line, Group } from 'react-konva';
import type { Point, Wall } from '@house-plan-maker/shared';
import { polygonCentroid } from '../utils/geometry';
interface WallLayerProps {
readonly walls: readonly Wall[];
readonly roomShape: readonly Point[];
readonly zoom: number;
readonly panOffset: Point;
readonly selectedIds: ReadonlySet<string>;
readonly plinthThickness?: number;
}
/** Wall fill color. */
const WALL_FILL = '#495057';
/** Wall stroke color. */
const WALL_STROKE = '#343a40';
/** Room interior fill color. */
const ROOM_FILL = '#f8f9fa';
/** Room interior stroke. */
const ROOM_STROKE = '#dee2e6';
/** Selected wall highlight. */
const WALL_SELECTED_STROKE = '#4c6ef5';
/**
* Offset a polygon outward by a fixed distance.
* Uses the angle bisector at each vertex to push the point outward.
*/
function offsetPolygonOutward(shape: readonly Point[], thickness: number, centroid: Point): Point[] {
const n = shape.length;
if (n < 3) return [...shape];
const result: Point[] = [];
for (let i = 0; i < n; i++) {
const prev = shape[(i - 1 + n) % n];
const curr = shape[i];
const next = shape[(i + 1) % n];
// Edge vectors
const e1x = curr.x - prev.x;
const e1y = curr.y - prev.y;
const e2x = next.x - curr.x;
const e2y = next.y - curr.y;
// Outward normals for each edge (perpendicular, pointing away from centroid)
let n1x = -e1y;
let n1y = e1x;
// Normalize
const len1 = Math.sqrt(n1x * n1x + n1y * n1y) || 1;
n1x /= len1;
n1y /= len1;
let n2x = -e2y;
let n2y = e2x;
const len2 = Math.sqrt(n2x * n2x + n2y * n2y) || 1;
n2x /= len2;
n2y /= len2;
// Ensure normals point away from centroid
const midX = curr.x;
const midY = curr.y;
const toCenterX = centroid.x - midX;
const toCenterY = centroid.y - midY;
if (n1x * toCenterX + n1y * toCenterY > 0) {
n1x = -n1x;
n1y = -n1y;
}
if (n2x * toCenterX + n2y * toCenterY > 0) {
n2x = -n2x;
n2y = -n2y;
}
// Average bisector direction
let bx = n1x + n2x;
let by = n1y + n2y;
const bLen = Math.sqrt(bx * bx + by * by);
if (bLen < 0.001) {
// Parallel edges — just use one normal
bx = n1x;
by = n1y;
} else {
bx /= bLen;
by /= bLen;
// Scale by 1/cos(half-angle) to maintain thickness at the corner
const cosHalf = bx * n1x + by * n1y;
const scale = cosHalf > 0.1 ? 1 / cosHalf : 1;
bx *= Math.min(scale, 3); // cap to prevent extreme spikes
by *= Math.min(scale, 3);
}
result.push({
x: curr.x + bx * thickness,
y: curr.y + by * thickness,
});
}
return result;
}
/** Plinth color. */
const PLINTH_COLOR = '#8b7355';
export const WallLayer = memo(function WallLayer({
walls,
roomShape,
zoom,
panOffset,
selectedIds,
plinthThickness = 0.01,
}: WallLayerProps) {
// Get wall thickness (use first wall's thickness as representative)
const wallThickness = walls.length > 0 ? walls[0].thickness : 0.1;
// Convert room shape to screen coordinates
const roomShapeScreen = useMemo(() => {
if (roomShape.length < 3) return [];
return roomShape.flatMap((p) => [
p.x * zoom + panOffset.x,
p.y * zoom + panOffset.y,
]);
}, [roomShape, zoom, panOffset]);
// Compute outer wall boundary (room shape offset outward by wall thickness)
const outerWallScreen = useMemo(() => {
if (roomShape.length < 3) return [];
const centroid = polygonCentroid(roomShape);
const outer = offsetPolygonOutward(roomShape, wallThickness, centroid);
return outer.flatMap((p) => [
p.x * zoom + panOffset.x,
p.y * zoom + panOffset.y,
]);
}, [roomShape, wallThickness, zoom, panOffset]);
// Compute inner plinth boundary (room shape offset inward by plinth thickness)
const plinthScreen = useMemo(() => {
if (roomShape.length < 3 || plinthThickness <= 0) return [];
const centroid = polygonCentroid(roomShape);
// Offset inward (toward centroid) = negative outward offset
const inner = offsetPolygonOutward(roomShape, -plinthThickness, centroid);
return inner.flatMap((p) => [
p.x * zoom + panOffset.x,
p.y * zoom + panOffset.y,
]);
}, [roomShape, plinthThickness, zoom, panOffset]);
// Selected wall segments for highlighting
const selectedWallSegments = useMemo(() => {
return walls
.filter((w) => selectedIds.has(w.id))
.map((wall) => {
const points = [
wall.startX * zoom + panOffset.x,
wall.startY * zoom + panOffset.y,
wall.endX * zoom + panOffset.x,
wall.endY * zoom + panOffset.y,
];
return { wall, points };
});
}, [walls, selectedIds, zoom, panOffset]);
return (
<Layer>
{/* Room interior fill */}
{roomShapeScreen.length >= 6 && (
<Line
points={roomShapeScreen}
closed
fill={ROOM_FILL}
stroke={ROOM_STROKE}
strokeWidth={1}
listening={false}
/>
)}
{/* Outer wall boundary — single continuous polygon, no corner gaps */}
{outerWallScreen.length >= 6 && (
<Group listening={false}>
{/* Outer fill */}
<Line
points={outerWallScreen}
closed
fill={WALL_FILL}
stroke={WALL_STROKE}
strokeWidth={1}
listening={false}
/>
{/* Cut out room interior by drawing room shape on top with room fill */}
<Line
points={roomShapeScreen}
closed
fill={ROOM_FILL}
stroke={WALL_STROKE}
strokeWidth={1}
listening={false}
/>
</Group>
)}
{/* Plinth strip along inside of walls */}
{plinthScreen.length >= 6 && roomShapeScreen.length >= 6 && (
<Group listening={false}>
{/* Draw room shape filled with plinth color */}
<Line
points={roomShapeScreen}
closed
fill={PLINTH_COLOR}
listening={false}
/>
{/* Cut out inner area (room minus plinth) */}
<Line
points={plinthScreen}
closed
fill={ROOM_FILL}
listening={false}
/>
</Group>
)}
{/* Selected wall highlights */}
{selectedWallSegments.map(({ wall, points }) => (
<Line
key={`sel-${wall.id}`}
points={points}
stroke={WALL_SELECTED_STROKE}
strokeWidth={wallThickness * zoom + 2}
lineCap="square"
listening={false}
/>
))}
</Layer>
);
});
@@ -0,0 +1,60 @@
import styles from './scale-bar.module.css';
interface ScaleBarProps {
readonly zoom: number;
}
/** Fixed pixel width of the scale bar on screen. */
const BAR_WIDTH_PX = 100;
/**
* Compute a "nice" real-world distance for the scale bar
* and the corresponding pixel width to display.
*/
function computeScaleInfo(zoom: number): { label: string; barPx: number } {
// zoom = pixels per meter
// BAR_WIDTH_PX pixels = BAR_WIDTH_PX / zoom meters
const rawMeters = BAR_WIDTH_PX / zoom;
// Find the nearest "nice" value
const niceValues = [
0.01, 0.02, 0.05, 0.1, 0.2, 0.25, 0.5,
1, 2, 5, 10, 20, 50, 100,
];
let bestValue = niceValues[0];
let bestDiff = Math.abs(rawMeters - bestValue);
for (const v of niceValues) {
const diff = Math.abs(rawMeters - v);
if (diff < bestDiff) {
bestDiff = diff;
bestValue = v;
}
}
const barPx = bestValue * zoom;
let label: string;
if (bestValue >= 1) {
label = `${bestValue}m`;
} else {
label = `${Math.round(bestValue * 100)}cm`;
}
return { label, barPx };
}
export function ScaleBar({ zoom }: ScaleBarProps) {
const { label, barPx } = computeScaleInfo(zoom);
return (
<div className={styles.container}>
<div className={styles.bar} style={{ width: `${Math.round(barPx)}px` }}>
<div className={styles.tickLeft} />
<div className={styles.line} />
<div className={styles.tickRight} />
</div>
<span className={styles.label}>{label}</span>
</div>
);
}
@@ -0,0 +1,41 @@
.container {
position: absolute;
bottom: 16px;
left: 16px;
display: flex;
flex-direction: column;
align-items: center;
pointer-events: none;
user-select: none;
z-index: 10;
}
.bar {
display: flex;
align-items: center;
height: 12px;
position: relative;
}
.line {
flex: 1;
height: 2px;
background-color: var(--color-text-secondary, #495057);
}
.tickLeft,
.tickRight {
width: 2px;
height: 12px;
background-color: var(--color-text-secondary, #495057);
flex-shrink: 0;
}
.label {
margin-top: 2px;
font-size: 10px;
font-weight: 600;
color: var(--color-text-secondary, #495057);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
@@ -0,0 +1,32 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { ElectricalItem } from '@house-plan-maker/shared';
import { computeCableRouteLength } from '../tools/ElectricalTool';
import styles from './cable-length-status.module.css';
interface CableLengthStatusProps {
readonly electricalItems: readonly ElectricalItem[];
}
/** Status bar component showing total cable route length. */
export function CableLengthStatus({ electricalItems }: CableLengthStatusProps) {
const { t } = useTranslation();
const totalLength = useMemo(
() => computeCableRouteLength(electricalItems),
[electricalItems],
);
const hasCables = electricalItems.some((i) => i.type === 'CABLE_ROUTE');
if (!hasCables) return null;
return (
<div className={styles.status}>
<span className={styles.label}>{t('cableLength.label')}</span>
<span className={styles.value}>
{totalLength.toFixed(2)}m
</span>
</div>
);
}
@@ -0,0 +1,84 @@
import { useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
ELECTRICAL_SYMBOL_DEFS,
type ElectricalSymbolDef,
} from '../symbols/electrical';
import styles from './electrical-palette.module.css';
interface ElectricalPaletteProps {
readonly selectedIndex: number | null;
readonly onSelect: (index: number) => void;
}
interface CategoryGroup {
readonly name: string;
readonly nameKey: string;
readonly icon: string;
readonly items: readonly { readonly def: ElectricalSymbolDef; readonly index: number }[];
}
const CATEGORY_META: Record<string, { nameKey: string; icon: string }> = {
outlet: { nameKey: 'electrical.outlets', icon: '\u26A1' },
switch: { nameKey: 'electrical.switches', icon: '\u{1F50C}' },
junction: { nameKey: 'electrical.junction', icon: '\u2B1C' },
light: { nameKey: 'electrical.lights', icon: '\u{1F4A1}' },
cable: { nameKey: 'electrical.cable', icon: '\u{1F517}' },
};
export function ElectricalPalette({ selectedIndex, onSelect }: ElectricalPaletteProps) {
const { t } = useTranslation();
const categories = useMemo<readonly CategoryGroup[]>(() => {
const groups = new Map<string, { readonly def: ElectricalSymbolDef; readonly index: number }[]>();
ELECTRICAL_SYMBOL_DEFS.forEach((def, index) => {
const list = groups.get(def.category) ?? [];
list.push({ def, index });
groups.set(def.category, list);
});
return Array.from(groups.entries()).map(([cat, items]) => ({
name: cat,
nameKey: CATEGORY_META[cat]?.nameKey ?? cat,
icon: CATEGORY_META[cat]?.icon ?? '',
items,
}));
}, []);
const handleSelect = useCallback(
(index: number) => {
onSelect(index);
},
[onSelect],
);
return (
<div className={styles.palette}>
<div className={styles.header}>{t('electrical.title')}</div>
{categories.map((cat) => (
<div key={cat.name} className={styles.category}>
<div className={styles.categoryTitle}>
{cat.icon} {t(cat.nameKey)}
</div>
<div className={styles.itemGrid}>
{cat.items.map(({ def, index }) => (
<button
key={index}
className={[
styles.itemBtn,
selectedIndex === index ? styles.itemBtnActive : '',
].join(' ')}
onClick={() => handleSelect(index)}
title={def.label}
>
<span className={styles.itemIcon}>{cat.icon}</span>
<span className={styles.itemLabel}>{def.label}</span>
</button>
))}
</div>
</div>
))}
</div>
);
}
@@ -0,0 +1,65 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { FURNITURE_DEFS, type FurnitureDef } from '../symbols/furniture';
import styles from './furniture-palette.module.css';
interface FurniturePaletteProps {
readonly selectedIndex: number | null;
readonly onSelect: (index: number) => void;
}
export function FurniturePalette({ selectedIndex, onSelect }: FurniturePaletteProps) {
const { t } = useTranslation();
const handleSelect = useCallback(
(index: number) => {
onSelect(index);
},
[onSelect],
);
return (
<div className={styles.palette}>
<div className={styles.header}>{t('furniture.title')}</div>
<div className={styles.itemList}>
{FURNITURE_DEFS.map((def, index) => (
<FurnitureItemBtn
key={index}
def={def}
index={index}
isActive={selectedIndex === index}
onSelect={handleSelect}
/>
))}
</div>
</div>
);
}
interface FurnitureItemBtnProps {
readonly def: FurnitureDef;
readonly index: number;
readonly isActive: boolean;
readonly onSelect: (index: number) => void;
}
function FurnitureItemBtn({ def, index, isActive, onSelect }: FurnitureItemBtnProps) {
return (
<button
className={[
styles.itemBtn,
isActive ? styles.itemBtnActive : '',
].join(' ')}
onClick={() => onSelect(index)}
title={`${def.label} (${def.width}m x ${def.depth}m)`}
>
<span className={styles.itemIcon}>{def.icon}</span>
<div className={styles.itemInfo}>
<span className={styles.itemLabel}>{def.label}</span>
<span className={styles.itemDims}>
{def.width}m x {def.depth}m
</span>
</div>
</button>
);
}
@@ -0,0 +1,20 @@
.status {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-3);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
background-color: var(--color-bg-elevated);
border-top: 1px solid var(--color-border);
min-height: 28px;
}
.label {
font-weight: var(--font-weight-medium);
}
.value {
font-variant-numeric: tabular-nums;
color: var(--color-text-primary);
}
@@ -0,0 +1,86 @@
.palette {
position: absolute;
top: 0;
left: 0;
z-index: var(--z-dropdown);
width: 220px;
max-height: 100%;
background-color: var(--color-bg-elevated);
border-right: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border);
border-radius: 0 0 var(--radius-md) 0;
overflow-y: auto;
display: flex;
flex-direction: column;
opacity: 0.95;
box-shadow: var(--shadow-lg);
}
.header {
padding: var(--space-3) var(--space-4);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
border-bottom: 1px solid var(--color-border);
letter-spacing: var(--letter-spacing-wide);
text-transform: uppercase;
}
.category {
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--color-border);
}
.categoryTitle {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: var(--letter-spacing-wide);
margin-bottom: var(--space-1);
}
.itemGrid {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.itemBtn {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: var(--space-1) var(--space-2);
border: 1px solid transparent;
border-radius: var(--radius-md);
background: none;
cursor: pointer;
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
transition: all var(--transition-fast);
font-family: var(--font-family);
min-width: 60px;
}
.itemBtn:hover {
background-color: var(--color-bg-hover);
color: var(--color-text-primary);
}
.itemBtnActive {
background-color: var(--color-accent-50);
border-color: var(--color-accent-200);
color: var(--color-accent-700);
}
.itemIcon {
font-size: 18px;
line-height: 1;
}
.itemLabel {
font-size: var(--font-size-xs);
text-align: center;
line-height: 1.2;
}
@@ -0,0 +1,87 @@
.palette {
position: absolute;
top: 0;
left: 0;
z-index: var(--z-dropdown);
width: 220px;
max-height: 100%;
background-color: var(--color-bg-elevated);
border-right: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border);
border-radius: 0 0 var(--radius-md) 0;
overflow-y: auto;
display: flex;
flex-direction: column;
opacity: 0.95;
box-shadow: var(--shadow-lg);
}
.header {
padding: var(--space-3) var(--space-4);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
border-bottom: 1px solid var(--color-border);
letter-spacing: var(--letter-spacing-wide);
text-transform: uppercase;
}
.itemList {
display: flex;
flex-direction: column;
padding: var(--space-1);
}
.itemBtn {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border: 1px solid transparent;
border-radius: var(--radius-md);
background: none;
cursor: pointer;
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
transition: all var(--transition-fast);
font-family: var(--font-family);
text-align: left;
width: 100%;
}
.itemBtn:hover {
background-color: var(--color-bg-hover);
color: var(--color-text-primary);
}
.itemBtnActive {
background-color: var(--color-accent-50);
border-color: var(--color-accent-200);
color: var(--color-accent-700);
}
.itemIcon {
font-size: 16px;
line-height: 1;
flex-shrink: 0;
}
.itemInfo {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.itemLabel {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.itemDims {
font-size: 11px;
color: var(--color-text-muted);
}
@@ -0,0 +1,146 @@
import { Group, Rect, Line, Text } from 'react-konva';
import type { ProjectedOpening } from '../utils/projectionMapping';
import { projectionToPixel } from '../utils/projectionMapping';
import type { DoorOpenDirection } from '@house-plan-maker/shared';
interface ProjectionDoorProps {
readonly projected: ProjectedOpening;
readonly wallHeight: number;
readonly scale: number;
readonly padding: number;
readonly isSelected: boolean;
readonly isDragging?: boolean;
readonly dragAlongWall?: number;
readonly onClick: () => void;
readonly onDragStart?: (openingId: string, evt: MouseEvent) => void;
}
/**
* Build polyline points for a quarter-circle arc used as the door swing indicator.
* The arc origin and sweep direction depend on the open direction.
*/
function buildSwingArcPoints(
topLeftX: number,
topLeftY: number,
pxWidth: number,
pxHeight: number,
openDirection: DoorOpenDirection,
): number[] {
const arcRadius = pxWidth * 0.8;
const steps = 12;
const points: number[] = [];
// Hinge at left or right bottom corner; arc sweeps accordingly
const hingeLeft = openDirection === 'LEFT' || openDirection === 'INWARD' || openDirection === 'OUTWARD';
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const angle = (Math.PI / 2) * t;
if (hingeLeft) {
// Hinge at bottom-left, arc sweeps rightward and upward
points.push(
topLeftX + Math.sin(angle) * arcRadius,
topLeftY + pxHeight - Math.cos(angle) * arcRadius,
);
} else {
// Hinge at bottom-right, arc sweeps leftward and upward
points.push(
topLeftX + pxWidth - Math.sin(angle) * arcRadius,
topLeftY + pxHeight - Math.cos(angle) * arcRadius,
);
}
}
return points;
}
/** Render a door in wall elevation view. */
export function ProjectionDoor({
projected,
wallHeight,
scale,
padding,
isSelected,
isDragging = false,
dragAlongWall,
onClick,
onDragStart,
}: ProjectionDoorProps) {
const { rect, opening } = projected;
const openDirection: DoorOpenDirection = opening.openDirection ?? 'LEFT';
const displayX = isDragging && dragAlongWall != null
? dragAlongWall - opening.width / 2
: rect.x;
const topLeft = projectionToPixel(displayX, rect.y + rect.height, wallHeight, scale, padding);
const pxWidth = rect.width * scale;
const pxHeight = rect.height * scale;
const arcPoints = buildSwingArcPoints(topLeft.x, topLeft.y, pxWidth, pxHeight, openDirection);
// Door leaf line position depends on hinge side
const isHingeLeft = openDirection === 'LEFT' || openDirection === 'INWARD' || openDirection === 'OUTWARD';
const leafPoints = isHingeLeft
? [topLeft.x, topLeft.y, topLeft.x, topLeft.y + pxHeight]
: [topLeft.x + pxWidth, topLeft.y, topLeft.x + pxWidth, topLeft.y + pxHeight];
return (
<Group
onClick={onClick}
onMouseDown={(e) => {
if (onDragStart && e.evt.button === 0) {
onDragStart(opening.id, e.evt);
}
}}
>
{/* Drag ghost outline */}
{isDragging && (
<Rect
x={topLeft.x - 2}
y={topLeft.y - 2}
width={pxWidth + 4}
height={pxHeight + 4}
stroke="#2563eb"
strokeWidth={1}
dash={[3, 3]}
fill="transparent"
/>
)}
{/* Door opening (gap in wall) */}
<Rect
x={topLeft.x}
y={topLeft.y}
width={pxWidth}
height={pxHeight}
fill="#ffffff"
stroke={isSelected ? '#2563eb' : '#64748b'}
strokeWidth={isSelected ? 2 : 1}
/>
{/* Door leaf line (vertical line showing hinge side) */}
<Line
points={leafPoints}
stroke="#94a3b8"
strokeWidth={2}
/>
{/* Door swing indicator arc */}
<Line
points={arcPoints}
stroke="#94a3b8"
strokeWidth={1}
dash={[4, 3]}
/>
{/* Door label */}
<Text
x={topLeft.x}
y={topLeft.y + pxHeight / 2 - 6}
width={pxWidth}
text="D"
align="center"
fontSize={11}
fill="#64748b"
/>
</Group>
);
}
@@ -0,0 +1,172 @@
import { Group, Circle, Line, Rect, Text } from 'react-konva';
import type { ProjectedElectrical } from '../utils/projectionMapping';
import { projectionToPixel } from '../utils/projectionMapping';
interface ProjectionElectricalProps {
readonly projected: ProjectedElectrical;
readonly wallHeight: number;
readonly scale: number;
readonly padding: number;
readonly isSelected: boolean;
readonly isDragging?: boolean;
readonly dragFromFloor?: number;
readonly dragAlongWall?: number;
readonly onClick: () => void;
readonly onDragStart?: (itemId: string, evt: MouseEvent) => void;
}
const SYMBOL_SIZE = 12;
/** Render a wall-mounted electrical item in wall elevation view. */
export function ProjectionElectrical({
projected,
wallHeight,
scale,
padding,
isSelected,
isDragging = false,
dragFromFloor,
dragAlongWall,
onClick,
onDragStart,
}: ProjectionElectricalProps) {
const { position, item } = projected;
const displayFromFloor = isDragging && dragFromFloor != null ? dragFromFloor : position.fromFloor;
const displayAlongWall = isDragging && dragAlongWall != null ? dragAlongWall : position.alongWall;
const center = projectionToPixel(
displayAlongWall,
displayFromFloor,
wallHeight,
scale,
padding,
);
const strokeColor = isSelected ? '#2563eb' : '#64748b';
const fillColor = isSelected ? '#dbeafe' : '#f1f5f9';
const half = SYMBOL_SIZE / 2;
return (
<Group
onClick={onClick}
onMouseDown={(e) => {
if (onDragStart && e.evt.button === 0) {
onDragStart(item.id, e.evt);
}
}}
style={{ cursor: onDragStart ? 'grab' : 'default' }}
>
{/* Drag ghost outline */}
{isDragging && (
<Rect
x={center.x - half - 2}
y={center.y - half - 2}
width={SYMBOL_SIZE + 4}
height={SYMBOL_SIZE + 4}
stroke="#2563eb"
strokeWidth={1}
dash={[3, 3]}
fill="transparent"
/>
)}
{item.type === 'OUTLET' && (
<>
{/* IEC outlet symbol: circle with two horizontal lines */}
<Circle
x={center.x}
y={center.y}
radius={half}
fill={fillColor}
stroke={strokeColor}
strokeWidth={1.5}
/>
<Line
points={[center.x - 3, center.y - 2, center.x + 3, center.y - 2]}
stroke={strokeColor}
strokeWidth={1.5}
/>
<Line
points={[center.x - 3, center.y + 2, center.x + 3, center.y + 2]}
stroke={strokeColor}
strokeWidth={1.5}
/>
</>
)}
{item.type === 'SWITCH' && (
<>
{/* IEC switch symbol: circle with diagonal line */}
<Circle
x={center.x}
y={center.y}
radius={half}
fill={fillColor}
stroke={strokeColor}
strokeWidth={1.5}
/>
<Line
points={[
center.x - half + 2,
center.y + half - 2,
center.x + half - 2,
center.y - half + 2,
]}
stroke={strokeColor}
strokeWidth={1.5}
/>
</>
)}
{item.type === 'LIGHT_WALL' && (
<>
{/* Wall light: semicircle shape */}
<Circle
x={center.x}
y={center.y}
radius={half}
fill="#fef9c3"
stroke={strokeColor}
strokeWidth={1.5}
/>
<Line
points={[
center.x - half, center.y,
center.x - half * 1.3, center.y + half * 0.8,
]}
stroke="#eab308"
strokeWidth={1}
/>
<Line
points={[
center.x + half, center.y,
center.x + half * 1.3, center.y + half * 0.8,
]}
stroke="#eab308"
strokeWidth={1}
/>
</>
)}
{/* Fallback for other wall-mounted types */}
{item.type !== 'OUTLET' && item.type !== 'SWITCH' && item.type !== 'LIGHT_WALL' && (
<Rect
x={center.x - half}
y={center.y - half}
width={SYMBOL_SIZE}
height={SYMBOL_SIZE}
fill={fillColor}
stroke={strokeColor}
strokeWidth={1.5}
/>
)}
{/* Type label below symbol */}
<Text
x={center.x - 20}
y={center.y + half + 2}
width={40}
text={item.type === 'OUTLET' ? 'OUT' : item.type === 'SWITCH' ? 'SW' : 'WL'}
align="center"
fontSize={8}
fill="#94a3b8"
/>
</Group>
);
}
@@ -0,0 +1,65 @@
import { Group, Rect, Text } from 'react-konva';
import type { ProjectedFurniture } from '../utils/projectionMapping';
import { projectionToPixel } from '../utils/projectionMapping';
interface ProjectionFurnitureProps {
readonly projected: ProjectedFurniture;
readonly wallHeight: number;
readonly scale: number;
readonly padding: number;
readonly isSelected: boolean;
readonly onClick: () => void;
}
const TYPE_COLORS: Record<string, string> = {
SHELF: '#d4a574',
BOOKCASE: '#b8860b',
WARDROBE: '#8b7355',
DRESSER: '#a0845c',
DESK: '#c9a96e',
TABLE: '#deb887',
};
/** Render a furniture item in wall elevation view. */
export function ProjectionFurniture({
projected,
wallHeight,
scale,
padding,
isSelected,
onClick,
}: ProjectionFurnitureProps) {
const { rect, item } = projected;
const topLeft = projectionToPixel(rect.x, rect.y + rect.height, wallHeight, scale, padding);
const pxWidth = rect.width * scale;
const pxHeight = rect.height * scale;
const color = TYPE_COLORS[item.type] ?? '#a0845c';
return (
<Group onClick={onClick}>
<Rect
x={topLeft.x}
y={topLeft.y}
width={pxWidth}
height={pxHeight}
fill={isSelected ? '#dbeafe' : color}
stroke={isSelected ? '#2563eb' : '#6b5b3a'}
strokeWidth={isSelected ? 2 : 1}
opacity={0.7}
/>
{/* Furniture label */}
<Text
x={topLeft.x}
y={topLeft.y + pxHeight / 2 - 5}
width={pxWidth}
text={item.label ?? item.type}
align="center"
fontSize={9}
fill={isSelected ? '#1e40af' : '#3b2f1e'}
ellipsis
/>
</Group>
);
}
@@ -0,0 +1,241 @@
import type { ReactNode } from 'react';
import { Group, Line, Text } from 'react-konva';
import type { ProjectedOpening, ProjectedElectrical } from '../utils/projectionMapping';
import { projectionToPixel } from '../utils/projectionMapping';
interface ProjectionMeasurementsProps {
readonly projectedOpenings: readonly ProjectedOpening[];
readonly projectedElectrical: readonly ProjectedElectrical[];
readonly wallLength: number;
readonly wallHeight: number;
readonly scale: number;
readonly padding: number;
}
/** Dimension line with arrows and text. */
function DimensionLine({
x1, y1, x2, y2, label, offset, horizontal,
}: {
readonly x1: number;
readonly y1: number;
readonly x2: number;
readonly y2: number;
readonly label: string;
readonly offset: number;
readonly horizontal: boolean;
}) {
const arrowSize = 4;
if (horizontal) {
const lineY = y1 + offset;
const midX = (x1 + x2) / 2;
return (
<Group>
{/* Extension lines */}
<Line points={[x1, y1, x1, lineY]} stroke="#94a3b8" strokeWidth={0.5} />
<Line points={[x2, y2, x2, lineY]} stroke="#94a3b8" strokeWidth={0.5} />
{/* Main line */}
<Line points={[x1, lineY, x2, lineY]} stroke="#94a3b8" strokeWidth={0.75} />
{/* Arrows */}
<Line
points={[x1, lineY, x1 + arrowSize, lineY - arrowSize / 2, x1 + arrowSize, lineY + arrowSize / 2]}
fill="#94a3b8"
closed
/>
<Line
points={[x2, lineY, x2 - arrowSize, lineY - arrowSize / 2, x2 - arrowSize, lineY + arrowSize / 2]}
fill="#94a3b8"
closed
/>
{/* Label */}
<Text
x={midX - 20}
y={lineY - 12}
width={40}
text={label}
align="center"
fontSize={9}
fill="#64748b"
/>
</Group>
);
}
// Vertical dimension
const lineX = x1 + offset;
const midY = (y1 + y2) / 2;
return (
<Group>
<Line points={[x1, y1, lineX, y1]} stroke="#94a3b8" strokeWidth={0.5} />
<Line points={[x1, y2, lineX, y2]} stroke="#94a3b8" strokeWidth={0.5} />
<Line points={[lineX, y1, lineX, y2]} stroke="#94a3b8" strokeWidth={0.75} />
<Line
points={[lineX, y1, lineX - arrowSize / 2, y1 + arrowSize, lineX + arrowSize / 2, y1 + arrowSize]}
fill="#94a3b8"
closed
/>
<Line
points={[lineX, y2, lineX - arrowSize / 2, y2 - arrowSize, lineX + arrowSize / 2, y2 - arrowSize]}
fill="#94a3b8"
closed
/>
<Text
x={lineX + 3}
y={midY - 5}
text={label}
fontSize={9}
fill="#64748b"
/>
</Group>
);
}
function formatM(meters: number): string {
return `${meters.toFixed(2)}m`;
}
/** Render measurement annotations on a wall projection view. */
export function ProjectionMeasurements({
projectedOpenings,
projectedElectrical,
wallLength: wallLen,
wallHeight,
scale,
padding,
}: ProjectionMeasurementsProps) {
const elements: ReactNode[] = [];
// Wall width dimension (along bottom)
const floorLeft = projectionToPixel(0, 0, wallHeight, scale, padding);
const floorRight = projectionToPixel(wallLen, 0, wallHeight, scale, padding);
elements.push(
<DimensionLine
key="wall-width"
x1={floorLeft.x}
y1={floorLeft.y}
x2={floorRight.x}
y2={floorRight.y}
label={formatM(wallLen)}
offset={18}
horizontal
/>,
);
// Wall height dimension (along right side)
const topRight = projectionToPixel(wallLen, wallHeight, wallHeight, scale, padding);
const bottomRight = projectionToPixel(wallLen, 0, wallHeight, scale, padding);
elements.push(
<DimensionLine
key="wall-height"
x1={topRight.x}
y1={topRight.y}
x2={bottomRight.x}
y2={bottomRight.y}
label={formatM(wallHeight)}
offset={18}
horizontal={false}
/>,
);
// Opening dimensions: sill height for windows, door height for doors
for (const po of projectedOpenings) {
const { rect, opening } = po;
const topLeft = projectionToPixel(rect.x, rect.y + rect.height, wallHeight, scale, padding);
const bottomLeft = projectionToPixel(rect.x, rect.y, wallHeight, scale, padding);
// Height annotation (vertical, left side of opening)
elements.push(
<DimensionLine
key={`opening-h-${opening.id}`}
x1={topLeft.x}
y1={topLeft.y}
x2={bottomLeft.x}
y2={bottomLeft.y}
label={formatM(rect.height)}
offset={-14}
horizontal={false}
/>,
);
// Sill height for windows
if (opening.type === 'WINDOW' && rect.y > 0.01) {
const floorBelow = projectionToPixel(rect.x, 0, wallHeight, scale, padding);
elements.push(
<DimensionLine
key={`sill-${opening.id}`}
x1={bottomLeft.x}
y1={bottomLeft.y}
x2={floorBelow.x}
y2={floorBelow.y}
label={formatM(rect.y)}
offset={-14}
horizontal={false}
/>,
);
}
// Width annotation (horizontal, above opening)
const topRight2 = projectionToPixel(rect.x + rect.width, rect.y + rect.height, wallHeight, scale, padding);
elements.push(
<DimensionLine
key={`opening-w-${opening.id}`}
x1={topLeft.x}
y1={topLeft.y}
x2={topRight2.x}
y2={topRight2.y}
label={formatM(rect.width)}
offset={-12}
horizontal
/>,
);
}
// Electrical item coordinate labels: (X; Y) near each item
for (const pe of projectedElectrical) {
const center = projectionToPixel(
pe.position.alongWall,
pe.position.fromFloor,
wallHeight,
scale,
padding,
);
const coordLabel = `(${pe.position.alongWall.toFixed(2)}; ${pe.elevation.toFixed(2)})`;
elements.push(
<Text
key={`elec-coord-${pe.item.id}`}
x={center.x + 10}
y={center.y - 4}
text={coordLabel}
fontSize={9}
fill="#64748b"
/>,
);
}
// Opening horizontal position labels
for (const po of projectedOpenings) {
const { rect, opening } = po;
const openingCenter = projectionToPixel(
rect.x + rect.width / 2,
rect.y + rect.height,
wallHeight,
scale,
padding,
);
elements.push(
<Text
key={`opening-x-${opening.id}`}
x={openingCenter.x - 16}
y={openingCenter.y - 22}
text={formatM(rect.x + rect.width / 2)}
fontSize={8}
fill="#94a3b8"
align="center"
width={32}
/>,
);
}
return <Group>{elements}</Group>;
}
@@ -0,0 +1,299 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { ElectricalItem, WallOpening } from '@house-plan-maker/shared';
import { useEditor } from '../context/EditorContext';
import { wallDirectionKey } from '../utils/projectionMapping';
import { wallStartEnd } from '../utils/wallUtils';
import { generateLocalId } from '../utils/geometry';
import { getDefaultElevation } from '../tools/ElectricalTool';
import { ELECTRICAL_SYMBOL_DEFS } from '../symbols/electrical';
import { WallProjectionView } from './WallProjectionView';
import styles from './projection-panel.module.css';
type LayoutMode = 'tabs' | 'grid';
interface ProjectionPanelProps {
readonly fullView?: boolean;
readonly onStageRef?: (wallId: string, stage: import('konva').default.Stage | null) => void;
}
export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPanelProps = {}) {
const { t } = useTranslation();
const { state, selectElement, updateElectrical, updateOpening, addElectrical } = useEditor();
const {
walls,
openings,
electricalItems,
furnitureItems,
annotations,
room,
selectedIds,
activeTool,
selectedElectricalIndex,
layerVisibility,
} = state;
const [isCollapsed, setIsCollapsed] = useState(false);
const [layoutMode, setLayoutMode] = useState<LayoutMode>('tabs');
const [activeWallIndex, setActiveWallIndex] = useState(0);
const contentRef = useRef<HTMLDivElement>(null);
const [contentSize, setContentSize] = useState({ width: 600, height: 240 });
// ── Determine active electrical type for placement ──
const selectedElectricalType = useMemo(() => {
if (activeTool !== 'electrical' || selectedElectricalIndex == null) return null;
const def = ELECTRICAL_SYMBOL_DEFS[selectedElectricalIndex];
return def?.type ?? null;
}, [activeTool, selectedElectricalIndex]);
// ── Determine which wall is selected in 2D view ──
const highlightedWallId = useMemo(() => {
for (const wall of walls) {
if (selectedIds.has(wall.id)) return wall.id;
}
for (const opening of openings) {
if (selectedIds.has(opening.id)) return opening.wallId;
}
for (const elec of electricalItems) {
if (selectedIds.has(elec.id) && elec.wallId) return elec.wallId;
}
return null;
}, [walls, openings, electricalItems, selectedIds]);
// ── Auto-switch tab when a wall-related item is selected ──
useEffect(() => {
if (highlightedWallId && layoutMode === 'tabs') {
const idx = walls.findIndex((w) => w.id === highlightedWallId);
if (idx >= 0) {
setActiveWallIndex(idx);
}
}
}, [highlightedWallId, walls, layoutMode]);
// ── Resize observer ──
useEffect(() => {
const container = contentRef.current;
if (!container) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
setContentSize({ width: Math.floor(width), height: Math.floor(height) });
}
});
observer.observe(container);
const rect = container.getBoundingClientRect();
setContentSize({ width: Math.floor(rect.width), height: Math.floor(rect.height) });
return () => observer.disconnect();
}, [isCollapsed, fullView]);
const handleToggleCollapse = useCallback(() => {
if (fullView) return;
setIsCollapsed((prev) => !prev);
}, [fullView]);
const handleToggleLayout = useCallback(() => {
setLayoutMode((prev) => (prev === 'tabs' ? 'grid' : 'tabs'));
}, []);
const handleSelectElement = useCallback(
(id: string) => {
selectElement(id);
},
[selectElement],
);
// ── Drag callbacks ──
const handleUpdateElectrical = useCallback(
(item: ElectricalItem) => {
updateElectrical(item);
},
[updateElectrical],
);
const handleUpdateOpening = useCallback(
(opening: WallOpening) => {
updateOpening(opening);
},
[updateOpening],
);
// ── Placement callback ──
const handlePlaceElectrical = useCallback(
(wallId: string, alongWall: number, fromFloor: number) => {
if (selectedElectricalIndex == null) return;
const symbolDef = ELECTRICAL_SYMBOL_DEFS[selectedElectricalIndex];
if (!symbolDef) return;
const wall = walls.find((w) => w.id === wallId);
if (!wall) return;
// Convert projection coordinates (alongWall) back to room 2D coordinates
const { start, end } = wallStartEnd(wall);
const wallLen = Math.sqrt(
(end.x - start.x) ** 2 + (end.y - start.y) ** 2,
);
if (wallLen === 0) return;
const dx = (end.x - start.x) / wallLen;
const dy = (end.y - start.y) / wallLen;
const x = start.x + dx * alongWall;
const y = start.y + dy * alongWall;
// Use the clicked elevation, or fall back to default for the type
const elevation = symbolDef.wallMounted
? fromFloor
: getDefaultElevation(symbolDef.type, room.wallHeight);
const metadata: Record<string, unknown> | null =
symbolDef.variant ? { variant: symbolDef.variant } : null;
const newItem: ElectricalItem = {
id: generateLocalId(),
roomId: room.id,
type: symbolDef.type,
x,
y,
wallId: symbolDef.wallMounted ? wallId : null,
elevationFromFloor: elevation,
rotation: (Math.atan2(dy, dx) * 180) / Math.PI,
metadata,
};
addElectrical(newItem);
selectElement(newItem.id);
},
[selectedElectricalIndex, walls, room, addElectrical, selectElement],
);
if (walls.length === 0) {
return null;
}
const clampedIndex = Math.min(activeWallIndex, walls.length - 1);
const sharedProps = {
openings,
electricalItems: layerVisibility.electrical ? electricalItems : [],
furnitureItems: layerVisibility.furniture ? furnitureItems : [],
annotations: layerVisibility.annotations ? annotations : [],
wallHeight: room.wallHeight,
plinthHeight: room.plinthHeight,
selectedIds,
onSelectElement: handleSelectElement,
onStageRef,
onUpdateElectrical: handleUpdateElectrical,
onUpdateOpening: handleUpdateOpening,
onPlaceElectrical: handlePlaceElectrical,
showMeasurements: layerVisibility.measurements,
activeTool,
selectedElectricalType,
} as const;
const panelClassName = fullView
? `${styles.panel} ${styles.panelFullView}`
: `${styles.panel} ${isCollapsed ? styles.panelCollapsed : styles.panelExpanded}`;
const isContentVisible = fullView || !isCollapsed;
return (
<div className={panelClassName}>
{/* Header — hidden in full-view mode (no collapse needed) */}
{!fullView && (
<div className={styles.header} onClick={handleToggleCollapse}>
<div className={styles.headerLeft}>
<span
className={`${styles.collapseIcon} ${isCollapsed ? '' : styles.collapseIconOpen}`}
>
&#9660;
</span>
<span className={styles.headerTitle}>{t('projection.title')}</span>
</div>
{!isCollapsed && (
<button
className={styles.layoutToggle}
onClick={(e) => {
e.stopPropagation();
handleToggleLayout();
}}
>
{layoutMode === 'tabs' ? t('projection.grid') : t('projection.tabs')}
</button>
)}
</div>
)}
{/* Layout toggle in full-view mode (inline bar instead of collapsible header) */}
{fullView && (
<div className={styles.fullViewToolbar}>
<button
className={styles.layoutToggle}
onClick={handleToggleLayout}
>
{layoutMode === 'tabs' ? t('projection.grid') : t('projection.tabs')}
</button>
</div>
)}
{isContentVisible && (
<>
{/* Tabs (only in tab mode) */}
{layoutMode === 'tabs' && (
<div className={styles.tabs}>
{walls.map((wall, idx) => (
<button
key={wall.id}
className={`${styles.tab} ${idx === clampedIndex ? styles.tabActive : ''}`}
onClick={() => setActiveWallIndex(idx)}
>
{t(wallDirectionKey(wall))}
</button>
))}
</div>
)}
{/* Content area */}
{layoutMode === 'tabs' ? (
<div ref={contentRef} className={styles.content}>
{walls[clampedIndex] ? (
<div className={styles.viewContainer}>
<WallProjectionView
wall={walls[clampedIndex]}
{...sharedProps}
isHighlighted={walls[clampedIndex].id === highlightedWallId}
width={contentSize.width}
height={contentSize.height}
/>
</div>
) : (
<div className={styles.emptyState}>{t('projection.noWall')}</div>
)}
</div>
) : (
<div ref={contentRef} className={styles.gridLayout}>
{walls.slice(0, 4).map((wall) => (
<div key={wall.id} className={styles.gridCell}>
<WallProjectionView
wall={wall}
{...sharedProps}
isHighlighted={wall.id === highlightedWallId}
width={Math.floor(contentSize.width / 2)}
height={Math.floor(contentSize.height / 2)}
/>
</div>
))}
{walls.length < 4 &&
Array.from({ length: 4 - walls.length }).map((_, i) => (
<div key={`empty-${i}`} className={styles.gridCell}>
<div className={styles.emptyState}>--</div>
</div>
))}
</div>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,130 @@
import { Group, Rect, Line } from 'react-konva';
import type { ProjectedOpening } from '../utils/projectionMapping';
import { projectionToPixel } from '../utils/projectionMapping';
interface ProjectionWindowProps {
readonly projected: ProjectedOpening;
readonly wallHeight: number;
readonly scale: number;
readonly padding: number;
readonly isSelected: boolean;
readonly isDragging?: boolean;
readonly dragAlongWall?: number;
readonly onClick: () => void;
readonly onDragStart?: (openingId: string, evt: MouseEvent) => void;
}
/** Render a window in wall elevation view. */
export function ProjectionWindow({
projected,
wallHeight,
scale,
padding,
isSelected,
isDragging = false,
dragAlongWall,
onClick,
onDragStart,
}: ProjectionWindowProps) {
const { rect, opening } = projected;
const displayX = isDragging && dragAlongWall != null
? dragAlongWall - opening.width / 2
: rect.x;
const topLeft = projectionToPixel(displayX, rect.y + rect.height, wallHeight, scale, padding);
const pxWidth = rect.width * scale;
const pxHeight = rect.height * scale;
const frameInset = 3;
return (
<Group
onClick={onClick}
onMouseDown={(e) => {
if (onDragStart && e.evt.button === 0) {
onDragStart(opening.id, e.evt);
}
}}
>
{/* Drag ghost outline */}
{isDragging && (
<Rect
x={topLeft.x - 2}
y={topLeft.y - 2}
width={pxWidth + 4}
height={pxHeight + 4}
stroke="#2563eb"
strokeWidth={1}
dash={[3, 3]}
fill="transparent"
/>
)}
{/* Window frame (outer) */}
<Rect
x={topLeft.x}
y={topLeft.y}
width={pxWidth}
height={pxHeight}
fill="#dbeafe"
stroke={isSelected ? '#2563eb' : '#3b82f6'}
strokeWidth={isSelected ? 2.5 : 1.5}
/>
{/* Glass pane (inner rectangle) */}
<Rect
x={topLeft.x + frameInset}
y={topLeft.y + frameInset}
width={pxWidth - frameInset * 2}
height={pxHeight - frameInset * 2}
fill="#bfdbfe"
stroke="#93c5fd"
strokeWidth={0.5}
/>
{/* Horizontal mullion (center divider) */}
<Line
points={[
topLeft.x + frameInset,
topLeft.y + pxHeight / 2,
topLeft.x + pxWidth - frameInset,
topLeft.y + pxHeight / 2,
]}
stroke="#3b82f6"
strokeWidth={1}
/>
{/* Vertical mullion (center divider) */}
<Line
points={[
topLeft.x + pxWidth / 2,
topLeft.y + frameInset,
topLeft.x + pxWidth / 2,
topLeft.y + pxHeight - frameInset,
]}
stroke="#3b82f6"
strokeWidth={1}
/>
{/* Glass cross lines for indication */}
<Line
points={[
topLeft.x + frameInset,
topLeft.y + frameInset,
topLeft.x + pxWidth / 2,
topLeft.y + pxHeight / 2,
]}
stroke="#93c5fd"
strokeWidth={0.5}
opacity={0.6}
/>
<Line
points={[
topLeft.x + pxWidth - frameInset,
topLeft.y + frameInset,
topLeft.x + pxWidth / 2,
topLeft.y + pxHeight / 2,
]}
stroke="#93c5fd"
strokeWidth={0.5}
opacity={0.6}
/>
</Group>
);
}
@@ -0,0 +1,717 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Stage, Layer, Rect, Line, Text, Group } from 'react-konva';
import type Konva from 'konva';
import type { Wall, WallOpening, ElectricalItem, FurnitureItem, ElectricalType, Annotation } from '@house-plan-maker/shared';
import { wallLength as computeWallLength, wallStartEnd } from '../utils/wallUtils';
import {
projectionScale,
projectionToPixel,
pixelToProjection,
projectOpenings,
projectElectricalItems,
projectFurnitureItems,
computePlinthSegments,
wallDirectionLabel,
} from '../utils/projectionMapping';
import { ProjectionDoor } from './ProjectionDoor';
import { ProjectionWindow } from './ProjectionWindow';
import { ProjectionElectrical } from './ProjectionElectrical';
import { ProjectionFurniture } from './ProjectionFurniture';
import { ProjectionMeasurements } from './ProjectionMeasurements';
import type { EditorToolType } from '../types';
interface WallProjectionViewProps {
readonly wall: Wall;
readonly openings: readonly WallOpening[];
readonly electricalItems: readonly ElectricalItem[];
readonly furnitureItems: readonly FurnitureItem[];
readonly annotations: readonly Annotation[];
readonly showMeasurements?: boolean;
readonly wallHeight: number;
readonly plinthHeight: number;
readonly selectedIds: ReadonlySet<string>;
readonly isHighlighted: boolean;
readonly onSelectElement: (id: string) => void;
readonly width: number;
readonly height: number;
readonly onStageRef?: (wallId: string, stage: Konva.Stage | null) => void;
readonly onUpdateElectrical?: (item: ElectricalItem) => void;
readonly onUpdateOpening?: (opening: WallOpening) => void;
readonly onPlaceElectrical?: (wallId: string, alongWall: number, fromFloor: number) => void;
readonly activeTool?: EditorToolType;
readonly selectedElectricalType?: ElectricalType | null;
}
const PADDING = 40;
const PLINTH_COLOR = '#8b7355';
const WALL_FILL = '#f8fafc';
const WALL_STROKE = '#334155';
const FLOOR_COLOR = '#94a3b8';
const CEILING_COLOR = '#94a3b8';
/** Minimum pixel distance to distinguish a drag from a click. */
const DRAG_THRESHOLD = 4;
type DragKind = 'electrical-elevation' | 'opening-position';
interface DragInfo {
readonly kind: DragKind;
readonly itemId: string;
readonly startPixelX: number;
readonly startPixelY: number;
readonly exceeded: boolean;
}
export function WallProjectionView({
wall,
openings,
electricalItems,
furnitureItems,
annotations,
showMeasurements = true,
wallHeight,
plinthHeight,
selectedIds,
isHighlighted,
onSelectElement,
width,
height,
onStageRef,
onUpdateElectrical,
onUpdateOpening,
onPlaceElectrical,
activeTool,
selectedElectricalType,
}: WallProjectionViewProps) {
const { t } = useTranslation();
const stageRef = useRef<Konva.Stage | null>(null);
// Expose stage ref to parent for export
useEffect(() => {
onStageRef?.(wall.id, stageRef.current);
});
// ── Zoom/Pan state ──
const [viewZoom, setViewZoom] = useState(1);
const [viewPan, setViewPan] = useState({ x: 0, y: 0 });
const isPanningRef = useRef(false);
const panStartRef = useRef({ x: 0, y: 0 });
const panStartOffsetRef = useRef({ x: 0, y: 0 });
const viewPanRef = useRef(viewPan);
viewPanRef.current = viewPan;
const wallLen = computeWallLength(wall);
const baseScale = projectionScale(wallLen, wallHeight, width, height, PADDING);
const effectiveScale = baseScale * viewZoom;
// ── Drag state (refs for transient data, state only for visual feedback) ──
const dragRef = useRef<DragInfo | null>(null);
const [dragElectricalFromFloor, setDragElectricalFromFloor] = useState<{ itemId: string; fromFloor: number } | null>(null);
const [dragElectricalAlongWall, setDragElectricalAlongWall] = useState<{ itemId: string; alongWall: number } | null>(null);
const [dragOpeningAlongWall, setDragOpeningAlongWall] = useState<{ openingId: string; alongWall: number } | null>(null);
// ── Projected data (memoized) ──
const projectedOpenings = useMemo(
() => projectOpenings(wall, openings),
[wall, openings],
);
const projectedElectrical = useMemo(
() => projectElectricalItems(wall, electricalItems),
[wall, electricalItems],
);
const projectedFurniture = useMemo(
() => projectFurnitureItems(wall, furnitureItems),
[wall, furnitureItems],
);
const plinthSegments = useMemo(
() => computePlinthSegments(wall, openings, plinthHeight),
[wall, openings, plinthHeight],
);
// ── Helper to get pixel position relative to stage ──
const getStagePointer = useCallback((evt: MouseEvent): { x: number; y: number } | null => {
const stage = stageRef.current;
if (!stage) return null;
const container = stage.container();
const rect = container.getBoundingClientRect();
return {
x: evt.clientX - rect.left,
y: evt.clientY - rect.top,
};
}, []);
// ── Electrical drag start ──
const handleElectricalDragStart = useCallback((itemId: string, evt: MouseEvent) => {
if (!onUpdateElectrical) return;
dragRef.current = {
kind: 'electrical-elevation',
itemId,
startPixelX: evt.clientX,
startPixelY: evt.clientY,
exceeded: false,
};
}, [onUpdateElectrical]);
// ── Opening drag start ──
const handleOpeningDragStart = useCallback((openingId: string, evt: MouseEvent) => {
if (!onUpdateOpening) return;
dragRef.current = {
kind: 'opening-position',
itemId: openingId,
startPixelX: evt.clientX,
startPixelY: evt.clientY,
exceeded: false,
};
}, [onUpdateOpening]);
// ── Zoom handler ──
const handleWheel = useCallback((e: Konva.KonvaEventObject<WheelEvent>) => {
e.evt.preventDefault();
const stage = e.target.getStage();
if (!stage) return;
const pointerPos = stage.getPointerPosition();
if (!pointerPos) return;
// Content is inside a Group with x=viewPan.x, y=viewPan.y.
// projectionToPixel gives: contentX = PADDING + worldX * effectiveScale
// On screen: screenX = contentX + viewPan.x
// So: worldX = (screenX - viewPan.x - PADDING) / effectiveScale
const oldScale = effectiveScale;
const worldX = (pointerPos.x - viewPan.x - PADDING) / oldScale;
const worldY = (pointerPos.y - viewPan.y - PADDING) / oldScale;
const delta = e.evt.deltaY;
const factor = delta > 0 ? 0.9 : 1.1;
const newZoom = Math.max(0.3, Math.min(5, viewZoom * factor));
const newScale = baseScale * newZoom;
// Keep world point under cursor: screenX = PADDING + worldX * newScale + newPanX
const newPan = {
x: pointerPos.x - PADDING - worldX * newScale,
y: pointerPos.y - PADDING - worldY * newScale,
};
setViewZoom(newZoom);
setViewPan(newPan);
}, [effectiveScale, viewPan, viewZoom, baseScale]);
// ── Pan handlers ──
const handleMouseDown = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
// Middle mouse or left mouse on empty space = pan
const inPlacementMode = activeTool === 'electrical' && selectedElectricalType != null;
if (e.evt.button === 1 || (e.evt.button === 0 && !inPlacementMode)) {
// Don't pan if clicking on an interactive element (electrical/opening/furniture)
const targetName = (e.target as { name?: () => string })?.name?.() ?? '';
const isOnItem = e.target !== e.target.getStage() && targetName !== 'wall-bg';
if (e.evt.button === 0 && isOnItem) {
// Let the click fall through to item handlers
} else {
isPanningRef.current = true;
panStartRef.current = { x: e.evt.clientX, y: e.evt.clientY };
panStartOffsetRef.current = { ...viewPan };
e.evt.preventDefault();
return;
}
}
// Left click on wall-bg in placement mode: place electrical
if (e.evt.button === 0 && e.target === e.currentTarget?.getStage()?.findOne('.wall-bg')) {
// This is handled in the wall-bg rect click
}
}, [viewPan]);
const handleMouseMove = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
// Handle panning
if (isPanningRef.current) {
const dx = e.evt.clientX - panStartRef.current.x;
const dy = e.evt.clientY - panStartRef.current.y;
setViewPan({
x: panStartOffsetRef.current.x + dx,
y: panStartOffsetRef.current.y + dy,
});
return;
}
// Handle dragging
const drag = dragRef.current;
if (!drag) return;
const dx = e.evt.clientX - drag.startPixelX;
const dy = e.evt.clientY - drag.startPixelY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (!drag.exceeded && dist < DRAG_THRESHOLD) return;
if (!drag.exceeded) {
dragRef.current = { ...drag, exceeded: true };
}
const pointer = getStagePointer(e.evt);
if (!pointer) return;
const proj = pixelToProjection(pointer.x - viewPanRef.current.x, pointer.y - viewPanRef.current.y, wallHeight, effectiveScale, PADDING);
if (drag.kind === 'electrical-elevation') {
if (e.evt.ctrlKey || e.evt.metaKey) {
// Ctrl+drag: move horizontally along wall
const clampedAlongWall = Math.max(0, Math.min(wallLen, proj.alongWall));
setDragElectricalAlongWall({ itemId: drag.itemId, alongWall: clampedAlongWall });
setDragElectricalFromFloor(null);
} else {
// Normal drag: move vertically (elevation)
const clampedFromFloor = Math.max(0, Math.min(wallHeight, proj.fromFloor));
setDragElectricalFromFloor({ itemId: drag.itemId, fromFloor: clampedFromFloor });
setDragElectricalAlongWall(null);
}
} else if (drag.kind === 'opening-position') {
const opening = openings.find((o) => o.id === drag.itemId);
const halfWidth = opening ? opening.width / 2 : 0;
const clampedAlongWall = Math.max(halfWidth, Math.min(wallLen - halfWidth, proj.alongWall));
setDragOpeningAlongWall({ openingId: drag.itemId, alongWall: clampedAlongWall });
}
}, [getStagePointer, wallHeight, effectiveScale, wallLen, openings]);
const handleMouseUp = useCallback(() => {
isPanningRef.current = false;
const drag = dragRef.current;
if (!drag) return;
dragRef.current = null;
// If threshold was not exceeded, this was a click, not a drag -- do nothing extra
if (!drag.exceeded) {
setDragElectricalFromFloor(null);
setDragElectricalAlongWall(null);
setDragOpeningAlongWall(null);
return;
}
// Commit the drag
if (drag.kind === 'electrical-elevation' && onUpdateElectrical) {
const item = electricalItems.find((i) => i.id === drag.itemId);
if (item) {
if (dragElectricalAlongWall) {
// Horizontal drag: compute new x,y from alongWall position on the wall
const { start, end } = wallStartEnd(wall);
const wLen = wallLen || 1;
const t = dragElectricalAlongWall.alongWall / wLen;
onUpdateElectrical({
...item,
x: start.x + (end.x - start.x) * t,
y: start.y + (end.y - start.y) * t,
});
} else if (dragElectricalFromFloor) {
onUpdateElectrical({
...item,
elevationFromFloor: Math.round(dragElectricalFromFloor.fromFloor * 100) / 100,
});
}
}
} else if (drag.kind === 'opening-position' && dragOpeningAlongWall && onUpdateOpening) {
const opening = openings.find((o) => o.id === drag.itemId);
if (opening) {
onUpdateOpening({
...opening,
positionAlongWall: Math.round(dragOpeningAlongWall.alongWall * 100) / 100,
});
}
}
setDragElectricalFromFloor(null);
setDragElectricalAlongWall(null);
setDragOpeningAlongWall(null);
}, [dragElectricalFromFloor, dragElectricalAlongWall, dragOpeningAlongWall, electricalItems, openings, wall, wallLen, onUpdateElectrical, onUpdateOpening]);
// ── Handle click on wall background for placement ──
const handleWallBgClick = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
// Only place when electrical tool is active
if (activeTool !== 'electrical' || !selectedElectricalType || !onPlaceElectrical) return;
const pointer = getStagePointer(e.evt);
if (!pointer) return;
const proj = pixelToProjection(pointer.x - viewPanRef.current.x, pointer.y - viewPanRef.current.y, wallHeight, effectiveScale, PADDING);
// Only place within wall bounds
if (proj.alongWall < 0 || proj.alongWall > wallLen) return;
if (proj.fromFloor < 0 || proj.fromFloor > wallHeight) return;
onPlaceElectrical(wall.id, proj.alongWall, proj.fromFloor);
}, [activeTool, selectedElectricalType, onPlaceElectrical, getStagePointer, wallHeight, effectiveScale, wallLen, wall.id]);
// ── Reset zoom when wall changes ──
useEffect(() => {
setViewZoom(1);
setViewPan({ x: 0, y: 0 });
}, [wall.id]);
// ── Coordinate helpers ──
const toPixel = useCallback(
(alongWall: number, fromFloor: number) =>
projectionToPixel(alongWall, fromFloor, wallHeight, effectiveScale, PADDING),
[wallHeight, effectiveScale],
);
// Wall rectangle corners
const topLeft = toPixel(0, wallHeight);
const topRight = toPixel(wallLen, wallHeight);
const bottomLeft = toPixel(0, 0);
const bottomRight = toPixel(wallLen, 0);
const label = wallDirectionLabel(wall);
const isPlacementMode = activeTool === 'electrical' && selectedElectricalType != null;
return (
<Stage
ref={stageRef}
width={width}
height={height}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
style={{ cursor: isPlacementMode ? 'crosshair' : 'default' }}
>
<Layer>
{/* Background */}
<Rect
x={0}
y={0}
width={width}
height={height}
fill={isHighlighted ? '#eff6ff' : '#ffffff'}
/>
{/* Pan group: all content shifts with viewPan */}
<Group x={viewPan.x} y={viewPan.y}>
{/* Grid lines */}
{(() => {
const gridSize = 0.1; // 10cm grid
const gridPixels = gridSize * effectiveScale;
if (gridPixels < 6) return null; // too dense, skip
const lines: React.ReactNode[] = [];
const majorEvery = 5; // every 0.5m = bold line
// Vertical grid lines (along wall)
const startAlongWall = Math.floor(0 / gridSize) * gridSize;
const endAlongWall = Math.ceil(wallLen / gridSize) * gridSize;
for (let w = startAlongWall; w <= endAlongWall; w += gridSize) {
const px = PADDING + w * effectiveScale;
const isMajor = Math.abs(Math.round(w / gridSize) % majorEvery) < 0.01;
lines.push(
<Line
key={`gv-${w}`}
points={[px, PADDING, px, PADDING + wallHeight * effectiveScale]}
stroke={isMajor ? '#cbd5e1' : '#e2e8f0'}
strokeWidth={isMajor ? 0.8 : 0.4}
listening={false}
/>,
);
}
// Horizontal grid lines (height from floor)
const startHeight = 0;
const endHeight = Math.ceil(wallHeight / gridSize) * gridSize;
for (let h = startHeight; h <= endHeight; h += gridSize) {
const py = PADDING + (wallHeight - h) * effectiveScale;
const isMajor = Math.abs(Math.round(h / gridSize) % majorEvery) < 0.01;
lines.push(
<Line
key={`gh-${h}`}
points={[PADDING, py, PADDING + wallLen * effectiveScale, py]}
stroke={isMajor ? '#cbd5e1' : '#e2e8f0'}
strokeWidth={isMajor ? 0.8 : 0.4}
listening={false}
/>,
);
}
return <>{lines}</>;
})()}
{/* Ruler ticks along bottom (wall length) */}
{(() => {
const step = 0.5; // every 0.5m
const ticks: React.ReactNode[] = [];
for (let w = 0; w <= wallLen; w += step) {
const px = PADDING + w * effectiveScale;
const py = PADDING + wallHeight * effectiveScale;
ticks.push(
<Line
key={`rt-${w}`}
points={[px, py, px, py + 6]}
stroke="#94a3b8"
strokeWidth={0.8}
listening={false}
/>,
);
ticks.push(
<Text
key={`rl-${w}`}
x={px - 10}
y={py + 7}
width={20}
text={`${w}`}
align="center"
fontSize={8}
fill="#94a3b8"
/>,
);
}
return <>{ticks}</>;
})()}
{/* Ruler ticks along left (height from floor) */}
{(() => {
const step = 0.5;
const ticks: React.ReactNode[] = [];
for (let h = 0; h <= wallHeight; h += step) {
const py = PADDING + (wallHeight - h) * effectiveScale;
ticks.push(
<Line
key={`ht-${h}`}
points={[PADDING - 6, py, PADDING, py]}
stroke="#94a3b8"
strokeWidth={0.8}
listening={false}
/>,
);
ticks.push(
<Text
key={`hl-${h}`}
x={PADDING - 28}
y={py - 5}
width={20}
text={`${h}`}
align="right"
fontSize={8}
fill="#94a3b8"
/>,
);
}
return <>{ticks}</>;
})()}
{/* Wall rectangle fill (clickable for placement) */}
<Rect
name="wall-bg"
x={topLeft.x}
y={topLeft.y}
width={(topRight.x - topLeft.x)}
height={(bottomLeft.y - topLeft.y)}
fill={WALL_FILL}
stroke={isHighlighted ? '#2563eb' : WALL_STROKE}
strokeWidth={isHighlighted ? 2 : 1.5}
onClick={handleWallBgClick}
/>
{/* Ceiling line */}
<Line
points={[topLeft.x, topLeft.y, topRight.x, topRight.y]}
stroke={CEILING_COLOR}
strokeWidth={1}
dash={[6, 4]}
/>
{/* Floor line */}
<Line
points={[bottomLeft.x, bottomLeft.y, bottomRight.x, bottomRight.y]}
stroke={FLOOR_COLOR}
strokeWidth={2}
/>
{/* Plinth segments */}
{plinthSegments.map((seg, i) => {
const segTopLeft = toPixel(seg.x, seg.height);
const pxWidth = seg.width * effectiveScale;
const pxHeight = seg.height * effectiveScale;
return (
<Rect
key={`plinth-${i}`}
x={segTopLeft.x}
y={segTopLeft.y}
width={pxWidth}
height={pxHeight}
fill={PLINTH_COLOR}
stroke="#6b5b3a"
strokeWidth={0.5}
opacity={0.8}
/>
);
})}
{/* Doors */}
{projectedOpenings
.filter((po) => po.opening.type === 'DOOR')
.map((po) => {
const isDragging = dragOpeningAlongWall?.openingId === po.opening.id;
return (
<ProjectionDoor
key={po.opening.id}
projected={po}
wallHeight={wallHeight}
scale={effectiveScale}
padding={PADDING}
isSelected={selectedIds.has(po.opening.id)}
isDragging={isDragging}
dragAlongWall={isDragging ? dragOpeningAlongWall?.alongWall : undefined}
onClick={() => onSelectElement(po.opening.id)}
onDragStart={onUpdateOpening ? handleOpeningDragStart : undefined}
/>
);
})}
{/* Windows */}
{projectedOpenings
.filter((po) => po.opening.type === 'WINDOW')
.map((po) => {
const isDragging = dragOpeningAlongWall?.openingId === po.opening.id;
return (
<ProjectionWindow
key={po.opening.id}
projected={po}
wallHeight={wallHeight}
scale={effectiveScale}
padding={PADDING}
isSelected={selectedIds.has(po.opening.id)}
isDragging={isDragging}
dragAlongWall={isDragging ? dragOpeningAlongWall?.alongWall : undefined}
onClick={() => onSelectElement(po.opening.id)}
onDragStart={onUpdateOpening ? handleOpeningDragStart : undefined}
/>
);
})}
{/* Furniture items (rendered first so electrical overlays them) */}
{projectedFurniture.map((pf) => (
<ProjectionFurniture
key={pf.item.id}
projected={pf}
wallHeight={wallHeight}
scale={effectiveScale}
padding={PADDING}
isSelected={selectedIds.has(pf.item.id)}
onClick={() => onSelectElement(pf.item.id)}
/>
))}
{/* Electrical items (on top of furniture) */}
{projectedElectrical.map((pe) => {
const isDraggingV = dragElectricalFromFloor?.itemId === pe.item.id;
const isDraggingH = dragElectricalAlongWall?.itemId === pe.item.id;
return (
<ProjectionElectrical
key={pe.item.id}
projected={pe}
wallHeight={wallHeight}
scale={effectiveScale}
padding={PADDING}
isSelected={selectedIds.has(pe.item.id)}
isDragging={isDraggingV || isDraggingH}
dragFromFloor={isDraggingV ? dragElectricalFromFloor?.fromFloor : undefined}
dragAlongWall={isDraggingH ? dragElectricalAlongWall?.alongWall : undefined}
onClick={() => onSelectElement(pe.item.id)}
onDragStart={onUpdateElectrical ? handleElectricalDragStart : undefined}
/>
);
})}
{/* Measurements */}
{showMeasurements && (
<ProjectionMeasurements
projectedOpenings={projectedOpenings}
projectedElectrical={projectedElectrical}
wallLength={wallLen}
wallHeight={wallHeight}
scale={effectiveScale}
padding={PADDING}
/>
)}
{/* Attached annotations for items on this wall */}
{annotations
.filter((ann) => {
if (!ann.attachedToId) return false;
return projectedElectrical.some((pe) => pe.item.id === ann.attachedToId) ||
projectedFurniture.some((pf) => pf.item.id === ann.attachedToId);
})
.map((ann) => {
// Find parent item position in projection coords
const elec = projectedElectrical.find((pe) => pe.item.id === ann.attachedToId);
const furn = projectedFurniture.find((pf) => pf.item.id === ann.attachedToId);
let anchorAlongWall = 0;
let anchorFromFloor = 0;
if (elec) {
anchorAlongWall = elec.position.alongWall;
anchorFromFloor = elec.position.fromFloor;
} else if (furn) {
anchorAlongWall = furn.rect.x + furn.rect.width / 2;
anchorFromFloor = furn.rect.y + furn.rect.height;
}
const anchorPx = projectionToPixel(anchorAlongWall, anchorFromFloor, wallHeight, effectiveScale, PADDING);
// Offset annotation slightly
const textX = anchorPx.x + ann.x * effectiveScale;
const textY = anchorPx.y + ann.y * effectiveScale;
return (
<Group key={ann.id}>
<Line
points={[anchorPx.x, anchorPx.y, textX, textY]}
stroke="#94a3b8"
strokeWidth={0.5}
dash={[2, 2]}
listening={false}
/>
<Rect
x={textX - 2}
y={textY - 1}
width={ann.text.length * 7 + 4}
height={12}
fill="rgba(255,255,255,0.9)"
cornerRadius={2}
listening={false}
/>
<Text
x={textX}
y={textY}
text={ann.text}
fontSize={10}
fill={ann.color ?? '#334155'}
/>
</Group>
);
})
}
{/* Wall label (inside pan group) */}
<Text
x={PADDING}
y={8}
text={`${label} (${wallLen.toFixed(2)}m)`}
fontSize={11}
fontStyle="bold"
fill="#334155"
/>
</Group>
{/* Placement mode indicator (fixed on screen, outside pan group) */}
{isPlacementMode && (
<Text
x={PADDING}
y={height - 20}
text={t('projection.clickToPlace')}
fontSize={9}
fill="#2563eb"
opacity={0.7}
/>
)}
</Layer>
</Stage>
);
}
@@ -0,0 +1,159 @@
.panel {
border-top: 1px solid var(--color-border);
background-color: var(--color-bg-elevated);
display: flex;
flex-direction: column;
overflow: hidden;
transition: height var(--transition-normal);
}
.panelCollapsed {
height: 36px;
}
.panelExpanded {
height: 320px;
}
.panelFullView {
flex: 1;
height: 100%;
min-height: 0;
border-top: none;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-1) var(--space-3);
border-bottom: 1px solid var(--color-border);
cursor: pointer;
user-select: none;
flex-shrink: 0;
}
.header:hover {
background-color: var(--color-bg-hover);
}
.headerLeft {
display: flex;
align-items: center;
gap: var(--space-2);
}
.headerTitle {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
letter-spacing: var(--letter-spacing-wide);
text-transform: uppercase;
}
.collapseIcon {
font-size: 12px;
color: var(--color-text-muted);
transition: transform var(--transition-fast);
}
.collapseIconOpen {
transform: rotate(180deg);
}
.tabs {
display: flex;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
overflow-x: auto;
}
.tab {
padding: var(--space-1) var(--space-3);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--color-text-muted);
border: none;
border-bottom: 2px solid transparent;
background: none;
cursor: pointer;
white-space: nowrap;
font-family: var(--font-family);
transition: all var(--transition-fast);
}
.tab:hover {
color: var(--color-text-primary);
background-color: var(--color-bg-hover);
}
.tabActive {
color: var(--color-accent-700);
border-bottom-color: var(--color-accent-500);
}
.content {
flex: 1;
overflow: hidden;
display: flex;
min-height: 0;
}
.viewContainer {
flex: 1;
overflow: hidden;
position: relative;
min-height: 0;
width: 100%;
height: 100%;
}
.gridLayout {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 1px;
background-color: var(--color-border);
flex: 1;
overflow: hidden;
}
.gridCell {
background-color: var(--color-bg);
overflow: hidden;
position: relative;
}
.emptyState {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
.fullViewToolbar {
display: flex;
align-items: center;
justify-content: flex-end;
padding: var(--space-1) var(--space-3);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.layoutToggle {
padding: var(--space-1);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: none;
cursor: pointer;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
font-family: var(--font-family);
}
.layoutToggle:hover {
background-color: var(--color-bg-hover);
color: var(--color-text-primary);
}
@@ -0,0 +1,133 @@
.panel {
width: 260px;
min-width: 260px;
background-color: var(--color-bg-elevated);
border-left: 1px solid var(--color-border);
overflow-y: auto;
flex-shrink: 0;
}
.header {
padding: var(--space-3) var(--space-4);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
border-bottom: 1px solid var(--color-border);
letter-spacing: var(--letter-spacing-wide);
text-transform: uppercase;
}
.empty {
padding: var(--space-6) var(--space-4);
text-align: center;
}
.emptyText {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
margin-bottom: var(--space-1);
}
.emptyHint {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
.section {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border);
}
.sectionTitle {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: var(--letter-spacing-wide);
margin-bottom: var(--space-2);
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-1) 0;
min-height: 28px;
}
.rowLabel {
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
flex-shrink: 0;
}
.rowValue {
font-size: var(--font-size-xs);
color: var(--color-text-primary);
font-variant-numeric: tabular-nums;
text-align: right;
}
.editableValue {
font-size: var(--font-size-xs);
color: var(--color-text-primary);
font-variant-numeric: tabular-nums;
text-align: right;
background: none;
border: none;
padding: var(--space-1) var(--space-1);
border-radius: var(--radius-sm);
cursor: pointer;
font-family: var(--font-family);
transition: background var(--transition-fast);
}
.editableValue:hover {
background-color: var(--color-bg-hover);
}
.editRow {
display: flex;
align-items: center;
gap: var(--space-1);
}
.editInput {
width: 60px;
padding: 2px var(--space-1);
border: 1px solid var(--color-accent-300);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-family: var(--font-mono);
color: var(--color-text-primary);
text-align: right;
outline: none;
background-color: var(--color-bg-elevated);
}
.editInput:focus {
border-color: var(--color-accent-500);
box-shadow: 0 0 0 2px var(--color-focus-ring);
}
.editUnit {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
.selectInput {
padding: 2px var(--space-1);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-family: var(--font-family);
color: var(--color-text-primary);
background-color: var(--color-bg-elevated);
cursor: pointer;
outline: none;
}
.selectInput:focus {
border-color: var(--color-accent-500);
box-shadow: 0 0 0 2px var(--color-focus-ring);
}
@@ -0,0 +1,102 @@
/*
* The editor layout is rendered inside the AppShell's content area.
* We use a negative margin + full viewport calc to break out of the
* content padding/max-width constraints for a full-bleed editor experience.
*/
.layout {
display: flex;
flex-direction: column;
/* Fill remaining height below the AppShell header */
height: calc(100vh - var(--header-height));
/* Break out of content padding */
margin: calc(-1 * var(--space-6));
overflow: hidden;
background-color: var(--color-bg);
}
@media (min-width: 768px) {
.layout {
margin: calc(-1 * var(--space-8));
}
}
.main {
display: flex;
flex: 1;
overflow: hidden;
min-height: 0;
height: 100%;
}
.canvasArea {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
position: relative;
min-height: 0;
}
.canvasContainer {
flex: 1;
overflow: hidden;
position: relative;
min-height: 0;
width: 100%;
}
.saveError {
padding: var(--space-2) var(--space-4);
background-color: var(--color-danger-50);
border-bottom: 1px solid var(--color-danger-100);
color: var(--color-danger-700);
font-size: var(--font-size-sm);
text-align: center;
}
/* ── 3D / 2D View Toggle ── */
.viewToggle {
display: flex;
gap: 2px;
padding: 4px 8px;
background: var(--color-bg);
border-bottom: 1px solid var(--color-border);
}
.viewToggleBtn {
padding: 4px 14px;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-sm);
background: var(--color-bg-elevated);
color: var(--color-text-secondary);
cursor: pointer;
transition: all var(--transition-normal);
}
.viewToggleBtn:hover {
background: var(--color-bg-hover);
}
.viewToggleBtnActive {
background: var(--color-accent-600);
color: var(--color-text-on-accent);
border-color: var(--color-accent-700);
}
.viewToggleBtnActive:hover {
background: var(--color-accent-700);
}
.loading3D {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: var(--color-text-muted);
font-size: var(--font-size-base);
}
@@ -0,0 +1,23 @@
import { Line } from 'react-konva';
interface CableRouteSymbolProps {
/** Flat array of [x1,y1, x2,y2, ...] in screen coordinates. */
readonly points: readonly number[];
readonly color: string;
readonly isSelected: boolean;
}
/** IEC cable route: dashed line connecting elements. */
export function CableRouteSymbol({ points, color, isSelected }: CableRouteSymbolProps) {
return (
<Line
points={points as number[]}
stroke={isSelected ? '#4c6ef5' : color}
strokeWidth={isSelected ? 2.5 : 2}
dash={[8, 4]}
lineCap="round"
lineJoin="round"
listening={false}
/>
);
}
@@ -0,0 +1,23 @@
import { Group, Circle, Line } from 'react-konva';
interface CeilingLightSymbolProps {
readonly x: number;
readonly y: number;
readonly rotation: number;
readonly color: string;
readonly scale: number;
}
/** IEC ceiling light: circle with cross pattern inside. */
export function CeilingLightSymbol({ x, y, rotation, color, scale }: CeilingLightSymbolProps) {
const r = 10 * scale;
return (
<Group x={x} y={y} rotation={rotation}>
<Circle radius={r} stroke={color} strokeWidth={1.5} fill="transparent" listening={false} />
{/* Cross pattern */}
<Line points={[-r, 0, r, 0]} stroke={color} strokeWidth={1.2} listening={false} />
<Line points={[0, -r, 0, r]} stroke={color} strokeWidth={1.2} listening={false} />
</Group>
);
}
@@ -0,0 +1,33 @@
import { Group, Rect, Line } from 'react-konva';
interface JunctionBoxSymbolProps {
readonly x: number;
readonly y: number;
readonly rotation: number;
readonly color: string;
readonly scale: number;
}
/** IEC junction box: square with an X inside. */
export function JunctionBoxSymbol({ x, y, rotation, color, scale }: JunctionBoxSymbolProps) {
const size = 14 * scale;
const half = size / 2;
return (
<Group x={x} y={y} rotation={rotation}>
<Rect
x={-half}
y={-half}
width={size}
height={size}
stroke={color}
strokeWidth={1.5}
fill="transparent"
listening={false}
/>
{/* X cross */}
<Line points={[-half, -half, half, half]} stroke={color} strokeWidth={1.2} listening={false} />
<Line points={[half, -half, -half, half]} stroke={color} strokeWidth={1.2} listening={false} />
</Group>
);
}
@@ -0,0 +1,77 @@
import { Group, Circle, Line } from 'react-konva';
interface OutletSymbolProps {
readonly x: number;
readonly y: number;
readonly rotation: number;
readonly color: string;
readonly scale: number;
}
/**
* IEC 60617 outlet symbol variants.
* Base: circle with two parallel prongs.
*/
/** Single outlet: circle + two vertical prongs. */
export function SingleOutletSymbol({ x, y, rotation, color, scale }: OutletSymbolProps) {
const r = 8 * scale;
const prongLen = 4 * scale;
const prongGap = 3 * scale;
return (
<Group x={x} y={y} rotation={rotation}>
<Circle radius={r} stroke={color} strokeWidth={1.5} fill="transparent" listening={false} />
<Line points={[-prongGap, -prongLen, -prongGap, prongLen]} stroke={color} strokeWidth={1.5} listening={false} />
<Line points={[prongGap, -prongLen, prongGap, prongLen]} stroke={color} strokeWidth={1.5} listening={false} />
</Group>
);
}
/** Double outlet: two overlapping circles + prongs. */
export function DoubleOutletSymbol({ x, y, rotation, color, scale }: OutletSymbolProps) {
const r = 8 * scale;
const offset = 6 * scale;
const prongLen = 3 * scale;
const prongGap = 2.5 * scale;
return (
<Group x={x} y={y} rotation={rotation}>
{/* Left outlet */}
<Group x={-offset}>
<Circle radius={r} stroke={color} strokeWidth={1.5} fill="transparent" listening={false} />
<Line points={[-prongGap, -prongLen, -prongGap, prongLen]} stroke={color} strokeWidth={1.5} listening={false} />
<Line points={[prongGap, -prongLen, prongGap, prongLen]} stroke={color} strokeWidth={1.5} listening={false} />
</Group>
{/* Right outlet */}
<Group x={offset}>
<Circle radius={r} stroke={color} strokeWidth={1.5} fill="transparent" listening={false} />
<Line points={[-prongGap, -prongLen, -prongGap, prongLen]} stroke={color} strokeWidth={1.5} listening={false} />
<Line points={[prongGap, -prongLen, prongGap, prongLen]} stroke={color} strokeWidth={1.5} listening={false} />
</Group>
</Group>
);
}
/** Grounded outlet: circle + two prongs + earth symbol (horizontal line + ground lines below). */
export function GroundedOutletSymbol({ x, y, rotation, color, scale }: OutletSymbolProps) {
const r = 8 * scale;
const prongLen = 3 * scale;
const prongGap = 3 * scale;
const earthY = 5 * scale;
const earthW = 4 * scale;
return (
<Group x={x} y={y} rotation={rotation}>
<Circle radius={r} stroke={color} strokeWidth={1.5} fill="transparent" listening={false} />
{/* Prongs */}
<Line points={[-prongGap, -prongLen, -prongGap, prongLen - 1]} stroke={color} strokeWidth={1.5} listening={false} />
<Line points={[prongGap, -prongLen, prongGap, prongLen - 1]} stroke={color} strokeWidth={1.5} listening={false} />
{/* Earth symbol — vertical line down + three horizontal lines */}
<Line points={[0, prongLen, 0, earthY]} stroke={color} strokeWidth={1.5} listening={false} />
<Line points={[-earthW, earthY, earthW, earthY]} stroke={color} strokeWidth={1.5} listening={false} />
<Line points={[-earthW * 0.6, earthY + 2 * scale, earthW * 0.6, earthY + 2 * scale]} stroke={color} strokeWidth={1.2} listening={false} />
<Line points={[-earthW * 0.3, earthY + 4 * scale, earthW * 0.3, earthY + 4 * scale]} stroke={color} strokeWidth={1} listening={false} />
</Group>
);
}
@@ -0,0 +1,105 @@
import { Group, Circle, Line, Arc } from 'react-konva';
interface SwitchSymbolProps {
readonly x: number;
readonly y: number;
readonly rotation: number;
readonly color: string;
readonly scale: number;
}
/**
* IEC 60617 switch symbols.
* Base: circle with a line at angle (toggle arm).
*/
/** Single switch: circle with angled line. */
export function SingleSwitchSymbol({ x, y, rotation, color, scale }: SwitchSymbolProps) {
const r = 6 * scale;
const armLen = 14 * scale;
return (
<Group x={x} y={y} rotation={rotation}>
<Circle radius={r} stroke={color} strokeWidth={1.5} fill="transparent" listening={false} />
{/* Toggle arm at 45 degrees from bottom of circle */}
<Line
points={[0, r, -armLen * Math.sin(Math.PI / 4), r + armLen * Math.cos(Math.PI / 4)]}
stroke={color}
strokeWidth={1.5}
listening={false}
/>
</Group>
);
}
/** Double switch: circle with two toggle marks. */
export function DoubleSwitchSymbol({ x, y, rotation, color, scale }: SwitchSymbolProps) {
const r = 6 * scale;
const armLen = 14 * scale;
const tickLen = 3 * scale;
return (
<Group x={x} y={y} rotation={rotation}>
<Circle radius={r} stroke={color} strokeWidth={1.5} fill="transparent" listening={false} />
{/* Toggle arm */}
<Line
points={[0, r, -armLen * Math.sin(Math.PI / 4), r + armLen * Math.cos(Math.PI / 4)]}
stroke={color}
strokeWidth={1.5}
listening={false}
/>
{/* Two crossing ticks on the arm to indicate double */}
<Line
points={[
-armLen * 0.4 * Math.sin(Math.PI / 4) - tickLen * Math.cos(Math.PI / 4),
r + armLen * 0.4 * Math.cos(Math.PI / 4) - tickLen * Math.sin(Math.PI / 4),
-armLen * 0.4 * Math.sin(Math.PI / 4) + tickLen * Math.cos(Math.PI / 4),
r + armLen * 0.4 * Math.cos(Math.PI / 4) + tickLen * Math.sin(Math.PI / 4),
]}
stroke={color}
strokeWidth={1.5}
listening={false}
/>
<Line
points={[
-armLen * 0.6 * Math.sin(Math.PI / 4) - tickLen * Math.cos(Math.PI / 4),
r + armLen * 0.6 * Math.cos(Math.PI / 4) - tickLen * Math.sin(Math.PI / 4),
-armLen * 0.6 * Math.sin(Math.PI / 4) + tickLen * Math.cos(Math.PI / 4),
r + armLen * 0.6 * Math.cos(Math.PI / 4) + tickLen * Math.sin(Math.PI / 4),
]}
stroke={color}
strokeWidth={1.5}
listening={false}
/>
</Group>
);
}
/** Dimmer switch: circle with an arc symbol. */
export function DimmerSwitchSymbol({ x, y, rotation, color, scale }: SwitchSymbolProps) {
const r = 6 * scale;
const armLen = 14 * scale;
return (
<Group x={x} y={y} rotation={rotation}>
<Circle radius={r} stroke={color} strokeWidth={1.5} fill="transparent" listening={false} />
{/* Toggle arm */}
<Line
points={[0, r, -armLen * Math.sin(Math.PI / 4), r + armLen * Math.cos(Math.PI / 4)]}
stroke={color}
strokeWidth={1.5}
listening={false}
/>
{/* Dimmer arc inside circle */}
<Arc
innerRadius={0}
outerRadius={r * 0.6}
angle={180}
rotation={-90}
stroke={color}
strokeWidth={1.2}
listening={false}
/>
</Group>
);
}
@@ -0,0 +1,32 @@
import { Group, Arc, Line } from 'react-konva';
interface WallLightSymbolProps {
readonly x: number;
readonly y: number;
readonly rotation: number;
readonly color: string;
readonly scale: number;
}
/** IEC wall light: half-circle against wall (flat side toward wall). */
export function WallLightSymbol({ x, y, rotation, color, scale }: WallLightSymbolProps) {
const r = 10 * scale;
return (
<Group x={x} y={y} rotation={rotation}>
{/* Half circle — the flat side faces the wall */}
<Arc
innerRadius={0}
outerRadius={r}
angle={180}
rotation={0}
stroke={color}
strokeWidth={1.5}
fill="transparent"
listening={false}
/>
{/* Flat baseline along wall */}
<Line points={[0, -r, 0, r]} stroke={color} strokeWidth={1.5} listening={false} />
</Group>
);
}
@@ -0,0 +1,42 @@
export { SingleOutletSymbol, DoubleOutletSymbol, GroundedOutletSymbol } from './OutletSymbol';
export { SingleSwitchSymbol, DoubleSwitchSymbol, DimmerSwitchSymbol } from './SwitchSymbol';
export { JunctionBoxSymbol } from './JunctionBoxSymbol';
export { CeilingLightSymbol } from './CeilingLightSymbol';
export { WallLightSymbol } from './WallLightSymbol';
export { CableRouteSymbol } from './CableRouteSymbol';
import type { ElectricalType } from '@house-plan-maker/shared';
/** Metadata for each electrical symbol type. */
export interface ElectricalSymbolDef {
readonly type: ElectricalType;
readonly label: string;
readonly category: 'outlet' | 'switch' | 'junction' | 'light' | 'cable';
/** Whether this item should snap to a wall. */
readonly wallMounted: boolean;
/** Default metadata variant (e.g. 'single', 'double', 'grounded'). */
readonly variant?: string;
/** Light coverage radius in meters (for lights). */
readonly coverageRadius?: number;
}
export const ELECTRICAL_SYMBOL_DEFS: readonly ElectricalSymbolDef[] = [
{ type: 'OUTLET', label: 'Single Outlet', category: 'outlet', wallMounted: true, variant: 'single' },
{ type: 'OUTLET', label: 'Double Outlet', category: 'outlet', wallMounted: true, variant: 'double' },
{ type: 'OUTLET', label: 'Grounded Outlet', category: 'outlet', wallMounted: true, variant: 'grounded' },
{ type: 'SWITCH', label: 'Single Switch', category: 'switch', wallMounted: true, variant: 'single' },
{ type: 'SWITCH', label: 'Double Switch', category: 'switch', wallMounted: true, variant: 'double' },
{ type: 'SWITCH', label: 'Dimmer Switch', category: 'switch', wallMounted: true, variant: 'dimmer' },
{ type: 'JUNCTION_BOX', label: 'Junction Box', category: 'junction', wallMounted: false },
{ type: 'LIGHT_CEILING', label: 'Ceiling Light', category: 'light', wallMounted: false, coverageRadius: 2.0 },
{ type: 'LIGHT_WALL', label: 'Wall Light', category: 'light', wallMounted: true, coverageRadius: 1.5 },
{ type: 'CABLE_ROUTE', label: 'Cable Route', category: 'cable', wallMounted: false },
];
/** Get the variant from an electrical item's metadata. */
export function getElectricalVariant(metadata: Record<string, unknown> | null): string {
if (metadata && typeof metadata['variant'] === 'string') {
return metadata['variant'];
}
return 'single';
}
@@ -0,0 +1,81 @@
import { Group, Rect, Line } from 'react-konva';
interface BedSilhouetteProps {
readonly x: number;
readonly y: number;
readonly width: number;
readonly depth: number;
readonly rotation: number;
readonly color: string;
readonly fillColor: string;
}
/** Top-down bed silhouette: rectangle with pillow(s) at head end. */
export function BedSilhouette({ x, y, width, depth, rotation, color, fillColor }: BedSilhouetteProps) {
const halfW = width / 2;
const halfD = depth / 2;
const pillowH = depth * 0.12;
const pillowInset = width * 0.05;
const pillowGap = width > 1.5 ? width * 0.03 : 0;
const pillowCount = width >= 1.5 ? 2 : 1;
return (
<Group x={x} y={y} rotation={rotation}>
{/* Bed frame */}
<Rect
x={-halfW}
y={-halfD}
width={width}
height={depth}
stroke={color}
strokeWidth={1.5}
fill={fillColor}
listening={false}
/>
{/* Pillows */}
{pillowCount === 1 ? (
<Rect
x={-halfW + pillowInset}
y={-halfD + pillowInset}
width={width - pillowInset * 2}
height={pillowH}
stroke={color}
strokeWidth={1}
cornerRadius={pillowH * 0.3}
listening={false}
/>
) : (
<>
<Rect
x={-halfW + pillowInset}
y={-halfD + pillowInset}
width={(width - pillowInset * 2 - pillowGap) / 2}
height={pillowH}
stroke={color}
strokeWidth={1}
cornerRadius={pillowH * 0.3}
listening={false}
/>
<Rect
x={pillowGap / 2}
y={-halfD + pillowInset}
width={(width - pillowInset * 2 - pillowGap) / 2}
height={pillowH}
stroke={color}
strokeWidth={1}
cornerRadius={pillowH * 0.3}
listening={false}
/>
</>
)}
{/* Divider line for blanket area */}
<Line
points={[-halfW, -halfD + pillowH + pillowInset * 2, halfW, -halfD + pillowH + pillowInset * 2]}
stroke={color}
strokeWidth={0.8}
dash={[4, 3]}
listening={false}
/>
</Group>
);
}
@@ -0,0 +1,47 @@
import { Group, Rect } from 'react-konva';
interface ChairSilhouetteProps {
readonly x: number;
readonly y: number;
readonly width: number;
readonly depth: number;
readonly rotation: number;
readonly color: string;
readonly fillColor: string;
}
/** Top-down chair silhouette: seat square + backrest line. */
export function ChairSilhouette({ x, y, width, depth, rotation, color, fillColor }: ChairSilhouetteProps) {
const halfW = width / 2;
const halfD = depth / 2;
const backD = depth * 0.15;
return (
<Group x={x} y={y} rotation={rotation}>
{/* Seat */}
<Rect
x={-halfW}
y={-halfD + backD}
width={width}
height={depth - backD}
stroke={color}
strokeWidth={1.5}
fill={fillColor}
cornerRadius={width * 0.05}
listening={false}
/>
{/* Backrest */}
<Rect
x={-halfW}
y={-halfD}
width={width}
height={backD}
stroke={color}
strokeWidth={1.5}
fill={color}
cornerRadius={width * 0.05}
listening={false}
/>
</Group>
);
}
@@ -0,0 +1,48 @@
import { Group, Rect, Line } from 'react-konva';
interface DeskSilhouetteProps {
readonly x: number;
readonly y: number;
readonly width: number;
readonly depth: number;
readonly rotation: number;
readonly color: string;
readonly fillColor: string;
}
/** Top-down desk silhouette: rectangle with chair indent area. */
export function DeskSilhouette({ x, y, width, depth, rotation, color, fillColor }: DeskSilhouetteProps) {
const halfW = width / 2;
const halfD = depth / 2;
return (
<Group x={x} y={y} rotation={rotation}>
{/* Desk surface */}
<Rect
x={-halfW}
y={-halfD}
width={width}
height={depth}
stroke={color}
strokeWidth={1.5}
fill={fillColor}
listening={false}
/>
{/* Drawer line */}
<Line
points={[-halfW + width * 0.6, -halfD + depth * 0.15, -halfW + width * 0.6, halfD - depth * 0.15]}
stroke={color}
strokeWidth={0.8}
listening={false}
/>
{/* Drawer handle dot */}
<Line
points={[-halfW + width * 0.65, 0, -halfW + width * 0.68, 0]}
stroke={color}
strokeWidth={2}
lineCap="round"
listening={false}
/>
</Group>
);
}
@@ -0,0 +1,48 @@
import { Group, Rect, Line } from 'react-konva';
interface ShelfSilhouetteProps {
readonly x: number;
readonly y: number;
readonly width: number;
readonly depth: number;
readonly rotation: number;
readonly color: string;
readonly fillColor: string;
}
/** Top-down shelf/bookcase silhouette: rectangle with horizontal shelf lines. */
export function ShelfSilhouette({ x, y, width, depth, rotation, color, fillColor }: ShelfSilhouetteProps) {
const halfW = width / 2;
const halfD = depth / 2;
const shelfCount = 3;
const shelfLines: number[] = [];
for (let i = 1; i <= shelfCount; i++) {
const yPos = -halfD + (depth / (shelfCount + 1)) * i;
shelfLines.push(yPos);
}
return (
<Group x={x} y={y} rotation={rotation}>
<Rect
x={-halfW}
y={-halfD}
width={width}
height={depth}
stroke={color}
strokeWidth={1.5}
fill={fillColor}
listening={false}
/>
{shelfLines.map((sy, idx) => (
<Line
key={idx}
points={[-halfW, sy, halfW, sy]}
stroke={color}
strokeWidth={0.8}
listening={false}
/>
))}
</Group>
);
}
@@ -0,0 +1,65 @@
import { Group, Rect } from 'react-konva';
interface SofaSilhouetteProps {
readonly x: number;
readonly y: number;
readonly width: number;
readonly depth: number;
readonly rotation: number;
readonly color: string;
readonly fillColor: string;
}
/** Top-down sofa silhouette: rectangle with backrest and armrests. */
export function SofaSilhouette({ x, y, width, depth, rotation, color, fillColor }: SofaSilhouetteProps) {
const halfW = width / 2;
const halfD = depth / 2;
const armW = depth * 0.2;
const backD = depth * 0.22;
return (
<Group x={x} y={y} rotation={rotation}>
{/* Main sofa body */}
<Rect
x={-halfW}
y={-halfD}
width={width}
height={depth}
stroke={color}
strokeWidth={1.5}
fill={fillColor}
listening={false}
/>
{/* Backrest */}
<Rect
x={-halfW}
y={-halfD}
width={width}
height={backD}
stroke={color}
strokeWidth={0.8}
listening={false}
/>
{/* Left armrest */}
<Rect
x={-halfW}
y={-halfD + backD}
width={armW}
height={depth - backD}
stroke={color}
strokeWidth={0.8}
listening={false}
/>
{/* Right armrest */}
<Rect
x={halfW - armW}
y={-halfD + backD}
width={armW}
height={depth - backD}
stroke={color}
strokeWidth={0.8}
listening={false}
/>
</Group>
);
}
@@ -0,0 +1,40 @@
import { Group, Rect, Circle } from 'react-konva';
interface TableSilhouetteProps {
readonly x: number;
readonly y: number;
readonly width: number;
readonly depth: number;
readonly rotation: number;
readonly color: string;
readonly fillColor: string;
}
/** Top-down dining table silhouette: rectangle with rounded corners + leg dots. */
export function TableSilhouette({ x, y, width, depth, rotation, color, fillColor }: TableSilhouetteProps) {
const halfW = width / 2;
const halfD = depth / 2;
const legInset = Math.min(width, depth) * 0.1;
const legR = Math.min(width, depth) * 0.03;
return (
<Group x={x} y={y} rotation={rotation}>
<Rect
x={-halfW}
y={-halfD}
width={width}
height={depth}
stroke={color}
strokeWidth={1.5}
fill={fillColor}
cornerRadius={Math.min(width, depth) * 0.05}
listening={false}
/>
{/* Four leg dots */}
<Circle x={-halfW + legInset} y={-halfD + legInset} radius={legR} fill={color} listening={false} />
<Circle x={halfW - legInset} y={-halfD + legInset} radius={legR} fill={color} listening={false} />
<Circle x={-halfW + legInset} y={halfD - legInset} radius={legR} fill={color} listening={false} />
<Circle x={halfW - legInset} y={halfD - legInset} radius={legR} fill={color} listening={false} />
</Group>
);
}
@@ -0,0 +1,47 @@
import { Group, Rect, Line } from 'react-konva';
interface TvSilhouetteProps {
readonly x: number;
readonly y: number;
readonly width: number;
readonly depth: number;
readonly rotation: number;
readonly color: string;
readonly fillColor: string;
}
/** Top-down TV silhouette: thin rectangle with screen indicator. */
export function TvSilhouette({ x, y, width, depth, rotation, color, fillColor }: TvSilhouetteProps) {
const halfW = width / 2;
const halfD = depth / 2;
return (
<Group x={x} y={y} rotation={rotation}>
{/* TV body */}
<Rect
x={-halfW}
y={-halfD}
width={width}
height={depth}
stroke={color}
strokeWidth={1.5}
fill={fillColor}
listening={false}
/>
{/* Screen line */}
<Line
points={[-halfW + width * 0.05, -halfD * 0.5, halfW - width * 0.05, -halfD * 0.5]}
stroke={color}
strokeWidth={1}
listening={false}
/>
{/* Stand center mark */}
<Line
points={[0, halfD * 0.3, 0, halfD]}
stroke={color}
strokeWidth={1.5}
listening={false}
/>
</Group>
);
}
@@ -0,0 +1,54 @@
import { Group, Rect, Line } from 'react-konva';
interface WardrobeSilhouetteProps {
readonly x: number;
readonly y: number;
readonly width: number;
readonly depth: number;
readonly rotation: number;
readonly color: string;
readonly fillColor: string;
}
/** Top-down wardrobe silhouette: rectangle with center door line and handles. */
export function WardrobeSilhouette({ x, y, width, depth, rotation, color, fillColor }: WardrobeSilhouetteProps) {
const halfW = width / 2;
const halfD = depth / 2;
return (
<Group x={x} y={y} rotation={rotation}>
<Rect
x={-halfW}
y={-halfD}
width={width}
height={depth}
stroke={color}
strokeWidth={1.5}
fill={fillColor}
listening={false}
/>
{/* Center divider (door split) */}
<Line
points={[0, -halfD, 0, halfD]}
stroke={color}
strokeWidth={0.8}
listening={false}
/>
{/* Door handles */}
<Line
points={[-width * 0.05, -depth * 0.1, -width * 0.05, depth * 0.1]}
stroke={color}
strokeWidth={2}
lineCap="round"
listening={false}
/>
<Line
points={[width * 0.05, -depth * 0.1, width * 0.05, depth * 0.1]}
stroke={color}
strokeWidth={2}
lineCap="round"
listening={false}
/>
</Group>
);
}
@@ -0,0 +1,46 @@
export { BedSilhouette } from './BedSilhouette';
export { DeskSilhouette } from './DeskSilhouette';
export { WardrobeSilhouette } from './WardrobeSilhouette';
export { SofaSilhouette } from './SofaSilhouette';
export { TableSilhouette } from './TableSilhouette';
export { ChairSilhouette } from './ChairSilhouette';
export { ShelfSilhouette } from './ShelfSilhouette';
export { TvSilhouette } from './TvSilhouette';
import type { FurnitureType } from '@house-plan-maker/shared';
/** Default dimensions for each furniture type (width x depth x height in meters). */
export interface FurnitureDef {
readonly type: FurnitureType;
readonly label: string;
readonly width: number;
readonly depth: number;
readonly height: number;
readonly icon: string;
}
export const FURNITURE_DEFS: readonly FurnitureDef[] = [
{ type: 'BED', label: 'Single Bed', width: 1.0, depth: 2.0, height: 0.5, icon: '\u{1F6CF}' },
{ type: 'BED', label: 'Double Bed', width: 1.4, depth: 2.0, height: 0.5, icon: '\u{1F6CF}' },
{ type: 'BED', label: 'Queen Bed', width: 1.6, depth: 2.0, height: 0.5, icon: '\u{1F6CF}' },
{ type: 'BED', label: 'King Bed', width: 1.8, depth: 2.0, height: 0.5, icon: '\u{1F6CF}' },
{ type: 'DESK', label: 'Desk', width: 1.2, depth: 0.6, height: 0.75, icon: '\u{1F4BC}' },
{ type: 'WARDROBE', label: 'Wardrobe (S)', width: 1.0, depth: 0.6, height: 2.0, icon: '\u{1F3EA}' },
{ type: 'WARDROBE', label: 'Wardrobe (M)', width: 1.5, depth: 0.6, height: 2.0, icon: '\u{1F3EA}' },
{ type: 'WARDROBE', label: 'Wardrobe (L)', width: 2.0, depth: 0.6, height: 2.0, icon: '\u{1F3EA}' },
{ type: 'SOFA', label: 'Sofa', width: 2.0, depth: 0.9, height: 0.8, icon: '\u{1FA91}' },
{ type: 'TABLE', label: 'Dining Table', width: 1.2, depth: 0.8, height: 0.75, icon: '\u{1F37D}' },
{ type: 'CHAIR', label: 'Chair', width: 0.45, depth: 0.45, height: 0.85, icon: '\u{1FA91}' },
{ type: 'SHELF', label: 'Tall Shelf', width: 0.8, depth: 0.3, height: 1.8, icon: '\u{1F4DA}' },
{ type: 'SHELF', label: 'Wall Shelf 60', width: 0.6, depth: 0.25, height: 0.04, icon: '\u{1F4DA}' },
{ type: 'SHELF', label: 'Wall Shelf 80', width: 0.8, depth: 0.25, height: 0.04, icon: '\u{1F4DA}' },
{ type: 'SHELF', label: 'Wall Shelf 120', width: 1.2, depth: 0.25, height: 0.04, icon: '\u{1F4DA}' },
{ type: 'NIGHTSTAND', label: 'Nightstand', width: 0.5, depth: 0.4, height: 0.5, icon: '\u{1F4E6}' },
{ type: 'DRESSER', label: 'Dresser', width: 1.0, depth: 0.5, height: 0.8, icon: '\u{1F3EA}' },
{ type: 'BOOKCASE', label: 'Bookcase', width: 0.8, depth: 0.3, height: 2.0, icon: '\u{1F4DA}' },
{ type: 'TV', label: 'TV 32"', width: 0.73, depth: 0.08, height: 0.43, icon: '\u{1F4FA}' },
{ type: 'TV', label: 'TV 43"', width: 0.97, depth: 0.08, height: 0.57, icon: '\u{1F4FA}' },
{ type: 'TV', label: 'TV 55"', width: 1.24, depth: 0.08, height: 0.72, icon: '\u{1F4FA}' },
{ type: 'TV', label: 'TV 65"', width: 1.46, depth: 0.08, height: 0.84, icon: '\u{1F4FA}' },
{ type: 'AC_UNIT', label: 'AC Unit', width: 0.85, depth: 0.2, height: 0.3, icon: '\u{2744}' },
];
@@ -0,0 +1,110 @@
import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Modal } from '../../ui/Modal';
import { ROOM_TEMPLATES, templateToCreateRoomDto } from './roomTemplates';
import type { RoomTemplate } from './roomTemplates';
import type { CreateRoomDto } from '@house-plan-maker/shared';
import styles from './template-picker.module.css';
const TEMPLATE_NAME_KEYS: Record<string, string> = {
bedroom: 'templates.bedroom',
kitchen: 'templates.kitchen',
bathroom: 'templates.bathroom',
'living-room': 'templates.livingRoom',
office: 'templates.office',
empty: 'templates.emptyRoom',
};
const TEMPLATE_DESC_KEYS: Record<string, string> = {
bedroom: 'templates.bedroomDesc',
kitchen: 'templates.kitchenDesc',
bathroom: 'templates.bathroomDesc',
'living-room': 'templates.livingRoomDesc',
office: 'templates.officeDesc',
empty: 'templates.emptyRoomDesc',
};
interface TemplatePickerProps {
readonly open: boolean;
readonly onClose: () => void;
readonly onCreateRoom: (dto: CreateRoomDto) => void;
readonly roomCount: number;
}
export function TemplatePicker({ open, onClose, onCreateRoom, roomCount }: TemplatePickerProps) {
const { t } = useTranslation();
const [selectedTemplate, setSelectedTemplate] = useState<RoomTemplate>(ROOM_TEMPLATES[0]);
const [roomName, setRoomName] = useState('');
const [isCreating, setIsCreating] = useState(false);
const handleCreate = useCallback(() => {
setIsCreating(true);
const name = roomName.trim() || selectedTemplate.name;
const dto = templateToCreateRoomDto(selectedTemplate, name, roomCount);
onCreateRoom(dto);
setIsCreating(false);
setRoomName('');
onClose();
}, [selectedTemplate, roomName, roomCount, onCreateRoom, onClose]);
const footer = (
<div className={styles.footerButtons}>
<button className={styles.cancelBtn} onClick={onClose} type="button">
{t('common.cancel')}
</button>
<button
className={styles.createBtn}
onClick={handleCreate}
disabled={isCreating}
type="button"
>
{t('templates.create')}
</button>
</div>
);
return (
<Modal open={open} onClose={onClose} title={t('templates.title')} footer={footer}>
<div className={styles.content}>
{/* Template grid */}
<div className={styles.grid}>
{ROOM_TEMPLATES.map((template) => (
<button
key={template.id}
className={`${styles.templateCard} ${
selectedTemplate.id === template.id ? styles.templateCardSelected : ''
}`}
onClick={() => setSelectedTemplate(template)}
type="button"
>
<span className={styles.templateIcon}>{template.icon}</span>
<span className={styles.templateName}>
{TEMPLATE_NAME_KEYS[template.id] ? t(TEMPLATE_NAME_KEYS[template.id]) : template.name}
</span>
<span className={styles.templateDesc}>
{TEMPLATE_DESC_KEYS[template.id] ? t(TEMPLATE_DESC_KEYS[template.id]) : template.description}
</span>
</button>
))}
</div>
{/* Room name input */}
<div>
<div className={styles.fieldLabel}>{t('templates.roomName')}</div>
<input
className={styles.nameInput}
type="text"
placeholder={selectedTemplate.name}
value={roomName}
onChange={(e) => setRoomName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleCreate();
}
}}
/>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest';
import { ROOM_TEMPLATES, templateToCreateRoomDto } from '../roomTemplates';
describe('ROOM_TEMPLATES', () => {
it('has at least 5 templates', () => {
expect(ROOM_TEMPLATES.length).toBeGreaterThanOrEqual(5);
});
it('all templates have required fields', () => {
for (const template of ROOM_TEMPLATES) {
expect(template.id).toBeTruthy();
expect(template.name).toBeTruthy();
expect(template.description).toBeTruthy();
expect(template.defaultWidth).toBeGreaterThan(0);
expect(template.defaultHeight).toBeGreaterThan(0);
expect(template.wallHeight).toBeGreaterThan(0);
expect(template.shape.length).toBeGreaterThanOrEqual(3);
}
});
it('has unique template ids', () => {
const ids = ROOM_TEMPLATES.map((t) => t.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('shapes form closed polygons with correct dimensions', () => {
for (const template of ROOM_TEMPLATES) {
const xs = template.shape.map((p) => p.x);
const ys = template.shape.map((p) => p.y);
const width = Math.max(...xs) - Math.min(...xs);
const height = Math.max(...ys) - Math.min(...ys);
expect(width).toBeCloseTo(template.defaultWidth);
expect(height).toBeCloseTo(template.defaultHeight);
}
});
});
describe('templateToCreateRoomDto', () => {
it('uses template name by default', () => {
const dto = templateToCreateRoomDto(ROOM_TEMPLATES[0]);
expect(dto.name).toBe(ROOM_TEMPLATES[0].name);
});
it('uses custom name when provided', () => {
const dto = templateToCreateRoomDto(ROOM_TEMPLATES[0], 'Custom Name');
expect(dto.name).toBe('Custom Name');
});
it('includes shape from template', () => {
const template = ROOM_TEMPLATES[0];
const dto = templateToCreateRoomDto(template);
expect(dto.shape).toHaveLength(template.shape.length);
expect(dto.width).toBe(template.defaultWidth);
expect(dto.height).toBe(template.defaultHeight);
expect(dto.wallHeight).toBe(template.wallHeight);
});
it('uses custom order', () => {
const dto = templateToCreateRoomDto(ROOM_TEMPLATES[0], undefined, 5);
expect(dto.order).toBe(5);
});
it('defaults order to 0', () => {
const dto = templateToCreateRoomDto(ROOM_TEMPLATES[0]);
expect(dto.order).toBe(0);
});
});
@@ -0,0 +1,118 @@
import type { Point, CreateRoomDto } from '@house-plan-maker/shared';
export interface RoomTemplate {
readonly id: string;
readonly name: string;
readonly description: string;
readonly icon: string;
readonly defaultWidth: number;
readonly defaultHeight: number;
readonly wallHeight: number;
readonly shape: readonly Point[];
/** Suggested door positions (positionAlongWall on wall index) */
readonly suggestedDoors: readonly { readonly wallIndex: number; readonly position: number }[];
/** Suggested window positions */
readonly suggestedWindows: readonly { readonly wallIndex: number; readonly position: number }[];
}
function rectangularShape(width: number, height: number): readonly Point[] {
return [
{ x: 0, y: 0 },
{ x: width, y: 0 },
{ x: width, y: height },
{ x: 0, y: height },
];
}
export const ROOM_TEMPLATES: readonly RoomTemplate[] = [
{
id: 'bedroom',
name: 'Bedroom',
description: 'Standard bedroom (4m x 3.5m) with door and window',
icon: '\u{1F6CF}',
defaultWidth: 4,
defaultHeight: 3.5,
wallHeight: 2.7,
shape: rectangularShape(4, 3.5),
suggestedDoors: [{ wallIndex: 3, position: 1 }],
suggestedWindows: [{ wallIndex: 1, position: 2 }],
},
{
id: 'kitchen',
name: 'Kitchen',
description: 'Kitchen (3.5m x 3m) with door',
icon: '\u{1F373}',
defaultWidth: 3.5,
defaultHeight: 3,
wallHeight: 2.7,
shape: rectangularShape(3.5, 3),
suggestedDoors: [{ wallIndex: 3, position: 1.5 }],
suggestedWindows: [{ wallIndex: 1, position: 1.5 }],
},
{
id: 'bathroom',
name: 'Bathroom',
description: 'Bathroom (2.5m x 2m)',
icon: '\u{1F6C1}',
defaultWidth: 2.5,
defaultHeight: 2,
wallHeight: 2.7,
shape: rectangularShape(2.5, 2),
suggestedDoors: [{ wallIndex: 3, position: 1 }],
suggestedWindows: [],
},
{
id: 'living-room',
name: 'Living Room',
description: 'Spacious living room (5m x 4m) with window',
icon: '\u{1F6CB}',
defaultWidth: 5,
defaultHeight: 4,
wallHeight: 2.7,
shape: rectangularShape(5, 4),
suggestedDoors: [{ wallIndex: 3, position: 1 }],
suggestedWindows: [{ wallIndex: 1, position: 2 }, { wallIndex: 2, position: 2.5 }],
},
{
id: 'office',
name: 'Office',
description: 'Home office (3m x 2.5m)',
icon: '\u{1F4BB}',
defaultWidth: 3,
defaultHeight: 2.5,
wallHeight: 2.7,
shape: rectangularShape(3, 2.5),
suggestedDoors: [{ wallIndex: 3, position: 1.25 }],
suggestedWindows: [{ wallIndex: 1, position: 1.25 }],
},
{
id: 'empty',
name: 'Empty Room',
description: 'Custom empty room (3m x 3m)',
icon: '\u25A1',
defaultWidth: 3,
defaultHeight: 3,
wallHeight: 2.7,
shape: rectangularShape(3, 3),
suggestedDoors: [],
suggestedWindows: [],
},
];
/**
* Convert a room template into a CreateRoomDto.
*/
export function templateToCreateRoomDto(
template: RoomTemplate,
name?: string,
order?: number,
): CreateRoomDto {
return {
name: name ?? template.name,
shape: [...template.shape],
width: template.defaultWidth,
height: template.defaultHeight,
wallHeight: template.wallHeight,
order: order ?? 0,
};
}
@@ -0,0 +1,121 @@
.content {
display: flex;
flex-direction: column;
gap: var(--space-4);
min-width: 420px;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-3);
}
.templateCard {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
padding: var(--space-3);
border: 2px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-bg);
cursor: pointer;
transition: all var(--transition-fast);
font-family: var(--font-family);
}
.templateCard:hover {
border-color: var(--color-accent-300);
background-color: var(--color-accent-50);
}
.templateCardSelected {
border-color: var(--color-accent-500);
background-color: var(--color-accent-50);
box-shadow: 0 0 0 2px var(--color-accent-200);
}
.templateIcon {
font-size: 28px;
line-height: 1;
}
.templateName {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
}
.templateDesc {
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
text-align: center;
}
.nameInput {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
font-family: var(--font-family);
background: var(--color-bg);
color: var(--color-text-primary);
}
.nameInput:focus {
outline: 2px solid var(--color-accent-400);
outline-offset: -1px;
}
.fieldLabel {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
margin-bottom: var(--space-1);
}
.footerButtons {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
}
.cancelBtn {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: none;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
cursor: pointer;
font-family: var(--font-family);
}
.cancelBtn:hover {
background-color: var(--color-bg-hover);
}
.createBtn {
padding: var(--space-2) var(--space-4);
border: none;
border-radius: var(--radius-md);
background-color: var(--color-accent-600);
color: var(--color-text-on-accent);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
cursor: pointer;
font-family: var(--font-family);
transition: background var(--transition-fast);
}
.createBtn:hover:not(:disabled) {
background-color: var(--color-accent-700);
}
.createBtn:disabled {
opacity: 0.6;
cursor: default;
}
@@ -0,0 +1,161 @@
import { useMemo, useRef } from 'react';
import { useThree } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';
import type { Point } from '@house-plan-maker/shared';
import { boundingBox } from '../utils/geometry';
interface CameraControlsProps {
readonly shape: readonly Point[];
readonly wallHeight: number;
}
export type CameraPreset = 'birds-eye' | 'eye-level' | 'corner-ne' | 'corner-nw' | 'corner-se' | 'corner-sw';
interface PresetConfig {
readonly position: [number, number, number];
readonly target: [number, number, number];
}
function computePresets(
shape: readonly Point[],
wallHeight: number,
): Record<CameraPreset, PresetConfig> {
const bb = boundingBox(shape);
const centerX = (bb.minX + bb.maxX) / 2;
const centerZ = (bb.minY + bb.maxY) / 2;
const centerY = wallHeight / 2;
const sizeX = bb.maxX - bb.minX;
const sizeZ = bb.maxY - bb.minY;
const maxSize = Math.max(sizeX, sizeZ, 2);
const dist = maxSize * 1.5;
const target: [number, number, number] = [centerX, centerY, centerZ];
const floorTarget: [number, number, number] = [centerX, 0, centerZ];
return {
'birds-eye': {
position: [centerX, dist * 1.5, centerZ + 0.01],
target: floorTarget,
},
'eye-level': {
position: [centerX - dist, 1.6, centerZ],
target: [centerX, 1.6, centerZ],
},
'corner-ne': {
position: [bb.maxX + dist * 0.6, wallHeight + dist * 0.4, bb.minY - dist * 0.6],
target,
},
'corner-nw': {
position: [bb.minX - dist * 0.6, wallHeight + dist * 0.4, bb.minY - dist * 0.6],
target,
},
'corner-se': {
position: [bb.maxX + dist * 0.6, wallHeight + dist * 0.4, bb.maxY + dist * 0.6],
target,
},
'corner-sw': {
position: [bb.minX - dist * 0.6, wallHeight + dist * 0.4, bb.maxY + dist * 0.6],
target,
},
};
}
export function CameraPresetsUI({
shape: _shape,
wallHeight: _wallHeight,
onPreset,
}: {
readonly shape: readonly Point[];
readonly wallHeight: number;
readonly onPreset: (preset: CameraPreset) => void;
}) {
const presetLabels: readonly { key: CameraPreset; label: string }[] = [
{ key: 'birds-eye', label: 'Bird\'s Eye' },
{ key: 'eye-level', label: 'Eye Level' },
{ key: 'corner-ne', label: 'NE Corner' },
{ key: 'corner-nw', label: 'NW Corner' },
{ key: 'corner-se', label: 'SE Corner' },
{ key: 'corner-sw', label: 'SW Corner' },
];
return (
<div style={{
position: 'absolute',
top: 8,
right: 8,
display: 'flex',
flexDirection: 'column',
gap: 4,
zIndex: 10,
}}>
{presetLabels.map(({ key, label }) => (
<button
key={key}
onClick={() => onPreset(key)}
style={{
padding: '4px 10px',
fontSize: '12px',
background: '#fff',
border: '1px solid #ccc',
borderRadius: 4,
cursor: 'pointer',
whiteSpace: 'nowrap',
}}
>
{label}
</button>
))}
</div>
);
}
export function SceneCamera({ shape, wallHeight, activePreset }: CameraControlsProps & { readonly activePreset: CameraPreset | null }) {
const { camera } = useThree();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const controlsRef = useRef<any>(null);
const presets = useMemo(
() => computePresets(shape, wallHeight),
[shape, wallHeight],
);
// Apply preset when it changes
const lastApplied = useRef<CameraPreset | null>(null);
if (activePreset && activePreset !== lastApplied.current) {
lastApplied.current = activePreset;
const preset = presets[activePreset];
if (preset) {
camera.position.set(...preset.position);
camera.lookAt(...preset.target);
if (controlsRef.current) {
controlsRef.current.target.set(...preset.target);
controlsRef.current.update();
}
}
}
// Set camera to bird's eye on every mount
const initialized = useRef(false);
if (!initialized.current && !activePreset) {
initialized.current = true;
const defaultPreset = presets['birds-eye'];
camera.position.set(...defaultPreset.position);
camera.lookAt(...defaultPreset.target);
camera.updateProjectionMatrix();
}
return (
<OrbitControls
ref={controlsRef}
makeDefault
enableDamping
dampingFactor={0.1}
minDistance={0.5}
maxDistance={50}
maxPolarAngle={Math.PI * 0.9}
target={presets['birds-eye'].target}
/>
);
}
@@ -0,0 +1,87 @@
import { useMemo } from 'react';
import type { Wall, WallOpening } from '@house-plan-maker/shared';
import {
wallRotationY,
positionAlongWall3D,
} from './utils/wallGeometry';
interface DoorOpeningProps {
readonly opening: WallOpening;
readonly wall: Wall;
readonly isSelected: boolean;
readonly onSelect?: (id: string) => void;
}
const FRAME_COLOR = '#8b7355';
const DOOR_PANEL_COLOR = '#a0522d';
const FRAME_THICKNESS = 0.03;
const DOOR_PANEL_THICKNESS = 0.04;
/** Angle (radians) the door panel is shown ajar, to indicate swing direction. */
const DOOR_AJAR_ANGLE = Math.PI / 6; // 30 degrees
export function DoorOpening({ opening, wall, isSelected, onSelect }: DoorOpeningProps) {
const rotY = useMemo(() => wallRotationY(wall), [wall]);
const [cx, cz] = useMemo(
() => positionAlongWall3D(wall, opening.positionAlongWall),
[wall, opening.positionAlongWall],
);
const frameColor = isSelected ? '#6fa8dc' : FRAME_COLOR;
const halfWidth = opening.width / 2;
const halfThick = wall.thickness / 2 + 0.005;
// Door panel rotation based on open direction
const openDir = opening.openDirection ?? 'LEFT';
const isRight = openDir === 'RIGHT';
const isInward = openDir === 'INWARD';
// Hinge position along the X axis (local frame coordinates)
const hingeX = isRight ? halfWidth : -halfWidth;
// Swing angle sign: inward swings in +Z, others swing in -Z
const swingSign = isInward ? 1 : -1;
const panelRotY = swingSign * DOOR_AJAR_ANGLE * (isRight ? -1 : 1);
// Panel center offset from hinge (half the door width along local X after rotation)
const panelHalfW = opening.width / 2;
const panelOffsetX = isRight
? -panelHalfW * Math.cos(DOOR_AJAR_ANGLE)
: panelHalfW * Math.cos(DOOR_AJAR_ANGLE);
const panelOffsetZ = swingSign * panelHalfW * Math.sin(DOOR_AJAR_ANGLE);
return (
<group
position={[cx, opening.elevationFromFloor + opening.height / 2, cz]}
rotation={[0, rotY, 0]}
onClick={onSelect ? (e) => { e.stopPropagation(); onSelect(opening.id); } : undefined}
>
{/* Left frame post */}
<mesh position={[-halfWidth, 0, 0]} castShadow>
<boxGeometry args={[FRAME_THICKNESS, opening.height, halfThick * 2]} />
<meshStandardMaterial color={frameColor} roughness={0.5} />
</mesh>
{/* Right frame post */}
<mesh position={[halfWidth, 0, 0]} castShadow>
<boxGeometry args={[FRAME_THICKNESS, opening.height, halfThick * 2]} />
<meshStandardMaterial color={frameColor} roughness={0.5} />
</mesh>
{/* Top frame bar (lintel) */}
<mesh position={[0, opening.height / 2, 0]} castShadow>
<boxGeometry args={[opening.width + FRAME_THICKNESS, FRAME_THICKNESS, halfThick * 2]} />
<meshStandardMaterial color={frameColor} roughness={0.5} />
</mesh>
{/* Door panel (shown slightly ajar to indicate swing direction) */}
<mesh
position={[hingeX + panelOffsetX, 0, panelOffsetZ]}
rotation={[0, panelRotY, 0]}
castShadow
>
<boxGeometry args={[opening.width, opening.height - FRAME_THICKNESS, DOOR_PANEL_THICKNESS]} />
<meshStandardMaterial color={DOOR_PANEL_COLOR} roughness={0.6} transparent opacity={0.85} />
</mesh>
</group>
);
}
@@ -0,0 +1,214 @@
import { useMemo } from 'react';
import * as THREE from 'three';
import type { ElectricalItem, ElectricalType, Wall } from '@house-plan-maker/shared';
import { wallRotationY, positionAlongWall3D, wallVector, wallNormal } from './utils/wallGeometry';
interface ElectricalMeshWithHeightProps {
readonly item: ElectricalItem;
readonly wallMap: ReadonlyMap<string, Wall>;
readonly wallHeight: number;
readonly isSelected: boolean;
readonly onSelect?: (id: string) => void;
}
const ELECTRICAL_COLORS: Record<ElectricalType, string> = {
OUTLET: '#f5f5f0',
SWITCH: '#f5f5f0',
JUNCTION_BOX: '#808080',
LIGHT_CEILING: '#fff8dc',
LIGHT_WALL: '#fff8dc',
CABLE_ROUTE: '#ff6b35',
};
const SELECTED_COLOR = '#6fa8dc';
function degToRad(degrees: number): number {
return (degrees * Math.PI) / 180;
}
/** Find the wall that this electrical item is attached to via O(1) map lookup. */
function findWallInMap(wallId: string | null, wallMap: ReadonlyMap<string, Wall>): Wall | null {
if (!wallId) return null;
return wallMap.get(wallId) ?? null;
}
/** Outlet: small rectangular box on wall */
function OutletMesh({ color }: { readonly color: string }) {
return (
<mesh castShadow>
<boxGeometry args={[0.08, 0.08, 0.02]} />
<meshStandardMaterial color={color} roughness={0.3} />
</mesh>
);
}
/** Switch: slightly taller rectangular box on wall */
function SwitchMesh({ color }: { readonly color: string }) {
return (
<group>
<mesh castShadow>
<boxGeometry args={[0.06, 0.10, 0.02]} />
<meshStandardMaterial color={color} roughness={0.3} />
</mesh>
{/* Toggle indicator */}
<mesh position={[0, 0.01, 0.011]} castShadow>
<boxGeometry args={[0.02, 0.04, 0.005]} />
<meshStandardMaterial color="#cccccc" roughness={0.3} />
</mesh>
</group>
);
}
/** Junction box: small gray box */
function JunctionBoxMesh({ color }: { readonly color: string }) {
return (
<mesh castShadow>
<boxGeometry args={[0.10, 0.10, 0.05]} />
<meshStandardMaterial color={color} roughness={0.5} />
</mesh>
);
}
/** Ceiling light: disc or sphere hanging from ceiling */
function CeilingLightMesh({ color, wallHeight }: { readonly color: string; readonly wallHeight: number }) {
return (
<group position={[0, wallHeight - 0.05, 0]}>
{/* Canopy */}
<mesh castShadow>
<cylinderGeometry args={[0.05, 0.05, 0.02, 16]} />
<meshStandardMaterial color="#666666" roughness={0.4} />
</mesh>
{/* Shade / bulb */}
<mesh position={[0, -0.12, 0]}>
<sphereGeometry args={[0.08, 16, 16]} />
<meshStandardMaterial
color={color}
emissive={color}
emissiveIntensity={0.3}
roughness={0.2}
/>
</mesh>
</group>
);
}
/** Wall light: half-sphere attached to wall */
function WallLightMesh({ color }: { readonly color: string }) {
return (
<group>
{/* Base plate */}
<mesh castShadow>
<boxGeometry args={[0.10, 0.10, 0.02]} />
<meshStandardMaterial color="#666666" roughness={0.4} />
</mesh>
{/* Half sphere shade */}
<mesh position={[0, 0, 0.03]}>
<sphereGeometry args={[0.06, 16, 8, 0, Math.PI * 2, 0, Math.PI / 2]} />
<meshStandardMaterial
color={color}
emissive={color}
emissiveIntensity={0.2}
roughness={0.3}
side={THREE.DoubleSide}
/>
</mesh>
</group>
);
}
/** Cable route: small orange marker */
function CableRouteMesh({ color }: { readonly color: string }) {
return (
<mesh castShadow>
<sphereGeometry args={[0.03, 8, 8]} />
<meshStandardMaterial color={color} roughness={0.5} />
</mesh>
);
}
/** Compute 3D position for an electrical item. */
function useElectricalPosition(
item: ElectricalItem,
wall: Wall | null,
): [number, number, number] {
return useMemo<[number, number, number]>(() => {
if (item.type === 'LIGHT_CEILING') {
return [item.x, 0, item.y];
}
if (wall && item.wallId) {
const { nx, ny } = wallNormal(wall);
const { length } = wallVector(wall);
const dx = item.x - wall.startX;
const dy = item.y - wall.startY;
const wallDx = wall.endX - wall.startX;
const wallDy = wall.endY - wall.startY;
const t = length > 0 ? (dx * wallDx + dy * wallDy) / (length * length) : 0;
const clampedT = Math.max(0, Math.min(1, t));
const [wx, wz] = positionAlongWall3D(wall, clampedT * length);
const offset = wall.thickness / 2 + 0.015;
const elevation = item.elevationFromFloor ?? 1.2;
return [wx + nx * offset, elevation, wz + ny * offset];
}
const elevation = item.elevationFromFloor ?? 0.3;
return [item.x, elevation, item.y];
}, [item, wall]);
}
export function ElectricalMeshWithHeight({
item,
wallMap,
wallHeight,
isSelected,
onSelect,
}: ElectricalMeshWithHeightProps) {
const color = isSelected ? SELECTED_COLOR : ELECTRICAL_COLORS[item.type];
const wall = useMemo(() => {
// Try exact wallId match first
const exact = findWallInMap(item.wallId, wallMap);
if (exact) return exact;
// Fallback: find nearest wall by proximity (wallId may be stale after save)
if (item.wallId || item.type === 'OUTLET' || item.type === 'SWITCH' || item.type === 'LIGHT_WALL' || item.type === 'JUNCTION_BOX') {
let bestWall: Wall | null = null;
let bestDist = 0.3; // max 30cm threshold
for (const w of wallMap.values()) {
const dx = item.x - w.startX;
const dy = item.y - w.startY;
const wx = w.endX - w.startX;
const wy = w.endY - w.startY;
const len = Math.sqrt(wx * wx + wy * wy);
if (len === 0) continue;
const perpDist = Math.abs(dx * (-wy / len) + dy * (wx / len));
const along = (dx * wx + dy * wy) / len;
if (perpDist < bestDist && along >= -0.1 && along <= len + 0.1) {
bestDist = perpDist;
bestWall = w;
}
}
return bestWall;
}
return null;
}, [item, wallMap]);
const position = useElectricalPosition(item, wall);
const rotY = useMemo(() => {
if (wall) return wallRotationY(wall);
return -degToRad(item.rotation);
}, [wall, item.rotation]);
return (
<group
position={position}
rotation={[0, rotY, 0]}
onClick={onSelect ? (e) => { e.stopPropagation(); onSelect(item.id); } : undefined}
>
{item.type === 'OUTLET' && <OutletMesh color={color} />}
{item.type === 'SWITCH' && <SwitchMesh color={color} />}
{item.type === 'JUNCTION_BOX' && <JunctionBoxMesh color={color} />}
{item.type === 'LIGHT_CEILING' && <CeilingLightMesh color={color} wallHeight={wallHeight} />}
{item.type === 'LIGHT_WALL' && <WallLightMesh color={color} />}
{item.type === 'CABLE_ROUTE' && <CableRouteMesh color={color} />}
</group>
);
}
@@ -0,0 +1,195 @@
import { useMemo } from 'react';
import * as THREE from 'three';
import type { Point, FloorType } from '@house-plan-maker/shared';
interface FloorCeilingProps {
readonly shape: readonly Point[];
readonly wallHeight: number;
readonly floorType?: FloorType;
}
function createPolygonGeometry(shape: readonly Point[]): THREE.ShapeGeometry | null {
if (shape.length < 3) return null;
const threeShape = new THREE.Shape();
threeShape.moveTo(shape[0].x, shape[0].y);
for (let i = 1; i < shape.length; i++) {
threeShape.lineTo(shape[i].x, shape[i].y);
}
threeShape.closePath();
return new THREE.ShapeGeometry(threeShape);
}
/** Generate a procedural floor texture on a canvas. */
function createFloorTexture(floorType: FloorType): THREE.CanvasTexture {
const size = 512;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d')!;
switch (floorType) {
case 'WOOD_LIGHT':
drawWoodPlanks(ctx, size, '#d4b896', '#c4a87a', '#b89868');
break;
case 'WOOD_MEDIUM':
drawWoodPlanks(ctx, size, '#a07850', '#8c6840', '#785830');
break;
case 'WOOD_DARK':
drawWoodPlanks(ctx, size, '#5c3c28', '#4c3020', '#3c2418');
break;
case 'WOOD_HERRINGBONE':
drawHerringbone(ctx, size, '#b08860', '#9a7850', '#8a6840');
break;
case 'TILE_WHITE':
drawTiles(ctx, size, '#f0f0f0', '#e0e0e0', '#d8d8d8');
break;
case 'TILE_GRAY':
drawTiles(ctx, size, '#a0a0a0', '#909090', '#888888');
break;
case 'LAMINATE':
drawWoodPlanks(ctx, size, '#c8b090', '#b8a080', '#a89070');
break;
case 'CONCRETE':
default:
drawConcrete(ctx, size);
break;
}
const texture = new THREE.CanvasTexture(canvas);
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(2, 2);
return texture;
}
function drawWoodPlanks(ctx: CanvasRenderingContext2D, size: number, c1: string, c2: string, c3: string) {
const plankHeight = size / 6;
for (let i = 0; i < 6; i++) {
const y = i * plankHeight;
// Alternate plank colors
ctx.fillStyle = i % 2 === 0 ? c1 : c2;
ctx.fillRect(0, y, size, plankHeight);
// Grain lines
ctx.strokeStyle = c3;
ctx.lineWidth = 0.5;
for (let g = 0; g < 8; g++) {
const gy = y + Math.random() * plankHeight;
ctx.beginPath();
ctx.moveTo(0, gy);
ctx.bezierCurveTo(
size * 0.3, gy + (Math.random() - 0.5) * 3,
size * 0.7, gy + (Math.random() - 0.5) * 3,
size, gy,
);
ctx.stroke();
}
// Plank gap
ctx.fillStyle = 'rgba(0,0,0,0.15)';
ctx.fillRect(0, y, size, 1);
// Vertical joint (staggered)
const jointX = (i % 2 === 0) ? size * 0.4 : size * 0.7;
ctx.fillRect(jointX, y, 1, plankHeight);
}
}
function drawHerringbone(ctx: CanvasRenderingContext2D, size: number, c1: string, c2: string, c3: string) {
ctx.fillStyle = c3;
ctx.fillRect(0, 0, size, size);
const plankW = size / 4;
const plankH = size / 8;
for (let row = -2; row < size / plankH + 2; row++) {
for (let col = -2; col < size / plankW + 2; col++) {
const isEven = (row + col) % 2 === 0;
ctx.fillStyle = isEven ? c1 : c2;
ctx.save();
const cx = col * plankW;
const cy = row * plankH;
ctx.translate(cx + plankW / 2, cy + plankH / 2);
ctx.rotate(isEven ? Math.PI / 4 : -Math.PI / 4);
ctx.fillRect(-plankW / 2, -plankH / 4, plankW, plankH / 2);
ctx.strokeStyle = 'rgba(0,0,0,0.1)';
ctx.lineWidth = 0.5;
ctx.strokeRect(-plankW / 2, -plankH / 4, plankW, plankH / 2);
ctx.restore();
}
}
}
function drawTiles(ctx: CanvasRenderingContext2D, size: number, c1: string, c2: string, grout: string) {
const tileSize = size / 4;
// Grout background
ctx.fillStyle = grout;
ctx.fillRect(0, 0, size, size);
const groutWidth = 3;
for (let row = 0; row < 4; row++) {
for (let col = 0; col < 4; col++) {
ctx.fillStyle = (row + col) % 2 === 0 ? c1 : c2;
ctx.fillRect(
col * tileSize + groutWidth / 2,
row * tileSize + groutWidth / 2,
tileSize - groutWidth,
tileSize - groutWidth,
);
}
}
}
function drawConcrete(ctx: CanvasRenderingContext2D, size: number) {
ctx.fillStyle = '#d4d0cc';
ctx.fillRect(0, 0, size, size);
// Noise texture
for (let i = 0; i < 5000; i++) {
const x = Math.random() * size;
const y = Math.random() * size;
const brightness = 180 + Math.random() * 40;
ctx.fillStyle = `rgb(${brightness},${brightness - 5},${brightness - 10})`;
ctx.fillRect(x, y, 2, 2);
}
}
const textureCache = new Map<FloorType, THREE.CanvasTexture>();
function getFloorTexture(floorType: FloorType): THREE.CanvasTexture {
let tex = textureCache.get(floorType);
if (!tex) {
tex = createFloorTexture(floorType);
textureCache.set(floorType, tex);
}
return tex;
}
export function FloorCeiling({ shape, wallHeight, floorType = 'CONCRETE' }: FloorCeilingProps) {
const floorGeometry = useMemo(() => createPolygonGeometry(shape), [shape]);
const texture = useMemo(() => getFloorTexture(floorType), [floorType]);
if (!floorGeometry) return null;
return (
<mesh
geometry={floorGeometry}
rotation={[-Math.PI / 2, 0, 0]}
scale={[1, -1, 1]}
position={[0, 0, 0]}
receiveShadow
>
<meshStandardMaterial
map={texture}
side={THREE.DoubleSide}
roughness={0.8}
/>
</mesh>
);
}
@@ -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>
);
}
@@ -0,0 +1,99 @@
import { useMemo } from 'react';
import * as THREE from 'three';
import type { Wall, WallOpening } from '@house-plan-maker/shared';
import {
getOpeningSlices,
wallVector,
wallRotationY,
positionAlongWall3D,
} from './utils/wallGeometry';
interface PlinthMeshProps {
readonly wall: Wall;
readonly openings: readonly WallOpening[];
readonly plinthHeight: number;
readonly plinthThickness: number;
}
const PLINTH_COLOR = '#d4c5b2';
// ── Shared plinth material (module-level singleton) ──
const plinthMaterial = new THREE.MeshStandardMaterial({
color: PLINTH_COLOR,
roughness: 0.6,
});
interface PlinthSegment {
readonly startAlongWall: number;
readonly endAlongWall: number;
}
/**
* Compute plinth segments for a wall — continuous along wall base,
* interrupted at door openings (openings with elevationFromFloor === 0).
*/
function computePlinthSegments(
wall: Wall,
openings: readonly WallOpening[],
): readonly PlinthSegment[] {
const { length } = wallVector(wall);
const slices = getOpeningSlices(wall, openings);
// Only doors (elevationFromFloor === 0) interrupt the plinth
const doorSlices = slices.filter((s) => s.opening.elevationFromFloor === 0);
if (doorSlices.length === 0) {
return [{ startAlongWall: 0, endAlongWall: length }];
}
const segments: PlinthSegment[] = [];
let cursor = 0;
for (const slice of doorSlices) {
if (slice.startAlongWall > cursor) {
segments.push({ startAlongWall: cursor, endAlongWall: slice.startAlongWall });
}
cursor = slice.endAlongWall;
}
if (cursor < length) {
segments.push({ startAlongWall: cursor, endAlongWall: length });
}
return segments;
}
export function PlinthMesh({ wall, openings, plinthHeight, plinthThickness }: PlinthMeshProps) {
const segments = useMemo(
() => computePlinthSegments(wall, openings),
[wall, openings],
);
const rotY = useMemo(() => wallRotationY(wall), [wall]);
if (plinthHeight <= 0 || plinthThickness <= 0) return null;
return (
<group>
{segments.map((segment, i) => {
const segLen = segment.endAlongWall - segment.startAlongWall;
if (segLen <= 0) return null;
const midAlongWall = (segment.startAlongWall + segment.endAlongWall) / 2;
const [cx, cz] = positionAlongWall3D(wall, midAlongWall);
return (
<mesh
key={`${wall.id}-plinth-${i}`}
position={[cx, plinthHeight / 2, cz]}
rotation={[0, rotY, 0]}
castShadow
material={plinthMaterial}
>
<boxGeometry args={[segLen, plinthHeight, wall.thickness + plinthThickness * 2]} />
</mesh>
);
})}
</group>
);
}
@@ -0,0 +1,259 @@
import { useState, useCallback, useMemo, useRef, Suspense } from 'react';
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import { PerspectiveCamera } from '@react-three/drei';
import * as THREE from 'three';
import { useEditor } from '../context/EditorContext';
import { boundingBox } from '../utils/geometry';
import { FloorCeiling } from './FloorCeiling';
import { WallMesh } from './WallMesh';
import { DoorOpening } from './DoorOpening';
import { WindowOpening } from './WindowOpening';
import { FurnitureMesh } from './FurnitureMesh';
import { ElectricalMeshWithHeight } from './ElectricalMesh';
import { PlinthMesh } from './PlinthMesh';
import { RoomLabels } from './RoomLabels';
import { SceneCamera, CameraPresetsUI, type CameraPreset } from './CameraControls';
import type { Wall } from '@house-plan-maker/shared';
/** Determines the two walls closest to the camera and reports their IDs. */
function NearestWallTracker({ walls, onUpdate }: { readonly walls: readonly Wall[]; readonly onUpdate: (ids: ReadonlySet<string>) => void }) {
const { camera } = useThree();
const lastIdsRef = useRef<string>('');
const frameCountRef = useRef(0);
useFrame(() => {
// Only check every 10 frames for performance
frameCountRef.current++;
if (frameCountRef.current % 10 !== 0) return;
const camPos = camera.position;
const camDir = new THREE.Vector3();
camera.getWorldDirection(camDir);
// Sort walls by distance to camera
const sorted = walls
.map((wall) => {
const midX = (wall.startX + wall.endX) / 2;
const midZ = (wall.startY + wall.endY) / 2;
const dist = Math.sqrt((camPos.x - midX) ** 2 + (camPos.z - midZ) ** 2);
return { id: wall.id, dist };
})
.sort((a, b) => a.dist - b.dist);
// Don't hide when in bird's eye (camera high above)
const camHeight = camPos.y;
const closestDist = sorted[0]?.dist ?? 0;
const hide = closestDist > 0.5 && camHeight < closestDist * 2;
const ids = hide
? new Set(sorted.slice(0, 2).map((s) => s.id))
: new Set<string>();
const key = [...ids].sort().join(',');
if (key !== lastIdsRef.current) {
lastIdsRef.current = key;
onUpdate(ids);
}
});
return null;
}
/**
* Room3DView — read-only 3D perspective view of the room.
* Renders inside a @react-three/fiber Canvas with orbit controls.
*/
export function Room3DView() {
const { state, dispatch } = useEditor();
const { room, walls, openings, electricalItems, furnitureItems, selectedIds, layerVisibility } = state;
const [activePreset, setActivePreset] = useState<CameraPreset | null>(null);
const [hiddenWallIds, setHiddenWallIds] = useState<ReadonlySet<string>>(new Set());
const handlePreset = useCallback((preset: CameraPreset) => {
setActivePreset(preset);
// Reset after applying so it can be re-applied
setTimeout(() => setActivePreset(null), 100);
}, []);
const handleSelect = useCallback(
(id: string) => {
dispatch({ type: 'SET_SELECTED', ids: new Set([id]) });
},
[dispatch],
);
const wallHeight = room.wallHeight;
const plinthHeight = room.plinthHeight;
const plinthThickness = room.plinthThickness;
const shape = room.shape;
// Separate openings by type
const doors = useMemo(
() => openings.filter((o) => o.type === 'DOOR'),
[openings],
);
const windows = useMemo(
() => openings.filter((o) => o.type === 'WINDOW'),
[openings],
);
// Compute bird's eye camera position from room bounds
const initialCameraPos = useMemo((): [number, number, number] => {
if (shape.length < 3) return [0, 10, 0.01];
const bb = boundingBox(shape);
const cx = (bb.minX + bb.maxX) / 2;
const cz = (bb.minY + bb.maxY) / 2;
const maxSize = Math.max(bb.maxX - bb.minX, bb.maxY - bb.minY, 2);
return [cx, maxSize * 1.5 * 1.5, cz + 0.01];
}, [shape]);
// Wall lookup by ID for openings
const wallMap = useMemo(() => {
const map = new Map<string, (typeof walls)[number]>();
for (const w of walls) {
map.set(w.id, w);
}
return map;
}, [walls]);
return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<CameraPresetsUI
shape={shape}
wallHeight={wallHeight}
onPreset={handlePreset}
/>
<Canvas
shadows
style={{ width: '100%', height: '100%', background: '#e8ecf0' }}
gl={{ antialias: true, preserveDrawingBuffer: true }}
>
<Suspense fallback={null}>
{/* Camera + Controls */}
<PerspectiveCamera makeDefault fov={50} near={0.1} far={200} position={initialCameraPos} />
<SceneCamera
shape={shape}
wallHeight={wallHeight}
activePreset={activePreset}
/>
{/* Lighting */}
<ambientLight intensity={0.5} />
<directionalLight
position={[10, 15, 10]}
intensity={1.0}
castShadow
shadow-mapSize-width={2048}
shadow-mapSize-height={2048}
shadow-camera-near={0.5}
shadow-camera-far={50}
shadow-camera-left={-15}
shadow-camera-right={15}
shadow-camera-top={15}
shadow-camera-bottom={-15}
/>
<directionalLight position={[-5, 8, -5]} intensity={0.3} />
{/* Track nearest wall to camera and hide it */}
<NearestWallTracker walls={walls} onUpdate={setHiddenWallIds} />
{/* Floor */}
<FloorCeiling shape={shape} wallHeight={wallHeight} floorType={room.floorType} />
{/* Walls (hide the one facing the camera) */}
{layerVisibility.walls && walls.map((wall) => (
hiddenWallIds.has(wall.id) ? null : (
<WallMesh
key={wall.id}
wall={wall}
openings={openings}
wallHeight={wallHeight}
wallColor={room.wallColor}
selectedIds={selectedIds}
onSelect={handleSelect}
/>
)
))}
{/* Plinths (hide matching wall's plinth too) */}
{layerVisibility.walls && walls.map((wall) => (
hiddenWallIds.has(wall.id) ? null : (
<PlinthMesh
key={`plinth-${wall.id}`}
wall={wall}
openings={openings}
plinthHeight={plinthHeight}
plinthThickness={plinthThickness}
/>
)
))}
{/* Door openings */}
{doors.map((door) => {
const wall = wallMap.get(door.wallId);
if (!wall) return null;
return (
<DoorOpening
key={door.id}
opening={door}
wall={wall}
isSelected={selectedIds.has(door.id)}
onSelect={handleSelect}
/>
);
})}
{/* Window openings */}
{windows.map((win) => {
const wall = wallMap.get(win.wallId);
if (!wall) return null;
return (
<WindowOpening
key={win.id}
opening={win}
wall={wall}
isSelected={selectedIds.has(win.id)}
onSelect={handleSelect}
/>
);
})}
{/* Furniture */}
{layerVisibility.furniture && furnitureItems.map((item) => (
<FurnitureMesh
key={item.id}
item={item}
isSelected={selectedIds.has(item.id)}
onSelect={handleSelect}
/>
))}
{/* Electrical */}
{layerVisibility.electrical && electricalItems.map((item) => (
<ElectricalMeshWithHeight
key={item.id}
item={item}
wallMap={wallMap}
wallHeight={wallHeight}
isSelected={selectedIds.has(item.id)}
onSelect={handleSelect}
/>
))}
{/* Room labels and dimensions */}
<RoomLabels
roomName={room.name}
shape={shape}
walls={walls}
wallHeight={wallHeight}
/>
{/* Contact shadows on the floor */}
{/* ContactShadows removed — floor is handled by FloorCeiling */}
</Suspense>
</Canvas>
</div>
);
}
@@ -0,0 +1,80 @@
import { useMemo } from 'react';
import { Html } from '@react-three/drei';
import type { Point, Wall } from '@house-plan-maker/shared';
import { boundingBox } from '../utils/geometry';
import { wallVector } from './utils/wallGeometry';
interface RoomLabelsProps {
readonly roomName: string;
readonly shape: readonly Point[];
readonly walls: readonly Wall[];
readonly wallHeight: number;
}
function formatLength(meters: number): string {
if (meters >= 1) {
return `${meters.toFixed(2)} m`;
}
return `${(meters * 100).toFixed(0)} cm`;
}
export function RoomLabels({ roomName, shape, walls, wallHeight }: RoomLabelsProps) {
const bb = useMemo(() => boundingBox(shape), [shape]);
const centerX = (bb.minX + bb.maxX) / 2;
const centerZ = (bb.minY + bb.maxY) / 2;
return (
<group>
{/* Room name label floating above center */}
<Html
position={[centerX, wallHeight + 0.3, centerZ]}
center
style={{ pointerEvents: 'none' }}
>
<div style={{
background: 'rgba(0, 0, 0, 0.7)',
color: '#fff',
padding: '4px 10px',
borderRadius: 4,
fontSize: '13px',
fontWeight: 600,
whiteSpace: 'nowrap',
fontFamily: 'system-ui, sans-serif',
}}>
{roomName}
</div>
</Html>
{/* Wall dimension labels */}
{walls.map((wall) => {
const { length } = wallVector(wall);
if (length < 0.1) return null;
const midX = (wall.startX + wall.endX) / 2;
const midZ = (wall.startY + wall.endY) / 2;
return (
<Html
key={`dim-${wall.id}`}
position={[midX, wallHeight + 0.15, midZ]}
center
style={{ pointerEvents: 'none' }}
>
<div style={{
background: 'rgba(255, 255, 255, 0.85)',
color: '#333',
padding: '2px 6px',
borderRadius: 3,
fontSize: '11px',
whiteSpace: 'nowrap',
fontFamily: 'system-ui, sans-serif',
border: '1px solid #ccc',
}}>
{formatLength(length)}
</div>
</Html>
);
})}
</group>
);
}
@@ -0,0 +1,105 @@
import { useMemo } from 'react';
import * as THREE from 'three';
import type { Wall, WallOpening } from '@house-plan-maker/shared';
import {
splitWallAroundOpenings,
wallRotationY,
wallSegmentCenter3D,
type WallSegment,
} from './utils/wallGeometry';
interface WallMeshProps {
readonly wall: Wall;
readonly openings: readonly WallOpening[];
readonly wallHeight: number;
readonly wallColor?: string;
readonly selectedIds: ReadonlySet<string>;
readonly onSelect?: (id: string) => void;
}
const DEFAULT_WALL_COLOR = '#f0ebe3';
const WALL_SELECTED_COLOR = '#b8d4e3';
// ── Wall material cache ──
const wallMaterialCache = new Map<string, THREE.MeshStandardMaterial>();
function getWallMaterial(color: string): THREE.MeshStandardMaterial {
let mat = wallMaterialCache.get(color);
if (!mat) {
mat = new THREE.MeshStandardMaterial({ color, roughness: 0.7, side: THREE.DoubleSide });
wallMaterialCache.set(color, mat);
}
return mat;
}
const wallSelectedMaterial = new THREE.MeshStandardMaterial({
color: WALL_SELECTED_COLOR,
roughness: 0.7,
side: THREE.DoubleSide,
});
function WallSegmentMesh({
wall,
segment,
thickness,
wallColor,
isSelected,
onSelect,
}: {
readonly wall: Wall;
readonly segment: WallSegment;
readonly thickness: number;
readonly wallColor: string;
readonly isSelected: boolean;
readonly onSelect?: (id: string) => void;
}) {
const segmentWidth = segment.endAlongWall - segment.startAlongWall;
const segmentHeight = segment.topY - segment.bottomY;
const center = useMemo(
() => wallSegmentCenter3D(wall, segment),
[wall, segment],
);
const rotY = useMemo(() => wallRotationY(wall), [wall]);
if (segmentWidth <= 0 || segmentHeight <= 0) return null;
return (
<mesh
position={center}
rotation={[0, rotY, 0]}
castShadow
receiveShadow
material={isSelected ? wallSelectedMaterial : getWallMaterial(wallColor)}
onClick={onSelect ? (e) => { e.stopPropagation(); onSelect(wall.id); } : undefined}
>
<boxGeometry args={[segmentWidth, segmentHeight, thickness]} />
</mesh>
);
}
export function WallMesh({ wall, openings, wallHeight, wallColor = DEFAULT_WALL_COLOR, selectedIds, onSelect }: WallMeshProps) {
const segments = useMemo(
() => splitWallAroundOpenings(wall, openings, wallHeight),
[wall, openings, wallHeight],
);
const isSelected = selectedIds.has(wall.id);
return (
<group>
{segments.map((segment, i) => (
<WallSegmentMesh
key={`${wall.id}-seg-${i}`}
wall={wall}
segment={segment}
thickness={wall.thickness}
wallColor={wallColor}
isSelected={isSelected}
onSelect={onSelect}
/>
))}
</group>
);
}
@@ -0,0 +1,86 @@
import { useMemo } from 'react';
import * as THREE from 'three';
import type { Wall, WallOpening } from '@house-plan-maker/shared';
import {
wallRotationY,
positionAlongWall3D,
} from './utils/wallGeometry';
interface WindowOpeningProps {
readonly opening: WallOpening;
readonly wall: Wall;
readonly isSelected: boolean;
readonly onSelect?: (id: string) => void;
}
const FRAME_COLOR = '#c0c0c0';
const GLASS_COLOR = '#a8d8ea';
const FRAME_THICKNESS = 0.03;
export function WindowOpening({ opening, wall, isSelected, onSelect }: WindowOpeningProps) {
const rotY = useMemo(() => wallRotationY(wall), [wall]);
const [cx, cz] = useMemo(
() => positionAlongWall3D(wall, opening.positionAlongWall),
[wall, opening.positionAlongWall],
);
const frameColor = isSelected ? '#6fa8dc' : FRAME_COLOR;
const halfWidth = opening.width / 2;
const halfThick = wall.thickness / 2 + 0.005;
return (
<group
position={[cx, opening.elevationFromFloor + opening.height / 2, cz]}
rotation={[0, rotY, 0]}
onClick={onSelect ? (e) => { e.stopPropagation(); onSelect(opening.id); } : undefined}
>
{/* Window frame — four sides */}
{/* Left */}
<mesh position={[-halfWidth, 0, 0]} castShadow>
<boxGeometry args={[FRAME_THICKNESS, opening.height, halfThick * 2]} />
<meshStandardMaterial color={frameColor} roughness={0.4} />
</mesh>
{/* Right */}
<mesh position={[halfWidth, 0, 0]} castShadow>
<boxGeometry args={[FRAME_THICKNESS, opening.height, halfThick * 2]} />
<meshStandardMaterial color={frameColor} roughness={0.4} />
</mesh>
{/* Top */}
<mesh position={[0, opening.height / 2, 0]} castShadow>
<boxGeometry args={[opening.width + FRAME_THICKNESS, FRAME_THICKNESS, halfThick * 2]} />
<meshStandardMaterial color={frameColor} roughness={0.4} />
</mesh>
{/* Bottom (sill) */}
<mesh position={[0, -opening.height / 2, 0]} castShadow>
<boxGeometry args={[opening.width + FRAME_THICKNESS, FRAME_THICKNESS, halfThick * 2]} />
<meshStandardMaterial color={frameColor} roughness={0.4} />
</mesh>
{/* Glass pane */}
<mesh position={[0, 0, 0]}>
<planeGeometry args={[opening.width, opening.height]} />
<meshStandardMaterial
color={GLASS_COLOR}
transparent
opacity={0.3}
side={THREE.DoubleSide}
roughness={0.1}
metalness={0.2}
/>
</mesh>
{/* Center cross divider — vertical */}
<mesh position={[0, 0, 0]} castShadow>
<boxGeometry args={[FRAME_THICKNESS * 0.7, opening.height, FRAME_THICKNESS * 0.7]} />
<meshStandardMaterial color={frameColor} roughness={0.4} />
</mesh>
{/* Center cross divider — horizontal */}
<mesh position={[0, 0, 0]} castShadow>
<boxGeometry args={[opening.width, FRAME_THICKNESS * 0.7, FRAME_THICKNESS * 0.7]} />
<meshStandardMaterial color={frameColor} roughness={0.4} />
</mesh>
</group>
);
}
@@ -0,0 +1,170 @@
import type { Point, Wall, WallOpening } from '@house-plan-maker/shared';
/**
* Coordinate conversion: 2D editor (X-right, Y-down) -> Three.js (X-right, Y-up, Z-forward).
* 2D (x, y) -> 3D (x, 0, y) for floor plane; Y axis is height.
*/
export function toThreeCoords(point: Point): [number, number, number] {
return [point.x, 0, point.y];
}
/** Wall direction vector and length in 2D. */
export function wallVector(wall: Wall): { dx: number; dy: number; length: number } {
const dx = wall.endX - wall.startX;
const dy = wall.endY - wall.startY;
const length = Math.sqrt(dx * dx + dy * dy);
return { dx, dy, length };
}
/** Wall perpendicular normal (unit vector pointing "left" of wall direction). */
export function wallNormal(wall: Wall): { nx: number; ny: number } {
const { dx, dy, length } = wallVector(wall);
if (length === 0) return { nx: 0, ny: 0 };
// Perpendicular: rotate 90 degrees CCW
return { nx: -dy / length, ny: dx / length };
}
/** Opening position on a wall: start and end distances along wall from wall start. */
export interface OpeningSlice {
readonly opening: WallOpening;
readonly startAlongWall: number;
readonly endAlongWall: number;
}
/**
* Sort openings on a wall by position and compute their start/end distances.
*/
export function getOpeningSlices(
wall: Wall,
openings: readonly WallOpening[],
): readonly OpeningSlice[] {
const wallOpenings = openings.filter((o) => o.wallId === wall.id);
return wallOpenings
.map((opening) => ({
opening,
startAlongWall: opening.positionAlongWall - opening.width / 2,
endAlongWall: opening.positionAlongWall + opening.width / 2,
}))
.sort((a, b) => a.startAlongWall - b.startAlongWall);
}
/**
* A solid segment of a wall (between openings or at wall ends).
* Heights define the vertical range of this segment.
*/
export interface WallSegment {
readonly startAlongWall: number;
readonly endAlongWall: number;
readonly bottomY: number;
readonly topY: number;
}
/**
* Split a wall into solid segments around openings.
* Returns segments that should be rendered as solid wall geometry.
*
* For each opening, we create:
* - A segment above the opening (from opening top to wall top)
* - A segment below the opening (from floor to opening bottom) — only if elevationFromFloor > 0
* - Solid wall to the left and right of the opening
*/
export function splitWallAroundOpenings(
wall: Wall,
openings: readonly WallOpening[],
wallHeight: number,
): readonly WallSegment[] {
const slices = getOpeningSlices(wall, openings);
const { length } = wallVector(wall);
if (slices.length === 0) {
return [{ startAlongWall: 0, endAlongWall: length, bottomY: 0, topY: wallHeight }];
}
const segments: WallSegment[] = [];
// Full-height segments between openings
let cursor = 0;
for (const slice of slices) {
// Solid wall before this opening
if (slice.startAlongWall > cursor) {
segments.push({
startAlongWall: cursor,
endAlongWall: slice.startAlongWall,
bottomY: 0,
topY: wallHeight,
});
}
// Segment below the opening (sill)
if (slice.opening.elevationFromFloor > 0) {
segments.push({
startAlongWall: slice.startAlongWall,
endAlongWall: slice.endAlongWall,
bottomY: 0,
topY: slice.opening.elevationFromFloor,
});
}
// Segment above the opening (lintel to ceiling)
const openingTop = slice.opening.elevationFromFloor + slice.opening.height;
if (openingTop < wallHeight) {
segments.push({
startAlongWall: slice.startAlongWall,
endAlongWall: slice.endAlongWall,
bottomY: openingTop,
topY: wallHeight,
});
}
cursor = slice.endAlongWall;
}
// Solid wall after last opening
if (cursor < length) {
segments.push({
startAlongWall: cursor,
endAlongWall: length,
bottomY: 0,
topY: wallHeight,
});
}
return segments;
}
/**
* Compute 3D position along a wall at a given distance from wall start.
* Returns [x, z] in Three.js floor plane coords.
*/
export function positionAlongWall3D(
wall: Wall,
distanceAlongWall: number,
): [number, number] {
const { dx, dy, length } = wallVector(wall);
if (length === 0) return [wall.startX, wall.startY];
const t = distanceAlongWall / length;
return [wall.startX + dx * t, wall.startY + dy * t];
}
/**
* Compute the center position of a wall segment in 3D space.
*/
export function wallSegmentCenter3D(
wall: Wall,
segment: WallSegment,
): [number, number, number] {
const midAlongWall = (segment.startAlongWall + segment.endAlongWall) / 2;
const [x, z] = positionAlongWall3D(wall, midAlongWall);
const y = (segment.bottomY + segment.topY) / 2;
return [x, y, z];
}
/**
* Get the rotation angle of a wall around the Y axis (for Three.js).
* Returns angle in radians.
*/
export function wallRotationY(wall: Wall): number {
const { dx, dy } = wallVector(wall);
return -Math.atan2(dy, dx);
}
@@ -0,0 +1,77 @@
import type { Point, Wall, WallOpening } from '@house-plan-maker/shared';
import { findNearestWall, wallLength } from '../utils/wallUtils';
import { generateLocalId } from '../utils/geometry';
import { hasOverlap } from '../utils/openingUtils';
/** Maximum distance (in meters) to the nearest wall for door placement. */
const MAX_WALL_DISTANCE = 0.5;
/** Default door width in meters. */
export const DEFAULT_DOOR_WIDTH = 0.9;
/** Default door height in meters. */
export const DEFAULT_DOOR_HEIGHT = 2.1;
export interface DoorPlacementPreview {
readonly wall: Wall;
readonly positionAlongWall: number;
readonly snappedPoint: Point;
readonly width: number;
readonly isValid: boolean;
}
/**
* Compute a door placement preview from a cursor position.
* Returns null if no wall is close enough.
*/
export function computeDoorPreview(
worldPoint: Point,
walls: readonly Wall[],
existingOpenings: readonly WallOpening[],
width: number = DEFAULT_DOOR_WIDTH,
): DoorPlacementPreview | null {
const nearest = findNearestWall(worldPoint, walls);
if (!nearest || nearest.distance > MAX_WALL_DISTANCE) {
return null;
}
const wLen = wallLength(nearest.wall);
const halfWidth = width / 2;
// Clamp position so the door fits within the wall
const clampedPos = Math.max(halfWidth, Math.min(wLen - halfWidth, nearest.positionAlongWall));
// Check for overlap with existing openings on the same wall
const isValid = !hasOverlap(nearest.wall.id, clampedPos, width, existingOpenings);
return {
wall: nearest.wall,
positionAlongWall: clampedPos,
snappedPoint: nearest.projected,
width,
isValid,
};
}
/**
* Create a WallOpening object for a door.
*/
export function createDoorOpening(
roomId: string,
wallId: string,
positionAlongWall: number,
width: number = DEFAULT_DOOR_WIDTH,
height: number = DEFAULT_DOOR_HEIGHT,
): WallOpening {
return {
id: generateLocalId(),
roomId,
wallId,
type: 'DOOR',
positionAlongWall,
width,
height,
elevationFromFloor: 0,
openDirection: 'LEFT',
};
}
@@ -0,0 +1,131 @@
import type { Point, Wall, ElectricalItem, ElectricalType } from '@house-plan-maker/shared';
import { findNearestWall, wallAngle } from '../utils/wallUtils';
import { generateLocalId } from '../utils/geometry';
import { DEFAULT_ELEVATIONS } from '../utils/projectionMapping';
import type { ElectricalSymbolDef } from '../symbols/electrical';
/** Maximum snap distance to wall (meters). */
const WALL_SNAP_DISTANCE = 0.5;
export interface ElectricalPlacementPreview {
readonly x: number;
readonly y: number;
readonly rotation: number;
readonly wallId: string | null;
readonly isValid: boolean;
}
/**
* Compute electrical item placement preview.
* Wall-mounted items snap to the nearest wall; ceiling items place freely.
*/
export function computeElectricalPreview(
worldPoint: Point,
walls: readonly Wall[],
symbolDef: ElectricalSymbolDef,
): ElectricalPlacementPreview {
if (symbolDef.wallMounted) {
const nearest = findNearestWall(worldPoint, walls);
if (nearest && nearest.distance <= WALL_SNAP_DISTANCE) {
const angle = wallAngle(nearest.wall);
return {
x: nearest.projected.x,
y: nearest.projected.y,
rotation: (angle * 180) / Math.PI,
wallId: nearest.wall.id,
isValid: true,
};
}
// Too far from any wall
return {
x: worldPoint.x,
y: worldPoint.y,
rotation: 0,
wallId: null,
isValid: false,
};
}
// Ceiling/free placement
return {
x: worldPoint.x,
y: worldPoint.y,
rotation: 0,
wallId: null,
isValid: true,
};
}
/**
* Resolve default elevation for an electrical item type.
* Wall-mounted types get their standard elevation, ceiling lights get wall height,
* and cable routes remain null.
*/
export function getDefaultElevation(
type: ElectricalType,
wallHeight?: number,
): number | null {
if (type === 'LIGHT_CEILING') {
return wallHeight ?? 2.5;
}
if (type === 'CABLE_ROUTE') {
return null;
}
return DEFAULT_ELEVATIONS[type] ?? null;
}
/**
* Create an ElectricalItem from placement data.
*/
export function createElectricalItemFromPlacement(
roomId: string,
preview: ElectricalPlacementPreview,
type: ElectricalType,
variant?: string,
wallHeight?: number,
): ElectricalItem {
const metadata: Record<string, unknown> | null = variant ? { variant } : null;
return {
id: generateLocalId(),
roomId,
type,
x: preview.x,
y: preview.y,
wallId: preview.wallId,
elevationFromFloor: getDefaultElevation(type, wallHeight),
rotation: preview.rotation,
metadata,
};
}
/**
* Compute total cable route length from a list of electrical items of type CABLE_ROUTE.
* Each cable route stores waypoints in metadata.points as Point[].
* Returns length in meters.
*/
export function computeCableRouteLength(electricalItems: readonly ElectricalItem[]): number {
let total = 0;
for (const item of electricalItems) {
if (item.type !== 'CABLE_ROUTE') continue;
const points = getCableWaypoints(item);
if (points.length < 2) continue;
for (let i = 1; i < points.length; i++) {
const dx = points[i].x - points[i - 1].x;
const dy = points[i].y - points[i - 1].y;
total += Math.sqrt(dx * dx + dy * dy);
}
}
return total;
}
/** Extract cable waypoints from metadata. */
export function getCableWaypoints(item: ElectricalItem): readonly Point[] {
if (!item.metadata || !Array.isArray(item.metadata['points'])) {
return [{ x: item.x, y: item.y }];
}
return item.metadata['points'] as Point[];
}
@@ -0,0 +1,73 @@
import type { Point, FurnitureItem } from '@house-plan-maker/shared';
import { generateLocalId } from '../utils/geometry';
import type { FurnitureDef } from '../symbols/furniture';
export interface FurniturePlacementPreview {
readonly x: number;
readonly y: number;
readonly width: number;
readonly depth: number;
readonly rotation: number;
readonly isValid: boolean;
}
/**
* Compute furniture placement preview.
* The x,y represents the top-left corner of the furniture piece.
* The cursor world point is treated as the desired center, so we offset
* by half-width and half-depth to get the top-left corner.
*/
export function computeFurniturePreview(
worldPoint: Point,
furnitureDef: FurnitureDef,
rotation: number = 0,
): FurniturePlacementPreview {
return {
x: worldPoint.x - furnitureDef.width / 2,
y: worldPoint.y - furnitureDef.depth / 2,
width: furnitureDef.width,
depth: furnitureDef.depth,
rotation,
isValid: true,
};
}
/**
* Create a FurnitureItem from placement data.
*/
export function createFurnitureItemFromPlacement(
roomId: string,
preview: FurniturePlacementPreview,
furnitureDef: FurnitureDef,
): FurnitureItem {
return {
id: generateLocalId(),
roomId,
type: furnitureDef.type,
x: preview.x,
y: preview.y,
width: preview.width,
depth: preview.depth,
height: furnitureDef.height,
rotation: preview.rotation,
elevationFromFloor: furnitureDef.type === 'AC_UNIT' ? 2.2 : furnitureDef.height <= 0.05 ? 1.2 : 0,
label: furnitureDef.label,
};
}
/** Rotate a furniture item by the given delta in degrees. */
export function rotateFurniture(item: FurnitureItem, deltaDeg: number): FurnitureItem {
return {
...item,
rotation: (item.rotation + deltaDeg) % 360,
};
}
/** Move a furniture item to a new position. */
export function moveFurniture(item: FurnitureItem, newPos: Point): FurnitureItem {
return {
...item,
x: newPos.x,
y: newPos.y,
};
}
@@ -0,0 +1,33 @@
import type { Point } from '@house-plan-maker/shared';
import type { MeasurementState, MeasurementResult } from '../types';
import { distance } from '../utils/geometry';
/** Start a new measurement from the given point. */
export function startMeasurement(point: Point): MeasurementState {
return {
startPoint: point,
endPoint: point,
distance: 0,
};
}
/** Update the measurement end point and recalculate distance. */
export function updateMeasurement(
state: MeasurementState,
point: Point,
): MeasurementState {
return {
...state,
endPoint: point,
distance: distance(state.startPoint, point),
};
}
/** Finalize the measurement and return the result. */
export function finishMeasurement(state: MeasurementState): MeasurementResult {
return {
startPoint: state.startPoint,
endPoint: state.endPoint,
distance: distance(state.startPoint, state.endPoint),
};
}
@@ -0,0 +1,240 @@
import type { Point, Wall, WallOpening, ElectricalItem, FurnitureItem } from '@house-plan-maker/shared';
import type { DragState } from '../types';
import { distance } from '../utils/geometry';
import { findNearestWall } from '../utils/wallUtils';
/** Hit-test radius in meters for selecting elements. */
const HIT_RADIUS = 0.15;
export interface SelectToolState {
readonly drag: DragState | null;
}
export function createInitialSelectState(): SelectToolState {
return { drag: null };
}
/**
* Find which element (if any) was clicked.
* Returns the element ID and type, or null.
*/
export function hitTest(
worldPoint: Point,
openings: readonly WallOpening[],
walls: readonly Wall[],
electricalItems: readonly ElectricalItem[],
furnitureItems: readonly FurnitureItem[],
): { id: string; type: 'opening' | 'wall' | 'electrical' | 'furniture' } | null {
// Check openings first (they're on walls)
for (const opening of openings) {
const wall = walls.find((w) => w.id === opening.wallId);
if (!wall) continue;
const wallLen = distance(
{ x: wall.startX, y: wall.startY },
{ x: wall.endX, y: wall.endY },
);
if (wallLen === 0) continue;
const dx = (wall.endX - wall.startX) / wallLen;
const dy = (wall.endY - wall.startY) / wallLen;
const centerX = wall.startX + dx * opening.positionAlongWall;
const centerY = wall.startY + dy * opening.positionAlongWall;
const dist = distance(worldPoint, { x: centerX, y: centerY });
if (dist < Math.max(opening.width / 2, HIT_RADIUS)) {
return { id: opening.id, type: 'opening' };
}
}
// Check electrical items
for (const item of electricalItems) {
const dist = distance(worldPoint, { x: item.x, y: item.y });
if (dist < HIT_RADIUS) {
return { id: item.id, type: 'electrical' };
}
}
// Check furniture items (rotation-aware: transform point into item's local space)
for (const item of furnitureItems) {
const cx = item.x + item.width / 2;
const cy = item.y + item.depth / 2;
// Translate point relative to item center
const dx = worldPoint.x - cx;
const dy = worldPoint.y - cy;
// Rotate point by -rotation to get local coordinates
const rad = -(item.rotation * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const localX = dx * cos - dy * sin;
const localY = dx * sin + dy * cos;
// Check if local point is within the unrotated rectangle
if (
Math.abs(localX) <= item.width / 2 &&
Math.abs(localY) <= item.depth / 2
) {
return { id: item.id, type: 'furniture' };
}
}
// Check walls
const nearestWall = findNearestWall(worldPoint, walls);
if (nearestWall && nearestWall.distance < Math.max(nearestWall.wall.thickness, HIT_RADIUS)) {
return { id: nearestWall.wall.id, type: 'wall' };
}
return null;
}
/**
* Compute the selection rectangle from drag start to current position.
* Returns { x, y, width, height } in world coordinates, normalized so width/height are positive.
*/
export function selectionRect(
start: Point,
current: Point,
): { x: number; y: number; width: number; height: number } {
const x = Math.min(start.x, current.x);
const y = Math.min(start.y, current.y);
const width = Math.abs(current.x - start.x);
const height = Math.abs(current.y - start.y);
return { x, y, width, height };
}
/**
* Find all element IDs inside a selection rectangle.
*/
export function elementsInRect(
rect: { x: number; y: number; width: number; height: number },
openings: readonly WallOpening[],
walls: readonly Wall[],
electricalItems: readonly ElectricalItem[],
furnitureItems: readonly FurnitureItem[],
): ReadonlySet<string> {
const ids = new Set<string>();
for (const opening of openings) {
const wall = walls.find((w) => w.id === opening.wallId);
if (!wall) continue;
const wallLen = distance(
{ x: wall.startX, y: wall.startY },
{ x: wall.endX, y: wall.endY },
);
if (wallLen === 0) continue;
const dx = (wall.endX - wall.startX) / wallLen;
const dy = (wall.endY - wall.startY) / wallLen;
const cx = wall.startX + dx * opening.positionAlongWall;
const cy = wall.startY + dy * opening.positionAlongWall;
if (
cx >= rect.x &&
cx <= rect.x + rect.width &&
cy >= rect.y &&
cy <= rect.y + rect.height
) {
ids.add(opening.id);
}
}
for (const item of electricalItems) {
if (
item.x >= rect.x &&
item.x <= rect.x + rect.width &&
item.y >= rect.y &&
item.y <= rect.y + rect.height
) {
ids.add(item.id);
}
}
for (const item of furnitureItems) {
// x,y is top-left; use center point for selection-rect containment
const cx = item.x + item.width / 2;
const cy = item.y + item.depth / 2;
if (
cx >= rect.x &&
cx <= rect.x + rect.width &&
cy >= rect.y &&
cy <= rect.y + rect.height
) {
ids.add(item.id);
}
}
return ids;
}
/**
* Compute the bounding box of all selected elements (for resize handles).
*/
export function selectedBoundingBox(
selectedIds: ReadonlySet<string>,
openings: readonly WallOpening[],
walls: readonly Wall[],
electricalItems: readonly ElectricalItem[],
furnitureItems: readonly FurnitureItem[],
): { x: number; y: number; width: number; height: number } | null {
const points: Point[] = [];
for (const id of selectedIds) {
const opening = openings.find((o) => o.id === id);
if (opening) {
const wall = walls.find((w) => w.id === opening.wallId);
if (wall) {
const wallLen = distance(
{ x: wall.startX, y: wall.startY },
{ x: wall.endX, y: wall.endY },
);
if (wallLen > 0) {
const dx = (wall.endX - wall.startX) / wallLen;
const dy = (wall.endY - wall.startY) / wallLen;
const cx = wall.startX + dx * opening.positionAlongWall;
const cy = wall.startY + dy * opening.positionAlongWall;
points.push({ x: cx - opening.width / 2, y: cy - opening.width / 2 });
points.push({ x: cx + opening.width / 2, y: cy + opening.width / 2 });
}
}
continue;
}
const elec = electricalItems.find((e) => e.id === id);
if (elec) {
points.push({ x: elec.x - 0.05, y: elec.y - 0.05 });
points.push({ x: elec.x + 0.05, y: elec.y + 0.05 });
continue;
}
const furn = furnitureItems.find((f) => f.id === id);
if (furn) {
// Compute rotated AABB from center + rotation
const cx = furn.x + furn.width / 2;
const cy = furn.y + furn.depth / 2;
const rad = (furn.rotation * Math.PI) / 180;
const cos = Math.abs(Math.cos(rad));
const sin = Math.abs(Math.sin(rad));
const halfW = (furn.width * cos + furn.depth * sin) / 2;
const halfD = (furn.width * sin + furn.depth * cos) / 2;
points.push({ x: cx - halfW, y: cy - halfD });
points.push({ x: cx + halfW, y: cy + halfD });
}
}
if (points.length === 0) return null;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const p of points) {
if (p.x < minX) minX = p.x;
if (p.y < minY) minY = p.y;
if (p.x > maxX) maxX = p.x;
if (p.y > maxY) maxY = p.y;
}
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
}
@@ -0,0 +1,81 @@
import type { Point, Wall, WallOpening } from '@house-plan-maker/shared';
import { findNearestWall, wallLength } from '../utils/wallUtils';
import { generateLocalId } from '../utils/geometry';
import { hasOverlap } from '../utils/openingUtils';
/** Maximum distance (in meters) to the nearest wall for window placement. */
const MAX_WALL_DISTANCE = 0.5;
/** Default window width in meters. */
export const DEFAULT_WINDOW_WIDTH = 1.2;
/** Default window height in meters. */
export const DEFAULT_WINDOW_HEIGHT = 1.4;
/** Default window elevation from floor in meters. */
export const DEFAULT_WINDOW_ELEVATION = 0.9;
export interface WindowPlacementPreview {
readonly wall: Wall;
readonly positionAlongWall: number;
readonly snappedPoint: Point;
readonly width: number;
readonly isValid: boolean;
}
/**
* Compute a window placement preview from a cursor position.
* Returns null if no wall is close enough.
*/
export function computeWindowPreview(
worldPoint: Point,
walls: readonly Wall[],
existingOpenings: readonly WallOpening[],
width: number = DEFAULT_WINDOW_WIDTH,
): WindowPlacementPreview | null {
const nearest = findNearestWall(worldPoint, walls);
if (!nearest || nearest.distance > MAX_WALL_DISTANCE) {
return null;
}
const wLen = wallLength(nearest.wall);
const halfWidth = width / 2;
// Clamp position so the window fits within the wall
const clampedPos = Math.max(halfWidth, Math.min(wLen - halfWidth, nearest.positionAlongWall));
// Check for overlap with existing openings on the same wall
const isValid = !hasOverlap(nearest.wall.id, clampedPos, width, existingOpenings);
return {
wall: nearest.wall,
positionAlongWall: clampedPos,
snappedPoint: nearest.projected,
width,
isValid,
};
}
/**
* Create a WallOpening object for a window.
*/
export function createWindowOpening(
roomId: string,
wallId: string,
positionAlongWall: number,
width: number = DEFAULT_WINDOW_WIDTH,
height: number = DEFAULT_WINDOW_HEIGHT,
elevation: number = DEFAULT_WINDOW_ELEVATION,
): WallOpening {
return {
id: generateLocalId(),
roomId,
wallId,
type: 'WINDOW',
positionAlongWall,
width,
height,
elevationFromFloor: elevation,
openDirection: 'LEFT',
};
}
+186
View File
@@ -0,0 +1,186 @@
import type { Point, Wall, WallOpening, ElectricalItem, FurnitureItem, RoomFull, Annotation } from '@house-plan-maker/shared';
// ── Tool Types ──
export type EditorToolType = 'select' | 'door' | 'window' | 'electrical' | 'furniture' | 'measure' | 'annotate';
export interface CanvasPointerEvent {
/** Position in world/meter coordinates. */
readonly worldPoint: Point;
/** Position in screen/pixel coordinates. */
readonly screenPoint: Point;
/** Whether shift key is held. */
readonly shiftKey: boolean;
/** Whether ctrl/cmd key is held. */
readonly ctrlKey: boolean;
/** The original Konva event (if available). */
readonly originalEvent?: MouseEvent;
}
// ── Selection ──
export type SelectableElementType = 'wall' | 'opening' | 'electrical' | 'furniture';
export interface SelectableElement {
readonly id: string;
readonly type: SelectableElementType;
}
// ── Editor State ──
/** Layer visibility toggles. */
export interface LayerVisibility {
readonly walls: boolean;
readonly electrical: boolean;
readonly furniture: boolean;
readonly measurements: boolean;
readonly annotations: boolean;
}
export interface EditorState {
readonly room: RoomFull;
readonly walls: readonly Wall[];
readonly openings: readonly WallOpening[];
readonly electricalItems: readonly ElectricalItem[];
readonly furnitureItems: readonly FurnitureItem[];
readonly selectedIds: ReadonlySet<string>;
readonly activeTool: EditorToolType;
readonly zoom: number;
readonly panOffset: Point;
readonly gridSize: number;
readonly gridVisible: boolean;
readonly snapEnabled: boolean;
readonly snapGranularity: number;
readonly layerVisibility: LayerVisibility;
/** Index into ELECTRICAL_SYMBOL_DEFS for electrical tool. */
readonly selectedElectricalIndex: number | null;
/** Index into FURNITURE_DEFS for furniture tool. */
readonly selectedFurnitureIndex: number | null;
readonly annotations: readonly Annotation[];
}
// ── Undo/Redo Commands ──
export interface EditorCommand {
readonly description: string;
execute(): void;
undo(): void;
}
// ── Editor Actions ──
export type EditorAction =
| { readonly type: 'SET_ROOM'; readonly room: RoomFull }
| { readonly type: 'UPDATE_ROOM_PROPS'; readonly props: Partial<Pick<RoomFull, 'floorType' | 'wallColor' | 'wallHeight' | 'plinthHeight' | 'plinthThickness'>> }
| { readonly type: 'SET_WALLS'; readonly walls: readonly Wall[] }
| { readonly type: 'UPDATE_WALL'; readonly wall: Wall }
| { readonly type: 'ADD_OPENING'; readonly opening: WallOpening }
| { readonly type: 'UPDATE_OPENING'; readonly opening: WallOpening }
| { readonly type: 'REMOVE_OPENING'; readonly id: string }
| { readonly type: 'ADD_ELECTRICAL'; readonly item: ElectricalItem }
| { readonly type: 'UPDATE_ELECTRICAL'; readonly item: ElectricalItem }
| { readonly type: 'REMOVE_ELECTRICAL'; readonly id: string }
| { readonly type: 'ADD_FURNITURE'; readonly item: FurnitureItem }
| { readonly type: 'UPDATE_FURNITURE'; readonly item: FurnitureItem }
| { readonly type: 'REMOVE_FURNITURE'; readonly id: string }
| { readonly type: 'SET_SELECTED'; readonly ids: ReadonlySet<string> }
| { readonly type: 'ADD_TO_SELECTION'; readonly id: string }
| { readonly type: 'REMOVE_FROM_SELECTION'; readonly id: string }
| { readonly type: 'CLEAR_SELECTION' }
| { readonly type: 'SET_TOOL'; readonly tool: EditorToolType }
| { readonly type: 'SET_ZOOM'; readonly zoom: number }
| { readonly type: 'SET_PAN_OFFSET'; readonly offset: Point }
| { readonly type: 'SET_GRID_SIZE'; readonly gridSize: number }
| { readonly type: 'TOGGLE_GRID' }
| { readonly type: 'TOGGLE_SNAP' }
| { readonly type: 'SET_SNAP_GRANULARITY'; readonly granularity: number }
| { readonly type: 'TOGGLE_LAYER'; readonly layer: keyof LayerVisibility }
| { readonly type: 'SET_ELECTRICAL_INDEX'; readonly index: number | null }
| { readonly type: 'SET_FURNITURE_INDEX'; readonly index: number | null }
| { readonly type: 'DELETE_SELECTED' }
| { readonly type: 'SELECT_ALL' }
// Clipboard
| { readonly type: 'COPY_SELECTED' }
| { readonly type: 'PASTE_CLIPBOARD'; readonly items: PastePayload }
// Alignment
| { readonly type: 'ALIGN_SELECTED'; readonly alignment: AlignmentDirection }
// Annotations
| { readonly type: 'ADD_ANNOTATION'; readonly annotation: Annotation }
| { readonly type: 'UPDATE_ANNOTATION'; readonly annotation: Annotation }
| { readonly type: 'REMOVE_ANNOTATION'; readonly id: string }
// Import
| {
readonly type: 'IMPORT_ROOM';
readonly room: {
readonly name: string;
readonly shape: readonly Point[];
readonly wallHeight: number;
readonly plinthHeight: number;
readonly plinthThickness: number;
};
readonly walls: readonly Wall[];
readonly openings: readonly WallOpening[];
readonly electricalItems: readonly ElectricalItem[];
readonly furnitureItems: readonly FurnitureItem[];
}
// Sync after save (update server IDs without full reload)
| {
readonly type: 'SYNC_SAVE';
readonly walls: readonly Wall[];
readonly openings: readonly WallOpening[];
readonly electricalItems: readonly ElectricalItem[];
readonly furnitureItems: readonly FurnitureItem[];
};
// ── Alignment ──
export type AlignmentDirection =
| 'left'
| 'right'
| 'top'
| 'bottom'
| 'center-h'
| 'center-v'
| 'distribute-h'
| 'distribute-v';
// ── Clipboard ──
export interface PastePayload {
readonly openings: readonly WallOpening[];
readonly electricalItems: readonly ElectricalItem[];
readonly furnitureItems: readonly FurnitureItem[];
readonly annotations: readonly Annotation[];
readonly newSelectedIds: ReadonlySet<string>;
}
// ── Zoom/Pan Config ──
export const MIN_ZOOM = 10; // 10 pixels per meter (very zoomed out)
export const MAX_ZOOM = 500; // 500 pixels per meter (very zoomed in)
export const DEFAULT_ZOOM = 100; // 100 pixels per meter
export const ZOOM_SENSITIVITY = 0.001;
export const DEFAULT_GRID_SIZE = 0.1; // 0.1 meters = 10cm
// ── Measurement State ──
export interface MeasurementState {
readonly startPoint: Point;
readonly endPoint: Point;
readonly distance: number;
}
export interface MeasurementResult {
readonly startPoint: Point;
readonly endPoint: Point;
readonly distance: number;
}
// ── Drag State (for selection tool) ──
export interface DragState {
readonly isDragging: boolean;
readonly startWorld: Point;
readonly currentWorld: Point;
readonly dragType: 'move' | 'select-rect';
}
@@ -0,0 +1,72 @@
import { describe, it, expect } from 'vitest';
import type { FurnitureItem } from '@house-plan-maker/shared';
import { findCollidingFurniture } from '../collisionDetection';
function makeFurniture(overrides: Partial<FurnitureItem> = {}): FurnitureItem {
return {
id: 'f1',
roomId: 'r1',
type: 'TABLE',
x: 0,
y: 0,
width: 1,
depth: 1,
height: 0.75,
rotation: 0,
elevationFromFloor: 0,
label: null,
...overrides,
};
}
describe('findCollidingFurniture', () => {
it('returns empty set for fewer than 2 items', () => {
expect(findCollidingFurniture([])).toEqual(new Set());
expect(findCollidingFurniture([makeFurniture()])).toEqual(new Set());
});
it('detects overlapping furniture', () => {
const items: readonly FurnitureItem[] = [
makeFurniture({ id: 'f1', x: 0, y: 0, width: 2, depth: 2 }),
makeFurniture({ id: 'f2', x: 1, y: 1, width: 2, depth: 2 }),
];
const result = findCollidingFurniture(items);
expect(result.has('f1')).toBe(true);
expect(result.has('f2')).toBe(true);
});
it('returns empty set for non-overlapping furniture', () => {
const items: readonly FurnitureItem[] = [
makeFurniture({ id: 'f1', x: 0, y: 0, width: 1, depth: 1 }),
makeFurniture({ id: 'f2', x: 5, y: 5, width: 1, depth: 1 }),
];
const result = findCollidingFurniture(items);
expect(result.size).toBe(0);
});
it('handles rotated furniture (AABB check)', () => {
// A 2x1 table rotated 90 degrees becomes 1x2 AABB
const items: readonly FurnitureItem[] = [
makeFurniture({ id: 'f1', x: 0, y: 0, width: 2, depth: 0.5, rotation: 0 }),
makeFurniture({ id: 'f2', x: 0, y: 0.5, width: 2, depth: 0.5, rotation: 0 }),
];
const result = findCollidingFurniture(items);
// These should be touching at edge, potentially overlapping depending on centers
// f1 center at (0,0), AABB y range: -0.25 to 0.25
// f2 center at (0,0.5), AABB y range: 0.25 to 0.75
// They touch at y=0.25 but don't overlap (strict inequality)
expect(result.size).toBe(0);
});
it('detects multiple collisions', () => {
const items: readonly FurnitureItem[] = [
makeFurniture({ id: 'f1', x: 0, y: 0, width: 2, depth: 2 }),
makeFurniture({ id: 'f2', x: 0.5, y: 0.5, width: 2, depth: 2 }),
makeFurniture({ id: 'f3', x: 10, y: 10, width: 1, depth: 1 }),
];
const result = findCollidingFurniture(items);
expect(result.has('f1')).toBe(true);
expect(result.has('f2')).toBe(true);
expect(result.has('f3')).toBe(false);
});
});
@@ -0,0 +1,242 @@
import { describe, it, expect } from 'vitest';
import {
distance,
snapToGrid,
snapPointToGrid,
clamp,
projectPointOntoSegment,
segmentAngle,
pointInPolygon,
boundingBox,
rectsOverlap,
generateLocalId,
} from '../geometry';
describe('distance', () => {
it('returns 0 for same point', () => {
expect(distance({ x: 1, y: 2 }, { x: 1, y: 2 })).toBe(0);
});
it('computes horizontal distance', () => {
expect(distance({ x: 0, y: 0 }, { x: 3, y: 0 })).toBe(3);
});
it('computes vertical distance', () => {
expect(distance({ x: 0, y: 0 }, { x: 0, y: 4 })).toBe(4);
});
it('computes diagonal distance (3-4-5 triangle)', () => {
expect(distance({ x: 0, y: 0 }, { x: 3, y: 4 })).toBe(5);
});
it('works with negative coordinates', () => {
expect(distance({ x: -1, y: -1 }, { x: 2, y: 3 })).toBe(5);
});
});
describe('snapToGrid', () => {
it('snaps to nearest multiple', () => {
expect(snapToGrid(0.14, 0.1)).toBeCloseTo(0.1);
});
it('snaps exactly on grid', () => {
expect(snapToGrid(0.2, 0.1)).toBeCloseTo(0.2);
});
it('rounds up when closer to next grid', () => {
expect(snapToGrid(0.16, 0.1)).toBeCloseTo(0.2);
});
it('snaps to 0 for values near 0', () => {
expect(snapToGrid(0.04, 0.1)).toBeCloseTo(0);
});
});
describe('snapPointToGrid', () => {
it('snaps both x and y', () => {
const result = snapPointToGrid({ x: 0.14, y: 0.27 }, 0.1);
expect(result.x).toBeCloseTo(0.1);
expect(result.y).toBeCloseTo(0.3);
});
});
describe('clamp', () => {
it('returns value when in range', () => {
expect(clamp(5, 0, 10)).toBe(5);
});
it('clamps to min', () => {
expect(clamp(-1, 0, 10)).toBe(0);
});
it('clamps to max', () => {
expect(clamp(15, 0, 10)).toBe(10);
});
it('handles min === max', () => {
expect(clamp(5, 3, 3)).toBe(3);
});
});
describe('projectPointOntoSegment', () => {
it('projects onto midpoint of horizontal segment', () => {
const result = projectPointOntoSegment(
{ x: 5, y: 3 },
{ x: 0, y: 0 },
{ x: 10, y: 0 },
);
expect(result.projected.x).toBeCloseTo(5);
expect(result.projected.y).toBeCloseTo(0);
expect(result.t).toBeCloseTo(0.5);
expect(result.distance).toBeCloseTo(3);
});
it('clamps to start of segment', () => {
const result = projectPointOntoSegment(
{ x: -5, y: 0 },
{ x: 0, y: 0 },
{ x: 10, y: 0 },
);
expect(result.projected.x).toBeCloseTo(0);
expect(result.t).toBeCloseTo(0);
});
it('clamps to end of segment', () => {
const result = projectPointOntoSegment(
{ x: 15, y: 0 },
{ x: 0, y: 0 },
{ x: 10, y: 0 },
);
expect(result.projected.x).toBeCloseTo(10);
expect(result.t).toBeCloseTo(1);
});
it('handles zero-length segment', () => {
const result = projectPointOntoSegment(
{ x: 3, y: 4 },
{ x: 0, y: 0 },
{ x: 0, y: 0 },
);
expect(result.t).toBe(0);
expect(result.distance).toBe(5);
});
});
describe('segmentAngle', () => {
it('returns 0 for rightward segment', () => {
expect(segmentAngle({ x: 0, y: 0 }, { x: 1, y: 0 })).toBeCloseTo(0);
});
it('returns PI/2 for downward segment', () => {
expect(segmentAngle({ x: 0, y: 0 }, { x: 0, y: 1 })).toBeCloseTo(Math.PI / 2);
});
it('returns PI for leftward segment', () => {
expect(segmentAngle({ x: 0, y: 0 }, { x: -1, y: 0 })).toBeCloseTo(Math.PI);
});
it('returns -PI/2 for upward segment', () => {
expect(segmentAngle({ x: 0, y: 0 }, { x: 0, y: -1 })).toBeCloseTo(-Math.PI / 2);
});
});
describe('pointInPolygon', () => {
const square = [
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 10, y: 10 },
{ x: 0, y: 10 },
];
it('returns true for point inside', () => {
expect(pointInPolygon({ x: 5, y: 5 }, square)).toBe(true);
});
it('returns false for point outside', () => {
expect(pointInPolygon({ x: 15, y: 5 }, square)).toBe(false);
});
it('returns false for point far outside', () => {
expect(pointInPolygon({ x: -5, y: -5 }, square)).toBe(false);
});
it('handles triangle', () => {
const triangle = [
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 5, y: 10 },
];
expect(pointInPolygon({ x: 5, y: 3 }, triangle)).toBe(true);
expect(pointInPolygon({ x: 9, y: 9 }, triangle)).toBe(false);
});
});
describe('boundingBox', () => {
it('returns zero-box for empty array', () => {
const bb = boundingBox([]);
expect(bb).toEqual({ minX: 0, minY: 0, maxX: 0, maxY: 0 });
});
it('returns point for single point', () => {
const bb = boundingBox([{ x: 3, y: 5 }]);
expect(bb).toEqual({ minX: 3, minY: 5, maxX: 3, maxY: 5 });
});
it('computes correct bounding box', () => {
const bb = boundingBox([
{ x: -1, y: 2 },
{ x: 5, y: -3 },
{ x: 3, y: 7 },
]);
expect(bb).toEqual({ minX: -1, minY: -3, maxX: 5, maxY: 7 });
});
});
describe('rectsOverlap', () => {
it('returns true for overlapping rects', () => {
expect(
rectsOverlap(
{ x: 0, y: 0, width: 5, height: 5 },
{ x: 3, y: 3, width: 5, height: 5 },
),
).toBe(true);
});
it('returns false for non-overlapping rects', () => {
expect(
rectsOverlap(
{ x: 0, y: 0, width: 5, height: 5 },
{ x: 10, y: 10, width: 5, height: 5 },
),
).toBe(false);
});
it('returns false for adjacent rects (touching edges)', () => {
expect(
rectsOverlap(
{ x: 0, y: 0, width: 5, height: 5 },
{ x: 5, y: 0, width: 5, height: 5 },
),
).toBe(false);
});
it('returns true for contained rect', () => {
expect(
rectsOverlap(
{ x: 0, y: 0, width: 10, height: 10 },
{ x: 2, y: 2, width: 3, height: 3 },
),
).toBe(true);
});
});
describe('generateLocalId', () => {
it('starts with "local-"', () => {
expect(generateLocalId()).toMatch(/^local-/);
});
it('generates unique ids', () => {
const ids = new Set(Array.from({ length: 100 }, () => generateLocalId()));
expect(ids.size).toBe(100);
});
});
@@ -0,0 +1,174 @@
import { describe, it, expect } from 'vitest';
import type { Wall, WallOpening } from '@house-plan-maker/shared';
import {
wallStartEnd,
wallLength,
wallAngle,
findNearestWall,
openingWorldPosition,
wallsFromShape,
wallOutlinePolygon,
} from '../wallUtils';
function makeWall(overrides: Partial<Wall> = {}): Wall {
return {
id: 'w1',
roomId: 'r1',
startX: 0,
startY: 0,
endX: 4,
endY: 0,
thickness: 0.1,
direction: 'NORTH' as const,
...overrides,
};
}
describe('wallStartEnd', () => {
it('returns start and end points', () => {
const wall = makeWall({ startX: 1, startY: 2, endX: 3, endY: 4 });
const result = wallStartEnd(wall);
expect(result.start).toEqual({ x: 1, y: 2 });
expect(result.end).toEqual({ x: 3, y: 4 });
});
});
describe('wallLength', () => {
it('computes length of horizontal wall', () => {
expect(wallLength(makeWall())).toBe(4);
});
it('computes length of vertical wall', () => {
expect(wallLength(makeWall({ startX: 0, startY: 0, endX: 0, endY: 3 }))).toBe(3);
});
it('computes length of diagonal wall', () => {
expect(wallLength(makeWall({ startX: 0, startY: 0, endX: 3, endY: 4 }))).toBe(5);
});
it('returns 0 for zero-length wall', () => {
expect(wallLength(makeWall({ startX: 2, startY: 3, endX: 2, endY: 3 }))).toBe(0);
});
});
describe('wallAngle', () => {
it('returns 0 for rightward wall', () => {
expect(wallAngle(makeWall())).toBeCloseTo(0);
});
it('returns PI/2 for downward wall', () => {
expect(wallAngle(makeWall({ endX: 0, endY: 5 }))).toBeCloseTo(Math.PI / 2);
});
});
describe('findNearestWall', () => {
it('returns null for no walls', () => {
expect(findNearestWall({ x: 0, y: 0 }, [])).toBeNull();
});
it('finds nearest wall', () => {
const walls: readonly Wall[] = [
makeWall({ id: 'w1', startX: 0, startY: 0, endX: 4, endY: 0 }),
makeWall({ id: 'w2', startX: 0, startY: 3, endX: 4, endY: 3 }),
];
const result = findNearestWall({ x: 2, y: 0.5 }, walls);
expect(result).not.toBeNull();
expect(result!.wall.id).toBe('w1');
expect(result!.distance).toBeCloseTo(0.5);
});
it('returns position along wall', () => {
const walls: readonly Wall[] = [
makeWall({ startX: 0, startY: 0, endX: 10, endY: 0 }),
];
const result = findNearestWall({ x: 5, y: 1 }, walls);
expect(result).not.toBeNull();
expect(result!.positionAlongWall).toBeCloseTo(5);
});
});
describe('openingWorldPosition', () => {
it('computes center for opening on horizontal wall', () => {
const wall = makeWall({ startX: 0, startY: 0, endX: 10, endY: 0 });
const opening: WallOpening = {
id: 'o1',
roomId: 'r1',
wallId: 'w1',
type: 'DOOR',
positionAlongWall: 5,
width: 1,
height: 2.1,
elevationFromFloor: 0,
openDirection: 'LEFT',
};
const result = openingWorldPosition(opening, wall);
expect(result.center.x).toBeCloseTo(5);
expect(result.center.y).toBeCloseTo(0);
expect(result.start.x).toBeCloseTo(4.5);
expect(result.end.x).toBeCloseTo(5.5);
});
it('handles zero-length wall', () => {
const wall = makeWall({ startX: 3, startY: 3, endX: 3, endY: 3 });
const opening: WallOpening = {
id: 'o1',
roomId: 'r1',
wallId: 'w1',
type: 'WINDOW',
positionAlongWall: 0,
width: 1,
height: 1.2,
elevationFromFloor: 0.9,
openDirection: 'LEFT',
};
const result = openingWorldPosition(opening, wall);
expect(result.center).toEqual({ x: 3, y: 3 });
expect(result.angle).toBe(0);
});
});
describe('wallsFromShape', () => {
it('returns empty for fewer than 2 points', () => {
expect(wallsFromShape([])).toEqual([]);
expect(wallsFromShape([{ x: 0, y: 0 }])).toEqual([]);
});
it('creates walls from rectangular shape', () => {
const shape = [
{ x: 0, y: 0 },
{ x: 4, y: 0 },
{ x: 4, y: 3 },
{ x: 0, y: 3 },
];
const walls = wallsFromShape(shape, 0.1);
expect(walls).toHaveLength(4);
// First wall: top
expect(walls[0]).toEqual({ startX: 0, startY: 0, endX: 4, endY: 0, thickness: 0.1 });
// Last wall: left, closing the loop
expect(walls[3]).toEqual({ startX: 0, startY: 3, endX: 0, endY: 0, thickness: 0.1 });
});
it('uses default thickness', () => {
const walls = wallsFromShape([{ x: 0, y: 0 }, { x: 1, y: 0 }]);
expect(walls[0].thickness).toBe(0.1);
});
});
describe('wallOutlinePolygon', () => {
it('returns 4 corner points for horizontal wall', () => {
const wall = makeWall({ startX: 0, startY: 0, endX: 4, endY: 0, thickness: 0.2 });
const poly = wallOutlinePolygon(wall);
expect(poly).toHaveLength(4);
// For a horizontal wall, perpendicular offset is in Y direction
expect(poly[0].x).toBeCloseTo(0);
expect(poly[0].y).toBeCloseTo(0.1); // +halfThick
expect(poly[1].x).toBeCloseTo(4);
expect(poly[1].y).toBeCloseTo(0.1);
expect(poly[2].x).toBeCloseTo(4);
expect(poly[2].y).toBeCloseTo(-0.1); // -halfThick
expect(poly[3].x).toBeCloseTo(0);
expect(poly[3].y).toBeCloseTo(-0.1);
});
});
@@ -0,0 +1,105 @@
import type { FurnitureItem } from '@house-plan-maker/shared';
interface OBB {
readonly id: string;
readonly cx: number;
readonly cy: number;
readonly halfW: number;
readonly halfD: number;
readonly cos: number;
readonly sin: number;
}
function computeOBB(item: FurnitureItem): OBB {
const rad = (item.rotation * Math.PI) / 180;
return {
id: item.id,
cx: item.x + item.width / 2,
cy: item.y + item.depth / 2,
halfW: item.width / 2,
halfD: item.depth / 2,
cos: Math.cos(rad),
sin: Math.sin(rad),
};
}
/** Get the 4 corners of an OBB. */
function getCorners(obb: OBB): [number, number][] {
const { cx, cy, halfW, halfD, cos, sin } = obb;
// Local corners at (±halfW, ±halfD), rotated and translated
return [
[cx + halfW * cos - halfD * sin, cy + halfW * sin + halfD * cos],
[cx - halfW * cos - halfD * sin, cy - halfW * sin + halfD * cos],
[cx - halfW * cos + halfD * sin, cy - halfW * sin - halfD * cos],
[cx + halfW * cos + halfD * sin, cy + halfW * sin - halfD * cos],
];
}
/** Project corners onto an axis and return [min, max]. */
function projectOntoAxis(corners: [number, number][], ax: number, ay: number): [number, number] {
let min = Infinity;
let max = -Infinity;
for (const [x, y] of corners) {
const p = x * ax + y * ay;
if (p < min) min = p;
if (p > max) max = p;
}
return [min, max];
}
/** SAT overlap test for two OBBs. */
function obbOverlap(a: OBB, b: OBB): boolean {
const cornersA = getCorners(a);
const cornersB = getCorners(b);
// 4 potential separating axes: 2 from each OBB's edges
const axes: [number, number][] = [
[a.cos, a.sin],
[-a.sin, a.cos],
[b.cos, b.sin],
[-b.sin, b.cos],
];
for (const [ax, ay] of axes) {
const [minA, maxA] = projectOntoAxis(cornersA, ax, ay);
const [minB, maxB] = projectOntoAxis(cornersB, ax, ay);
if (maxA <= minB || maxB <= minA) {
return false; // Separating axis found — no overlap
}
}
return true; // No separating axis — overlapping
}
/**
* Find all furniture IDs that collide using proper OBB (rotation-aware) overlap.
*/
export function findCollidingFurniture(
items: readonly FurnitureItem[],
): ReadonlySet<string> {
if (items.length < 2) return new Set();
const obbs = items.map(computeOBB);
const colliding = new Set<string>();
for (let i = 0; i < obbs.length; i++) {
for (let j = i + 1; j < obbs.length; j++) {
// Check vertical overlap first (elevation + height)
const a = items[i];
const b = items[j];
const aBottom = a.elevationFromFloor;
const aTop = a.elevationFromFloor + a.height;
const bBottom = b.elevationFromFloor;
const bTop = b.elevationFromFloor + b.height;
if (aTop <= bBottom || bTop <= aBottom) continue; // no vertical overlap
// Then check 2D footprint overlap
if (obbOverlap(obbs[i], obbs[j])) {
colliding.add(obbs[i].id);
colliding.add(obbs[j].id);
}
}
}
return colliding;
}
@@ -0,0 +1,196 @@
import type { Point } from '@house-plan-maker/shared';
/** Distance between two points. */
export function distance(a: Point, b: Point): number {
const dx = b.x - a.x;
const dy = b.y - a.y;
return Math.sqrt(dx * dx + dy * dy);
}
/** Snap a value to the nearest multiple of `grid`. */
export function snapToGrid(value: number, grid: number): number {
return Math.round(value / grid) * grid;
}
/** Snap a point to the nearest grid intersection. */
export function snapPointToGrid(point: Point, grid: number): Point {
return {
x: snapToGrid(point.x, grid),
y: snapToGrid(point.y, grid),
};
}
/** Clamp a number between min and max. */
export function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
/**
* Project a point onto a line segment and return the closest point on the segment
* plus the parameter t (0..1) along the segment.
*/
export function projectPointOntoSegment(
point: Point,
segStart: Point,
segEnd: Point,
): { projected: Point; t: number; distance: number } {
const dx = segEnd.x - segStart.x;
const dy = segEnd.y - segStart.y;
const lengthSq = dx * dx + dy * dy;
if (lengthSq === 0) {
return { projected: segStart, t: 0, distance: distance(point, segStart) };
}
const t = clamp(
((point.x - segStart.x) * dx + (point.y - segStart.y) * dy) / lengthSq,
0,
1,
);
const projected: Point = {
x: segStart.x + t * dx,
y: segStart.y + t * dy,
};
return { projected, t, distance: distance(point, projected) };
}
/** Compute the angle (in radians) of a segment from start to end. */
export function segmentAngle(start: Point, end: Point): number {
return Math.atan2(end.y - start.y, end.x - start.x);
}
/** Check if a point is inside a polygon (ray casting). */
export function pointInPolygon(point: Point, polygon: readonly Point[]): boolean {
let inside = false;
const n = polygon.length;
for (let i = 0, j = n - 1; i < n; j = i++) {
const xi = polygon[i].x;
const yi = polygon[i].y;
const xj = polygon[j].x;
const yj = polygon[j].y;
const intersect =
yi > point.y !== yj > point.y &&
point.x < ((xj - xi) * (point.y - yi)) / (yj - yi) + xi;
if (intersect) {
inside = !inside;
}
}
return inside;
}
/**
* Compute bounding box of a set of points.
* Returns { minX, minY, maxX, maxY }.
*/
export function boundingBox(points: readonly Point[]): {
minX: number;
minY: number;
maxX: number;
maxY: number;
} {
if (points.length === 0) {
return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const p of points) {
if (p.x < minX) minX = p.x;
if (p.y < minY) minY = p.y;
if (p.x > maxX) maxX = p.x;
if (p.y > maxY) maxY = p.y;
}
return { minX, minY, maxX, maxY };
}
/** Check if two axis-aligned rectangles overlap. */
export function rectsOverlap(
a: { x: number; y: number; width: number; height: number },
b: { x: number; y: number; width: number; height: number },
): boolean {
return (
a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y
);
}
/**
* Compute the area of a polygon using the Shoelace formula.
* Points should be ordered (CW or CCW); the result is always positive.
*/
export function polygonArea(points: readonly Point[]): number {
const n = points.length;
if (n < 3) return 0;
let sum = 0;
for (let i = 0; i < n; i++) {
const current = points[i];
const next = points[(i + 1) % n];
sum += current.x * next.y - next.x * current.y;
}
return Math.abs(sum) / 2;
}
/**
* Compute the perimeter of a polygon (sum of all edge lengths).
*/
export function polygonPerimeter(points: readonly Point[]): number {
const n = points.length;
if (n < 2) return 0;
let perimeter = 0;
for (let i = 0; i < n; i++) {
perimeter += distance(points[i], points[(i + 1) % n]);
}
return perimeter;
}
/**
* Compute the centroid of a polygon.
* Returns the geometric center (average of vertices for simple polygons).
*/
export function polygonCentroid(points: readonly Point[]): Point {
const n = points.length;
if (n === 0) return { x: 0, y: 0 };
if (n === 1) return points[0];
if (n === 2) return { x: (points[0].x + points[1].x) / 2, y: (points[0].y + points[1].y) / 2 };
let cx = 0;
let cy = 0;
let areaSum = 0;
for (let i = 0; i < n; i++) {
const current = points[i];
const next = points[(i + 1) % n];
const cross = current.x * next.y - next.x * current.y;
cx += (current.x + next.x) * cross;
cy += (current.y + next.y) * cross;
areaSum += cross;
}
const area6 = 3 * areaSum; // 6A / 2 = 3 * sum
if (Math.abs(area6) < 1e-10) {
// Degenerate polygon — fall back to average of vertices
let sx = 0;
let sy = 0;
for (const p of points) {
sx += p.x;
sy += p.y;
}
return { x: sx / n, y: sy / n };
}
return { x: cx / area6, y: cy / area6 };
}
/** Generate a simple unique ID for local use. */
export function generateLocalId(): string {
return `local-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
@@ -0,0 +1,13 @@
import type { ElectricalItem } from '@house-plan-maker/shared';
import { ELECTRICAL_SYMBOL_DEFS } from '../symbols/electrical';
/**
* Get the coverage radius for a light fixture, if applicable.
* Returns the radius in meters, or null if not a light type.
*/
export function getLightCoverageRadius(item: ElectricalItem): number | null {
const def = ELECTRICAL_SYMBOL_DEFS.find(
(d) => d.type === item.type && d.coverageRadius != null,
);
return def?.coverageRadius ?? null;
}
@@ -0,0 +1,29 @@
import type { WallOpening } from '@house-plan-maker/shared';
/**
* Check if a new opening at the given position would overlap with existing openings
* on the same wall.
*/
export function hasOverlap(
wallId: string,
position: number,
width: number,
existingOpenings: readonly WallOpening[],
): boolean {
const halfWidth = width / 2;
const newStart = position - halfWidth;
const newEnd = position + halfWidth;
for (const opening of existingOpenings) {
if (opening.wallId !== wallId) continue;
const existingStart = opening.positionAlongWall - opening.width / 2;
const existingEnd = opening.positionAlongWall + opening.width / 2;
if (newStart < existingEnd && newEnd > existingStart) {
return true;
}
}
return false;
}
@@ -0,0 +1,354 @@
import type { Wall, WallOpening, ElectricalItem, FurnitureItem } from '@house-plan-maker/shared';
import { wallLength, wallStartEnd } from './wallUtils';
// ── Constants ──
/** Standard door height in meters. */
export const STANDARD_DOOR_HEIGHT = 2.1;
/** Default elevation for electrical items when elevationFromFloor is null. */
export const DEFAULT_ELEVATIONS: Record<string, number> = {
OUTLET: 0.3,
SWITCH: 1.2,
LIGHT_WALL: 2.0,
JUNCTION_BOX: 0.3,
};
/** Electrical types that are always wall-mounted. */
export const WALL_MOUNTED_ELECTRICAL_TYPES = new Set([
'OUTLET',
'SWITCH',
'LIGHT_WALL',
'JUNCTION_BOX',
]);
/** Wall-mounted furniture types that should appear in projection. */
const WALL_MOUNTED_FURNITURE = new Set(['SHELF', 'BOOKCASE', 'WARDROBE']);
// ── Types ──
/** A projected item's position in the wall elevation coordinate system. */
export interface ProjectedPosition {
/** Horizontal position along the wall from start, in meters. */
readonly alongWall: number;
/** Vertical position from the floor, in meters. */
readonly fromFloor: number;
}
/** A projected rectangle in wall elevation coordinates. */
export interface ProjectedRect {
/** Left edge position along wall, in meters. */
readonly x: number;
/** Bottom edge distance from floor, in meters. */
readonly y: number;
/** Width along wall, in meters. */
readonly width: number;
/** Height from bottom to top, in meters. */
readonly height: number;
}
/** Information about an opening projected onto a wall. */
export interface ProjectedOpening {
readonly opening: WallOpening;
readonly rect: ProjectedRect;
}
/** Information about an electrical item projected onto a wall. */
export interface ProjectedElectrical {
readonly item: ElectricalItem;
readonly position: ProjectedPosition;
readonly elevation: number;
}
/** Information about a furniture item projected onto a wall. */
export interface ProjectedFurniture {
readonly item: FurnitureItem;
readonly rect: ProjectedRect;
}
/** Plinth segment (may be interrupted by door openings). */
export interface PlinthSegment {
readonly x: number;
readonly width: number;
readonly height: number;
}
// ── Coordinate Mapping ──
/** Scale factor for converting meters to projection-view pixels. */
export function projectionScale(
wallLengthM: number,
wallHeightM: number,
viewWidth: number,
viewHeight: number,
padding: number = 40,
): number {
const availableWidth = viewWidth - padding * 2;
const availableHeight = viewHeight - padding * 2;
if (wallLengthM <= 0 || wallHeightM <= 0) return 1;
return Math.min(availableWidth / wallLengthM, availableHeight / wallHeightM);
}
/** Convert projection coordinates (meters) to view pixels. */
export function projectionToPixel(
alongWall: number,
fromFloor: number,
wallHeightM: number,
scale: number,
padding: number = 40,
): { x: number; y: number } {
return {
x: padding + alongWall * scale,
// Y is inverted: floor is at bottom, ceiling at top
y: padding + (wallHeightM - fromFloor) * scale,
};
}
/** Convert view pixels back to projection coordinates (meters). Inverse of projectionToPixel. */
export function pixelToProjection(
pixelX: number,
pixelY: number,
wallHeightM: number,
scale: number,
padding: number = 40,
): { alongWall: number; fromFloor: number } {
return {
alongWall: (pixelX - padding) / scale,
fromFloor: wallHeightM - (pixelY - padding) / scale,
};
}
// ── Opening Projection ──
/** Get all openings that belong to a given wall, projected into wall elevation coords. */
export function projectOpenings(
wall: Wall,
openings: readonly WallOpening[],
): readonly ProjectedOpening[] {
const wallLen = wallLength(wall);
return openings
.filter((o) => o.wallId === wall.id)
.map((opening) => {
const halfWidth = opening.width / 2;
const leftEdge = opening.positionAlongWall - halfWidth;
const isDoor = opening.type === 'DOOR';
const fromFloor = isDoor ? 0 : opening.elevationFromFloor;
const height = isDoor ? STANDARD_DOOR_HEIGHT : opening.height;
return {
opening,
rect: {
x: Math.max(0, leftEdge),
y: fromFloor,
width: Math.min(opening.width, wallLen - Math.max(0, leftEdge)),
height,
},
};
});
}
// ── Electrical Projection ──
/** Project wall-mounted electrical items onto a wall's elevation. */
export function projectElectricalItems(
wall: Wall,
electricalItems: readonly ElectricalItem[],
): readonly ProjectedElectrical[] {
const { start, end } = wallStartEnd(wall);
const wallLen = wallLength(wall);
if (wallLen === 0) return [];
const dx = (end.x - start.x) / wallLen;
const dy = (end.y - start.y) / wallLen;
// Match by wallId first; fall back to proximity for items whose wallId
// became stale after a save (wall IDs change on bulk replace)
const PROXIMITY_THRESHOLD = 0.3;
return electricalItems
.filter((item) => {
// Exact wallId match
if (item.wallId === wall.id) return true;
// Proximity fallback for wall-mounted items with mismatched/null wallId
if (!item.wallId || WALL_MOUNTED_ELECTRICAL_TYPES.has(item.type)) {
const vx = item.x - start.x;
const vy = item.y - start.y;
const perpDist = Math.abs(vx * (-dy) + vy * dx);
const alongWall = vx * dx + vy * dy;
return perpDist < PROXIMITY_THRESHOLD && alongWall >= -0.1 && alongWall <= wallLen + 0.1;
}
return false;
})
.map((item) => {
// Project item position onto wall axis
const vx = item.x - start.x;
const vy = item.y - start.y;
const alongWall = vx * dx + vy * dy;
const elevation =
item.elevationFromFloor ?? DEFAULT_ELEVATIONS[item.type] ?? 1.0;
return {
item,
position: { alongWall: Math.max(0, Math.min(wallLen, alongWall)), fromFloor: elevation },
elevation,
};
});
}
// ── Furniture Projection ──
/**
* Compute the distance from the nearest edge of a furniture item to a wall.
* Returns the gap between the item's closest edge and the wall line.
*/
function furnitureEdgeDistanceToWall(
item: FurnitureItem,
wall: Wall,
): number {
const { start, end } = wallStartEnd(wall);
const wallLen = wallLength(wall);
if (wallLen === 0) return Infinity;
const dx = (end.x - start.x) / wallLen;
const dy = (end.y - start.y) / wallLen;
// x,y is top-left corner; compute center for distance calculation
const cx = item.x + item.width / 2;
const cy = item.y + item.depth / 2;
// Vector from wall start to item center
const vx = cx - start.x;
const vy = cy - start.y;
// Perpendicular distance from center to wall
const centerDist = Math.abs(vx * (-dy) + vy * dx);
// Subtract the item's half-extent in the perpendicular direction
// (approximation: use the larger of width/depth halves)
const halfExtent = Math.max(item.width, item.depth) / 2;
const edgeDist = Math.max(0, centerDist - halfExtent);
// Along-wall distance: item must overlap with the wall's length
const alongWall = vx * dx + vy * dy;
const halfWidth = Math.max(item.width, item.depth) / 2;
if (alongWall < -halfWidth || alongWall > wallLen + halfWidth) return Infinity;
return edgeDist;
}
/** Project furniture items that are near a wall into elevation coords. */
export function projectFurnitureItems(
wall: Wall,
furnitureItems: readonly FurnitureItem[],
wallThreshold: number = 0.15,
): readonly ProjectedFurniture[] {
const { start, end } = wallStartEnd(wall);
const wallLen = wallLength(wall);
if (wallLen === 0) return [];
const dx = (end.x - start.x) / wallLen;
const dy = (end.y - start.y) / wallLen;
return furnitureItems
.filter((item) => {
const dist = furnitureEdgeDistanceToWall(item, wall);
return dist < wallThreshold;
})
.map((item) => {
// x,y is top-left corner; compute center for wall projection
const cx = item.x + item.width / 2;
const cy = item.y + item.depth / 2;
const vx = cx - start.x;
const vy = cy - start.y;
const alongWall = vx * dx + vy * dy;
// For wall projection, use the item's depth as the "width" we see from the side
// and height as the vertical extent
const projectedWidth = item.width;
const fromFloor = item.elevationFromFloor ?? 0;
return {
item,
rect: {
x: Math.max(0, alongWall - projectedWidth / 2),
y: fromFloor,
width: projectedWidth,
height: item.height,
},
};
});
}
// ── Plinth Segments ──
/** Compute plinth segments, interrupted at door openings. */
export function computePlinthSegments(
wall: Wall,
openings: readonly WallOpening[],
plinthHeight: number,
): readonly PlinthSegment[] {
const wallLen = wallLength(wall);
if (wallLen <= 0 || plinthHeight <= 0) return [];
// Collect door gaps (sorted by position)
const doors = openings
.filter((o) => o.wallId === wall.id && o.type === 'DOOR')
.map((o) => ({
start: Math.max(0, o.positionAlongWall - o.width / 2),
end: Math.min(wallLen, o.positionAlongWall + o.width / 2),
}))
.sort((a, b) => a.start - b.start);
if (doors.length === 0) {
return [{ x: 0, width: wallLen, height: plinthHeight }];
}
const segments: PlinthSegment[] = [];
let cursor = 0;
for (const door of doors) {
if (door.start > cursor) {
segments.push({
x: cursor,
width: door.start - cursor,
height: plinthHeight,
});
}
cursor = door.end;
}
if (cursor < wallLen) {
segments.push({
x: cursor,
width: wallLen - cursor,
height: plinthHeight,
});
}
return segments;
}
/** Get an i18n key for a wall direction label. */
export function wallDirectionKey(wall: Wall): string {
switch (wall.direction) {
case 'NORTH': return 'wall.north';
case 'SOUTH': return 'wall.south';
case 'EAST': return 'wall.east';
case 'WEST': return 'wall.west';
default: return 'wall.other';
}
}
/** Get a human-readable label for a wall direction (legacy, non-i18n). */
export function wallDirectionLabel(wall: Wall): string {
switch (wall.direction) {
case 'NORTH': return 'North Wall';
case 'SOUTH': return 'South Wall';
case 'EAST': return 'East Wall';
case 'WEST': return 'West Wall';
default: return `Wall ${wall.id.slice(0, 6)}`;
}
}

Some files were not shown because too many files have changed in this diff Show More