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:
+35
@@ -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*
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"arrowParens": "always"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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": "Сменить язык"
|
||||
}
|
||||
@@ -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²
|
||||
</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')}
|
||||
>
|
||||
↩
|
||||
</button>
|
||||
<button
|
||||
className={styles.actionBtn}
|
||||
onClick={redo}
|
||||
disabled={!canRedo}
|
||||
title={t('toolbar.redo')}
|
||||
>
|
||||
↪
|
||||
</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')}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className={styles.zoomLabel}>{zoomPercent}%</span>
|
||||
<button
|
||||
className={styles.actionBtn}
|
||||
onClick={() => setZoom(zoom * 1.2)}
|
||||
title={t('toolbar.zoomIn')}
|
||||
>
|
||||
+
|
||||
</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')}
|
||||
>
|
||||
⬆
|
||||
</button>
|
||||
)}
|
||||
{onExport && (
|
||||
<button
|
||||
className={styles.actionBtn}
|
||||
onClick={onExport}
|
||||
title={t('toolbar.export')}
|
||||
>
|
||||
⬇
|
||||
</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}`}
|
||||
>
|
||||
▼
|
||||
</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',
|
||||
};
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user