diff --git a/apps/client/public/locales/en/translation.json b/apps/client/public/locales/en/translation.json index ea6a7b1..ec8bd5e 100644 --- a/apps/client/public/locales/en/translation.json +++ b/apps/client/public/locales/en/translation.json @@ -22,6 +22,8 @@ "furniture.other": "Other", "nav.apartments": "Apartments", + "nav.collapse": "Collapse sidebar", + "nav.expand": "Expand sidebar", "breadcrumb.apartments": "Apartments", "breadcrumb.apartmentDetails": "Apartment Details", @@ -108,6 +110,13 @@ "roomCard.edit": "Edit", "roomCard.delete": "Delete", + "roomCard.clone": "Clone", + "view3d.lightControls": "Light", + "view3d.azimuth": "Azimuth", + "view3d.elevation": "Elevation", + "view3d.intensity": "Intensity", + "view3d.reset": "Reset", + "view3d.doorsOpen": "Show doors open", "common.cancel": "Cancel", "common.delete": "Delete", @@ -162,6 +171,8 @@ "toolbar.distributeV": "Distribute vertical", "properties.title": "Properties", + "properties.collapse": "Collapse panel", + "properties.expand": "Expand panel", "properties.area": "Area", "properties.perimeter": "Perimeter", "properties.noSelection": "No element selected", @@ -197,6 +208,14 @@ "properties.yes": "Yes", "properties.depth": "Depth", "properties.wallColor": "Wall color", + "properties.wallFinish": "Wall finish", + "properties.wallColorPaintOnly": "Wall color only applies to the Paint finish", + "wallFinish.PAINT": "Paint", + "wallFinish.PLASTER": "Plaster", + "wallFinish.BRICK": "Brick", + "wallFinish.CONCRETE": "Concrete", + "wallFinish.WOOD_PANEL": "Wood panel", + "wallFinish.WALLPAPER": "Wallpaper", "properties.floorType": "Floor", "floor.CONCRETE": "Concrete", "floor.WOOD_LIGHT": "Light Wood", @@ -207,6 +226,31 @@ "floor.TILE_GRAY": "Gray Tile", "floor.LAMINATE": "Laminate", "properties.addNote": "Add note", + "properties.showProjection": "Show on wall projection", + "properties.opacity": "Opacity", + "properties.customLabel": "Title", + "properties.windowGridCols": "Grid columns", + "properties.windowGridRows": "Grid rows", + "properties.windowSlopeDepth": "Reveal depth", + "properties.openingFrameThickness": "Frame thickness", + "properties.shelfRows": "Shelf rows", + "properties.hasBackPanel": "Back panel", + "properties.curtainOpen": "Open", + "properties.curtainLeftOpen": "Left open", + "properties.curtainRightOpen": "Right open", + "properties.curtainFabricColor": "Fabric color", + "properties.outletWidth": "Outlet width", + "properties.outletHeight": "Outlet height", + "properties.outletCount": "Count", + "properties.anchor": "Anchor", + "anchor.left": "Left", + "anchor.middle": "Middle", + "anchor.right": "Right", + "anchor.top": "Top", + "anchor.bottom": "Bottom", + "toolbar.furnitureOpacity": "Furniture opacity", + "annotation.edit": "Edit", + "annotation.delete": "Delete", "properties.stand": "Stand", "properties.openDirection": "Open direction", "properties.openDir.LEFT": "Left", @@ -226,6 +270,18 @@ "electrical.cable": "Cable", "furniture.title": "Furniture", + "furniture.searchPlaceholder": "Search furniture\u2026", + "furniture.noResults": "No matching furniture", + "electrical.searchPlaceholder": "Search electrical\u2026", + "electrical.noResults": "No matching items", + "furnitureCategory.all": "All", + "furnitureCategory.sleeping": "Sleeping", + "furnitureCategory.seating": "Seating", + "furnitureCategory.tables": "Tables", + "furnitureCategory.storage": "Storage", + "furnitureCategory.electronics": "Electronics", + "furnitureCategory.climate": "Climate", + "furnitureCategory.decor": "Decor", "cableLength.label": "Cable length:", diff --git a/apps/client/public/locales/ru/translation.json b/apps/client/public/locales/ru/translation.json index 5cf23a3..c0ebbc2 100644 --- a/apps/client/public/locales/ru/translation.json +++ b/apps/client/public/locales/ru/translation.json @@ -22,6 +22,8 @@ "furniture.other": "Другое", "nav.apartments": "Квартиры", + "nav.collapse": "Свернуть боковую панель", + "nav.expand": "Развернуть боковую панель", "breadcrumb.apartments": "Квартиры", "breadcrumb.apartmentDetails": "Детали квартиры", @@ -111,6 +113,13 @@ "roomCard.edit": "Изменить", "roomCard.delete": "Удалить", + "roomCard.clone": "Дублировать", + "view3d.lightControls": "Свет", + "view3d.azimuth": "Азимут", + "view3d.elevation": "Высота", + "view3d.intensity": "Интенсивность", + "view3d.reset": "Сброс", + "view3d.doorsOpen": "Показать двери открытыми", "common.cancel": "Отмена", "common.delete": "Удалить", @@ -165,6 +174,8 @@ "toolbar.distributeV": "Распределить по вертикали", "properties.title": "Свойства", + "properties.collapse": "Свернуть панель", + "properties.expand": "Развернуть панель", "properties.area": "Площадь", "properties.perimeter": "Периметр", "properties.noSelection": "Элемент не выбран", @@ -200,6 +211,14 @@ "properties.yes": "Да", "properties.depth": "Глубина", "properties.wallColor": "Цвет стен", + "properties.wallFinish": "Отделка стен", + "properties.wallColorPaintOnly": "Цвет применяется только к покраске", + "wallFinish.PAINT": "Покраска", + "wallFinish.PLASTER": "Штукатурка", + "wallFinish.BRICK": "Кирпич", + "wallFinish.CONCRETE": "Бетон", + "wallFinish.WOOD_PANEL": "Деревянная панель", + "wallFinish.WALLPAPER": "Обои", "properties.floorType": "Пол", "floor.CONCRETE": "Бетон", "floor.WOOD_LIGHT": "Светлое дерево", @@ -210,6 +229,31 @@ "floor.TILE_GRAY": "Серая плитка", "floor.LAMINATE": "Ламинат", "properties.addNote": "Добавить заметку", + "properties.showProjection": "Показать на проекции стены", + "properties.opacity": "Прозрачность", + "properties.customLabel": "Название", + "properties.windowGridCols": "Сетка: столбцы", + "properties.windowGridRows": "Сетка: строки", + "properties.windowSlopeDepth": "Глубина откоса", + "properties.openingFrameThickness": "Толщина рамы", + "properties.shelfRows": "Количество полок", + "properties.hasBackPanel": "Задняя стенка", + "properties.curtainOpen": "Раскрытие", + "properties.curtainLeftOpen": "Левая створка", + "properties.curtainRightOpen": "Правая створка", + "properties.curtainFabricColor": "Цвет ткани", + "properties.outletWidth": "Ширина розетки", + "properties.outletHeight": "Высота розетки", + "properties.outletCount": "Количество", + "properties.anchor": "Привязка", + "anchor.left": "Слева", + "anchor.middle": "По центру", + "anchor.right": "Справа", + "anchor.top": "Сверху", + "anchor.bottom": "Снизу", + "toolbar.furnitureOpacity": "Прозрачность мебели", + "annotation.edit": "Изменить", + "annotation.delete": "Удалить", "properties.stand": "Подставка", "properties.openDirection": "Направление открытия", "properties.openDir.LEFT": "Влево", @@ -229,6 +273,18 @@ "electrical.cable": "Кабель", "furniture.title": "Мебель", + "furniture.searchPlaceholder": "Поиск мебели\u2026", + "furniture.noResults": "Ничего не найдено", + "electrical.searchPlaceholder": "Поиск элементов\u2026", + "electrical.noResults": "Ничего не найдено", + "furnitureCategory.all": "Все", + "furnitureCategory.sleeping": "Спальня", + "furnitureCategory.seating": "Сиденья", + "furnitureCategory.tables": "Столы", + "furnitureCategory.storage": "Хранение", + "furnitureCategory.electronics": "Электроника", + "furnitureCategory.climate": "Климат", + "furnitureCategory.decor": "Декор", "cableLength.label": "Длина кабеля:", diff --git a/apps/client/public/textures/floors/concrete/color.jpg b/apps/client/public/textures/floors/concrete/color.jpg new file mode 100644 index 0000000..bffa2c2 Binary files /dev/null and b/apps/client/public/textures/floors/concrete/color.jpg differ diff --git a/apps/client/public/textures/floors/concrete/normal.jpg b/apps/client/public/textures/floors/concrete/normal.jpg new file mode 100644 index 0000000..8233e13 Binary files /dev/null and b/apps/client/public/textures/floors/concrete/normal.jpg differ diff --git a/apps/client/public/textures/floors/concrete/roughness.jpg b/apps/client/public/textures/floors/concrete/roughness.jpg new file mode 100644 index 0000000..3a37112 Binary files /dev/null and b/apps/client/public/textures/floors/concrete/roughness.jpg differ diff --git a/apps/client/public/textures/floors/laminate/color.jpg b/apps/client/public/textures/floors/laminate/color.jpg new file mode 100644 index 0000000..6abb7a8 Binary files /dev/null and b/apps/client/public/textures/floors/laminate/color.jpg differ diff --git a/apps/client/public/textures/floors/laminate/normal.jpg b/apps/client/public/textures/floors/laminate/normal.jpg new file mode 100644 index 0000000..50bb206 Binary files /dev/null and b/apps/client/public/textures/floors/laminate/normal.jpg differ diff --git a/apps/client/public/textures/floors/laminate/roughness.jpg b/apps/client/public/textures/floors/laminate/roughness.jpg new file mode 100644 index 0000000..715fae1 Binary files /dev/null and b/apps/client/public/textures/floors/laminate/roughness.jpg differ diff --git a/apps/client/public/textures/floors/tile_gray/color.jpg b/apps/client/public/textures/floors/tile_gray/color.jpg new file mode 100644 index 0000000..b3353bb Binary files /dev/null and b/apps/client/public/textures/floors/tile_gray/color.jpg differ diff --git a/apps/client/public/textures/floors/tile_gray/normal.jpg b/apps/client/public/textures/floors/tile_gray/normal.jpg new file mode 100644 index 0000000..5846e87 Binary files /dev/null and b/apps/client/public/textures/floors/tile_gray/normal.jpg differ diff --git a/apps/client/public/textures/floors/tile_gray/roughness.jpg b/apps/client/public/textures/floors/tile_gray/roughness.jpg new file mode 100644 index 0000000..e09e652 Binary files /dev/null and b/apps/client/public/textures/floors/tile_gray/roughness.jpg differ diff --git a/apps/client/public/textures/floors/tile_white/color.jpg b/apps/client/public/textures/floors/tile_white/color.jpg new file mode 100644 index 0000000..9bdf27b Binary files /dev/null and b/apps/client/public/textures/floors/tile_white/color.jpg differ diff --git a/apps/client/public/textures/floors/tile_white/normal.jpg b/apps/client/public/textures/floors/tile_white/normal.jpg new file mode 100644 index 0000000..c0d683d Binary files /dev/null and b/apps/client/public/textures/floors/tile_white/normal.jpg differ diff --git a/apps/client/public/textures/floors/tile_white/roughness.jpg b/apps/client/public/textures/floors/tile_white/roughness.jpg new file mode 100644 index 0000000..2501803 Binary files /dev/null and b/apps/client/public/textures/floors/tile_white/roughness.jpg differ diff --git a/apps/client/public/textures/floors/wood_dark/color.jpg b/apps/client/public/textures/floors/wood_dark/color.jpg new file mode 100644 index 0000000..c3e0d1c Binary files /dev/null and b/apps/client/public/textures/floors/wood_dark/color.jpg differ diff --git a/apps/client/public/textures/floors/wood_dark/normal.jpg b/apps/client/public/textures/floors/wood_dark/normal.jpg new file mode 100644 index 0000000..806334a Binary files /dev/null and b/apps/client/public/textures/floors/wood_dark/normal.jpg differ diff --git a/apps/client/public/textures/floors/wood_dark/roughness.jpg b/apps/client/public/textures/floors/wood_dark/roughness.jpg new file mode 100644 index 0000000..f922357 Binary files /dev/null and b/apps/client/public/textures/floors/wood_dark/roughness.jpg differ diff --git a/apps/client/public/textures/floors/wood_herringbone/color.jpg b/apps/client/public/textures/floors/wood_herringbone/color.jpg new file mode 100644 index 0000000..e9c2b95 Binary files /dev/null and b/apps/client/public/textures/floors/wood_herringbone/color.jpg differ diff --git a/apps/client/public/textures/floors/wood_herringbone/normal.jpg b/apps/client/public/textures/floors/wood_herringbone/normal.jpg new file mode 100644 index 0000000..882f5ed Binary files /dev/null and b/apps/client/public/textures/floors/wood_herringbone/normal.jpg differ diff --git a/apps/client/public/textures/floors/wood_herringbone/roughness.jpg b/apps/client/public/textures/floors/wood_herringbone/roughness.jpg new file mode 100644 index 0000000..7a5a28d Binary files /dev/null and b/apps/client/public/textures/floors/wood_herringbone/roughness.jpg differ diff --git a/apps/client/public/textures/floors/wood_light/color.jpg b/apps/client/public/textures/floors/wood_light/color.jpg new file mode 100644 index 0000000..7ff97a0 Binary files /dev/null and b/apps/client/public/textures/floors/wood_light/color.jpg differ diff --git a/apps/client/public/textures/floors/wood_light/normal.jpg b/apps/client/public/textures/floors/wood_light/normal.jpg new file mode 100644 index 0000000..50bb206 Binary files /dev/null and b/apps/client/public/textures/floors/wood_light/normal.jpg differ diff --git a/apps/client/public/textures/floors/wood_light/roughness.jpg b/apps/client/public/textures/floors/wood_light/roughness.jpg new file mode 100644 index 0000000..459f3f9 Binary files /dev/null and b/apps/client/public/textures/floors/wood_light/roughness.jpg differ diff --git a/apps/client/public/textures/floors/wood_medium/color.jpg b/apps/client/public/textures/floors/wood_medium/color.jpg new file mode 100644 index 0000000..5a7f7b4 Binary files /dev/null and b/apps/client/public/textures/floors/wood_medium/color.jpg differ diff --git a/apps/client/public/textures/floors/wood_medium/normal.jpg b/apps/client/public/textures/floors/wood_medium/normal.jpg new file mode 100644 index 0000000..8f439a2 Binary files /dev/null and b/apps/client/public/textures/floors/wood_medium/normal.jpg differ diff --git a/apps/client/public/textures/floors/wood_medium/roughness.jpg b/apps/client/public/textures/floors/wood_medium/roughness.jpg new file mode 100644 index 0000000..0442169 Binary files /dev/null and b/apps/client/public/textures/floors/wood_medium/roughness.jpg differ diff --git a/apps/client/public/textures/walls/brick/color.jpg b/apps/client/public/textures/walls/brick/color.jpg new file mode 100644 index 0000000..87306e6 Binary files /dev/null and b/apps/client/public/textures/walls/brick/color.jpg differ diff --git a/apps/client/public/textures/walls/brick/normal.jpg b/apps/client/public/textures/walls/brick/normal.jpg new file mode 100644 index 0000000..03886c0 Binary files /dev/null and b/apps/client/public/textures/walls/brick/normal.jpg differ diff --git a/apps/client/public/textures/walls/brick/roughness.jpg b/apps/client/public/textures/walls/brick/roughness.jpg new file mode 100644 index 0000000..9d2394c Binary files /dev/null and b/apps/client/public/textures/walls/brick/roughness.jpg differ diff --git a/apps/client/public/textures/walls/concrete/color.jpg b/apps/client/public/textures/walls/concrete/color.jpg new file mode 100644 index 0000000..1267496 Binary files /dev/null and b/apps/client/public/textures/walls/concrete/color.jpg differ diff --git a/apps/client/public/textures/walls/concrete/normal.jpg b/apps/client/public/textures/walls/concrete/normal.jpg new file mode 100644 index 0000000..233bb2b Binary files /dev/null and b/apps/client/public/textures/walls/concrete/normal.jpg differ diff --git a/apps/client/public/textures/walls/concrete/roughness.jpg b/apps/client/public/textures/walls/concrete/roughness.jpg new file mode 100644 index 0000000..f54a53a Binary files /dev/null and b/apps/client/public/textures/walls/concrete/roughness.jpg differ diff --git a/apps/client/public/textures/walls/plaster/color.jpg b/apps/client/public/textures/walls/plaster/color.jpg new file mode 100644 index 0000000..c24504b Binary files /dev/null and b/apps/client/public/textures/walls/plaster/color.jpg differ diff --git a/apps/client/public/textures/walls/plaster/normal.jpg b/apps/client/public/textures/walls/plaster/normal.jpg new file mode 100644 index 0000000..a64287e Binary files /dev/null and b/apps/client/public/textures/walls/plaster/normal.jpg differ diff --git a/apps/client/public/textures/walls/plaster/roughness.jpg b/apps/client/public/textures/walls/plaster/roughness.jpg new file mode 100644 index 0000000..3f1c3eb Binary files /dev/null and b/apps/client/public/textures/walls/plaster/roughness.jpg differ diff --git a/apps/client/public/textures/walls/wallpaper/color.jpg b/apps/client/public/textures/walls/wallpaper/color.jpg new file mode 100644 index 0000000..b92f6f4 Binary files /dev/null and b/apps/client/public/textures/walls/wallpaper/color.jpg differ diff --git a/apps/client/public/textures/walls/wallpaper/normal.jpg b/apps/client/public/textures/walls/wallpaper/normal.jpg new file mode 100644 index 0000000..d9008e6 Binary files /dev/null and b/apps/client/public/textures/walls/wallpaper/normal.jpg differ diff --git a/apps/client/public/textures/walls/wallpaper/roughness.jpg b/apps/client/public/textures/walls/wallpaper/roughness.jpg new file mode 100644 index 0000000..9a2d9da Binary files /dev/null and b/apps/client/public/textures/walls/wallpaper/roughness.jpg differ diff --git a/apps/client/public/textures/walls/wood_panel/color.jpg b/apps/client/public/textures/walls/wood_panel/color.jpg new file mode 100644 index 0000000..d6112e0 Binary files /dev/null and b/apps/client/public/textures/walls/wood_panel/color.jpg differ diff --git a/apps/client/public/textures/walls/wood_panel/normal.jpg b/apps/client/public/textures/walls/wood_panel/normal.jpg new file mode 100644 index 0000000..a20e150 Binary files /dev/null and b/apps/client/public/textures/walls/wood_panel/normal.jpg differ diff --git a/apps/client/public/textures/walls/wood_panel/roughness.jpg b/apps/client/public/textures/walls/wood_panel/roughness.jpg new file mode 100644 index 0000000..a1776c0 Binary files /dev/null and b/apps/client/public/textures/walls/wood_panel/roughness.jpg differ diff --git a/apps/client/src/api/client.ts b/apps/client/src/api/client.ts index 376c7d6..ed259f2 100644 --- a/apps/client/src/api/client.ts +++ b/apps/client/src/api/client.ts @@ -21,6 +21,8 @@ import type { BatchSyncOpeningsDto, BatchSyncElectricalDto, BatchSyncFurnitureDto, + BatchSyncAnnotationsDto, + Annotation, ApiResponse, ApiListResponse, ApiErrorResponse, @@ -157,6 +159,13 @@ export async function deleteRoom(id: string): Promise { await request(`/rooms/${id}`, { method: 'DELETE' }); } +export async function cloneRoom(id: string): Promise { + const result = await request>(`/rooms/${id}/clone`, { + method: 'POST', + }); + return result.data; +} + // ── Walls ── export async function bulkUpdateWalls( @@ -337,4 +346,18 @@ export async function batchSyncFurniture( return result.data; } +export async function batchSyncAnnotations( + roomId: string, + data: BatchSyncAnnotationsDto, +): Promise { + const result = await request>( + `/rooms/${roomId}/annotations/batch`, + { + method: 'PUT', + body: JSON.stringify(data), + }, + ); + return result.data; +} + export { ApiError }; diff --git a/apps/client/src/components/editor/EditorCanvas.tsx b/apps/client/src/components/editor/EditorCanvas.tsx index d25c12c..69ff698 100644 --- a/apps/client/src/components/editor/EditorCanvas.tsx +++ b/apps/client/src/components/editor/EditorCanvas.tsx @@ -1,6 +1,6 @@ import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { Stage } from 'react-konva'; +import { Stage, Layer } from 'react-konva'; import type Konva from 'konva'; import type { Point } from '@house-plan-maker/shared'; import { useZoomPan, useSelection, useSceneData } from './context/EditorContext'; @@ -31,6 +31,7 @@ import { FURNITURE_DEFS } from './symbols/furniture'; import { AnnotationLayer } from './layers/AnnotationLayer'; import { MeasureOverlayLayer } from './layers/MeasureOverlayLayer'; import { generateLocalId } from './utils/geometry'; +import { TextPromptModal } from '../ui/TextPromptModal'; import type { EditorCommand, MeasurementState } from './types'; interface EditorCanvasProps { @@ -72,6 +73,7 @@ export function EditorCanvas({ width, height, onStageRef }: EditorCanvasProps) { selectedElectricalIndex, selectedFurnitureIndex, annotations, + globalFurnitureOpacity, dispatch: sceneDispatch, addOpening, addElectrical, @@ -124,6 +126,13 @@ export function EditorCanvas({ width, height, onStageRef }: EditorCanvasProps) { // ── Opening placement preview ── const [openingPreview, setOpeningPreview] = useState(null); + // ── Annotation editing modal ── + const [editingAnnotationId, setEditingAnnotationId] = useState(null); + const editingAnnotation = useMemo( + () => (editingAnnotationId ? annotations.find((a) => a.id === editingAnnotationId) ?? null : null), + [editingAnnotationId, annotations], + ); + // ── Measurement tool state ��─ const [measureState, setMeasureState] = useState(null); const isMeasuringRef = useRef(false); @@ -502,120 +511,135 @@ export function EditorCanvas({ width, height, onStageRef }: EditorCanvasProps) { onMouseUp={handleMouseUp} style={{ cursor, background: '#ffffff' }} > - {/* Layer 1: Grid + rulers */} - + {/* + Konva renders one HTML per ; performance recommends 3-5 + layers max. The previous tree had 10 Layers — one per logical group — + which fired runtime warnings on Stage. We now collapse them into 3 + actual canvases (background, content, overlay) and use Group internally + for each logical "layer". Visibility/listening props are preserved on + the Group roots. + */} - {/* Layer 2: Walls + room fill */} - {layerVisibility.walls && ( - + - )} + - {/* Layer 3: Openings (doors + windows) */} - + {/* Content canvas — room geometry, items, annotations, measurements */} + + {layerVisibility.walls && ( + + )} - {/* Layer 4: Electrical */} - - - {/* Layer 5: Furniture */} - - - {/* Layer 6: Measurements */} - {layerVisibility.measurements && ( - - )} - - {/* Layer 7: Room labels */} - {layerVisibility.measurements && ( - - )} - {/* Layer 8: Annotations */} - {layerVisibility.annotations && ( - 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 }); - } - }} + visible={layerVisibility.electrical} + outletWidth={room.outletWidth} + outletHeight={room.outletHeight} /> - )} - {/* Layer 9: Measure overlay */} - + - {/* Layer 10: Selection overlay */} - + {layerVisibility.measurements && ( + + )} + + {layerVisibility.measurements && ( + + )} + + {layerVisibility.annotations && ( + selectElement(id)} + onDragEnd={(id, x, y) => { + const ann = annotations.find((a) => a.id === id); + if (ann) updateAnnotation({ ...ann, x, y }); + }} + onDoubleClick={(id) => setEditingAnnotationId(id)} + /> + )} + + + {/* Overlay canvas — transient interaction feedback (measure tool, selection) */} + + + + + { + if (editingAnnotation && value !== editingAnnotation.text) { + updateAnnotation({ ...editingAnnotation, text: value }); + } + setEditingAnnotationId(null); + }} + onCancel={() => setEditingAnnotationId(null)} + /> ); } diff --git a/apps/client/src/components/editor/EditorToolbar.tsx b/apps/client/src/components/editor/EditorToolbar.tsx index 89ffef1..0f3814d 100644 --- a/apps/client/src/components/editor/EditorToolbar.tsx +++ b/apps/client/src/components/editor/EditorToolbar.tsx @@ -53,7 +53,7 @@ export function EditorToolbar({ onSave, isSaving, onExport, onImport }: EditorTo const { state, setTool, setZoom, dispatch } = useEditor(); const { undo, redo, canUndo, canRedo } = useUndoRedo(); - const { activeTool, zoom, gridVisible, snapEnabled, layerVisibility } = state; + const { activeTool, zoom, gridVisible, snapEnabled, layerVisibility, globalFurnitureOpacity } = state; const zoomPercent = Math.round((zoom / 100) * 100); @@ -193,6 +193,37 @@ export function EditorToolbar({ onSave, isSaving, onExport, onImport }: EditorTo > {t('toolbar.meas')} + {/* Alignment tools — visible when 2+ items selected */} diff --git a/apps/client/src/components/editor/PropertiesPanel.tsx b/apps/client/src/components/editor/PropertiesPanel.tsx index 53c2ff6..c7bee10 100644 --- a/apps/client/src/components/editor/PropertiesPanel.tsx +++ b/apps/client/src/components/editor/PropertiesPanel.tsx @@ -1,21 +1,70 @@ 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 type { Wall, WallOpening, ElectricalItem, FurnitureItem, DoorOpenDirection, FloorType, WallFinish, Annotation, PositionAnchor, HorizontalAnchor, VerticalAnchor } from '@house-plan-maker/shared'; +import { TextPromptModal } from '../ui/TextPromptModal'; +import { DOOR_OPEN_DIRECTIONS, FLOOR_TYPES, WALL_FINISHES, HORIZONTAL_ANCHORS, VERTICAL_ANCHORS } 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 { normalizeAngleDegrees } from './utils/angle'; import { getElectricalVariant, ELECTRICAL_SYMBOL_DEFS } from './symbols/electrical'; +import { + getCurtainLeftOpen, + getCurtainRightOpen, + getCurtainFabricColor, +} from './utils/curtainMetadata'; import type { EditorCommand } from './types'; import styles from './properties-panel.module.css'; +const PROPERTIES_COLLAPSED_KEY = 'editor.propertiesPanel.collapsed'; + +function readCollapsed(): boolean { + try { + return localStorage.getItem(PROPERTIES_COLLAPSED_KEY) === 'true'; + } catch { + return false; + } +} + +function writeCollapsed(value: boolean): void { + try { + localStorage.setItem(PROPERTIES_COLLAPSED_KEY, String(value)); + } catch { + /* ignore quota / disabled storage */ + } +} + export function PropertiesPanel() { const { t } = useTranslation(); - const { state, dispatch, updateOpening, updateElectrical, updateFurniture, updateWall, addAnnotation } = useEditor(); + const { state, dispatch, updateOpening, updateElectrical, updateFurniture, updateWall } = useEditor(); const { execute } = useUndoRedo(); const { selectedIds, walls, openings, electricalItems, furnitureItems, room } = state; + const [collapsed, setCollapsed] = useState(() => readCollapsed()); + const toggleCollapsed = useCallback(() => { + setCollapsed((prev) => { + const next = !prev; + writeCollapsed(next); + return next; + }); + }, []); + + const header = ( +
+ {t('properties.title')} + +
+ ); + const roomArea = useMemo( () => room.shape.length >= 3 ? polygonArea(room.shape) : 0, [room.shape], @@ -57,10 +106,26 @@ export function PropertiesPanel() { return items; }, [selectedIds, walls, openings, electricalItems, furnitureItems]); + if (collapsed) { + return ( +
+ +
+ ); + } + if (selected.length === 0) { return (
-
{t('properties.title')}
+ {header}

{t('properties.noSelection')}

{t('properties.selectHint')}

@@ -85,6 +150,15 @@ export function PropertiesPanel() { }))} onChange={(v) => dispatch({ type: 'UPDATE_ROOM_PROPS', props: { floorType: v } })} /> + + label={t('properties.wallFinish')} + value={room.wallFinish ?? 'PAINT'} + options={WALL_FINISHES.map((wf) => ({ + value: wf, + label: t(`wallFinish.${wf}`), + }))} + onChange={(v) => dispatch({ type: 'UPDATE_ROOM_PROPS', props: { wallFinish: v } })} + />
{t('properties.wallColor')} 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 }} + // Wall color only renders on the PAINT finish — when a textured + // finish is selected the value is still editable so the user + // can pre-pick a colour for when they switch back. Tooltip + // explains when it won't be visible. + title={(room.wallFinish ?? 'PAINT') !== 'PAINT' ? t('properties.wallColorPaintOnly') : t('properties.wallColor')} />
+ {/* Room-level outlet dimensions — used to draw outlet boundaries in + all views (2D/3D/projection). Stored in meters; edited in cm. */} + { + const cm = parseFloat(v); + if (!isNaN(cm) && cm > 0 && cm <= 100) { + dispatch({ type: 'UPDATE_ROOM_PROPS', props: { outletWidth: cm / 100 } }); + } + }} + /> + { + const cm = parseFloat(v); + if (!isNaN(cm) && cm > 0 && cm <= 100) { + dispatch({ type: 'UPDATE_ROOM_PROPS', props: { outletHeight: cm / 100 } }); + } + }} + />
); @@ -104,7 +207,7 @@ export function PropertiesPanel() { if (selected.length > 1) { return (
-
{t('properties.title')}
+ {header}

{t('properties.multipleSelected', { count: selected.length })}

@@ -116,7 +219,7 @@ export function PropertiesPanel() { return (
-
{t('properties.title')}
+ {header} {item.type === 'wall' && ( )} - {/* Add note button for any item */} + {/* Add note / edit attached annotations for any item */} {(item.type === 'electrical' || item.type === 'furniture') && ( -
+ + )} +
+ ); +} + +// ── Attached annotation manager ── + +interface ItemAnnotationManagerProps { + readonly itemId: string; + readonly roomId: string; +} + +function ItemAnnotationManager({ itemId, roomId }: ItemAnnotationManagerProps) { + const { t } = useTranslation(); + const { state, addAnnotation, updateAnnotation, removeAnnotation } = useEditor(); + const attached = useMemo( + () => state.annotations.filter((a) => a.attachedToId === itemId), + [state.annotations, itemId], + ); + + const [editing, setEditing] = useState<{ kind: 'add' } | { kind: 'edit'; annotation: Annotation } | null>(null); + + return ( +
+ {attached.map((ann) => ( +
+ + {ann.text} + +
- )} + ))} + + { + const trimmed = value.trim(); + if (!editing) return; + if (editing.kind === 'add') { + if (trimmed) { + addAnnotation({ + id: generateLocalId(), + roomId, + x: 0.3, + y: -0.2, + text: trimmed, + fontSize: 12, + attachedToId: itemId, + }); + } + } else if (trimmed && trimmed !== editing.annotation.text) { + updateAnnotation({ ...editing.annotation, text: trimmed }); + } + setEditing(null); + }} + onCancel={() => setEditing(null)} + />
); } @@ -260,9 +434,35 @@ interface OpeningPropertiesProps { } function OpeningProperties({ opening, walls, onUpdate }: OpeningPropertiesProps) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const wall = walls.find((w) => w.id === opening.wallId); const wLen = wall ? wallLength(wall) : 0; + const gridColsLabel = i18n.exists('properties.windowGridCols') + ? t('properties.windowGridCols') + : 'Grid columns'; + const gridRowsLabel = i18n.exists('properties.windowGridRows') + ? t('properties.windowGridRows') + : 'Grid rows'; + const slopeDepthLabel = i18n.exists('properties.windowSlopeDepth') + ? t('properties.windowSlopeDepth') + : 'Reveal depth (slope)'; + const frameThicknessLabel = i18n.exists('properties.openingFrameThickness') + ? t('properties.openingFrameThickness') + : 'Frame thickness'; + // Slope projects outward from the window into and through the wall + // toward the exterior, so it isn't bounded by wall thickness — only by + // the schema-level cap (2 m), which is plenty for any realistic reveal. + const maxSlopeDepth = 2; + // Frame thickness is bounded by the schema cap (0.5 m); deeper than that + // would dwarf even oversize doors and is almost certainly a typo. + const maxFrameThickness = 0.5; + + // Openings always store canonical (positionAlongWall=center, elevationFromFloor=bottom). + // The anchor on an opening is a view-only preference: it controls how the + // numbers are displayed and edited in this panel, but does not move the + // physical opening. Toggling anchor only changes which edge of the opening + // the displayed values refer to. + const anchor = opening.positionAnchor; const handleWidthChange = useCallback( (value: string) => { @@ -284,31 +484,50 @@ function OpeningProperties({ opening, walls, onUpdate }: OpeningPropertiesProps) [opening, onUpdate], ); - // Position displayed as left edge offset, stored as center - const displayPosition = Math.round((opening.positionAlongWall - opening.width / 2) * 1000) / 1000; + // Convert canonical (center along wall, bottom from floor) into the value + // displayed in the panel based on the user's anchor preference. + const displayPosition = Math.round((() => { + if (anchor.horizontal === 'left') return opening.positionAlongWall - opening.width / 2; + if (anchor.horizontal === 'right') return opening.positionAlongWall + opening.width / 2; + return opening.positionAlongWall; + })() * 1000) / 1000; + + const displayElevation = Math.round((() => { + if (anchor.vertical === 'top') return opening.elevationFromFloor + opening.height; + if (anchor.vertical === 'middle') return opening.elevationFromFloor + opening.height / 2; + return opening.elevationFromFloor; + })() * 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) { + if (!isNaN(num)) { + // Convert anchored value back to canonical center position. + let centerPos = num; + if (anchor.horizontal === 'left') centerPos = num + opening.width / 2; + else if (anchor.horizontal === 'right') centerPos = num - opening.width / 2; + if (centerPos >= 0 && centerPos <= wLen) { onUpdate({ ...opening, positionAlongWall: centerPos }); } } }, - [opening, onUpdate, wLen], + [opening, onUpdate, wLen, anchor.horizontal], ); const handleElevationChange = useCallback( (value: string) => { const num = parseFloat(value); - if (!isNaN(num) && num >= 0) { - onUpdate({ ...opening, elevationFromFloor: num }); + if (!isNaN(num)) { + // Convert anchored vertical value back to canonical bottom-edge. + let bottom = num; + if (anchor.vertical === 'top') bottom = num - opening.height; + else if (anchor.vertical === 'middle') bottom = num - opening.height / 2; + if (bottom >= 0) { + onUpdate({ ...opening, elevationFromFloor: bottom }); + } } }, - [opening, onUpdate], + [opening, onUpdate, anchor.vertical], ); const handleOpenDirectionChange = useCallback( @@ -337,10 +556,27 @@ function OpeningProperties({ opening, walls, onUpdate }: OpeningPropertiesProps) /> + onUpdate({ ...opening, positionAnchor })} + /> + {/* Frame member thickness — applies to both doors and windows. Clamped + to the schema cap so a typo can't produce a wall-sized frame. */} + { + const n = parseFloat(v); + if (!isNaN(n) && n >= 0) { + onUpdate({ ...opening, frameThickness: Math.min(n, maxFrameThickness) }); + } + }} + /> {opening.type === 'DOOR' && ( )} {opening.type === 'WINDOW' && ( - + <> + + {/* Grid subdivision: N columns × M rows of panes. The 3D and + projection views render (cols-1) vertical mullions and + (rows-1) horizontal mullions. Clamp to [1, 10] so a user + typo can't produce a 1000-mullion window. */} + { + const n = parseInt(v, 10); + if (!isNaN(n) && n >= 1 && n <= 10) { + onUpdate({ ...opening, gridCols: n }); + } + }} + /> + { + const n = parseInt(v, 10); + if (!isNaN(n) && n >= 1 && n <= 10) { + onUpdate({ ...opening, gridRows: n }); + } + }} + /> + {/* Reveal (откос) depth — how far the angled jamb panels protrude + from the window frame into the room. 0 = flush, no slope. The + renderer clamps the value so it cannot exceed half the wall + thickness; we mirror that clamp in the input handler so a + typo can't push the window out the back of the wall. */} + { + const n = parseFloat(v); + if (!isNaN(n) && n >= 0) { + onUpdate({ ...opening, slopeDepth: Math.min(n, maxSlopeDepth) }); + } + }} + /> + )} {wall && ( @@ -449,6 +727,52 @@ function EditablePropertyRow({ label, value, unit, onCommit }: EditablePropertyR ); } +// ── Position Anchor Editor ── +// +// Renders two side-by-side select boxes that edit `positionAnchor`. Used for +// every placeable item (electrical, furniture, openings). + +interface PositionAnchorEditorProps { + readonly anchor: PositionAnchor; + readonly onChange: (anchor: PositionAnchor) => void; +} + +function PositionAnchorEditor({ anchor, onChange }: PositionAnchorEditorProps) { + const { t, i18n } = useTranslation(); + const label = i18n.exists('properties.anchor') ? t('properties.anchor') : 'Anchor'; + const labelFor = (v: HorizontalAnchor | VerticalAnchor): string => { + const key = `anchor.${v}`; + return i18n.exists(key) ? t(key) : v; + }; + return ( +
+ {label} + + + + +
+ ); +} + // ── Select Property Row ── interface SelectPropertyRowProps { @@ -458,6 +782,65 @@ interface SelectPropertyRowProps { readonly onChange: (value: T) => void; } +// ── Label override row ── +// +// Editable text input that overrides the default symbol/furniture label. +// Empty string clears the override (stored as `null`); the placeholder +// shows the default the item would fall back to. + +interface LabelOverrideRowProps { + readonly value: string | null; + readonly placeholder: string; + readonly onChange: (value: string | null) => void; +} + +function LabelOverrideRow({ value, placeholder, onChange }: LabelOverrideRowProps) { + const { t, i18n } = useTranslation(); + const [draft, setDraft] = useState(value ?? ''); + const [editing, setEditing] = useState(false); + + // Sync external changes (selecting a different item) into the draft when + // the input is not currently being edited. + if (!editing && draft !== (value ?? '')) { + setDraft(value ?? ''); + } + + const commit = useCallback(() => { + setEditing(false); + const trimmed = draft.trim(); + const next = trimmed.length > 0 ? trimmed : null; + if (next !== value) { + onChange(next); + } + }, [draft, value, onChange]); + + const label = i18n.exists('properties.customLabel') ? t('properties.customLabel') : 'Title'; + + return ( +
+ {label} + setEditing(true)} + onChange={(e) => setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === 'Enter') { + (e.target as HTMLInputElement).blur(); + } else if (e.key === 'Escape') { + setDraft(value ?? ''); + setEditing(false); + } + }} + style={{ flex: 1, minWidth: 0 }} + /> +
+ ); +} + function SelectPropertyRow({ label, value, options, onChange }: SelectPropertyRowProps) { return (
@@ -485,13 +868,15 @@ interface ElectricalPropertiesProps { } function ElectricalProperties({ item, onUpdate }: ElectricalPropertiesProps) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const variant = getElectricalVariant(item.metadata); const def = ELECTRICAL_SYMBOL_DEFS.find( - (d) => d.type === item.type && (d.variant ?? 'single') === variant, + (d) => d.type === item.type && (d.variant ?? 'single') === (item.type === 'OUTLET' ? undefined : variant), ); const isWallMounted = item.wallId !== null; + const isOutlet = item.type === 'OUTLET'; + const countLabel = i18n.exists('properties.outletCount') ? t('properties.outletCount') : 'Count'; const handleXChange = useCallback( (value: string) => { @@ -512,7 +897,7 @@ function ElectricalProperties({ item, onUpdate }: ElectricalPropertiesProps) { const handleRotationChange = useCallback( (value: string) => { const num = parseFloat(value); - if (!isNaN(num)) onUpdate({ ...item, rotation: num % 360 }); + if (!isNaN(num)) onUpdate({ ...item, rotation: normalizeAngleDegrees(num) }); }, [item, onUpdate], ); @@ -527,16 +912,38 @@ function ElectricalProperties({ item, onUpdate }: ElectricalPropertiesProps) { [item, onUpdate], ); + // Section title shows the user's custom label if set, otherwise the + // default symbol-def label or raw type code as fallback. + const displayTitle = item.label ?? def?.label ?? item.type; + const labelPlaceholder = def?.label ?? item.type; + return (
-
- {def?.label ?? item.type} -
+
{displayTitle}
- {variant !== 'single' && } + {!isOutlet && variant !== 'single' && } + onUpdate({ ...item, label })} + /> + {isOutlet && ( + { + const n = parseInt(v, 10); + if (!isNaN(n) && n >= 1 && n <= 20) onUpdate({ ...item, count: n }); + }} + /> + )} + onUpdate({ ...item, positionAnchor })} + /> {isWallMounted && ( <> @@ -559,8 +966,34 @@ interface FurniturePropertiesProps { readonly onUpdate: (item: FurnitureItem) => void; } +/** + * Strip the legacy `[no-stand]` marker from a furniture label so the input + * field shows only the user-visible name. The marker is a single-purpose + * boolean stored in the label string for TVs to disable the stand mesh. + */ +function stripFurnitureMarkers(label: string | null): string | null { + if (!label) return null; + const cleaned = label.replace('[no-stand]', '').trim(); + return cleaned.length > 0 ? cleaned : null; +} + +/** + * Re-attach any markers that the original label carried after the user + * edited the visible portion. Currently only `[no-stand]` is preserved. + */ +function preserveFurnitureMarkers(originalLabel: string | null, newDisplay: string | null): string | null { + const hadNoStand = originalLabel?.includes('[no-stand]') ?? false; + if (!hadNoStand) return newDisplay; + const trimmed = (newDisplay ?? '').trim(); + return trimmed.length > 0 ? `${trimmed} [no-stand]` : '[no-stand]'; +} + function FurnitureProperties({ item, onUpdate }: FurniturePropertiesProps) { const { t } = useTranslation(); + const displayLabel = stripFurnitureMarkers(item.label); + // Furniture's "default" label for the placeholder is the type code; we + // don't have access to the original FurnitureDef from a placed item. + const labelPlaceholder = item.type; const handleXChange = useCallback( (value: string) => { @@ -613,7 +1046,7 @@ function FurnitureProperties({ item, onUpdate }: FurniturePropertiesProps) { const handleRotationChange = useCallback( (value: string) => { const num = parseFloat(value); - if (!isNaN(num)) onUpdate({ ...item, rotation: num % 360 }); + if (!isNaN(num)) onUpdate({ ...item, rotation: normalizeAngleDegrees(num) }); }, [item, onUpdate], ); @@ -621,9 +1054,16 @@ function FurnitureProperties({ item, onUpdate }: FurniturePropertiesProps) { return (
- {item.label ?? item.type} + {displayLabel ?? item.type}
+ + onUpdate({ ...item, label: preserveFurnitureMarkers(item.label, newDisplay) }) + } + /> @@ -631,6 +1071,14 @@ function FurnitureProperties({ item, onUpdate }: FurniturePropertiesProps) { + onUpdate({ ...item, positionAnchor })} + /> + + + {item.type === 'CURTAIN' && } + {item.type === 'BOOKCASE' && } {item.type === 'TV' && (
{t('properties.stand')} @@ -651,6 +1099,247 @@ function FurnitureProperties({ item, onUpdate }: FurniturePropertiesProps) {
)} + {item.type === 'DIGITAL_PIANO' && ( +
+ {t('properties.stand')} + +
+ )} +
+ ); +} + +function FurnitureOpacitySlider({ item }: { readonly item: FurnitureItem }) { + const { t, i18n } = useTranslation(); + const { updateFurniture } = useEditor(); + const value = item.opacity ?? 1; + const label = i18n.exists('properties.opacity') ? t('properties.opacity') : 'Opacity'; + return ( +
+ {label} + + { + const next = parseFloat(e.target.value); + updateFurniture({ ...item, opacity: Number.isFinite(next) ? next : 1 }); + }} + style={{ width: 100 }} + /> + + {Math.round(value * 100)}% + + +
+ ); +} + +function FurnitureProjectionToggle({ item }: { readonly item: FurnitureItem }) { + const { t, i18n } = useTranslation(); + const { updateFurniture } = useEditor(); + const enabled = item.showProjection ?? false; + const label = i18n.exists('properties.showProjection') + ? t('properties.showProjection') + : 'Show on wall projection'; + return ( +
+ {label} + +
+ ); +} + +// ── Curtain-specific controls ── +// +// Curtains store their state in the item's metadata bag: +// - `leftOpen`, `rightOpen` (0..1) — per-side retraction +// - `openAmount` (legacy symmetric) — still honoured by the helpers as a +// fallback for either side when a per-side key is missing +// - `fabricColor` (hex) +// +// Editing either side writes the explicit per-side key. We also clear the +// legacy `openAmount` on first edit so the new per-side values don't get +// shadowed by a stale symmetric value on subsequent reads. + +interface CurtainControlsProps { + readonly item: FurnitureItem; + readonly onUpdate: (item: FurnitureItem) => void; +} + +function CurtainControls({ item, onUpdate }: CurtainControlsProps) { + const { t, i18n } = useTranslation(); + const leftOpen = getCurtainLeftOpen(item.metadata); + const rightOpen = getCurtainRightOpen(item.metadata); + const fabricColor = getCurtainFabricColor(item.metadata); + + const updateMetadata = useCallback( + (patch: Record) => { + // When a per-side key is being written, drop the legacy symmetric + // `openAmount` so it doesn't keep overriding the new per-side values + // on subsequent reads via the fallback in `curtainMetadata.ts`. + const base = { ...(item.metadata ?? {}) }; + if ('leftOpen' in patch || 'rightOpen' in patch) { + delete base['openAmount']; + } + const next = { ...base, ...patch }; + onUpdate({ ...item, metadata: next }); + }, + [item, onUpdate], + ); + + const leftLabel = i18n.exists('properties.curtainLeftOpen') + ? t('properties.curtainLeftOpen') + : 'Left open'; + const rightLabel = i18n.exists('properties.curtainRightOpen') + ? t('properties.curtainRightOpen') + : 'Right open'; + const colorLabel = i18n.exists('properties.curtainFabricColor') + ? t('properties.curtainFabricColor') + : 'Fabric color'; + + return ( + <> + updateMetadata({ leftOpen: v })} + /> + updateMetadata({ rightOpen: v })} + /> +
+ {colorLabel} + updateMetadata({ fabricColor: e.target.value })} + style={{ width: 32, height: 24, border: '1px solid var(--color-border)', borderRadius: 4, cursor: 'pointer', padding: 0 }} + /> +
+ + ); +} + +// ── Bookcase controls ── +// +// A bookcase has two editable per-item properties stored in its +// metadata bag: +// - `shelfRows`: number of storage compartments (integer, 1–12). +// The 3D mesh draws one more horizontal board than this number +// (top + bottom + internal dividers). +// - `hasBackPanel`: whether the unit has a solid back panel. An +// "open bookshelf" that can double as a room divider omits it. +// Missing values fall back to the legacy behaviour: auto-derive the +// row count from the item's height and always draw the back panel. + +interface BookcaseControlsProps { + readonly item: FurnitureItem; + readonly onUpdate: (item: FurnitureItem) => void; +} + +function BookcaseControls({ item, onUpdate }: BookcaseControlsProps) { + const { t, i18n } = useTranslation(); + const rowsLabel = i18n.exists('properties.shelfRows') + ? t('properties.shelfRows') + : 'Shelf rows'; + const backPanelLabel = i18n.exists('properties.hasBackPanel') + ? t('properties.hasBackPanel') + : 'Back panel'; + + const metadataRows = item.metadata?.['shelfRows']; + const currentRows = + typeof metadataRows === 'number' && metadataRows >= 1 + ? Math.round(metadataRows) + : Math.max(2, Math.round(item.height / 0.35)); + + const metadataHasBack = item.metadata?.['hasBackPanel']; + const hasBack = typeof metadataHasBack === 'boolean' ? metadataHasBack : true; + + const updateMetadata = useCallback( + (patch: Record) => { + const next = { ...(item.metadata ?? {}), ...patch }; + onUpdate({ ...item, metadata: next }); + }, + [item, onUpdate], + ); + + return ( + <> + { + const n = parseInt(v, 10); + if (!isNaN(n) && n >= 1 && n <= 12) { + updateMetadata({ shelfRows: n }); + } + }} + /> +
+ {backPanelLabel} + +
+ + ); +} + +interface CurtainOpenSliderProps { + readonly label: string; + readonly value: number; + readonly onChange: (value: number) => void; +} + +function CurtainOpenSlider({ label, value, onChange }: CurtainOpenSliderProps) { + return ( +
+ {label} + + { + const next = parseFloat(e.target.value); + if (Number.isFinite(next)) onChange(next); + }} + style={{ width: 100 }} + /> + + {Math.round(value * 100)}% + +
); } diff --git a/apps/client/src/components/editor/RoomEditorLayout.tsx b/apps/client/src/components/editor/RoomEditorLayout.tsx index 7f19016..80e4176 100644 --- a/apps/client/src/components/editor/RoomEditorLayout.tsx +++ b/apps/client/src/components/editor/RoomEditorLayout.tsx @@ -5,6 +5,7 @@ import type Konva from 'konva'; import { useEditor } from './context/EditorContext'; import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'; import { boundingBox } from './utils/geometry'; +import { normalizeAngleDegrees } from './utils/angle'; import { EditorCanvas } from './EditorCanvas'; import { EditorToolbar } from './EditorToolbar'; import { PropertiesPanel } from './PropertiesPanel'; @@ -24,6 +25,8 @@ import { batchSyncOpenings, batchSyncElectrical, batchSyncFurniture, + batchSyncAnnotations, + updateRoom, } from '../../api/client'; import type { CreateWallOpeningDto, @@ -32,6 +35,8 @@ import type { UpdateElectricalItemDto, CreateFurnitureItemDto, UpdateFurnitureItemDto, + CreateAnnotationDto, + UpdateAnnotationDto, } from '@house-plan-maker/shared'; import styles from './room-editor-layout.module.css'; @@ -51,7 +56,10 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) { const [viewMode, setViewMode] = useState('2d'); const [showExport, setShowExport] = useState(false); const canvasContainerRef = useRef(null); - const [canvasSize, setCanvasSize] = useState({ width: 800, height: 600 }); + // Start as null so the initial render doesn't use a seed 800×600 size — + // the Stage (and the auto-fit effect) only kicks in after the container + // has been measured, avoiding the multi-frame resize flicker on open. + const [canvasSize, setCanvasSize] = useState<{ width: number; height: number } | null>(null); // ── Dirty tracking ── const [isDirty, setIsDirty] = useState(false); @@ -60,6 +68,7 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) { openings: state.openings, electricalItems: state.electricalItems, furnitureItems: state.furnitureItems, + room: state.room, }); // Mark dirty when state diverges from last saved snapshot @@ -69,9 +78,33 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) { state.walls !== saved.walls || state.openings !== saved.openings || state.electricalItems !== saved.electricalItems || - state.furnitureItems !== saved.furnitureItems; + state.furnitureItems !== saved.furnitureItems || + state.room.floorType !== saved.room.floorType || + state.room.wallColor !== saved.room.wallColor || + state.room.wallFinish !== saved.room.wallFinish || + state.room.wallHeight !== saved.room.wallHeight || + state.room.plinthHeight !== saved.room.plinthHeight || + state.room.plinthThickness !== saved.room.plinthThickness || + state.room.outletWidth !== saved.room.outletWidth || + state.room.outletHeight !== saved.room.outletHeight || + state.room.name !== saved.room.name; setIsDirty(dirty); - }, [state.walls, state.openings, state.electricalItems, state.furnitureItems]); + }, [ + state.walls, + state.openings, + state.electricalItems, + state.furnitureItems, + state.room.floorType, + state.room.wallColor, + state.room.wallFinish, + state.room.wallHeight, + state.room.plinthHeight, + state.room.plinthThickness, + state.room.outletWidth, + state.room.outletHeight, + state.room.name, + state.room, + ]); // Warn on browser close / refresh useEffect(() => { @@ -123,10 +156,21 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) { const container = canvasContainerRef.current; if (!container) return; + const commitSize = (w: number, h: number): void => { + const width = Math.floor(w); + const height = Math.floor(h); + if (width <= 0 || height <= 0) return; + // Skip no-op updates so the auto-fit effect doesn't re-run on every + // ResizeObserver tick that doesn't actually change the pixel size. + setCanvasSize((prev) => { + if (prev && prev.width === width && prev.height === height) return prev; + return { width, height }; + }); + }; + const observer = new ResizeObserver((entries) => { for (const entry of entries) { - const { width, height } = entry.contentRect; - setCanvasSize({ width: Math.floor(width), height: Math.floor(height) }); + commitSize(entry.contentRect.width, entry.contentRect.height); } }); @@ -134,15 +178,23 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) { // Initial size const rect = container.getBoundingClientRect(); - setCanvasSize({ width: Math.floor(rect.width), height: Math.floor(rect.height) }); + commitSize(rect.width, rect.height); return () => observer.disconnect(); }, []); - // ── Center room in canvas on first mount ── - const hasCenteredRef = useRef(false); + // ── Auto-fit the room into the 2D canvas ── + // Fires once the container has been measured and the room shape is + // available, and again whenever either changes. Skips no-op reruns where + // the canvas and room already match the last fit signature so we don't + // flicker through multiple frames on open. + const hasUserAdjustedViewRef = useRef(false); + const lastFitSignatureRef = useRef(''); + const lastDispatchedViewRef = useRef<{ zoom: number; panX: number; panY: number } | null>(null); + useEffect(() => { - if (hasCenteredRef.current) return; + if (viewMode !== '2d') return; + if (!canvasSize) return; if (canvasSize.width <= 100 || canvasSize.height <= 100) return; if (state.room.shape.length === 0) return; @@ -151,7 +203,14 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) { const roomH = bbox.maxY - bbox.minY; if (roomW <= 0 || roomH <= 0) return; - // Fit room in canvas with some padding + const signature = `${canvasSize.width}x${canvasSize.height}|${bbox.minX},${bbox.minY},${bbox.maxX},${bbox.maxY}`; + + // Already fit at this signature? Nothing to do. + if (lastFitSignatureRef.current === signature) return; + // User moved the camera → don't clobber their view until the room or + // canvas actually changes dimensions (which gives a new signature). + if (hasUserAdjustedViewRef.current && lastFitSignatureRef.current !== '') return; + const padding = 80; const scaleX = (canvasSize.width - padding * 2) / roomW; const scaleY = (canvasSize.height - padding * 2) / roomH; @@ -162,10 +221,27 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) { 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]); + lastDispatchedViewRef.current = { zoom: fitZoom, panX, panY }; + // Single atomic reducer pass — produces one new state, not two, so the + // ZoomPanContext can't emit an intermediate (newZoom, oldPan) frame. + dispatch({ type: 'SET_VIEW', zoom: fitZoom, offset: { x: panX, y: panY } }); + lastFitSignatureRef.current = signature; + }, [viewMode, canvasSize, state.room.shape, dispatch]); + + // Detect *manual* zoom/pan. Comparing against the values we just + // dispatched prevents the auto-fit itself from flipping the flag. + useEffect(() => { + const last = lastDispatchedViewRef.current; + if (!last) return; + const EPS = 0.5; + const cameFromAutoFit = + Math.abs(state.zoom - last.zoom) < EPS && + Math.abs(state.panOffset.x - last.panX) < EPS && + Math.abs(state.panOffset.y - last.panY) < EPS; + if (!cameFromAutoFit) { + hasUserAdjustedViewRef.current = true; + } + }, [state.zoom, state.panOffset]); // ── Re-measure canvas when switching back to 2D view ── useEffect(() => { @@ -192,6 +268,19 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) { setSaveError(null); try { + // 0. Save room-level properties (floor, wall color, heights, name) + await updateRoom(roomId, { + name: state.room.name, + floorType: state.room.floorType, + wallColor: state.room.wallColor, + wallFinish: state.room.wallFinish, + wallHeight: state.room.wallHeight, + plinthHeight: state.room.plinthHeight, + plinthThickness: state.room.plinthThickness, + outletWidth: state.room.outletWidth, + outletHeight: state.room.outletHeight, + }); + // 1. Save walls first (bulk replace) to get server-assigned wall IDs const wallDtos = state.walls.map((w) => ({ startX: w.startX, @@ -237,6 +326,11 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) { height: opening.height, elevationFromFloor: opening.elevationFromFloor, openDirection: opening.openDirection, + positionAnchor: opening.positionAnchor, + gridCols: opening.gridCols, + gridRows: opening.gridRows, + slopeDepth: opening.slopeDepth, + frameThickness: opening.frameThickness, }); } // No updates or deletes needed — cascade already removed all server openings @@ -255,7 +349,10 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) { y: elec.y, wallId: serverWallId, elevationFromFloor: elec.elevationFromFloor, - rotation: elec.rotation, + rotation: normalizeAngleDegrees(elec.rotation ?? 0), + count: elec.count, + positionAnchor: elec.positionAnchor, + label: elec.label, metadata: elec.metadata, }); } @@ -280,10 +377,19 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) { rotation: furn.rotation, elevationFromFloor: furn.elevationFromFloor, label: furn.label, + showProjection: furn.showProjection ?? false, + opacity: furn.opacity ?? 1, + positionAnchor: furn.positionAnchor, + metadata: furn.metadata ?? null, }); } else if (serverFurnIds.has(furn.id)) { const serverFurn = freshRoom.furnitureItems.find((f) => f.id === furn.id); if (serverFurn) { + const anchorChanged = + serverFurn.positionAnchor.horizontal !== furn.positionAnchor.horizontal || + serverFurn.positionAnchor.vertical !== furn.positionAnchor.vertical; + const metadataChanged = + JSON.stringify(serverFurn.metadata ?? null) !== JSON.stringify(furn.metadata ?? null); const hasChanges = serverFurn.x !== furn.x || serverFurn.y !== furn.y || @@ -292,7 +398,11 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) { serverFurn.height !== furn.height || serverFurn.rotation !== furn.rotation || serverFurn.elevationFromFloor !== furn.elevationFromFloor || - serverFurn.label !== furn.label; + serverFurn.label !== furn.label || + (serverFurn.showProjection ?? false) !== (furn.showProjection ?? false) || + (serverFurn.opacity ?? 1) !== (furn.opacity ?? 1) || + anchorChanged || + metadataChanged; if (hasChanges) { furnUpdate.push({ id: furn.id, @@ -303,9 +413,13 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) { width: furn.width, depth: furn.depth, height: furn.height, - rotation: furn.rotation, + rotation: normalizeAngleDegrees(furn.rotation ?? 0), elevationFromFloor: furn.elevationFromFloor, label: furn.label, + showProjection: furn.showProjection ?? false, + opacity: furn.opacity ?? 1, + positionAnchor: furn.positionAnchor, + metadata: furn.metadata ?? null, }, }); } @@ -319,7 +433,9 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) { } } - // 6. Execute all 3 batch calls in parallel — responses contain final server state + // 6. Execute the 3 element batch calls in parallel — responses contain + // final server state. Annotations need to wait until after this so we + // can remap their attachedToId through the new server-side ids. const [syncedOpenings, syncedElectrical, syncedFurniture] = await Promise.all([ batchSyncOpenings(roomId, { create: openingsCreate, @@ -338,13 +454,132 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) { }), ]); - // 7. Sync state with server-assigned IDs (single dispatch, no flicker) + // 7. Build an id map (old local id → new server id) so the reducer can + // preserve the user's selection across the bulk-replace save flow. + // The server batch endpoints return items in non-deterministic order, so + // we match by content, then consume each match exactly once. + const idMap = new Map(); + + for (const [localId, serverId] of wallIdMap) { + idMap.set(localId, serverId); + } + + const consumedOpenings = new Set(); + for (const local of state.openings) { + const localServerWallId = wallIdMap.get(local.wallId) ?? local.wallId; + const match = syncedOpenings.find( + (o) => + !consumedOpenings.has(o.id) && + o.wallId === localServerWallId && + o.type === local.type && + Math.abs(o.positionAlongWall - local.positionAlongWall) < 0.001 && + Math.abs(o.width - local.width) < 0.001, + ); + if (match) { + consumedOpenings.add(match.id); + idMap.set(local.id, match.id); + } + } + + const consumedElectrical = new Set(); + for (const local of state.electricalItems) { + const localServerWallId = local.wallId + ? (wallIdMap.get(local.wallId) ?? local.wallId) + : null; + const match = syncedElectrical.find( + (e) => + !consumedElectrical.has(e.id) && + e.type === local.type && + (e.wallId ?? null) === localServerWallId && + Math.abs(e.x - local.x) < 0.001 && + Math.abs(e.y - local.y) < 0.001, + ); + if (match) { + consumedElectrical.add(match.id); + idMap.set(local.id, match.id); + } + } + + const consumedFurniture = new Set(); + for (const local of state.furnitureItems) { + if (!local.id.startsWith('local-') && syncedFurniture.some((f) => f.id === local.id)) { + idMap.set(local.id, local.id); + consumedFurniture.add(local.id); + continue; + } + const match = syncedFurniture.find( + (f) => + !consumedFurniture.has(f.id) && + f.type === local.type && + Math.abs(f.x - local.x) < 0.001 && + Math.abs(f.y - local.y) < 0.001 && + Math.abs(f.width - local.width) < 0.001 && + Math.abs(f.depth - local.depth) < 0.001, + ); + if (match) { + consumedFurniture.add(match.id); + idMap.set(local.id, match.id); + } + } + + // 7b. Now that the id map is built, save annotations with attachedToId + // remapped to the new server-side item ids. + const serverAnnIds = new Set((freshRoom.annotations ?? []).map((a) => a.id)); + const localAnnIds = new Set(state.annotations.map((a) => a.id)); + const annCreate: CreateAnnotationDto[] = []; + const annUpdate: { id: string; data: UpdateAnnotationDto }[] = []; + const annDelete: string[] = []; + + for (const ann of state.annotations) { + const remappedAttachedTo = ann.attachedToId + ? (idMap.get(ann.attachedToId) ?? ann.attachedToId) + : null; + if (ann.id.startsWith('local-') || !serverAnnIds.has(ann.id)) { + annCreate.push({ + x: ann.x, + y: ann.y, + text: ann.text, + fontSize: ann.fontSize, + color: ann.color, + attachedToId: remappedAttachedTo, + projectionOffsetX: ann.projectionOffsetX, + projectionOffsetY: ann.projectionOffsetY, + }); + } else { + annUpdate.push({ + id: ann.id, + data: { + x: ann.x, + y: ann.y, + text: ann.text, + fontSize: ann.fontSize, + color: ann.color, + attachedToId: remappedAttachedTo, + projectionOffsetX: ann.projectionOffsetX, + projectionOffsetY: ann.projectionOffsetY, + }, + }); + } + } + for (const id of serverAnnIds) { + if (!localAnnIds.has(id)) annDelete.push(id); + } + + const syncedAnnotations = await batchSyncAnnotations(roomId, { + create: annCreate, + update: annUpdate, + delete: annDelete, + }); + + // 8. Sync state with server-assigned IDs (single dispatch, no flicker) dispatch({ type: 'SYNC_SAVE', walls: serverWalls, openings: syncedOpenings, electricalItems: syncedElectrical, furnitureItems: syncedFurniture, + annotations: syncedAnnotations, + idMap, }); // Mark state as clean after successful save @@ -353,6 +588,7 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) { openings: syncedOpenings, electricalItems: syncedElectrical, furnitureItems: syncedFurniture, + room: state.room, }; } catch (err: unknown) { const message = err instanceof Error ? err.message : t('editor.error.load'); @@ -361,7 +597,7 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) { setIsSaving(false); isSavingRef.current = false; } - }, [roomId, state.walls, state.openings, state.electricalItems, state.furnitureItems, dispatch]); + }, [roomId, state.walls, state.openings, state.electricalItems, state.furnitureItems, state.annotations, state.room, dispatch]); // ── Auto-save with ref-based debounce ── const autoSaveTimerRef = useRef | null>(null); @@ -544,11 +780,16 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) { className={styles.canvasContainer} style={viewMode !== '2d' ? { position: 'absolute', width: 0, height: 0, overflow: 'hidden', pointerEvents: 'none' } : undefined} > - + {/* Only mount the Konva stage once the container has been + measured — rendering at a seed 800×600 and then re-rendering + at the real size causes a visible flicker on open. */} + {canvasSize && ( + + )}
{viewMode === '2d' && ( diff --git a/apps/client/src/components/editor/context/EditorContext.tsx b/apps/client/src/components/editor/context/EditorContext.tsx index 2aeab29..8304db6 100644 --- a/apps/client/src/components/editor/context/EditorContext.tsx +++ b/apps/client/src/components/editor/context/EditorContext.tsx @@ -76,7 +76,9 @@ function createInitialState(room: RoomFull): EditorState { layerVisibility: { walls: true, electrical: true, furniture: true, measurements: true, annotations: true }, selectedElectricalIndex: null, selectedFurnitureIndex: null, - annotations: [], + annotations: room.annotations ?? [], + furnitureProjectionIds: new Set(), + globalFurnitureOpacity: 1, }; } @@ -93,6 +95,7 @@ function editorReducer(state: EditorState, action: EditorAction): EditorState { openings: existingMatch ? room.openings : [], electricalItems: room.electricalItems, furnitureItems: room.furnitureItems, + annotations: room.annotations ?? state.annotations, }; } case 'UPDATE_ROOM_PROPS': @@ -177,6 +180,8 @@ function editorReducer(state: EditorState, action: EditorAction): EditorState { return { ...state, zoom: action.zoom }; case 'SET_PAN_OFFSET': return { ...state, panOffset: action.offset }; + case 'SET_VIEW': + return { ...state, zoom: action.zoom, panOffset: action.offset }; case 'SET_GRID_SIZE': return { ...state, gridSize: action.gridSize }; case 'TOGGLE_GRID': @@ -251,18 +256,43 @@ function editorReducer(state: EditorState, action: EditorAction): EditorState { annotations: state.annotations.filter((a) => a.id !== action.id), selectedIds: removeFromSet(state.selectedIds, action.id), }; + case 'TOGGLE_FURNITURE_PROJECTION': { + const next = new Set(state.furnitureProjectionIds); + if (next.has(action.id)) next.delete(action.id); + else next.add(action.id); + return { ...state, furnitureProjectionIds: next }; + } + case 'SET_GLOBAL_FURNITURE_OPACITY': { + const clamped = Math.min(1, Math.max(0, action.opacity)); + return { ...state, globalFurnitureOpacity: clamped }; + } // ── Import ── case 'SYNC_SAVE': { - // Build set of all new IDs to prune stale selections + // Build set of all new IDs so we can prune any selection that did not survive const newIds = new Set(); 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(); + // Remap selected IDs through the id map (so freshly created server items + // stay selected). Fall back to the original id when no mapping is given. + const idMap = action.idMap; + const remappedSelection = new Set(); for (const id of state.selectedIds) { - if (newIds.has(id)) prunedSelection.add(id); + const next = idMap?.get(id) ?? id; + if (newIds.has(next)) remappedSelection.add(next); + } + // Use server annotations when provided; otherwise just remap attached ids + // for the existing client-only annotation list. + let remappedAnnotations = action.annotations + ? [...action.annotations] + : state.annotations; + if (idMap) { + remappedAnnotations = remappedAnnotations.map((a) => + a.attachedToId && idMap.has(a.attachedToId) + ? { ...a, attachedToId: idMap.get(a.attachedToId)! } + : a, + ); } return { ...state, @@ -270,7 +300,8 @@ function editorReducer(state: EditorState, action: EditorAction): EditorState { openings: action.openings, electricalItems: action.electricalItems, furnitureItems: action.furnitureItems, - selectedIds: prunedSelection, + selectedIds: remappedSelection, + annotations: remappedAnnotations, }; } case 'IMPORT_ROOM': @@ -445,6 +476,8 @@ interface SceneDataContextValue { readonly electricalItems: readonly ElectricalItem[]; readonly furnitureItems: readonly FurnitureItem[]; readonly annotations: readonly Annotation[]; + readonly furnitureProjectionIds: ReadonlySet; + readonly globalFurnitureOpacity: number; readonly gridSize: number; readonly gridVisible: boolean; readonly snapEnabled: boolean; @@ -467,6 +500,7 @@ interface SceneDataContextValue { addAnnotation(annotation: Annotation): void; updateAnnotation(annotation: Annotation): void; removeAnnotation(id: string): void; + toggleFurnitureProjection(id: string): void; copySelected(): void; pasteClipboard(): void; } @@ -499,6 +533,7 @@ interface EditorContextValue { addAnnotation(annotation: Annotation): void; updateAnnotation(annotation: Annotation): void; removeAnnotation(id: string): void; + toggleFurnitureProjection(id: string): void; copySelected(): void; pasteClipboard(): void; } @@ -615,6 +650,10 @@ export function EditorProvider({ room, children }: EditorProviderProps) { (id: string) => dispatch({ type: 'REMOVE_ANNOTATION', id }), [], ); + const toggleFurnitureProjection = useCallback( + (id: string) => dispatch({ type: 'TOGGLE_FURNITURE_PROJECTION', id }), + [], + ); // ── Clipboard (ref-based so copy reads current state without closures) ── const clipboardRef = useRef<{ @@ -712,6 +751,8 @@ export function EditorProvider({ room, children }: EditorProviderProps) { electricalItems: state.electricalItems, furnitureItems: state.furnitureItems, annotations: state.annotations, + furnitureProjectionIds: state.furnitureProjectionIds, + globalFurnitureOpacity: state.globalFurnitureOpacity, gridSize: state.gridSize, gridVisible: state.gridVisible, snapEnabled: state.snapEnabled, @@ -734,6 +775,7 @@ export function EditorProvider({ room, children }: EditorProviderProps) { addAnnotation, updateAnnotation, removeAnnotation, + toggleFurnitureProjection, copySelected, pasteClipboard, }), @@ -744,6 +786,8 @@ export function EditorProvider({ room, children }: EditorProviderProps) { state.electricalItems, state.furnitureItems, state.annotations, + state.furnitureProjectionIds, + state.globalFurnitureOpacity, state.gridSize, state.gridVisible, state.snapEnabled, @@ -765,6 +809,7 @@ export function EditorProvider({ room, children }: EditorProviderProps) { addAnnotation, updateAnnotation, removeAnnotation, + toggleFurnitureProjection, copySelected, pasteClipboard, ], @@ -796,6 +841,7 @@ export function EditorProvider({ room, children }: EditorProviderProps) { addAnnotation, updateAnnotation, removeAnnotation, + toggleFurnitureProjection, copySelected, pasteClipboard, }), diff --git a/apps/client/src/components/editor/export/roomFormat.ts b/apps/client/src/components/editor/export/roomFormat.ts index 806af32..8ffab4b 100644 --- a/apps/client/src/components/editor/export/roomFormat.ts +++ b/apps/client/src/components/editor/export/roomFormat.ts @@ -306,6 +306,15 @@ export function importRoomFromJson(json: string): ImportResult { height: assertField(o, 'height', isNumber, `openings[${i}]`), elevationFromFloor: assertField(o, 'elevationFromFloor', isNumber, `openings[${i}]`), openDirection: (isString((o as Record).openDirection) ? (o as Record).openDirection as string : 'LEFT') as WallOpening['openDirection'], + // Imported openings use the canonical anchor (centre/bottom) — see notes + // in WallOpening for why this is the default for openings. + positionAnchor: { horizontal: 'middle', vertical: 'bottom' }, + gridCols: isNumber(o.gridCols) && o.gridCols >= 1 ? Math.round(o.gridCols) : 2, + gridRows: isNumber(o.gridRows) && o.gridRows >= 1 ? Math.round(o.gridRows) : 2, + slopeDepth: isNumber(o.slopeDepth) && o.slopeDepth >= 0 ? Math.min(2, o.slopeDepth) : 0, + frameThickness: isNumber(o.frameThickness) && o.frameThickness >= 0 + ? Math.min(0.5, o.frameThickness) + : 0.03, }); } @@ -340,6 +349,7 @@ export function importRoomFromJson(json: string): ImportResult { validatedMetadata = metadata; } + const importedCount = isNumber(e.count) && e.count >= 1 ? Math.round(e.count) : 1; electricalItems.push({ id: generateLocalId(), roomId, @@ -351,6 +361,9 @@ export function importRoomFromJson(json: string): ImportResult { wallId, elevationFromFloor: e.elevationFromFloor === null ? null : assertField(e, 'elevationFromFloor', isNumber, `electricalItems[${i}]`), rotation: assertField(e, 'rotation', isNumber, `electricalItems[${i}]`), + count: importedCount, + positionAnchor: { horizontal: 'middle', vertical: 'middle' }, + label: typeof e.label === 'string' ? e.label : null, metadata: validatedMetadata, }); } @@ -386,6 +399,11 @@ export function importRoomFromJson(json: string): ImportResult { rotation: assertField(f, 'rotation', isNumber, `furnitureItems[${i}]`), elevationFromFloor: isNumber((f as Record).elevationFromFloor) ? (f as Record).elevationFromFloor as number : 0, label: (label as string | null) ?? null, + // Imported furniture defaults to the legacy top-left anchor so the + // (x, y) values from the export file (which were saved as top-left) + // continue to refer to the same physical point. + positionAnchor: { horizontal: 'left', vertical: 'top' }, + metadata: isRecord(f.metadata) ? (f.metadata as Record) : null, }); } diff --git a/apps/client/src/components/editor/layers/AnnotationLayer.tsx b/apps/client/src/components/editor/layers/AnnotationLayer.tsx index 94655fa..55612b6 100644 --- a/apps/client/src/components/editor/layers/AnnotationLayer.tsx +++ b/apps/client/src/components/editor/layers/AnnotationLayer.tsx @@ -1,6 +1,7 @@ import { memo, useMemo } from 'react'; -import { Layer, Text, Rect, Group, Line } from 'react-konva'; +import { Text, Rect, Group, Line } from 'react-konva'; import type { Point, Annotation, ElectricalItem, FurnitureItem } from '@house-plan-maker/shared'; +import { rotatedAnchorOffsetToCenter } from '@house-plan-maker/shared'; interface AnnotationLayerProps { readonly annotations: readonly Annotation[]; @@ -17,9 +18,19 @@ interface AnnotationLayerProps { const DEFAULT_FONT_SIZE = 14; const DEFAULT_COLOR = '#333333'; +const LINK_COLOR = '#2563eb'; const SELECTED_COLOR = '#4c6ef5'; const SELECTION_PADDING = 4; +// Match plain http(s) URLs only — anything else stays a regular annotation. +// Anchored to start/end so a label like "see http://x" isn't treated as a +// link (we want the whole text to be the URL). +const URL_PATTERN = /^https?:\/\/\S+$/i; + +function isUrlAnnotation(text: string): boolean { + return URL_PATTERN.test(text.trim()); +} + function toScreen(point: Point, zoom: number, panOffset: Point): { x: number; y: number } { return { x: point.x * zoom + panOffset.x, @@ -46,7 +57,13 @@ export const AnnotationLayer = memo(function AnnotationLayer({ 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 }); + const offset = rotatedAnchorOffsetToCenter( + item.positionAnchor, + item.width, + item.depth, + item.rotation, + ); + map.set(item.id, { x: item.x + offset.dx, y: item.y + offset.dy }); } return map; }, [electricalItems, furnitureItems]); @@ -57,7 +74,7 @@ export const AnnotationLayer = memo(function AnnotationLayer({ }, [annotations, visible]); return ( - + {renderedAnnotations.map((annotation) => { // Resolve position: if attached, offset from parent item let worldX = annotation.x; @@ -76,7 +93,15 @@ export const AnnotationLayer = memo(function AnnotationLayer({ 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); + const isLink = isUrlAnnotation(annotation.text); + // Link annotations get a distinctive blue tint when not selected so + // users can spot them; selection still wins to keep the affordance + // consistent with non-link annotations. + const color = isSelected + ? SELECTED_COLOR + : isLink + ? (annotation.color ?? LINK_COLOR) + : (annotation.color ?? DEFAULT_COLOR); return ( @@ -108,7 +133,19 @@ export const AnnotationLayer = memo(function AnnotationLayer({ } onDragEnd?.(annotation.id, newX, newY); }} - onClick={() => onSelect?.(annotation.id)} + onClick={(e) => { + // Ctrl/Cmd-click on a URL annotation opens it in a new tab. + // We swallow the event so it doesn't also trigger selection + // or upstream stage handlers (which would deselect the link + // immediately on focus loss). Plain clicks fall through to + // the regular select handler. + if (isLink && (e.evt.ctrlKey || e.evt.metaKey)) { + e.cancelBubble = true; + window.open(annotation.text.trim(), '_blank', 'noopener,noreferrer'); + return; + } + onSelect?.(annotation.id); + }} onDblClick={() => onDoubleClick?.(annotation.id)} > {/* Background */} @@ -143,6 +180,6 @@ export const AnnotationLayer = memo(function AnnotationLayer({ ); })} - + ); }); diff --git a/apps/client/src/components/editor/layers/ElectricalLayer.tsx b/apps/client/src/components/editor/layers/ElectricalLayer.tsx index 75dfc3c..6dd2823 100644 --- a/apps/client/src/components/editor/layers/ElectricalLayer.tsx +++ b/apps/client/src/components/editor/layers/ElectricalLayer.tsx @@ -1,10 +1,9 @@ import { memo, useMemo } from 'react'; -import { Layer, Group, Circle } from 'react-konva'; +import { Group, Circle } from 'react-konva'; import type { Point, ElectricalItem } from '@house-plan-maker/shared'; +import { anchorOffsetToCenter, DEFAULT_OUTLET_WIDTH, DEFAULT_OUTLET_HEIGHT } from '@house-plan-maker/shared'; import { - SingleOutletSymbol, - DoubleOutletSymbol, - GroundedOutletSymbol, + OutletSymbol, SingleSwitchSymbol, DoubleSwitchSymbol, DimmerSwitchSymbol, @@ -23,6 +22,10 @@ interface ElectricalLayerProps { readonly panOffset: Point; readonly selectedIds: ReadonlySet; readonly visible?: boolean; + /** Physical width of a single outlet face plate (meters). Used to size outlet boundaries. */ + readonly outletWidth?: number; + /** Physical height of a single outlet face plate (meters). Used to size outlet boundaries. */ + readonly outletHeight?: number; } const ELECTRICAL_COLOR = '#d63384'; @@ -43,8 +46,13 @@ export const ElectricalLayer = memo(function ElectricalLayer({ panOffset, selectedIds, visible = true, + outletWidth = DEFAULT_OUTLET_WIDTH, + outletHeight = DEFAULT_OUTLET_HEIGHT, }: ElectricalLayerProps) { const scale = Math.max(0.6, Math.min(1.5, zoom / 100)); + // Convert real-world outlet dimensions to screen pixels for the current zoom. + const outletWidthPx = outletWidth * zoom; + const outletHeightPx = outletHeight * zoom; const renderedItems = useMemo(() => { if (!visible) return []; @@ -57,7 +65,7 @@ export const ElectricalLayer = memo(function ElectricalLayer({ }, [items, visible]); return ( - + {/* Cable routes first (below symbols) */} {cableItems.map((item) => { const waypoints = getCableWaypoints(item); @@ -83,25 +91,40 @@ export const ElectricalLayer = memo(function ElectricalLayer({ const color = isSelected ? SELECTED_COLOR : ELECTRICAL_COLOR; const variant = getElectricalVariant(item.metadata); + // Bounding box for outlets is count * outletWidth × outletHeight; for + // other symbols anchor offset is irrelevant (legacy symbols are point- + // based) but we still respect a non-default anchor by treating the + // symbol bounding box as a unit cell so the math degenerates to zero + // when anchor is middle/middle. + const bboxWidthPx = + item.type === 'OUTLET' ? Math.max(1, item.count) * outletWidthPx : 0; + const bboxHeightPx = item.type === 'OUTLET' ? outletHeightPx : 0; + const offset = anchorOffsetToCenter(item.positionAnchor, bboxWidthPx, bboxHeightPx); + return ( {/* Light coverage circle (only for selected light fixtures) */} {isSelected && renderLightCoverage(item, zoom, panOffset)} {/* Symbol */} - {renderElectricalSymbol( - item.type, + {renderElectricalSymbol({ + type: item.type, variant, - screen.x, - screen.y, - item.rotation, + count: item.count, + x: screen.x, + y: screen.y, + rotation: item.rotation, color, scale, - )} + outletWidthPx, + outletHeightPx, + centerOffsetX: offset.dx, + centerOffsetY: offset.dy, + })} ); })} - + ); }); @@ -130,25 +153,38 @@ function renderLightCoverage( ); } -function renderElectricalSymbol( - type: string, - variant: string, - x: number, - y: number, - rotation: number, - color: string, - scale: number, -): React.ReactNode { +interface RenderSymbolArgs { + readonly type: string; + readonly variant: string; + readonly count: number; + readonly x: number; + readonly y: number; + readonly rotation: number; + readonly color: string; + readonly scale: number; + readonly outletWidthPx: number; + readonly outletHeightPx: number; + readonly centerOffsetX: number; + readonly centerOffsetY: number; +} + +function renderElectricalSymbol(args: RenderSymbolArgs): React.ReactNode { + const { type, variant, count, x, y, rotation, color, scale } = args; switch (type) { case 'OUTLET': - switch (variant) { - case 'double': - return ; - case 'grounded': - return ; - default: - return ; - } + return ( + + ); case 'SWITCH': switch (variant) { case 'double': diff --git a/apps/client/src/components/editor/layers/FurnitureLayer.tsx b/apps/client/src/components/editor/layers/FurnitureLayer.tsx index 00dfdc5..3d5235e 100644 --- a/apps/client/src/components/editor/layers/FurnitureLayer.tsx +++ b/apps/client/src/components/editor/layers/FurnitureLayer.tsx @@ -1,6 +1,7 @@ import { memo, useMemo } from 'react'; -import { Layer, Group, Rect, Line } from 'react-konva'; +import { Group, Rect, Line } from 'react-konva'; import type { Point, FurnitureItem } from '@house-plan-maker/shared'; +import { rotatedAnchorOffsetToCenter } from '@house-plan-maker/shared'; import { BedSilhouette } from '../symbols/furniture/BedSilhouette'; import { DeskSilhouette } from '../symbols/furniture/DeskSilhouette'; import { WardrobeSilhouette } from '../symbols/furniture/WardrobeSilhouette'; @@ -17,6 +18,8 @@ interface FurnitureLayerProps { readonly panOffset: Point; readonly selectedIds: ReadonlySet; readonly visible?: boolean; + /** Global multiplier applied to every furniture item's opacity. */ + readonly globalOpacity?: number; } const FURNITURE_COLOR = '#495057'; @@ -39,15 +42,25 @@ export const FurnitureLayer = memo(function FurnitureLayer({ panOffset, selectedIds, visible = true, + globalOpacity = 1, }: FurnitureLayerProps) { const collidingIds = useMemo(() => findCollidingFurniture(items), [items]); return ( - + {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; + // (x, y) is the anchored point on the ROTATED visual; convert to + // bounding-box center using the rotation-aware helper so "left" + // tracks the visual left edge regardless of how the item is + // rotated. Reduces to (0, 0) for the default middle/middle anchor. + const offset = rotatedAnchorOffsetToCenter( + item.positionAnchor, + item.width, + item.depth, + item.rotation, + ); + const centerX = item.x + offset.dx; + const centerY = item.y + offset.dy; const screenCenter = toScreen({ x: centerX, y: centerY }, zoom, panOffset); const isSelected = selectedIds.has(item.id); const isColliding = collidingIds.has(item.id); @@ -57,8 +70,9 @@ export const FurnitureLayer = memo(function FurnitureLayer({ const color = isColliding ? COLLISION_COLOR : isSelected ? SELECTED_COLOR : FURNITURE_COLOR; const fillColor = isColliding ? COLLISION_FILL : isSelected ? SELECTED_FILL : FURNITURE_FILL; + const opacity = (item.opacity ?? 1) * globalOpacity; return ( - + {renderFurnitureSilhouette( item.type, screenCenter.x, @@ -81,7 +95,7 @@ export const FurnitureLayer = memo(function FurnitureLayer({ ); })} - + ); }); @@ -156,20 +170,31 @@ function renderFurnitureSilhouette( return ; case 'TV': return ; - default: - // Generic rectangle for OTHER / unknown + default: { + // Generic rectangle fallback for types without a custom silhouette + // (OTHER, RADIATOR, WALL_COLLAGE, CURTAIN, …). + // + // Rotation MUST pivot around the item's center. Konva's `` + // rotates around its stored (x, y) — which is the top-left corner, + // not the center — so a bare `` + // pivots around the unrotated top-left corner and drifts away from + // the hit-test box as the item rotates. Wrapping the Rect in a + // `` at (cx, cy) with the rotation on the Group gives the + // correct center-pivot behaviour (matching the custom silhouettes). return ( - + + + ); + } } } diff --git a/apps/client/src/components/editor/layers/GridLayer.tsx b/apps/client/src/components/editor/layers/GridLayer.tsx index b71dd09..233aa42 100644 --- a/apps/client/src/components/editor/layers/GridLayer.tsx +++ b/apps/client/src/components/editor/layers/GridLayer.tsx @@ -1,5 +1,5 @@ import { memo, useMemo } from 'react'; -import { Layer, Line, Text, Rect } from 'react-konva'; +import { Group, Line, Text, Rect } from 'react-konva'; import type { Point } from '@house-plan-maker/shared'; interface GridLayerProps { @@ -129,7 +129,7 @@ export const GridLayer = memo(function GridLayer({ }, [zoom, panOffset, stageWidth, stageHeight]); return ( - + {/* Grid lines */} {visible && gridLines.lines.map((line, i) => ( @@ -221,7 +221,7 @@ export const GridLayer = memo(function GridLayer({ fill={RULER_BG_COLOR} listening={false} /> - + ); }); diff --git a/apps/client/src/components/editor/layers/MeasureOverlayLayer.tsx b/apps/client/src/components/editor/layers/MeasureOverlayLayer.tsx index c00cc52..03b09dc 100644 --- a/apps/client/src/components/editor/layers/MeasureOverlayLayer.tsx +++ b/apps/client/src/components/editor/layers/MeasureOverlayLayer.tsx @@ -1,5 +1,5 @@ import { memo } from 'react'; -import { Layer, Line, Text, Circle } from 'react-konva'; +import { Group, Line, Text, Circle } from 'react-konva'; import type { Point } from '@house-plan-maker/shared'; import type { MeasurementState } from '../types'; @@ -24,7 +24,7 @@ export const MeasureOverlayLayer = memo(function MeasureOverlayLayer({ zoom, panOffset, }: MeasureOverlayLayerProps) { - if (!measurement) return ; + if (!measurement) return ; const start = toScreen(measurement.startPoint, zoom, panOffset); const end = toScreen(measurement.endPoint, zoom, panOffset); @@ -38,7 +38,7 @@ export const MeasureOverlayLayer = memo(function MeasureOverlayLayer({ : `${(distanceM * 100).toFixed(1)} cm`; return ( - + )} - + ); }); diff --git a/apps/client/src/components/editor/layers/MeasurementLayer.tsx b/apps/client/src/components/editor/layers/MeasurementLayer.tsx index c431bbe..6f96135 100644 --- a/apps/client/src/components/editor/layers/MeasurementLayer.tsx +++ b/apps/client/src/components/editor/layers/MeasurementLayer.tsx @@ -1,5 +1,5 @@ import { memo, useMemo } from 'react'; -import { Layer, Line, Text, Group } from 'react-konva'; +import { Line, Text, Group } from 'react-konva'; import type { Point, Wall, WallOpening } from '@house-plan-maker/shared'; import { wallLength, wallAngle, wallStartEnd } from '../utils/wallUtils'; @@ -28,22 +28,15 @@ export const MeasurementLayer = memo(function MeasurementLayer({ }: MeasurementLayerProps) { // Hide measurements at very low zoom levels if (zoom < MIN_ZOOM_FOR_MEASUREMENTS) { - return ; + return ; } return ( - - {/* Wall length annotations */} - {walls.map((wall) => ( - - ))} - - {/* Room overall dimensions */} + + {/* Outer room dimensions — one horizontal + one vertical label outside + the room bounding box. The former per-wall inner labels were + removed because they duplicated these numbers on every wall of a + rectangular room. */} {roomShape.length >= 3 && ( ); })} - + ); }); -// ── 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 ( - - ); -} - -// ── Room overall dimensions ── +// ── Room overall dimensions (outer bbox labels) ── interface RoomDimensionsProps { readonly roomShape: readonly Point[]; @@ -159,7 +115,6 @@ function RoomDimensions({ roomShape, zoom, panOffset }: RoomDimensionsProps) { strokeWidth={1} listening={false} /> - {/* End ticks */} + {renderedOpenings.map(({ opening, wall, pos, isSelected }) => { const screenCenter = toScreen(pos.center, zoom, panOffset); const angle = wallAngle(wall); @@ -140,7 +140,7 @@ export const OpeningLayer = memo(function OpeningLayer({ panOffset={panOffset} /> )} - + ); }); @@ -162,8 +162,12 @@ function DoorSymbol({ x, y, angleDeg, halfWidthPx, wallThicknessPx, isSelected, const color = isSelected ? SELECTED_COLOR : DOOR_COLOR; const doorWidthPx = halfWidthPx * 2; - const isRight = openDirection === 'RIGHT'; - const isOutward = openDirection === 'OUTWARD'; + // The four enum values encode the two orthogonal axes (hinge side × swing + // direction) — this mapping keeps LEFT/RIGHT/OUTWARD intact and gives + // INWARD its own visual (right hinge swinging outward) so it no longer + // collides with LEFT. + const isRight = openDirection === 'RIGHT' || openDirection === 'INWARD'; + const isOutward = openDirection === 'OUTWARD' || openDirection === 'INWARD'; // Mirror the entire door group for RIGHT hinge const groupScaleX = isRight ? -1 : 1; @@ -317,6 +321,11 @@ function PreviewSymbol({ wall, positionAlongWall, width, type, isValid: _isValid height: 0, elevationFromFloor: 0, openDirection: 'LEFT', + positionAnchor: { horizontal: 'middle', vertical: 'bottom' }, + gridCols: 2, + gridRows: 2, + slopeDepth: 0, + frameThickness: 0.03, }; const pos = openingWorldPosition(tempOpening, wall); diff --git a/apps/client/src/components/editor/layers/RoomLabelLayer.tsx b/apps/client/src/components/editor/layers/RoomLabelLayer.tsx index 824b6d9..ce44b51 100644 --- a/apps/client/src/components/editor/layers/RoomLabelLayer.tsx +++ b/apps/client/src/components/editor/layers/RoomLabelLayer.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; -import { Layer, Group, Rect, Text } from 'react-konva'; +import { Group, Rect, Text } from 'react-konva'; import type { Point } from '@house-plan-maker/shared'; -import { polygonArea, polygonCentroid } from '../utils/geometry'; +import { polygonArea, boundingBox } from '../utils/geometry'; interface RoomLabelLayerProps { readonly roomName: string; @@ -31,37 +31,41 @@ export function RoomLabelLayer({ [roomShape], ); - const centroid = useMemo( - () => (roomShape.length >= 3 ? polygonCentroid(roomShape) : null), + const bbox = useMemo( + () => (roomShape.length >= 2 ? boundingBox(roomShape) : null), [roomShape], ); - if (!centroid || zoom < MIN_ZOOM_FOR_LABELS) { - return ; + if (!bbox || zoom < MIN_ZOOM_FOR_LABELS) { + return ; } 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; + // Generous text-width estimate: bold font + multibyte glyphs (Cyrillic, etc.) + // can exceed Latin averages, so we over-allocate to avoid line wrapping. + const nameWidth = roomName.length * (LABEL_FONT_SIZE * 0.75) + 6; + const areaWidth = areaText.length * (AREA_FONT_SIZE * 0.7) + 6; 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; + // Anchor the badge just outside the room's top-left corner so it stays out of + // the way of any walls, openings, or items inside the room. + const cornerScreenX = bbox.minX * zoom + panOffset.x; + const cornerScreenY = bbox.minY * zoom + panOffset.y; + const screenX = cornerScreenX; + const screenY = cornerScreenY - bgHeight - 6; + return ( - + {/* Semi-transparent background */} {/* Room name */} {/* Area */} - + ); } diff --git a/apps/client/src/components/editor/layers/SelectionLayer.tsx b/apps/client/src/components/editor/layers/SelectionLayer.tsx index 08810d2..04b2c93 100644 --- a/apps/client/src/components/editor/layers/SelectionLayer.tsx +++ b/apps/client/src/components/editor/layers/SelectionLayer.tsx @@ -1,5 +1,5 @@ import { memo } from 'react'; -import { Layer, Rect } from 'react-konva'; +import { Group, Rect } from 'react-konva'; import type { Point } from '@house-plan-maker/shared'; interface SelectionLayerProps { @@ -35,7 +35,7 @@ export const SelectionLayer = memo(function SelectionLayer({ dragRect, }: SelectionLayerProps) { return ( - + {/* Selection bounding box with resize handles */} {selectionBox && ( )} - + ); }); diff --git a/apps/client/src/components/editor/layers/WallLayer.tsx b/apps/client/src/components/editor/layers/WallLayer.tsx index 23773a4..d1d6eef 100644 --- a/apps/client/src/components/editor/layers/WallLayer.tsx +++ b/apps/client/src/components/editor/layers/WallLayer.tsx @@ -1,5 +1,5 @@ import { memo, useMemo } from 'react'; -import { Layer, Line, Group } from 'react-konva'; +import { Line, Group } from 'react-konva'; import type { Point, Wall } from '@house-plan-maker/shared'; import { polygonCentroid } from '../utils/geometry'; @@ -163,7 +163,7 @@ export const WallLayer = memo(function WallLayer({ }, [walls, selectedIds, zoom, panOffset]); return ( - + {/* Room interior fill */} {roomShapeScreen.length >= 6 && ( ))} - + ); }); diff --git a/apps/client/src/components/editor/panels/ElectricalPalette.tsx b/apps/client/src/components/editor/panels/ElectricalPalette.tsx index ac96bb9..69241ce 100644 --- a/apps/client/src/components/editor/panels/ElectricalPalette.tsx +++ b/apps/client/src/components/editor/panels/ElectricalPalette.tsx @@ -1,50 +1,45 @@ -import { useMemo, useCallback } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ELECTRICAL_SYMBOL_DEFS, type ElectricalSymbolDef, } from '../symbols/electrical'; -import styles from './electrical-palette.module.css'; +import styles from './item-picker.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 }[]; +interface IndexedDef { + readonly def: ElectricalSymbolDef; + readonly index: number; } -const CATEGORY_META: Record = { - 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}' }, +interface CategoryGroup { + readonly category: string; + readonly items: readonly IndexedDef[]; +} + +/** + * UI metadata per electrical category. The `category` field on + * ElectricalSymbolDef drives this — adding a new category requires only + * a new entry here and the matching i18n key in the locale file. + */ +const CATEGORY_META: Record = { + outlet: { icon: '\u26A1', key: 'electrical.outlets', order: 0 }, + switch: { icon: '\u{1F50C}', key: 'electrical.switches', order: 1 }, + junction: { icon: '\u2B1C', key: 'electrical.junction', order: 2 }, + light: { icon: '\u{1F4A1}', key: 'electrical.lights', order: 3 }, + cable: { icon: '\u{1F517}', key: 'electrical.cable', order: 4 }, }; +type CategoryFilter = string | 'all'; + export function ElectricalPalette({ selectedIndex, onSelect }: ElectricalPaletteProps) { - const { t } = useTranslation(); - - const categories = useMemo(() => { - const groups = new Map(); - - 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 { t, i18n } = useTranslation(); + const [search, setSearch] = useState(''); + const [activeCategory, setActiveCategory] = useState('all'); const handleSelect = useCallback( (index: number) => { @@ -53,32 +48,174 @@ export function ElectricalPalette({ selectedIndex, onSelect }: ElectricalPalette [onSelect], ); + // Pre-compute the indexed list once. Indexes here MUST match positions in + // ELECTRICAL_SYMBOL_DEFS so the picker continues to satisfy the + // EditorCanvas contract: `ELECTRICAL_SYMBOL_DEFS[selectedElectricalIndex]`. + const allIndexed = useMemo( + () => ELECTRICAL_SYMBOL_DEFS.map((def, index) => ({ def, index })), + [], + ); + + // Distinct categories ordered by CATEGORY_META.order, falling back to + // insertion order for unknown categories. + const orderedCategories = useMemo(() => { + const seen = new Set(); + for (const item of allIndexed) { + seen.add(item.def.category); + } + return Array.from(seen).sort((a, b) => { + const oa = CATEGORY_META[a]?.order ?? 999; + const ob = CATEGORY_META[b]?.order ?? 999; + return oa - ob; + }); + }, [allIndexed]); + + const visibleGroups = useMemo(() => { + const trimmed = search.trim().toLowerCase(); + const matches = (item: IndexedDef): boolean => { + if (activeCategory !== 'all' && item.def.category !== activeCategory) return false; + if (!trimmed) return true; + return ( + item.def.label.toLowerCase().includes(trimmed) || + item.def.type.toLowerCase().includes(trimmed) || + (item.def.variant?.toLowerCase().includes(trimmed) ?? false) + ); + }; + + const buckets = new Map(); + for (const item of allIndexed) { + if (!matches(item)) continue; + const list = buckets.get(item.def.category) ?? []; + list.push(item); + buckets.set(item.def.category, list); + } + + return orderedCategories.flatMap((category) => { + const items = buckets.get(category); + return items && items.length > 0 ? [{ category, items }] : []; + }); + }, [allIndexed, orderedCategories, search, activeCategory]); + + const categoryLabel = (category: string): string => { + const meta = CATEGORY_META[category]; + if (!meta) return category; + return i18n.exists(meta.key) ? t(meta.key) : category; + }; + + const totalVisible = visibleGroups.reduce((acc, g) => acc + g.items.length, 0); + return (
{t('electrical.title')}
- {categories.map((cat) => ( -
-
- {cat.icon} {t(cat.nameKey)} + +
+ + {'\u{1F50D}'} + + setSearch(e.target.value)} + placeholder={ + i18n.exists('electrical.searchPlaceholder') + ? t('electrical.searchPlaceholder') + : 'Search electrical…' + } + aria-label={ + i18n.exists('electrical.searchPlaceholder') + ? t('electrical.searchPlaceholder') + : 'Search electrical' + } + /> + {search && ( + + )} +
+ +
+ setActiveCategory('all')} + /> + {orderedCategories.map((category) => { + const meta = CATEGORY_META[category]; + const icon = meta?.icon ?? ''; + return ( + setActiveCategory(category)} + /> + ); + })} +
+ +
+ {totalVisible === 0 ? ( +
+ {i18n.exists('electrical.noResults') ? t('electrical.noResults') : 'No matches'}
-
- {cat.items.map(({ def, index }) => ( - - ))} -
-
- ))} + ) : ( + visibleGroups.map((group) => { + const meta = CATEGORY_META[group.category]; + const icon = meta?.icon ?? ''; + return ( +
+
+ {icon} + {categoryLabel(group.category)} + {group.items.length} +
+
+ {group.items.map(({ def, index }) => ( + + ))} +
+
+ ); + }) + )} +
); } + +interface CategoryChipProps { + readonly label: string; + readonly active: boolean; + readonly onClick: () => void; +} + +function CategoryChip({ label, active, onClick }: CategoryChipProps) { + return ( + + ); +} diff --git a/apps/client/src/components/editor/panels/FurniturePalette.tsx b/apps/client/src/components/editor/panels/FurniturePalette.tsx index ca9a97b..af4c1d2 100644 --- a/apps/client/src/components/editor/panels/FurniturePalette.tsx +++ b/apps/client/src/components/editor/panels/FurniturePalette.tsx @@ -1,15 +1,48 @@ -import { useCallback } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { FURNITURE_DEFS, type FurnitureDef } from '../symbols/furniture'; -import styles from './furniture-palette.module.css'; +import { + FURNITURE_DEFS, + FURNITURE_CATEGORIES, + type FurnitureDef, + type FurnitureCategory, +} from '../symbols/furniture'; +import styles from './item-picker.module.css'; interface FurniturePaletteProps { readonly selectedIndex: number | null; readonly onSelect: (index: number) => void; } +interface IndexedDef { + readonly def: FurnitureDef; + readonly index: number; +} + +interface CategoryGroup { + readonly category: FurnitureCategory; + readonly items: readonly IndexedDef[]; +} + +/** + * Display metadata for each furniture category. The icon is purely visual + * (the chip + section header use it). i18n keys live in `furnitureCategory.*`. + */ +const CATEGORY_META: Record = { + sleeping: { icon: '\u{1F6CF}', key: 'furnitureCategory.sleeping' }, + seating: { icon: '\u{1FA91}', key: 'furnitureCategory.seating' }, + tables: { icon: '\u{1F37D}', key: 'furnitureCategory.tables' }, + storage: { icon: '\u{1F4DA}', key: 'furnitureCategory.storage' }, + electronics: { icon: '\u{1F4FA}', key: 'furnitureCategory.electronics' }, + climate: { icon: '\u{1F525}', key: 'furnitureCategory.climate' }, + decor: { icon: '\u{1F5BC}', key: 'furnitureCategory.decor' }, +}; + +type CategoryFilter = FurnitureCategory | 'all'; + export function FurniturePalette({ selectedIndex, onSelect }: FurniturePaletteProps) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const [search, setSearch] = useState(''); + const [activeCategory, setActiveCategory] = useState('all'); const handleSelect = useCallback( (index: number) => { @@ -18,24 +51,152 @@ export function FurniturePalette({ selectedIndex, onSelect }: FurniturePalettePr [onSelect], ); + // Pre-compute the indexed list once. Indexes here MUST match positions in + // FURNITURE_DEFS so the picker continues to satisfy the EditorCanvas + // contract: `FURNITURE_DEFS[selectedFurnitureIndex]`. + const allIndexed = useMemo( + () => FURNITURE_DEFS.map((def, index) => ({ def, index })), + [], + ); + + // Filter by search term and active category, then group by category for + // display. Empty search + "all" reproduces the original ordering. + const visibleGroups = useMemo(() => { + const trimmed = search.trim().toLowerCase(); + const matches = (item: IndexedDef): boolean => { + if (activeCategory !== 'all' && item.def.category !== activeCategory) return false; + if (!trimmed) return true; + // Allow matching the type code (e.g. "tv", "radiator") and label. + return ( + item.def.label.toLowerCase().includes(trimmed) || + item.def.type.toLowerCase().includes(trimmed) + ); + }; + + const buckets = new Map(); + for (const item of allIndexed) { + if (!matches(item)) continue; + const list = buckets.get(item.def.category) ?? []; + list.push(item); + buckets.set(item.def.category, list); + } + + // Preserve canonical category order from FURNITURE_CATEGORIES. + return FURNITURE_CATEGORIES.flatMap((category) => { + const items = buckets.get(category); + return items && items.length > 0 ? [{ category, items }] : []; + }); + }, [allIndexed, search, activeCategory]); + + const categoryLabel = (category: FurnitureCategory): string => { + const meta = CATEGORY_META[category]; + return i18n.exists(meta.key) ? t(meta.key) : category; + }; + + const totalVisible = visibleGroups.reduce((acc, g) => acc + g.items.length, 0); + return (
{t('furniture.title')}
-
- {FURNITURE_DEFS.map((def, index) => ( - + + {'\u{1F50D}'} + + setSearch(e.target.value)} + placeholder={ + i18n.exists('furniture.searchPlaceholder') + ? t('furniture.searchPlaceholder') + : 'Search furniture…' + } + aria-label={ + i18n.exists('furniture.searchPlaceholder') + ? t('furniture.searchPlaceholder') + : 'Search furniture' + } + /> + {search && ( + + )} +
+ +
+ setActiveCategory('all')} + /> + {FURNITURE_CATEGORIES.map((category) => ( + setActiveCategory(category)} /> ))}
+ +
+ {totalVisible === 0 ? ( +
+ {i18n.exists('furniture.noResults') ? t('furniture.noResults') : 'No matches'} +
+ ) : ( + visibleGroups.map((group) => ( +
+
+ {CATEGORY_META[group.category].icon} + {categoryLabel(group.category)} + {group.items.length} +
+
+ {group.items.map(({ def, index }) => ( + + ))} +
+
+ )) + )} +
); } +interface CategoryChipProps { + readonly label: string; + readonly active: boolean; + readonly onClick: () => void; +} + +function CategoryChip({ label, active, onClick }: CategoryChipProps) { + return ( + + ); +} + interface FurnitureItemBtnProps { readonly def: FurnitureDef; readonly index: number; @@ -46,20 +207,16 @@ interface FurnitureItemBtnProps { function FurnitureItemBtn({ def, index, isActive, onSelect }: FurnitureItemBtnProps) { return ( ); } diff --git a/apps/client/src/components/editor/panels/item-picker.module.css b/apps/client/src/components/editor/panels/item-picker.module.css new file mode 100644 index 0000000..936e2af --- /dev/null +++ b/apps/client/src/components/editor/panels/item-picker.module.css @@ -0,0 +1,220 @@ +/* + * Shared styles for the item-picker panels (furniture, electrical). + * Layout: header → search box → category chip row → grouped item list. + * The picker is a fixed-width column anchored to the top-left of the canvas + * and scrolls vertically when the content overflows. + */ + +.palette { + position: absolute; + top: 0; + left: 0; + z-index: var(--z-dropdown); + width: 260px; + 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; + display: flex; + flex-direction: column; + opacity: 0.97; + box-shadow: var(--shadow-lg); + overflow: hidden; +} + +.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; +} + +/* Search input row with leading icon and clear button. */ +.searchRow { + position: relative; + padding: var(--space-2) var(--space-3) var(--space-1); +} + +.searchInput { + width: 100%; + box-sizing: border-box; + padding: 6px 26px 6px 26px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-input, #fff); + color: var(--color-text-primary); + font-family: var(--font-family); + font-size: var(--font-size-xs); + outline: none; + transition: border-color var(--transition-fast); +} + +.searchInput:focus { + border-color: var(--color-accent-400); +} + +.searchIcon { + position: absolute; + left: calc(var(--space-3) + 8px); + top: 50%; + transform: translateY(-50%); + font-size: 12px; + color: var(--color-text-muted); + pointer-events: none; +} + +.searchClear { + position: absolute; + right: calc(var(--space-3) + 6px); + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + cursor: pointer; + color: var(--color-text-muted); + font-size: 14px; + padding: 0 4px; + line-height: 1; +} + +.searchClear:hover { + color: var(--color-text-primary); +} + +/* Horizontal scrollable row of category filter chips. Scrollbar styling + is inherited from the global rule in styles/global.css. */ +.categoryChips { + display: flex; + gap: 4px; + padding: var(--space-1) var(--space-3) var(--space-2); + overflow-x: auto; +} + +.categoryChip { + flex-shrink: 0; + padding: 3px 10px; + border: 1px solid var(--color-border); + border-radius: 999px; + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + font-family: var(--font-family); + font-size: 11px; + white-space: nowrap; + transition: all var(--transition-fast); +} + +.categoryChip:hover { + background-color: var(--color-bg-hover); + color: var(--color-text-primary); +} + +.categoryChipActive { + background-color: var(--color-accent-100); + border-color: var(--color-accent-300); + color: var(--color-accent-700); +} + +/* Scrollable item list area. */ +.scrollArea { + flex: 1; + overflow-y: auto; + border-top: 1px solid var(--color-border); +} + +.section { + padding: var(--space-2) var(--space-3); +} + +.sectionHeader { + display: flex; + align-items: center; + gap: 6px; + font-size: 10px; + 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); +} + +.sectionCount { + color: var(--color-text-muted); + font-weight: var(--font-weight-normal); + margin-left: auto; +} + +/* Item button — vertical layout (icon over label) for grid mode, horizontal + for list mode. Both share the same active/hover treatment. + `minmax(0, 1fr)` (instead of plain `1fr`) is crucial: it lets a column + shrink below the intrinsic min-width of its contents. Without it, a long + label like "Open Bookshelf 4" keeps the column as wide as the text and + pushes the third column past the panel's right edge, where it gets clipped + by `.palette { overflow: hidden }`. With `minmax(0, ...)` the ellipsis in + `.itemLabel` actually triggers and the grid stays within 260px. */ +.itemGrid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 4px; +} + +.itemBtn { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + padding: 6px 4px; + border: 1px solid transparent; + border-radius: var(--radius-md); + background: none; + cursor: pointer; + font-family: var(--font-family); + color: var(--color-text-secondary); + transition: all var(--transition-fast); + min-height: 60px; + text-align: center; +} + +.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-300); + color: var(--color-accent-700); +} + +.itemIcon { + font-size: 20px; + line-height: 1; +} + +.itemLabel { + font-size: 10px; + font-weight: var(--font-weight-medium); + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.itemDims { + font-size: 9px; + color: var(--color-text-muted); + line-height: 1; +} + +.empty { + padding: var(--space-4); + text-align: center; + color: var(--color-text-muted); + font-size: var(--font-size-xs); +} diff --git a/apps/client/src/components/editor/projection/ProjectionElectrical.tsx b/apps/client/src/components/editor/projection/ProjectionElectrical.tsx index 6b91d45..52d34d7 100644 --- a/apps/client/src/components/editor/projection/ProjectionElectrical.tsx +++ b/apps/client/src/components/editor/projection/ProjectionElectrical.tsx @@ -1,6 +1,7 @@ import { Group, Circle, Line, Rect, Text } from 'react-konva'; import type { ProjectedElectrical } from '../utils/projectionMapping'; import { projectionToPixel } from '../utils/projectionMapping'; +import { DEFAULT_OUTLET_WIDTH, DEFAULT_OUTLET_HEIGHT } from '@house-plan-maker/shared'; interface ProjectionElectricalProps { readonly projected: ProjectedElectrical; @@ -11,6 +12,10 @@ interface ProjectionElectricalProps { readonly isDragging?: boolean; readonly dragFromFloor?: number; readonly dragAlongWall?: number; + /** Physical width of a single outlet face plate (meters). */ + readonly outletWidth?: number; + /** Physical height of a single outlet face plate (meters). */ + readonly outletHeight?: number; readonly onClick: () => void; readonly onDragStart?: (itemId: string, evt: MouseEvent) => void; } @@ -27,6 +32,8 @@ export function ProjectionElectrical({ isDragging = false, dragFromFloor, dragAlongWall, + outletWidth = DEFAULT_OUTLET_WIDTH, + outletHeight = DEFAULT_OUTLET_HEIGHT, onClick, onDragStart, }: ProjectionElectricalProps) { @@ -70,29 +77,84 @@ export function ProjectionElectrical({ fill="transparent" /> )} - {item.type === 'OUTLET' && ( - <> - {/* IEC outlet symbol: circle with two horizontal lines */} - - - - - )} + {item.type === 'OUTLET' && (() => { + const safeCount = Math.max(1, Math.round(item.count)); + // Convert physical outlet dims to projection-pixel dims. + const wPx = outletWidth * scale; + const hPx = outletHeight * scale; + // Anchor offset to bounding-box center, in projection pixels. + // Horizontal axis = along-wall (positive right), vertical axis = up the wall. + // In screen coords +y is down, so vertical='top' anchor means center is BELOW (positive y). + // + // When the projection axis is flipped (the canonical direction + // runs opposite to the wall's stored start→end), we mirror the + // horizontal anchor so "left" still refers to the same physical + // side of the wall in both 3D and projection views. Without this + // an outlet anchored "left" on a flipped wall would appear on + // opposite sides of the two views. + const anchor = item.positionAnchor; + const mirroredHorizontal = projected.axisFlipped + ? anchor.horizontal === 'left' + ? 'right' + : anchor.horizontal === 'right' + ? 'left' + : 'middle' + : anchor.horizontal; + const totalW = safeCount * wPx; + const offX = + mirroredHorizontal === 'left' ? totalW / 2 : mirroredHorizontal === 'right' ? -totalW / 2 : 0; + const offY = + anchor.vertical === 'top' ? hPx / 2 : anchor.vertical === 'bottom' ? -hPx / 2 : 0; + const cx = center.x + offX; + const cy = center.y + offY; + const left = cx - totalW / 2; + const top = cy - hPx / 2; + const cellMin = Math.min(wPx, hPx); + const faceR = cellMin * 0.32; + const prongL = cellMin * 0.18; + const prongG = cellMin * 0.12; + return ( + <> + {Array.from({ length: safeCount }).map((_, i) => { + const cellLeft = left + i * wPx; + const cellCx = cellLeft + wPx / 2; + const cellCy = top + hPx / 2; + return ( + + + + + + + ); + })} + + ); + })()} {item.type === 'SWITCH' && ( <> {/* IEC switch symbol: circle with diagonal line */} @@ -157,12 +219,21 @@ export function ProjectionElectrical({ strokeWidth={1.5} /> )} - {/* Type label below symbol */} + {/* Type label below symbol — uses the user's custom label if set, + otherwise falls back to the short type code. */} 0 + ? item.label + : item.type === 'OUTLET' + ? 'OUT' + : item.type === 'SWITCH' + ? 'SW' + : 'WL' + } align="center" fontSize={8} fill="#94a3b8" diff --git a/apps/client/src/components/editor/projection/ProjectionFurniture.tsx b/apps/client/src/components/editor/projection/ProjectionFurniture.tsx index d2cd748..a2500ab 100644 --- a/apps/client/src/components/editor/projection/ProjectionFurniture.tsx +++ b/apps/client/src/components/editor/projection/ProjectionFurniture.tsx @@ -8,6 +8,7 @@ interface ProjectionFurnitureProps { readonly scale: number; readonly padding: number; readonly isSelected: boolean; + readonly globalOpacity?: number; readonly onClick: () => void; } @@ -27,6 +28,7 @@ export function ProjectionFurniture({ scale, padding, isSelected, + globalOpacity = 1, onClick, }: ProjectionFurnitureProps) { const { rect, item } = projected; @@ -37,8 +39,9 @@ export function ProjectionFurniture({ const color = TYPE_COLORS[item.type] ?? '#a0845c'; + const itemOpacity = (item.opacity ?? 1) * globalOpacity; return ( - + , - ); + if (showWallDimensions) { + // Wall width dimension (along bottom) + const floorLeft = projectionToPixel(0, 0, wallHeight, scale, padding); + const floorRight = projectionToPixel(wallLen, 0, wallHeight, scale, padding); + elements.push( + , + ); - // Wall height dimension (along right side) - const topRight = projectionToPixel(wallLen, wallHeight, wallHeight, scale, padding); - const bottomRight = projectionToPixel(wallLen, 0, wallHeight, scale, padding); - elements.push( - , - ); + // Wall height dimension (along right side) + const topRight = projectionToPixel(wallLen, wallHeight, wallHeight, scale, padding); + const bottomRight = projectionToPixel(wallLen, 0, wallHeight, scale, padding); + elements.push( + , + ); + } // Opening dimensions: sill height for windows, door height for doors for (const po of projectedOpenings) { @@ -190,7 +207,12 @@ export function ProjectionMeasurements({ ); } - // Electrical item coordinate labels: (X; Y) near each item + // Electrical item coordinate labels: (X; Y) near each item. + // For OUTLET groups with count > 1, the symbol bounding box extends to + // either side of the anchor by `count * outletWidth / 2`. Push the label + // past the right edge of the box (plus a small gap) so it doesn't overlap + // the outlet face plates. A semi-opaque background pill keeps it readable + // even when it sits over a wall stripe or other UI. for (const pe of projectedElectrical) { const center = projectionToPixel( pe.position.alongWall, @@ -200,16 +222,38 @@ export function ProjectionMeasurements({ padding, ); + // Half-width of the visible symbol along the wall axis, in pixels. + let halfWidthPx = 8; // default for non-outlet symbols (~SYMBOL_SIZE/2 + margin) + if (pe.item.type === 'OUTLET') { + const safeCount = Math.max(1, Math.round(pe.item.count)); + halfWidthPx = (safeCount * outletWidth * scale) / 2; + } + const coordLabel = `(${pe.position.alongWall.toFixed(2)}; ${pe.elevation.toFixed(2)})`; + const labelX = center.x + halfWidthPx + 6; + const labelY = center.y - 6; + // Rough text-width estimate (monospace-ish): ~5.5px per char at fontSize 9. + const labelWidth = coordLabel.length * 5.5 + 4; elements.push( - , + + + + , ); } @@ -237,5 +281,159 @@ export function ProjectionMeasurements({ ); } + // Per-furniture dimension overlay (only when toggled on). + // Coordinates are only projected onto an axis when the item is *near* that + // axis — otherwise the extension lines would sprawl across the whole view + // and add more noise than information. + const NEAR_AXIS = 0.1; // meters + for (const pf of projectedFurniture) { + if (!pf.item.showProjection) continue; + const { rect, item } = pf; + { + + const isNearFloor = rect.y <= NEAR_AXIS; + const isNearLeft = rect.x <= NEAR_AXIS; + + // ── Horizontal axis (along-wall) projection ── + // If the item touches the floor we extend the dimension all the way to + // the bottom ruler; otherwise we draw the width dimension just below + // the item itself. + const wLeft = projectionToPixel(rect.x, rect.y, wallHeight, scale, padding); + const wRight = projectionToPixel(rect.x + rect.width, rect.y, wallHeight, scale, padding); + if (isNearFloor) { + const wLeftFloor = projectionToPixel(rect.x, 0, wallHeight, scale, padding); + const wRightFloor = projectionToPixel(rect.x + rect.width, 0, wallHeight, scale, padding); + elements.push( + , + ); + if (rect.x > 0.001) { + const oLeft = projectionToPixel(0, 0, wallHeight, scale, padding); + elements.push( + , + ); + } + } else { + // Inline width dimension drawn just below the bottom edge of the item + elements.push( + , + ); + // Inline start-offset (distance from wall start to the left edge), + // shown as a thin extension line + label so the user still sees the + // horizontal position when the item is not on the floor. + if (rect.x > 0.001) { + const oLeft = projectionToPixel(0, rect.y, wallHeight, scale, padding); + elements.push( + , + ); + } + } + + // ── Vertical axis (height) projection ── + // If the item touches the left edge of the wall we extend to the left + // ruler; otherwise we draw the height dimension just to the left of + // the item itself. + const hBottom = projectionToPixel(rect.x, rect.y, wallHeight, scale, padding); + const hTop = projectionToPixel(rect.x, rect.y + rect.height, wallHeight, scale, padding); + if (isNearLeft) { + elements.push( + , + ); + if (rect.y > 0.001) { + const eFloor = projectionToPixel(rect.x, 0, wallHeight, scale, padding); + elements.push( + , + ); + } + } else { + // Inline height dimension drawn just to the left of the item + elements.push( + , + ); + // Inline elevation (distance from floor to the bottom of the item), + // so wall-mounted items still show their vertical position. + if (rect.y > 0.001) { + const eFloor = projectionToPixel(rect.x + rect.width, 0, wallHeight, scale, padding); + const eBottom = projectionToPixel(rect.x + rect.width, rect.y, wallHeight, scale, padding); + elements.push( + , + ); + } + } + } + } + return {elements}; } + diff --git a/apps/client/src/components/editor/projection/ProjectionPanel.tsx b/apps/client/src/components/editor/projection/ProjectionPanel.tsx index 33ad058..d204d6a 100644 --- a/apps/client/src/components/editor/projection/ProjectionPanel.tsx +++ b/apps/client/src/components/editor/projection/ProjectionPanel.tsx @@ -1,10 +1,11 @@ import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import type { ElectricalItem, WallOpening } from '@house-plan-maker/shared'; +import type { ElectricalItem, WallOpening, Annotation } from '@house-plan-maker/shared'; import { useEditor } from '../context/EditorContext'; -import { wallDirectionKey } from '../utils/projectionMapping'; -import { wallStartEnd } from '../utils/wallUtils'; +import { wallDirectionKey, getProjectionAxis } from '../utils/projectionMapping'; import { generateLocalId } from '../utils/geometry'; +import { normalizeAngleDegrees } from '../utils/angle'; +import { TextPromptModal } from '../../ui/TextPromptModal'; import { getDefaultElevation } from '../tools/ElectricalTool'; import { ELECTRICAL_SYMBOL_DEFS } from '../symbols/electrical'; import { WallProjectionView } from './WallProjectionView'; @@ -19,13 +20,14 @@ interface ProjectionPanelProps { export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPanelProps = {}) { const { t } = useTranslation(); - const { state, selectElement, updateElectrical, updateOpening, addElectrical } = useEditor(); + const { state, selectElement, updateElectrical, updateOpening, addElectrical, updateAnnotation } = useEditor(); const { walls, openings, electricalItems, furnitureItems, annotations, + globalFurnitureOpacity, room, selectedIds, activeTool, @@ -120,6 +122,21 @@ export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPane [updateOpening], ); + const handleUpdateAnnotation = useCallback( + (annotation: Annotation) => { + updateAnnotation(annotation); + }, + [updateAnnotation], + ); + + // ── Annotation editing modal (shared by all wall views) ── + const [editingAnnotationId, setEditingAnnotationId] = useState(null); + const editingAnnotation = useMemo( + () => (editingAnnotationId ? state.annotations.find((a) => a.id === editingAnnotationId) ?? null : null), + [editingAnnotationId, state.annotations], + ); + const handleEditAnnotation = useCallback((id: string) => setEditingAnnotationId(id), []); + // ── Placement callback ── const handlePlaceElectrical = useCallback( (wallId: string, alongWall: number, fromFloor: number) => { @@ -131,10 +148,9 @@ export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPane 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, - ); + // using the canonical projection axis (so south/west walls aren't mirrored). + const axis = getProjectionAxis(wall); + const { start, end, length: wallLen } = axis; if (wallLen === 0) return; const dx = (end.x - start.x) / wallLen; @@ -147,8 +163,10 @@ export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPane ? fromFloor : getDefaultElevation(symbolDef.type, room.wallHeight); + // Outlets no longer use a metadata variant (count is the source of + // truth). Other electrical types still pass variant through metadata. const metadata: Record | null = - symbolDef.variant ? { variant: symbolDef.variant } : null; + symbolDef.type !== 'OUTLET' && symbolDef.variant ? { variant: symbolDef.variant } : null; const newItem: ElectricalItem = { id: generateLocalId(), @@ -158,7 +176,10 @@ export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPane y, wallId: symbolDef.wallMounted ? wallId : null, elevationFromFloor: elevation, - rotation: (Math.atan2(dy, dx) * 180) / Math.PI, + rotation: normalizeAngleDegrees((Math.atan2(dy, dx) * 180) / Math.PI), + count: 1, + positionAnchor: { horizontal: 'middle', vertical: 'middle' }, + label: null, metadata, }; @@ -179,13 +200,18 @@ export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPane electricalItems: layerVisibility.electrical ? electricalItems : [], furnitureItems: layerVisibility.furniture ? furnitureItems : [], annotations: layerVisibility.annotations ? annotations : [], + globalFurnitureOpacity, wallHeight: room.wallHeight, plinthHeight: room.plinthHeight, + outletWidth: room.outletWidth, + outletHeight: room.outletHeight, selectedIds, onSelectElement: handleSelectElement, onStageRef, onUpdateElectrical: handleUpdateElectrical, onUpdateOpening: handleUpdateOpening, + onUpdateAnnotation: handleUpdateAnnotation, + onEditAnnotation: handleEditAnnotation, onPlaceElectrical: handlePlaceElectrical, showMeasurements: layerVisibility.measurements, activeTool, @@ -294,6 +320,18 @@ export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPane )} )} + { + if (editingAnnotation && value !== editingAnnotation.text) { + updateAnnotation({ ...editingAnnotation, text: value }); + } + setEditingAnnotationId(null); + }} + onCancel={() => setEditingAnnotationId(null)} + />
); } diff --git a/apps/client/src/components/editor/projection/ProjectionWindow.tsx b/apps/client/src/components/editor/projection/ProjectionWindow.tsx index 90f5a15..77a11fd 100644 --- a/apps/client/src/components/editor/projection/ProjectionWindow.tsx +++ b/apps/client/src/components/editor/projection/ProjectionWindow.tsx @@ -80,51 +80,42 @@ export function ProjectionWindow({ stroke="#93c5fd" strokeWidth={0.5} /> - {/* Horizontal mullion (center divider) */} - - {/* Vertical mullion (center divider) */} - - {/* Glass cross lines for indication */} - - + {/* Internal mullions — N×M grid. Rendered as lines spanning the + glass area; `gridCols - 1` verticals + `gridRows - 1` + horizontals. Defaults to 2×2 for legacy windows without an + explicit grid set. */} + {(() => { + const cols = Math.max(1, Math.min(10, Math.round(opening.gridCols ?? 2))); + const rows = Math.max(1, Math.min(10, Math.round(opening.gridRows ?? 2))); + const innerLeft = topLeft.x + frameInset; + const innerTop = topLeft.y + frameInset; + const innerWidth = pxWidth - frameInset * 2; + const innerHeight = pxHeight - frameInset * 2; + const lines: React.ReactNode[] = []; + for (let i = 1; i < cols; i++) { + const x = innerLeft + (innerWidth * i) / cols; + lines.push( + , + ); + } + for (let i = 1; i < rows; i++) { + const y = innerTop + (innerHeight * i) / rows; + lines.push( + , + ); + } + return lines; + })()} ); } diff --git a/apps/client/src/components/editor/projection/WallProjectionView.tsx b/apps/client/src/components/editor/projection/WallProjectionView.tsx index 8b8dea9..7cf5575 100644 --- a/apps/client/src/components/editor/projection/WallProjectionView.tsx +++ b/apps/client/src/components/editor/projection/WallProjectionView.tsx @@ -3,7 +3,7 @@ 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 { wallLength as computeWallLength } from '../utils/wallUtils'; import { projectionScale, projectionToPixel, @@ -13,6 +13,7 @@ import { projectFurnitureItems, computePlinthSegments, wallDirectionLabel, + getProjectionAxis, } from '../utils/projectionMapping'; import { ProjectionDoor } from './ProjectionDoor'; import { ProjectionWindow } from './ProjectionWindow'; @@ -27,9 +28,12 @@ interface WallProjectionViewProps { readonly electricalItems: readonly ElectricalItem[]; readonly furnitureItems: readonly FurnitureItem[]; readonly annotations: readonly Annotation[]; + readonly globalFurnitureOpacity?: number; readonly showMeasurements?: boolean; readonly wallHeight: number; readonly plinthHeight: number; + readonly outletWidth?: number; + readonly outletHeight?: number; readonly selectedIds: ReadonlySet; readonly isHighlighted: boolean; readonly onSelectElement: (id: string) => void; @@ -38,6 +42,8 @@ interface WallProjectionViewProps { readonly onStageRef?: (wallId: string, stage: Konva.Stage | null) => void; readonly onUpdateElectrical?: (item: ElectricalItem) => void; readonly onUpdateOpening?: (opening: WallOpening) => void; + readonly onUpdateAnnotation?: (annotation: Annotation) => void; + readonly onEditAnnotation?: (annotationId: string) => void; readonly onPlaceElectrical?: (wallId: string, alongWall: number, fromFloor: number) => void; readonly activeTool?: EditorToolType; readonly selectedElectricalType?: ElectricalType | null; @@ -60,6 +66,15 @@ interface DragInfo { readonly itemId: string; readonly startPixelX: number; readonly startPixelY: number; + /** + * Projection-coord offset between the cursor and the item's stored + * anchor at drag start. Subtracted from the live cursor position so + * the drag respects where inside the item the user grabbed instead of + * teleporting the anchor to the cursor (which made non-middle anchors + * jump on drag start). + */ + readonly offsetAlongWall: number; + readonly offsetFromFloor: number; readonly exceeded: boolean; } @@ -69,9 +84,12 @@ export function WallProjectionView({ electricalItems, furnitureItems, annotations, + globalFurnitureOpacity = 1, showMeasurements = true, wallHeight, plinthHeight, + outletWidth, + outletHeight, selectedIds, isHighlighted, onSelectElement, @@ -80,6 +98,8 @@ export function WallProjectionView({ onStageRef, onUpdateElectrical, onUpdateOpening, + onUpdateAnnotation, + onEditAnnotation, onPlaceElectrical, activeTool, selectedElectricalType, @@ -102,6 +122,7 @@ export function WallProjectionView({ viewPanRef.current = viewPan; const wallLen = computeWallLength(wall); + const projectionAxis = useMemo(() => getProjectionAxis(wall), [wall]); const baseScale = projectionScale(wallLen, wallHeight, width, height, PADDING); const effectiveScale = baseScale * viewZoom; @@ -111,6 +132,32 @@ export function WallProjectionView({ const [dragElectricalAlongWall, setDragElectricalAlongWall] = useState<{ itemId: string; alongWall: number } | null>(null); const [dragOpeningAlongWall, setDragOpeningAlongWall] = useState<{ openingId: string; alongWall: number } | null>(null); + // ── Measure tool state ── + // Stored in projection coordinates: `alongWall` = meters from the + // canonical wall start, `fromFloor` = meters from the floor. Distance + // is the Euclidean distance between the two endpoints in those units, + // i.e. real-world metres on the wall surface. + interface ProjectionMeasurement { + readonly start: { alongWall: number; fromFloor: number }; + readonly end: { alongWall: number; fromFloor: number }; + } + const [measurement, setMeasurement] = useState(null); + const isMeasuringRef = useRef(false); + + // Clear the measurement when the user switches to a different tool or + // the wall changes — otherwise stale lines persist between unrelated + // interactions. + useEffect(() => { + if (activeTool !== 'measure') { + setMeasurement(null); + isMeasuringRef.current = false; + } + }, [activeTool]); + useEffect(() => { + setMeasurement(null); + isMeasuringRef.current = false; + }, [wall.id]); + // ── Projected data (memoized) ── const projectedOpenings = useMemo( () => projectOpenings(wall, openings), @@ -144,26 +191,66 @@ export function WallProjectionView({ // ── Electrical drag start ── const handleElectricalDragStart = useCallback((itemId: string, evt: MouseEvent) => { if (!onUpdateElectrical) return; + const item = electricalItems.find((i) => i.id === itemId); + if (!item) return; + const pointer = getStagePointer(evt); + if (!pointer) return; + const proj = pixelToProjection( + pointer.x - viewPanRef.current.x, + pointer.y - viewPanRef.current.y, + wallHeight, + effectiveScale, + PADDING, + ); + // Project the item's stored (x, y) anchor onto the canonical wall axis + // so we can compute the cursor's offset from that anchor at drag start. + const wLen = wallLen || 1; + const dx = (projectionAxis.end.x - projectionAxis.start.x) / wLen; + const dy = (projectionAxis.end.y - projectionAxis.start.y) / wLen; + const itemAlongWall = (item.x - projectionAxis.start.x) * dx + (item.y - projectionAxis.start.y) * dy; + const itemFromFloor = item.elevationFromFloor ?? 0; dragRef.current = { kind: 'electrical-elevation', itemId, startPixelX: evt.clientX, startPixelY: evt.clientY, + offsetAlongWall: proj.alongWall - itemAlongWall, + offsetFromFloor: proj.fromFloor - itemFromFloor, exceeded: false, }; - }, [onUpdateElectrical]); + }, [onUpdateElectrical, electricalItems, getStagePointer, projectionAxis, wallLen, wallHeight, effectiveScale]); // ── Opening drag start ── const handleOpeningDragStart = useCallback((openingId: string, evt: MouseEvent) => { if (!onUpdateOpening) return; + const opening = openings.find((o) => o.id === openingId); + if (!opening) return; + const pointer = getStagePointer(evt); + if (!pointer) return; + const proj = pixelToProjection( + pointer.x - viewPanRef.current.x, + pointer.y - viewPanRef.current.y, + wallHeight, + effectiveScale, + PADDING, + ); + // Opening's projected alongWall takes axis flipping into account so + // the offset stays consistent with how the symbol is rendered. + const projectedAlongWall = projectionAxis.flipped + ? (wallLen - opening.positionAlongWall) + : opening.positionAlongWall; dragRef.current = { kind: 'opening-position', itemId: openingId, startPixelX: evt.clientX, startPixelY: evt.clientY, + offsetAlongWall: proj.alongWall - projectedAlongWall, + // Openings only drag horizontally for now, but populate the field for + // type completeness so the DragInfo invariant holds. + offsetFromFloor: 0, exceeded: false, }; - }, [onUpdateOpening]); + }, [onUpdateOpening, openings, getStagePointer, projectionAxis, wallLen, wallHeight, effectiveScale]); // ── Zoom handler ── const handleWheel = useCallback((e: Konva.KonvaEventObject) => { @@ -200,6 +287,34 @@ export function WallProjectionView({ // ── Pan handlers ── const handleMouseDown = useCallback((e: Konva.KonvaEventObject) => { + // Measure tool takes precedence over pan: a left-click on the empty + // wall (not on an interactive item) starts a new measurement rather + // than panning. Middle-click still pans in measure mode so the user + // can reposition the view between measurements. + if (activeTool === 'measure' && e.evt.button === 0) { + const targetName = (e.target as { name?: () => string })?.name?.() ?? ''; + const isOnItem = e.target !== e.target.getStage() && targetName !== 'wall-bg'; + if (!isOnItem) { + const pointer = getStagePointer(e.evt); + if (pointer) { + const proj = pixelToProjection( + pointer.x - viewPanRef.current.x, + pointer.y - viewPanRef.current.y, + wallHeight, + effectiveScale, + PADDING, + ); + setMeasurement({ + start: { alongWall: proj.alongWall, fromFloor: proj.fromFloor }, + end: { alongWall: proj.alongWall, fromFloor: proj.fromFloor }, + }); + isMeasuringRef.current = true; + e.evt.preventDefault(); + return; + } + } + } + // 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)) { @@ -221,7 +336,7 @@ export function WallProjectionView({ if (e.evt.button === 0 && e.target === e.currentTarget?.getStage()?.findOne('.wall-bg')) { // This is handled in the wall-bg rect click } - }, [viewPan]); + }, [activeTool, selectedElectricalType, viewPan, wallHeight, effectiveScale, getStagePointer]); const handleMouseMove = useCallback((e: Konva.KonvaEventObject) => { // Handle panning @@ -235,6 +350,29 @@ export function WallProjectionView({ return; } + // Update live measurement endpoint while the mouse is held down. + if (isMeasuringRef.current) { + const pointer = getStagePointer(e.evt); + if (pointer) { + const proj = pixelToProjection( + pointer.x - viewPanRef.current.x, + pointer.y - viewPanRef.current.y, + wallHeight, + effectiveScale, + PADDING, + ); + setMeasurement((prev) => + prev + ? { + start: prev.start, + end: { alongWall: proj.alongWall, fromFloor: proj.fromFloor }, + } + : prev, + ); + } + return; + } + // Handle dragging const drag = dragRef.current; if (!drag) return; @@ -254,22 +392,28 @@ export function WallProjectionView({ const proj = pixelToProjection(pointer.x - viewPanRef.current.x, pointer.y - viewPanRef.current.y, wallHeight, effectiveScale, PADDING); + // Subtract the grab offset so the item moves with the cursor delta + // instead of teleporting its anchor to the cursor. This matters for + // non-middle anchors and for grabbing near an item edge. + const targetAlongWall = proj.alongWall - drag.offsetAlongWall; + const targetFromFloor = proj.fromFloor - drag.offsetFromFloor; + 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)); + const clampedAlongWall = Math.max(0, Math.min(wallLen, targetAlongWall)); setDragElectricalAlongWall({ itemId: drag.itemId, alongWall: clampedAlongWall }); setDragElectricalFromFloor(null); } else { // Normal drag: move vertically (elevation) - const clampedFromFloor = Math.max(0, Math.min(wallHeight, proj.fromFloor)); + const clampedFromFloor = Math.max(0, Math.min(wallHeight, targetFromFloor)); 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)); + const clampedAlongWall = Math.max(halfWidth, Math.min(wallLen - halfWidth, targetAlongWall)); setDragOpeningAlongWall({ openingId: drag.itemId, alongWall: clampedAlongWall }); } }, [getStagePointer, wallHeight, effectiveScale, wallLen, openings]); @@ -277,6 +421,21 @@ export function WallProjectionView({ const handleMouseUp = useCallback(() => { isPanningRef.current = false; + // Release the measurement drag but keep the rendered line on screen + // so the user can read the value. The next left-click on empty space + // in measure mode will start a fresh measurement. + if (isMeasuringRef.current) { + isMeasuringRef.current = false; + // Clear a zero-length measurement (single click without drag). + setMeasurement((prev) => { + if (!prev) return prev; + const dAlong = prev.end.alongWall - prev.start.alongWall; + const dUp = prev.end.fromFloor - prev.start.fromFloor; + if (Math.hypot(dAlong, dUp) < 0.01) return null; + return prev; + }); + } + const drag = dragRef.current; if (!drag) return; @@ -295,8 +454,8 @@ export function WallProjectionView({ 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); + // Horizontal drag: compute new x,y from canonical alongWall position + const { start, end } = projectionAxis; const wLen = wallLen || 1; const t = dragElectricalAlongWall.alongWall / wLen; onUpdateElectrical({ @@ -314,9 +473,13 @@ export function WallProjectionView({ } else if (drag.kind === 'opening-position' && dragOpeningAlongWall && onUpdateOpening) { const opening = openings.find((o) => o.id === drag.itemId); if (opening) { + // Map canonical projection position back to storage (relative to wall.start) + const storedPos = projectionAxis.flipped + ? wallLen - dragOpeningAlongWall.alongWall + : dragOpeningAlongWall.alongWall; onUpdateOpening({ ...opening, - positionAlongWall: Math.round(dragOpeningAlongWall.alongWall * 100) / 100, + positionAlongWall: Math.round(storedPos * 100) / 100, }); } } @@ -324,7 +487,7 @@ export function WallProjectionView({ setDragElectricalFromFloor(null); setDragElectricalAlongWall(null); setDragOpeningAlongWall(null); - }, [dragElectricalFromFloor, dragElectricalAlongWall, dragOpeningAlongWall, electricalItems, openings, wall, wallLen, onUpdateElectrical, onUpdateOpening]); + }, [dragElectricalFromFloor, dragElectricalAlongWall, dragOpeningAlongWall, electricalItems, openings, projectionAxis, wallLen, onUpdateElectrical, onUpdateOpening]); // ── Handle click on wall background for placement ── const handleWallBgClick = useCallback((e: Konva.KonvaEventObject) => { @@ -343,11 +506,14 @@ export function WallProjectionView({ onPlaceElectrical(wall.id, proj.alongWall, proj.fromFloor); }, [activeTool, selectedElectricalType, onPlaceElectrical, getStagePointer, wallHeight, effectiveScale, wallLen, wall.id]); - // ── Reset zoom when wall changes ── + // ── Reset zoom when the *physical* wall changes ── + // We key on the wall's start/end coords rather than id so a save (which + // re-creates walls with new ids) does not wipe the user's pan/zoom. + const wallKey = `${wall.startX},${wall.startY},${wall.endX},${wall.endY}`; useEffect(() => { setViewZoom(1); setViewPan({ x: 0, y: 0 }); - }, [wall.id]); + }, [wallKey]); // ── Coordinate helpers ── const toPixel = useCallback( @@ -597,6 +763,7 @@ export function WallProjectionView({ scale={effectiveScale} padding={PADDING} isSelected={selectedIds.has(pf.item.id)} + globalOpacity={globalFurnitureOpacity} onClick={() => onSelectElement(pf.item.id)} /> ))} @@ -616,25 +783,81 @@ export function WallProjectionView({ isDragging={isDraggingV || isDraggingH} dragFromFloor={isDraggingV ? dragElectricalFromFloor?.fromFloor : undefined} dragAlongWall={isDraggingH ? dragElectricalAlongWall?.alongWall : undefined} + outletWidth={outletWidth} + outletHeight={outletHeight} onClick={() => onSelectElement(pe.item.id)} onDragStart={onUpdateElectrical ? handleElectricalDragStart : undefined} /> ); })} - {/* Measurements */} - {showMeasurements && ( - - )} + {/* Measure tool overlay — a dashed line from start to end with a + distance label. Rendered above the items but below annotations. + Dimensions are in wall-surface metres (√((Δalong)² + (Δup)²)). */} + {measurement && (() => { + const startPx = projectionToPixel( + measurement.start.alongWall, + measurement.start.fromFloor, + wallHeight, + effectiveScale, + PADDING, + ); + const endPx = projectionToPixel( + measurement.end.alongWall, + measurement.end.fromFloor, + wallHeight, + effectiveScale, + PADDING, + ); + const dAlong = measurement.end.alongWall - measurement.start.alongWall; + const dUp = measurement.end.fromFloor - measurement.start.fromFloor; + const distM = Math.hypot(dAlong, dUp); + const midX = (startPx.x + endPx.x) / 2; + const midY = (startPx.y + endPx.y) / 2; + const label = distM >= 1 ? `${distM.toFixed(2)} m` : `${(distM * 100).toFixed(1)} cm`; + return ( + + + {/* Endpoint dots */} + + + {distM > 0.005 && ( + + )} + + ); + })()} - {/* Attached annotations for items on this wall */} + {/* Measurements (and per-furniture projection overlay, which renders + independently so it stays visible even when measurements are hidden) */} + + + {/* Attached annotations for items on this wall — interactive */} {annotations .filter((ann) => { if (!ann.attachedToId) return false; @@ -642,7 +865,6 @@ export function WallProjectionView({ 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; @@ -655,34 +877,80 @@ export function WallProjectionView({ 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; + // Use the dedicated projection offset (defaulting to a small offset + // up & to the right of the anchor) so dragging in this view does + // not corrupt the floor-plan offset stored in ann.x/ann.y. + const projOffsetX = ann.projectionOffsetX ?? 0.3; + const projOffsetY = ann.projectionOffsetY ?? -0.4; + const textX = anchorPx.x + projOffsetX * effectiveScale; + const textY = anchorPx.y + projOffsetY * effectiveScale; + const isSelected = selectedIds.has(ann.id); + const fontSize = ann.fontSize ?? 10; + const boxWidth = ann.text.length * (fontSize * 0.6) + 6; + const boxHeight = fontSize + 4; + // URL detection mirrors the floor-plan AnnotationLayer so links + // get the same blue tint and Ctrl/Cmd-click affordance in both + // views. Anchored to start/end so "see http://x" stays plain. + const annText = ann.text.trim(); + const isLink = /^https?:\/\/\S+$/i.test(annText); + const linkColor = '#2563eb'; + const textFill = isSelected + ? '#2563eb' + : isLink + ? (ann.color ?? linkColor) + : (ann.color ?? '#334155'); return ( - - + draggable={onUpdateAnnotation != null} + onClick={(e) => { + if (isLink && (e.evt.ctrlKey || e.evt.metaKey)) { + e.cancelBubble = true; + window.open(annText, '_blank', 'noopener,noreferrer'); + return; + } + onSelectElement(ann.id); + }} + onDblClick={() => onEditAnnotation?.(ann.id)} + onDragEnd={(e) => { + if (!onUpdateAnnotation) return; + // The Group's parent is the pan group, so node.x()/y() are + // already in the same coordinate system as anchorPx (no + // need to compensate for viewPan). + const newOffsetX = (e.target.x() - anchorPx.x) / effectiveScale; + const newOffsetY = (e.target.y() - anchorPx.y) / effectiveScale; + onUpdateAnnotation({ + ...ann, + projectionOffsetX: Math.round(newOffsetX * 1000) / 1000, + projectionOffsetY: Math.round(newOffsetY * 1000) / 1000, + }); + }} + > + + + ); }) diff --git a/apps/client/src/components/editor/properties-panel.module.css b/apps/client/src/components/editor/properties-panel.module.css index 692d2a9..b588e96 100644 --- a/apps/client/src/components/editor/properties-panel.module.css +++ b/apps/client/src/components/editor/properties-panel.module.css @@ -8,6 +8,10 @@ } .header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); padding: var(--space-3) var(--space-4); font-size: var(--font-size-sm); font-weight: var(--font-weight-semibold); @@ -17,6 +21,40 @@ text-transform: uppercase; } +.collapseBtn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-bg); + color: var(--color-text-secondary); + cursor: pointer; + font-size: 12px; + line-height: 1; + transition: background-color var(--transition-fast), color var(--transition-fast); +} + +.collapseBtn:hover { + background-color: var(--color-bg-hover); + color: var(--color-text-primary); +} + +.panelCollapsed { + width: 32px; + min-width: 32px; + background-color: var(--color-bg-elevated); + border-left: 1px solid var(--color-border); + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; + padding-top: var(--space-2); +} + .empty { padding: var(--space-6) var(--space-4); text-align: center; diff --git a/apps/client/src/components/editor/symbols/electrical/OutletSymbol.tsx b/apps/client/src/components/editor/symbols/electrical/OutletSymbol.tsx index 338d84b..56385a9 100644 --- a/apps/client/src/components/editor/symbols/electrical/OutletSymbol.tsx +++ b/apps/client/src/components/editor/symbols/electrical/OutletSymbol.tsx @@ -1,77 +1,100 @@ -import { Group, Circle, Line } from 'react-konva'; +import { Group, Rect, Circle, Line } from 'react-konva'; interface OutletSymbolProps { + /** Screen-space position of the anchor point (already includes pan/zoom + anchor offset). */ readonly x: number; readonly y: number; + /** Item rotation in degrees. Applied around the local origin. */ readonly rotation: number; readonly color: string; - readonly scale: number; + /** Number of individual outlets in the group (>= 1). */ + readonly count: number; + /** Physical width of a single outlet face plate, expressed in screen pixels (m * zoom). */ + readonly outletWidthPx: number; + /** Physical height of a single outlet face plate, expressed in screen pixels (m * zoom). */ + readonly outletHeightPx: number; + /** + * Local-space offset (pre-rotation) from the (x, y) anchor to the geometric + * center of the bounding box. The bounding box has width = count * outletWidthPx + * and height = outletHeightPx, arranged horizontally along the local x-axis. + */ + readonly centerOffsetX: number; + readonly centerOffsetY: number; } /** - * IEC 60617 outlet symbol variants. - * Base: circle with two parallel prongs. + * Outlet symbol — renders `count` adjacent face plates side-by-side along the + * local x-axis (which aligns with the wall when the item is wall-mounted). + * Each face plate is drawn as a rounded rectangle with a small "outlet face" + * (circle + two prongs) centered inside it. Sized in real-world units so the + * boundaries reflect the room's configured outlet dimensions. */ +export function OutletSymbol({ + x, + y, + rotation, + color, + count, + outletWidthPx, + outletHeightPx, + centerOffsetX, + centerOffsetY, +}: OutletSymbolProps) { + const safeCount = Math.max(1, Math.round(count)); + const totalWidth = safeCount * outletWidthPx; + // Top-left corner of the bounding box in local space (relative to the anchor). + const left = centerOffsetX - totalWidth / 2; + const top = centerOffsetY - outletHeightPx / 2; -/** 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; + // Inner outlet face geometry: shrink to fit the smaller dimension of one cell. + const cellMin = Math.min(outletWidthPx, outletHeightPx); + const faceRadius = cellMin * 0.32; + const prongLen = cellMin * 0.18; + const prongGap = cellMin * 0.12; return ( - - - - - - ); -} - -/** 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 ( - - {/* Left outlet */} - - - - - - {/* Right outlet */} - - - - - - - ); -} - -/** 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 ( - - - {/* Prongs */} - - - {/* Earth symbol — vertical line down + three horizontal lines */} - - - - + + {Array.from({ length: safeCount }).map((_, i) => { + const cellLeft = left + i * outletWidthPx; + const cellCenterX = cellLeft + outletWidthPx / 2; + const cellCenterY = top + outletHeightPx / 2; + return ( + + + + + + + ); + })} ); } diff --git a/apps/client/src/components/editor/symbols/electrical/index.ts b/apps/client/src/components/editor/symbols/electrical/index.ts index dd46bb3..9994b03 100644 --- a/apps/client/src/components/editor/symbols/electrical/index.ts +++ b/apps/client/src/components/editor/symbols/electrical/index.ts @@ -1,4 +1,4 @@ -export { SingleOutletSymbol, DoubleOutletSymbol, GroundedOutletSymbol } from './OutletSymbol'; +export { OutletSymbol } from './OutletSymbol'; export { SingleSwitchSymbol, DoubleSwitchSymbol, DimmerSwitchSymbol } from './SwitchSymbol'; export { JunctionBoxSymbol } from './JunctionBoxSymbol'; export { CeilingLightSymbol } from './CeilingLightSymbol'; @@ -21,9 +21,7 @@ export interface ElectricalSymbolDef { } 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: 'OUTLET', label: 'Outlet', category: 'outlet', wallMounted: true }, { 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' }, @@ -33,7 +31,7 @@ export const ELECTRICAL_SYMBOL_DEFS: readonly ElectricalSymbolDef[] = [ { type: 'CABLE_ROUTE', label: 'Cable Route', category: 'cable', wallMounted: false }, ]; -/** Get the variant from an electrical item's metadata. */ +/** Get the variant from an electrical item's metadata. Used by switches; outlets use `count`. */ export function getElectricalVariant(metadata: Record | null): string { if (metadata && typeof metadata['variant'] === 'string') { return metadata['variant']; diff --git a/apps/client/src/components/editor/symbols/furniture/index.ts b/apps/client/src/components/editor/symbols/furniture/index.ts index b36637c..4094c75 100644 --- a/apps/client/src/components/editor/symbols/furniture/index.ts +++ b/apps/client/src/components/editor/symbols/furniture/index.ts @@ -9,38 +9,152 @@ export { TvSilhouette } from './TvSilhouette'; import type { FurnitureType } from '@house-plan-maker/shared'; +/** + * Logical grouping for the furniture picker. The category controls which + * tab/section an item appears under and is purely a UI concern — the data + * model only cares about `type`. + */ +export const FURNITURE_CATEGORIES = [ + 'sleeping', + 'seating', + 'tables', + 'storage', + 'electronics', + 'climate', + 'decor', +] as const; +export type FurnitureCategory = (typeof FURNITURE_CATEGORIES)[number]; + /** Default dimensions for each furniture type (width x depth x height in meters). */ export interface FurnitureDef { readonly type: FurnitureType; + readonly category: FurnitureCategory; readonly label: string; readonly width: number; readonly depth: number; readonly height: number; readonly icon: string; + /** + * Type-specific default metadata applied when a new item is placed from + * this preset. Used for plants to seed `{ variant, flowerColor }`, for + * curtains to seed `{ openAmount, fabricColor }` when new presets need + * non-default state, etc. The shape is opaque to the picker — readers + * on the mesh side look for the keys they care about. + */ + readonly defaultMetadata?: Record; } 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}' }, + { type: 'BED', category: 'sleeping', label: 'Single Bed', width: 1.0, depth: 2.0, height: 0.5, icon: '\u{1F6CF}' }, + { type: 'BED', category: 'sleeping', label: 'Double Bed', width: 1.4, depth: 2.0, height: 0.5, icon: '\u{1F6CF}' }, + { type: 'BED', category: 'sleeping', label: 'Queen Bed', width: 1.6, depth: 2.0, height: 0.5, icon: '\u{1F6CF}' }, + { type: 'BED', category: 'sleeping', label: 'King Bed', width: 1.8, depth: 2.0, height: 0.5, icon: '\u{1F6CF}' }, + // Cribs — a baby bed with slatted rails. Standard EU/US sizes: full-size + // ~70×130cm and compact ~60×120cm. Total height includes the top rail + // (~95cm from floor) which is what the 3D mesh draws the slats up to. + { type: 'CRIB', category: 'sleeping', label: 'Crib (Standard)', width: 0.72, depth: 1.32, height: 0.95, icon: '\u{1F476}' }, + { type: 'CRIB', category: 'sleeping', label: 'Crib (Compact)', width: 0.6, depth: 1.2, height: 0.95, icon: '\u{1F476}' }, + { type: 'NIGHTSTAND', category: 'sleeping', label: 'Nightstand', width: 0.5, depth: 0.4, height: 0.5, icon: '\u{1F4E6}' }, + { type: 'SOFA', category: 'seating', label: 'Sofa', width: 2.0, depth: 0.9, height: 0.8, icon: '\u{1FA91}' }, + { type: 'CHAIR', category: 'seating', label: 'Chair', width: 0.45, depth: 0.45, height: 0.85, icon: '\u{1FA91}' }, + // Office chairs — ergonomic task chair with wheeled 5-star base, gas + // lift, padded seat and tall backrest. Total height is top of the + // backrest; seat pan sits at ~45% of total height. Three presets: + // compact task chair, standard, and tall executive. + { type: 'OFFICE_CHAIR', category: 'seating', label: 'Office Chair', width: 0.6, depth: 0.6, height: 1.05, icon: '\u{1FA91}' }, + { type: 'OFFICE_CHAIR', category: 'seating', label: 'Office Chair (Task)', width: 0.55, depth: 0.55, height: 0.95, icon: '\u{1FA91}' }, + { type: 'OFFICE_CHAIR', category: 'seating', label: 'Office Chair (Executive)', width: 0.68, depth: 0.68, height: 1.2, icon: '\u{1FA91}' }, + { type: 'DESK', category: 'tables', label: 'Desk', width: 1.2, depth: 0.6, height: 0.75, icon: '\u{1F4BC}' }, + { type: 'TABLE', category: 'tables', label: 'Dining Table', width: 1.2, depth: 0.8, height: 0.75, icon: '\u{1F37D}' }, + { type: 'WARDROBE', category: 'storage', label: 'Wardrobe (S)', width: 1.0, depth: 0.6, height: 2.0, icon: '\u{1F3EA}' }, + { type: 'WARDROBE', category: 'storage', label: 'Wardrobe (M)', width: 1.5, depth: 0.6, height: 2.0, icon: '\u{1F3EA}' }, + { type: 'WARDROBE', category: 'storage', label: 'Wardrobe (L)', width: 2.0, depth: 0.6, height: 2.0, icon: '\u{1F3EA}' }, + { type: 'DRESSER', category: 'storage', label: 'Dresser', width: 1.0, depth: 0.5, height: 0.8, icon: '\u{1F3EA}' }, + // Dressing table / vanity — desk-like base with an upright mirror. The + // height includes the mirror (total ~1.5m) and the mesh derives the desk + // slab thickness from `height * 0.45`. + { type: 'DRESSING_TABLE', category: 'storage', label: 'Dressing Table', width: 1.0, depth: 0.4, height: 1.5, icon: '\u{1F484}' }, + { type: 'DRESSING_TABLE', category: 'storage', label: 'Dressing Table (L)', width: 1.3, depth: 0.45, height: 1.6, icon: '\u{1F484}' }, + { type: 'SHELF', category: 'storage', label: 'Tall Shelf', width: 0.8, depth: 0.3, height: 1.8, icon: '\u{1F4DA}' }, + { type: 'SHELF', category: 'storage', label: 'Wall Shelf 60', width: 0.6, depth: 0.25, height: 0.04, icon: '\u{1F4DA}' }, + { type: 'SHELF', category: 'storage', label: 'Wall Shelf 80', width: 0.8, depth: 0.25, height: 0.04, icon: '\u{1F4DA}' }, + { type: 'SHELF', category: 'storage', label: 'Wall Shelf 120', width: 1.2, depth: 0.25, height: 0.04, icon: '\u{1F4DA}' }, + { type: 'BOOKCASE', category: 'storage', label: 'Bookcase', width: 0.8, depth: 0.3, height: 2.0, icon: '\u{1F4DA}' }, + // Open bookshelves — no back panel, so the shelf contents are + // visible from both sides and the unit reads as a room divider. + // Row count is user-configurable via the properties panel and + // stored in `metadata.shelfRows`. Presets cover common + // 3/4/5-cube sizes. + { type: 'BOOKCASE', category: 'storage', label: 'Open Bookshelf 3', width: 0.8, depth: 0.3, height: 1.2, icon: '\u{1F4D6}', defaultMetadata: { shelfRows: 3, hasBackPanel: false } }, + { type: 'BOOKCASE', category: 'storage', label: 'Open Bookshelf 4', width: 0.8, depth: 0.3, height: 1.6, icon: '\u{1F4D6}', defaultMetadata: { shelfRows: 4, hasBackPanel: false } }, + { type: 'BOOKCASE', category: 'storage', label: 'Open Bookshelf 5', width: 0.8, depth: 0.3, height: 2.0, icon: '\u{1F4D6}', defaultMetadata: { shelfRows: 5, hasBackPanel: false } }, + { type: 'BOOKCASE', category: 'storage', label: 'Open Bookshelf (Wide)', width: 1.6, depth: 0.3, height: 2.0, icon: '\u{1F4D6}', defaultMetadata: { shelfRows: 5, hasBackPanel: false } }, + { type: 'TV', category: 'electronics', label: 'TV 32"', width: 0.73, depth: 0.08, height: 0.43, icon: '\u{1F4FA}' }, + { type: 'TV', category: 'electronics', label: 'TV 43"', width: 0.97, depth: 0.08, height: 0.57, icon: '\u{1F4FA}' }, + { type: 'TV', category: 'electronics', label: 'TV 55"', width: 1.24, depth: 0.08, height: 0.72, icon: '\u{1F4FA}' }, + { type: 'TV', category: 'electronics', label: 'TV 65"', width: 1.46, depth: 0.08, height: 0.84, icon: '\u{1F4FA}' }, + // Digital pianos. Width ≈ number of keys × ~2.4cm + 5cm for chassis + // sides; total height includes an X-frame stand when `hasStand` is on. + // Default stand height brings the chassis to ~72cm (standard playing + // height); turn the stand off in the properties panel to place the + // piano on a table. 88-key is the full-range preset; 76 and 61 are + // common portable sizes. + { type: 'DIGITAL_PIANO', category: 'electronics', label: 'Digital Piano 88', width: 1.3, depth: 0.32, height: 0.85, icon: '\u{1F3B9}', defaultMetadata: { hasStand: true } }, + { type: 'DIGITAL_PIANO', category: 'electronics', label: 'Digital Piano 76', width: 1.15, depth: 0.3, height: 0.85, icon: '\u{1F3B9}', defaultMetadata: { hasStand: true } }, + { type: 'DIGITAL_PIANO', category: 'electronics', label: 'Digital Piano 61', width: 0.95, depth: 0.28, height: 0.85, icon: '\u{1F3B9}', defaultMetadata: { hasStand: true } }, + // Speakers — compact shelf monitors and tall floor-standing towers. + // Shelf dimensions are typical 2-way bookshelf (~20×25×35cm); floor + // towers are 3-way with a deeper cabinet and ~1m height. + { type: 'SPEAKER', category: 'electronics', label: 'Shelf Speaker (S)', width: 0.18, depth: 0.22, height: 0.3, icon: '\u{1F50A}', defaultMetadata: { variant: 'shelf' } }, + { type: 'SPEAKER', category: 'electronics', label: 'Shelf Speaker (M)', width: 0.22, depth: 0.28, height: 0.38, icon: '\u{1F50A}', defaultMetadata: { variant: 'shelf' } }, + { type: 'SPEAKER', category: 'electronics', label: 'Shelf Speaker (L)', width: 0.26, depth: 0.32, height: 0.44, icon: '\u{1F50A}', defaultMetadata: { variant: 'shelf' } }, + { type: 'SPEAKER', category: 'electronics', label: 'Floor Speaker', width: 0.25, depth: 0.35, height: 1.0, icon: '\u{1F50A}', defaultMetadata: { variant: 'floor' } }, + { type: 'SPEAKER', category: 'electronics', label: 'Floor Speaker (L)', width: 0.3, depth: 0.4, height: 1.2, icon: '\u{1F50A}', defaultMetadata: { variant: 'floor' } }, + // PC tower / desktop case. Dimensions match typical ATX mid-tower (~20cm + // wide, ~45cm deep, ~45cm tall) and mini-tower variants. Sits on the + // floor or under a desk — default elevation 0. + { type: 'PC_TOWER', category: 'electronics', label: 'PC Tower (Mid)', width: 0.2, depth: 0.45, height: 0.45, icon: '\u{1F5A5}' }, + { type: 'PC_TOWER', category: 'electronics', label: 'PC Tower (Full)', width: 0.22, depth: 0.5, height: 0.55, icon: '\u{1F5A5}' }, + { type: 'PC_TOWER', category: 'electronics', label: 'PC Tower (Mini)', width: 0.18, depth: 0.38, height: 0.38, icon: '\u{1F5A5}' }, + { type: 'AC_UNIT', category: 'climate', label: 'AC Unit', width: 0.85, depth: 0.2, height: 0.3, icon: '\u{2744}' }, + // Standard panel-type room radiators. Width varies by section count; + // the 3D mesh derives the fin count from the width so the visual + // automatically tracks the chosen size. Depth is fairly thin (~10cm) + // and height matches typical 50cm under-window or 60cm wall units. + { type: 'RADIATOR', category: 'climate', label: 'Radiator 60', width: 0.6, depth: 0.1, height: 0.5, icon: '\u{1F525}' }, + { type: 'RADIATOR', category: 'climate', label: 'Radiator 100', width: 1.0, depth: 0.1, height: 0.5, icon: '\u{1F525}' }, + { type: 'RADIATOR', category: 'climate', label: 'Radiator 140', width: 1.4, depth: 0.1, height: 0.5, icon: '\u{1F525}' }, + // Wall photo collage — flat decorative panel mounted on a wall. Default + // elevation 1.4m (eye level) is set in FurnitureTool when placed. + { type: 'WALL_COLLAGE', category: 'decor', label: 'Wall Collage 3x2', width: 1.0, depth: 0.02, height: 0.7, icon: '\u{1F5BC}' }, + { type: 'WALL_COLLAGE', category: 'decor', label: 'Wall Collage 4x3', width: 1.4, depth: 0.02, height: 1.0, icon: '\u{1F5BC}' }, + { type: 'WALL_COLLAGE', category: 'decor', label: 'Wall Collage 5x2', width: 1.5, depth: 0.02, height: 0.6, icon: '\u{1F5BC}' }, + // Curtains — pleated fabric hanging from a rod. Height defaults to a + // typical 2.2m window drop; width varies by window size. Depth includes + // the pleat bulge (~10cm) so the item doesn't clip into the wall. + { type: 'CURTAIN', category: 'decor', label: 'Curtain 120', width: 1.2, depth: 0.1, height: 2.2, icon: '\u{1FA9F}' }, + { type: 'CURTAIN', category: 'decor', label: 'Curtain 160', width: 1.6, depth: 0.1, height: 2.2, icon: '\u{1FA9F}' }, + { type: 'CURTAIN', category: 'decor', label: 'Curtain 200', width: 2.0, depth: 0.1, height: 2.2, icon: '\u{1FA9F}' }, + // Plants — terracotta pot + foliage. Variant controls the mesh style: + // - `bush` → spherical leafy top (default if variant omitted) + // - `tall` → trunk + canopy (ficus / indoor tree) + // - `flower` → coloured blossom on a short stem + // flowerColor is used only by the flower variant. + { type: 'PLANT', category: 'decor', label: 'Potted Plant (S)', width: 0.22, depth: 0.22, height: 0.45, icon: '\u{1FAB4}', defaultMetadata: { variant: 'bush' } }, + { type: 'PLANT', category: 'decor', label: 'Potted Plant (M)', width: 0.3, depth: 0.3, height: 0.7, icon: '\u{1FAB4}', defaultMetadata: { variant: 'bush' } }, + { type: 'PLANT', category: 'decor', label: 'Floor Plant (Ficus)', width: 0.4, depth: 0.4, height: 1.5, icon: '\u{1F333}', defaultMetadata: { variant: 'tall' } }, + { type: 'PLANT', category: 'decor', label: 'Floor Plant (Tall)', width: 0.45, depth: 0.45, height: 1.8, icon: '\u{1F333}', defaultMetadata: { variant: 'tall' } }, + { type: 'PLANT', category: 'decor', label: 'Pink Flower', width: 0.2, depth: 0.2, height: 0.4, icon: '\u{1F337}', defaultMetadata: { variant: 'flower', flowerColor: '#e05570' } }, + { type: 'PLANT', category: 'decor', label: 'Yellow Flower', width: 0.2, depth: 0.2, height: 0.4, icon: '\u{1F33B}', defaultMetadata: { variant: 'flower', flowerColor: '#f0c040' } }, + { type: 'PLANT', category: 'decor', label: 'Red Flower', width: 0.2, depth: 0.2, height: 0.4, icon: '\u{1F339}', defaultMetadata: { variant: 'flower', flowerColor: '#c8394a' } }, + { type: 'PLANT', category: 'decor', label: 'White Flower', width: 0.2, depth: 0.2, height: 0.4, icon: '\u{1F33C}', defaultMetadata: { variant: 'flower', flowerColor: '#f4f4ee' } }, + // Mirrors — framed reflective panels. Two variants: + // - wall: thin panel, wall-mounted, default elevation ~1.2m (eye level) + // - floor: full-length with an A-frame stand, sits on the floor + // Depth reflects the physical thickness including any stand legs. + { type: 'MIRROR', category: 'decor', label: 'Wall Mirror (S)', width: 0.6, depth: 0.04, height: 0.9, icon: '\u{1FA9E}', defaultMetadata: { variant: 'wall' } }, + { type: 'MIRROR', category: 'decor', label: 'Wall Mirror (M)', width: 0.8, depth: 0.04, height: 1.2, icon: '\u{1FA9E}', defaultMetadata: { variant: 'wall' } }, + { type: 'MIRROR', category: 'decor', label: 'Wall Mirror (L)', width: 1.0, depth: 0.04, height: 1.5, icon: '\u{1FA9E}', defaultMetadata: { variant: 'wall' } }, + { type: 'MIRROR', category: 'decor', label: 'Floor Mirror', width: 0.5, depth: 0.4, height: 1.6, icon: '\u{1FA9E}', defaultMetadata: { variant: 'floor' } }, + { type: 'MIRROR', category: 'decor', label: 'Floor Mirror (L)', width: 0.6, depth: 0.5, height: 1.8, icon: '\u{1FA9E}', defaultMetadata: { variant: 'floor' } }, ]; diff --git a/apps/client/src/components/editor/three/CameraControls.tsx b/apps/client/src/components/editor/three/CameraControls.tsx index 2af01fa..35c53d4 100644 --- a/apps/client/src/components/editor/three/CameraControls.tsx +++ b/apps/client/src/components/editor/three/CameraControls.tsx @@ -1,8 +1,66 @@ import { useMemo, useRef } from 'react'; -import { useThree } from '@react-three/fiber'; +import { useFrame, useThree } from '@react-three/fiber'; import { OrbitControls } from '@react-three/drei'; import type { Point } from '@house-plan-maker/shared'; import { boundingBox } from '../utils/geometry'; +import styles from './camera-view-cube.module.css'; + +export interface CameraOrientation { + /** Rotation around the world Y axis, in degrees. 0 = looking south (toward +Z). */ + readonly yawDeg: number; + /** Rotation around the world X axis, in degrees. Negative = looking down. */ + readonly pitchDeg: number; +} + +/** + * CameraOrientationTracker reports the live camera direction (yaw + pitch) + * to the parent so the view-cube widget can mirror it. Uses useFrame so we + * pick up OrbitControls damping without subscribing to its onChange event. + */ +export function CameraOrientationTracker({ + onChange, +}: { + readonly onChange: (orientation: CameraOrientation) => void; +}) { + const { camera, controls } = useThree() as unknown as { + camera: import('three').Camera; + controls?: { target?: { x: number; y: number; z: number } }; + }; + const lastReportRef = useRef({ yawDeg: NaN, pitchDeg: NaN }); + + useFrame(() => { + const target = controls?.target; + const tx = target?.x ?? 0; + const ty = target?.y ?? 0; + const tz = target?.z ?? 0; + const dx = tx - camera.position.x; + const dy = ty - camera.position.y; + const dz = tz - camera.position.z; + const len = Math.hypot(dx, dy, dz); + if (len < 1e-6) return; + + const nx = dx / len; + const ny = dy / len; + const nz = dz / len; + + // Yaw: rotation around world Y. atan2(x, z) gives the heading angle. + const yawDeg = (Math.atan2(nx, nz) * 180) / Math.PI; + // Pitch: arcsin of vertical component. Negative when looking down. + const pitchDeg = (Math.asin(Math.max(-1, Math.min(1, ny))) * 180) / Math.PI; + + const last = lastReportRef.current; + if ( + Number.isNaN(last.yawDeg) || + Math.abs(yawDeg - last.yawDeg) > 0.5 || + Math.abs(pitchDeg - last.pitchDeg) > 0.5 + ) { + lastReportRef.current = { yawDeg, pitchDeg }; + onChange({ yawDeg, pitchDeg }); + } + }); + + return null; +} interface CameraControlsProps { readonly shape: readonly Point[]; @@ -64,48 +122,97 @@ function computePresets( export function CameraPresetsUI({ shape: _shape, wallHeight: _wallHeight, + orientation, onPreset, }: { readonly shape: readonly Point[]; readonly wallHeight: number; + readonly orientation?: CameraOrientation; 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' }, - ]; + // A small CSS-3D "view cube" widget. Top face = bird's eye, front face = + // eye level, and the four corner pins map to the corner presets. + // The cube rotates live with the Three.js camera so it doubles as an + // orientation indicator. + // + // Mapping: a camera looking down at the scene gives pitch ≈ -90° and we + // want the cube to show its top. The cube's `rotateX` flips the top face + // toward the viewer when positive, so we use -pitch. Yaw maps directly so + // turning the camera right rotates the cube right. + const cubeRotateX = orientation ? -orientation.pitchDeg : -26; + const cubeRotateY = orientation ? orientation.yawDeg : -32; + const cubeStyle = { + transform: `rotateX(${cubeRotateX}deg) rotateY(${cubeRotateY}deg)`, + } as const; return ( -
- {presetLabels.map(({ key, label }) => ( +
+
+
+ + +
+
+
+
+
+
+ + {/* Corner pins live outside .scene so they're never sucked into the + cube's 3D perspective transform. */} +
- ))} + + + +
+
View
); } diff --git a/apps/client/src/components/editor/three/DoorOpening.tsx b/apps/client/src/components/editor/three/DoorOpening.tsx index 1b8e61d..ced643a 100644 --- a/apps/client/src/components/editor/three/DoorOpening.tsx +++ b/apps/client/src/components/editor/three/DoorOpening.tsx @@ -10,17 +10,24 @@ interface DoorOpeningProps { readonly wall: Wall; readonly isSelected: boolean; readonly onSelect?: (id: string) => void; + /** + * When true, render the door panel ajar at ~30° to indicate swing + * direction. When false, the panel sits flush with the frame (closed). + * Toggled globally via the 3D view's HUD; not persisted on the opening. + */ + readonly isOpen?: boolean; } const FRAME_COLOR = '#8b7355'; const DOOR_PANEL_COLOR = '#a0522d'; -const FRAME_THICKNESS = 0.03; +/** Fallback frame thickness for legacy openings without an explicit value. */ +const DEFAULT_FRAME_THICKNESS = 0.03; const DOOR_PANEL_THICKNESS = 0.04; -/** Angle (radians) the door panel is shown ajar, to indicate swing direction. */ +/** Angle (radians) the door panel is shown ajar when "open" mode is on. */ const DOOR_AJAR_ANGLE = Math.PI / 6; // 30 degrees -export function DoorOpening({ opening, wall, isSelected, onSelect }: DoorOpeningProps) { +export function DoorOpening({ opening, wall, isSelected, onSelect, isOpen = true }: DoorOpeningProps) { const rotY = useMemo(() => wallRotationY(wall), [wall]); const [cx, cz] = useMemo( @@ -31,23 +38,28 @@ export function DoorOpening({ opening, wall, isSelected, onSelect }: DoorOpening const frameColor = isSelected ? '#6fa8dc' : FRAME_COLOR; const halfWidth = opening.width / 2; const halfThick = wall.thickness / 2 + 0.005; + // Per-opening frame thickness with a sane fallback for old data. + const frameThickness = Math.max(0, opening.frameThickness ?? DEFAULT_FRAME_THICKNESS); // Door panel rotation based on open direction const openDir = opening.openDirection ?? 'LEFT'; const isRight = openDir === 'RIGHT'; const isInward = openDir === 'INWARD'; + // Effective ajar angle is 0 when the door is shown closed. + const ajarAngle = isOpen ? DOOR_AJAR_ANGLE : 0; + // 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); + const panelRotY = swingSign * ajarAngle * (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); + ? -panelHalfW * Math.cos(ajarAngle) + : panelHalfW * Math.cos(ajarAngle); + const panelOffsetZ = swingSign * panelHalfW * Math.sin(ajarAngle); return ( {/* Left frame post */} - + {/* Right frame post */} - + {/* Top frame bar (lintel) */} - + @@ -79,7 +91,7 @@ export function DoorOpening({ opening, wall, isSelected, onSelect }: DoorOpening rotation={[0, panelRotY, 0]} castShadow > - + diff --git a/apps/client/src/components/editor/three/ElectricalMesh.tsx b/apps/client/src/components/editor/three/ElectricalMesh.tsx index 4c71c17..878c8ef 100644 --- a/apps/client/src/components/editor/three/ElectricalMesh.tsx +++ b/apps/client/src/components/editor/three/ElectricalMesh.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import * as THREE from 'three'; -import type { ElectricalItem, ElectricalType, Wall } from '@house-plan-maker/shared'; +import type { ElectricalItem, ElectricalType, Point, Wall } from '@house-plan-maker/shared'; import { wallRotationY, positionAlongWall3D, wallVector, wallNormal } from './utils/wallGeometry'; interface ElectricalMeshWithHeightProps { @@ -9,6 +9,36 @@ interface ElectricalMeshWithHeightProps { readonly wallHeight: number; readonly isSelected: boolean; readonly onSelect?: (id: string) => void; + /** Physical width of a single outlet face plate (meters). */ + readonly outletWidth: number; + /** Physical height of a single outlet face plate (meters). */ + readonly outletHeight: number; + /** + * Centroid of the room polygon (2D editor coords). Used to pick the + * inward-pointing side of a wall so wall-mounted items (outlets, + * switches, wall lights) sit on the room-facing face regardless of + * which direction `wallNormal` happens to return. + */ + readonly roomCentroid?: Point; +} + +/** + * Compute the local-space offset from the anchored origin (item.x, elevation, + * item.y projected onto wall) to the geometric center of the outlet group's + * bounding box. Local axes: +x along wall, +y vertical (up). + */ +function outletAnchorOffset( + item: ElectricalItem, + outletWidth: number, + outletHeight: number, +): { readonly cx: number; readonly cy: number } { + const totalWidth = Math.max(1, item.count) * outletWidth; + const h = item.positionAnchor.horizontal; + const v = item.positionAnchor.vertical; + const cx = h === 'left' ? totalWidth / 2 : h === 'right' ? -totalWidth / 2 : 0; + // Note: in 3D +y is up, so 'top' anchor means the center is BELOW (negative y). + const cy = v === 'top' ? -outletHeight / 2 : v === 'bottom' ? outletHeight / 2 : 0; + return { cx, cy }; } const ELECTRICAL_COLORS: Record = { @@ -32,13 +62,43 @@ function findWallInMap(wallId: string | null, wallMap: ReadonlyMap return wallMap.get(wallId) ?? null; } -/** Outlet: small rectangular box on wall */ -function OutletMesh({ color }: { readonly color: string }) { +/** + * Outlet group: renders `count` adjacent face plates side by side along the + * local x-axis (which is aligned with the wall). Each face plate is sized by + * the room's outlet dimensions and the whole group is offset so that the + * (item.x, item.y) anchor lies on the box edge or center as configured. + */ +function OutletMesh({ + color, + count, + outletWidth, + outletHeight, + centerX, + centerY, +}: { + readonly color: string; + readonly count: number; + readonly outletWidth: number; + readonly outletHeight: number; + readonly centerX: number; + readonly centerY: number; +}) { + const safeCount = Math.max(1, Math.round(count)); + // Depth into the wall: stays small, just enough to be visible. + const depth = 0.02; return ( - - - - + + {Array.from({ length: safeCount }).map((_, i) => { + // Center index 0..N-1 around 0 along local x. + const localX = (i - (safeCount - 1) / 2) * outletWidth; + return ( + + + + + ); + })} + ); } @@ -130,6 +190,7 @@ function CableRouteMesh({ color }: { readonly color: string }) { function useElectricalPosition( item: ElectricalItem, wall: Wall | null, + roomCentroid: Point | null, ): [number, number, number] { return useMemo<[number, number, number]>(() => { if (item.type === 'LIGHT_CEILING') { @@ -146,14 +207,33 @@ function useElectricalPosition( 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; + + // The wall's stored line is its INNER face (WallMesh shifts the box + // outward by thickness/2 so the inner face sits on the polygon edge). + // We want the outlet flush against that inner face, protruding a + // small gap (~1.5cm) INTO the room. `wallNormal` returns one of the + // two perpendiculars without knowing which side is the room interior; + // use the centroid to pick the inward-pointing sign. This is the same + // trick WallMesh uses to decide its outward shift. + let inwardSign = 1; + if (roomCentroid) { + const midX = (wall.startX + wall.endX) / 2; + const midY = (wall.startY + wall.endY) / 2; + // Vector from wall midpoint to centroid points INTO the room. + const toCentroidX = roomCentroid.x - midX; + const toCentroidY = roomCentroid.y - midY; + // Positive dot product → normal already points inward; otherwise flip. + inwardSign = nx * toCentroidX + ny * toCentroidY >= 0 ? 1 : -1; + } + + const offset = 0.015 * inwardSign; 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]); + }, [item, wall, roomCentroid]); } export function ElectricalMeshWithHeight({ @@ -162,6 +242,9 @@ export function ElectricalMeshWithHeight({ wallHeight, isSelected, onSelect, + outletWidth, + outletHeight, + roomCentroid, }: ElectricalMeshWithHeightProps) { const color = isSelected ? SELECTED_COLOR : ELECTRICAL_COLORS[item.type]; const wall = useMemo(() => { @@ -190,7 +273,7 @@ export function ElectricalMeshWithHeight({ } return null; }, [item, wallMap]); - const position = useElectricalPosition(item, wall); + const position = useElectricalPosition(item, wall, roomCentroid ?? null); const rotY = useMemo(() => { if (wall) return wallRotationY(wall); @@ -203,7 +286,19 @@ export function ElectricalMeshWithHeight({ rotation={[0, rotY, 0]} onClick={onSelect ? (e) => { e.stopPropagation(); onSelect(item.id); } : undefined} > - {item.type === 'OUTLET' && } + {item.type === 'OUTLET' && (() => { + const offset = outletAnchorOffset(item, outletWidth, outletHeight); + return ( + + ); + })()} {item.type === 'SWITCH' && } {item.type === 'JUNCTION_BOX' && } {item.type === 'LIGHT_CEILING' && } diff --git a/apps/client/src/components/editor/three/FloorCeiling.tsx b/apps/client/src/components/editor/three/FloorCeiling.tsx index 40962ad..14a49a1 100644 --- a/apps/client/src/components/editor/three/FloorCeiling.tsx +++ b/apps/client/src/components/editor/three/FloorCeiling.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import * as THREE from 'three'; import type { Point, FloorType } from '@house-plan-maker/shared'; +import { getFloorPbr } from './utils/pbrTextures'; interface FloorCeilingProps { readonly shape: readonly Point[]; @@ -8,7 +9,20 @@ interface FloorCeilingProps { readonly floorType?: FloorType; } -function createPolygonGeometry(shape: readonly Point[]): THREE.ShapeGeometry | null { +/** + * Build a ShapeGeometry from the room polygon and rescale its UVs so the + * floor's PBR texture tiles every `tileMeters` instead of stretching once + * over the full room. ShapeGeometry's default UVs are the shape coordinates + * directly (in meters), so dividing by `tileMeters` yields a UV that + * advances by 1.0 every `tileMeters` along each axis — exactly what + * `wrapS/wrapT = RepeatWrapping` needs to tile the texture. + * + * This puts the tiling on the geometry side, which means a single shared + * material instance can be reused across rooms of different sizes without + * mutating the material's texture.repeat (which would affect every mesh + * sharing the material). + */ +function buildFloorGeometry(shape: readonly Point[], tileMeters: number): THREE.ShapeGeometry | null { if (shape.length < 3) return null; const threeShape = new THREE.Shape(); @@ -18,178 +32,39 @@ function createPolygonGeometry(shape: readonly Point[]): THREE.ShapeGeometry | n } threeShape.closePath(); - return new THREE.ShapeGeometry(threeShape); -} + const geometry = 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(); + // Rescale UVs in place. Default ShapeGeometry UVs equal (x, y) coordinates + // in meters; dividing by tileMeters gives the desired tile density. + const uv = geometry.attributes.uv; + if (uv) { + const scale = 1 / tileMeters; + for (let i = 0; i < uv.count; i++) { + uv.setXY(i, uv.getX(i) * scale, uv.getY(i) * scale); } - - // 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); + uv.needsUpdate = true; } + + return geometry; } -function drawHerringbone(ctx: CanvasRenderingContext2D, size: number, c1: string, c2: string, c3: string) { - ctx.fillStyle = c3; - ctx.fillRect(0, 0, size, size); +export function FloorCeiling({ shape, wallHeight: _wallHeight, floorType = 'CONCRETE' }: FloorCeilingProps) { + const pbr = useMemo(() => getFloorPbr(floorType), [floorType]); + const geometry = useMemo( + () => buildFloorGeometry(shape, pbr.tileMeters), + [shape, pbr.tileMeters], + ); - 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(); - -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; + if (!geometry) return null; return ( - - + /> ); } diff --git a/apps/client/src/components/editor/three/FurnitureMesh.tsx b/apps/client/src/components/editor/three/FurnitureMesh.tsx index 708f858..6b32c6e 100644 --- a/apps/client/src/components/editor/three/FurnitureMesh.tsx +++ b/apps/client/src/components/editor/three/FurnitureMesh.tsx @@ -1,26 +1,49 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import * as THREE from 'three'; import type { FurnitureItem, FurnitureType } from '@house-plan-maker/shared'; +import { rotatedAnchorOffsetToCenter } from '@house-plan-maker/shared'; +import { getCurtainLeftOpen, getCurtainRightOpen, getCurtainFabricColor } from '../utils/curtainMetadata'; + +/** + * Below this opacity threshold, furniture meshes stop casting shadows. Cast + * shadows from near-invisible objects look like floating dark blobs because + * the shadow map ignores material opacity, so the visual is jarring. 1% gives + * users a clean "ghost" mode when they slide opacity to zero. + */ +const SHADOW_OPACITY_THRESHOLD = 0.01; interface FurnitureMeshProps { readonly item: FurnitureItem; readonly isSelected: boolean; readonly onSelect?: (id: string) => void; + /** Global furniture opacity multiplier from the toolbar slider (0..1). */ + readonly globalOpacity?: number; } const FURNITURE_COLORS: Record = { BED: '#8fa5b2', + CRIB: '#f0e4d2', DESK: '#b08968', WARDROBE: '#7a6652', SOFA: '#9b8e7e', TABLE: '#c4a882', CHAIR: '#a0937d', + OFFICE_CHAIR: '#2a2a30', SHELF: '#b09e8a', NIGHTSTAND: '#9b8b7a', DRESSER: '#8a7a6a', + DRESSING_TABLE: '#c4a882', BOOKCASE: '#7a6a5a', TV: '#2a2a3a', + PC_TOWER: '#2a2a33', AC_UNIT: '#e8e8e8', + RADIATOR: '#e0e0dc', + WALL_COLLAGE: '#3a2f1e', + CURTAIN: '#e8dfc8', + PLANT: '#4a7a3a', + MIRROR: '#8a7a5c', + DIGITAL_PIANO: '#1a1a22', + SPEAKER: '#20201e', OTHER: '#a0a0a0', }; @@ -30,6 +53,13 @@ const LEG_RADIUS = 0.02; const LEG_SEGMENTS = 6; // ── Shared materials (module-level singletons) ── +// +// These are the *template* materials picked up by the inner mesh +// components (BedMesh, DeskMesh, …). Every FurnitureMesh CLONES them at +// mount time so each item can have its own opacity without affecting +// others. Without the clone step the shared `legMaterial` would be +// referenced by every bed frame in the scene — setting its `opacity` to +// 0.3 on one bed would ghost every bed. const legMaterial = new THREE.MeshStandardMaterial({ color: LEG_COLOR, roughness: 0.6 }); const legMaterialSmooth = new THREE.MeshStandardMaterial({ color: LEG_COLOR, roughness: 0.5 }); @@ -44,6 +74,18 @@ function getFurnitureMaterial(color: string, roughness: number): THREE.MeshStand return mat; } +/** + * Legacy no-op kept for backward compatibility with any external callers. + * The previous implementation mutated the shared material singletons above + * to express a scene-wide furniture opacity, which broke per-item opacity + * because items share materials. Per-item cloning in FurnitureMesh now + * handles both the per-item and global factors via a single effective + * opacity product, so this function no longer needs to do anything. + */ +export function setFurnitureGlobalOpacity(_opacity: number): void { + // intentionally empty — per-item material clones own their opacity now +} + // ── Shared geometries for common shapes ── const legGeometry = new THREE.CylinderGeometry(LEG_RADIUS, LEG_RADIUS, 1, LEG_SEGMENTS); @@ -230,18 +272,46 @@ function SimpleBoxMesh({ item, color }: { readonly item: FurnitureItem; readonly ); } -/** Bookcase: open shelves */ +/** + * Bookcase: open shelves with a back panel and two sides. + * + * The number of shelf rows is read from `metadata.shelfRows` when + * present. `shelfRows` counts the STORAGE COMPARTMENTS — so 3 rows + * means 3 usable spaces which the mesh draws as 4 horizontal shelf + * boards (top + bottom + two intermediate dividers). If the metadata + * value is missing or invalid, the shelf count is derived from the + * item's height (~one compartment every 35cm) so legacy bookcases + * render unchanged. + * + * Variants via `metadata.hasBackPanel`: when explicitly `false`, the + * back panel is omitted — used by the "Open Bookshelf" preset which + * reads as a room divider / floating shelf unit rather than a cabinet + * with a back. + */ function BookcaseMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { - const shelfCount = Math.max(2, Math.round(item.height / 0.35)); + const metadataRows = item.metadata?.['shelfRows']; + const rawRows = + typeof metadataRows === 'number' && Number.isFinite(metadataRows) && metadataRows >= 1 + ? Math.round(metadataRows) + : Math.max(2, Math.round(item.height / 0.35)); + // Clamp — 12 is plenty even for tall library units. + const shelfRows = Math.max(1, Math.min(12, rawRows)); + + const hasBackPanelRaw = item.metadata?.['hasBackPanel']; + const hasBackPanel = typeof hasBackPanelRaw === 'boolean' ? hasBackPanelRaw : true; + const panelThickness = 0.02; const material = useMemo(() => getFurnitureMaterial(color, 0.6), [color]); return ( - {/* Back panel */} - - - + {/* Back panel (optional). When omitted the bookshelf reads as an + open unit that can be seen through from both sides. */} + {hasBackPanel && ( + + + + )} {/* Side panels */} {[-1, 1].map((side) => ( ))} - {/* Shelves */} - {Array.from({ length: shelfCount + 1 }).map((_, i) => { - const y = (i / shelfCount) * item.height; + {/* Horizontal shelf boards. `shelfRows` compartments produce + `shelfRows + 1` boards (top + bottom + internal dividers), + spaced evenly along the full height. */} + {Array.from({ length: shelfRows + 1 }).map((_, i) => { + const y = (i / shelfRows) * item.height; return ( @@ -301,17 +373,990 @@ function TvMesh({ item, color }: { readonly item: FurnitureItem; readonly color: ); } +/** + * Wall collage: a flat mounted panel divided into a grid of "photos" by + * the dark frame. The grid count is derived from the aspect ratio so a + * 1.5×0.6 panel reads as a 5×2 collage automatically. Each cell shows a + * lightly tinted plane that stands in for a photograph; the user gets a + * recognisable "photo wall" silhouette without us needing to ship images. + */ +function WallCollageMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { + // Pick a grid that roughly matches the panel aspect ratio. Cells are + // square-ish and bounded so they look like real photo frames. + const aspect = item.width / item.height; + const cols = Math.max(2, Math.min(6, Math.round(aspect * 2))); + const rows = Math.max(1, Math.min(4, Math.round(cols / aspect))); + + const frameMaterial = useMemo(() => getFurnitureMaterial(color, 0.5), [color]); + // A small palette of warm photo-ish tints — picking via index keeps the + // result deterministic per cell so it doesn't shimmer between renders. + const photoMaterials = useMemo( + () => [ + getFurnitureMaterial('#b6c8d4', 0.4), + getFurnitureMaterial('#d8c4a0', 0.4), + getFurnitureMaterial('#a0b88a', 0.4), + getFurnitureMaterial('#c8a0a0', 0.4), + getFurnitureMaterial('#9aa0b8', 0.4), + getFurnitureMaterial('#d0b890', 0.4), + ], + [], + ); + + const panelDepth = Math.max(0.005, item.depth); + const cellMargin = 0.01; // gap between photos within the frame + const cellW = (item.width - cellMargin * (cols + 1)) / cols; + const cellH = (item.height - cellMargin * (rows + 1)) / rows; + + return ( + + {/* Frame backing panel */} + + + + {/* Grid of photo cells, each slightly proud of the frame so the + dark frame border is visible between them. */} + {Array.from({ length: rows }).map((_, r) => + Array.from({ length: cols }).map((_, c) => { + const idx = r * cols + c; + const localX = -item.width / 2 + cellMargin + cellW / 2 + c * (cellW + cellMargin); + const localY = item.height - cellMargin - cellH / 2 - r * (cellH + cellMargin); + return ( + + + + ); + }), + )} + + ); +} + +/** + * Radiator: a back panel covered by a row of vertical fins. Looks like a + * standard panel-type room radiator from any angle. The fin count is + * derived from the width so wider radiators read as having more sections. + */ +function RadiatorMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { + const bodyMaterial = useMemo(() => getFurnitureMaterial(color, 0.45), [color]); + // Radiators are typically ~10cm fin pitch. + const finPitch = 0.1; + const finCount = Math.max(3, Math.round(item.width / finPitch)); + const finWidth = (item.width / finCount) * 0.65; + const finDepth = item.depth * 0.85; + // Back-plate sits flush against the wall (negative Z is "into the wall" + // for wall-mounted items, but radiators are free-standing — keep the + // panel centered along Z). + const panelDepth = item.depth * 0.25; + + return ( + + {/* Back panel */} + + + + {/* Vertical fins */} + {Array.from({ length: finCount }).map((_, i) => { + const offsetX = -item.width / 2 + (i + 0.5) * (item.width / finCount); + return ( + + + + ); + })} + {/* Top cap (the slotted grille typical of panel radiators) */} + + + + + ); +} + +/** + * Curtain: a slim rod across the top with pleated fabric panels hanging + * from it. Left and right panels are independently drawn aside via the + * `leftOpen` and `rightOpen` metadata values (0 = fully closed, 1 = fully + * retracted to the outer edge). A legacy symmetric `openAmount` field is + * honoured as a fallback so older curtain rows keep their appearance. + * + * Rendering is identical to the symmetric version in structure — two + * panels of pleated strips — except each panel consults its own open + * value so the user can tie back just one side. + */ +function CurtainMesh({ item, color: _defaultColor }: { readonly item: FurnitureItem; readonly color: string }) { + // Read state from metadata with safe defaults for legacy rows. + const fabricColor = getCurtainFabricColor(item.metadata); + const leftOpen = getCurtainLeftOpen(item.metadata); + const rightOpen = getCurtainRightOpen(item.metadata); + + const fabricMaterial = useMemo(() => getFurnitureMaterial(fabricColor, 0.95), [fabricColor]); + const rodMaterial = useMemo(() => getFurnitureMaterial('#8a7a5c', 0.4), []); + + // Rod sits just above the top of the curtain, 2cm thick. + const rodRadius = 0.012; + const rodY = item.height + 0.02; + const fabricHeight = item.height; + const panelDepth = 0.02; + const pleatDepth = 0.025; + + // Total pleats across the full (closed) width; split into two equal halves. + const totalPleats = Math.max(8, Math.round(item.width / 0.08)); + const pleatsPerPanel = Math.max(1, Math.floor(totalPleats / 2)); + + // Each panel's visible width is derived from its own open value. A small + // residual (~5% of half-width) keeps the bunched stack visible when a + // panel is fully retracted instead of collapsing into zero-width geometry. + const minPanelWidth = item.width * 0.05; + const halfWidth = item.width / 2; + const leftPanelWidth = Math.max(minPanelWidth, halfWidth * (1 - leftOpen)); + const rightPanelWidth = Math.max(minPanelWidth, halfWidth * (1 - rightOpen)); + const leftPleatWidth = leftPanelWidth / pleatsPerPanel; + const rightPleatWidth = rightPanelWidth / pleatsPerPanel; + + const leftOuterX = -halfWidth; + const rightOuterX = halfWidth; + + return ( + + {/* Horizontal rod runs the full width regardless of open state. */} + + + + {/* Rod finials (end caps) */} + {[-1, 1].map((side) => ( + + + + ))} + {/* Left panel pleats grow inward from the left outer edge. */} + {Array.from({ length: pleatsPerPanel }).map((_, i) => { + const localX = leftOuterX + (i + 0.5) * leftPleatWidth; + const isFront = i % 2 === 0; + const zOffset = isFront ? pleatDepth / 2 : -pleatDepth / 2; + return ( + + + + ); + })} + {/* Right panel pleats grow inward from the right outer edge. */} + {Array.from({ length: pleatsPerPanel }).map((_, i) => { + const localX = rightOuterX - (i + 0.5) * rightPleatWidth; + const isFront = i % 2 === 0; + const zOffset = isFront ? pleatDepth / 2 : -pleatDepth / 2; + return ( + + + + ); + })} + + ); +} + +/** + * Crib (baby bed): solid floor panel with a mattress, surrounded by a + * ring of vertical slats that give cribs their characteristic look. + * Slat density scales with width so wider cribs get more rails without + * looking sparse. + */ +function CribMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { + const frameMaterial = useMemo(() => getFurnitureMaterial(color, 0.55), [color]); + const mattressMaterial = useMemo(() => getFurnitureMaterial('#f4eadf', 0.9), []); + + const mattressThick = 0.08; + const mattressY = 0.25; // top of the mattress sits just below cot top rail + const mattressLift = mattressY; + // Slats + const slatRadius = 0.01; + const slatHeight = item.height - mattressLift - 0.04; + const slatsPerSide = Math.max(6, Math.round((item.width - 0.1) / 0.08)); + const slatsPerEnd = Math.max(4, Math.round((item.depth - 0.1) / 0.08)); + // Top rail, bottom rail, corner posts + const railThick = 0.02; + + const cornerPosts = ([-1, 1] as const).flatMap((sx) => + ([-1, 1] as const).map((sz) => ({ sx, sz })), + ); + + return ( + + {/* Bottom rail ring (sits just above the mattress top) */} + + + + + + + + + + + + + {/* Top rail ring */} + + + + + + + + + + + + + {/* Corner posts */} + {cornerPosts.map(({ sx, sz }) => ( + + + + ))} + {/* Long-side slats (front + back) */} + {([-1, 1] as const).map((sz) => + Array.from({ length: slatsPerSide }).map((_, i) => { + const localX = -item.width / 2 + railThick + (i + 0.5) * ((item.width - railThick * 2) / slatsPerSide); + return ( + + + + ); + }), + )} + {/* Short-end slats (left + right) */} + {([-1, 1] as const).map((sx) => + Array.from({ length: slatsPerEnd }).map((_, i) => { + const localZ = -item.depth / 2 + railThick + (i + 0.5) * ((item.depth - railThick * 2) / slatsPerEnd); + return ( + + + + ); + }), + )} + {/* Mattress */} + + + + + ); +} + +/** + * Dressing table (vanity): a desk-like base with drawers and an upright + * mirror mounted at the back. Depth is typically shallow (~40cm) and the + * mirror takes up roughly half the total height. Height of the desk slab + * is derived as a fraction of the total so the mirror always reads as an + * upright panel. + */ +function DressingTableMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { + const bodyMaterial = useMemo(() => getFurnitureMaterial(color, 0.5), [color]); + const mirrorMaterial = useMemo(() => getFurnitureMaterial('#b8d0d8', 0.05), []); + const mirrorFrameMaterial = useMemo(() => getFurnitureMaterial(color, 0.4), [color]); + + const deskHeight = Math.min(0.8, item.height * 0.45); + const mirrorHeight = item.height - deskHeight; + const mirrorWidth = item.width * 0.7; + const mirrorThickness = 0.015; + const frameThickness = 0.025; + + return ( + + {/* Desk body — a closed box representing drawers */} + + + + {/* Drawer divider lines on the front face */} + + + + {/* Mirror frame (slightly larger than the mirror itself) */} + + + + {/* Mirror surface */} + + + + + ); +} + +/** + * PC tower (desktop computer case): a tall rectangular box with a subtle + * front-panel accent and a small power LED. Depth > width > short so it + * reads as a standing tower regardless of exact dimensions. + */ +function PcTowerMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { + const bodyMaterial = useMemo(() => getFurnitureMaterial(color, 0.45), [color]); + const accentMaterial = useMemo(() => getFurnitureMaterial('#141418', 0.3), []); + const ledMaterial = useMemo(() => { + const mat = new THREE.MeshStandardMaterial({ + color: '#4cc9ff', + emissive: '#4cc9ff', + emissiveIntensity: 0.8, + roughness: 0.2, + }); + return mat; + }, []); + + const accentDepth = 0.002; + + return ( + + {/* Main body */} + + + + {/* Front-panel darker inset (slightly recessed visually) */} + + + + {/* Power LED */} + + + + + ); +} + +/** + * Plant / flower: terracotta pot with foliage on top. Rendering switches + * between a few styles based on `metadata.variant`: + * - `flower` → short pot with a coloured blossom sphere (uses + * `metadata.flowerColor` or a warm default) + * - `tall` → tall pot with an elongated green foliage cylinder capped + * by a sphere (ficus / indoor tree look) + * - `bush` (default) → pot with a spherical green foliage ball + * No new data model is required — the variant is set at placement time + * via the FurnitureDef's `defaultMetadata`. + */ +function PlantMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { + const variant = ((item.metadata?.['variant'] as string | undefined) ?? 'bush').toLowerCase(); + const flowerColorRaw = item.metadata?.['flowerColor']; + const flowerColor = + typeof flowerColorRaw === 'string' && /^#[0-9a-fA-F]{6}$/.test(flowerColorRaw) + ? flowerColorRaw + : '#e05570'; + + const potHeight = Math.min(item.height * 0.35, 0.25); + const potRadiusTop = Math.min(item.width, item.depth) / 2; + const potRadiusBottom = potRadiusTop * 0.75; + const foliageBottom = potHeight; + const foliageHeight = item.height - potHeight; + + const potMaterial = useMemo(() => getFurnitureMaterial('#a85a3a', 0.8), []); + const soilMaterial = useMemo(() => getFurnitureMaterial('#3a2a1a', 0.95), []); + const leafMaterial = useMemo(() => getFurnitureMaterial(color, 0.85), [color]); + const flowerMaterial = useMemo(() => getFurnitureMaterial(flowerColor, 0.7), [flowerColor]); + + return ( + + {/* Terracotta pot */} + + + + {/* Soil disk just below the pot rim */} + + + + {/* Foliage */} + {variant === 'tall' ? ( + <> + {/* Elongated trunk + top sphere */} + + + + + + + + ) : variant === 'flower' ? ( + <> + {/* Short stem */} + + + + {/* Blossom */} + + + + + ) : ( + // Default: leafy bush — a single foliage sphere just above the pot + + + + )} + + ); +} + +/** + * Mirror: a framed reflective panel. Two variants selected via + * `metadata.variant`: + * + * - `wall` (default): thin wall-mounted panel. Only the front-facing + * mirror and the frame are drawn — no stand. The item's `depth` + * should be small (~3cm) and it's placed flush against a wall, so + * elevation controls where on the wall it hangs. + * + * - `floor`: free-standing full-length mirror with an A-frame stand + * behind it. A tilt is NOT applied — the mirror sits upright against + * the stand, and the stand legs splay backward into the `depth` + * dimension so the mesh remains within the item's bounding box. + * Elevation should be 0 (on the floor). + * + * The frame color is a wood tone by default; the mirror surface itself + * uses a cool pale blue/grey material with low roughness so it reads as + * glass without requiring real reflections. + */ +function MirrorMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { + const variantRaw = item.metadata?.['variant']; + const variant = typeof variantRaw === 'string' ? variantRaw.toLowerCase() : 'wall'; + const isFloor = variant === 'floor'; + + const frameMaterial = useMemo(() => getFurnitureMaterial(color, 0.5), [color]); + const mirrorMaterial = useMemo(() => getFurnitureMaterial('#c8dce3', 0.05), []); + + // Mirror sits at the back of the bounding box so the frame protrudes + // forward and the stand (for floor variant) can live in front of it. + const frameThickness = 0.03; + const panelThickness = 0.015; + const frameInset = 0.025; // frame width around the mirror edge + + // The glass occupies most of the height/width, with the frame wrapping it. + const glassWidth = item.width - frameInset * 2; + const glassHeight = item.height - frameInset * 2; + + // Wall variant: mirror sits at the back face of the bounding box so it + // reads as flush against a wall. Floor variant: the mirror is pushed + // slightly forward to leave room for the stand legs behind it. + const mirrorZ = isFloor + ? -item.depth / 2 + frameThickness + 0.005 + : -item.depth / 2 + frameThickness + panelThickness / 2; + + return ( + + {/* Frame — a rectangular ring around the mirror. Built from four + rail meshes so the centre stays open for the glass. */} + {/* Top rail */} + + + + {/* Bottom rail */} + + + + {/* Left rail */} + + + + {/* Right rail */} + + + + {/* Glass */} + + + + + {/* Floor stand — A-frame behind the mirror. Two splayed legs + + a crossbar at the top that the mirror rests against. Only + rendered for the floor variant. */} + {isFloor && ( + <> + {(() => { + const legThickness = 0.025; + // Legs go from the back-bottom (at mirrorZ - 0.01) splayed out + // to near the front of the bounding box at ground level. + const legTopY = item.height * 0.75; + const legTopZ = mirrorZ - 0.005; + const legBottomZ = item.depth / 2 - legThickness; + const legLength = Math.hypot(legTopY, legBottomZ - legTopZ); + const angle = Math.atan2(legBottomZ - legTopZ, -legTopY); // tilt around X + return ( + <> + {/* Back crossbar connecting the tops of the two legs — a + short horizontal rail the mirror rests against. */} + + + + {/* Two splayed legs */} + {[-1, 1].map((side) => ( + + + + ))} + + ); + })()} + + )} + + ); +} + +/** + * Digital piano: a slim keyboard chassis with an optional X-frame stand. + * + * The stand is controlled via `metadata.hasStand`: when true (default), + * the chassis sits elevated on a pair of X-shaped legs and the total + * `item.height` includes the stand. When false, the chassis rests on + * whatever surface the piano is placed on (i.e., starts at `y=0` in the + * item's local frame) and the full height is the chassis height. + * + * The keyboard's top surface shows a row of alternating white/black + * keys so the instrument reads as a piano from any angle. + */ +function DigitalPianoMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { + const hasStandRaw = item.metadata?.['hasStand']; + const hasStand = typeof hasStandRaw === 'boolean' ? hasStandRaw : true; + + const chassisMaterial = useMemo(() => getFurnitureMaterial(color, 0.4), [color]); + const standMaterial = useMemo(() => getFurnitureMaterial('#15151a', 0.5), []); + const whiteKeyMaterial = useMemo(() => getFurnitureMaterial('#f4f0e8', 0.35), []); + const blackKeyMaterial = useMemo(() => getFurnitureMaterial('#0a0a0e', 0.2), []); + + // Split the total height between chassis and stand. Chassis is ~15cm + // regardless; the rest is stand (or zero if no stand). + const chassisHeight = 0.15; + const standHeight = hasStand ? Math.max(0, item.height - chassisHeight) : 0; + const chassisBottom = standHeight; + const chassisCenterY = chassisBottom + chassisHeight / 2; + + // Keys sit on top of the chassis, inset slightly from the edges. + const keyAreaWidth = item.width * 0.95; + const keyAreaDepth = item.depth * 0.6; + const keyAreaX0 = -keyAreaWidth / 2; + const keyAreaZ = item.depth * 0.05; // shifted toward the front (player-facing) + const whiteKeyTopY = chassisBottom + chassisHeight + 0.005; + const whiteKeyHeight = 0.015; + // Number of white keys scales with width; a typical 88-key digital + // piano is ~1.27m wide with 52 white keys, so we target roughly one + // white key every 2.4cm. + const whiteKeyCount = Math.max(12, Math.round(item.width / 0.024)); + const whiteKeyWidth = keyAreaWidth / whiteKeyCount; + + // Black keys appear on 5 out of every 7 white key positions (after + // white indices 0, 1, 3, 4, 5). We draw one shorter + slightly + // raised on top of the white key row. + const blackKeyPositions: number[] = []; + for (let i = 0; i < whiteKeyCount - 1; i++) { + const mod = i % 7; + if (mod !== 2 && mod !== 6) { + blackKeyPositions.push(i); + } + } + + return ( + + {/* Stand (X-frame legs + horizontal crossbar). Only when hasStand. */} + {hasStand && standHeight > 0.05 && ( + <> + {/* Two splayed legs at the left and right of the chassis, + forming an X when viewed from the front. Simplified to two + diagonal bars on each side rather than a true X, which is + hard to render with a single box. */} + {[-1, 1].map((side) => ( + + + + ))} + {/* Horizontal crossbar near the bottom for stability */} + + + + + )} + {/* Chassis (the piano body) */} + + + + {/* White key row */} + {Array.from({ length: whiteKeyCount }).map((_, i) => { + const localX = keyAreaX0 + (i + 0.5) * whiteKeyWidth; + return ( + + + + ); + })} + {/* Black keys sit on the gaps between adjacent white keys */} + {blackKeyPositions.map((i) => { + const localX = keyAreaX0 + (i + 1) * whiteKeyWidth; + return ( + + + + ); + })} + + ); +} + +/** + * Audio speaker with two variants selected via `metadata.variant`: + * + * - `shelf` (default): compact bookshelf speaker — a squat rectangular + * cabinet with one tweeter dome and one woofer cone on the front face. + * Typically placed on a shelf, desk, or stand. Height ≤ 0.5 m. + * + * - `floor`: full-range tower / floor-standing speaker — a tall, slim + * cabinet with a tweeter, a midrange, and a woofer stacked on the + * front. Sits directly on the floor. Height usually > 0.8 m. + * + * The cabinet is a single box, the drivers are flat discs pressed + * against the front face. The driver layout is derived from the height + * so both variants read correctly as speakers regardless of exact + * preset dimensions. + */ +function SpeakerMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { + const variantRaw = item.metadata?.['variant']; + const variant = typeof variantRaw === 'string' ? variantRaw.toLowerCase() : 'shelf'; + const isFloor = variant === 'floor'; + + const cabinetMaterial = useMemo(() => getFurnitureMaterial(color, 0.55), [color]); + // Subtle accent for driver cones (warm off-black so they read as + // distinct from the cabinet even on a dark cabinet). + const driverMaterial = useMemo(() => getFurnitureMaterial('#2a2520', 0.3), []); + const tweeterMaterial = useMemo(() => getFurnitureMaterial('#3a3430', 0.15), []); + + // Cabinet + const cabinetHeight = item.height; + const frontZ = item.depth / 2 + 0.001; + + // Driver sizing — scale with cabinet width so drivers always fit + // comfortably within the front face and look proportional. + const maxDriverRadius = item.width * 0.4; + const tweeterRadius = Math.min(0.025, maxDriverRadius * 0.3); + const midRadius = Math.min(0.05, maxDriverRadius * 0.55); + const wooferRadius = Math.min(0.09, maxDriverRadius * 0.85); + const driverThickness = 0.006; + + // Vertical placement: tweeter near the top, woofer near the bottom, + // midrange between (floor variant only). All positions are in the + // cabinet's local frame where y=0 is the floor. + const topY = cabinetHeight - tweeterRadius - 0.03; + const bottomY = wooferRadius + 0.04; + const midY = (topY + bottomY) / 2; + + return ( + + {/* Cabinet body */} + + + + {/* Tweeter (top) — small recessed disc */} + + + + {/* Midrange — only on floor towers, sits between tweeter and woofer */} + {isFloor && ( + + + + )} + {/* Woofer — larger driver near the bottom */} + + + + {/* Subtle phase plug in the centre of the woofer (tiny dome) */} + + + + + ); +} + +/** + * Office chair: wheeled 5-star base → gas-lift post → seat pan → tall + * backrest → two short armrests. Proportions are driven by `item.height` + * (total top-of-backrest) and `item.width` (seat pan width), so the + * same mesh looks correct across preset sizes from low task chair to + * tall executive chair. The seat height is derived as a fraction of + * total so the backrest is always recognisably taller than the seat. + */ +function OfficeChairMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) { + const uphMaterial = useMemo(() => getFurnitureMaterial(color, 0.75), [color]); + const frameMaterial = useMemo(() => getFurnitureMaterial('#1a1a1e', 0.4), []); + const casterMaterial = useMemo(() => getFurnitureMaterial('#141418', 0.3), []); + + const seatHeight = item.height * 0.42; + const seatPanThickness = 0.05; + const backrestBottom = seatHeight + seatPanThickness; + const backrestHeight = item.height - backrestBottom; + const seatWidth = item.width; + const seatDepth = item.depth * 0.85; + + // 5-star base. The arms splay out radially from a central hub; each + // arm carries a caster at its outer end. We render the arms as thin + // boxes oriented toward the outer caster position. + const baseRadius = Math.min(item.width, item.depth) * 0.55; + const hubRadius = 0.035; + const armThickness = 0.02; + const casterRadius = 0.022; + const armCount = 5; + + // Gas-lift cylinder from hub up to just under the seat + const postRadius = 0.025; + const postHeight = seatHeight - 0.05; // leave room for the hub + + return ( + + {/* 5-star base — central hub + 5 splayed arms + casters */} + + + + {Array.from({ length: armCount }).map((_, i) => { + const angle = (i / armCount) * Math.PI * 2; + const midR = baseRadius * 0.5; + const cx = Math.cos(angle) * midR; + const cz = Math.sin(angle) * midR; + return ( + + {/* Arm — rotated around Y so its long axis points outward */} + + + + {/* Caster (sphere) at the outer end of the arm */} + + + + + ); + })} + {/* Gas-lift post */} + + + + {/* Seat pan */} + + + + {/* Backrest — taller than it is wide, slightly set back from the seat */} + + + + {/* Armrests — two short horizontal pads at ~elbow height */} + {([-1, 1] as const).map((side) => { + const armY = seatHeight + seatPanThickness + 0.18; + const armX = side * (seatWidth / 2 + 0.015); + return ( + + {/* Vertical support from seat level up to the pad */} + + + + {/* Horizontal pad */} + + + + + ); + })} + + ); +} + function getFurnitureComponent(type: FurnitureType) { switch (type) { case 'BED': return BedMesh; + case 'CRIB': return CribMesh; case 'DESK': return DeskMesh; case 'WARDROBE': return WardrobeMesh; case 'SOFA': return SofaMesh; case 'TABLE': return TableMesh; case 'CHAIR': return ChairMesh; + case 'OFFICE_CHAIR': return OfficeChairMesh; case 'BOOKCASE': return BookcaseMesh; case 'TV': return TvMesh; + case 'PC_TOWER': return PcTowerMesh; case 'AC_UNIT': return SimpleBoxMesh; + case 'RADIATOR': return RadiatorMesh; + case 'WALL_COLLAGE': return WallCollageMesh; + case 'CURTAIN': return CurtainMesh; + case 'DRESSING_TABLE': return DressingTableMesh; + case 'PLANT': return PlantMesh; + case 'MIRROR': return MirrorMesh; + case 'DIGITAL_PIANO': return DigitalPianoMesh; + case 'SPEAKER': return SpeakerMesh; case 'SHELF': case 'NIGHTSTAND': case 'DRESSER': @@ -321,16 +1366,116 @@ function getFurnitureComponent(type: FurnitureType) { } } -export function FurnitureMesh({ item, isSelected, onSelect }: FurnitureMeshProps) { +export function FurnitureMesh({ item, isSelected, onSelect, globalOpacity = 1 }: 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; + // (item.x, item.y) is the anchored point on the rotated visual. Use the + // rotation-aware helper so "left" tracks the visual left edge of the + // rotated box. Reduces to (0, 0) for the default middle/middle anchor. + const offset = rotatedAnchorOffsetToCenter( + item.positionAnchor, + item.width, + item.depth, + item.rotation, + ); + const centerX = item.x + offset.dx; + const centerY = item.y + offset.dy; + + // The inner mesh components draw from shared, module-level materials so + // that identical furniture (e.g. three radiators of the same color) can + // reuse GPU uploads. That means mutating `material.opacity` on one item + // would ghost every other item that references the same singleton. + // + // To give each item its own opacity we clone the shared materials at + // mount time (and whenever the inner component re-renders with new + // materials — e.g. when width/height/type/color change), keep the clones + // in a ref so we can dispose them on unmount, and mutate the clones' + // opacity whenever the effective opacity changes. The clones are owned + // by THIS FurnitureMesh instance exclusively. + // + // Effective opacity = per-item opacity × global furniture opacity. + // Using the product means per-item and global both compose correctly + // and the global slider no longer needs to mutate shared singletons. + const groupRef = useRef(null); + const clonedMaterialsRef = useRef([]); + const effectiveOpacity = (item.opacity ?? 1) * globalOpacity; + const shouldCastShadow = effectiveOpacity > SHADOW_OPACITY_THRESHOLD; + + // Clone materials after the inner component has mounted / re-rendered. + // Re-running on dimension / type / color / rotation / variant changes is + // important because the inner component may emit fresh mesh instances + // that reference the shared pool again. Each run disposes the previous + // set of clones before making new ones. + useEffect(() => { + const group = groupRef.current; + if (!group) return; + + // Dispose any clones from a previous render before creating new ones. + for (const m of clonedMaterialsRef.current) { + m.dispose(); + } + const cloned: THREE.Material[] = []; + + group.traverse((obj) => { + if (!(obj as THREE.Mesh).isMesh) return; + const mesh = obj as THREE.Mesh; + if (!mesh.material) return; + if (Array.isArray(mesh.material)) { + const replacements = mesh.material.map((m) => { + const c = m.clone(); + cloned.push(c); + return c; + }); + mesh.material = replacements; + } else { + const c = mesh.material.clone(); + cloned.push(c); + mesh.material = c; + } + mesh.castShadow = shouldCastShadow; + }); + + clonedMaterialsRef.current = cloned; + + return () => { + for (const m of cloned) m.dispose(); + }; + // `shouldCastShadow` is intentionally included so the traversal also + // updates castShadow on newly-cloned meshes after an opacity change + // that crosses the threshold. + }, [ + shouldCastShadow, + item.type, + item.width, + item.depth, + item.height, + color, + // Re-clone when curtain/plant/mirror variant changes, because the + // inner component may swap between branches that emit different + // meshes (different material pools). + item.metadata, + ]); + + // Apply the effective opacity to the clones whenever it changes. + // This runs both after the cloning effect above (via the `cloned` + // array in the ref) and on every subsequent opacity change, without + // re-cloning materials. + useEffect(() => { + for (const mat of clonedMaterialsRef.current) { + // MeshStandardMaterial is the only material type the inner + // components create. Cast lets us mutate opacity fields uniformly. + const m = mat as THREE.MeshStandardMaterial; + m.transparent = effectiveOpacity < 1; + m.opacity = effectiveOpacity; + m.depthWrite = effectiveOpacity >= 1; + m.needsUpdate = true; + } + }, [effectiveOpacity]); + return ( { e.stopPropagation(); onSelect(item.id); } : undefined} diff --git a/apps/client/src/components/editor/three/PlinthMesh.tsx b/apps/client/src/components/editor/three/PlinthMesh.tsx index 7a30be3..615e58d 100644 --- a/apps/client/src/components/editor/three/PlinthMesh.tsx +++ b/apps/client/src/components/editor/three/PlinthMesh.tsx @@ -1,9 +1,10 @@ import { useMemo } from 'react'; import * as THREE from 'three'; -import type { Wall, WallOpening } from '@house-plan-maker/shared'; +import type { Point, Wall, WallOpening } from '@house-plan-maker/shared'; import { getOpeningSlices, wallVector, + wallNormal, wallRotationY, positionAlongWall3D, } from './utils/wallGeometry'; @@ -13,6 +14,8 @@ interface PlinthMeshProps { readonly openings: readonly WallOpening[]; readonly plinthHeight: number; readonly plinthThickness: number; + /** See WallMesh — same outward shift so the plinth stays aligned with the wall. */ + readonly roomCentroid?: Point; } const PLINTH_COLOR = '#d4c5b2'; @@ -63,7 +66,7 @@ function computePlinthSegments( return segments; } -export function PlinthMesh({ wall, openings, plinthHeight, plinthThickness }: PlinthMeshProps) { +export function PlinthMesh({ wall, openings, plinthHeight, plinthThickness, roomCentroid }: PlinthMeshProps) { const segments = useMemo( () => computePlinthSegments(wall, openings), [wall, openings], @@ -71,6 +74,20 @@ export function PlinthMesh({ wall, openings, plinthHeight, plinthThickness }: Pl const rotY = useMemo(() => wallRotationY(wall), [wall]); + // Same outward shift as the wall — see WallMesh.outwardOffset. + const outwardOffset = useMemo<[number, number]>(() => { + if (!roomCentroid) return [0, 0]; + const { nx, ny } = wallNormal(wall); + if (nx === 0 && ny === 0) return [0, 0]; + const midX = (wall.startX + wall.endX) / 2; + const midY = (wall.startY + wall.endY) / 2; + const outX = midX - roomCentroid.x; + const outY = midY - roomCentroid.y; + const sign = nx * outX + ny * outY >= 0 ? 1 : -1; + const half = wall.thickness / 2; + return [sign * nx * half, sign * ny * half]; + }, [wall, roomCentroid]); + if (plinthHeight <= 0 || plinthThickness <= 0) return null; return ( @@ -85,7 +102,7 @@ export function PlinthMesh({ wall, openings, plinthHeight, plinthThickness }: Pl return ( (null); const [hiddenWallIds, setHiddenWallIds] = useState>(new Set()); + const [cameraOrientation, setCameraOrientation] = useState({ + yawDeg: -32, + pitchDeg: 26, + }); + + // Sun (key directional light) position. Stored as spherical coordinates so + // sliders are intuitive: azimuth rotates the sun around the room, elevation + // raises/lowers it above the horizon. The cartesian position is recomputed + // from these on every change. Viewer-only state — not persisted to the room. + const [sunAzimuthDeg, setSunAzimuthDeg] = useState(45); + const [sunElevationDeg, setSunElevationDeg] = useState(55); + // Lowered default sun intensity (was 0.7) so the directional key light no + // longer dominates over the omnidirectional contribution, which is what + // caused walls facing away from the sun to look noticeably darker. The + // slider still lets the user crank it back up for stronger shadow drama. + const [sunIntensity, setSunIntensity] = useState(0.35); + const [showLightControls, setShowLightControls] = useState(false); + + // Whether door panels render ajar (true) or closed (false). Viewer-only + // setting; not persisted because it's purely a visualisation preference. + const [doorsOpen, setDoorsOpen] = useState(true); + + const sunPosition = useMemo<[number, number, number]>(() => { + const azimuth = (sunAzimuthDeg * Math.PI) / 180; + const elevation = (sunElevationDeg * Math.PI) / 180; + const radius = 18; // far enough to act like a distant sun + const horizontal = Math.cos(elevation) * radius; + return [ + Math.cos(azimuth) * horizontal, + Math.sin(elevation) * radius, + Math.sin(azimuth) * horizontal, + ]; + }, [sunAzimuthDeg, sunElevationDeg]); const handlePreset = useCallback((preset: CameraPreset) => { setActivePreset(preset); @@ -77,6 +128,12 @@ export function Room3DView() { setTimeout(() => setActivePreset(null), 100); }, []); + // Reset the camera to the default (Bird's Eye) view. Bound to the Canvas + // element's onDoubleClick so double-tapping empty space snaps back. + const handleResetView = useCallback(() => { + handlePreset('birds-eye'); + }, [handlePreset]); + const handleSelect = useCallback( (id: string) => { dispatch({ type: 'SET_SELECTED', ids: new Set([id]) }); @@ -99,6 +156,13 @@ export function Room3DView() { [openings], ); + // Centroid of the room polygon — used to push wall + plinth meshes outward + // so they don't bleed into the room interior. See WallMesh for the why. + const roomCentroid = useMemo( + () => (shape.length >= 3 ? polygonCentroid(shape) : { x: 0, y: 0 }), + [shape], + ); + // Compute bird's eye camera position from room bounds const initialCameraPos = useMemo((): [number, number, number] => { if (shape.length < 3) return [0, 10, 0.01]; @@ -123,12 +187,32 @@ export function Room3DView() { {/* Camera + Controls */} @@ -138,23 +222,45 @@ export function Room3DView() { wallHeight={wallHeight} activePreset={activePreset} /> + - {/* Lighting */} - + {/* Image-based lighting from a pre-baked HDR environment is now + the PRIMARY light source. IBL integrates light from every + direction so walls facing any orientation receive roughly + equal illumination — that's what we want for aligned wall + brightness. `environmentIntensity` was bumped from 0.35 to + 0.85 to dominate over the weaker directional lights. + `background={false}` keeps the canvas grey. */} + + + {/* Analytic lighting layered on top of the IBL. + - Ambient light: a flat minimum brightness floor so even + walls with no direct light never fall below a usable level. + - Hemisphere light: subtle sky/ground tint adds a natural + gradient from floor to ceiling without favouring any wall. + - Key directional light (sun): intensity lowered to 0.35 in + the default slider state so it adds shadow contrast + without visibly overpowering any single wall. + - Fill light removed — the omnidirectional IBL + ambient + + hemisphere combination already lights the back walls. */} + + - {/* Track nearest wall to camera and hide it */} @@ -171,8 +277,10 @@ export function Room3DView() { openings={openings} wallHeight={wallHeight} wallColor={room.wallColor} + wallFinish={room.wallFinish} selectedIds={selectedIds} onSelect={handleSelect} + roomCentroid={roomCentroid} /> ) ))} @@ -186,6 +294,7 @@ export function Room3DView() { openings={openings} plinthHeight={plinthHeight} plinthThickness={plinthThickness} + roomCentroid={roomCentroid} /> ) ))} @@ -201,6 +310,7 @@ export function Room3DView() { wall={wall} isSelected={selectedIds.has(door.id)} onSelect={handleSelect} + isOpen={doorsOpen} /> ); })} @@ -227,6 +337,7 @@ export function Room3DView() { item={item} isSelected={selectedIds.has(item.id)} onSelect={handleSelect} + globalOpacity={globalFurnitureOpacity} /> ))} @@ -239,6 +350,9 @@ export function Room3DView() { wallHeight={wallHeight} isSelected={selectedIds.has(item.id)} onSelect={handleSelect} + outletWidth={room.outletWidth} + outletHeight={room.outletHeight} + roomCentroid={roomCentroid} /> ))} @@ -254,6 +368,144 @@ export function Room3DView() { {/* ContactShadows removed — floor is handled by FloorCeiling */} + + {/* Sun/light controls — collapsible HUD anchored bottom-right of the + 3D viewport. Sliders drive azimuth (rotation around the room), + elevation (height above the horizon), and the directional light + intensity. State is local to this component, not persisted. */} +
+ + {showLightControls && ( +
+ + + v.toFixed(2)} + onChange={setSunIntensity} + /> + +
+ {/* Door display toggle — purely a viewer preference. */} + +
+ )} +
); } + +interface SliderRowProps { + readonly label: string; + readonly value: number; + readonly min: number; + readonly max: number; + readonly step: number; + readonly suffix: string; + readonly onChange: (value: number) => void; + readonly format?: (value: number) => string; +} + +function SliderRow({ label, value, min, max, step, suffix, onChange, format }: SliderRowProps) { + const display = format ? format(value) : Math.round(value).toString(); + return ( + + ); +} diff --git a/apps/client/src/components/editor/three/WallMesh.tsx b/apps/client/src/components/editor/three/WallMesh.tsx index 74b11cd..89cc274 100644 --- a/apps/client/src/components/editor/three/WallMesh.tsx +++ b/apps/client/src/components/editor/three/WallMesh.tsx @@ -1,33 +1,51 @@ import { useMemo } from 'react'; import * as THREE from 'three'; -import type { Wall, WallOpening } from '@house-plan-maker/shared'; +import type { Point, Wall, WallFinish, WallOpening } from '@house-plan-maker/shared'; import { splitWallAroundOpenings, wallRotationY, wallSegmentCenter3D, + wallNormal, type WallSegment, } from './utils/wallGeometry'; +import { getWallPbr } from './utils/pbrTextures'; interface WallMeshProps { readonly wall: Wall; readonly openings: readonly WallOpening[]; readonly wallHeight: number; readonly wallColor?: string; + readonly wallFinish?: WallFinish; readonly selectedIds: ReadonlySet; readonly onSelect?: (id: string) => void; + /** + * Centroid of the room polygon (in 2D editor coords). The wall's + * `startX/Y..endX/Y` line represents the **inner** edge of the wall (this + * matches how the 2D WallLayer renders walls — it draws an outer boundary + * offset outward by `thickness`). In 3D, however, a box geometry is + * centered on the line by default, so the wall would bleed `thickness/2` + * into the room and collide with furniture sitting against it. We push + * each wall segment outward (away from the centroid) by `thickness/2` so + * the inner face stays on the wall line. + */ + readonly roomCentroid?: Point; } const DEFAULT_WALL_COLOR = '#f0ebe3'; const WALL_SELECTED_COLOR = '#b8d4e3'; -// ── Wall material cache ── -const wallMaterialCache = new Map(); +// ── PAINT material cache (one per color) ── +// +// PAINT is the only finish that uses `wallColor`; the textured finishes +// (PLASTER/BRICK/CONCRETE/WOOD_PANEL/WALLPAPER) ignore color and apply a +// shared PBR material loaded lazily from getWallPbr(). +const paintMaterialCache = new Map(); -function getWallMaterial(color: string): THREE.MeshStandardMaterial { - let mat = wallMaterialCache.get(color); +function getPaintMaterial(color: string): THREE.MeshStandardMaterial { + let mat = paintMaterialCache.get(color); if (!mat) { - mat = new THREE.MeshStandardMaterial({ color, roughness: 0.7, side: THREE.DoubleSide }); - wallMaterialCache.set(color, mat); + mat = new THREE.MeshStandardMaterial({ color, roughness: 0.85, side: THREE.DoubleSide }); + paintMaterialCache.set(color, mat); } return mat; } @@ -38,31 +56,71 @@ const wallSelectedMaterial = new THREE.MeshStandardMaterial({ side: THREE.DoubleSide, }); +/** + * Build a BoxGeometry for a wall segment and rescale its UVs so a textured + * wall finish tiles every `tileMeters` of physical surface instead of + * stretching one tile across the whole segment. The default BoxGeometry has + * UVs in the 0..1 range per face — multiplying by `(width/tile, height/tile)` + * gives the desired tile density and `wrapS/wrapT = RepeatWrapping` on the + * texture handles the modulo. The UV scale is baked into the geometry so a + * single shared material instance can serve walls of any size. + */ +function buildSegmentGeometry( + width: number, + height: number, + thickness: number, + tileMeters: number | null, +): THREE.BoxGeometry { + const geometry = new THREE.BoxGeometry(width, height, thickness); + if (tileMeters != null) { + const u = Math.max(1, width / tileMeters); + const v = Math.max(1, height / tileMeters); + const uv = geometry.attributes.uv; + if (uv) { + for (let i = 0; i < uv.count; i++) { + uv.setXY(i, uv.getX(i) * u, uv.getY(i) * v); + } + uv.needsUpdate = true; + } + } + return geometry; +} + function WallSegmentMesh({ wall, segment, thickness, - wallColor, + material, + tileMeters, isSelected, onSelect, + outwardOffset, }: { readonly wall: Wall; readonly segment: WallSegment; readonly thickness: number; - readonly wallColor: string; + readonly material: THREE.MeshStandardMaterial; + readonly tileMeters: number | null; readonly isSelected: boolean; readonly onSelect?: (id: string) => void; + /** [dx, 0, dz] offset to push the box from "centered on wall line" to "outer side of wall line". */ + readonly outwardOffset: readonly [number, number, number]; }) { const segmentWidth = segment.endAlongWall - segment.startAlongWall; const segmentHeight = segment.topY - segment.bottomY; - const center = useMemo( - () => wallSegmentCenter3D(wall, segment), - [wall, segment], - ); + const center = useMemo<[number, number, number]>(() => { + const [x, y, z] = wallSegmentCenter3D(wall, segment); + return [x + outwardOffset[0], y, z + outwardOffset[2]]; + }, [wall, segment, outwardOffset]); const rotY = useMemo(() => wallRotationY(wall), [wall]); + const geometry = useMemo( + () => buildSegmentGeometry(segmentWidth, segmentHeight, thickness, tileMeters), + [segmentWidth, segmentHeight, thickness, tileMeters], + ); + if (segmentWidth <= 0 || segmentHeight <= 0) return null; return ( @@ -71,20 +129,52 @@ function WallSegmentMesh({ rotation={[0, rotY, 0]} castShadow receiveShadow - material={isSelected ? wallSelectedMaterial : getWallMaterial(wallColor)} + material={isSelected ? wallSelectedMaterial : material} + geometry={geometry} onClick={onSelect ? (e) => { e.stopPropagation(); onSelect(wall.id); } : undefined} - > - - + /> ); } -export function WallMesh({ wall, openings, wallHeight, wallColor = DEFAULT_WALL_COLOR, selectedIds, onSelect }: WallMeshProps) { +export function WallMesh({ wall, openings, wallHeight, wallColor = DEFAULT_WALL_COLOR, wallFinish = 'PAINT', selectedIds, onSelect, roomCentroid }: WallMeshProps) { const segments = useMemo( () => splitWallAroundOpenings(wall, openings, wallHeight), [wall, openings, wallHeight], ); + // Resolve the finish to a material + tile size. PAINT uses the per-color + // cache (no UV rescale needed); textured finishes load a shared PBR set. + const { material, tileMeters } = useMemo(() => { + if (wallFinish === 'PAINT') { + return { material: getPaintMaterial(wallColor), tileMeters: null as number | null }; + } + const pbr = getWallPbr(wallFinish); + return { material: pbr.material, tileMeters: pbr.tileMeters }; + }, [wallFinish, wallColor]); + + // Compute the outward (away-from-room-centroid) offset along the wall's + // perpendicular normal. Without this the wall box straddles the wall line + // and the inner half-thickness collides with furniture placed against the + // wall. The 2D editor draws walls extending entirely outward from the + // shape — this matches that semantic. + const outwardOffset = useMemo<[number, number, number]>(() => { + if (!roomCentroid) return [0, 0, 0]; + const { nx, ny } = wallNormal(wall); + if (nx === 0 && ny === 0) return [0, 0, 0]; + // Wall midpoint, used to decide which side of the wall is "outside". + const midX = (wall.startX + wall.endX) / 2; + const midY = (wall.startY + wall.endY) / 2; + // Vector from centroid to wall midpoint = outward direction (pre-normal-projection). + const outX = midX - roomCentroid.x; + const outY = midY - roomCentroid.y; + // Sign of normal-along-outward tells us whether to flip. + const dot = nx * outX + ny * outY; + const sign = dot >= 0 ? 1 : -1; + const half = wall.thickness / 2; + // 2D y-axis maps to 3D z-axis. + return [sign * nx * half, 0, sign * ny * half]; + }, [wall, roomCentroid]); + const isSelected = selectedIds.has(wall.id); return ( @@ -95,9 +185,11 @@ export function WallMesh({ wall, openings, wallHeight, wallColor = DEFAULT_WALL_ wall={wall} segment={segment} thickness={wall.thickness} - wallColor={wallColor} + material={material} + tileMeters={tileMeters} isSelected={isSelected} onSelect={onSelect} + outwardOffset={outwardOffset} /> ))} diff --git a/apps/client/src/components/editor/three/WindowOpening.tsx b/apps/client/src/components/editor/three/WindowOpening.tsx index 4decf05..cc07359 100644 --- a/apps/client/src/components/editor/three/WindowOpening.tsx +++ b/apps/client/src/components/editor/three/WindowOpening.tsx @@ -15,7 +15,8 @@ interface WindowOpeningProps { const FRAME_COLOR = '#c0c0c0'; const GLASS_COLOR = '#a8d8ea'; -const FRAME_THICKNESS = 0.03; +/** Fallback frame thickness for legacy openings without an explicit value. */ +const DEFAULT_FRAME_THICKNESS = 0.03; export function WindowOpening({ opening, wall, isSelected, onSelect }: WindowOpeningProps) { const rotY = useMemo(() => wallRotationY(wall), [wall]); @@ -28,6 +29,33 @@ export function WindowOpening({ opening, wall, isSelected, onSelect }: WindowOpe const frameColor = isSelected ? '#6fa8dc' : FRAME_COLOR; const halfWidth = opening.width / 2; const halfThick = wall.thickness / 2 + 0.005; + // Per-opening frame thickness with a sane fallback for old data. The + // mullion thickness is derived from the frame thickness so the grid + // dividers stay visually proportional regardless of the user's choice. + const frameThickness = Math.max(0, opening.frameThickness ?? DEFAULT_FRAME_THICKNESS); + + // Window reveal (откос). The slope panels project OUTWARD from the + // window frame's outer face, away from the room interior, lining the wall + // opening as the wall extends toward the exterior. `0` keeps the window + // flush. The schema cap of 2 m is plenty so we don't clamp again here. + const slopeDepth = Math.max(0, opening.slopeDepth ?? 0); + const slopeColor = isSelected ? '#9bbfdc' : '#e8e0d4'; + // Visible jamb thickness — substantial enough to read as an architectural + // element rather than a hairline. + const slopeStripThickness = 0.025; + // Centre of the slope slab in window-local z. The wall's room-facing side + // sits at -halfThick in window-local coords (verified empirically), so the + // panels grow outward into the wall by spanning -halfThick to + // -halfThick - slopeDepth. + const slopeFaceZ = -halfThick - slopeDepth / 2; + + // Grid subdivision — clamp to sensible bounds. `cols` verticals produce + // `cols-1` internal mullions; same for rows. Default to a 2×2 grid + // (single vertical + single horizontal mullion) to match the classic + // look for windows without an explicit grid set. + const gridCols = Math.max(1, Math.min(10, Math.round(opening.gridCols ?? 2))); + const gridRows = Math.max(1, Math.min(10, Math.round(opening.gridRows ?? 2))); + const mullionThickness = frameThickness * 0.55; return ( { e.stopPropagation(); onSelect(opening.id); } : undefined} > + {/* Reveal (откос) panels — flat jamb/sill strips lining the wall + opening between the room face and the window frame. Rendered only + when the user has set a positive slopeDepth. Each panel is a thin + slab oriented parallel to the wall face, just inside the opening + edge so the wall material isn't visible against the frame. */} + {slopeDepth > 0 && ( + <> + {/* Reveal panels are visual only — `raycast={() => null}` opts them out + of pointer hit-testing so the window's clickable area stays + limited to the frame and glass instead of ballooning to the + entire reveal sleeve. */} + {/* Left jamb */} + null}> + + + + {/* Right jamb */} + null}> + + + + {/* Head (top) */} + null}> + + + + {/* Sill (bottom) */} + null}> + + + + + )} + {/* Window frame, glass and mullions are all wrapped in a sub-group + shifted by -slopeDepth so they sit at the outer end of the reveal + panels. With slopeDepth = 0 the offset is zero and behaviour matches + the legacy flush-mounted window. */} {/* Window frame — four sides */} {/* Left */} - - + + {/* Right */} - - + + {/* Top */} - - + + {/* Bottom (sill) */} - - + + {/* Glass pane */} - + - {/* Center cross divider — vertical */} - - - - + {/* Vertical mullions — `gridCols - 1` internal dividers spaced + evenly between the left and right frame posts. */} + {Array.from({ length: gridCols - 1 }).map((_, i) => { + const x = -halfWidth + ((i + 1) * opening.width) / gridCols; + return ( + + + + + ); + })} - {/* Center cross divider — horizontal */} - - - - + {/* Horizontal mullions — `gridRows - 1` internal dividers spaced + evenly between the top and bottom frame rails. */} + {Array.from({ length: gridRows - 1 }).map((_, i) => { + const y = -opening.height / 2 + ((i + 1) * opening.height) / gridRows; + return ( + + + + + ); + })} ); } diff --git a/apps/client/src/components/editor/three/camera-view-cube.module.css b/apps/client/src/components/editor/three/camera-view-cube.module.css new file mode 100644 index 0000000..e9bebcf --- /dev/null +++ b/apps/client/src/components/editor/three/camera-view-cube.module.css @@ -0,0 +1,179 @@ +/* + * View-cube widget + * + * The wrapper is intentionally a generous square so the cube can rotate + * without clipping at any angle. The 3D scene + cube live in their own + * stacking context (.scene) so the corner pins, which sit in a separate + * .pins layer, never get sucked into the perspective transform. + */ + +:root { + /* Half-side of the cube in pixels — must equal half of .cube width/height. */ +} + +.wrapper { + position: absolute; + top: 12px; + right: 12px; + width: 130px; + height: 130px; + z-index: 10; + user-select: none; + /* Allow corner pins to extend slightly past the wrapper without clipping. */ + overflow: visible; +} + +.scene { + position: absolute; + inset: 0; + perspective: 600px; + perspective-origin: 50% 50%; + /* The 3D scene must not clip its children when rotation pushes them out + of the box for a frame. */ + overflow: visible; +} + +.cube { + position: absolute; + /* Centred 80×80 cube — half-side = 40 px. */ + width: 80px; + height: 80px; + top: 50%; + left: 50%; + margin-top: -40px; + margin-left: -40px; + transform-style: preserve-3d; + transform-origin: 50% 50% 0; + transform: rotateX(-26deg) rotateY(-32deg); + will-change: transform; +} + +.face { + position: absolute; + inset: 0; + background: linear-gradient( + 135deg, + rgba(248, 250, 252, 0.96), + rgba(226, 232, 240, 0.92) + ); + border: 1px solid rgba(100, 116, 139, 0.6); + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 600; + color: #334155; + letter-spacing: 0.5px; + cursor: pointer; + text-transform: uppercase; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.4); + /* Hide the back side of every face so rotated faces don't z-fight with + the face behind them. */ + backface-visibility: hidden; + -webkit-backface-visibility: hidden; + transition: background 160ms ease, color 160ms ease; +} + +.face:hover { + background: linear-gradient( + 135deg, + rgba(219, 234, 254, 0.98), + rgba(191, 219, 254, 0.96) + ); + color: #1d4ed8; +} + +/* Each face is pushed out by half the cube side (40 px). */ +.faceTop { + transform: rotateX(90deg) translateZ(40px); +} +.faceFront { + transform: translateZ(40px); +} +.faceBack { + transform: rotateY(180deg) translateZ(40px); +} +.faceRight { + transform: rotateY(90deg) translateZ(40px); +} +.faceLeft { + transform: rotateY(-90deg) translateZ(40px); +} +.faceBottom { + /* Real face now — backface-visibility hides it unless the camera looks up + at the cube, instead of the previous opacity:0 hack which left a + z-fighting ghost behind. */ + transform: rotateX(-90deg) translateZ(40px); + background: linear-gradient( + 135deg, + rgba(241, 245, 249, 0.92), + rgba(203, 213, 225, 0.88) + ); + cursor: default; +} + +/* + * Corner pins live in their own non-3D layer so they're not affected by + * the cube's perspective transform. Higher z-index than .scene keeps them + * crisp on top regardless of which cube face is currently in front. + */ +.pins { + position: absolute; + inset: 0; + z-index: 3; + pointer-events: none; +} + +.corner { + position: absolute; + width: 18px; + height: 18px; + border-radius: 50%; + background: radial-gradient(circle at 30% 30%, #fbbf24, #d97706); + border: 1.5px solid #fff; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 9px; + font-weight: 700; + color: #fff; + pointer-events: auto; + transition: transform 160ms ease, box-shadow 160ms ease; + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.4); +} + +.corner:hover { + transform: scale(1.18); + box-shadow: 0 3px 6px rgba(0, 0, 0, 0.35); +} + +.cornerNW { + top: 4px; + left: 4px; +} +.cornerNE { + top: 4px; + right: 4px; +} +.cornerSW { + bottom: 4px; + left: 4px; +} +.cornerSE { + bottom: 4px; + right: 4px; +} + +.label { + position: absolute; + bottom: -16px; + left: 0; + right: 0; + text-align: center; + font-size: 10px; + color: #64748b; + font-weight: 500; + pointer-events: none; +} diff --git a/apps/client/src/components/editor/three/utils/pbrTextures.ts b/apps/client/src/components/editor/three/utils/pbrTextures.ts new file mode 100644 index 0000000..1f459de --- /dev/null +++ b/apps/client/src/components/editor/three/utils/pbrTextures.ts @@ -0,0 +1,124 @@ +import * as THREE from 'three'; +import type { FloorType, WallFinish } from '@house-plan-maker/shared'; + +/** + * Loads and caches PBR texture sets (albedo + normal + roughness) for floor + * and wall materials. Textures are CC0 photo-based maps from ambientCG, + * downsampled to 512x512 JPEG. They live in `apps/client/public/textures/` + * and are fetched lazily on first use; subsequent calls return the cached + * `MeshStandardMaterial` instance so the renderer can reuse it across rooms + * without re-uploading to the GPU. + * + * Each material is keyed by enum value, so swapping floorType/wallFinish in + * the editor only triggers a single material swap on the mesh — no texture + * re-decode. + * + * The PAINT wall finish is intentionally not in this cache: it has no + * texture data, only the room's `wallColor` tint, and lives in WallMesh. + */ + +const TEXTURE_LOADER = new THREE.TextureLoader(); + +// Real-world tiling for each material, in meters. e.g. a value of 1 means +// "the texture covers 1m x 1m of the surface". Larger numbers => smaller +// pattern repeats; smaller numbers => texture stretches over more area. +const FLOOR_TILE_METERS: Record = { + CONCRETE: 2.0, + WOOD_LIGHT: 1.4, + WOOD_MEDIUM: 1.4, + WOOD_DARK: 1.4, + WOOD_HERRINGBONE: 1.0, + TILE_WHITE: 1.0, + TILE_GRAY: 1.0, + LAMINATE: 1.4, +}; + +const WALL_TILE_METERS: Record, number> = { + PLASTER: 1.5, + BRICK: 1.0, + CONCRETE: 1.5, + WOOD_PANEL: 1.0, + WALLPAPER: 0.6, +}; + +interface PbrSet { + readonly material: THREE.MeshStandardMaterial; + /** Real-world meters covered by one full UV repeat. */ + readonly tileMeters: number; +} + +const floorCache = new Map(); +const wallCache = new Map, PbrSet>(); + +function loadMap(url: string, isColor: boolean): THREE.Texture { + const tex = TEXTURE_LOADER.load(url); + tex.wrapS = THREE.RepeatWrapping; + tex.wrapT = THREE.RepeatWrapping; + // Color maps are sRGB-encoded JPEGs; normal/roughness must stay linear. + if (isColor) { + tex.colorSpace = THREE.SRGBColorSpace; + } else { + tex.colorSpace = THREE.NoColorSpace; + } + // Anisotropic filtering eliminates the blurry-at-grazing-angle look that + // long floors have under default settings. 8x is a good ceiling on most + // GPUs and is a no-op if unsupported. + tex.anisotropy = 8; + return tex; +} + +function buildPbrSet(basePath: string, tileMeters: number, roughnessMul = 1): PbrSet { + const colorMap = loadMap(`${basePath}/color.jpg`, true); + const normalMap = loadMap(`${basePath}/normal.jpg`, false); + const roughnessMap = loadMap(`${basePath}/roughness.jpg`, false); + + const material = new THREE.MeshStandardMaterial({ + map: colorMap, + normalMap, + roughnessMap, + roughness: roughnessMul, + metalness: 0, + side: THREE.DoubleSide, + }); + return { material, tileMeters }; +} + +export function getFloorPbr(floorType: FloorType): PbrSet { + let entry = floorCache.get(floorType); + if (!entry) { + entry = buildPbrSet( + `/textures/floors/${floorType.toLowerCase()}`, + FLOOR_TILE_METERS[floorType], + ); + floorCache.set(floorType, entry); + } + return entry; +} + +export function getWallPbr(finish: Exclude): PbrSet { + let entry = wallCache.get(finish); + if (!entry) { + entry = buildPbrSet( + `/textures/walls/${finish.toLowerCase()}`, + WALL_TILE_METERS[finish], + ); + wallCache.set(finish, entry); + } + return entry; +} + +/** + * Compute UV repeat counts for a given physical surface so the texture tiles + * once per `tileMeters`. Apply this on the geometry side rather than the + * material side, otherwise every mesh sharing the same material would force + * the same repeat count. + */ +export function computeTextureRepeat( + widthMeters: number, + heightMeters: number, + tileMeters: number, +): { readonly u: number; readonly v: number } { + const u = Math.max(1, widthMeters / tileMeters); + const v = Math.max(1, heightMeters / tileMeters); + return { u, v }; +} diff --git a/apps/client/src/components/editor/three/utils/wallGeometry.ts b/apps/client/src/components/editor/three/utils/wallGeometry.ts index 77560b1..af82d0f 100644 --- a/apps/client/src/components/editor/three/utils/wallGeometry.ts +++ b/apps/client/src/components/editor/three/utils/wallGeometry.ts @@ -68,6 +68,19 @@ export interface WallSegment { * - 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 + * + * The segments that touch the physical wall ends (start=0 or end=length) are + * extended along the wall's own axis by the full `thickness`. Combined with + * the outward perpendicular shift applied in WallMesh, this is the exact + * amount needed for two perpendicular walls' boxes to share their corner + * cube without leaving a vertical gap at the inside or outside corner. + * + * For non-90 corners this creates a small overlap rather than a gap, which + * is the right tradeoff: overlap is invisible (same material, no z-fighting + * artifacts because the meshes are coplanar at the corner), gap is not. + * + * Inner segments (those bordering an opening) are not extended — extending + * them would close the opening. */ export function splitWallAroundOpenings( wall: Wall, @@ -76,20 +89,23 @@ export function splitWallAroundOpenings( ): readonly WallSegment[] { const slices = getOpeningSlices(wall, openings); const { length } = wallVector(wall); + const ext = wall.thickness; if (slices.length === 0) { - return [{ startAlongWall: 0, endAlongWall: length, bottomY: 0, topY: wallHeight }]; + return [{ startAlongWall: -ext, endAlongWall: length + ext, bottomY: 0, topY: wallHeight }]; } const segments: WallSegment[] = []; // Full-height segments between openings let cursor = 0; - for (const slice of slices) { + for (let i = 0; i < slices.length; i++) { + const slice = slices[i]; // Solid wall before this opening if (slice.startAlongWall > cursor) { segments.push({ - startAlongWall: cursor, + // Extend the start outward only if this segment touches the wall start. + startAlongWall: cursor === 0 ? -ext : cursor, endAlongWall: slice.startAlongWall, bottomY: 0, topY: wallHeight, @@ -124,7 +140,8 @@ export function splitWallAroundOpenings( if (cursor < length) { segments.push({ startAlongWall: cursor, - endAlongWall: length, + // Extend the end outward — this segment touches the wall end. + endAlongWall: length + ext, bottomY: 0, topY: wallHeight, }); diff --git a/apps/client/src/components/editor/tools/DoorTool.ts b/apps/client/src/components/editor/tools/DoorTool.ts index e66e6c5..17d9ef9 100644 --- a/apps/client/src/components/editor/tools/DoorTool.ts +++ b/apps/client/src/components/editor/tools/DoorTool.ts @@ -1,4 +1,5 @@ import type { Point, Wall, WallOpening } from '@house-plan-maker/shared'; +import { DEFAULT_POSITION_ANCHOR } from '@house-plan-maker/shared'; import { findNearestWall, wallLength } from '../utils/wallUtils'; import { generateLocalId } from '../utils/geometry'; import { hasOverlap } from '../utils/openingUtils'; @@ -73,5 +74,19 @@ export function createDoorOpening( height, elevationFromFloor: 0, openDirection: 'LEFT', + // Openings store canonical (positionAlongWall = center, elevationFromFloor = bottom). + // The default anchor matches that convention; the user may change it in + // the properties panel as a view-only display preference. + positionAnchor: { ...DEFAULT_POSITION_ANCHOR, vertical: 'bottom' }, + // Doors don't use grid subdivision — set to 1×1 so the renderer + // draws no internal mullions. Default kept for uniformity with the + // shared WallOpening type. + gridCols: 1, + gridRows: 1, + // Doors ignore slopeDepth (window-only field); kept at 0 for type completeness. + slopeDepth: 0, + // Default frame thickness matches the legacy constant in DoorOpening.tsx + // so newly placed doors look identical to existing ones until edited. + frameThickness: 0.03, }; } diff --git a/apps/client/src/components/editor/tools/ElectricalTool.ts b/apps/client/src/components/editor/tools/ElectricalTool.ts index ae71e69..7c3293a 100644 --- a/apps/client/src/components/editor/tools/ElectricalTool.ts +++ b/apps/client/src/components/editor/tools/ElectricalTool.ts @@ -1,7 +1,9 @@ import type { Point, Wall, ElectricalItem, ElectricalType } from '@house-plan-maker/shared'; +import { DEFAULT_POSITION_ANCHOR } from '@house-plan-maker/shared'; import { findNearestWall, wallAngle } from '../utils/wallUtils'; import { generateLocalId } from '../utils/geometry'; import { DEFAULT_ELEVATIONS } from '../utils/projectionMapping'; +import { normalizeAngleDegrees } from '../utils/angle'; import type { ElectricalSymbolDef } from '../symbols/electrical'; /** Maximum snap distance to wall (meters). */ @@ -31,7 +33,7 @@ export function computeElectricalPreview( return { x: nearest.projected.x, y: nearest.projected.y, - rotation: (angle * 180) / Math.PI, + rotation: normalizeAngleDegrees((angle * 180) / Math.PI), wallId: nearest.wall.id, isValid: true, }; @@ -84,7 +86,10 @@ export function createElectricalItemFromPlacement( variant?: string, wallHeight?: number, ): ElectricalItem { - const metadata: Record | null = variant ? { variant } : null; + // Outlets no longer use a `variant` — count is the source of truth. For all + // other types we still pass variant through metadata (e.g., switches). + const metadata: Record | null = + type !== 'OUTLET' && variant ? { variant } : null; return { id: generateLocalId(), @@ -95,6 +100,9 @@ export function createElectricalItemFromPlacement( wallId: preview.wallId, elevationFromFloor: getDefaultElevation(type, wallHeight), rotation: preview.rotation, + count: 1, + positionAnchor: DEFAULT_POSITION_ANCHOR, + label: null, metadata, }; } diff --git a/apps/client/src/components/editor/tools/FurnitureTool.ts b/apps/client/src/components/editor/tools/FurnitureTool.ts index 2fd5e3f..fe2a2c1 100644 --- a/apps/client/src/components/editor/tools/FurnitureTool.ts +++ b/apps/client/src/components/editor/tools/FurnitureTool.ts @@ -1,4 +1,5 @@ import type { Point, FurnitureItem } from '@house-plan-maker/shared'; +import { DEFAULT_POSITION_ANCHOR } from '@house-plan-maker/shared'; import { generateLocalId } from '../utils/geometry'; import type { FurnitureDef } from '../symbols/furniture'; @@ -13,9 +14,8 @@ export interface FurniturePlacementPreview { /** * 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. + * New furniture uses the default anchor (middle/middle), so the cursor world + * point IS the (x, y) — no offset needed. */ export function computeFurniturePreview( worldPoint: Point, @@ -23,8 +23,8 @@ export function computeFurniturePreview( rotation: number = 0, ): FurniturePlacementPreview { return { - x: worldPoint.x - furnitureDef.width / 2, - y: worldPoint.y - furnitureDef.depth / 2, + x: worldPoint.x, + y: worldPoint.y, width: furnitureDef.width, depth: furnitureDef.depth, rotation, @@ -34,12 +34,20 @@ export function computeFurniturePreview( /** * Create a FurnitureItem from placement data. + * Propagates the FurnitureDef's defaultMetadata into item.metadata so + * presets like plant variants or flower colours apply on first render. */ export function createFurnitureItemFromPlacement( roomId: string, preview: FurniturePlacementPreview, furnitureDef: FurnitureDef, ): FurnitureItem { + // Shallow-copy the default metadata so later mutations on the item don't + // leak back into the static FurnitureDef table. + const metadata: Record | null = furnitureDef.defaultMetadata + ? { ...furnitureDef.defaultMetadata } + : null; + return { id: generateLocalId(), roomId, @@ -50,8 +58,22 @@ export function createFurnitureItemFromPlacement( depth: preview.depth, height: furnitureDef.height, rotation: preview.rotation, - elevationFromFloor: furnitureDef.type === 'AC_UNIT' ? 2.2 : furnitureDef.height <= 0.05 ? 1.2 : 0, + elevationFromFloor: + furnitureDef.type === 'AC_UNIT' + ? 2.2 + : furnitureDef.type === 'WALL_COLLAGE' + ? 1.4 // eye level — user can adjust in the panel + : furnitureDef.type === 'RADIATOR' + ? 0.12 // small clearance from the floor (typical) + : furnitureDef.type === 'MIRROR' && + (furnitureDef.defaultMetadata?.['variant'] as string | undefined) === 'wall' + ? Math.max(0, 1.5 - furnitureDef.height / 2) // hang so centre sits around eye level + : furnitureDef.height <= 0.05 + ? 1.2 + : 0, label: furnitureDef.label, + positionAnchor: DEFAULT_POSITION_ANCHOR, + metadata, }; } diff --git a/apps/client/src/components/editor/tools/SelectTool.ts b/apps/client/src/components/editor/tools/SelectTool.ts index 30a406c..c3d0aa2 100644 --- a/apps/client/src/components/editor/tools/SelectTool.ts +++ b/apps/client/src/components/editor/tools/SelectTool.ts @@ -1,8 +1,19 @@ import type { Point, Wall, WallOpening, ElectricalItem, FurnitureItem } from '@house-plan-maker/shared'; +import { rotatedAnchorOffsetToCenter } from '@house-plan-maker/shared'; import type { DragState } from '../types'; import { distance } from '../utils/geometry'; import { findNearestWall } from '../utils/wallUtils'; +function furnitureCenter(item: FurnitureItem): { cx: number; cy: number } { + const offset = rotatedAnchorOffsetToCenter( + item.positionAnchor, + item.width, + item.depth, + item.rotation, + ); + return { cx: item.x + offset.dx, cy: item.y + offset.dy }; +} + /** Hit-test radius in meters for selecting elements. */ const HIT_RADIUS = 0.15; @@ -58,8 +69,7 @@ export function hitTest( // 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; + const { cx, cy } = furnitureCenter(item); // Translate point relative to item center const dx = worldPoint.x - cx; const dy = worldPoint.y - cy; @@ -151,9 +161,8 @@ export function elementsInRect( } 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; + // Use the bounding-box centre (anchor-aware) for selection-rect containment + const { cx, cy } = furnitureCenter(item); if ( cx >= rect.x && cx <= rect.x + rect.width && @@ -193,8 +202,25 @@ export function selectedBoundingBox( 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 }); + // Compute the four corners of the oriented opening rectangle + // (along-wall = opening.width, perpendicular = wall.thickness), + // then push them all so the resulting AABB is the correct + // world-axis box for the rotated symbol. The previous version + // used `opening.width/2` on both axes, producing a square that + // ballooned out from the wall on long windows. + const halfW = opening.width / 2; + const halfT = wall.thickness / 2; + // Wall normal (perpendicular to direction). + const nx = -dy; + const ny = dx; + for (const su of [-halfW, halfW]) { + for (const sv of [-halfT, halfT]) { + points.push({ + x: cx + dx * su + nx * sv, + y: cy + dy * su + ny * sv, + }); + } + } } } continue; @@ -209,9 +235,12 @@ export function selectedBoundingBox( 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; + // Selection overlay must enclose the ROTATED visual — otherwise a + // thin rotated item (e.g. a curtain rotated 90°) would show a + // horizontal dashed box while the visible rectangle is vertical. + // Compute the world-axis AABB of the rotated `width × depth` + // rectangle centred on the item's (rotated) centre. + const { cx, cy } = furnitureCenter(furn); const rad = (furn.rotation * Math.PI) / 180; const cos = Math.abs(Math.cos(rad)); const sin = Math.abs(Math.sin(rad)); diff --git a/apps/client/src/components/editor/tools/WindowTool.ts b/apps/client/src/components/editor/tools/WindowTool.ts index 49a1e53..0e21eff 100644 --- a/apps/client/src/components/editor/tools/WindowTool.ts +++ b/apps/client/src/components/editor/tools/WindowTool.ts @@ -1,4 +1,5 @@ import type { Point, Wall, WallOpening } from '@house-plan-maker/shared'; +import { DEFAULT_POSITION_ANCHOR } from '@house-plan-maker/shared'; import { findNearestWall, wallLength } from '../utils/wallUtils'; import { generateLocalId } from '../utils/geometry'; import { hasOverlap } from '../utils/openingUtils'; @@ -77,5 +78,13 @@ export function createWindowOpening( height, elevationFromFloor: elevation, openDirection: 'LEFT', + positionAnchor: { ...DEFAULT_POSITION_ANCHOR, vertical: 'bottom' }, + // Default to a classic 2×2 grid (one vertical + one horizontal mullion). + // The user can change this in the properties panel after placing. + gridCols: 2, + gridRows: 2, + // No reveal slope by default — user can opt in via the properties panel. + slopeDepth: 0, + frameThickness: 0.03, }; } diff --git a/apps/client/src/components/editor/types.ts b/apps/client/src/components/editor/types.ts index 7c567f7..d77a09c 100644 --- a/apps/client/src/components/editor/types.ts +++ b/apps/client/src/components/editor/types.ts @@ -57,6 +57,10 @@ export interface EditorState { /** Index into FURNITURE_DEFS for furniture tool. */ readonly selectedFurnitureIndex: number | null; readonly annotations: readonly Annotation[]; + /** Furniture IDs whose dimension/offset projection should be drawn on every wall view. */ + readonly furnitureProjectionIds: ReadonlySet; + /** Global multiplier applied to every furniture item's render opacity. */ + readonly globalFurnitureOpacity: number; } // ── Undo/Redo Commands ── @@ -71,7 +75,7 @@ export interface EditorCommand { export type EditorAction = | { readonly type: 'SET_ROOM'; readonly room: RoomFull } - | { readonly type: 'UPDATE_ROOM_PROPS'; readonly props: Partial> } + | { readonly type: 'UPDATE_ROOM_PROPS'; readonly props: Partial> } | { readonly type: 'SET_WALLS'; readonly walls: readonly Wall[] } | { readonly type: 'UPDATE_WALL'; readonly wall: Wall } | { readonly type: 'ADD_OPENING'; readonly opening: WallOpening } @@ -90,6 +94,9 @@ export type EditorAction = | { readonly type: 'SET_TOOL'; readonly tool: EditorToolType } | { readonly type: 'SET_ZOOM'; readonly zoom: number } | { readonly type: 'SET_PAN_OFFSET'; readonly offset: Point } + // Atomic zoom + pan update. Used by auto-fit so we don't flicker through + // an intermediate (newZoom, oldPan) state between two separate dispatches. + | { readonly type: 'SET_VIEW'; readonly zoom: number; readonly offset: Point } | { readonly type: 'SET_GRID_SIZE'; readonly gridSize: number } | { readonly type: 'TOGGLE_GRID' } | { readonly type: 'TOGGLE_SNAP' } @@ -108,6 +115,10 @@ export type EditorAction = | { readonly type: 'ADD_ANNOTATION'; readonly annotation: Annotation } | { readonly type: 'UPDATE_ANNOTATION'; readonly annotation: Annotation } | { readonly type: 'REMOVE_ANNOTATION'; readonly id: string } + // Furniture projection toggle (per-item dimension/offset overlay on wall views) + | { readonly type: 'TOGGLE_FURNITURE_PROJECTION'; readonly id: string } + // Global furniture opacity (client-side display setting, multiplied with item.opacity) + | { readonly type: 'SET_GLOBAL_FURNITURE_OPACITY'; readonly opacity: number } // Import | { readonly type: 'IMPORT_ROOM'; @@ -130,6 +141,9 @@ export type EditorAction = readonly openings: readonly WallOpening[]; readonly electricalItems: readonly ElectricalItem[]; readonly furnitureItems: readonly FurnitureItem[]; + readonly annotations?: readonly Annotation[]; + /** Map from old (pre-save) item IDs to new (server) item IDs. */ + readonly idMap?: ReadonlyMap; }; // ── Alignment ── diff --git a/apps/client/src/components/editor/utils/__tests__/collisionDetection.test.ts b/apps/client/src/components/editor/utils/__tests__/collisionDetection.test.ts index 0e7763e..284d668 100644 --- a/apps/client/src/components/editor/utils/__tests__/collisionDetection.test.ts +++ b/apps/client/src/components/editor/utils/__tests__/collisionDetection.test.ts @@ -15,6 +15,7 @@ function makeFurniture(overrides: Partial = {}): FurnitureItem { rotation: 0, elevationFromFloor: 0, label: null, + positionAnchor: { horizontal: 'left', vertical: 'top' }, ...overrides, }; } diff --git a/apps/client/src/components/editor/utils/__tests__/wallUtils.test.ts b/apps/client/src/components/editor/utils/__tests__/wallUtils.test.ts index 2dc94ef..c3228dd 100644 --- a/apps/client/src/components/editor/utils/__tests__/wallUtils.test.ts +++ b/apps/client/src/components/editor/utils/__tests__/wallUtils.test.ts @@ -100,6 +100,11 @@ describe('openingWorldPosition', () => { height: 2.1, elevationFromFloor: 0, openDirection: 'LEFT', + positionAnchor: { horizontal: 'middle', vertical: 'bottom' }, + gridCols: 2, + gridRows: 2, + slopeDepth: 0, + frameThickness: 0.03, }; const result = openingWorldPosition(opening, wall); expect(result.center.x).toBeCloseTo(5); @@ -120,6 +125,11 @@ describe('openingWorldPosition', () => { height: 1.2, elevationFromFloor: 0.9, openDirection: 'LEFT', + positionAnchor: { horizontal: 'middle', vertical: 'bottom' }, + gridCols: 2, + gridRows: 2, + slopeDepth: 0, + frameThickness: 0.03, }; const result = openingWorldPosition(opening, wall); expect(result.center).toEqual({ x: 3, y: 3 }); diff --git a/apps/client/src/components/editor/utils/angle.ts b/apps/client/src/components/editor/utils/angle.ts new file mode 100644 index 0000000..2c68f62 --- /dev/null +++ b/apps/client/src/components/editor/utils/angle.ts @@ -0,0 +1,9 @@ +/** + * Normalize an angle in degrees to the [0, 360) range. + * Used for storage because the schema requires `rotation: z.number().min(0).max(360)`. + */ +export function normalizeAngleDegrees(deg: number): number { + if (!Number.isFinite(deg)) return 0; + const wrapped = deg % 360; + return wrapped < 0 ? wrapped + 360 : wrapped; +} diff --git a/apps/client/src/components/editor/utils/collisionDetection.ts b/apps/client/src/components/editor/utils/collisionDetection.ts index 176e94e..81d9e18 100644 --- a/apps/client/src/components/editor/utils/collisionDetection.ts +++ b/apps/client/src/components/editor/utils/collisionDetection.ts @@ -1,102 +1,74 @@ import type { FurnitureItem } from '@house-plan-maker/shared'; +import { rotatedAnchorOffsetToCenter } from '@house-plan-maker/shared'; -interface OBB { +/** + * Axis-aligned bounding box for a furniture item, in world (room) coordinates. + * + * Per the project rule "rotation only affects visuals and hitbox, positioning + * always uses the initial coordinate system", collision detection — which is + * a positional concern — operates on the UNROTATED rectangle. A rotated bed + * is treated as if it were axis-aligned for the purpose of overlap testing. + */ +interface AABB { readonly id: string; - readonly cx: number; - readonly cy: number; - readonly halfW: number; - readonly halfD: number; - readonly cos: number; - readonly sin: number; + readonly minX: number; + readonly minY: number; + readonly maxX: number; + readonly maxY: number; + readonly bottom: number; + readonly top: number; } -function computeOBB(item: FurnitureItem): OBB { - const rad = (item.rotation * Math.PI) / 180; +function computeAabb(item: FurnitureItem): AABB { + // Center is rotation-aware via the anchor helper. The AABB itself is + // still the unrotated `width × depth` rectangle (per the project rule + // that positioning math uses the unrotated extents), but its position + // tracks the rotated anchor offset. + const offset = rotatedAnchorOffsetToCenter( + item.positionAnchor, + item.width, + item.depth, + item.rotation, + ); + const cx = item.x + offset.dx; + const cy = item.y + offset.dy; + const halfW = item.width / 2; + const halfD = item.depth / 2; 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), + minX: cx - halfW, + minY: cy - halfD, + maxX: cx + halfW, + maxY: cy + halfD, + bottom: item.elevationFromFloor, + top: item.elevationFromFloor + item.height, }; } -/** 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 +function aabbOverlap(a: AABB, b: AABB): boolean { + if (a.maxX <= b.minX || b.maxX <= a.minX) return false; + if (a.maxY <= b.minY || b.maxY <= a.minY) return false; + if (a.top <= b.bottom || b.top <= a.bottom) return false; + return true; } /** - * Find all furniture IDs that collide using proper OBB (rotation-aware) overlap. + * Find all furniture IDs that collide. Uses unrotated axis-aligned bounding + * boxes — see the AABB doc comment for the rationale. */ export function findCollidingFurniture( items: readonly FurnitureItem[], ): ReadonlySet { if (items.length < 2) return new Set(); - const obbs = items.map(computeOBB); + const boxes = items.map(computeAabb); const colliding = new Set(); - 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); + for (let i = 0; i < boxes.length; i++) { + for (let j = i + 1; j < boxes.length; j++) { + if (aabbOverlap(boxes[i], boxes[j])) { + colliding.add(boxes[i].id); + colliding.add(boxes[j].id); } } } diff --git a/apps/client/src/components/editor/utils/curtainMetadata.ts b/apps/client/src/components/editor/utils/curtainMetadata.ts new file mode 100644 index 0000000..eb2b0f6 --- /dev/null +++ b/apps/client/src/components/editor/utils/curtainMetadata.ts @@ -0,0 +1,69 @@ +/** + * Helpers for reading curtain-specific fields out of a FurnitureItem's + * metadata bag. Kept in a standalone utility module so both the 3D mesh + * and the properties panel can read/write the same shape without one + * importing the other. + * + * Metadata shape (all keys optional): + * - `leftOpen`: number in [0, 1] — how far the LEFT panel is drawn aside + * (0 = fully closed, 1 = fully retracted to the left edge). + * - `rightOpen`: number in [0, 1] — mirror of `leftOpen` for the RIGHT + * panel. + * - `openAmount`: legacy symmetric field. Used as the fallback for both + * sides when `leftOpen` / `rightOpen` are absent so existing curtain + * rows keep rendering identically. + * - `fabricColor`: `#rrggbb` hex string. Falls back to the default when + * missing or malformed. + */ + +export const DEFAULT_CURTAIN_OPEN_AMOUNT = 0; +export const DEFAULT_CURTAIN_FABRIC_COLOR = '#e8dfc8'; + +type MetadataBag = Record | null | undefined; + +function readOpenValue(raw: unknown): number | null { + if (typeof raw !== 'number' || !Number.isFinite(raw)) return null; + return Math.max(0, Math.min(1, raw)); +} + +/** + * Legacy symmetric open amount. Returned only for UI compatibility — + * the mesh and panel should prefer the per-side helpers below. + */ +export function getCurtainOpenAmount(metadata: MetadataBag): number { + if (!metadata) return DEFAULT_CURTAIN_OPEN_AMOUNT; + const v = readOpenValue(metadata['openAmount']); + return v ?? DEFAULT_CURTAIN_OPEN_AMOUNT; +} + +/** + * Left panel open amount. Prefers the explicit `leftOpen` key, then falls + * back to the legacy symmetric `openAmount`, then to the default. + */ +export function getCurtainLeftOpen(metadata: MetadataBag): number { + if (!metadata) return DEFAULT_CURTAIN_OPEN_AMOUNT; + const explicit = readOpenValue(metadata['leftOpen']); + if (explicit !== null) return explicit; + const legacy = readOpenValue(metadata['openAmount']); + return legacy ?? DEFAULT_CURTAIN_OPEN_AMOUNT; +} + +/** Right panel open amount. Symmetric counterpart to {@link getCurtainLeftOpen}. */ +export function getCurtainRightOpen(metadata: MetadataBag): number { + if (!metadata) return DEFAULT_CURTAIN_OPEN_AMOUNT; + const explicit = readOpenValue(metadata['rightOpen']); + if (explicit !== null) return explicit; + const legacy = readOpenValue(metadata['openAmount']); + return legacy ?? DEFAULT_CURTAIN_OPEN_AMOUNT; +} + +/** + * Returns the curtain fabric color as a `#rrggbb` hex string. Missing or + * malformed values fall back to the shared default so legacy curtain rows + * render correctly. + */ +export function getCurtainFabricColor(metadata: MetadataBag): string { + if (!metadata) return DEFAULT_CURTAIN_FABRIC_COLOR; + const raw = metadata['fabricColor']; + return typeof raw === 'string' && /^#[0-9a-fA-F]{6}$/.test(raw) ? raw : DEFAULT_CURTAIN_FABRIC_COLOR; +} diff --git a/apps/client/src/components/editor/utils/projectionMapping.ts b/apps/client/src/components/editor/utils/projectionMapping.ts index bdda2b1..6ea9499 100644 --- a/apps/client/src/components/editor/utils/projectionMapping.ts +++ b/apps/client/src/components/editor/utils/projectionMapping.ts @@ -1,6 +1,47 @@ -import type { Wall, WallOpening, ElectricalItem, FurnitureItem } from '@house-plan-maker/shared'; +import type { Wall, WallOpening, ElectricalItem, FurnitureItem, Point } from '@house-plan-maker/shared'; +import { rotatedAnchorOffsetToCenter } from '@house-plan-maker/shared'; import { wallLength, wallStartEnd } from './wallUtils'; +// ── Projection axis (canonical orientation for elevation views) ── + +/** + * Pick a canonical orientation for the projection X axis. + * + * For axis-aligned walls, the projection axis is oriented so that it matches the + * floor plan's positive X (for horizontal walls) or positive Y (for vertical walls) + * direction. This means a south wall is shown left-to-right matching west→east on + * the floor plan, instead of mirrored. + * + * Diagonal walls keep their natural start→end orientation. + * + * @returns the canonical start, end, length and whether the axis is flipped + * relative to the wall's stored start→end. + */ +export interface ProjectionAxis { + readonly start: Point; + readonly end: Point; + readonly length: number; + /** True when the canonical axis runs opposite the wall's stored start→end. */ + readonly flipped: boolean; +} + +export function getProjectionAxis(wall: Wall): ProjectionAxis { + const { start, end } = wallStartEnd(wall); + const length = wallLength(wall); + const dx = end.x - start.x; + const dy = end.y - start.y; + const ax = Math.abs(dx); + const ay = Math.abs(dy); + const isHorizontal = ax >= ay; + + // Want horizontal walls to go +X, vertical walls to go +Y. + const flipped = isHorizontal ? dx < 0 : dy < 0; + if (flipped) { + return { start: end, end: start, length, flipped: true }; + } + return { start, end, length, flipped: false }; +} + // ── Constants ── /** Standard door height in meters. */ @@ -58,6 +99,16 @@ export interface ProjectedElectrical { readonly item: ElectricalItem; readonly position: ProjectedPosition; readonly elevation: number; + /** + * True when the wall's stored start→end direction is opposite the + * canonical projection axis (see `getProjectionAxis`). The renderer uses + * this to mirror the horizontal anchor label: "left" in the canonical + * projection view corresponds to "right" in the wall's natural frame + * (which is what 3D uses). Without this flag, a left-anchored outlet on + * a flipped wall would appear on opposite sides of the 3D and projection + * views. + */ + readonly axisFlipped: boolean; } /** Information about a furniture item projected onto a wall. */ @@ -125,12 +176,18 @@ export function projectOpenings( wall: Wall, openings: readonly WallOpening[], ): readonly ProjectedOpening[] { - const wallLen = wallLength(wall); + const axis = getProjectionAxis(wall); + const wallLen = axis.length; return openings .filter((o) => o.wallId === wall.id) .map((opening) => { const halfWidth = opening.width / 2; - const leftEdge = opening.positionAlongWall - halfWidth; + // Map storage position (relative to wall.start) to projection position + // (relative to canonical start). When the axis is flipped, mirror it. + const projectedCenter = axis.flipped + ? wallLen - opening.positionAlongWall + : opening.positionAlongWall; + const leftEdge = projectedCenter - halfWidth; const isDoor = opening.type === 'DOOR'; const fromFloor = isDoor ? 0 : opening.elevationFromFloor; @@ -155,8 +212,8 @@ export function projectElectricalItems( wall: Wall, electricalItems: readonly ElectricalItem[], ): readonly ProjectedElectrical[] { - const { start, end } = wallStartEnd(wall); - const wallLen = wallLength(wall); + const axis = getProjectionAxis(wall); + const { start, end, length: wallLen } = axis; if (wallLen === 0) return []; @@ -194,6 +251,7 @@ export function projectElectricalItems( item, position: { alongWall: Math.max(0, Math.min(wallLen, alongWall)), fromFloor: elevation }, elevation, + axisFlipped: axis.flipped, }; }); } @@ -201,8 +259,59 @@ export function projectElectricalItems( // ── 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. + * Project the ROTATED half-extents of a furniture item onto the wall axis + * and the wall-perpendicular axis. + * + * Rotation pivots around the rectangle centre, so the local axes become: + * u = ( cos r, sin r) // width direction in world coords + * v = (-sin r, cos r) // depth direction in world coords + * + * Projecting (w/2)·u and (d/2)·v onto the wall direction (and its + * perpendicular) gives the support of the rotated bounding box along each + * axis. This is what the user sees as the silhouette in 3D and top-down + * views, so the projection view on the wall elevation must match it. + * + * History: an earlier iteration used unrotated extents under the rule + * "rotation only affects visuals and hitbox, positioning uses the initial + * coordinate system". That turned out to be wrong for projection because + * projection-onto-a-wall IS a visual operation — it literally asks "what + * does the rotated shape look like from this wall?" — so rotated extents + * are the correct answer. + */ +function rotatedHalfExtents( + item: FurnitureItem, + wallDirX: number, + wallDirY: number, +): { halfAlong: number; halfPerp: number } { + const r = ((item.rotation ?? 0) * Math.PI) / 180; + const cos = Math.cos(r); + const sin = Math.sin(r); + const halfW = item.width / 2; + const halfD = item.depth / 2; + + // Rotated half-axis vectors in world space. + const ux = cos * halfW; + const uy = sin * halfW; + const vx = -sin * halfD; + const vy = cos * halfD; + + // Wall-perpendicular unit vector (rotated 90° CCW from the wall direction). + const perpX = -wallDirY; + const perpY = wallDirX; + + const halfAlong = + Math.abs(ux * wallDirX + uy * wallDirY) + Math.abs(vx * wallDirX + vy * wallDirY); + const halfPerp = + Math.abs(ux * perpX + uy * perpY) + Math.abs(vx * perpX + vy * perpY); + + return { halfAlong, halfPerp }; +} + +/** + * Distance from the nearest edge of a furniture item to a wall. Uses the + * ROTATED bounding box so that a rotated item parallel to the wall is + * picked up by the proximity filter when its edge is close, not when its + * unrotated footprint happens to touch. */ function furnitureEdgeDistanceToWall( item: FurnitureItem, @@ -215,26 +324,32 @@ function furnitureEdgeDistanceToWall( 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; + // (item.x, item.y) is the anchored point on the rotated bounding box. + // Convert to box centre. + const offset = rotatedAnchorOffsetToCenter( + item.positionAnchor, + item.width, + item.depth, + item.rotation, + ); + const cx = item.x + offset.dx; + const cy = item.y + offset.dy; - // Vector from wall start to item center - const vx = cx - start.x; - const vy = cy - start.y; + // Vector from wall start to item centre + const dxC = cx - start.x; + const dyC = cy - start.y; - // Perpendicular distance from center to wall - const centerDist = Math.abs(vx * (-dy) + vy * dx); + // Perpendicular distance from centre to wall line + const centerDist = Math.abs(dxC * (-dy) + dyC * 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); + const { halfAlong, halfPerp } = rotatedHalfExtents(item, dx, dy); + const edgeDist = Math.max(0, centerDist - halfPerp); - // 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; + // Along-wall extent: item (rotated) must overlap the wall's length. + const alongWallCenter = dxC * dx + dyC * dy; + if (alongWallCenter < -halfAlong || alongWallCenter > wallLen + halfAlong) { + return Infinity; + } return edgeDist; } @@ -245,8 +360,8 @@ export function projectFurnitureItems( furnitureItems: readonly FurnitureItem[], wallThreshold: number = 0.15, ): readonly ProjectedFurniture[] { - const { start, end } = wallStartEnd(wall); - const wallLen = wallLength(wall); + const axis = getProjectionAxis(wall); + const { start, end, length: wallLen } = axis; if (wallLen === 0) return []; const dx = (end.x - start.x) / wallLen; @@ -258,22 +373,29 @@ export function projectFurnitureItems( 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; + // Convert anchored (x, y) to rotated bounding-box centre. + const offset = rotatedAnchorOffsetToCenter( + item.positionAnchor, + item.width, + item.depth, + item.rotation, + ); + const cx = item.x + offset.dx; + const cy = item.y + offset.dy; + const dxC = cx - start.x; + const dyC = cy - start.y; + const alongWallCenter = dxC * dx + dyC * 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; + // Silhouette width on the wall = rotated half-extent along the wall + // direction, doubled. Matches what the 3D view shows. + const { halfAlong } = rotatedHalfExtents(item, dx, dy); + const projectedWidth = halfAlong * 2; const fromFloor = item.elevationFromFloor ?? 0; return { item, rect: { - x: Math.max(0, alongWall - projectedWidth / 2), + x: alongWallCenter - halfAlong, y: fromFloor, width: projectedWidth, height: item.height, @@ -290,16 +412,20 @@ export function computePlinthSegments( openings: readonly WallOpening[], plinthHeight: number, ): readonly PlinthSegment[] { - const wallLen = wallLength(wall); + const axis = getProjectionAxis(wall); + const wallLen = axis.length; if (wallLen <= 0 || plinthHeight <= 0) return []; - // Collect door gaps (sorted by position) + // Collect door gaps (sorted by canonical projection 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), - })) + .map((o) => { + const projectedCenter = axis.flipped ? wallLen - o.positionAlongWall : o.positionAlongWall; + return { + start: Math.max(0, projectedCenter - o.width / 2), + end: Math.min(wallLen, projectedCenter + o.width / 2), + }; + }) .sort((a, b) => a.start - b.start); if (doors.length === 0) { diff --git a/apps/client/src/components/layout/AppShell.tsx b/apps/client/src/components/layout/AppShell.tsx index e789c61..f569610 100644 --- a/apps/client/src/components/layout/AppShell.tsx +++ b/apps/client/src/components/layout/AppShell.tsx @@ -1,8 +1,27 @@ +import { useCallback, useState } from 'react'; import { Outlet, Link, NavLink, useMatches } from 'react-router'; import { useTranslation } from 'react-i18next'; import { useTheme } from '../../contexts/ThemeContext'; import styles from './app-shell.module.css'; +const SIDEBAR_COLLAPSED_KEY = 'appShell.sidebar.collapsed'; + +function readSidebarCollapsed(): boolean { + try { + return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === 'true'; + } catch { + return false; + } +} + +function writeSidebarCollapsed(value: boolean): void { + try { + localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(value)); + } catch { + /* ignore quota / disabled storage */ + } +} + interface CrumbHandle { crumb?: string | ((data: unknown) => string); } @@ -18,6 +37,17 @@ export function AppShell() { const { theme, toggleTheme } = useTheme(); const matches = useMatches() as MatchWithHandle[]; + const [sidebarCollapsed, setSidebarCollapsed] = useState(() => + readSidebarCollapsed(), + ); + const toggleSidebar = useCallback(() => { + setSidebarCollapsed((prev) => { + const next = !prev; + writeSidebarCollapsed(next); + return next; + }); + }, []); + const crumbs = matches .filter((m) => m.handle?.crumb) .map((m) => { @@ -95,7 +125,24 @@ export function AppShell() { {/* Body */}
{/* Sidebar (desktop) */} -
+ } + > + {multiline ? ( +