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