feat: editor improvements and collapsible sidebars
Add collapse/expand toggle for the AppShell navigation sidebar and the editor properties panel (both persisted to localStorage). Bundles other in-progress editor work including position anchors, outlet sizing, PBR textures, window slope/frame depth, curtain metadata, and various 2D/3D rendering tweaks.
@@ -22,6 +22,8 @@
|
|||||||
"furniture.other": "Other",
|
"furniture.other": "Other",
|
||||||
|
|
||||||
"nav.apartments": "Apartments",
|
"nav.apartments": "Apartments",
|
||||||
|
"nav.collapse": "Collapse sidebar",
|
||||||
|
"nav.expand": "Expand sidebar",
|
||||||
|
|
||||||
"breadcrumb.apartments": "Apartments",
|
"breadcrumb.apartments": "Apartments",
|
||||||
"breadcrumb.apartmentDetails": "Apartment Details",
|
"breadcrumb.apartmentDetails": "Apartment Details",
|
||||||
@@ -108,6 +110,13 @@
|
|||||||
|
|
||||||
"roomCard.edit": "Edit",
|
"roomCard.edit": "Edit",
|
||||||
"roomCard.delete": "Delete",
|
"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.cancel": "Cancel",
|
||||||
"common.delete": "Delete",
|
"common.delete": "Delete",
|
||||||
@@ -162,6 +171,8 @@
|
|||||||
"toolbar.distributeV": "Distribute vertical",
|
"toolbar.distributeV": "Distribute vertical",
|
||||||
|
|
||||||
"properties.title": "Properties",
|
"properties.title": "Properties",
|
||||||
|
"properties.collapse": "Collapse panel",
|
||||||
|
"properties.expand": "Expand panel",
|
||||||
"properties.area": "Area",
|
"properties.area": "Area",
|
||||||
"properties.perimeter": "Perimeter",
|
"properties.perimeter": "Perimeter",
|
||||||
"properties.noSelection": "No element selected",
|
"properties.noSelection": "No element selected",
|
||||||
@@ -197,6 +208,14 @@
|
|||||||
"properties.yes": "Yes",
|
"properties.yes": "Yes",
|
||||||
"properties.depth": "Depth",
|
"properties.depth": "Depth",
|
||||||
"properties.wallColor": "Wall color",
|
"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",
|
"properties.floorType": "Floor",
|
||||||
"floor.CONCRETE": "Concrete",
|
"floor.CONCRETE": "Concrete",
|
||||||
"floor.WOOD_LIGHT": "Light Wood",
|
"floor.WOOD_LIGHT": "Light Wood",
|
||||||
@@ -207,6 +226,31 @@
|
|||||||
"floor.TILE_GRAY": "Gray Tile",
|
"floor.TILE_GRAY": "Gray Tile",
|
||||||
"floor.LAMINATE": "Laminate",
|
"floor.LAMINATE": "Laminate",
|
||||||
"properties.addNote": "Add note",
|
"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.stand": "Stand",
|
||||||
"properties.openDirection": "Open direction",
|
"properties.openDirection": "Open direction",
|
||||||
"properties.openDir.LEFT": "Left",
|
"properties.openDir.LEFT": "Left",
|
||||||
@@ -226,6 +270,18 @@
|
|||||||
"electrical.cable": "Cable",
|
"electrical.cable": "Cable",
|
||||||
|
|
||||||
"furniture.title": "Furniture",
|
"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:",
|
"cableLength.label": "Cable length:",
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
"furniture.other": "Другое",
|
"furniture.other": "Другое",
|
||||||
|
|
||||||
"nav.apartments": "Квартиры",
|
"nav.apartments": "Квартиры",
|
||||||
|
"nav.collapse": "Свернуть боковую панель",
|
||||||
|
"nav.expand": "Развернуть боковую панель",
|
||||||
|
|
||||||
"breadcrumb.apartments": "Квартиры",
|
"breadcrumb.apartments": "Квартиры",
|
||||||
"breadcrumb.apartmentDetails": "Детали квартиры",
|
"breadcrumb.apartmentDetails": "Детали квартиры",
|
||||||
@@ -111,6 +113,13 @@
|
|||||||
|
|
||||||
"roomCard.edit": "Изменить",
|
"roomCard.edit": "Изменить",
|
||||||
"roomCard.delete": "Удалить",
|
"roomCard.delete": "Удалить",
|
||||||
|
"roomCard.clone": "Дублировать",
|
||||||
|
"view3d.lightControls": "Свет",
|
||||||
|
"view3d.azimuth": "Азимут",
|
||||||
|
"view3d.elevation": "Высота",
|
||||||
|
"view3d.intensity": "Интенсивность",
|
||||||
|
"view3d.reset": "Сброс",
|
||||||
|
"view3d.doorsOpen": "Показать двери открытыми",
|
||||||
|
|
||||||
"common.cancel": "Отмена",
|
"common.cancel": "Отмена",
|
||||||
"common.delete": "Удалить",
|
"common.delete": "Удалить",
|
||||||
@@ -165,6 +174,8 @@
|
|||||||
"toolbar.distributeV": "Распределить по вертикали",
|
"toolbar.distributeV": "Распределить по вертикали",
|
||||||
|
|
||||||
"properties.title": "Свойства",
|
"properties.title": "Свойства",
|
||||||
|
"properties.collapse": "Свернуть панель",
|
||||||
|
"properties.expand": "Развернуть панель",
|
||||||
"properties.area": "Площадь",
|
"properties.area": "Площадь",
|
||||||
"properties.perimeter": "Периметр",
|
"properties.perimeter": "Периметр",
|
||||||
"properties.noSelection": "Элемент не выбран",
|
"properties.noSelection": "Элемент не выбран",
|
||||||
@@ -200,6 +211,14 @@
|
|||||||
"properties.yes": "Да",
|
"properties.yes": "Да",
|
||||||
"properties.depth": "Глубина",
|
"properties.depth": "Глубина",
|
||||||
"properties.wallColor": "Цвет стен",
|
"properties.wallColor": "Цвет стен",
|
||||||
|
"properties.wallFinish": "Отделка стен",
|
||||||
|
"properties.wallColorPaintOnly": "Цвет применяется только к покраске",
|
||||||
|
"wallFinish.PAINT": "Покраска",
|
||||||
|
"wallFinish.PLASTER": "Штукатурка",
|
||||||
|
"wallFinish.BRICK": "Кирпич",
|
||||||
|
"wallFinish.CONCRETE": "Бетон",
|
||||||
|
"wallFinish.WOOD_PANEL": "Деревянная панель",
|
||||||
|
"wallFinish.WALLPAPER": "Обои",
|
||||||
"properties.floorType": "Пол",
|
"properties.floorType": "Пол",
|
||||||
"floor.CONCRETE": "Бетон",
|
"floor.CONCRETE": "Бетон",
|
||||||
"floor.WOOD_LIGHT": "Светлое дерево",
|
"floor.WOOD_LIGHT": "Светлое дерево",
|
||||||
@@ -210,6 +229,31 @@
|
|||||||
"floor.TILE_GRAY": "Серая плитка",
|
"floor.TILE_GRAY": "Серая плитка",
|
||||||
"floor.LAMINATE": "Ламинат",
|
"floor.LAMINATE": "Ламинат",
|
||||||
"properties.addNote": "Добавить заметку",
|
"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.stand": "Подставка",
|
||||||
"properties.openDirection": "Направление открытия",
|
"properties.openDirection": "Направление открытия",
|
||||||
"properties.openDir.LEFT": "Влево",
|
"properties.openDir.LEFT": "Влево",
|
||||||
@@ -229,6 +273,18 @@
|
|||||||
"electrical.cable": "Кабель",
|
"electrical.cable": "Кабель",
|
||||||
|
|
||||||
"furniture.title": "Мебель",
|
"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": "Длина кабеля:",
|
"cableLength.label": "Длина кабеля:",
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 29 KiB |
@@ -21,6 +21,8 @@ import type {
|
|||||||
BatchSyncOpeningsDto,
|
BatchSyncOpeningsDto,
|
||||||
BatchSyncElectricalDto,
|
BatchSyncElectricalDto,
|
||||||
BatchSyncFurnitureDto,
|
BatchSyncFurnitureDto,
|
||||||
|
BatchSyncAnnotationsDto,
|
||||||
|
Annotation,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
ApiListResponse,
|
ApiListResponse,
|
||||||
ApiErrorResponse,
|
ApiErrorResponse,
|
||||||
@@ -157,6 +159,13 @@ export async function deleteRoom(id: string): Promise<void> {
|
|||||||
await request<void>(`/rooms/${id}`, { method: 'DELETE' });
|
await request<void>(`/rooms/${id}`, { method: 'DELETE' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function cloneRoom(id: string): Promise<Room> {
|
||||||
|
const result = await request<ApiResponse<Room>>(`/rooms/${id}/clone`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Walls ──
|
// ── Walls ──
|
||||||
|
|
||||||
export async function bulkUpdateWalls(
|
export async function bulkUpdateWalls(
|
||||||
@@ -337,4 +346,18 @@ export async function batchSyncFurniture(
|
|||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function batchSyncAnnotations(
|
||||||
|
roomId: string,
|
||||||
|
data: BatchSyncAnnotationsDto,
|
||||||
|
): Promise<readonly Annotation[]> {
|
||||||
|
const result = await request<ApiListResponse<Annotation>>(
|
||||||
|
`/rooms/${roomId}/annotations/batch`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
export { ApiError };
|
export { ApiError };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Stage } from 'react-konva';
|
import { Stage, Layer } from 'react-konva';
|
||||||
import type Konva from 'konva';
|
import type Konva from 'konva';
|
||||||
import type { Point } from '@house-plan-maker/shared';
|
import type { Point } from '@house-plan-maker/shared';
|
||||||
import { useZoomPan, useSelection, useSceneData } from './context/EditorContext';
|
import { useZoomPan, useSelection, useSceneData } from './context/EditorContext';
|
||||||
@@ -31,6 +31,7 @@ import { FURNITURE_DEFS } from './symbols/furniture';
|
|||||||
import { AnnotationLayer } from './layers/AnnotationLayer';
|
import { AnnotationLayer } from './layers/AnnotationLayer';
|
||||||
import { MeasureOverlayLayer } from './layers/MeasureOverlayLayer';
|
import { MeasureOverlayLayer } from './layers/MeasureOverlayLayer';
|
||||||
import { generateLocalId } from './utils/geometry';
|
import { generateLocalId } from './utils/geometry';
|
||||||
|
import { TextPromptModal } from '../ui/TextPromptModal';
|
||||||
import type { EditorCommand, MeasurementState } from './types';
|
import type { EditorCommand, MeasurementState } from './types';
|
||||||
|
|
||||||
interface EditorCanvasProps {
|
interface EditorCanvasProps {
|
||||||
@@ -72,6 +73,7 @@ export function EditorCanvas({ width, height, onStageRef }: EditorCanvasProps) {
|
|||||||
selectedElectricalIndex,
|
selectedElectricalIndex,
|
||||||
selectedFurnitureIndex,
|
selectedFurnitureIndex,
|
||||||
annotations,
|
annotations,
|
||||||
|
globalFurnitureOpacity,
|
||||||
dispatch: sceneDispatch,
|
dispatch: sceneDispatch,
|
||||||
addOpening,
|
addOpening,
|
||||||
addElectrical,
|
addElectrical,
|
||||||
@@ -124,6 +126,13 @@ export function EditorCanvas({ width, height, onStageRef }: EditorCanvasProps) {
|
|||||||
// ── Opening placement preview ──
|
// ── Opening placement preview ──
|
||||||
const [openingPreview, setOpeningPreview] = useState<OpeningPreview | null>(null);
|
const [openingPreview, setOpeningPreview] = useState<OpeningPreview | null>(null);
|
||||||
|
|
||||||
|
// ── Annotation editing modal ──
|
||||||
|
const [editingAnnotationId, setEditingAnnotationId] = useState<string | null>(null);
|
||||||
|
const editingAnnotation = useMemo(
|
||||||
|
() => (editingAnnotationId ? annotations.find((a) => a.id === editingAnnotationId) ?? null : null),
|
||||||
|
[editingAnnotationId, annotations],
|
||||||
|
);
|
||||||
|
|
||||||
// ── Measurement tool state ��─
|
// ── Measurement tool state ��─
|
||||||
const [measureState, setMeasureState] = useState<MeasurementState | null>(null);
|
const [measureState, setMeasureState] = useState<MeasurementState | null>(null);
|
||||||
const isMeasuringRef = useRef(false);
|
const isMeasuringRef = useRef(false);
|
||||||
@@ -502,120 +511,135 @@ export function EditorCanvas({ width, height, onStageRef }: EditorCanvasProps) {
|
|||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
style={{ cursor, background: '#ffffff' }}
|
style={{ cursor, background: '#ffffff' }}
|
||||||
>
|
>
|
||||||
{/* Layer 1: Grid + rulers */}
|
{/*
|
||||||
<GridLayer
|
Konva renders one HTML <canvas> per <Layer>; performance recommends 3-5
|
||||||
zoom={zoom}
|
layers max. The previous tree had 10 Layers — one per logical group —
|
||||||
panOffset={panOffset}
|
which fired runtime warnings on Stage. We now collapse them into 3
|
||||||
stageWidth={width}
|
actual canvases (background, content, overlay) and use Group internally
|
||||||
stageHeight={height}
|
for each logical "layer". Visibility/listening props are preserved on
|
||||||
gridSize={gridSize}
|
the Group roots.
|
||||||
visible={gridVisible}
|
*/}
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Layer 2: Walls + room fill */}
|
{/* Background canvas — grid + rulers (rarely interacted with) */}
|
||||||
{layerVisibility.walls && (
|
<Layer listening={false}>
|
||||||
<WallLayer
|
<GridLayer
|
||||||
walls={walls}
|
|
||||||
roomShape={room.shape}
|
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
panOffset={panOffset}
|
panOffset={panOffset}
|
||||||
selectedIds={selectedIds}
|
stageWidth={width}
|
||||||
plinthThickness={room.plinthThickness}
|
stageHeight={height}
|
||||||
|
gridSize={gridSize}
|
||||||
|
visible={gridVisible}
|
||||||
/>
|
/>
|
||||||
)}
|
</Layer>
|
||||||
|
|
||||||
{/* Layer 3: Openings (doors + windows) */}
|
{/* Content canvas — room geometry, items, annotations, measurements */}
|
||||||
<OpeningLayer
|
<Layer>
|
||||||
openings={openings}
|
{layerVisibility.walls && (
|
||||||
walls={walls}
|
<WallLayer
|
||||||
roomShape={room.shape}
|
walls={walls}
|
||||||
zoom={zoom}
|
roomShape={room.shape}
|
||||||
panOffset={panOffset}
|
zoom={zoom}
|
||||||
selectedIds={selectedIds}
|
panOffset={panOffset}
|
||||||
preview={openingPreview}
|
selectedIds={selectedIds}
|
||||||
/>
|
plinthThickness={room.plinthThickness}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Layer 4: Electrical */}
|
<OpeningLayer
|
||||||
<ElectricalLayer
|
|
||||||
items={electricalItems}
|
|
||||||
zoom={zoom}
|
|
||||||
panOffset={panOffset}
|
|
||||||
selectedIds={selectedIds}
|
|
||||||
visible={layerVisibility.electrical}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Layer 5: Furniture */}
|
|
||||||
<FurnitureLayer
|
|
||||||
items={furnitureItems}
|
|
||||||
zoom={zoom}
|
|
||||||
panOffset={panOffset}
|
|
||||||
selectedIds={selectedIds}
|
|
||||||
visible={layerVisibility.furniture}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Layer 6: Measurements */}
|
|
||||||
{layerVisibility.measurements && (
|
|
||||||
<MeasurementLayer
|
|
||||||
walls={walls}
|
|
||||||
openings={openings}
|
openings={openings}
|
||||||
zoom={zoom}
|
walls={walls}
|
||||||
panOffset={panOffset}
|
|
||||||
selectedIds={selectedIds}
|
|
||||||
roomShape={room.shape}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Layer 7: Room labels */}
|
|
||||||
{layerVisibility.measurements && (
|
|
||||||
<RoomLabelLayer
|
|
||||||
roomName={room.name}
|
|
||||||
roomShape={room.shape}
|
roomShape={room.shape}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
panOffset={panOffset}
|
panOffset={panOffset}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
preview={openingPreview}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Layer 8: Annotations */}
|
<ElectricalLayer
|
||||||
{layerVisibility.annotations && (
|
items={electricalItems}
|
||||||
<AnnotationLayer
|
|
||||||
annotations={annotations}
|
|
||||||
electricalItems={electricalItems}
|
|
||||||
furnitureItems={furnitureItems}
|
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
panOffset={panOffset}
|
panOffset={panOffset}
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
onSelect={(id) => selectElement(id)}
|
visible={layerVisibility.electrical}
|
||||||
onDragEnd={(id, x, y) => {
|
outletWidth={room.outletWidth}
|
||||||
const ann = annotations.find((a) => a.id === id);
|
outletHeight={room.outletHeight}
|
||||||
if (ann) updateAnnotation({ ...ann, x, y });
|
|
||||||
}}
|
|
||||||
onDoubleClick={(id) => {
|
|
||||||
const ann = annotations.find((a) => a.id === id);
|
|
||||||
if (!ann) return;
|
|
||||||
const newText = window.prompt(t('annotation.editPrompt'), ann.text);
|
|
||||||
if (newText != null && newText !== ann.text) {
|
|
||||||
updateAnnotation({ ...ann, text: newText });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Layer 9: Measure overlay */}
|
<FurnitureLayer
|
||||||
<MeasureOverlayLayer
|
items={furnitureItems}
|
||||||
measurement={measureState}
|
zoom={zoom}
|
||||||
zoom={zoom}
|
panOffset={panOffset}
|
||||||
panOffset={panOffset}
|
selectedIds={selectedIds}
|
||||||
/>
|
visible={layerVisibility.furniture}
|
||||||
|
globalOpacity={globalFurnitureOpacity}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Layer 10: Selection overlay */}
|
{layerVisibility.measurements && (
|
||||||
<SelectionLayer
|
<MeasurementLayer
|
||||||
zoom={zoom}
|
walls={walls}
|
||||||
panOffset={panOffset}
|
openings={openings}
|
||||||
selectionBox={selBox}
|
zoom={zoom}
|
||||||
dragRect={dragRect}
|
panOffset={panOffset}
|
||||||
/>
|
selectedIds={selectedIds}
|
||||||
|
roomShape={room.shape}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{layerVisibility.measurements && (
|
||||||
|
<RoomLabelLayer
|
||||||
|
roomName={room.name}
|
||||||
|
roomShape={room.shape}
|
||||||
|
zoom={zoom}
|
||||||
|
panOffset={panOffset}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{layerVisibility.annotations && (
|
||||||
|
<AnnotationLayer
|
||||||
|
annotations={annotations}
|
||||||
|
electricalItems={electricalItems}
|
||||||
|
furnitureItems={furnitureItems}
|
||||||
|
zoom={zoom}
|
||||||
|
panOffset={panOffset}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
onSelect={(id) => selectElement(id)}
|
||||||
|
onDragEnd={(id, x, y) => {
|
||||||
|
const ann = annotations.find((a) => a.id === id);
|
||||||
|
if (ann) updateAnnotation({ ...ann, x, y });
|
||||||
|
}}
|
||||||
|
onDoubleClick={(id) => setEditingAnnotationId(id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Layer>
|
||||||
|
|
||||||
|
{/* Overlay canvas — transient interaction feedback (measure tool, selection) */}
|
||||||
|
<Layer listening={false}>
|
||||||
|
<MeasureOverlayLayer
|
||||||
|
measurement={measureState}
|
||||||
|
zoom={zoom}
|
||||||
|
panOffset={panOffset}
|
||||||
|
/>
|
||||||
|
<SelectionLayer
|
||||||
|
zoom={zoom}
|
||||||
|
panOffset={panOffset}
|
||||||
|
selectionBox={selBox}
|
||||||
|
dragRect={dragRect}
|
||||||
|
/>
|
||||||
|
</Layer>
|
||||||
</Stage>
|
</Stage>
|
||||||
<ScaleBar zoom={zoom} />
|
<ScaleBar zoom={zoom} />
|
||||||
|
<TextPromptModal
|
||||||
|
open={editingAnnotation != null}
|
||||||
|
title={t('annotation.editPrompt')}
|
||||||
|
initialValue={editingAnnotation?.text ?? ''}
|
||||||
|
onConfirm={(value) => {
|
||||||
|
if (editingAnnotation && value !== editingAnnotation.text) {
|
||||||
|
updateAnnotation({ ...editingAnnotation, text: value });
|
||||||
|
}
|
||||||
|
setEditingAnnotationId(null);
|
||||||
|
}}
|
||||||
|
onCancel={() => setEditingAnnotationId(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function EditorToolbar({ onSave, isSaving, onExport, onImport }: EditorTo
|
|||||||
const { state, setTool, setZoom, dispatch } = useEditor();
|
const { state, setTool, setZoom, dispatch } = useEditor();
|
||||||
const { undo, redo, canUndo, canRedo } = useUndoRedo();
|
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);
|
const zoomPercent = Math.round((zoom / 100) * 100);
|
||||||
|
|
||||||
@@ -193,6 +193,37 @@ export function EditorToolbar({ onSave, isSaving, onExport, onImport }: EditorTo
|
|||||||
>
|
>
|
||||||
{t('toolbar.meas')}
|
{t('toolbar.meas')}
|
||||||
</button>
|
</button>
|
||||||
|
<label
|
||||||
|
title={t('toolbar.furnitureOpacity') ?? 'Furniture opacity'}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
paddingLeft: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span aria-hidden style={{ fontSize: 14 }}>◐</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.05}
|
||||||
|
value={globalFurnitureOpacity}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = parseFloat(e.target.value);
|
||||||
|
if (Number.isFinite(next)) {
|
||||||
|
dispatch({ type: 'SET_GLOBAL_FURNITURE_OPACITY', opacity: next });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ width: 70 }}
|
||||||
|
aria-label={t('toolbar.furnitureOpacity') ?? 'Furniture opacity'}
|
||||||
|
/>
|
||||||
|
<span style={{ minWidth: 30, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
|
||||||
|
{Math.round(globalFurnitureOpacity * 100)}%
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Alignment tools — visible when 2+ items selected */}
|
{/* Alignment tools — visible when 2+ items selected */}
|
||||||
|
|||||||
@@ -1,21 +1,70 @@
|
|||||||
import { useMemo, useState, useCallback } from 'react';
|
import { useMemo, useState, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { Wall, WallOpening, ElectricalItem, FurnitureItem, DoorOpenDirection, FloorType } from '@house-plan-maker/shared';
|
import type { Wall, WallOpening, ElectricalItem, FurnitureItem, DoorOpenDirection, FloorType, WallFinish, Annotation, PositionAnchor, HorizontalAnchor, VerticalAnchor } from '@house-plan-maker/shared';
|
||||||
import { DOOR_OPEN_DIRECTIONS, FLOOR_TYPES } 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 { useEditor } from './context/EditorContext';
|
||||||
import { useUndoRedo } from './context/UndoRedoContext';
|
import { useUndoRedo } from './context/UndoRedoContext';
|
||||||
import { wallLength } from './utils/wallUtils';
|
import { wallLength } from './utils/wallUtils';
|
||||||
import { polygonArea, polygonPerimeter, generateLocalId } from './utils/geometry';
|
import { polygonArea, polygonPerimeter, generateLocalId } from './utils/geometry';
|
||||||
|
import { normalizeAngleDegrees } from './utils/angle';
|
||||||
import { getElectricalVariant, ELECTRICAL_SYMBOL_DEFS } from './symbols/electrical';
|
import { getElectricalVariant, ELECTRICAL_SYMBOL_DEFS } from './symbols/electrical';
|
||||||
|
import {
|
||||||
|
getCurtainLeftOpen,
|
||||||
|
getCurtainRightOpen,
|
||||||
|
getCurtainFabricColor,
|
||||||
|
} from './utils/curtainMetadata';
|
||||||
import type { EditorCommand } from './types';
|
import type { EditorCommand } from './types';
|
||||||
import styles from './properties-panel.module.css';
|
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() {
|
export function PropertiesPanel() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { state, dispatch, updateOpening, updateElectrical, updateFurniture, updateWall, addAnnotation } = useEditor();
|
const { state, dispatch, updateOpening, updateElectrical, updateFurniture, updateWall } = useEditor();
|
||||||
const { execute } = useUndoRedo();
|
const { execute } = useUndoRedo();
|
||||||
const { selectedIds, walls, openings, electricalItems, furnitureItems, room } = state;
|
const { selectedIds, walls, openings, electricalItems, furnitureItems, room } = state;
|
||||||
|
|
||||||
|
const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsed());
|
||||||
|
const toggleCollapsed = useCallback(() => {
|
||||||
|
setCollapsed((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
writeCollapsed(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const header = (
|
||||||
|
<div className={styles.header}>
|
||||||
|
<span>{t('properties.title')}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.collapseBtn}
|
||||||
|
onClick={toggleCollapsed}
|
||||||
|
title={t('properties.collapse')}
|
||||||
|
aria-label={t('properties.collapse')}
|
||||||
|
>
|
||||||
|
{'\u25B6'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const roomArea = useMemo(
|
const roomArea = useMemo(
|
||||||
() => room.shape.length >= 3 ? polygonArea(room.shape) : 0,
|
() => room.shape.length >= 3 ? polygonArea(room.shape) : 0,
|
||||||
[room.shape],
|
[room.shape],
|
||||||
@@ -57,10 +106,26 @@ export function PropertiesPanel() {
|
|||||||
return items;
|
return items;
|
||||||
}, [selectedIds, walls, openings, electricalItems, furnitureItems]);
|
}, [selectedIds, walls, openings, electricalItems, furnitureItems]);
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
|
return (
|
||||||
|
<div className={styles.panelCollapsed}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.collapseBtn}
|
||||||
|
onClick={toggleCollapsed}
|
||||||
|
title={t('properties.expand')}
|
||||||
|
aria-label={t('properties.expand')}
|
||||||
|
>
|
||||||
|
{'\u25C0'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (selected.length === 0) {
|
if (selected.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.panel}>
|
<div className={styles.panel}>
|
||||||
<div className={styles.header}>{t('properties.title')}</div>
|
{header}
|
||||||
<div className={styles.empty}>
|
<div className={styles.empty}>
|
||||||
<p className={styles.emptyText}>{t('properties.noSelection')}</p>
|
<p className={styles.emptyText}>{t('properties.noSelection')}</p>
|
||||||
<p className={styles.emptyHint}>{t('properties.selectHint')}</p>
|
<p className={styles.emptyHint}>{t('properties.selectHint')}</p>
|
||||||
@@ -85,6 +150,15 @@ export function PropertiesPanel() {
|
|||||||
}))}
|
}))}
|
||||||
onChange={(v) => dispatch({ type: 'UPDATE_ROOM_PROPS', props: { floorType: v } })}
|
onChange={(v) => dispatch({ type: 'UPDATE_ROOM_PROPS', props: { floorType: v } })}
|
||||||
/>
|
/>
|
||||||
|
<SelectPropertyRow<WallFinish>
|
||||||
|
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 } })}
|
||||||
|
/>
|
||||||
<div className={styles.row}>
|
<div className={styles.row}>
|
||||||
<span className={styles.rowLabel}>{t('properties.wallColor')}</span>
|
<span className={styles.rowLabel}>{t('properties.wallColor')}</span>
|
||||||
<input
|
<input
|
||||||
@@ -92,10 +166,39 @@ export function PropertiesPanel() {
|
|||||||
value={room.wallColor ?? '#f5f0eb'}
|
value={room.wallColor ?? '#f5f0eb'}
|
||||||
onChange={(e) => dispatch({ type: 'UPDATE_ROOM_PROPS', props: { wallColor: e.target.value } })}
|
onChange={(e) => dispatch({ type: 'UPDATE_ROOM_PROPS', props: { wallColor: e.target.value } })}
|
||||||
style={{ width: 32, height: 24, border: '1px solid var(--color-border)', borderRadius: 4, cursor: 'pointer', padding: 0 }}
|
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')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<PropertyRow label={t('properties.walls')} value={String(walls.length)} />
|
<PropertyRow label={t('properties.walls')} value={String(walls.length)} />
|
||||||
<PropertyRow label={t('properties.openings')} value={String(openings.length)} />
|
<PropertyRow label={t('properties.openings')} value={String(openings.length)} />
|
||||||
|
{/* Room-level outlet dimensions — used to draw outlet boundaries in
|
||||||
|
all views (2D/3D/projection). Stored in meters; edited in cm. */}
|
||||||
|
<EditablePropertyRow
|
||||||
|
label={t('properties.outletWidth')}
|
||||||
|
value={String(Math.round(room.outletWidth * 1000) / 10)}
|
||||||
|
unit="cm"
|
||||||
|
onCommit={(v) => {
|
||||||
|
const cm = parseFloat(v);
|
||||||
|
if (!isNaN(cm) && cm > 0 && cm <= 100) {
|
||||||
|
dispatch({ type: 'UPDATE_ROOM_PROPS', props: { outletWidth: cm / 100 } });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<EditablePropertyRow
|
||||||
|
label={t('properties.outletHeight')}
|
||||||
|
value={String(Math.round(room.outletHeight * 1000) / 10)}
|
||||||
|
unit="cm"
|
||||||
|
onCommit={(v) => {
|
||||||
|
const cm = parseFloat(v);
|
||||||
|
if (!isNaN(cm) && cm > 0 && cm <= 100) {
|
||||||
|
dispatch({ type: 'UPDATE_ROOM_PROPS', props: { outletHeight: cm / 100 } });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -104,7 +207,7 @@ export function PropertiesPanel() {
|
|||||||
if (selected.length > 1) {
|
if (selected.length > 1) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.panel}>
|
<div className={styles.panel}>
|
||||||
<div className={styles.header}>{t('properties.title')}</div>
|
{header}
|
||||||
<div className={styles.empty}>
|
<div className={styles.empty}>
|
||||||
<p className={styles.emptyText}>{t('properties.multipleSelected', { count: selected.length })}</p>
|
<p className={styles.emptyText}>{t('properties.multipleSelected', { count: selected.length })}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,7 +219,7 @@ export function PropertiesPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.panel}>
|
<div className={styles.panel}>
|
||||||
<div className={styles.header}>{t('properties.title')}</div>
|
{header}
|
||||||
{item.type === 'wall' && (
|
{item.type === 'wall' && (
|
||||||
<WallProperties
|
<WallProperties
|
||||||
wall={item.data as Wall}
|
wall={item.data as Wall}
|
||||||
@@ -174,39 +277,110 @@ export function PropertiesPanel() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* Add note button for any item */}
|
{/* Add note / edit attached annotations for any item */}
|
||||||
{(item.type === 'electrical' || item.type === 'furniture') && (
|
{(item.type === 'electrical' || item.type === 'furniture') && (
|
||||||
<div style={{ padding: '4px 8px' }}>
|
<ItemAnnotationManager itemId={item.data.id} roomId={room.id} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<div style={{ padding: '4px 8px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{attached.map((ann) => (
|
||||||
|
<div
|
||||||
|
key={ann.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
padding: '2px 6px',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 4,
|
||||||
|
background: 'var(--color-bg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{ann.text}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
style={{
|
type="button"
|
||||||
width: '100%',
|
style={{ fontSize: 11, padding: '0 4px' }}
|
||||||
padding: '4px 8px',
|
onClick={() => setEditing({ kind: 'edit', annotation: ann })}
|
||||||
fontSize: '12px',
|
aria-label={t('annotation.edit') ?? 'Edit'}
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
borderRadius: '4px',
|
|
||||||
background: 'var(--color-bg)',
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
const text = window.prompt(t('annotation.editPrompt'), '');
|
|
||||||
if (text) {
|
|
||||||
addAnnotation({
|
|
||||||
id: generateLocalId(),
|
|
||||||
roomId: room.id,
|
|
||||||
x: 0.3,
|
|
||||||
y: -0.2,
|
|
||||||
text,
|
|
||||||
fontSize: 12,
|
|
||||||
attachedToId: item.data.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t('properties.addNote')}
|
✎
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={{ fontSize: 11, padding: '0 4px' }}
|
||||||
|
onClick={() => removeAnnotation(ann.id)}
|
||||||
|
aria-label={t('annotation.delete') ?? 'Delete'}
|
||||||
|
>
|
||||||
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '4px 8px',
|
||||||
|
fontSize: 12,
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 4,
|
||||||
|
background: 'var(--color-bg)',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={() => setEditing({ kind: 'add' })}
|
||||||
|
>
|
||||||
|
{t('properties.addNote')}
|
||||||
|
</button>
|
||||||
|
<TextPromptModal
|
||||||
|
open={editing != null}
|
||||||
|
title={t('annotation.editPrompt')}
|
||||||
|
initialValue={editing?.kind === 'edit' ? editing.annotation.text : ''}
|
||||||
|
onConfirm={(value) => {
|
||||||
|
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)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -260,9 +434,35 @@ interface OpeningPropertiesProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function OpeningProperties({ opening, walls, onUpdate }: OpeningPropertiesProps) {
|
function OpeningProperties({ opening, walls, onUpdate }: OpeningPropertiesProps) {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const wall = walls.find((w) => w.id === opening.wallId);
|
const wall = walls.find((w) => w.id === opening.wallId);
|
||||||
const wLen = wall ? wallLength(wall) : 0;
|
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(
|
const handleWidthChange = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
@@ -284,31 +484,50 @@ function OpeningProperties({ opening, walls, onUpdate }: OpeningPropertiesProps)
|
|||||||
[opening, onUpdate],
|
[opening, onUpdate],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Position displayed as left edge offset, stored as center
|
// Convert canonical (center along wall, bottom from floor) into the value
|
||||||
const displayPosition = Math.round((opening.positionAlongWall - opening.width / 2) * 1000) / 1000;
|
// 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(
|
const handlePositionChange = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
const num = parseFloat(value);
|
const num = parseFloat(value);
|
||||||
if (!isNaN(num) && num >= 0) {
|
if (!isNaN(num)) {
|
||||||
// Convert left edge offset back to center position
|
// Convert anchored value back to canonical center position.
|
||||||
const centerPos = num + opening.width / 2;
|
let centerPos = num;
|
||||||
if (centerPos <= wLen) {
|
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 });
|
onUpdate({ ...opening, positionAlongWall: centerPos });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[opening, onUpdate, wLen],
|
[opening, onUpdate, wLen, anchor.horizontal],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleElevationChange = useCallback(
|
const handleElevationChange = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
const num = parseFloat(value);
|
const num = parseFloat(value);
|
||||||
if (!isNaN(num) && num >= 0) {
|
if (!isNaN(num)) {
|
||||||
onUpdate({ ...opening, elevationFromFloor: 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(
|
const handleOpenDirectionChange = useCallback(
|
||||||
@@ -337,10 +556,27 @@ function OpeningProperties({ opening, walls, onUpdate }: OpeningPropertiesProps)
|
|||||||
/>
|
/>
|
||||||
<EditablePropertyRow
|
<EditablePropertyRow
|
||||||
label={t('properties.position')}
|
label={t('properties.position')}
|
||||||
value={String(Math.max(0, displayPosition))}
|
value={String(displayPosition)}
|
||||||
unit="m"
|
unit="m"
|
||||||
onCommit={handlePositionChange}
|
onCommit={handlePositionChange}
|
||||||
/>
|
/>
|
||||||
|
<PositionAnchorEditor
|
||||||
|
anchor={anchor}
|
||||||
|
onChange={(positionAnchor) => 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. */}
|
||||||
|
<EditablePropertyRow
|
||||||
|
label={frameThicknessLabel}
|
||||||
|
value={String(opening.frameThickness)}
|
||||||
|
unit="m"
|
||||||
|
onCommit={(v) => {
|
||||||
|
const n = parseFloat(v);
|
||||||
|
if (!isNaN(n) && n >= 0) {
|
||||||
|
onUpdate({ ...opening, frameThickness: Math.min(n, maxFrameThickness) });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{opening.type === 'DOOR' && (
|
{opening.type === 'DOOR' && (
|
||||||
<SelectPropertyRow
|
<SelectPropertyRow
|
||||||
label={t('properties.openDirection')}
|
label={t('properties.openDirection')}
|
||||||
@@ -353,12 +589,54 @@ function OpeningProperties({ opening, walls, onUpdate }: OpeningPropertiesProps)
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{opening.type === 'WINDOW' && (
|
{opening.type === 'WINDOW' && (
|
||||||
<EditablePropertyRow
|
<>
|
||||||
label={t('properties.elevation')}
|
<EditablePropertyRow
|
||||||
value={String(opening.elevationFromFloor)}
|
label={t('properties.elevation')}
|
||||||
unit="m"
|
value={String(displayElevation)}
|
||||||
onCommit={handleElevationChange}
|
unit="m"
|
||||||
/>
|
onCommit={handleElevationChange}
|
||||||
|
/>
|
||||||
|
{/* 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. */}
|
||||||
|
<EditablePropertyRow
|
||||||
|
label={gridColsLabel}
|
||||||
|
value={String(opening.gridCols)}
|
||||||
|
onCommit={(v) => {
|
||||||
|
const n = parseInt(v, 10);
|
||||||
|
if (!isNaN(n) && n >= 1 && n <= 10) {
|
||||||
|
onUpdate({ ...opening, gridCols: n });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<EditablePropertyRow
|
||||||
|
label={gridRowsLabel}
|
||||||
|
value={String(opening.gridRows)}
|
||||||
|
onCommit={(v) => {
|
||||||
|
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. */}
|
||||||
|
<EditablePropertyRow
|
||||||
|
label={slopeDepthLabel}
|
||||||
|
value={String(opening.slopeDepth)}
|
||||||
|
unit="m"
|
||||||
|
onCommit={(v) => {
|
||||||
|
const n = parseFloat(v);
|
||||||
|
if (!isNaN(n) && n >= 0) {
|
||||||
|
onUpdate({ ...opening, slopeDepth: Math.min(n, maxSlopeDepth) });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{wall && (
|
{wall && (
|
||||||
<PropertyRow label={t('properties.wallLength')} value={formatM(wLen)} />
|
<PropertyRow label={t('properties.wallLength')} value={formatM(wLen)} />
|
||||||
@@ -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 (
|
||||||
|
<div className={styles.row}>
|
||||||
|
<span className={styles.rowLabel}>{label}</span>
|
||||||
|
<span style={{ display: 'flex', gap: 4, flex: 1, justifyContent: 'flex-end' }}>
|
||||||
|
<select
|
||||||
|
className={styles.selectInput}
|
||||||
|
value={anchor.horizontal}
|
||||||
|
onChange={(e) => onChange({ ...anchor, horizontal: e.target.value as HorizontalAnchor })}
|
||||||
|
style={{ flex: 1, minWidth: 0 }}
|
||||||
|
>
|
||||||
|
{HORIZONTAL_ANCHORS.map((h) => (
|
||||||
|
<option key={h} value={h}>{labelFor(h)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className={styles.selectInput}
|
||||||
|
value={anchor.vertical}
|
||||||
|
onChange={(e) => onChange({ ...anchor, vertical: e.target.value as VerticalAnchor })}
|
||||||
|
style={{ flex: 1, minWidth: 0 }}
|
||||||
|
>
|
||||||
|
{VERTICAL_ANCHORS.map((v) => (
|
||||||
|
<option key={v} value={v}>{labelFor(v)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Select Property Row ──
|
// ── Select Property Row ──
|
||||||
|
|
||||||
interface SelectPropertyRowProps<T extends string> {
|
interface SelectPropertyRowProps<T extends string> {
|
||||||
@@ -458,6 +782,65 @@ interface SelectPropertyRowProps<T extends string> {
|
|||||||
readonly onChange: (value: T) => void;
|
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 (
|
||||||
|
<div className={styles.row}>
|
||||||
|
<span className={styles.rowLabel}>{label}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.editInput}
|
||||||
|
value={draft}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onFocus={() => 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 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SelectPropertyRow<T extends string>({ label, value, options, onChange }: SelectPropertyRowProps<T>) {
|
function SelectPropertyRow<T extends string>({ label, value, options, onChange }: SelectPropertyRowProps<T>) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.row}>
|
<div className={styles.row}>
|
||||||
@@ -485,13 +868,15 @@ interface ElectricalPropertiesProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ElectricalProperties({ item, onUpdate }: ElectricalPropertiesProps) {
|
function ElectricalProperties({ item, onUpdate }: ElectricalPropertiesProps) {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const variant = getElectricalVariant(item.metadata);
|
const variant = getElectricalVariant(item.metadata);
|
||||||
const def = ELECTRICAL_SYMBOL_DEFS.find(
|
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 isWallMounted = item.wallId !== null;
|
||||||
|
const isOutlet = item.type === 'OUTLET';
|
||||||
|
const countLabel = i18n.exists('properties.outletCount') ? t('properties.outletCount') : 'Count';
|
||||||
|
|
||||||
const handleXChange = useCallback(
|
const handleXChange = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
@@ -512,7 +897,7 @@ function ElectricalProperties({ item, onUpdate }: ElectricalPropertiesProps) {
|
|||||||
const handleRotationChange = useCallback(
|
const handleRotationChange = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
const num = parseFloat(value);
|
const num = parseFloat(value);
|
||||||
if (!isNaN(num)) onUpdate({ ...item, rotation: num % 360 });
|
if (!isNaN(num)) onUpdate({ ...item, rotation: normalizeAngleDegrees(num) });
|
||||||
},
|
},
|
||||||
[item, onUpdate],
|
[item, onUpdate],
|
||||||
);
|
);
|
||||||
@@ -527,16 +912,38 @@ function ElectricalProperties({ item, onUpdate }: ElectricalPropertiesProps) {
|
|||||||
[item, onUpdate],
|
[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 (
|
return (
|
||||||
<div className={styles.section}>
|
<div className={styles.section}>
|
||||||
<div className={styles.sectionTitle}>
|
<div className={styles.sectionTitle}>{displayTitle}</div>
|
||||||
{def?.label ?? item.type}
|
|
||||||
</div>
|
|
||||||
<PropertyRow label={t('properties.type')} value={item.type} />
|
<PropertyRow label={t('properties.type')} value={item.type} />
|
||||||
{variant !== 'single' && <PropertyRow label={t('properties.variant')} value={variant} />}
|
{!isOutlet && variant !== 'single' && <PropertyRow label={t('properties.variant')} value={variant} />}
|
||||||
|
<LabelOverrideRow
|
||||||
|
value={item.label}
|
||||||
|
placeholder={labelPlaceholder}
|
||||||
|
onChange={(label) => onUpdate({ ...item, label })}
|
||||||
|
/>
|
||||||
|
{isOutlet && (
|
||||||
|
<EditablePropertyRow
|
||||||
|
label={countLabel}
|
||||||
|
value={String(item.count)}
|
||||||
|
onCommit={(v) => {
|
||||||
|
const n = parseInt(v, 10);
|
||||||
|
if (!isNaN(n) && n >= 1 && n <= 20) onUpdate({ ...item, count: n });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<EditablePropertyRow label={t('properties.x')} value={String(Math.round(item.x * 1000) / 1000)} unit="m" onCommit={handleXChange} />
|
<EditablePropertyRow label={t('properties.x')} value={String(Math.round(item.x * 1000) / 1000)} unit="m" onCommit={handleXChange} />
|
||||||
<EditablePropertyRow label={t('properties.y')} value={String(Math.round(item.y * 1000) / 1000)} unit="m" onCommit={handleYChange} />
|
<EditablePropertyRow label={t('properties.y')} value={String(Math.round(item.y * 1000) / 1000)} unit="m" onCommit={handleYChange} />
|
||||||
<EditablePropertyRow label={t('properties.rotation')} value={String(Math.round(item.rotation))} unit={"\u00b0"} onCommit={handleRotationChange} />
|
<EditablePropertyRow label={t('properties.rotation')} value={String(Math.round(item.rotation))} unit={"\u00b0"} onCommit={handleRotationChange} />
|
||||||
|
<PositionAnchorEditor
|
||||||
|
anchor={item.positionAnchor}
|
||||||
|
onChange={(positionAnchor) => onUpdate({ ...item, positionAnchor })}
|
||||||
|
/>
|
||||||
{isWallMounted && (
|
{isWallMounted && (
|
||||||
<>
|
<>
|
||||||
<PropertyRow label={t('properties.wallMounted')} value={t('properties.yes')} />
|
<PropertyRow label={t('properties.wallMounted')} value={t('properties.yes')} />
|
||||||
@@ -559,8 +966,34 @@ interface FurniturePropertiesProps {
|
|||||||
readonly onUpdate: (item: FurnitureItem) => void;
|
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) {
|
function FurnitureProperties({ item, onUpdate }: FurniturePropertiesProps) {
|
||||||
const { t } = useTranslation();
|
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(
|
const handleXChange = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
@@ -613,7 +1046,7 @@ function FurnitureProperties({ item, onUpdate }: FurniturePropertiesProps) {
|
|||||||
const handleRotationChange = useCallback(
|
const handleRotationChange = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
const num = parseFloat(value);
|
const num = parseFloat(value);
|
||||||
if (!isNaN(num)) onUpdate({ ...item, rotation: num % 360 });
|
if (!isNaN(num)) onUpdate({ ...item, rotation: normalizeAngleDegrees(num) });
|
||||||
},
|
},
|
||||||
[item, onUpdate],
|
[item, onUpdate],
|
||||||
);
|
);
|
||||||
@@ -621,9 +1054,16 @@ function FurnitureProperties({ item, onUpdate }: FurniturePropertiesProps) {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.section}>
|
<div className={styles.section}>
|
||||||
<div className={styles.sectionTitle}>
|
<div className={styles.sectionTitle}>
|
||||||
{item.label ?? item.type}
|
{displayLabel ?? item.type}
|
||||||
</div>
|
</div>
|
||||||
<PropertyRow label={t('properties.type')} value={item.type} />
|
<PropertyRow label={t('properties.type')} value={item.type} />
|
||||||
|
<LabelOverrideRow
|
||||||
|
value={displayLabel}
|
||||||
|
placeholder={labelPlaceholder}
|
||||||
|
onChange={(newDisplay) =>
|
||||||
|
onUpdate({ ...item, label: preserveFurnitureMarkers(item.label, newDisplay) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
<EditablePropertyRow label={t('properties.x')} value={String(Math.round(item.x * 1000) / 1000)} unit="m" onCommit={handleXChange} />
|
<EditablePropertyRow label={t('properties.x')} value={String(Math.round(item.x * 1000) / 1000)} unit="m" onCommit={handleXChange} />
|
||||||
<EditablePropertyRow label={t('properties.y')} value={String(Math.round(item.y * 1000) / 1000)} unit="m" onCommit={handleYChange} />
|
<EditablePropertyRow label={t('properties.y')} value={String(Math.round(item.y * 1000) / 1000)} unit="m" onCommit={handleYChange} />
|
||||||
<EditablePropertyRow label={t('properties.width')} value={String(item.width)} unit="m" onCommit={handleWidthChange} />
|
<EditablePropertyRow label={t('properties.width')} value={String(item.width)} unit="m" onCommit={handleWidthChange} />
|
||||||
@@ -631,6 +1071,14 @@ function FurnitureProperties({ item, onUpdate }: FurniturePropertiesProps) {
|
|||||||
<EditablePropertyRow label={t('properties.height')} value={String(item.height)} unit="m" onCommit={handleHeightChange} />
|
<EditablePropertyRow label={t('properties.height')} value={String(item.height)} unit="m" onCommit={handleHeightChange} />
|
||||||
<EditablePropertyRow label={t('properties.elevation')} value={String(Math.round(item.elevationFromFloor * 1000) / 1000)} unit="m" onCommit={handleElevationChange} />
|
<EditablePropertyRow label={t('properties.elevation')} value={String(Math.round(item.elevationFromFloor * 1000) / 1000)} unit="m" onCommit={handleElevationChange} />
|
||||||
<EditablePropertyRow label={t('properties.rotation')} value={String(Math.round(item.rotation))} unit={"\u00b0"} onCommit={handleRotationChange} />
|
<EditablePropertyRow label={t('properties.rotation')} value={String(Math.round(item.rotation))} unit={"\u00b0"} onCommit={handleRotationChange} />
|
||||||
|
<PositionAnchorEditor
|
||||||
|
anchor={item.positionAnchor}
|
||||||
|
onChange={(positionAnchor) => onUpdate({ ...item, positionAnchor })}
|
||||||
|
/>
|
||||||
|
<FurnitureOpacitySlider item={item} />
|
||||||
|
<FurnitureProjectionToggle item={item} />
|
||||||
|
{item.type === 'CURTAIN' && <CurtainControls item={item} onUpdate={onUpdate} />}
|
||||||
|
{item.type === 'BOOKCASE' && <BookcaseControls item={item} onUpdate={onUpdate} />}
|
||||||
{item.type === 'TV' && (
|
{item.type === 'TV' && (
|
||||||
<div className={styles.row}>
|
<div className={styles.row}>
|
||||||
<span className={styles.rowLabel}>{t('properties.stand')}</span>
|
<span className={styles.rowLabel}>{t('properties.stand')}</span>
|
||||||
@@ -651,6 +1099,247 @@ function FurnitureProperties({ item, onUpdate }: FurniturePropertiesProps) {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{item.type === 'DIGITAL_PIANO' && (
|
||||||
|
<div className={styles.row}>
|
||||||
|
<span className={styles.rowLabel}>{t('properties.stand')}</span>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
// Missing value means "default on" — matches the preset default.
|
||||||
|
checked={(item.metadata?.['hasStand'] as boolean | undefined) ?? true}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = { ...(item.metadata ?? {}), hasStand: e.target.checked };
|
||||||
|
onUpdate({ ...item, metadata: next });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{t('properties.yes')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className={styles.row}>
|
||||||
|
<span className={styles.rowLabel}>{label}</span>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 6, flex: 1, justifyContent: 'flex-end' }}>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.05}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = parseFloat(e.target.value);
|
||||||
|
updateFurniture({ ...item, opacity: Number.isFinite(next) ? next : 1 });
|
||||||
|
}}
|
||||||
|
style={{ width: 100 }}
|
||||||
|
/>
|
||||||
|
<span style={{ minWidth: 32, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
|
||||||
|
{Math.round(value * 100)}%
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className={styles.row}>
|
||||||
|
<span className={styles.rowLabel}>{label}</span>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={() => updateFurniture({ ...item, showProjection: !enabled })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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<string, unknown>) => {
|
||||||
|
// 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 (
|
||||||
|
<>
|
||||||
|
<CurtainOpenSlider
|
||||||
|
label={leftLabel}
|
||||||
|
value={leftOpen}
|
||||||
|
onChange={(v) => updateMetadata({ leftOpen: v })}
|
||||||
|
/>
|
||||||
|
<CurtainOpenSlider
|
||||||
|
label={rightLabel}
|
||||||
|
value={rightOpen}
|
||||||
|
onChange={(v) => updateMetadata({ rightOpen: v })}
|
||||||
|
/>
|
||||||
|
<div className={styles.row}>
|
||||||
|
<span className={styles.rowLabel}>{colorLabel}</span>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={fabricColor}
|
||||||
|
onChange={(e) => updateMetadata({ fabricColor: e.target.value })}
|
||||||
|
style={{ width: 32, height: 24, border: '1px solid var(--color-border)', borderRadius: 4, cursor: 'pointer', padding: 0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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<string, unknown>) => {
|
||||||
|
const next = { ...(item.metadata ?? {}), ...patch };
|
||||||
|
onUpdate({ ...item, metadata: next });
|
||||||
|
},
|
||||||
|
[item, onUpdate],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EditablePropertyRow
|
||||||
|
label={rowsLabel}
|
||||||
|
value={String(currentRows)}
|
||||||
|
onCommit={(v) => {
|
||||||
|
const n = parseInt(v, 10);
|
||||||
|
if (!isNaN(n) && n >= 1 && n <= 12) {
|
||||||
|
updateMetadata({ shelfRows: n });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className={styles.row}>
|
||||||
|
<span className={styles.rowLabel}>{backPanelLabel}</span>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hasBack}
|
||||||
|
onChange={(e) => updateMetadata({ hasBackPanel: e.target.checked })}
|
||||||
|
/>
|
||||||
|
{t('properties.yes')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CurtainOpenSliderProps {
|
||||||
|
readonly label: string;
|
||||||
|
readonly value: number;
|
||||||
|
readonly onChange: (value: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CurtainOpenSlider({ label, value, onChange }: CurtainOpenSliderProps) {
|
||||||
|
return (
|
||||||
|
<div className={styles.row}>
|
||||||
|
<span className={styles.rowLabel}>{label}</span>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 6, flex: 1, justifyContent: 'flex-end' }}>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.05}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = parseFloat(e.target.value);
|
||||||
|
if (Number.isFinite(next)) onChange(next);
|
||||||
|
}}
|
||||||
|
style={{ width: 100 }}
|
||||||
|
/>
|
||||||
|
<span style={{ minWidth: 32, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
|
||||||
|
{Math.round(value * 100)}%
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type Konva from 'konva';
|
|||||||
import { useEditor } from './context/EditorContext';
|
import { useEditor } from './context/EditorContext';
|
||||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
|
||||||
import { boundingBox } from './utils/geometry';
|
import { boundingBox } from './utils/geometry';
|
||||||
|
import { normalizeAngleDegrees } from './utils/angle';
|
||||||
import { EditorCanvas } from './EditorCanvas';
|
import { EditorCanvas } from './EditorCanvas';
|
||||||
import { EditorToolbar } from './EditorToolbar';
|
import { EditorToolbar } from './EditorToolbar';
|
||||||
import { PropertiesPanel } from './PropertiesPanel';
|
import { PropertiesPanel } from './PropertiesPanel';
|
||||||
@@ -24,6 +25,8 @@ import {
|
|||||||
batchSyncOpenings,
|
batchSyncOpenings,
|
||||||
batchSyncElectrical,
|
batchSyncElectrical,
|
||||||
batchSyncFurniture,
|
batchSyncFurniture,
|
||||||
|
batchSyncAnnotations,
|
||||||
|
updateRoom,
|
||||||
} from '../../api/client';
|
} from '../../api/client';
|
||||||
import type {
|
import type {
|
||||||
CreateWallOpeningDto,
|
CreateWallOpeningDto,
|
||||||
@@ -32,6 +35,8 @@ import type {
|
|||||||
UpdateElectricalItemDto,
|
UpdateElectricalItemDto,
|
||||||
CreateFurnitureItemDto,
|
CreateFurnitureItemDto,
|
||||||
UpdateFurnitureItemDto,
|
UpdateFurnitureItemDto,
|
||||||
|
CreateAnnotationDto,
|
||||||
|
UpdateAnnotationDto,
|
||||||
} from '@house-plan-maker/shared';
|
} from '@house-plan-maker/shared';
|
||||||
import styles from './room-editor-layout.module.css';
|
import styles from './room-editor-layout.module.css';
|
||||||
|
|
||||||
@@ -51,7 +56,10 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
|||||||
const [viewMode, setViewMode] = useState<ViewMode>('2d');
|
const [viewMode, setViewMode] = useState<ViewMode>('2d');
|
||||||
const [showExport, setShowExport] = useState(false);
|
const [showExport, setShowExport] = useState(false);
|
||||||
const canvasContainerRef = useRef<HTMLDivElement>(null);
|
const canvasContainerRef = useRef<HTMLDivElement>(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 ──
|
// ── Dirty tracking ──
|
||||||
const [isDirty, setIsDirty] = useState(false);
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
@@ -60,6 +68,7 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
|||||||
openings: state.openings,
|
openings: state.openings,
|
||||||
electricalItems: state.electricalItems,
|
electricalItems: state.electricalItems,
|
||||||
furnitureItems: state.furnitureItems,
|
furnitureItems: state.furnitureItems,
|
||||||
|
room: state.room,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mark dirty when state diverges from last saved snapshot
|
// Mark dirty when state diverges from last saved snapshot
|
||||||
@@ -69,9 +78,33 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
|||||||
state.walls !== saved.walls ||
|
state.walls !== saved.walls ||
|
||||||
state.openings !== saved.openings ||
|
state.openings !== saved.openings ||
|
||||||
state.electricalItems !== saved.electricalItems ||
|
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);
|
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
|
// Warn on browser close / refresh
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -123,10 +156,21 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
|||||||
const container = canvasContainerRef.current;
|
const container = canvasContainerRef.current;
|
||||||
if (!container) return;
|
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) => {
|
const observer = new ResizeObserver((entries) => {
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const { width, height } = entry.contentRect;
|
commitSize(entry.contentRect.width, entry.contentRect.height);
|
||||||
setCanvasSize({ width: Math.floor(width), height: Math.floor(height) });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -134,15 +178,23 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
|||||||
|
|
||||||
// Initial size
|
// Initial size
|
||||||
const rect = container.getBoundingClientRect();
|
const rect = container.getBoundingClientRect();
|
||||||
setCanvasSize({ width: Math.floor(rect.width), height: Math.floor(rect.height) });
|
commitSize(rect.width, rect.height);
|
||||||
|
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ── Center room in canvas on first mount ──
|
// ── Auto-fit the room into the 2D canvas ──
|
||||||
const hasCenteredRef = useRef(false);
|
// 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<string>('');
|
||||||
|
const lastDispatchedViewRef = useRef<{ zoom: number; panX: number; panY: number } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasCenteredRef.current) return;
|
if (viewMode !== '2d') return;
|
||||||
|
if (!canvasSize) return;
|
||||||
if (canvasSize.width <= 100 || canvasSize.height <= 100) return;
|
if (canvasSize.width <= 100 || canvasSize.height <= 100) return;
|
||||||
if (state.room.shape.length === 0) return;
|
if (state.room.shape.length === 0) return;
|
||||||
|
|
||||||
@@ -151,7 +203,14 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
|||||||
const roomH = bbox.maxY - bbox.minY;
|
const roomH = bbox.maxY - bbox.minY;
|
||||||
if (roomW <= 0 || roomH <= 0) return;
|
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 padding = 80;
|
||||||
const scaleX = (canvasSize.width - padding * 2) / roomW;
|
const scaleX = (canvasSize.width - padding * 2) / roomW;
|
||||||
const scaleY = (canvasSize.height - padding * 2) / roomH;
|
const scaleY = (canvasSize.height - padding * 2) / roomH;
|
||||||
@@ -162,10 +221,27 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
|||||||
const panX = canvasSize.width / 2 - centerX * fitZoom;
|
const panX = canvasSize.width / 2 - centerX * fitZoom;
|
||||||
const panY = canvasSize.height / 2 - centerY * fitZoom;
|
const panY = canvasSize.height / 2 - centerY * fitZoom;
|
||||||
|
|
||||||
dispatch({ type: 'SET_ZOOM', zoom: fitZoom });
|
lastDispatchedViewRef.current = { zoom: fitZoom, panX, panY };
|
||||||
dispatch({ type: 'SET_PAN_OFFSET', offset: { x: panX, y: panY } });
|
// Single atomic reducer pass — produces one new state, not two, so the
|
||||||
hasCenteredRef.current = true;
|
// ZoomPanContext can't emit an intermediate (newZoom, oldPan) frame.
|
||||||
}, [canvasSize, state.room.shape, dispatch]);
|
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 ──
|
// ── Re-measure canvas when switching back to 2D view ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -192,6 +268,19 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
|||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
|
|
||||||
try {
|
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
|
// 1. Save walls first (bulk replace) to get server-assigned wall IDs
|
||||||
const wallDtos = state.walls.map((w) => ({
|
const wallDtos = state.walls.map((w) => ({
|
||||||
startX: w.startX,
|
startX: w.startX,
|
||||||
@@ -237,6 +326,11 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
|||||||
height: opening.height,
|
height: opening.height,
|
||||||
elevationFromFloor: opening.elevationFromFloor,
|
elevationFromFloor: opening.elevationFromFloor,
|
||||||
openDirection: opening.openDirection,
|
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
|
// No updates or deletes needed — cascade already removed all server openings
|
||||||
@@ -255,7 +349,10 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
|||||||
y: elec.y,
|
y: elec.y,
|
||||||
wallId: serverWallId,
|
wallId: serverWallId,
|
||||||
elevationFromFloor: elec.elevationFromFloor,
|
elevationFromFloor: elec.elevationFromFloor,
|
||||||
rotation: elec.rotation,
|
rotation: normalizeAngleDegrees(elec.rotation ?? 0),
|
||||||
|
count: elec.count,
|
||||||
|
positionAnchor: elec.positionAnchor,
|
||||||
|
label: elec.label,
|
||||||
metadata: elec.metadata,
|
metadata: elec.metadata,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -280,10 +377,19 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
|||||||
rotation: furn.rotation,
|
rotation: furn.rotation,
|
||||||
elevationFromFloor: furn.elevationFromFloor,
|
elevationFromFloor: furn.elevationFromFloor,
|
||||||
label: furn.label,
|
label: furn.label,
|
||||||
|
showProjection: furn.showProjection ?? false,
|
||||||
|
opacity: furn.opacity ?? 1,
|
||||||
|
positionAnchor: furn.positionAnchor,
|
||||||
|
metadata: furn.metadata ?? null,
|
||||||
});
|
});
|
||||||
} else if (serverFurnIds.has(furn.id)) {
|
} else if (serverFurnIds.has(furn.id)) {
|
||||||
const serverFurn = freshRoom.furnitureItems.find((f) => f.id === furn.id);
|
const serverFurn = freshRoom.furnitureItems.find((f) => f.id === furn.id);
|
||||||
if (serverFurn) {
|
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 =
|
const hasChanges =
|
||||||
serverFurn.x !== furn.x ||
|
serverFurn.x !== furn.x ||
|
||||||
serverFurn.y !== furn.y ||
|
serverFurn.y !== furn.y ||
|
||||||
@@ -292,7 +398,11 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
|||||||
serverFurn.height !== furn.height ||
|
serverFurn.height !== furn.height ||
|
||||||
serverFurn.rotation !== furn.rotation ||
|
serverFurn.rotation !== furn.rotation ||
|
||||||
serverFurn.elevationFromFloor !== furn.elevationFromFloor ||
|
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) {
|
if (hasChanges) {
|
||||||
furnUpdate.push({
|
furnUpdate.push({
|
||||||
id: furn.id,
|
id: furn.id,
|
||||||
@@ -303,9 +413,13 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
|||||||
width: furn.width,
|
width: furn.width,
|
||||||
depth: furn.depth,
|
depth: furn.depth,
|
||||||
height: furn.height,
|
height: furn.height,
|
||||||
rotation: furn.rotation,
|
rotation: normalizeAngleDegrees(furn.rotation ?? 0),
|
||||||
elevationFromFloor: furn.elevationFromFloor,
|
elevationFromFloor: furn.elevationFromFloor,
|
||||||
label: furn.label,
|
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([
|
const [syncedOpenings, syncedElectrical, syncedFurniture] = await Promise.all([
|
||||||
batchSyncOpenings(roomId, {
|
batchSyncOpenings(roomId, {
|
||||||
create: openingsCreate,
|
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<string, string>();
|
||||||
|
|
||||||
|
for (const [localId, serverId] of wallIdMap) {
|
||||||
|
idMap.set(localId, serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const consumedOpenings = new Set<string>();
|
||||||
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
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({
|
dispatch({
|
||||||
type: 'SYNC_SAVE',
|
type: 'SYNC_SAVE',
|
||||||
walls: serverWalls,
|
walls: serverWalls,
|
||||||
openings: syncedOpenings,
|
openings: syncedOpenings,
|
||||||
electricalItems: syncedElectrical,
|
electricalItems: syncedElectrical,
|
||||||
furnitureItems: syncedFurniture,
|
furnitureItems: syncedFurniture,
|
||||||
|
annotations: syncedAnnotations,
|
||||||
|
idMap,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mark state as clean after successful save
|
// Mark state as clean after successful save
|
||||||
@@ -353,6 +588,7 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
|||||||
openings: syncedOpenings,
|
openings: syncedOpenings,
|
||||||
electricalItems: syncedElectrical,
|
electricalItems: syncedElectrical,
|
||||||
furnitureItems: syncedFurniture,
|
furnitureItems: syncedFurniture,
|
||||||
|
room: state.room,
|
||||||
};
|
};
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : t('editor.error.load');
|
const message = err instanceof Error ? err.message : t('editor.error.load');
|
||||||
@@ -361,7 +597,7 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
|||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
isSavingRef.current = 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 ──
|
// ── Auto-save with ref-based debounce ──
|
||||||
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -544,11 +780,16 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
|||||||
className={styles.canvasContainer}
|
className={styles.canvasContainer}
|
||||||
style={viewMode !== '2d' ? { position: 'absolute', width: 0, height: 0, overflow: 'hidden', pointerEvents: 'none' } : undefined}
|
style={viewMode !== '2d' ? { position: 'absolute', width: 0, height: 0, overflow: 'hidden', pointerEvents: 'none' } : undefined}
|
||||||
>
|
>
|
||||||
<EditorCanvas
|
{/* Only mount the Konva stage once the container has been
|
||||||
width={canvasSize.width}
|
measured — rendering at a seed 800×600 and then re-rendering
|
||||||
height={canvasSize.height}
|
at the real size causes a visible flicker on open. */}
|
||||||
onStageRef={handleMainStageRef}
|
{canvasSize && (
|
||||||
/>
|
<EditorCanvas
|
||||||
|
width={canvasSize.width}
|
||||||
|
height={canvasSize.height}
|
||||||
|
onStageRef={handleMainStageRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{viewMode === '2d' && (
|
{viewMode === '2d' && (
|
||||||
<CableLengthStatus electricalItems={state.electricalItems} />
|
<CableLengthStatus electricalItems={state.electricalItems} />
|
||||||
|
|||||||
@@ -76,7 +76,9 @@ function createInitialState(room: RoomFull): EditorState {
|
|||||||
layerVisibility: { walls: true, electrical: true, furniture: true, measurements: true, annotations: true },
|
layerVisibility: { walls: true, electrical: true, furniture: true, measurements: true, annotations: true },
|
||||||
selectedElectricalIndex: null,
|
selectedElectricalIndex: null,
|
||||||
selectedFurnitureIndex: 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 : [],
|
openings: existingMatch ? room.openings : [],
|
||||||
electricalItems: room.electricalItems,
|
electricalItems: room.electricalItems,
|
||||||
furnitureItems: room.furnitureItems,
|
furnitureItems: room.furnitureItems,
|
||||||
|
annotations: room.annotations ?? state.annotations,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'UPDATE_ROOM_PROPS':
|
case 'UPDATE_ROOM_PROPS':
|
||||||
@@ -177,6 +180,8 @@ function editorReducer(state: EditorState, action: EditorAction): EditorState {
|
|||||||
return { ...state, zoom: action.zoom };
|
return { ...state, zoom: action.zoom };
|
||||||
case 'SET_PAN_OFFSET':
|
case 'SET_PAN_OFFSET':
|
||||||
return { ...state, panOffset: action.offset };
|
return { ...state, panOffset: action.offset };
|
||||||
|
case 'SET_VIEW':
|
||||||
|
return { ...state, zoom: action.zoom, panOffset: action.offset };
|
||||||
case 'SET_GRID_SIZE':
|
case 'SET_GRID_SIZE':
|
||||||
return { ...state, gridSize: action.gridSize };
|
return { ...state, gridSize: action.gridSize };
|
||||||
case 'TOGGLE_GRID':
|
case 'TOGGLE_GRID':
|
||||||
@@ -251,18 +256,43 @@ function editorReducer(state: EditorState, action: EditorAction): EditorState {
|
|||||||
annotations: state.annotations.filter((a) => a.id !== action.id),
|
annotations: state.annotations.filter((a) => a.id !== action.id),
|
||||||
selectedIds: removeFromSet(state.selectedIds, 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 ──
|
// ── Import ──
|
||||||
case 'SYNC_SAVE': {
|
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<string>();
|
const newIds = new Set<string>();
|
||||||
for (const w of action.walls) newIds.add(w.id);
|
for (const w of action.walls) newIds.add(w.id);
|
||||||
for (const o of action.openings) newIds.add(o.id);
|
for (const o of action.openings) newIds.add(o.id);
|
||||||
for (const e of action.electricalItems) newIds.add(e.id);
|
for (const e of action.electricalItems) newIds.add(e.id);
|
||||||
for (const f of action.furnitureItems) newIds.add(f.id);
|
for (const f of action.furnitureItems) newIds.add(f.id);
|
||||||
// Keep only selected IDs that still exist in the new data
|
// Remap selected IDs through the id map (so freshly created server items
|
||||||
const prunedSelection = new Set<string>();
|
// stay selected). Fall back to the original id when no mapping is given.
|
||||||
|
const idMap = action.idMap;
|
||||||
|
const remappedSelection = new Set<string>();
|
||||||
for (const id of state.selectedIds) {
|
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 {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@@ -270,7 +300,8 @@ function editorReducer(state: EditorState, action: EditorAction): EditorState {
|
|||||||
openings: action.openings,
|
openings: action.openings,
|
||||||
electricalItems: action.electricalItems,
|
electricalItems: action.electricalItems,
|
||||||
furnitureItems: action.furnitureItems,
|
furnitureItems: action.furnitureItems,
|
||||||
selectedIds: prunedSelection,
|
selectedIds: remappedSelection,
|
||||||
|
annotations: remappedAnnotations,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'IMPORT_ROOM':
|
case 'IMPORT_ROOM':
|
||||||
@@ -445,6 +476,8 @@ interface SceneDataContextValue {
|
|||||||
readonly electricalItems: readonly ElectricalItem[];
|
readonly electricalItems: readonly ElectricalItem[];
|
||||||
readonly furnitureItems: readonly FurnitureItem[];
|
readonly furnitureItems: readonly FurnitureItem[];
|
||||||
readonly annotations: readonly Annotation[];
|
readonly annotations: readonly Annotation[];
|
||||||
|
readonly furnitureProjectionIds: ReadonlySet<string>;
|
||||||
|
readonly globalFurnitureOpacity: number;
|
||||||
readonly gridSize: number;
|
readonly gridSize: number;
|
||||||
readonly gridVisible: boolean;
|
readonly gridVisible: boolean;
|
||||||
readonly snapEnabled: boolean;
|
readonly snapEnabled: boolean;
|
||||||
@@ -467,6 +500,7 @@ interface SceneDataContextValue {
|
|||||||
addAnnotation(annotation: Annotation): void;
|
addAnnotation(annotation: Annotation): void;
|
||||||
updateAnnotation(annotation: Annotation): void;
|
updateAnnotation(annotation: Annotation): void;
|
||||||
removeAnnotation(id: string): void;
|
removeAnnotation(id: string): void;
|
||||||
|
toggleFurnitureProjection(id: string): void;
|
||||||
copySelected(): void;
|
copySelected(): void;
|
||||||
pasteClipboard(): void;
|
pasteClipboard(): void;
|
||||||
}
|
}
|
||||||
@@ -499,6 +533,7 @@ interface EditorContextValue {
|
|||||||
addAnnotation(annotation: Annotation): void;
|
addAnnotation(annotation: Annotation): void;
|
||||||
updateAnnotation(annotation: Annotation): void;
|
updateAnnotation(annotation: Annotation): void;
|
||||||
removeAnnotation(id: string): void;
|
removeAnnotation(id: string): void;
|
||||||
|
toggleFurnitureProjection(id: string): void;
|
||||||
copySelected(): void;
|
copySelected(): void;
|
||||||
pasteClipboard(): void;
|
pasteClipboard(): void;
|
||||||
}
|
}
|
||||||
@@ -615,6 +650,10 @@ export function EditorProvider({ room, children }: EditorProviderProps) {
|
|||||||
(id: string) => dispatch({ type: 'REMOVE_ANNOTATION', id }),
|
(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) ──
|
// ── Clipboard (ref-based so copy reads current state without closures) ──
|
||||||
const clipboardRef = useRef<{
|
const clipboardRef = useRef<{
|
||||||
@@ -712,6 +751,8 @@ export function EditorProvider({ room, children }: EditorProviderProps) {
|
|||||||
electricalItems: state.electricalItems,
|
electricalItems: state.electricalItems,
|
||||||
furnitureItems: state.furnitureItems,
|
furnitureItems: state.furnitureItems,
|
||||||
annotations: state.annotations,
|
annotations: state.annotations,
|
||||||
|
furnitureProjectionIds: state.furnitureProjectionIds,
|
||||||
|
globalFurnitureOpacity: state.globalFurnitureOpacity,
|
||||||
gridSize: state.gridSize,
|
gridSize: state.gridSize,
|
||||||
gridVisible: state.gridVisible,
|
gridVisible: state.gridVisible,
|
||||||
snapEnabled: state.snapEnabled,
|
snapEnabled: state.snapEnabled,
|
||||||
@@ -734,6 +775,7 @@ export function EditorProvider({ room, children }: EditorProviderProps) {
|
|||||||
addAnnotation,
|
addAnnotation,
|
||||||
updateAnnotation,
|
updateAnnotation,
|
||||||
removeAnnotation,
|
removeAnnotation,
|
||||||
|
toggleFurnitureProjection,
|
||||||
copySelected,
|
copySelected,
|
||||||
pasteClipboard,
|
pasteClipboard,
|
||||||
}),
|
}),
|
||||||
@@ -744,6 +786,8 @@ export function EditorProvider({ room, children }: EditorProviderProps) {
|
|||||||
state.electricalItems,
|
state.electricalItems,
|
||||||
state.furnitureItems,
|
state.furnitureItems,
|
||||||
state.annotations,
|
state.annotations,
|
||||||
|
state.furnitureProjectionIds,
|
||||||
|
state.globalFurnitureOpacity,
|
||||||
state.gridSize,
|
state.gridSize,
|
||||||
state.gridVisible,
|
state.gridVisible,
|
||||||
state.snapEnabled,
|
state.snapEnabled,
|
||||||
@@ -765,6 +809,7 @@ export function EditorProvider({ room, children }: EditorProviderProps) {
|
|||||||
addAnnotation,
|
addAnnotation,
|
||||||
updateAnnotation,
|
updateAnnotation,
|
||||||
removeAnnotation,
|
removeAnnotation,
|
||||||
|
toggleFurnitureProjection,
|
||||||
copySelected,
|
copySelected,
|
||||||
pasteClipboard,
|
pasteClipboard,
|
||||||
],
|
],
|
||||||
@@ -796,6 +841,7 @@ export function EditorProvider({ room, children }: EditorProviderProps) {
|
|||||||
addAnnotation,
|
addAnnotation,
|
||||||
updateAnnotation,
|
updateAnnotation,
|
||||||
removeAnnotation,
|
removeAnnotation,
|
||||||
|
toggleFurnitureProjection,
|
||||||
copySelected,
|
copySelected,
|
||||||
pasteClipboard,
|
pasteClipboard,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -306,6 +306,15 @@ export function importRoomFromJson(json: string): ImportResult {
|
|||||||
height: assertField(o, 'height', isNumber, `openings[${i}]`),
|
height: assertField(o, 'height', isNumber, `openings[${i}]`),
|
||||||
elevationFromFloor: assertField(o, 'elevationFromFloor', isNumber, `openings[${i}]`),
|
elevationFromFloor: assertField(o, 'elevationFromFloor', isNumber, `openings[${i}]`),
|
||||||
openDirection: (isString((o as Record<string, unknown>).openDirection) ? (o as Record<string, unknown>).openDirection as string : 'LEFT') as WallOpening['openDirection'],
|
openDirection: (isString((o as Record<string, unknown>).openDirection) ? (o as Record<string, unknown>).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;
|
validatedMetadata = metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const importedCount = isNumber(e.count) && e.count >= 1 ? Math.round(e.count) : 1;
|
||||||
electricalItems.push({
|
electricalItems.push({
|
||||||
id: generateLocalId(),
|
id: generateLocalId(),
|
||||||
roomId,
|
roomId,
|
||||||
@@ -351,6 +361,9 @@ export function importRoomFromJson(json: string): ImportResult {
|
|||||||
wallId,
|
wallId,
|
||||||
elevationFromFloor: e.elevationFromFloor === null ? null : assertField(e, 'elevationFromFloor', isNumber, `electricalItems[${i}]`),
|
elevationFromFloor: e.elevationFromFloor === null ? null : assertField(e, 'elevationFromFloor', isNumber, `electricalItems[${i}]`),
|
||||||
rotation: assertField(e, 'rotation', 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,
|
metadata: validatedMetadata,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -386,6 +399,11 @@ export function importRoomFromJson(json: string): ImportResult {
|
|||||||
rotation: assertField(f, 'rotation', isNumber, `furnitureItems[${i}]`),
|
rotation: assertField(f, 'rotation', isNumber, `furnitureItems[${i}]`),
|
||||||
elevationFromFloor: isNumber((f as Record<string, unknown>).elevationFromFloor) ? (f as Record<string, unknown>).elevationFromFloor as number : 0,
|
elevationFromFloor: isNumber((f as Record<string, unknown>).elevationFromFloor) ? (f as Record<string, unknown>).elevationFromFloor as number : 0,
|
||||||
label: (label as string | null) ?? null,
|
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<string, unknown>) : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { memo, useMemo } from 'react';
|
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 type { Point, Annotation, ElectricalItem, FurnitureItem } from '@house-plan-maker/shared';
|
||||||
|
import { rotatedAnchorOffsetToCenter } from '@house-plan-maker/shared';
|
||||||
|
|
||||||
interface AnnotationLayerProps {
|
interface AnnotationLayerProps {
|
||||||
readonly annotations: readonly Annotation[];
|
readonly annotations: readonly Annotation[];
|
||||||
@@ -17,9 +18,19 @@ interface AnnotationLayerProps {
|
|||||||
|
|
||||||
const DEFAULT_FONT_SIZE = 14;
|
const DEFAULT_FONT_SIZE = 14;
|
||||||
const DEFAULT_COLOR = '#333333';
|
const DEFAULT_COLOR = '#333333';
|
||||||
|
const LINK_COLOR = '#2563eb';
|
||||||
const SELECTED_COLOR = '#4c6ef5';
|
const SELECTED_COLOR = '#4c6ef5';
|
||||||
const SELECTION_PADDING = 4;
|
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 } {
|
function toScreen(point: Point, zoom: number, panOffset: Point): { x: number; y: number } {
|
||||||
return {
|
return {
|
||||||
x: point.x * zoom + panOffset.x,
|
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 });
|
map.set(item.id, { x: item.x, y: item.y });
|
||||||
}
|
}
|
||||||
for (const item of furnitureItems) {
|
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;
|
return map;
|
||||||
}, [electricalItems, furnitureItems]);
|
}, [electricalItems, furnitureItems]);
|
||||||
@@ -57,7 +74,7 @@ export const AnnotationLayer = memo(function AnnotationLayer({
|
|||||||
}, [annotations, visible]);
|
}, [annotations, visible]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layer visible={visible}>
|
<Group visible={visible}>
|
||||||
{renderedAnnotations.map((annotation) => {
|
{renderedAnnotations.map((annotation) => {
|
||||||
// Resolve position: if attached, offset from parent item
|
// Resolve position: if attached, offset from parent item
|
||||||
let worldX = annotation.x;
|
let worldX = annotation.x;
|
||||||
@@ -76,7 +93,15 @@ export const AnnotationLayer = memo(function AnnotationLayer({
|
|||||||
const screen = toScreen({ x: worldX, y: worldY }, zoom, panOffset);
|
const screen = toScreen({ x: worldX, y: worldY }, zoom, panOffset);
|
||||||
const isSelected = selectedIds.has(annotation.id);
|
const isSelected = selectedIds.has(annotation.id);
|
||||||
const fontSize = annotation.fontSize ?? DEFAULT_FONT_SIZE;
|
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 (
|
return (
|
||||||
<Group key={annotation.id}>
|
<Group key={annotation.id}>
|
||||||
@@ -108,7 +133,19 @@ export const AnnotationLayer = memo(function AnnotationLayer({
|
|||||||
}
|
}
|
||||||
onDragEnd?.(annotation.id, newX, newY);
|
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)}
|
onDblClick={() => onDoubleClick?.(annotation.id)}
|
||||||
>
|
>
|
||||||
{/* Background */}
|
{/* Background */}
|
||||||
@@ -143,6 +180,6 @@ export const AnnotationLayer = memo(function AnnotationLayer({
|
|||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Layer>
|
</Group>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { memo, useMemo } from 'react';
|
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 type { Point, ElectricalItem } from '@house-plan-maker/shared';
|
||||||
|
import { anchorOffsetToCenter, DEFAULT_OUTLET_WIDTH, DEFAULT_OUTLET_HEIGHT } from '@house-plan-maker/shared';
|
||||||
import {
|
import {
|
||||||
SingleOutletSymbol,
|
OutletSymbol,
|
||||||
DoubleOutletSymbol,
|
|
||||||
GroundedOutletSymbol,
|
|
||||||
SingleSwitchSymbol,
|
SingleSwitchSymbol,
|
||||||
DoubleSwitchSymbol,
|
DoubleSwitchSymbol,
|
||||||
DimmerSwitchSymbol,
|
DimmerSwitchSymbol,
|
||||||
@@ -23,6 +22,10 @@ interface ElectricalLayerProps {
|
|||||||
readonly panOffset: Point;
|
readonly panOffset: Point;
|
||||||
readonly selectedIds: ReadonlySet<string>;
|
readonly selectedIds: ReadonlySet<string>;
|
||||||
readonly visible?: boolean;
|
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';
|
const ELECTRICAL_COLOR = '#d63384';
|
||||||
@@ -43,8 +46,13 @@ export const ElectricalLayer = memo(function ElectricalLayer({
|
|||||||
panOffset,
|
panOffset,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
visible = true,
|
visible = true,
|
||||||
|
outletWidth = DEFAULT_OUTLET_WIDTH,
|
||||||
|
outletHeight = DEFAULT_OUTLET_HEIGHT,
|
||||||
}: ElectricalLayerProps) {
|
}: ElectricalLayerProps) {
|
||||||
const scale = Math.max(0.6, Math.min(1.5, zoom / 100));
|
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(() => {
|
const renderedItems = useMemo(() => {
|
||||||
if (!visible) return [];
|
if (!visible) return [];
|
||||||
@@ -57,7 +65,7 @@ export const ElectricalLayer = memo(function ElectricalLayer({
|
|||||||
}, [items, visible]);
|
}, [items, visible]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layer listening={false} visible={visible}>
|
<Group listening={false} visible={visible}>
|
||||||
{/* Cable routes first (below symbols) */}
|
{/* Cable routes first (below symbols) */}
|
||||||
{cableItems.map((item) => {
|
{cableItems.map((item) => {
|
||||||
const waypoints = getCableWaypoints(item);
|
const waypoints = getCableWaypoints(item);
|
||||||
@@ -83,25 +91,40 @@ export const ElectricalLayer = memo(function ElectricalLayer({
|
|||||||
const color = isSelected ? SELECTED_COLOR : ELECTRICAL_COLOR;
|
const color = isSelected ? SELECTED_COLOR : ELECTRICAL_COLOR;
|
||||||
const variant = getElectricalVariant(item.metadata);
|
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 (
|
return (
|
||||||
<Group key={item.id}>
|
<Group key={item.id}>
|
||||||
{/* Light coverage circle (only for selected light fixtures) */}
|
{/* Light coverage circle (only for selected light fixtures) */}
|
||||||
{isSelected && renderLightCoverage(item, zoom, panOffset)}
|
{isSelected && renderLightCoverage(item, zoom, panOffset)}
|
||||||
|
|
||||||
{/* Symbol */}
|
{/* Symbol */}
|
||||||
{renderElectricalSymbol(
|
{renderElectricalSymbol({
|
||||||
item.type,
|
type: item.type,
|
||||||
variant,
|
variant,
|
||||||
screen.x,
|
count: item.count,
|
||||||
screen.y,
|
x: screen.x,
|
||||||
item.rotation,
|
y: screen.y,
|
||||||
|
rotation: item.rotation,
|
||||||
color,
|
color,
|
||||||
scale,
|
scale,
|
||||||
)}
|
outletWidthPx,
|
||||||
|
outletHeightPx,
|
||||||
|
centerOffsetX: offset.dx,
|
||||||
|
centerOffsetY: offset.dy,
|
||||||
|
})}
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Layer>
|
</Group>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -130,25 +153,38 @@ function renderLightCoverage(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderElectricalSymbol(
|
interface RenderSymbolArgs {
|
||||||
type: string,
|
readonly type: string;
|
||||||
variant: string,
|
readonly variant: string;
|
||||||
x: number,
|
readonly count: number;
|
||||||
y: number,
|
readonly x: number;
|
||||||
rotation: number,
|
readonly y: number;
|
||||||
color: string,
|
readonly rotation: number;
|
||||||
scale: number,
|
readonly color: string;
|
||||||
): React.ReactNode {
|
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) {
|
switch (type) {
|
||||||
case 'OUTLET':
|
case 'OUTLET':
|
||||||
switch (variant) {
|
return (
|
||||||
case 'double':
|
<OutletSymbol
|
||||||
return <DoubleOutletSymbol x={x} y={y} rotation={rotation} color={color} scale={scale} />;
|
x={x}
|
||||||
case 'grounded':
|
y={y}
|
||||||
return <GroundedOutletSymbol x={x} y={y} rotation={rotation} color={color} scale={scale} />;
|
rotation={rotation}
|
||||||
default:
|
color={color}
|
||||||
return <SingleOutletSymbol x={x} y={y} rotation={rotation} color={color} scale={scale} />;
|
count={count}
|
||||||
}
|
outletWidthPx={args.outletWidthPx}
|
||||||
|
outletHeightPx={args.outletHeightPx}
|
||||||
|
centerOffsetX={args.centerOffsetX}
|
||||||
|
centerOffsetY={args.centerOffsetY}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case 'SWITCH':
|
case 'SWITCH':
|
||||||
switch (variant) {
|
switch (variant) {
|
||||||
case 'double':
|
case 'double':
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { memo, useMemo } from 'react';
|
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 type { Point, FurnitureItem } from '@house-plan-maker/shared';
|
||||||
|
import { rotatedAnchorOffsetToCenter } from '@house-plan-maker/shared';
|
||||||
import { BedSilhouette } from '../symbols/furniture/BedSilhouette';
|
import { BedSilhouette } from '../symbols/furniture/BedSilhouette';
|
||||||
import { DeskSilhouette } from '../symbols/furniture/DeskSilhouette';
|
import { DeskSilhouette } from '../symbols/furniture/DeskSilhouette';
|
||||||
import { WardrobeSilhouette } from '../symbols/furniture/WardrobeSilhouette';
|
import { WardrobeSilhouette } from '../symbols/furniture/WardrobeSilhouette';
|
||||||
@@ -17,6 +18,8 @@ interface FurnitureLayerProps {
|
|||||||
readonly panOffset: Point;
|
readonly panOffset: Point;
|
||||||
readonly selectedIds: ReadonlySet<string>;
|
readonly selectedIds: ReadonlySet<string>;
|
||||||
readonly visible?: boolean;
|
readonly visible?: boolean;
|
||||||
|
/** Global multiplier applied to every furniture item's opacity. */
|
||||||
|
readonly globalOpacity?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FURNITURE_COLOR = '#495057';
|
const FURNITURE_COLOR = '#495057';
|
||||||
@@ -39,15 +42,25 @@ export const FurnitureLayer = memo(function FurnitureLayer({
|
|||||||
panOffset,
|
panOffset,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
visible = true,
|
visible = true,
|
||||||
|
globalOpacity = 1,
|
||||||
}: FurnitureLayerProps) {
|
}: FurnitureLayerProps) {
|
||||||
const collidingIds = useMemo(() => findCollidingFurniture(items), [items]);
|
const collidingIds = useMemo(() => findCollidingFurniture(items), [items]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layer listening={false} visible={visible}>
|
<Group listening={false} visible={visible}>
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
// x,y is the top-left corner; compute center for silhouette rendering
|
// (x, y) is the anchored point on the ROTATED visual; convert to
|
||||||
const centerX = item.x + item.width / 2;
|
// bounding-box center using the rotation-aware helper so "left"
|
||||||
const centerY = item.y + item.depth / 2;
|
// 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 screenCenter = toScreen({ x: centerX, y: centerY }, zoom, panOffset);
|
||||||
const isSelected = selectedIds.has(item.id);
|
const isSelected = selectedIds.has(item.id);
|
||||||
const isColliding = collidingIds.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 color = isColliding ? COLLISION_COLOR : isSelected ? SELECTED_COLOR : FURNITURE_COLOR;
|
||||||
const fillColor = isColliding ? COLLISION_FILL : isSelected ? SELECTED_FILL : FURNITURE_FILL;
|
const fillColor = isColliding ? COLLISION_FILL : isSelected ? SELECTED_FILL : FURNITURE_FILL;
|
||||||
|
|
||||||
|
const opacity = (item.opacity ?? 1) * globalOpacity;
|
||||||
return (
|
return (
|
||||||
<Group key={item.id}>
|
<Group key={item.id} opacity={opacity}>
|
||||||
{renderFurnitureSilhouette(
|
{renderFurnitureSilhouette(
|
||||||
item.type,
|
item.type,
|
||||||
screenCenter.x,
|
screenCenter.x,
|
||||||
@@ -81,7 +95,7 @@ export const FurnitureLayer = memo(function FurnitureLayer({
|
|||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Layer>
|
</Group>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,20 +170,31 @@ function renderFurnitureSilhouette(
|
|||||||
return <WardrobeSilhouette {...props} />;
|
return <WardrobeSilhouette {...props} />;
|
||||||
case 'TV':
|
case 'TV':
|
||||||
return <TvSilhouette {...props} />;
|
return <TvSilhouette {...props} />;
|
||||||
default:
|
default: {
|
||||||
// Generic rectangle for OTHER / unknown
|
// Generic rectangle fallback for types without a custom silhouette
|
||||||
|
// (OTHER, RADIATOR, WALL_COLLAGE, CURTAIN, …).
|
||||||
|
//
|
||||||
|
// Rotation MUST pivot around the item's center. Konva's `<Rect>`
|
||||||
|
// rotates around its stored (x, y) — which is the top-left corner,
|
||||||
|
// not the center — so a bare `<Rect x={cx-w/2} y={cy-d/2} rotation>`
|
||||||
|
// pivots around the unrotated top-left corner and drifts away from
|
||||||
|
// the hit-test box as the item rotates. Wrapping the Rect in a
|
||||||
|
// `<Group>` at (cx, cy) with the rotation on the Group gives the
|
||||||
|
// correct center-pivot behaviour (matching the custom silhouettes).
|
||||||
return (
|
return (
|
||||||
<Rect
|
<Group x={x} y={y} rotation={rotation} listening={false}>
|
||||||
x={x - width / 2}
|
<Rect
|
||||||
y={y - depth / 2}
|
x={-width / 2}
|
||||||
width={width}
|
y={-depth / 2}
|
||||||
height={depth}
|
width={width}
|
||||||
rotation={rotation}
|
height={depth}
|
||||||
stroke={color}
|
stroke={color}
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
fill={fillColor}
|
fill={fillColor}
|
||||||
listening={false}
|
listening={false}
|
||||||
/>
|
/>
|
||||||
|
</Group>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { memo, useMemo } from 'react';
|
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';
|
import type { Point } from '@house-plan-maker/shared';
|
||||||
|
|
||||||
interface GridLayerProps {
|
interface GridLayerProps {
|
||||||
@@ -129,7 +129,7 @@ export const GridLayer = memo(function GridLayer({
|
|||||||
}, [zoom, panOffset, stageWidth, stageHeight]);
|
}, [zoom, panOffset, stageWidth, stageHeight]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layer listening={false}>
|
<Group listening={false}>
|
||||||
{/* Grid lines */}
|
{/* Grid lines */}
|
||||||
{visible &&
|
{visible &&
|
||||||
gridLines.lines.map((line, i) => (
|
gridLines.lines.map((line, i) => (
|
||||||
@@ -221,7 +221,7 @@ export const GridLayer = memo(function GridLayer({
|
|||||||
fill={RULER_BG_COLOR}
|
fill={RULER_BG_COLOR}
|
||||||
listening={false}
|
listening={false}
|
||||||
/>
|
/>
|
||||||
</Layer>
|
</Group>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { memo } from 'react';
|
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 { Point } from '@house-plan-maker/shared';
|
||||||
import type { MeasurementState } from '../types';
|
import type { MeasurementState } from '../types';
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ export const MeasureOverlayLayer = memo(function MeasureOverlayLayer({
|
|||||||
zoom,
|
zoom,
|
||||||
panOffset,
|
panOffset,
|
||||||
}: MeasureOverlayLayerProps) {
|
}: MeasureOverlayLayerProps) {
|
||||||
if (!measurement) return <Layer listening={false} />;
|
if (!measurement) return <Group listening={false} />;
|
||||||
|
|
||||||
const start = toScreen(measurement.startPoint, zoom, panOffset);
|
const start = toScreen(measurement.startPoint, zoom, panOffset);
|
||||||
const end = toScreen(measurement.endPoint, zoom, panOffset);
|
const end = toScreen(measurement.endPoint, zoom, panOffset);
|
||||||
@@ -38,7 +38,7 @@ export const MeasureOverlayLayer = memo(function MeasureOverlayLayer({
|
|||||||
: `${(distanceM * 100).toFixed(1)} cm`;
|
: `${(distanceM * 100).toFixed(1)} cm`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layer listening={false}>
|
<Group listening={false}>
|
||||||
<Line
|
<Line
|
||||||
points={[start.x, start.y, end.x, end.y]}
|
points={[start.x, start.y, end.x, end.y]}
|
||||||
stroke={MEASURE_COLOR}
|
stroke={MEASURE_COLOR}
|
||||||
@@ -69,6 +69,6 @@ export const MeasureOverlayLayer = memo(function MeasureOverlayLayer({
|
|||||||
padding={2}
|
padding={2}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Layer>
|
</Group>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { memo, useMemo } from 'react';
|
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 type { Point, Wall, WallOpening } from '@house-plan-maker/shared';
|
||||||
import { wallLength, wallAngle, wallStartEnd } from '../utils/wallUtils';
|
import { wallLength, wallAngle, wallStartEnd } from '../utils/wallUtils';
|
||||||
|
|
||||||
@@ -28,22 +28,15 @@ export const MeasurementLayer = memo(function MeasurementLayer({
|
|||||||
}: MeasurementLayerProps) {
|
}: MeasurementLayerProps) {
|
||||||
// Hide measurements at very low zoom levels
|
// Hide measurements at very low zoom levels
|
||||||
if (zoom < MIN_ZOOM_FOR_MEASUREMENTS) {
|
if (zoom < MIN_ZOOM_FOR_MEASUREMENTS) {
|
||||||
return <Layer listening={false} />;
|
return <Group listening={false} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layer listening={false}>
|
<Group listening={false}>
|
||||||
{/* Wall length annotations */}
|
{/* Outer room dimensions — one horizontal + one vertical label outside
|
||||||
{walls.map((wall) => (
|
the room bounding box. The former per-wall inner labels were
|
||||||
<WallMeasurement
|
removed because they duplicated these numbers on every wall of a
|
||||||
key={`wm-${wall.id}`}
|
rectangular room. */}
|
||||||
wall={wall}
|
|
||||||
zoom={zoom}
|
|
||||||
panOffset={panOffset}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Room overall dimensions */}
|
|
||||||
{roomShape.length >= 3 && (
|
{roomShape.length >= 3 && (
|
||||||
<RoomDimensions
|
<RoomDimensions
|
||||||
roomShape={roomShape}
|
roomShape={roomShape}
|
||||||
@@ -68,48 +61,11 @@ export const MeasurementLayer = memo(function MeasurementLayer({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Layer>
|
</Group>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Wall length annotation ──
|
// ── Room overall dimensions (outer bbox labels) ──
|
||||||
|
|
||||||
interface WallMeasurementProps {
|
|
||||||
readonly wall: Wall;
|
|
||||||
readonly zoom: number;
|
|
||||||
readonly panOffset: Point;
|
|
||||||
}
|
|
||||||
|
|
||||||
function WallMeasurement({ wall, zoom, panOffset }: WallMeasurementProps) {
|
|
||||||
const len = wallLength(wall);
|
|
||||||
if (len < 0.01) return null;
|
|
||||||
|
|
||||||
const { start, end } = wallStartEnd(wall);
|
|
||||||
const angle = wallAngle(wall);
|
|
||||||
|
|
||||||
// Midpoint of wall in screen coords
|
|
||||||
const midX = ((start.x + end.x) / 2) * zoom + panOffset.x;
|
|
||||||
const midY = ((start.y + end.y) / 2) * zoom + panOffset.y;
|
|
||||||
|
|
||||||
// Offset perpendicular to wall
|
|
||||||
const offsetX = -Math.sin(angle) * MEASUREMENT_OFFSET;
|
|
||||||
const offsetY = Math.cos(angle) * MEASUREMENT_OFFSET;
|
|
||||||
|
|
||||||
const label = formatMeasurement(len);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Text
|
|
||||||
x={midX + offsetX - 20}
|
|
||||||
y={midY + offsetY - 5}
|
|
||||||
text={label}
|
|
||||||
fontSize={MEASUREMENT_FONT_SIZE}
|
|
||||||
fill={MEASUREMENT_COLOR}
|
|
||||||
listening={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Room overall dimensions ──
|
|
||||||
|
|
||||||
interface RoomDimensionsProps {
|
interface RoomDimensionsProps {
|
||||||
readonly roomShape: readonly Point[];
|
readonly roomShape: readonly Point[];
|
||||||
@@ -159,7 +115,6 @@ function RoomDimensions({ roomShape, zoom, panOffset }: RoomDimensionsProps) {
|
|||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
listening={false}
|
listening={false}
|
||||||
/>
|
/>
|
||||||
{/* End ticks */}
|
|
||||||
<Line
|
<Line
|
||||||
points={[hStartX, topY - 4, hStartX, topY + 4]}
|
points={[hStartX, topY - 4, hStartX, topY + 4]}
|
||||||
stroke={MEASUREMENT_COLOR}
|
stroke={MEASUREMENT_COLOR}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { memo, useMemo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import { Layer, Line, Arc, Group } from 'react-konva';
|
import { Line, Arc, Group } from 'react-konva';
|
||||||
import type { Point, Wall, WallOpening, DoorOpenDirection } from '@house-plan-maker/shared';
|
import type { Point, Wall, WallOpening, DoorOpenDirection } from '@house-plan-maker/shared';
|
||||||
import { openingWorldPosition, wallAngle } from '../utils/wallUtils';
|
import { openingWorldPosition, wallAngle } from '../utils/wallUtils';
|
||||||
import { polygonCentroid } from '../utils/geometry';
|
import { polygonCentroid } from '../utils/geometry';
|
||||||
@@ -79,7 +79,7 @@ export const OpeningLayer = memo(function OpeningLayer({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layer>
|
<Group>
|
||||||
{renderedOpenings.map(({ opening, wall, pos, isSelected }) => {
|
{renderedOpenings.map(({ opening, wall, pos, isSelected }) => {
|
||||||
const screenCenter = toScreen(pos.center, zoom, panOffset);
|
const screenCenter = toScreen(pos.center, zoom, panOffset);
|
||||||
const angle = wallAngle(wall);
|
const angle = wallAngle(wall);
|
||||||
@@ -140,7 +140,7 @@ export const OpeningLayer = memo(function OpeningLayer({
|
|||||||
panOffset={panOffset}
|
panOffset={panOffset}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Layer>
|
</Group>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -162,8 +162,12 @@ function DoorSymbol({ x, y, angleDeg, halfWidthPx, wallThicknessPx, isSelected,
|
|||||||
const color = isSelected ? SELECTED_COLOR : DOOR_COLOR;
|
const color = isSelected ? SELECTED_COLOR : DOOR_COLOR;
|
||||||
const doorWidthPx = halfWidthPx * 2;
|
const doorWidthPx = halfWidthPx * 2;
|
||||||
|
|
||||||
const isRight = openDirection === 'RIGHT';
|
// The four enum values encode the two orthogonal axes (hinge side × swing
|
||||||
const isOutward = openDirection === 'OUTWARD';
|
// 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
|
// Mirror the entire door group for RIGHT hinge
|
||||||
const groupScaleX = isRight ? -1 : 1;
|
const groupScaleX = isRight ? -1 : 1;
|
||||||
@@ -317,6 +321,11 @@ function PreviewSymbol({ wall, positionAlongWall, width, type, isValid: _isValid
|
|||||||
height: 0,
|
height: 0,
|
||||||
elevationFromFloor: 0,
|
elevationFromFloor: 0,
|
||||||
openDirection: 'LEFT',
|
openDirection: 'LEFT',
|
||||||
|
positionAnchor: { horizontal: 'middle', vertical: 'bottom' },
|
||||||
|
gridCols: 2,
|
||||||
|
gridRows: 2,
|
||||||
|
slopeDepth: 0,
|
||||||
|
frameThickness: 0.03,
|
||||||
};
|
};
|
||||||
|
|
||||||
const pos = openingWorldPosition(tempOpening, wall);
|
const pos = openingWorldPosition(tempOpening, wall);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo } from 'react';
|
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 type { Point } from '@house-plan-maker/shared';
|
||||||
import { polygonArea, polygonCentroid } from '../utils/geometry';
|
import { polygonArea, boundingBox } from '../utils/geometry';
|
||||||
|
|
||||||
interface RoomLabelLayerProps {
|
interface RoomLabelLayerProps {
|
||||||
readonly roomName: string;
|
readonly roomName: string;
|
||||||
@@ -31,37 +31,41 @@ export function RoomLabelLayer({
|
|||||||
[roomShape],
|
[roomShape],
|
||||||
);
|
);
|
||||||
|
|
||||||
const centroid = useMemo(
|
const bbox = useMemo(
|
||||||
() => (roomShape.length >= 3 ? polygonCentroid(roomShape) : null),
|
() => (roomShape.length >= 2 ? boundingBox(roomShape) : null),
|
||||||
[roomShape],
|
[roomShape],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!centroid || zoom < MIN_ZOOM_FOR_LABELS) {
|
if (!bbox || zoom < MIN_ZOOM_FOR_LABELS) {
|
||||||
return <Layer listening={false} />;
|
return <Group listening={false} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const areaText = `${(Math.round(area * 100) / 100).toFixed(1)} m\u00B2`;
|
const areaText = `${(Math.round(area * 100) / 100).toFixed(1)} m\u00B2`;
|
||||||
|
|
||||||
// Position in screen coordinates
|
// Generous text-width estimate: bold font + multibyte glyphs (Cyrillic, etc.)
|
||||||
const screenX = centroid.x * zoom + panOffset.x;
|
// can exceed Latin averages, so we over-allocate to avoid line wrapping.
|
||||||
const screenY = centroid.y * zoom + panOffset.y;
|
const nameWidth = roomName.length * (LABEL_FONT_SIZE * 0.75) + 6;
|
||||||
|
const areaWidth = areaText.length * (AREA_FONT_SIZE * 0.7) + 6;
|
||||||
// Estimate text widths (approximate: ~7px per char at font size 13, ~6px at 11)
|
|
||||||
const nameWidth = roomName.length * 7.5;
|
|
||||||
const areaWidth = areaText.length * 6.5;
|
|
||||||
const maxWidth = Math.max(nameWidth, areaWidth);
|
const maxWidth = Math.max(nameWidth, areaWidth);
|
||||||
const totalHeight = LABEL_FONT_SIZE + LINE_SPACING + AREA_FONT_SIZE;
|
const totalHeight = LABEL_FONT_SIZE + LINE_SPACING + AREA_FONT_SIZE;
|
||||||
|
|
||||||
const bgWidth = maxWidth + BG_PADDING_X * 2;
|
const bgWidth = maxWidth + BG_PADDING_X * 2;
|
||||||
const bgHeight = totalHeight + BG_PADDING_Y * 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 (
|
return (
|
||||||
<Layer listening={false}>
|
<Group listening={false}>
|
||||||
<Group x={screenX} y={screenY}>
|
<Group x={screenX} y={screenY}>
|
||||||
{/* Semi-transparent background */}
|
{/* Semi-transparent background */}
|
||||||
<Rect
|
<Rect
|
||||||
x={-bgWidth / 2}
|
x={0}
|
||||||
y={-bgHeight / 2}
|
y={0}
|
||||||
width={bgWidth}
|
width={bgWidth}
|
||||||
height={bgHeight}
|
height={bgHeight}
|
||||||
fill={BG_COLOR}
|
fill={BG_COLOR}
|
||||||
@@ -70,28 +74,28 @@ export function RoomLabelLayer({
|
|||||||
/>
|
/>
|
||||||
{/* Room name */}
|
{/* Room name */}
|
||||||
<Text
|
<Text
|
||||||
x={-maxWidth / 2}
|
x={BG_PADDING_X}
|
||||||
y={-bgHeight / 2 + BG_PADDING_Y}
|
y={BG_PADDING_Y}
|
||||||
width={maxWidth}
|
|
||||||
text={roomName}
|
text={roomName}
|
||||||
fontSize={LABEL_FONT_SIZE}
|
fontSize={LABEL_FONT_SIZE}
|
||||||
fontStyle="bold"
|
fontStyle="bold"
|
||||||
fill={LABEL_COLOR}
|
fill={LABEL_COLOR}
|
||||||
align="center"
|
align="left"
|
||||||
|
wrap="none"
|
||||||
listening={false}
|
listening={false}
|
||||||
/>
|
/>
|
||||||
{/* Area */}
|
{/* Area */}
|
||||||
<Text
|
<Text
|
||||||
x={-maxWidth / 2}
|
x={BG_PADDING_X}
|
||||||
y={-bgHeight / 2 + BG_PADDING_Y + LABEL_FONT_SIZE + LINE_SPACING}
|
y={BG_PADDING_Y + LABEL_FONT_SIZE + LINE_SPACING}
|
||||||
width={maxWidth}
|
|
||||||
text={areaText}
|
text={areaText}
|
||||||
fontSize={AREA_FONT_SIZE}
|
fontSize={AREA_FONT_SIZE}
|
||||||
fill={AREA_COLOR}
|
fill={AREA_COLOR}
|
||||||
align="center"
|
align="left"
|
||||||
|
wrap="none"
|
||||||
listening={false}
|
listening={false}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
</Layer>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { Layer, Rect } from 'react-konva';
|
import { Group, Rect } from 'react-konva';
|
||||||
import type { Point } from '@house-plan-maker/shared';
|
import type { Point } from '@house-plan-maker/shared';
|
||||||
|
|
||||||
interface SelectionLayerProps {
|
interface SelectionLayerProps {
|
||||||
@@ -35,7 +35,7 @@ export const SelectionLayer = memo(function SelectionLayer({
|
|||||||
dragRect,
|
dragRect,
|
||||||
}: SelectionLayerProps) {
|
}: SelectionLayerProps) {
|
||||||
return (
|
return (
|
||||||
<Layer listening={false}>
|
<Group listening={false}>
|
||||||
{/* Selection bounding box with resize handles */}
|
{/* Selection bounding box with resize handles */}
|
||||||
{selectionBox && (
|
{selectionBox && (
|
||||||
<SelectionBoundingBox
|
<SelectionBoundingBox
|
||||||
@@ -53,7 +53,7 @@ export const SelectionLayer = memo(function SelectionLayer({
|
|||||||
panOffset={panOffset}
|
panOffset={panOffset}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Layer>
|
</Group>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { memo, useMemo } from 'react';
|
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 type { Point, Wall } from '@house-plan-maker/shared';
|
||||||
import { polygonCentroid } from '../utils/geometry';
|
import { polygonCentroid } from '../utils/geometry';
|
||||||
|
|
||||||
@@ -163,7 +163,7 @@ export const WallLayer = memo(function WallLayer({
|
|||||||
}, [walls, selectedIds, zoom, panOffset]);
|
}, [walls, selectedIds, zoom, panOffset]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layer>
|
<Group>
|
||||||
{/* Room interior fill */}
|
{/* Room interior fill */}
|
||||||
{roomShapeScreen.length >= 6 && (
|
{roomShapeScreen.length >= 6 && (
|
||||||
<Line
|
<Line
|
||||||
@@ -231,6 +231,6 @@ export const WallLayer = memo(function WallLayer({
|
|||||||
listening={false}
|
listening={false}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Layer>
|
</Group>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,50 +1,45 @@
|
|||||||
import { useMemo, useCallback } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
ELECTRICAL_SYMBOL_DEFS,
|
ELECTRICAL_SYMBOL_DEFS,
|
||||||
type ElectricalSymbolDef,
|
type ElectricalSymbolDef,
|
||||||
} from '../symbols/electrical';
|
} from '../symbols/electrical';
|
||||||
import styles from './electrical-palette.module.css';
|
import styles from './item-picker.module.css';
|
||||||
|
|
||||||
interface ElectricalPaletteProps {
|
interface ElectricalPaletteProps {
|
||||||
readonly selectedIndex: number | null;
|
readonly selectedIndex: number | null;
|
||||||
readonly onSelect: (index: number) => void;
|
readonly onSelect: (index: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CategoryGroup {
|
interface IndexedDef {
|
||||||
readonly name: string;
|
readonly def: ElectricalSymbolDef;
|
||||||
readonly nameKey: string;
|
readonly index: number;
|
||||||
readonly icon: string;
|
|
||||||
readonly items: readonly { readonly def: ElectricalSymbolDef; readonly index: number }[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORY_META: Record<string, { nameKey: string; icon: string }> = {
|
interface CategoryGroup {
|
||||||
outlet: { nameKey: 'electrical.outlets', icon: '\u26A1' },
|
readonly category: string;
|
||||||
switch: { nameKey: 'electrical.switches', icon: '\u{1F50C}' },
|
readonly items: readonly IndexedDef[];
|
||||||
junction: { nameKey: 'electrical.junction', icon: '\u2B1C' },
|
}
|
||||||
light: { nameKey: 'electrical.lights', icon: '\u{1F4A1}' },
|
|
||||||
cable: { nameKey: 'electrical.cable', icon: '\u{1F517}' },
|
/**
|
||||||
|
* 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<string, { icon: string; key: string; order: number }> = {
|
||||||
|
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) {
|
export function ElectricalPalette({ selectedIndex, onSelect }: ElectricalPaletteProps) {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
const categories = useMemo<readonly CategoryGroup[]>(() => {
|
const [activeCategory, setActiveCategory] = useState<CategoryFilter>('all');
|
||||||
const groups = new Map<string, { readonly def: ElectricalSymbolDef; readonly index: number }[]>();
|
|
||||||
|
|
||||||
ELECTRICAL_SYMBOL_DEFS.forEach((def, index) => {
|
|
||||||
const list = groups.get(def.category) ?? [];
|
|
||||||
list.push({ def, index });
|
|
||||||
groups.set(def.category, list);
|
|
||||||
});
|
|
||||||
|
|
||||||
return Array.from(groups.entries()).map(([cat, items]) => ({
|
|
||||||
name: cat,
|
|
||||||
nameKey: CATEGORY_META[cat]?.nameKey ?? cat,
|
|
||||||
icon: CATEGORY_META[cat]?.icon ?? '',
|
|
||||||
items,
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
@@ -53,32 +48,174 @@ export function ElectricalPalette({ selectedIndex, onSelect }: ElectricalPalette
|
|||||||
[onSelect],
|
[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<readonly IndexedDef[]>(
|
||||||
|
() => 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<readonly string[]>(() => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
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<readonly CategoryGroup[]>(() => {
|
||||||
|
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<string, IndexedDef[]>();
|
||||||
|
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 (
|
return (
|
||||||
<div className={styles.palette}>
|
<div className={styles.palette}>
|
||||||
<div className={styles.header}>{t('electrical.title')}</div>
|
<div className={styles.header}>{t('electrical.title')}</div>
|
||||||
{categories.map((cat) => (
|
|
||||||
<div key={cat.name} className={styles.category}>
|
<div className={styles.searchRow}>
|
||||||
<div className={styles.categoryTitle}>
|
<span className={styles.searchIcon} aria-hidden>
|
||||||
{cat.icon} {t(cat.nameKey)}
|
{'\u{1F50D}'}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className={styles.searchInput}
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => 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 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.searchClear}
|
||||||
|
onClick={() => setSearch('')}
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
{'\u00D7'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.categoryChips}>
|
||||||
|
<CategoryChip
|
||||||
|
label={i18n.exists('furnitureCategory.all') ? t('furnitureCategory.all') : 'All'}
|
||||||
|
active={activeCategory === 'all'}
|
||||||
|
onClick={() => setActiveCategory('all')}
|
||||||
|
/>
|
||||||
|
{orderedCategories.map((category) => {
|
||||||
|
const meta = CATEGORY_META[category];
|
||||||
|
const icon = meta?.icon ?? '';
|
||||||
|
return (
|
||||||
|
<CategoryChip
|
||||||
|
key={category}
|
||||||
|
label={`${icon} ${categoryLabel(category)}`}
|
||||||
|
active={activeCategory === category}
|
||||||
|
onClick={() => setActiveCategory(category)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.scrollArea}>
|
||||||
|
{totalVisible === 0 ? (
|
||||||
|
<div className={styles.empty}>
|
||||||
|
{i18n.exists('electrical.noResults') ? t('electrical.noResults') : 'No matches'}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.itemGrid}>
|
) : (
|
||||||
{cat.items.map(({ def, index }) => (
|
visibleGroups.map((group) => {
|
||||||
<button
|
const meta = CATEGORY_META[group.category];
|
||||||
key={index}
|
const icon = meta?.icon ?? '';
|
||||||
className={[
|
return (
|
||||||
styles.itemBtn,
|
<div key={group.category} className={styles.section}>
|
||||||
selectedIndex === index ? styles.itemBtnActive : '',
|
<div className={styles.sectionHeader}>
|
||||||
].join(' ')}
|
<span aria-hidden>{icon}</span>
|
||||||
onClick={() => handleSelect(index)}
|
<span>{categoryLabel(group.category)}</span>
|
||||||
title={def.label}
|
<span className={styles.sectionCount}>{group.items.length}</span>
|
||||||
>
|
</div>
|
||||||
<span className={styles.itemIcon}>{cat.icon}</span>
|
<div className={styles.itemGrid}>
|
||||||
<span className={styles.itemLabel}>{def.label}</span>
|
{group.items.map(({ def, index }) => (
|
||||||
</button>
|
<button
|
||||||
))}
|
key={index}
|
||||||
</div>
|
type="button"
|
||||||
</div>
|
className={[
|
||||||
))}
|
styles.itemBtn,
|
||||||
|
selectedIndex === index ? styles.itemBtnActive : '',
|
||||||
|
].join(' ')}
|
||||||
|
onClick={() => handleSelect(index)}
|
||||||
|
title={def.label}
|
||||||
|
>
|
||||||
|
<span className={styles.itemIcon}>{icon}</span>
|
||||||
|
<span className={styles.itemLabel}>{def.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CategoryChipProps {
|
||||||
|
readonly label: string;
|
||||||
|
readonly active: boolean;
|
||||||
|
readonly onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryChip({ label, active, onClick }: CategoryChipProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={[styles.categoryChip, active ? styles.categoryChipActive : ''].join(' ')}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,48 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { FURNITURE_DEFS, type FurnitureDef } from '../symbols/furniture';
|
import {
|
||||||
import styles from './furniture-palette.module.css';
|
FURNITURE_DEFS,
|
||||||
|
FURNITURE_CATEGORIES,
|
||||||
|
type FurnitureDef,
|
||||||
|
type FurnitureCategory,
|
||||||
|
} from '../symbols/furniture';
|
||||||
|
import styles from './item-picker.module.css';
|
||||||
|
|
||||||
interface FurniturePaletteProps {
|
interface FurniturePaletteProps {
|
||||||
readonly selectedIndex: number | null;
|
readonly selectedIndex: number | null;
|
||||||
readonly onSelect: (index: number) => void;
|
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<FurnitureCategory, { icon: string; key: string }> = {
|
||||||
|
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) {
|
export function FurniturePalette({ selectedIndex, onSelect }: FurniturePaletteProps) {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [activeCategory, setActiveCategory] = useState<CategoryFilter>('all');
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
@@ -18,24 +51,152 @@ export function FurniturePalette({ selectedIndex, onSelect }: FurniturePalettePr
|
|||||||
[onSelect],
|
[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<readonly IndexedDef[]>(
|
||||||
|
() => 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<readonly CategoryGroup[]>(() => {
|
||||||
|
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<FurnitureCategory, IndexedDef[]>();
|
||||||
|
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 (
|
return (
|
||||||
<div className={styles.palette}>
|
<div className={styles.palette}>
|
||||||
<div className={styles.header}>{t('furniture.title')}</div>
|
<div className={styles.header}>{t('furniture.title')}</div>
|
||||||
<div className={styles.itemList}>
|
|
||||||
{FURNITURE_DEFS.map((def, index) => (
|
<div className={styles.searchRow}>
|
||||||
<FurnitureItemBtn
|
<span className={styles.searchIcon} aria-hidden>
|
||||||
key={index}
|
{'\u{1F50D}'}
|
||||||
def={def}
|
</span>
|
||||||
index={index}
|
<input
|
||||||
isActive={selectedIndex === index}
|
className={styles.searchInput}
|
||||||
onSelect={handleSelect}
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => 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 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.searchClear}
|
||||||
|
onClick={() => setSearch('')}
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
{'\u00D7'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.categoryChips}>
|
||||||
|
<CategoryChip
|
||||||
|
label={i18n.exists('furnitureCategory.all') ? t('furnitureCategory.all') : 'All'}
|
||||||
|
active={activeCategory === 'all'}
|
||||||
|
onClick={() => setActiveCategory('all')}
|
||||||
|
/>
|
||||||
|
{FURNITURE_CATEGORIES.map((category) => (
|
||||||
|
<CategoryChip
|
||||||
|
key={category}
|
||||||
|
label={`${CATEGORY_META[category].icon} ${categoryLabel(category)}`}
|
||||||
|
active={activeCategory === category}
|
||||||
|
onClick={() => setActiveCategory(category)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.scrollArea}>
|
||||||
|
{totalVisible === 0 ? (
|
||||||
|
<div className={styles.empty}>
|
||||||
|
{i18n.exists('furniture.noResults') ? t('furniture.noResults') : 'No matches'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
visibleGroups.map((group) => (
|
||||||
|
<div key={group.category} className={styles.section}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<span aria-hidden>{CATEGORY_META[group.category].icon}</span>
|
||||||
|
<span>{categoryLabel(group.category)}</span>
|
||||||
|
<span className={styles.sectionCount}>{group.items.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.itemGrid}>
|
||||||
|
{group.items.map(({ def, index }) => (
|
||||||
|
<FurnitureItemBtn
|
||||||
|
key={index}
|
||||||
|
def={def}
|
||||||
|
index={index}
|
||||||
|
isActive={selectedIndex === index}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CategoryChipProps {
|
||||||
|
readonly label: string;
|
||||||
|
readonly active: boolean;
|
||||||
|
readonly onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryChip({ label, active, onClick }: CategoryChipProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={[styles.categoryChip, active ? styles.categoryChipActive : ''].join(' ')}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface FurnitureItemBtnProps {
|
interface FurnitureItemBtnProps {
|
||||||
readonly def: FurnitureDef;
|
readonly def: FurnitureDef;
|
||||||
readonly index: number;
|
readonly index: number;
|
||||||
@@ -46,20 +207,16 @@ interface FurnitureItemBtnProps {
|
|||||||
function FurnitureItemBtn({ def, index, isActive, onSelect }: FurnitureItemBtnProps) {
|
function FurnitureItemBtn({ def, index, isActive, onSelect }: FurnitureItemBtnProps) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={[
|
type="button"
|
||||||
styles.itemBtn,
|
className={[styles.itemBtn, isActive ? styles.itemBtnActive : ''].join(' ')}
|
||||||
isActive ? styles.itemBtnActive : '',
|
|
||||||
].join(' ')}
|
|
||||||
onClick={() => onSelect(index)}
|
onClick={() => onSelect(index)}
|
||||||
title={`${def.label} (${def.width}m x ${def.depth}m)`}
|
title={`${def.label} (${def.width}m × ${def.depth}m × ${def.height}m)`}
|
||||||
>
|
>
|
||||||
<span className={styles.itemIcon}>{def.icon}</span>
|
<span className={styles.itemIcon}>{def.icon}</span>
|
||||||
<div className={styles.itemInfo}>
|
<span className={styles.itemLabel}>{def.label}</span>
|
||||||
<span className={styles.itemLabel}>{def.label}</span>
|
<span className={styles.itemDims}>
|
||||||
<span className={styles.itemDims}>
|
{def.width}×{def.depth}m
|
||||||
{def.width}m x {def.depth}m
|
</span>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Group, Circle, Line, Rect, Text } from 'react-konva';
|
import { Group, Circle, Line, Rect, Text } from 'react-konva';
|
||||||
import type { ProjectedElectrical } from '../utils/projectionMapping';
|
import type { ProjectedElectrical } from '../utils/projectionMapping';
|
||||||
import { projectionToPixel } from '../utils/projectionMapping';
|
import { projectionToPixel } from '../utils/projectionMapping';
|
||||||
|
import { DEFAULT_OUTLET_WIDTH, DEFAULT_OUTLET_HEIGHT } from '@house-plan-maker/shared';
|
||||||
|
|
||||||
interface ProjectionElectricalProps {
|
interface ProjectionElectricalProps {
|
||||||
readonly projected: ProjectedElectrical;
|
readonly projected: ProjectedElectrical;
|
||||||
@@ -11,6 +12,10 @@ interface ProjectionElectricalProps {
|
|||||||
readonly isDragging?: boolean;
|
readonly isDragging?: boolean;
|
||||||
readonly dragFromFloor?: number;
|
readonly dragFromFloor?: number;
|
||||||
readonly dragAlongWall?: 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 onClick: () => void;
|
||||||
readonly onDragStart?: (itemId: string, evt: MouseEvent) => void;
|
readonly onDragStart?: (itemId: string, evt: MouseEvent) => void;
|
||||||
}
|
}
|
||||||
@@ -27,6 +32,8 @@ export function ProjectionElectrical({
|
|||||||
isDragging = false,
|
isDragging = false,
|
||||||
dragFromFloor,
|
dragFromFloor,
|
||||||
dragAlongWall,
|
dragAlongWall,
|
||||||
|
outletWidth = DEFAULT_OUTLET_WIDTH,
|
||||||
|
outletHeight = DEFAULT_OUTLET_HEIGHT,
|
||||||
onClick,
|
onClick,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
}: ProjectionElectricalProps) {
|
}: ProjectionElectricalProps) {
|
||||||
@@ -70,29 +77,84 @@ export function ProjectionElectrical({
|
|||||||
fill="transparent"
|
fill="transparent"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{item.type === 'OUTLET' && (
|
{item.type === 'OUTLET' && (() => {
|
||||||
<>
|
const safeCount = Math.max(1, Math.round(item.count));
|
||||||
{/* IEC outlet symbol: circle with two horizontal lines */}
|
// Convert physical outlet dims to projection-pixel dims.
|
||||||
<Circle
|
const wPx = outletWidth * scale;
|
||||||
x={center.x}
|
const hPx = outletHeight * scale;
|
||||||
y={center.y}
|
// Anchor offset to bounding-box center, in projection pixels.
|
||||||
radius={half}
|
// Horizontal axis = along-wall (positive right), vertical axis = up the wall.
|
||||||
fill={fillColor}
|
// In screen coords +y is down, so vertical='top' anchor means center is BELOW (positive y).
|
||||||
stroke={strokeColor}
|
//
|
||||||
strokeWidth={1.5}
|
// When the projection axis is flipped (the canonical direction
|
||||||
/>
|
// runs opposite to the wall's stored start→end), we mirror the
|
||||||
<Line
|
// horizontal anchor so "left" still refers to the same physical
|
||||||
points={[center.x - 3, center.y - 2, center.x + 3, center.y - 2]}
|
// side of the wall in both 3D and projection views. Without this
|
||||||
stroke={strokeColor}
|
// an outlet anchored "left" on a flipped wall would appear on
|
||||||
strokeWidth={1.5}
|
// opposite sides of the two views.
|
||||||
/>
|
const anchor = item.positionAnchor;
|
||||||
<Line
|
const mirroredHorizontal = projected.axisFlipped
|
||||||
points={[center.x - 3, center.y + 2, center.x + 3, center.y + 2]}
|
? anchor.horizontal === 'left'
|
||||||
stroke={strokeColor}
|
? 'right'
|
||||||
strokeWidth={1.5}
|
: 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 (
|
||||||
|
<Group key={i}>
|
||||||
|
<Rect
|
||||||
|
x={cellLeft}
|
||||||
|
y={top}
|
||||||
|
width={wPx}
|
||||||
|
height={hPx}
|
||||||
|
cornerRadius={Math.max(1, cellMin * 0.12)}
|
||||||
|
fill={fillColor}
|
||||||
|
stroke={strokeColor}
|
||||||
|
strokeWidth={1.25}
|
||||||
|
/>
|
||||||
|
<Circle
|
||||||
|
x={cellCx}
|
||||||
|
y={cellCy}
|
||||||
|
radius={faceR}
|
||||||
|
stroke={strokeColor}
|
||||||
|
strokeWidth={1.25}
|
||||||
|
fill="transparent"
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
points={[cellCx - prongG, cellCy - prongL, cellCx - prongG, cellCy + prongL]}
|
||||||
|
stroke={strokeColor}
|
||||||
|
strokeWidth={1.25}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
points={[cellCx + prongG, cellCy - prongL, cellCx + prongG, cellCy + prongL]}
|
||||||
|
stroke={strokeColor}
|
||||||
|
strokeWidth={1.25}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{item.type === 'SWITCH' && (
|
{item.type === 'SWITCH' && (
|
||||||
<>
|
<>
|
||||||
{/* IEC switch symbol: circle with diagonal line */}
|
{/* IEC switch symbol: circle with diagonal line */}
|
||||||
@@ -157,12 +219,21 @@ export function ProjectionElectrical({
|
|||||||
strokeWidth={1.5}
|
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. */}
|
||||||
<Text
|
<Text
|
||||||
x={center.x - 20}
|
x={center.x - 30}
|
||||||
y={center.y + half + 2}
|
y={center.y + half + 2}
|
||||||
width={40}
|
width={60}
|
||||||
text={item.type === 'OUTLET' ? 'OUT' : item.type === 'SWITCH' ? 'SW' : 'WL'}
|
text={
|
||||||
|
item.label && item.label.trim().length > 0
|
||||||
|
? item.label
|
||||||
|
: item.type === 'OUTLET'
|
||||||
|
? 'OUT'
|
||||||
|
: item.type === 'SWITCH'
|
||||||
|
? 'SW'
|
||||||
|
: 'WL'
|
||||||
|
}
|
||||||
align="center"
|
align="center"
|
||||||
fontSize={8}
|
fontSize={8}
|
||||||
fill="#94a3b8"
|
fill="#94a3b8"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface ProjectionFurnitureProps {
|
|||||||
readonly scale: number;
|
readonly scale: number;
|
||||||
readonly padding: number;
|
readonly padding: number;
|
||||||
readonly isSelected: boolean;
|
readonly isSelected: boolean;
|
||||||
|
readonly globalOpacity?: number;
|
||||||
readonly onClick: () => void;
|
readonly onClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ export function ProjectionFurniture({
|
|||||||
scale,
|
scale,
|
||||||
padding,
|
padding,
|
||||||
isSelected,
|
isSelected,
|
||||||
|
globalOpacity = 1,
|
||||||
onClick,
|
onClick,
|
||||||
}: ProjectionFurnitureProps) {
|
}: ProjectionFurnitureProps) {
|
||||||
const { rect, item } = projected;
|
const { rect, item } = projected;
|
||||||
@@ -37,8 +39,9 @@ export function ProjectionFurniture({
|
|||||||
|
|
||||||
const color = TYPE_COLORS[item.type] ?? '#a0845c';
|
const color = TYPE_COLORS[item.type] ?? '#a0845c';
|
||||||
|
|
||||||
|
const itemOpacity = (item.opacity ?? 1) * globalOpacity;
|
||||||
return (
|
return (
|
||||||
<Group onClick={onClick}>
|
<Group onClick={onClick} opacity={itemOpacity}>
|
||||||
<Rect
|
<Rect
|
||||||
x={topLeft.x}
|
x={topLeft.x}
|
||||||
y={topLeft.y}
|
y={topLeft.y}
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Group, Line, Text } from 'react-konva';
|
import { Group, Rect, Line, Text } from 'react-konva';
|
||||||
import type { ProjectedOpening, ProjectedElectrical } from '../utils/projectionMapping';
|
import type {
|
||||||
|
ProjectedOpening,
|
||||||
|
ProjectedElectrical,
|
||||||
|
ProjectedFurniture,
|
||||||
|
} from '../utils/projectionMapping';
|
||||||
import { projectionToPixel } from '../utils/projectionMapping';
|
import { projectionToPixel } from '../utils/projectionMapping';
|
||||||
|
import { DEFAULT_OUTLET_WIDTH, DEFAULT_OUTLET_HEIGHT } from '@house-plan-maker/shared';
|
||||||
|
|
||||||
interface ProjectionMeasurementsProps {
|
interface ProjectionMeasurementsProps {
|
||||||
readonly projectedOpenings: readonly ProjectedOpening[];
|
readonly projectedOpenings: readonly ProjectedOpening[];
|
||||||
readonly projectedElectrical: readonly ProjectedElectrical[];
|
readonly projectedElectrical: readonly ProjectedElectrical[];
|
||||||
|
readonly projectedFurniture?: readonly ProjectedFurniture[];
|
||||||
readonly wallLength: number;
|
readonly wallLength: number;
|
||||||
readonly wallHeight: number;
|
readonly wallHeight: number;
|
||||||
readonly scale: number;
|
readonly scale: number;
|
||||||
readonly padding: number;
|
readonly padding: number;
|
||||||
|
readonly outletWidth?: number;
|
||||||
|
readonly outletHeight?: number;
|
||||||
|
/** When false, the wall-level dimensions/labels are skipped (useful when the
|
||||||
|
* measurements layer is toggled off but per-item overlays should still draw). */
|
||||||
|
readonly showWallDimensions?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dimension line with arrows and text. */
|
/** Dimension line with arrows and text. */
|
||||||
@@ -98,44 +109,50 @@ function formatM(meters: number): string {
|
|||||||
export function ProjectionMeasurements({
|
export function ProjectionMeasurements({
|
||||||
projectedOpenings,
|
projectedOpenings,
|
||||||
projectedElectrical,
|
projectedElectrical,
|
||||||
|
projectedFurniture = [],
|
||||||
wallLength: wallLen,
|
wallLength: wallLen,
|
||||||
wallHeight,
|
wallHeight,
|
||||||
scale,
|
scale,
|
||||||
padding,
|
padding,
|
||||||
|
outletWidth = DEFAULT_OUTLET_WIDTH,
|
||||||
|
outletHeight = DEFAULT_OUTLET_HEIGHT,
|
||||||
|
showWallDimensions = true,
|
||||||
}: ProjectionMeasurementsProps) {
|
}: ProjectionMeasurementsProps) {
|
||||||
const elements: ReactNode[] = [];
|
const elements: ReactNode[] = [];
|
||||||
|
|
||||||
// Wall width dimension (along bottom)
|
if (showWallDimensions) {
|
||||||
const floorLeft = projectionToPixel(0, 0, wallHeight, scale, padding);
|
// Wall width dimension (along bottom)
|
||||||
const floorRight = projectionToPixel(wallLen, 0, wallHeight, scale, padding);
|
const floorLeft = projectionToPixel(0, 0, wallHeight, scale, padding);
|
||||||
elements.push(
|
const floorRight = projectionToPixel(wallLen, 0, wallHeight, scale, padding);
|
||||||
<DimensionLine
|
elements.push(
|
||||||
key="wall-width"
|
<DimensionLine
|
||||||
x1={floorLeft.x}
|
key="wall-width"
|
||||||
y1={floorLeft.y}
|
x1={floorLeft.x}
|
||||||
x2={floorRight.x}
|
y1={floorLeft.y}
|
||||||
y2={floorRight.y}
|
x2={floorRight.x}
|
||||||
label={formatM(wallLen)}
|
y2={floorRight.y}
|
||||||
offset={18}
|
label={formatM(wallLen)}
|
||||||
horizontal
|
offset={18}
|
||||||
/>,
|
horizontal
|
||||||
);
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
// Wall height dimension (along right side)
|
// Wall height dimension (along right side)
|
||||||
const topRight = projectionToPixel(wallLen, wallHeight, wallHeight, scale, padding);
|
const topRight = projectionToPixel(wallLen, wallHeight, wallHeight, scale, padding);
|
||||||
const bottomRight = projectionToPixel(wallLen, 0, wallHeight, scale, padding);
|
const bottomRight = projectionToPixel(wallLen, 0, wallHeight, scale, padding);
|
||||||
elements.push(
|
elements.push(
|
||||||
<DimensionLine
|
<DimensionLine
|
||||||
key="wall-height"
|
key="wall-height"
|
||||||
x1={topRight.x}
|
x1={topRight.x}
|
||||||
y1={topRight.y}
|
y1={topRight.y}
|
||||||
x2={bottomRight.x}
|
x2={bottomRight.x}
|
||||||
y2={bottomRight.y}
|
y2={bottomRight.y}
|
||||||
label={formatM(wallHeight)}
|
label={formatM(wallHeight)}
|
||||||
offset={18}
|
offset={18}
|
||||||
horizontal={false}
|
horizontal={false}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Opening dimensions: sill height for windows, door height for doors
|
// Opening dimensions: sill height for windows, door height for doors
|
||||||
for (const po of projectedOpenings) {
|
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) {
|
for (const pe of projectedElectrical) {
|
||||||
const center = projectionToPixel(
|
const center = projectionToPixel(
|
||||||
pe.position.alongWall,
|
pe.position.alongWall,
|
||||||
@@ -200,16 +222,38 @@ export function ProjectionMeasurements({
|
|||||||
padding,
|
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 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(
|
elements.push(
|
||||||
<Text
|
<Group key={`elec-coord-${pe.item.id}`}>
|
||||||
key={`elec-coord-${pe.item.id}`}
|
<Rect
|
||||||
x={center.x + 10}
|
x={labelX - 2}
|
||||||
y={center.y - 4}
|
y={labelY - 1}
|
||||||
text={coordLabel}
|
width={labelWidth}
|
||||||
fontSize={9}
|
height={12}
|
||||||
fill="#64748b"
|
fill="rgba(255, 255, 255, 0.85)"
|
||||||
/>,
|
cornerRadius={2}
|
||||||
|
listening={false}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
x={labelX}
|
||||||
|
y={labelY}
|
||||||
|
text={coordLabel}
|
||||||
|
fontSize={9}
|
||||||
|
fill="#475569"
|
||||||
|
listening={false}
|
||||||
|
/>
|
||||||
|
</Group>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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(
|
||||||
|
<DimensionLine
|
||||||
|
key={`furn-w-${item.id}`}
|
||||||
|
x1={wLeftFloor.x}
|
||||||
|
y1={wLeftFloor.y}
|
||||||
|
x2={wRightFloor.x}
|
||||||
|
y2={wRightFloor.y}
|
||||||
|
label={formatM(rect.width)}
|
||||||
|
offset={32}
|
||||||
|
horizontal
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
if (rect.x > 0.001) {
|
||||||
|
const oLeft = projectionToPixel(0, 0, wallHeight, scale, padding);
|
||||||
|
elements.push(
|
||||||
|
<DimensionLine
|
||||||
|
key={`furn-off-${item.id}`}
|
||||||
|
x1={oLeft.x}
|
||||||
|
y1={oLeft.y}
|
||||||
|
x2={wLeftFloor.x}
|
||||||
|
y2={wLeftFloor.y}
|
||||||
|
label={formatM(rect.x)}
|
||||||
|
offset={46}
|
||||||
|
horizontal
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Inline width dimension drawn just below the bottom edge of the item
|
||||||
|
elements.push(
|
||||||
|
<DimensionLine
|
||||||
|
key={`furn-w-${item.id}`}
|
||||||
|
x1={wLeft.x}
|
||||||
|
y1={wLeft.y}
|
||||||
|
x2={wRight.x}
|
||||||
|
y2={wRight.y}
|
||||||
|
label={formatM(rect.width)}
|
||||||
|
offset={14}
|
||||||
|
horizontal
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// 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(
|
||||||
|
<DimensionLine
|
||||||
|
key={`furn-off-${item.id}`}
|
||||||
|
x1={oLeft.x}
|
||||||
|
y1={oLeft.y}
|
||||||
|
x2={wLeft.x}
|
||||||
|
y2={wLeft.y}
|
||||||
|
label={formatM(rect.x)}
|
||||||
|
offset={-6}
|
||||||
|
horizontal
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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(
|
||||||
|
<DimensionLine
|
||||||
|
key={`furn-h-${item.id}`}
|
||||||
|
x1={hTop.x}
|
||||||
|
y1={hTop.y}
|
||||||
|
x2={hBottom.x}
|
||||||
|
y2={hBottom.y}
|
||||||
|
label={formatM(rect.height)}
|
||||||
|
offset={-32}
|
||||||
|
horizontal={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
if (rect.y > 0.001) {
|
||||||
|
const eFloor = projectionToPixel(rect.x, 0, wallHeight, scale, padding);
|
||||||
|
elements.push(
|
||||||
|
<DimensionLine
|
||||||
|
key={`furn-elev-${item.id}`}
|
||||||
|
x1={hBottom.x}
|
||||||
|
y1={hBottom.y}
|
||||||
|
x2={eFloor.x}
|
||||||
|
y2={eFloor.y}
|
||||||
|
label={formatM(rect.y)}
|
||||||
|
offset={-46}
|
||||||
|
horizontal={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Inline height dimension drawn just to the left of the item
|
||||||
|
elements.push(
|
||||||
|
<DimensionLine
|
||||||
|
key={`furn-h-${item.id}`}
|
||||||
|
x1={hTop.x}
|
||||||
|
y1={hTop.y}
|
||||||
|
x2={hBottom.x}
|
||||||
|
y2={hBottom.y}
|
||||||
|
label={formatM(rect.height)}
|
||||||
|
offset={-14}
|
||||||
|
horizontal={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// 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(
|
||||||
|
<DimensionLine
|
||||||
|
key={`furn-elev-${item.id}`}
|
||||||
|
x1={eBottom.x}
|
||||||
|
y1={eBottom.y}
|
||||||
|
x2={eFloor.x}
|
||||||
|
y2={eFloor.y}
|
||||||
|
label={formatM(rect.y)}
|
||||||
|
offset={6}
|
||||||
|
horizontal={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return <Group>{elements}</Group>;
|
return <Group>{elements}</Group>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { useEditor } from '../context/EditorContext';
|
||||||
import { wallDirectionKey } from '../utils/projectionMapping';
|
import { wallDirectionKey, getProjectionAxis } from '../utils/projectionMapping';
|
||||||
import { wallStartEnd } from '../utils/wallUtils';
|
|
||||||
import { generateLocalId } from '../utils/geometry';
|
import { generateLocalId } from '../utils/geometry';
|
||||||
|
import { normalizeAngleDegrees } from '../utils/angle';
|
||||||
|
import { TextPromptModal } from '../../ui/TextPromptModal';
|
||||||
import { getDefaultElevation } from '../tools/ElectricalTool';
|
import { getDefaultElevation } from '../tools/ElectricalTool';
|
||||||
import { ELECTRICAL_SYMBOL_DEFS } from '../symbols/electrical';
|
import { ELECTRICAL_SYMBOL_DEFS } from '../symbols/electrical';
|
||||||
import { WallProjectionView } from './WallProjectionView';
|
import { WallProjectionView } from './WallProjectionView';
|
||||||
@@ -19,13 +20,14 @@ interface ProjectionPanelProps {
|
|||||||
|
|
||||||
export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPanelProps = {}) {
|
export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPanelProps = {}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { state, selectElement, updateElectrical, updateOpening, addElectrical } = useEditor();
|
const { state, selectElement, updateElectrical, updateOpening, addElectrical, updateAnnotation } = useEditor();
|
||||||
const {
|
const {
|
||||||
walls,
|
walls,
|
||||||
openings,
|
openings,
|
||||||
electricalItems,
|
electricalItems,
|
||||||
furnitureItems,
|
furnitureItems,
|
||||||
annotations,
|
annotations,
|
||||||
|
globalFurnitureOpacity,
|
||||||
room,
|
room,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
activeTool,
|
activeTool,
|
||||||
@@ -120,6 +122,21 @@ export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPane
|
|||||||
[updateOpening],
|
[updateOpening],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleUpdateAnnotation = useCallback(
|
||||||
|
(annotation: Annotation) => {
|
||||||
|
updateAnnotation(annotation);
|
||||||
|
},
|
||||||
|
[updateAnnotation],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Annotation editing modal (shared by all wall views) ──
|
||||||
|
const [editingAnnotationId, setEditingAnnotationId] = useState<string | null>(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 ──
|
// ── Placement callback ──
|
||||||
const handlePlaceElectrical = useCallback(
|
const handlePlaceElectrical = useCallback(
|
||||||
(wallId: string, alongWall: number, fromFloor: number) => {
|
(wallId: string, alongWall: number, fromFloor: number) => {
|
||||||
@@ -131,10 +148,9 @@ export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPane
|
|||||||
if (!wall) return;
|
if (!wall) return;
|
||||||
|
|
||||||
// Convert projection coordinates (alongWall) back to room 2D coordinates
|
// Convert projection coordinates (alongWall) back to room 2D coordinates
|
||||||
const { start, end } = wallStartEnd(wall);
|
// using the canonical projection axis (so south/west walls aren't mirrored).
|
||||||
const wallLen = Math.sqrt(
|
const axis = getProjectionAxis(wall);
|
||||||
(end.x - start.x) ** 2 + (end.y - start.y) ** 2,
|
const { start, end, length: wallLen } = axis;
|
||||||
);
|
|
||||||
if (wallLen === 0) return;
|
if (wallLen === 0) return;
|
||||||
|
|
||||||
const dx = (end.x - start.x) / wallLen;
|
const dx = (end.x - start.x) / wallLen;
|
||||||
@@ -147,8 +163,10 @@ export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPane
|
|||||||
? fromFloor
|
? fromFloor
|
||||||
: getDefaultElevation(symbolDef.type, room.wallHeight);
|
: 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<string, unknown> | null =
|
const metadata: Record<string, unknown> | null =
|
||||||
symbolDef.variant ? { variant: symbolDef.variant } : null;
|
symbolDef.type !== 'OUTLET' && symbolDef.variant ? { variant: symbolDef.variant } : null;
|
||||||
|
|
||||||
const newItem: ElectricalItem = {
|
const newItem: ElectricalItem = {
|
||||||
id: generateLocalId(),
|
id: generateLocalId(),
|
||||||
@@ -158,7 +176,10 @@ export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPane
|
|||||||
y,
|
y,
|
||||||
wallId: symbolDef.wallMounted ? wallId : null,
|
wallId: symbolDef.wallMounted ? wallId : null,
|
||||||
elevationFromFloor: elevation,
|
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,
|
metadata,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -179,13 +200,18 @@ export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPane
|
|||||||
electricalItems: layerVisibility.electrical ? electricalItems : [],
|
electricalItems: layerVisibility.electrical ? electricalItems : [],
|
||||||
furnitureItems: layerVisibility.furniture ? furnitureItems : [],
|
furnitureItems: layerVisibility.furniture ? furnitureItems : [],
|
||||||
annotations: layerVisibility.annotations ? annotations : [],
|
annotations: layerVisibility.annotations ? annotations : [],
|
||||||
|
globalFurnitureOpacity,
|
||||||
wallHeight: room.wallHeight,
|
wallHeight: room.wallHeight,
|
||||||
plinthHeight: room.plinthHeight,
|
plinthHeight: room.plinthHeight,
|
||||||
|
outletWidth: room.outletWidth,
|
||||||
|
outletHeight: room.outletHeight,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
onSelectElement: handleSelectElement,
|
onSelectElement: handleSelectElement,
|
||||||
onStageRef,
|
onStageRef,
|
||||||
onUpdateElectrical: handleUpdateElectrical,
|
onUpdateElectrical: handleUpdateElectrical,
|
||||||
onUpdateOpening: handleUpdateOpening,
|
onUpdateOpening: handleUpdateOpening,
|
||||||
|
onUpdateAnnotation: handleUpdateAnnotation,
|
||||||
|
onEditAnnotation: handleEditAnnotation,
|
||||||
onPlaceElectrical: handlePlaceElectrical,
|
onPlaceElectrical: handlePlaceElectrical,
|
||||||
showMeasurements: layerVisibility.measurements,
|
showMeasurements: layerVisibility.measurements,
|
||||||
activeTool,
|
activeTool,
|
||||||
@@ -294,6 +320,18 @@ export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPane
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<TextPromptModal
|
||||||
|
open={editingAnnotation != null}
|
||||||
|
title={t('annotation.editPrompt')}
|
||||||
|
initialValue={editingAnnotation?.text ?? ''}
|
||||||
|
onConfirm={(value) => {
|
||||||
|
if (editingAnnotation && value !== editingAnnotation.text) {
|
||||||
|
updateAnnotation({ ...editingAnnotation, text: value });
|
||||||
|
}
|
||||||
|
setEditingAnnotationId(null);
|
||||||
|
}}
|
||||||
|
onCancel={() => setEditingAnnotationId(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,51 +80,42 @@ export function ProjectionWindow({
|
|||||||
stroke="#93c5fd"
|
stroke="#93c5fd"
|
||||||
strokeWidth={0.5}
|
strokeWidth={0.5}
|
||||||
/>
|
/>
|
||||||
{/* Horizontal mullion (center divider) */}
|
{/* Internal mullions — N×M grid. Rendered as lines spanning the
|
||||||
<Line
|
glass area; `gridCols - 1` verticals + `gridRows - 1`
|
||||||
points={[
|
horizontals. Defaults to 2×2 for legacy windows without an
|
||||||
topLeft.x + frameInset,
|
explicit grid set. */}
|
||||||
topLeft.y + pxHeight / 2,
|
{(() => {
|
||||||
topLeft.x + pxWidth - frameInset,
|
const cols = Math.max(1, Math.min(10, Math.round(opening.gridCols ?? 2)));
|
||||||
topLeft.y + pxHeight / 2,
|
const rows = Math.max(1, Math.min(10, Math.round(opening.gridRows ?? 2)));
|
||||||
]}
|
const innerLeft = topLeft.x + frameInset;
|
||||||
stroke="#3b82f6"
|
const innerTop = topLeft.y + frameInset;
|
||||||
strokeWidth={1}
|
const innerWidth = pxWidth - frameInset * 2;
|
||||||
/>
|
const innerHeight = pxHeight - frameInset * 2;
|
||||||
{/* Vertical mullion (center divider) */}
|
const lines: React.ReactNode[] = [];
|
||||||
<Line
|
for (let i = 1; i < cols; i++) {
|
||||||
points={[
|
const x = innerLeft + (innerWidth * i) / cols;
|
||||||
topLeft.x + pxWidth / 2,
|
lines.push(
|
||||||
topLeft.y + frameInset,
|
<Line
|
||||||
topLeft.x + pxWidth / 2,
|
key={`vmul-${i}`}
|
||||||
topLeft.y + pxHeight - frameInset,
|
points={[x, innerTop, x, innerTop + innerHeight]}
|
||||||
]}
|
stroke="#3b82f6"
|
||||||
stroke="#3b82f6"
|
strokeWidth={1}
|
||||||
strokeWidth={1}
|
/>,
|
||||||
/>
|
);
|
||||||
{/* Glass cross lines for indication */}
|
}
|
||||||
<Line
|
for (let i = 1; i < rows; i++) {
|
||||||
points={[
|
const y = innerTop + (innerHeight * i) / rows;
|
||||||
topLeft.x + frameInset,
|
lines.push(
|
||||||
topLeft.y + frameInset,
|
<Line
|
||||||
topLeft.x + pxWidth / 2,
|
key={`hmul-${i}`}
|
||||||
topLeft.y + pxHeight / 2,
|
points={[innerLeft, y, innerLeft + innerWidth, y]}
|
||||||
]}
|
stroke="#3b82f6"
|
||||||
stroke="#93c5fd"
|
strokeWidth={1}
|
||||||
strokeWidth={0.5}
|
/>,
|
||||||
opacity={0.6}
|
);
|
||||||
/>
|
}
|
||||||
<Line
|
return lines;
|
||||||
points={[
|
})()}
|
||||||
topLeft.x + pxWidth - frameInset,
|
|
||||||
topLeft.y + frameInset,
|
|
||||||
topLeft.x + pxWidth / 2,
|
|
||||||
topLeft.y + pxHeight / 2,
|
|
||||||
]}
|
|
||||||
stroke="#93c5fd"
|
|
||||||
strokeWidth={0.5}
|
|
||||||
opacity={0.6}
|
|
||||||
/>
|
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Stage, Layer, Rect, Line, Text, Group } from 'react-konva';
|
import { Stage, Layer, Rect, Line, Text, Group } from 'react-konva';
|
||||||
import type Konva from 'konva';
|
import type Konva from 'konva';
|
||||||
import type { Wall, WallOpening, ElectricalItem, FurnitureItem, ElectricalType, Annotation } from '@house-plan-maker/shared';
|
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 {
|
import {
|
||||||
projectionScale,
|
projectionScale,
|
||||||
projectionToPixel,
|
projectionToPixel,
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
projectFurnitureItems,
|
projectFurnitureItems,
|
||||||
computePlinthSegments,
|
computePlinthSegments,
|
||||||
wallDirectionLabel,
|
wallDirectionLabel,
|
||||||
|
getProjectionAxis,
|
||||||
} from '../utils/projectionMapping';
|
} from '../utils/projectionMapping';
|
||||||
import { ProjectionDoor } from './ProjectionDoor';
|
import { ProjectionDoor } from './ProjectionDoor';
|
||||||
import { ProjectionWindow } from './ProjectionWindow';
|
import { ProjectionWindow } from './ProjectionWindow';
|
||||||
@@ -27,9 +28,12 @@ interface WallProjectionViewProps {
|
|||||||
readonly electricalItems: readonly ElectricalItem[];
|
readonly electricalItems: readonly ElectricalItem[];
|
||||||
readonly furnitureItems: readonly FurnitureItem[];
|
readonly furnitureItems: readonly FurnitureItem[];
|
||||||
readonly annotations: readonly Annotation[];
|
readonly annotations: readonly Annotation[];
|
||||||
|
readonly globalFurnitureOpacity?: number;
|
||||||
readonly showMeasurements?: boolean;
|
readonly showMeasurements?: boolean;
|
||||||
readonly wallHeight: number;
|
readonly wallHeight: number;
|
||||||
readonly plinthHeight: number;
|
readonly plinthHeight: number;
|
||||||
|
readonly outletWidth?: number;
|
||||||
|
readonly outletHeight?: number;
|
||||||
readonly selectedIds: ReadonlySet<string>;
|
readonly selectedIds: ReadonlySet<string>;
|
||||||
readonly isHighlighted: boolean;
|
readonly isHighlighted: boolean;
|
||||||
readonly onSelectElement: (id: string) => void;
|
readonly onSelectElement: (id: string) => void;
|
||||||
@@ -38,6 +42,8 @@ interface WallProjectionViewProps {
|
|||||||
readonly onStageRef?: (wallId: string, stage: Konva.Stage | null) => void;
|
readonly onStageRef?: (wallId: string, stage: Konva.Stage | null) => void;
|
||||||
readonly onUpdateElectrical?: (item: ElectricalItem) => void;
|
readonly onUpdateElectrical?: (item: ElectricalItem) => void;
|
||||||
readonly onUpdateOpening?: (opening: WallOpening) => 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 onPlaceElectrical?: (wallId: string, alongWall: number, fromFloor: number) => void;
|
||||||
readonly activeTool?: EditorToolType;
|
readonly activeTool?: EditorToolType;
|
||||||
readonly selectedElectricalType?: ElectricalType | null;
|
readonly selectedElectricalType?: ElectricalType | null;
|
||||||
@@ -60,6 +66,15 @@ interface DragInfo {
|
|||||||
readonly itemId: string;
|
readonly itemId: string;
|
||||||
readonly startPixelX: number;
|
readonly startPixelX: number;
|
||||||
readonly startPixelY: 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;
|
readonly exceeded: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,9 +84,12 @@ export function WallProjectionView({
|
|||||||
electricalItems,
|
electricalItems,
|
||||||
furnitureItems,
|
furnitureItems,
|
||||||
annotations,
|
annotations,
|
||||||
|
globalFurnitureOpacity = 1,
|
||||||
showMeasurements = true,
|
showMeasurements = true,
|
||||||
wallHeight,
|
wallHeight,
|
||||||
plinthHeight,
|
plinthHeight,
|
||||||
|
outletWidth,
|
||||||
|
outletHeight,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
isHighlighted,
|
isHighlighted,
|
||||||
onSelectElement,
|
onSelectElement,
|
||||||
@@ -80,6 +98,8 @@ export function WallProjectionView({
|
|||||||
onStageRef,
|
onStageRef,
|
||||||
onUpdateElectrical,
|
onUpdateElectrical,
|
||||||
onUpdateOpening,
|
onUpdateOpening,
|
||||||
|
onUpdateAnnotation,
|
||||||
|
onEditAnnotation,
|
||||||
onPlaceElectrical,
|
onPlaceElectrical,
|
||||||
activeTool,
|
activeTool,
|
||||||
selectedElectricalType,
|
selectedElectricalType,
|
||||||
@@ -102,6 +122,7 @@ export function WallProjectionView({
|
|||||||
viewPanRef.current = viewPan;
|
viewPanRef.current = viewPan;
|
||||||
|
|
||||||
const wallLen = computeWallLength(wall);
|
const wallLen = computeWallLength(wall);
|
||||||
|
const projectionAxis = useMemo(() => getProjectionAxis(wall), [wall]);
|
||||||
const baseScale = projectionScale(wallLen, wallHeight, width, height, PADDING);
|
const baseScale = projectionScale(wallLen, wallHeight, width, height, PADDING);
|
||||||
const effectiveScale = baseScale * viewZoom;
|
const effectiveScale = baseScale * viewZoom;
|
||||||
|
|
||||||
@@ -111,6 +132,32 @@ export function WallProjectionView({
|
|||||||
const [dragElectricalAlongWall, setDragElectricalAlongWall] = useState<{ itemId: string; alongWall: number } | null>(null);
|
const [dragElectricalAlongWall, setDragElectricalAlongWall] = useState<{ itemId: string; alongWall: number } | null>(null);
|
||||||
const [dragOpeningAlongWall, setDragOpeningAlongWall] = useState<{ openingId: 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<ProjectionMeasurement | null>(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) ──
|
// ── Projected data (memoized) ──
|
||||||
const projectedOpenings = useMemo(
|
const projectedOpenings = useMemo(
|
||||||
() => projectOpenings(wall, openings),
|
() => projectOpenings(wall, openings),
|
||||||
@@ -144,26 +191,66 @@ export function WallProjectionView({
|
|||||||
// ── Electrical drag start ──
|
// ── Electrical drag start ──
|
||||||
const handleElectricalDragStart = useCallback((itemId: string, evt: MouseEvent) => {
|
const handleElectricalDragStart = useCallback((itemId: string, evt: MouseEvent) => {
|
||||||
if (!onUpdateElectrical) return;
|
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 = {
|
dragRef.current = {
|
||||||
kind: 'electrical-elevation',
|
kind: 'electrical-elevation',
|
||||||
itemId,
|
itemId,
|
||||||
startPixelX: evt.clientX,
|
startPixelX: evt.clientX,
|
||||||
startPixelY: evt.clientY,
|
startPixelY: evt.clientY,
|
||||||
|
offsetAlongWall: proj.alongWall - itemAlongWall,
|
||||||
|
offsetFromFloor: proj.fromFloor - itemFromFloor,
|
||||||
exceeded: false,
|
exceeded: false,
|
||||||
};
|
};
|
||||||
}, [onUpdateElectrical]);
|
}, [onUpdateElectrical, electricalItems, getStagePointer, projectionAxis, wallLen, wallHeight, effectiveScale]);
|
||||||
|
|
||||||
// ── Opening drag start ──
|
// ── Opening drag start ──
|
||||||
const handleOpeningDragStart = useCallback((openingId: string, evt: MouseEvent) => {
|
const handleOpeningDragStart = useCallback((openingId: string, evt: MouseEvent) => {
|
||||||
if (!onUpdateOpening) return;
|
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 = {
|
dragRef.current = {
|
||||||
kind: 'opening-position',
|
kind: 'opening-position',
|
||||||
itemId: openingId,
|
itemId: openingId,
|
||||||
startPixelX: evt.clientX,
|
startPixelX: evt.clientX,
|
||||||
startPixelY: evt.clientY,
|
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,
|
exceeded: false,
|
||||||
};
|
};
|
||||||
}, [onUpdateOpening]);
|
}, [onUpdateOpening, openings, getStagePointer, projectionAxis, wallLen, wallHeight, effectiveScale]);
|
||||||
|
|
||||||
// ── Zoom handler ──
|
// ── Zoom handler ──
|
||||||
const handleWheel = useCallback((e: Konva.KonvaEventObject<WheelEvent>) => {
|
const handleWheel = useCallback((e: Konva.KonvaEventObject<WheelEvent>) => {
|
||||||
@@ -200,6 +287,34 @@ export function WallProjectionView({
|
|||||||
|
|
||||||
// ── Pan handlers ──
|
// ── Pan handlers ──
|
||||||
const handleMouseDown = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
|
const handleMouseDown = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||||
|
// 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
|
// Middle mouse or left mouse on empty space = pan
|
||||||
const inPlacementMode = activeTool === 'electrical' && selectedElectricalType != null;
|
const inPlacementMode = activeTool === 'electrical' && selectedElectricalType != null;
|
||||||
if (e.evt.button === 1 || (e.evt.button === 0 && !inPlacementMode)) {
|
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')) {
|
if (e.evt.button === 0 && e.target === e.currentTarget?.getStage()?.findOne('.wall-bg')) {
|
||||||
// This is handled in the wall-bg rect click
|
// This is handled in the wall-bg rect click
|
||||||
}
|
}
|
||||||
}, [viewPan]);
|
}, [activeTool, selectedElectricalType, viewPan, wallHeight, effectiveScale, getStagePointer]);
|
||||||
|
|
||||||
const handleMouseMove = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
|
const handleMouseMove = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||||
// Handle panning
|
// Handle panning
|
||||||
@@ -235,6 +350,29 @@ export function WallProjectionView({
|
|||||||
return;
|
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
|
// Handle dragging
|
||||||
const drag = dragRef.current;
|
const drag = dragRef.current;
|
||||||
if (!drag) return;
|
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);
|
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 (drag.kind === 'electrical-elevation') {
|
||||||
if (e.evt.ctrlKey || e.evt.metaKey) {
|
if (e.evt.ctrlKey || e.evt.metaKey) {
|
||||||
// Ctrl+drag: move horizontally along wall
|
// 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 });
|
setDragElectricalAlongWall({ itemId: drag.itemId, alongWall: clampedAlongWall });
|
||||||
setDragElectricalFromFloor(null);
|
setDragElectricalFromFloor(null);
|
||||||
} else {
|
} else {
|
||||||
// Normal drag: move vertically (elevation)
|
// 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 });
|
setDragElectricalFromFloor({ itemId: drag.itemId, fromFloor: clampedFromFloor });
|
||||||
setDragElectricalAlongWall(null);
|
setDragElectricalAlongWall(null);
|
||||||
}
|
}
|
||||||
} else if (drag.kind === 'opening-position') {
|
} else if (drag.kind === 'opening-position') {
|
||||||
const opening = openings.find((o) => o.id === drag.itemId);
|
const opening = openings.find((o) => o.id === drag.itemId);
|
||||||
const halfWidth = opening ? opening.width / 2 : 0;
|
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 });
|
setDragOpeningAlongWall({ openingId: drag.itemId, alongWall: clampedAlongWall });
|
||||||
}
|
}
|
||||||
}, [getStagePointer, wallHeight, effectiveScale, wallLen, openings]);
|
}, [getStagePointer, wallHeight, effectiveScale, wallLen, openings]);
|
||||||
@@ -277,6 +421,21 @@ export function WallProjectionView({
|
|||||||
const handleMouseUp = useCallback(() => {
|
const handleMouseUp = useCallback(() => {
|
||||||
isPanningRef.current = false;
|
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;
|
const drag = dragRef.current;
|
||||||
if (!drag) return;
|
if (!drag) return;
|
||||||
|
|
||||||
@@ -295,8 +454,8 @@ export function WallProjectionView({
|
|||||||
const item = electricalItems.find((i) => i.id === drag.itemId);
|
const item = electricalItems.find((i) => i.id === drag.itemId);
|
||||||
if (item) {
|
if (item) {
|
||||||
if (dragElectricalAlongWall) {
|
if (dragElectricalAlongWall) {
|
||||||
// Horizontal drag: compute new x,y from alongWall position on the wall
|
// Horizontal drag: compute new x,y from canonical alongWall position
|
||||||
const { start, end } = wallStartEnd(wall);
|
const { start, end } = projectionAxis;
|
||||||
const wLen = wallLen || 1;
|
const wLen = wallLen || 1;
|
||||||
const t = dragElectricalAlongWall.alongWall / wLen;
|
const t = dragElectricalAlongWall.alongWall / wLen;
|
||||||
onUpdateElectrical({
|
onUpdateElectrical({
|
||||||
@@ -314,9 +473,13 @@ export function WallProjectionView({
|
|||||||
} else if (drag.kind === 'opening-position' && dragOpeningAlongWall && onUpdateOpening) {
|
} else if (drag.kind === 'opening-position' && dragOpeningAlongWall && onUpdateOpening) {
|
||||||
const opening = openings.find((o) => o.id === drag.itemId);
|
const opening = openings.find((o) => o.id === drag.itemId);
|
||||||
if (opening) {
|
if (opening) {
|
||||||
|
// Map canonical projection position back to storage (relative to wall.start)
|
||||||
|
const storedPos = projectionAxis.flipped
|
||||||
|
? wallLen - dragOpeningAlongWall.alongWall
|
||||||
|
: dragOpeningAlongWall.alongWall;
|
||||||
onUpdateOpening({
|
onUpdateOpening({
|
||||||
...opening,
|
...opening,
|
||||||
positionAlongWall: Math.round(dragOpeningAlongWall.alongWall * 100) / 100,
|
positionAlongWall: Math.round(storedPos * 100) / 100,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,7 +487,7 @@ export function WallProjectionView({
|
|||||||
setDragElectricalFromFloor(null);
|
setDragElectricalFromFloor(null);
|
||||||
setDragElectricalAlongWall(null);
|
setDragElectricalAlongWall(null);
|
||||||
setDragOpeningAlongWall(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 ──
|
// ── Handle click on wall background for placement ──
|
||||||
const handleWallBgClick = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
|
const handleWallBgClick = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||||
@@ -343,11 +506,14 @@ export function WallProjectionView({
|
|||||||
onPlaceElectrical(wall.id, proj.alongWall, proj.fromFloor);
|
onPlaceElectrical(wall.id, proj.alongWall, proj.fromFloor);
|
||||||
}, [activeTool, selectedElectricalType, onPlaceElectrical, getStagePointer, wallHeight, effectiveScale, wallLen, wall.id]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
setViewZoom(1);
|
setViewZoom(1);
|
||||||
setViewPan({ x: 0, y: 0 });
|
setViewPan({ x: 0, y: 0 });
|
||||||
}, [wall.id]);
|
}, [wallKey]);
|
||||||
|
|
||||||
// ── Coordinate helpers ──
|
// ── Coordinate helpers ──
|
||||||
const toPixel = useCallback(
|
const toPixel = useCallback(
|
||||||
@@ -597,6 +763,7 @@ export function WallProjectionView({
|
|||||||
scale={effectiveScale}
|
scale={effectiveScale}
|
||||||
padding={PADDING}
|
padding={PADDING}
|
||||||
isSelected={selectedIds.has(pf.item.id)}
|
isSelected={selectedIds.has(pf.item.id)}
|
||||||
|
globalOpacity={globalFurnitureOpacity}
|
||||||
onClick={() => onSelectElement(pf.item.id)}
|
onClick={() => onSelectElement(pf.item.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -616,25 +783,81 @@ export function WallProjectionView({
|
|||||||
isDragging={isDraggingV || isDraggingH}
|
isDragging={isDraggingV || isDraggingH}
|
||||||
dragFromFloor={isDraggingV ? dragElectricalFromFloor?.fromFloor : undefined}
|
dragFromFloor={isDraggingV ? dragElectricalFromFloor?.fromFloor : undefined}
|
||||||
dragAlongWall={isDraggingH ? dragElectricalAlongWall?.alongWall : undefined}
|
dragAlongWall={isDraggingH ? dragElectricalAlongWall?.alongWall : undefined}
|
||||||
|
outletWidth={outletWidth}
|
||||||
|
outletHeight={outletHeight}
|
||||||
onClick={() => onSelectElement(pe.item.id)}
|
onClick={() => onSelectElement(pe.item.id)}
|
||||||
onDragStart={onUpdateElectrical ? handleElectricalDragStart : undefined}
|
onDragStart={onUpdateElectrical ? handleElectricalDragStart : undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Measurements */}
|
{/* Measure tool overlay — a dashed line from start to end with a
|
||||||
{showMeasurements && (
|
distance label. Rendered above the items but below annotations.
|
||||||
<ProjectionMeasurements
|
Dimensions are in wall-surface metres (√((Δalong)² + (Δup)²)). */}
|
||||||
projectedOpenings={projectedOpenings}
|
{measurement && (() => {
|
||||||
projectedElectrical={projectedElectrical}
|
const startPx = projectionToPixel(
|
||||||
wallLength={wallLen}
|
measurement.start.alongWall,
|
||||||
wallHeight={wallHeight}
|
measurement.start.fromFloor,
|
||||||
scale={effectiveScale}
|
wallHeight,
|
||||||
padding={PADDING}
|
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 (
|
||||||
|
<Group listening={false}>
|
||||||
|
<Line
|
||||||
|
points={[startPx.x, startPx.y, endPx.x, endPx.y]}
|
||||||
|
stroke="#e74c3c"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
dash={[8, 4]}
|
||||||
|
/>
|
||||||
|
{/* Endpoint dots */}
|
||||||
|
<Rect x={startPx.x - 3} y={startPx.y - 3} width={6} height={6} fill="#e74c3c" cornerRadius={3} />
|
||||||
|
<Rect x={endPx.x - 3} y={endPx.y - 3} width={6} height={6} fill="#e74c3c" cornerRadius={3} />
|
||||||
|
{distM > 0.005 && (
|
||||||
|
<Text
|
||||||
|
x={midX + 8}
|
||||||
|
y={midY - 14}
|
||||||
|
text={label}
|
||||||
|
fontSize={12}
|
||||||
|
fontFamily="sans-serif"
|
||||||
|
fontStyle="bold"
|
||||||
|
fill="#e74c3c"
|
||||||
|
padding={2}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* 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) */}
|
||||||
|
<ProjectionMeasurements
|
||||||
|
projectedOpenings={showMeasurements ? projectedOpenings : []}
|
||||||
|
projectedElectrical={showMeasurements ? projectedElectrical : []}
|
||||||
|
projectedFurniture={projectedFurniture}
|
||||||
|
wallLength={wallLen}
|
||||||
|
wallHeight={wallHeight}
|
||||||
|
scale={effectiveScale}
|
||||||
|
padding={PADDING}
|
||||||
|
outletWidth={outletWidth}
|
||||||
|
outletHeight={outletHeight}
|
||||||
|
showWallDimensions={showMeasurements}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Attached annotations for items on this wall — interactive */}
|
||||||
{annotations
|
{annotations
|
||||||
.filter((ann) => {
|
.filter((ann) => {
|
||||||
if (!ann.attachedToId) return false;
|
if (!ann.attachedToId) return false;
|
||||||
@@ -642,7 +865,6 @@ export function WallProjectionView({
|
|||||||
projectedFurniture.some((pf) => pf.item.id === ann.attachedToId);
|
projectedFurniture.some((pf) => pf.item.id === ann.attachedToId);
|
||||||
})
|
})
|
||||||
.map((ann) => {
|
.map((ann) => {
|
||||||
// Find parent item position in projection coords
|
|
||||||
const elec = projectedElectrical.find((pe) => pe.item.id === ann.attachedToId);
|
const elec = projectedElectrical.find((pe) => pe.item.id === ann.attachedToId);
|
||||||
const furn = projectedFurniture.find((pf) => pf.item.id === ann.attachedToId);
|
const furn = projectedFurniture.find((pf) => pf.item.id === ann.attachedToId);
|
||||||
let anchorAlongWall = 0;
|
let anchorAlongWall = 0;
|
||||||
@@ -655,34 +877,80 @@ export function WallProjectionView({
|
|||||||
anchorFromFloor = furn.rect.y + furn.rect.height;
|
anchorFromFloor = furn.rect.y + furn.rect.height;
|
||||||
}
|
}
|
||||||
const anchorPx = projectionToPixel(anchorAlongWall, anchorFromFloor, wallHeight, effectiveScale, PADDING);
|
const anchorPx = projectionToPixel(anchorAlongWall, anchorFromFloor, wallHeight, effectiveScale, PADDING);
|
||||||
// Offset annotation slightly
|
// Use the dedicated projection offset (defaulting to a small offset
|
||||||
const textX = anchorPx.x + ann.x * effectiveScale;
|
// up & to the right of the anchor) so dragging in this view does
|
||||||
const textY = anchorPx.y + ann.y * effectiveScale;
|
// 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 (
|
return (
|
||||||
<Group key={ann.id}>
|
<Group key={ann.id}>
|
||||||
<Line
|
<Line
|
||||||
points={[anchorPx.x, anchorPx.y, textX, textY]}
|
points={[anchorPx.x, anchorPx.y, textX + boxWidth / 2, textY + boxHeight / 2]}
|
||||||
stroke="#94a3b8"
|
stroke={isSelected ? '#2563eb' : '#94a3b8'}
|
||||||
strokeWidth={0.5}
|
strokeWidth={0.6}
|
||||||
dash={[2, 2]}
|
dash={[2, 2]}
|
||||||
listening={false}
|
listening={false}
|
||||||
/>
|
/>
|
||||||
<Rect
|
<Group
|
||||||
x={textX - 2}
|
|
||||||
y={textY - 1}
|
|
||||||
width={ann.text.length * 7 + 4}
|
|
||||||
height={12}
|
|
||||||
fill="rgba(255,255,255,0.9)"
|
|
||||||
cornerRadius={2}
|
|
||||||
listening={false}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
x={textX}
|
x={textX}
|
||||||
y={textY}
|
y={textY}
|
||||||
text={ann.text}
|
draggable={onUpdateAnnotation != null}
|
||||||
fontSize={10}
|
onClick={(e) => {
|
||||||
fill={ann.color ?? '#334155'}
|
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,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Rect
|
||||||
|
x={-2}
|
||||||
|
y={-1}
|
||||||
|
width={boxWidth}
|
||||||
|
height={boxHeight}
|
||||||
|
fill="rgba(255,255,255,0.92)"
|
||||||
|
stroke={isSelected ? '#2563eb' : 'transparent'}
|
||||||
|
strokeWidth={isSelected ? 1 : 0}
|
||||||
|
cornerRadius={2}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
text={ann.text}
|
||||||
|
fontSize={fontSize}
|
||||||
|
fill={textFill}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
@@ -17,6 +21,40 @@
|
|||||||
text-transform: uppercase;
|
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 {
|
.empty {
|
||||||
padding: var(--space-6) var(--space-4);
|
padding: var(--space-6) var(--space-4);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -1,77 +1,100 @@
|
|||||||
import { Group, Circle, Line } from 'react-konva';
|
import { Group, Rect, Circle, Line } from 'react-konva';
|
||||||
|
|
||||||
interface OutletSymbolProps {
|
interface OutletSymbolProps {
|
||||||
|
/** Screen-space position of the anchor point (already includes pan/zoom + anchor offset). */
|
||||||
readonly x: number;
|
readonly x: number;
|
||||||
readonly y: number;
|
readonly y: number;
|
||||||
|
/** Item rotation in degrees. Applied around the local origin. */
|
||||||
readonly rotation: number;
|
readonly rotation: number;
|
||||||
readonly color: string;
|
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.
|
* Outlet symbol — renders `count` adjacent face plates side-by-side along the
|
||||||
* Base: circle with two parallel prongs.
|
* 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. */
|
// Inner outlet face geometry: shrink to fit the smaller dimension of one cell.
|
||||||
export function SingleOutletSymbol({ x, y, rotation, color, scale }: OutletSymbolProps) {
|
const cellMin = Math.min(outletWidthPx, outletHeightPx);
|
||||||
const r = 8 * scale;
|
const faceRadius = cellMin * 0.32;
|
||||||
const prongLen = 4 * scale;
|
const prongLen = cellMin * 0.18;
|
||||||
const prongGap = 3 * scale;
|
const prongGap = cellMin * 0.12;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group x={x} y={y} rotation={rotation}>
|
<Group x={x} y={y} rotation={rotation} listening={false}>
|
||||||
<Circle radius={r} stroke={color} strokeWidth={1.5} fill="transparent" listening={false} />
|
{Array.from({ length: safeCount }).map((_, i) => {
|
||||||
<Line points={[-prongGap, -prongLen, -prongGap, prongLen]} stroke={color} strokeWidth={1.5} listening={false} />
|
const cellLeft = left + i * outletWidthPx;
|
||||||
<Line points={[prongGap, -prongLen, prongGap, prongLen]} stroke={color} strokeWidth={1.5} listening={false} />
|
const cellCenterX = cellLeft + outletWidthPx / 2;
|
||||||
</Group>
|
const cellCenterY = top + outletHeightPx / 2;
|
||||||
);
|
return (
|
||||||
}
|
<Group key={i}>
|
||||||
|
<Rect
|
||||||
/** Double outlet: two overlapping circles + prongs. */
|
x={cellLeft}
|
||||||
export function DoubleOutletSymbol({ x, y, rotation, color, scale }: OutletSymbolProps) {
|
y={top}
|
||||||
const r = 8 * scale;
|
width={outletWidthPx}
|
||||||
const offset = 6 * scale;
|
height={outletHeightPx}
|
||||||
const prongLen = 3 * scale;
|
cornerRadius={Math.max(1, cellMin * 0.12)}
|
||||||
const prongGap = 2.5 * scale;
|
stroke={color}
|
||||||
|
strokeWidth={1.25}
|
||||||
return (
|
fill="rgba(255, 255, 255, 0.55)"
|
||||||
<Group x={x} y={y} rotation={rotation}>
|
listening={false}
|
||||||
{/* Left outlet */}
|
/>
|
||||||
<Group x={-offset}>
|
<Circle
|
||||||
<Circle radius={r} stroke={color} strokeWidth={1.5} fill="transparent" listening={false} />
|
x={cellCenterX}
|
||||||
<Line points={[-prongGap, -prongLen, -prongGap, prongLen]} stroke={color} strokeWidth={1.5} listening={false} />
|
y={cellCenterY}
|
||||||
<Line points={[prongGap, -prongLen, prongGap, prongLen]} stroke={color} strokeWidth={1.5} listening={false} />
|
radius={faceRadius}
|
||||||
</Group>
|
stroke={color}
|
||||||
{/* Right outlet */}
|
strokeWidth={1.25}
|
||||||
<Group x={offset}>
|
fill="transparent"
|
||||||
<Circle radius={r} stroke={color} strokeWidth={1.5} fill="transparent" listening={false} />
|
listening={false}
|
||||||
<Line points={[-prongGap, -prongLen, -prongGap, prongLen]} stroke={color} strokeWidth={1.5} listening={false} />
|
/>
|
||||||
<Line points={[prongGap, -prongLen, prongGap, prongLen]} stroke={color} strokeWidth={1.5} listening={false} />
|
<Line
|
||||||
</Group>
|
points={[cellCenterX - prongGap, cellCenterY - prongLen, cellCenterX - prongGap, cellCenterY + prongLen]}
|
||||||
</Group>
|
stroke={color}
|
||||||
);
|
strokeWidth={1.25}
|
||||||
}
|
listening={false}
|
||||||
|
/>
|
||||||
/** Grounded outlet: circle + two prongs + earth symbol (horizontal line + ground lines below). */
|
<Line
|
||||||
export function GroundedOutletSymbol({ x, y, rotation, color, scale }: OutletSymbolProps) {
|
points={[cellCenterX + prongGap, cellCenterY - prongLen, cellCenterX + prongGap, cellCenterY + prongLen]}
|
||||||
const r = 8 * scale;
|
stroke={color}
|
||||||
const prongLen = 3 * scale;
|
strokeWidth={1.25}
|
||||||
const prongGap = 3 * scale;
|
listening={false}
|
||||||
const earthY = 5 * scale;
|
/>
|
||||||
const earthW = 4 * scale;
|
</Group>
|
||||||
|
);
|
||||||
return (
|
})}
|
||||||
<Group x={x} y={y} rotation={rotation}>
|
|
||||||
<Circle radius={r} stroke={color} strokeWidth={1.5} fill="transparent" listening={false} />
|
|
||||||
{/* Prongs */}
|
|
||||||
<Line points={[-prongGap, -prongLen, -prongGap, prongLen - 1]} stroke={color} strokeWidth={1.5} listening={false} />
|
|
||||||
<Line points={[prongGap, -prongLen, prongGap, prongLen - 1]} stroke={color} strokeWidth={1.5} listening={false} />
|
|
||||||
{/* Earth symbol — vertical line down + three horizontal lines */}
|
|
||||||
<Line points={[0, prongLen, 0, earthY]} stroke={color} strokeWidth={1.5} listening={false} />
|
|
||||||
<Line points={[-earthW, earthY, earthW, earthY]} stroke={color} strokeWidth={1.5} listening={false} />
|
|
||||||
<Line points={[-earthW * 0.6, earthY + 2 * scale, earthW * 0.6, earthY + 2 * scale]} stroke={color} strokeWidth={1.2} listening={false} />
|
|
||||||
<Line points={[-earthW * 0.3, earthY + 4 * scale, earthW * 0.3, earthY + 4 * scale]} stroke={color} strokeWidth={1} listening={false} />
|
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export { SingleOutletSymbol, DoubleOutletSymbol, GroundedOutletSymbol } from './OutletSymbol';
|
export { OutletSymbol } from './OutletSymbol';
|
||||||
export { SingleSwitchSymbol, DoubleSwitchSymbol, DimmerSwitchSymbol } from './SwitchSymbol';
|
export { SingleSwitchSymbol, DoubleSwitchSymbol, DimmerSwitchSymbol } from './SwitchSymbol';
|
||||||
export { JunctionBoxSymbol } from './JunctionBoxSymbol';
|
export { JunctionBoxSymbol } from './JunctionBoxSymbol';
|
||||||
export { CeilingLightSymbol } from './CeilingLightSymbol';
|
export { CeilingLightSymbol } from './CeilingLightSymbol';
|
||||||
@@ -21,9 +21,7 @@ export interface ElectricalSymbolDef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ELECTRICAL_SYMBOL_DEFS: readonly ElectricalSymbolDef[] = [
|
export const ELECTRICAL_SYMBOL_DEFS: readonly ElectricalSymbolDef[] = [
|
||||||
{ type: 'OUTLET', label: 'Single Outlet', category: 'outlet', wallMounted: true, variant: 'single' },
|
{ type: 'OUTLET', label: 'Outlet', category: 'outlet', wallMounted: true },
|
||||||
{ type: 'OUTLET', label: 'Double Outlet', category: 'outlet', wallMounted: true, variant: 'double' },
|
|
||||||
{ type: 'OUTLET', label: 'Grounded Outlet', category: 'outlet', wallMounted: true, variant: 'grounded' },
|
|
||||||
{ type: 'SWITCH', label: 'Single Switch', category: 'switch', wallMounted: true, variant: 'single' },
|
{ type: 'SWITCH', label: 'Single Switch', category: 'switch', wallMounted: true, variant: 'single' },
|
||||||
{ type: 'SWITCH', label: 'Double Switch', category: 'switch', wallMounted: true, variant: 'double' },
|
{ type: 'SWITCH', label: 'Double Switch', category: 'switch', wallMounted: true, variant: 'double' },
|
||||||
{ type: 'SWITCH', label: 'Dimmer Switch', category: 'switch', wallMounted: true, variant: 'dimmer' },
|
{ 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 },
|
{ 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<string, unknown> | null): string {
|
export function getElectricalVariant(metadata: Record<string, unknown> | null): string {
|
||||||
if (metadata && typeof metadata['variant'] === 'string') {
|
if (metadata && typeof metadata['variant'] === 'string') {
|
||||||
return metadata['variant'];
|
return metadata['variant'];
|
||||||
|
|||||||
@@ -9,38 +9,152 @@ export { TvSilhouette } from './TvSilhouette';
|
|||||||
|
|
||||||
import type { FurnitureType } from '@house-plan-maker/shared';
|
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). */
|
/** Default dimensions for each furniture type (width x depth x height in meters). */
|
||||||
export interface FurnitureDef {
|
export interface FurnitureDef {
|
||||||
readonly type: FurnitureType;
|
readonly type: FurnitureType;
|
||||||
|
readonly category: FurnitureCategory;
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
readonly width: number;
|
readonly width: number;
|
||||||
readonly depth: number;
|
readonly depth: number;
|
||||||
readonly height: number;
|
readonly height: number;
|
||||||
readonly icon: string;
|
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<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FURNITURE_DEFS: readonly FurnitureDef[] = [
|
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', category: 'sleeping', 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', category: 'sleeping', 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', category: 'sleeping', 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: 'BED', category: 'sleeping', 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}' },
|
// Cribs — a baby bed with slatted rails. Standard EU/US sizes: full-size
|
||||||
{ type: 'WARDROBE', label: 'Wardrobe (S)', width: 1.0, depth: 0.6, height: 2.0, icon: '\u{1F3EA}' },
|
// ~70×130cm and compact ~60×120cm. Total height includes the top rail
|
||||||
{ type: 'WARDROBE', label: 'Wardrobe (M)', width: 1.5, depth: 0.6, height: 2.0, icon: '\u{1F3EA}' },
|
// (~95cm from floor) which is what the 3D mesh draws the slats up to.
|
||||||
{ type: 'WARDROBE', label: 'Wardrobe (L)', width: 2.0, depth: 0.6, height: 2.0, icon: '\u{1F3EA}' },
|
{ type: 'CRIB', category: 'sleeping', label: 'Crib (Standard)', width: 0.72, depth: 1.32, height: 0.95, icon: '\u{1F476}' },
|
||||||
{ type: 'SOFA', label: 'Sofa', width: 2.0, depth: 0.9, height: 0.8, icon: '\u{1FA91}' },
|
{ type: 'CRIB', category: 'sleeping', label: 'Crib (Compact)', width: 0.6, depth: 1.2, height: 0.95, icon: '\u{1F476}' },
|
||||||
{ type: 'TABLE', label: 'Dining Table', width: 1.2, depth: 0.8, height: 0.75, icon: '\u{1F37D}' },
|
{ type: 'NIGHTSTAND', category: 'sleeping', label: 'Nightstand', width: 0.5, depth: 0.4, height: 0.5, icon: '\u{1F4E6}' },
|
||||||
{ type: 'CHAIR', label: 'Chair', width: 0.45, depth: 0.45, height: 0.85, icon: '\u{1FA91}' },
|
{ type: 'SOFA', category: 'seating', label: 'Sofa', width: 2.0, depth: 0.9, height: 0.8, icon: '\u{1FA91}' },
|
||||||
{ type: 'SHELF', label: 'Tall Shelf', width: 0.8, depth: 0.3, height: 1.8, icon: '\u{1F4DA}' },
|
{ type: 'CHAIR', category: 'seating', label: 'Chair', width: 0.45, depth: 0.45, height: 0.85, icon: '\u{1FA91}' },
|
||||||
{ type: 'SHELF', label: 'Wall Shelf 60', width: 0.6, depth: 0.25, height: 0.04, icon: '\u{1F4DA}' },
|
// Office chairs — ergonomic task chair with wheeled 5-star base, gas
|
||||||
{ type: 'SHELF', label: 'Wall Shelf 80', width: 0.8, depth: 0.25, height: 0.04, icon: '\u{1F4DA}' },
|
// lift, padded seat and tall backrest. Total height is top of the
|
||||||
{ type: 'SHELF', label: 'Wall Shelf 120', width: 1.2, depth: 0.25, height: 0.04, icon: '\u{1F4DA}' },
|
// backrest; seat pan sits at ~45% of total height. Three presets:
|
||||||
{ type: 'NIGHTSTAND', label: 'Nightstand', width: 0.5, depth: 0.4, height: 0.5, icon: '\u{1F4E6}' },
|
// compact task chair, standard, and tall executive.
|
||||||
{ type: 'DRESSER', label: 'Dresser', width: 1.0, depth: 0.5, height: 0.8, icon: '\u{1F3EA}' },
|
{ type: 'OFFICE_CHAIR', category: 'seating', label: 'Office Chair', width: 0.6, depth: 0.6, height: 1.05, icon: '\u{1FA91}' },
|
||||||
{ type: 'BOOKCASE', label: 'Bookcase', width: 0.8, depth: 0.3, height: 2.0, icon: '\u{1F4DA}' },
|
{ type: 'OFFICE_CHAIR', category: 'seating', label: 'Office Chair (Task)', width: 0.55, depth: 0.55, height: 0.95, icon: '\u{1FA91}' },
|
||||||
{ type: 'TV', label: 'TV 32"', width: 0.73, depth: 0.08, height: 0.43, icon: '\u{1F4FA}' },
|
{ type: 'OFFICE_CHAIR', category: 'seating', label: 'Office Chair (Executive)', width: 0.68, depth: 0.68, height: 1.2, icon: '\u{1FA91}' },
|
||||||
{ type: 'TV', label: 'TV 43"', width: 0.97, depth: 0.08, height: 0.57, icon: '\u{1F4FA}' },
|
{ type: 'DESK', category: 'tables', label: 'Desk', width: 1.2, depth: 0.6, height: 0.75, icon: '\u{1F4BC}' },
|
||||||
{ type: 'TV', label: 'TV 55"', width: 1.24, depth: 0.08, height: 0.72, icon: '\u{1F4FA}' },
|
{ type: 'TABLE', category: 'tables', label: 'Dining Table', width: 1.2, depth: 0.8, height: 0.75, icon: '\u{1F37D}' },
|
||||||
{ type: 'TV', label: 'TV 65"', width: 1.46, depth: 0.08, height: 0.84, icon: '\u{1F4FA}' },
|
{ type: 'WARDROBE', category: 'storage', label: 'Wardrobe (S)', width: 1.0, depth: 0.6, height: 2.0, icon: '\u{1F3EA}' },
|
||||||
{ type: 'AC_UNIT', label: 'AC Unit', width: 0.85, depth: 0.2, height: 0.3, icon: '\u{2744}' },
|
{ 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' } },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,8 +1,66 @@
|
|||||||
import { useMemo, useRef } from 'react';
|
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 { OrbitControls } from '@react-three/drei';
|
||||||
import type { Point } from '@house-plan-maker/shared';
|
import type { Point } from '@house-plan-maker/shared';
|
||||||
import { boundingBox } from '../utils/geometry';
|
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<CameraOrientation>({ 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 {
|
interface CameraControlsProps {
|
||||||
readonly shape: readonly Point[];
|
readonly shape: readonly Point[];
|
||||||
@@ -64,48 +122,97 @@ function computePresets(
|
|||||||
export function CameraPresetsUI({
|
export function CameraPresetsUI({
|
||||||
shape: _shape,
|
shape: _shape,
|
||||||
wallHeight: _wallHeight,
|
wallHeight: _wallHeight,
|
||||||
|
orientation,
|
||||||
onPreset,
|
onPreset,
|
||||||
}: {
|
}: {
|
||||||
readonly shape: readonly Point[];
|
readonly shape: readonly Point[];
|
||||||
readonly wallHeight: number;
|
readonly wallHeight: number;
|
||||||
|
readonly orientation?: CameraOrientation;
|
||||||
readonly onPreset: (preset: CameraPreset) => void;
|
readonly onPreset: (preset: CameraPreset) => void;
|
||||||
}) {
|
}) {
|
||||||
const presetLabels: readonly { key: CameraPreset; label: string }[] = [
|
// A small CSS-3D "view cube" widget. Top face = bird's eye, front face =
|
||||||
{ key: 'birds-eye', label: 'Bird\'s Eye' },
|
// eye level, and the four corner pins map to the corner presets.
|
||||||
{ key: 'eye-level', label: 'Eye Level' },
|
// The cube rotates live with the Three.js camera so it doubles as an
|
||||||
{ key: 'corner-ne', label: 'NE Corner' },
|
// orientation indicator.
|
||||||
{ key: 'corner-nw', label: 'NW Corner' },
|
//
|
||||||
{ key: 'corner-se', label: 'SE Corner' },
|
// Mapping: a camera looking down at the scene gives pitch ≈ -90° and we
|
||||||
{ key: 'corner-sw', label: 'SW Corner' },
|
// 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 (
|
return (
|
||||||
<div style={{
|
<div className={styles.wrapper} aria-label="Camera view selector">
|
||||||
position: 'absolute',
|
<div className={styles.scene}>
|
||||||
top: 8,
|
<div className={styles.cube} style={cubeStyle}>
|
||||||
right: 8,
|
<button
|
||||||
display: 'flex',
|
type="button"
|
||||||
flexDirection: 'column',
|
className={`${styles.face} ${styles.faceTop}`}
|
||||||
gap: 4,
|
onClick={() => onPreset('birds-eye')}
|
||||||
zIndex: 10,
|
title="Bird's Eye"
|
||||||
}}>
|
>
|
||||||
{presetLabels.map(({ key, label }) => (
|
Top
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.face} ${styles.faceFront}`}
|
||||||
|
onClick={() => onPreset('eye-level')}
|
||||||
|
title="Eye Level"
|
||||||
|
>
|
||||||
|
Eye
|
||||||
|
</button>
|
||||||
|
<div className={`${styles.face} ${styles.faceBack}`} />
|
||||||
|
<div className={`${styles.face} ${styles.faceRight}`} />
|
||||||
|
<div className={`${styles.face} ${styles.faceLeft}`} />
|
||||||
|
<div className={`${styles.face} ${styles.faceBottom}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Corner pins live outside .scene so they're never sucked into the
|
||||||
|
cube's 3D perspective transform. */}
|
||||||
|
<div className={styles.pins}>
|
||||||
<button
|
<button
|
||||||
key={key}
|
type="button"
|
||||||
onClick={() => onPreset(key)}
|
className={`${styles.corner} ${styles.cornerNW}`}
|
||||||
style={{
|
onClick={() => onPreset('corner-nw')}
|
||||||
padding: '4px 10px',
|
title="NW Corner"
|
||||||
fontSize: '12px',
|
aria-label="NW Corner"
|
||||||
background: '#fff',
|
|
||||||
border: '1px solid #ccc',
|
|
||||||
borderRadius: 4,
|
|
||||||
cursor: 'pointer',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{label}
|
NW
|
||||||
</button>
|
</button>
|
||||||
))}
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.corner} ${styles.cornerNE}`}
|
||||||
|
onClick={() => onPreset('corner-ne')}
|
||||||
|
title="NE Corner"
|
||||||
|
aria-label="NE Corner"
|
||||||
|
>
|
||||||
|
NE
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.corner} ${styles.cornerSW}`}
|
||||||
|
onClick={() => onPreset('corner-sw')}
|
||||||
|
title="SW Corner"
|
||||||
|
aria-label="SW Corner"
|
||||||
|
>
|
||||||
|
SW
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.corner} ${styles.cornerSE}`}
|
||||||
|
onClick={() => onPreset('corner-se')}
|
||||||
|
title="SE Corner"
|
||||||
|
aria-label="SE Corner"
|
||||||
|
>
|
||||||
|
SE
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.label}>View</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,17 +10,24 @@ interface DoorOpeningProps {
|
|||||||
readonly wall: Wall;
|
readonly wall: Wall;
|
||||||
readonly isSelected: boolean;
|
readonly isSelected: boolean;
|
||||||
readonly onSelect?: (id: string) => void;
|
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 FRAME_COLOR = '#8b7355';
|
||||||
const DOOR_PANEL_COLOR = '#a0522d';
|
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;
|
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
|
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 rotY = useMemo(() => wallRotationY(wall), [wall]);
|
||||||
|
|
||||||
const [cx, cz] = useMemo(
|
const [cx, cz] = useMemo(
|
||||||
@@ -31,23 +38,28 @@ export function DoorOpening({ opening, wall, isSelected, onSelect }: DoorOpening
|
|||||||
const frameColor = isSelected ? '#6fa8dc' : FRAME_COLOR;
|
const frameColor = isSelected ? '#6fa8dc' : FRAME_COLOR;
|
||||||
const halfWidth = opening.width / 2;
|
const halfWidth = opening.width / 2;
|
||||||
const halfThick = wall.thickness / 2 + 0.005;
|
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
|
// Door panel rotation based on open direction
|
||||||
const openDir = opening.openDirection ?? 'LEFT';
|
const openDir = opening.openDirection ?? 'LEFT';
|
||||||
const isRight = openDir === 'RIGHT';
|
const isRight = openDir === 'RIGHT';
|
||||||
const isInward = openDir === 'INWARD';
|
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)
|
// Hinge position along the X axis (local frame coordinates)
|
||||||
const hingeX = isRight ? halfWidth : -halfWidth;
|
const hingeX = isRight ? halfWidth : -halfWidth;
|
||||||
// Swing angle sign: inward swings in +Z, others swing in -Z
|
// Swing angle sign: inward swings in +Z, others swing in -Z
|
||||||
const swingSign = isInward ? 1 : -1;
|
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)
|
// Panel center offset from hinge (half the door width along local X after rotation)
|
||||||
const panelHalfW = opening.width / 2;
|
const panelHalfW = opening.width / 2;
|
||||||
const panelOffsetX = isRight
|
const panelOffsetX = isRight
|
||||||
? -panelHalfW * Math.cos(DOOR_AJAR_ANGLE)
|
? -panelHalfW * Math.cos(ajarAngle)
|
||||||
: panelHalfW * Math.cos(DOOR_AJAR_ANGLE);
|
: panelHalfW * Math.cos(ajarAngle);
|
||||||
const panelOffsetZ = swingSign * panelHalfW * Math.sin(DOOR_AJAR_ANGLE);
|
const panelOffsetZ = swingSign * panelHalfW * Math.sin(ajarAngle);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group
|
<group
|
||||||
@@ -57,19 +69,19 @@ export function DoorOpening({ opening, wall, isSelected, onSelect }: DoorOpening
|
|||||||
>
|
>
|
||||||
{/* Left frame post */}
|
{/* Left frame post */}
|
||||||
<mesh position={[-halfWidth, 0, 0]} castShadow>
|
<mesh position={[-halfWidth, 0, 0]} castShadow>
|
||||||
<boxGeometry args={[FRAME_THICKNESS, opening.height, halfThick * 2]} />
|
<boxGeometry args={[frameThickness, opening.height, halfThick * 2]} />
|
||||||
<meshStandardMaterial color={frameColor} roughness={0.5} />
|
<meshStandardMaterial color={frameColor} roughness={0.5} />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
{/* Right frame post */}
|
{/* Right frame post */}
|
||||||
<mesh position={[halfWidth, 0, 0]} castShadow>
|
<mesh position={[halfWidth, 0, 0]} castShadow>
|
||||||
<boxGeometry args={[FRAME_THICKNESS, opening.height, halfThick * 2]} />
|
<boxGeometry args={[frameThickness, opening.height, halfThick * 2]} />
|
||||||
<meshStandardMaterial color={frameColor} roughness={0.5} />
|
<meshStandardMaterial color={frameColor} roughness={0.5} />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
{/* Top frame bar (lintel) */}
|
{/* Top frame bar (lintel) */}
|
||||||
<mesh position={[0, opening.height / 2, 0]} castShadow>
|
<mesh position={[0, opening.height / 2, 0]} castShadow>
|
||||||
<boxGeometry args={[opening.width + FRAME_THICKNESS, FRAME_THICKNESS, halfThick * 2]} />
|
<boxGeometry args={[opening.width + frameThickness, frameThickness, halfThick * 2]} />
|
||||||
<meshStandardMaterial color={frameColor} roughness={0.5} />
|
<meshStandardMaterial color={frameColor} roughness={0.5} />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
@@ -79,7 +91,7 @@ export function DoorOpening({ opening, wall, isSelected, onSelect }: DoorOpening
|
|||||||
rotation={[0, panelRotY, 0]}
|
rotation={[0, panelRotY, 0]}
|
||||||
castShadow
|
castShadow
|
||||||
>
|
>
|
||||||
<boxGeometry args={[opening.width, opening.height - FRAME_THICKNESS, DOOR_PANEL_THICKNESS]} />
|
<boxGeometry args={[opening.width, opening.height - frameThickness, DOOR_PANEL_THICKNESS]} />
|
||||||
<meshStandardMaterial color={DOOR_PANEL_COLOR} roughness={0.6} transparent opacity={0.85} />
|
<meshStandardMaterial color={DOOR_PANEL_COLOR} roughness={0.6} transparent opacity={0.85} />
|
||||||
</mesh>
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import * as THREE from 'three';
|
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';
|
import { wallRotationY, positionAlongWall3D, wallVector, wallNormal } from './utils/wallGeometry';
|
||||||
|
|
||||||
interface ElectricalMeshWithHeightProps {
|
interface ElectricalMeshWithHeightProps {
|
||||||
@@ -9,6 +9,36 @@ interface ElectricalMeshWithHeightProps {
|
|||||||
readonly wallHeight: number;
|
readonly wallHeight: number;
|
||||||
readonly isSelected: boolean;
|
readonly isSelected: boolean;
|
||||||
readonly onSelect?: (id: string) => void;
|
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<ElectricalType, string> = {
|
const ELECTRICAL_COLORS: Record<ElectricalType, string> = {
|
||||||
@@ -32,13 +62,43 @@ function findWallInMap(wallId: string | null, wallMap: ReadonlyMap<string, Wall>
|
|||||||
return wallMap.get(wallId) ?? null;
|
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 (
|
return (
|
||||||
<mesh castShadow>
|
<group position={[centerX, centerY, 0]}>
|
||||||
<boxGeometry args={[0.08, 0.08, 0.02]} />
|
{Array.from({ length: safeCount }).map((_, i) => {
|
||||||
<meshStandardMaterial color={color} roughness={0.3} />
|
// Center index 0..N-1 around 0 along local x.
|
||||||
</mesh>
|
const localX = (i - (safeCount - 1) / 2) * outletWidth;
|
||||||
|
return (
|
||||||
|
<mesh key={i} position={[localX, 0, 0]} castShadow>
|
||||||
|
<boxGeometry args={[outletWidth * 0.95, outletHeight * 0.95, depth]} />
|
||||||
|
<meshStandardMaterial color={color} roughness={0.3} />
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +190,7 @@ function CableRouteMesh({ color }: { readonly color: string }) {
|
|||||||
function useElectricalPosition(
|
function useElectricalPosition(
|
||||||
item: ElectricalItem,
|
item: ElectricalItem,
|
||||||
wall: Wall | null,
|
wall: Wall | null,
|
||||||
|
roomCentroid: Point | null,
|
||||||
): [number, number, number] {
|
): [number, number, number] {
|
||||||
return useMemo<[number, number, number]>(() => {
|
return useMemo<[number, number, number]>(() => {
|
||||||
if (item.type === 'LIGHT_CEILING') {
|
if (item.type === 'LIGHT_CEILING') {
|
||||||
@@ -146,14 +207,33 @@ function useElectricalPosition(
|
|||||||
const t = length > 0 ? (dx * wallDx + dy * wallDy) / (length * length) : 0;
|
const t = length > 0 ? (dx * wallDx + dy * wallDy) / (length * length) : 0;
|
||||||
const clampedT = Math.max(0, Math.min(1, t));
|
const clampedT = Math.max(0, Math.min(1, t));
|
||||||
const [wx, wz] = positionAlongWall3D(wall, clampedT * length);
|
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;
|
const elevation = item.elevationFromFloor ?? 1.2;
|
||||||
return [wx + nx * offset, elevation, wz + ny * offset];
|
return [wx + nx * offset, elevation, wz + ny * offset];
|
||||||
}
|
}
|
||||||
|
|
||||||
const elevation = item.elevationFromFloor ?? 0.3;
|
const elevation = item.elevationFromFloor ?? 0.3;
|
||||||
return [item.x, elevation, item.y];
|
return [item.x, elevation, item.y];
|
||||||
}, [item, wall]);
|
}, [item, wall, roomCentroid]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ElectricalMeshWithHeight({
|
export function ElectricalMeshWithHeight({
|
||||||
@@ -162,6 +242,9 @@ export function ElectricalMeshWithHeight({
|
|||||||
wallHeight,
|
wallHeight,
|
||||||
isSelected,
|
isSelected,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
outletWidth,
|
||||||
|
outletHeight,
|
||||||
|
roomCentroid,
|
||||||
}: ElectricalMeshWithHeightProps) {
|
}: ElectricalMeshWithHeightProps) {
|
||||||
const color = isSelected ? SELECTED_COLOR : ELECTRICAL_COLORS[item.type];
|
const color = isSelected ? SELECTED_COLOR : ELECTRICAL_COLORS[item.type];
|
||||||
const wall = useMemo(() => {
|
const wall = useMemo(() => {
|
||||||
@@ -190,7 +273,7 @@ export function ElectricalMeshWithHeight({
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}, [item, wallMap]);
|
}, [item, wallMap]);
|
||||||
const position = useElectricalPosition(item, wall);
|
const position = useElectricalPosition(item, wall, roomCentroid ?? null);
|
||||||
|
|
||||||
const rotY = useMemo(() => {
|
const rotY = useMemo(() => {
|
||||||
if (wall) return wallRotationY(wall);
|
if (wall) return wallRotationY(wall);
|
||||||
@@ -203,7 +286,19 @@ export function ElectricalMeshWithHeight({
|
|||||||
rotation={[0, rotY, 0]}
|
rotation={[0, rotY, 0]}
|
||||||
onClick={onSelect ? (e) => { e.stopPropagation(); onSelect(item.id); } : undefined}
|
onClick={onSelect ? (e) => { e.stopPropagation(); onSelect(item.id); } : undefined}
|
||||||
>
|
>
|
||||||
{item.type === 'OUTLET' && <OutletMesh color={color} />}
|
{item.type === 'OUTLET' && (() => {
|
||||||
|
const offset = outletAnchorOffset(item, outletWidth, outletHeight);
|
||||||
|
return (
|
||||||
|
<OutletMesh
|
||||||
|
color={color}
|
||||||
|
count={item.count}
|
||||||
|
outletWidth={outletWidth}
|
||||||
|
outletHeight={outletHeight}
|
||||||
|
centerX={offset.cx}
|
||||||
|
centerY={offset.cy}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{item.type === 'SWITCH' && <SwitchMesh color={color} />}
|
{item.type === 'SWITCH' && <SwitchMesh color={color} />}
|
||||||
{item.type === 'JUNCTION_BOX' && <JunctionBoxMesh color={color} />}
|
{item.type === 'JUNCTION_BOX' && <JunctionBoxMesh color={color} />}
|
||||||
{item.type === 'LIGHT_CEILING' && <CeilingLightMesh color={color} wallHeight={wallHeight} />}
|
{item.type === 'LIGHT_CEILING' && <CeilingLightMesh color={color} wallHeight={wallHeight} />}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import type { Point, FloorType } from '@house-plan-maker/shared';
|
import type { Point, FloorType } from '@house-plan-maker/shared';
|
||||||
|
import { getFloorPbr } from './utils/pbrTextures';
|
||||||
|
|
||||||
interface FloorCeilingProps {
|
interface FloorCeilingProps {
|
||||||
readonly shape: readonly Point[];
|
readonly shape: readonly Point[];
|
||||||
@@ -8,7 +9,20 @@ interface FloorCeilingProps {
|
|||||||
readonly floorType?: FloorType;
|
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;
|
if (shape.length < 3) return null;
|
||||||
|
|
||||||
const threeShape = new THREE.Shape();
|
const threeShape = new THREE.Shape();
|
||||||
@@ -18,178 +32,39 @@ function createPolygonGeometry(shape: readonly Point[]): THREE.ShapeGeometry | n
|
|||||||
}
|
}
|
||||||
threeShape.closePath();
|
threeShape.closePath();
|
||||||
|
|
||||||
return new THREE.ShapeGeometry(threeShape);
|
const geometry = new THREE.ShapeGeometry(threeShape);
|
||||||
}
|
|
||||||
|
|
||||||
/** Generate a procedural floor texture on a canvas. */
|
// Rescale UVs in place. Default ShapeGeometry UVs equal (x, y) coordinates
|
||||||
function createFloorTexture(floorType: FloorType): THREE.CanvasTexture {
|
// in meters; dividing by tileMeters gives the desired tile density.
|
||||||
const size = 512;
|
const uv = geometry.attributes.uv;
|
||||||
const canvas = document.createElement('canvas');
|
if (uv) {
|
||||||
canvas.width = size;
|
const scale = 1 / tileMeters;
|
||||||
canvas.height = size;
|
for (let i = 0; i < uv.count; i++) {
|
||||||
const ctx = canvas.getContext('2d')!;
|
uv.setXY(i, uv.getX(i) * scale, uv.getY(i) * scale);
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
uv.needsUpdate = true;
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return geometry;
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawHerringbone(ctx: CanvasRenderingContext2D, size: number, c1: string, c2: string, c3: string) {
|
export function FloorCeiling({ shape, wallHeight: _wallHeight, floorType = 'CONCRETE' }: FloorCeilingProps) {
|
||||||
ctx.fillStyle = c3;
|
const pbr = useMemo(() => getFloorPbr(floorType), [floorType]);
|
||||||
ctx.fillRect(0, 0, size, size);
|
const geometry = useMemo(
|
||||||
|
() => buildFloorGeometry(shape, pbr.tileMeters),
|
||||||
|
[shape, pbr.tileMeters],
|
||||||
|
);
|
||||||
|
|
||||||
const plankW = size / 4;
|
if (!geometry) return null;
|
||||||
const plankH = size / 8;
|
|
||||||
|
|
||||||
for (let row = -2; row < size / plankH + 2; row++) {
|
|
||||||
for (let col = -2; col < size / plankW + 2; col++) {
|
|
||||||
const isEven = (row + col) % 2 === 0;
|
|
||||||
ctx.fillStyle = isEven ? c1 : c2;
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
const cx = col * plankW;
|
|
||||||
const cy = row * plankH;
|
|
||||||
ctx.translate(cx + plankW / 2, cy + plankH / 2);
|
|
||||||
ctx.rotate(isEven ? Math.PI / 4 : -Math.PI / 4);
|
|
||||||
ctx.fillRect(-plankW / 2, -plankH / 4, plankW, plankH / 2);
|
|
||||||
ctx.strokeStyle = 'rgba(0,0,0,0.1)';
|
|
||||||
ctx.lineWidth = 0.5;
|
|
||||||
ctx.strokeRect(-plankW / 2, -plankH / 4, plankW, plankH / 2);
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawTiles(ctx: CanvasRenderingContext2D, size: number, c1: string, c2: string, grout: string) {
|
|
||||||
const tileSize = size / 4;
|
|
||||||
|
|
||||||
// Grout background
|
|
||||||
ctx.fillStyle = grout;
|
|
||||||
ctx.fillRect(0, 0, size, size);
|
|
||||||
|
|
||||||
const groutWidth = 3;
|
|
||||||
|
|
||||||
for (let row = 0; row < 4; row++) {
|
|
||||||
for (let col = 0; col < 4; col++) {
|
|
||||||
ctx.fillStyle = (row + col) % 2 === 0 ? c1 : c2;
|
|
||||||
ctx.fillRect(
|
|
||||||
col * tileSize + groutWidth / 2,
|
|
||||||
row * tileSize + groutWidth / 2,
|
|
||||||
tileSize - groutWidth,
|
|
||||||
tileSize - groutWidth,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawConcrete(ctx: CanvasRenderingContext2D, size: number) {
|
|
||||||
ctx.fillStyle = '#d4d0cc';
|
|
||||||
ctx.fillRect(0, 0, size, size);
|
|
||||||
|
|
||||||
// Noise texture
|
|
||||||
for (let i = 0; i < 5000; i++) {
|
|
||||||
const x = Math.random() * size;
|
|
||||||
const y = Math.random() * size;
|
|
||||||
const brightness = 180 + Math.random() * 40;
|
|
||||||
ctx.fillStyle = `rgb(${brightness},${brightness - 5},${brightness - 10})`;
|
|
||||||
ctx.fillRect(x, y, 2, 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const textureCache = new Map<FloorType, THREE.CanvasTexture>();
|
|
||||||
|
|
||||||
function getFloorTexture(floorType: FloorType): THREE.CanvasTexture {
|
|
||||||
let tex = textureCache.get(floorType);
|
|
||||||
if (!tex) {
|
|
||||||
tex = createFloorTexture(floorType);
|
|
||||||
textureCache.set(floorType, tex);
|
|
||||||
}
|
|
||||||
return tex;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FloorCeiling({ shape, wallHeight, floorType = 'CONCRETE' }: FloorCeilingProps) {
|
|
||||||
const floorGeometry = useMemo(() => createPolygonGeometry(shape), [shape]);
|
|
||||||
const texture = useMemo(() => getFloorTexture(floorType), [floorType]);
|
|
||||||
|
|
||||||
if (!floorGeometry) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh
|
<mesh
|
||||||
geometry={floorGeometry}
|
geometry={geometry}
|
||||||
|
material={pbr.material}
|
||||||
rotation={[-Math.PI / 2, 0, 0]}
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
scale={[1, -1, 1]}
|
scale={[1, -1, 1]}
|
||||||
position={[0, 0, 0]}
|
position={[0, 0, 0]}
|
||||||
receiveShadow
|
receiveShadow
|
||||||
>
|
/>
|
||||||
<meshStandardMaterial
|
|
||||||
map={texture}
|
|
||||||
side={THREE.DoubleSide}
|
|
||||||
roughness={0.8}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import * as THREE from 'three';
|
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 {
|
import {
|
||||||
getOpeningSlices,
|
getOpeningSlices,
|
||||||
wallVector,
|
wallVector,
|
||||||
|
wallNormal,
|
||||||
wallRotationY,
|
wallRotationY,
|
||||||
positionAlongWall3D,
|
positionAlongWall3D,
|
||||||
} from './utils/wallGeometry';
|
} from './utils/wallGeometry';
|
||||||
@@ -13,6 +14,8 @@ interface PlinthMeshProps {
|
|||||||
readonly openings: readonly WallOpening[];
|
readonly openings: readonly WallOpening[];
|
||||||
readonly plinthHeight: number;
|
readonly plinthHeight: number;
|
||||||
readonly plinthThickness: number;
|
readonly plinthThickness: number;
|
||||||
|
/** See WallMesh — same outward shift so the plinth stays aligned with the wall. */
|
||||||
|
readonly roomCentroid?: Point;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLINTH_COLOR = '#d4c5b2';
|
const PLINTH_COLOR = '#d4c5b2';
|
||||||
@@ -63,7 +66,7 @@ function computePlinthSegments(
|
|||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlinthMesh({ wall, openings, plinthHeight, plinthThickness }: PlinthMeshProps) {
|
export function PlinthMesh({ wall, openings, plinthHeight, plinthThickness, roomCentroid }: PlinthMeshProps) {
|
||||||
const segments = useMemo(
|
const segments = useMemo(
|
||||||
() => computePlinthSegments(wall, openings),
|
() => computePlinthSegments(wall, openings),
|
||||||
[wall, openings],
|
[wall, openings],
|
||||||
@@ -71,6 +74,20 @@ export function PlinthMesh({ wall, openings, plinthHeight, plinthThickness }: Pl
|
|||||||
|
|
||||||
const rotY = useMemo(() => wallRotationY(wall), [wall]);
|
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;
|
if (plinthHeight <= 0 || plinthThickness <= 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -85,7 +102,7 @@ export function PlinthMesh({ wall, openings, plinthHeight, plinthThickness }: Pl
|
|||||||
return (
|
return (
|
||||||
<mesh
|
<mesh
|
||||||
key={`${wall.id}-plinth-${i}`}
|
key={`${wall.id}-plinth-${i}`}
|
||||||
position={[cx, plinthHeight / 2, cz]}
|
position={[cx + outwardOffset[0], plinthHeight / 2, cz + outwardOffset[1]]}
|
||||||
rotation={[0, rotY, 0]}
|
rotation={[0, rotY, 0]}
|
||||||
castShadow
|
castShadow
|
||||||
material={plinthMaterial}
|
material={plinthMaterial}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useState, useCallback, useMemo, useRef, Suspense } from 'react';
|
import { useState, useCallback, useEffect, useMemo, useRef, Suspense } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
||||||
import { PerspectiveCamera } from '@react-three/drei';
|
import { PerspectiveCamera, Environment } from '@react-three/drei';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { useEditor } from '../context/EditorContext';
|
import { useEditor } from '../context/EditorContext';
|
||||||
import { boundingBox } from '../utils/geometry';
|
import { useTheme } from '../../../contexts/ThemeContext';
|
||||||
|
import { boundingBox, polygonCentroid } from '../utils/geometry';
|
||||||
import { FloorCeiling } from './FloorCeiling';
|
import { FloorCeiling } from './FloorCeiling';
|
||||||
import { WallMesh } from './WallMesh';
|
import { WallMesh } from './WallMesh';
|
||||||
import { DoorOpening } from './DoorOpening';
|
import { DoorOpening } from './DoorOpening';
|
||||||
@@ -12,7 +14,13 @@ import { FurnitureMesh } from './FurnitureMesh';
|
|||||||
import { ElectricalMeshWithHeight } from './ElectricalMesh';
|
import { ElectricalMeshWithHeight } from './ElectricalMesh';
|
||||||
import { PlinthMesh } from './PlinthMesh';
|
import { PlinthMesh } from './PlinthMesh';
|
||||||
import { RoomLabels } from './RoomLabels';
|
import { RoomLabels } from './RoomLabels';
|
||||||
import { SceneCamera, CameraPresetsUI, type CameraPreset } from './CameraControls';
|
import {
|
||||||
|
SceneCamera,
|
||||||
|
CameraPresetsUI,
|
||||||
|
CameraOrientationTracker,
|
||||||
|
type CameraPreset,
|
||||||
|
type CameraOrientation,
|
||||||
|
} from './CameraControls';
|
||||||
|
|
||||||
import type { Wall } from '@house-plan-maker/shared';
|
import type { Wall } from '@house-plan-maker/shared';
|
||||||
|
|
||||||
@@ -65,11 +73,54 @@ function NearestWallTracker({ walls, onUpdate }: { readonly walls: readonly Wall
|
|||||||
* Renders inside a @react-three/fiber Canvas with orbit controls.
|
* Renders inside a @react-three/fiber Canvas with orbit controls.
|
||||||
*/
|
*/
|
||||||
export function Room3DView() {
|
export function Room3DView() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
const { state, dispatch } = useEditor();
|
const { state, dispatch } = useEditor();
|
||||||
const { room, walls, openings, electricalItems, furnitureItems, selectedIds, layerVisibility } = state;
|
const { theme } = useTheme();
|
||||||
|
const { room, walls, openings, electricalItems, furnitureItems, selectedIds, layerVisibility, globalFurnitureOpacity } = state;
|
||||||
|
// Canvas backdrop tracks the app theme: a soft slate-grey for light mode
|
||||||
|
// and a deep neutral for dark mode so the white walls of the room don't
|
||||||
|
// bleed into the page background.
|
||||||
|
const canvasBackground = theme === 'dark' ? '#1a1d23' : '#e8ecf0';
|
||||||
|
|
||||||
|
// Global furniture opacity is applied per-item inside FurnitureMesh via
|
||||||
|
// `effectiveOpacity = item.opacity * globalOpacity` on cloned materials.
|
||||||
|
// No scene-level mutation is needed any more — it's just a prop.
|
||||||
|
|
||||||
const [activePreset, setActivePreset] = useState<CameraPreset | null>(null);
|
const [activePreset, setActivePreset] = useState<CameraPreset | null>(null);
|
||||||
const [hiddenWallIds, setHiddenWallIds] = useState<ReadonlySet<string>>(new Set());
|
const [hiddenWallIds, setHiddenWallIds] = useState<ReadonlySet<string>>(new Set());
|
||||||
|
const [cameraOrientation, setCameraOrientation] = useState<CameraOrientation>({
|
||||||
|
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) => {
|
const handlePreset = useCallback((preset: CameraPreset) => {
|
||||||
setActivePreset(preset);
|
setActivePreset(preset);
|
||||||
@@ -77,6 +128,12 @@ export function Room3DView() {
|
|||||||
setTimeout(() => setActivePreset(null), 100);
|
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(
|
const handleSelect = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
dispatch({ type: 'SET_SELECTED', ids: new Set([id]) });
|
dispatch({ type: 'SET_SELECTED', ids: new Set([id]) });
|
||||||
@@ -99,6 +156,13 @@ export function Room3DView() {
|
|||||||
[openings],
|
[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
|
// Compute bird's eye camera position from room bounds
|
||||||
const initialCameraPos = useMemo((): [number, number, number] => {
|
const initialCameraPos = useMemo((): [number, number, number] => {
|
||||||
if (shape.length < 3) return [0, 10, 0.01];
|
if (shape.length < 3) return [0, 10, 0.01];
|
||||||
@@ -123,12 +187,32 @@ export function Room3DView() {
|
|||||||
<CameraPresetsUI
|
<CameraPresetsUI
|
||||||
shape={shape}
|
shape={shape}
|
||||||
wallHeight={wallHeight}
|
wallHeight={wallHeight}
|
||||||
|
orientation={cameraOrientation}
|
||||||
onPreset={handlePreset}
|
onPreset={handlePreset}
|
||||||
/>
|
/>
|
||||||
<Canvas
|
<Canvas
|
||||||
shadows
|
// `shadows="percentage"` selects PCFShadowMap — the non-deprecated
|
||||||
style={{ width: '100%', height: '100%', background: '#e8ecf0' }}
|
// replacement for the default PCFSoftShadowMap that Three.js removed
|
||||||
gl={{ antialias: true, preserveDrawingBuffer: true }}
|
// support for in recent versions. Double-click anywhere on the empty
|
||||||
|
// canvas background resets the camera to the default Bird's Eye view.
|
||||||
|
shadows="percentage"
|
||||||
|
// Render at the device pixel ratio (capped at 2) for crisp output on
|
||||||
|
// HiDPI displays without blowing up GPU cost on 3x screens.
|
||||||
|
dpr={[1, 2]}
|
||||||
|
onDoubleClick={handleResetView}
|
||||||
|
style={{ width: '100%', height: '100%', background: canvasBackground }}
|
||||||
|
gl={{
|
||||||
|
antialias: true,
|
||||||
|
preserveDrawingBuffer: true,
|
||||||
|
powerPreference: 'high-performance',
|
||||||
|
// ACES filmic tone mapping gives more natural highlights/shadows
|
||||||
|
// than the default LinearToneMapping. Exposure is tuned low enough
|
||||||
|
// that the IBL environment + directional lights don't blow out
|
||||||
|
// light walls and floors.
|
||||||
|
toneMapping: THREE.ACESFilmicToneMapping,
|
||||||
|
toneMappingExposure: 0.75,
|
||||||
|
outputColorSpace: THREE.SRGBColorSpace,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
{/* Camera + Controls */}
|
{/* Camera + Controls */}
|
||||||
@@ -138,23 +222,45 @@ export function Room3DView() {
|
|||||||
wallHeight={wallHeight}
|
wallHeight={wallHeight}
|
||||||
activePreset={activePreset}
|
activePreset={activePreset}
|
||||||
/>
|
/>
|
||||||
|
<CameraOrientationTracker onChange={setCameraOrientation} />
|
||||||
|
|
||||||
{/* Lighting */}
|
{/* Image-based lighting from a pre-baked HDR environment is now
|
||||||
<ambientLight intensity={0.5} />
|
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. */}
|
||||||
|
<Environment preset="apartment" background={false} environmentIntensity={0.85} />
|
||||||
|
|
||||||
|
{/* 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. */}
|
||||||
|
<ambientLight intensity={0.3} />
|
||||||
|
<hemisphereLight args={['#f0f4f8', '#3a2f1e', 0.35]} />
|
||||||
<directionalLight
|
<directionalLight
|
||||||
position={[10, 15, 10]}
|
position={sunPosition}
|
||||||
intensity={1.0}
|
intensity={sunIntensity}
|
||||||
castShadow
|
castShadow
|
||||||
shadow-mapSize-width={2048}
|
shadow-mapSize-width={4096}
|
||||||
shadow-mapSize-height={2048}
|
shadow-mapSize-height={4096}
|
||||||
shadow-camera-near={0.5}
|
shadow-camera-near={0.5}
|
||||||
shadow-camera-far={50}
|
shadow-camera-far={50}
|
||||||
shadow-camera-left={-15}
|
shadow-camera-left={-15}
|
||||||
shadow-camera-right={15}
|
shadow-camera-right={15}
|
||||||
shadow-camera-top={15}
|
shadow-camera-top={15}
|
||||||
shadow-camera-bottom={-15}
|
shadow-camera-bottom={-15}
|
||||||
|
shadow-bias={-0.0001}
|
||||||
|
shadow-normalBias={0.02}
|
||||||
|
shadow-radius={4}
|
||||||
/>
|
/>
|
||||||
<directionalLight position={[-5, 8, -5]} intensity={0.3} />
|
|
||||||
|
|
||||||
{/* Track nearest wall to camera and hide it */}
|
{/* Track nearest wall to camera and hide it */}
|
||||||
<NearestWallTracker walls={walls} onUpdate={setHiddenWallIds} />
|
<NearestWallTracker walls={walls} onUpdate={setHiddenWallIds} />
|
||||||
@@ -171,8 +277,10 @@ export function Room3DView() {
|
|||||||
openings={openings}
|
openings={openings}
|
||||||
wallHeight={wallHeight}
|
wallHeight={wallHeight}
|
||||||
wallColor={room.wallColor}
|
wallColor={room.wallColor}
|
||||||
|
wallFinish={room.wallFinish}
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
|
roomCentroid={roomCentroid}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
))}
|
))}
|
||||||
@@ -186,6 +294,7 @@ export function Room3DView() {
|
|||||||
openings={openings}
|
openings={openings}
|
||||||
plinthHeight={plinthHeight}
|
plinthHeight={plinthHeight}
|
||||||
plinthThickness={plinthThickness}
|
plinthThickness={plinthThickness}
|
||||||
|
roomCentroid={roomCentroid}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
))}
|
))}
|
||||||
@@ -201,6 +310,7 @@ export function Room3DView() {
|
|||||||
wall={wall}
|
wall={wall}
|
||||||
isSelected={selectedIds.has(door.id)}
|
isSelected={selectedIds.has(door.id)}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
|
isOpen={doorsOpen}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -227,6 +337,7 @@ export function Room3DView() {
|
|||||||
item={item}
|
item={item}
|
||||||
isSelected={selectedIds.has(item.id)}
|
isSelected={selectedIds.has(item.id)}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
|
globalOpacity={globalFurnitureOpacity}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -239,6 +350,9 @@ export function Room3DView() {
|
|||||||
wallHeight={wallHeight}
|
wallHeight={wallHeight}
|
||||||
isSelected={selectedIds.has(item.id)}
|
isSelected={selectedIds.has(item.id)}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
|
outletWidth={room.outletWidth}
|
||||||
|
outletHeight={room.outletHeight}
|
||||||
|
roomCentroid={roomCentroid}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -254,6 +368,144 @@ export function Room3DView() {
|
|||||||
{/* ContactShadows removed — floor is handled by FloorCeiling */}
|
{/* ContactShadows removed — floor is handled by FloorCeiling */}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
|
|
||||||
|
{/* 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. */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 12,
|
||||||
|
bottom: 12,
|
||||||
|
background: 'rgba(255, 255, 255, 0.92)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: showLightControls ? '10px 12px' : '6px 10px',
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||||
|
fontSize: 12,
|
||||||
|
minWidth: showLightControls ? 200 : undefined,
|
||||||
|
color: '#333',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLightControls((v) => !v)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 0,
|
||||||
|
font: 'inherit',
|
||||||
|
color: 'inherit',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
aria-expanded={showLightControls}
|
||||||
|
>
|
||||||
|
<span aria-hidden style={{ fontSize: 14 }}>{'\u2600'}</span>
|
||||||
|
<span style={{ flex: 1, textAlign: 'left' }}>
|
||||||
|
{i18n.exists('view3d.lightControls') ? t('view3d.lightControls') : 'Light'}
|
||||||
|
</span>
|
||||||
|
<span aria-hidden>{showLightControls ? '\u25BE' : '\u25B8'}</span>
|
||||||
|
</button>
|
||||||
|
{showLightControls && (
|
||||||
|
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<SliderRow
|
||||||
|
label={i18n.exists('view3d.azimuth') ? t('view3d.azimuth') : 'Azimuth'}
|
||||||
|
value={sunAzimuthDeg}
|
||||||
|
min={0}
|
||||||
|
max={360}
|
||||||
|
step={5}
|
||||||
|
suffix={'\u00b0'}
|
||||||
|
onChange={setSunAzimuthDeg}
|
||||||
|
/>
|
||||||
|
<SliderRow
|
||||||
|
label={i18n.exists('view3d.elevation') ? t('view3d.elevation') : 'Elevation'}
|
||||||
|
value={sunElevationDeg}
|
||||||
|
min={5}
|
||||||
|
max={89}
|
||||||
|
step={1}
|
||||||
|
suffix={'\u00b0'}
|
||||||
|
onChange={setSunElevationDeg}
|
||||||
|
/>
|
||||||
|
<SliderRow
|
||||||
|
label={i18n.exists('view3d.intensity') ? t('view3d.intensity') : 'Intensity'}
|
||||||
|
value={sunIntensity}
|
||||||
|
min={0}
|
||||||
|
max={2}
|
||||||
|
step={0.05}
|
||||||
|
suffix=""
|
||||||
|
format={(v) => v.toFixed(2)}
|
||||||
|
onChange={setSunIntensity}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSunAzimuthDeg(45);
|
||||||
|
setSunElevationDeg(55);
|
||||||
|
setSunIntensity(0.35);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginTop: 4,
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid var(--color-border, #ccc)',
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: '3px 6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
font: 'inherit',
|
||||||
|
color: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{i18n.exists('view3d.reset') ? t('view3d.reset') : 'Reset'}
|
||||||
|
</button>
|
||||||
|
<hr style={{ border: 'none', borderTop: '1px solid var(--color-border, #e2e2e2)', margin: '4px 0' }} />
|
||||||
|
{/* Door display toggle — purely a viewer preference. */}
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer', fontSize: 11, color: '#555' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={doorsOpen}
|
||||||
|
onChange={(e) => setDoorsOpen(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>{i18n.exists('view3d.doorsOpen') ? t('view3d.doorsOpen') : 'Show doors open'}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<span style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: '#555' }}>
|
||||||
|
<span>{label}</span>
|
||||||
|
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{display}{suffix}</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = parseFloat(e.target.value);
|
||||||
|
if (Number.isFinite(next)) onChange(next);
|
||||||
|
}}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,33 +1,51 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import * as THREE from 'three';
|
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 {
|
import {
|
||||||
splitWallAroundOpenings,
|
splitWallAroundOpenings,
|
||||||
wallRotationY,
|
wallRotationY,
|
||||||
wallSegmentCenter3D,
|
wallSegmentCenter3D,
|
||||||
|
wallNormal,
|
||||||
type WallSegment,
|
type WallSegment,
|
||||||
} from './utils/wallGeometry';
|
} from './utils/wallGeometry';
|
||||||
|
import { getWallPbr } from './utils/pbrTextures';
|
||||||
|
|
||||||
interface WallMeshProps {
|
interface WallMeshProps {
|
||||||
readonly wall: Wall;
|
readonly wall: Wall;
|
||||||
readonly openings: readonly WallOpening[];
|
readonly openings: readonly WallOpening[];
|
||||||
readonly wallHeight: number;
|
readonly wallHeight: number;
|
||||||
readonly wallColor?: string;
|
readonly wallColor?: string;
|
||||||
|
readonly wallFinish?: WallFinish;
|
||||||
readonly selectedIds: ReadonlySet<string>;
|
readonly selectedIds: ReadonlySet<string>;
|
||||||
readonly onSelect?: (id: string) => void;
|
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 DEFAULT_WALL_COLOR = '#f0ebe3';
|
||||||
const WALL_SELECTED_COLOR = '#b8d4e3';
|
const WALL_SELECTED_COLOR = '#b8d4e3';
|
||||||
|
|
||||||
// ── Wall material cache ──
|
// ── PAINT material cache (one per color) ──
|
||||||
const wallMaterialCache = new Map<string, THREE.MeshStandardMaterial>();
|
//
|
||||||
|
// 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<string, THREE.MeshStandardMaterial>();
|
||||||
|
|
||||||
function getWallMaterial(color: string): THREE.MeshStandardMaterial {
|
function getPaintMaterial(color: string): THREE.MeshStandardMaterial {
|
||||||
let mat = wallMaterialCache.get(color);
|
let mat = paintMaterialCache.get(color);
|
||||||
if (!mat) {
|
if (!mat) {
|
||||||
mat = new THREE.MeshStandardMaterial({ color, roughness: 0.7, side: THREE.DoubleSide });
|
mat = new THREE.MeshStandardMaterial({ color, roughness: 0.85, side: THREE.DoubleSide });
|
||||||
wallMaterialCache.set(color, mat);
|
paintMaterialCache.set(color, mat);
|
||||||
}
|
}
|
||||||
return mat;
|
return mat;
|
||||||
}
|
}
|
||||||
@@ -38,31 +56,71 @@ const wallSelectedMaterial = new THREE.MeshStandardMaterial({
|
|||||||
side: THREE.DoubleSide,
|
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({
|
function WallSegmentMesh({
|
||||||
wall,
|
wall,
|
||||||
segment,
|
segment,
|
||||||
thickness,
|
thickness,
|
||||||
wallColor,
|
material,
|
||||||
|
tileMeters,
|
||||||
isSelected,
|
isSelected,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
outwardOffset,
|
||||||
}: {
|
}: {
|
||||||
readonly wall: Wall;
|
readonly wall: Wall;
|
||||||
readonly segment: WallSegment;
|
readonly segment: WallSegment;
|
||||||
readonly thickness: number;
|
readonly thickness: number;
|
||||||
readonly wallColor: string;
|
readonly material: THREE.MeshStandardMaterial;
|
||||||
|
readonly tileMeters: number | null;
|
||||||
readonly isSelected: boolean;
|
readonly isSelected: boolean;
|
||||||
readonly onSelect?: (id: string) => void;
|
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 segmentWidth = segment.endAlongWall - segment.startAlongWall;
|
||||||
const segmentHeight = segment.topY - segment.bottomY;
|
const segmentHeight = segment.topY - segment.bottomY;
|
||||||
|
|
||||||
const center = useMemo(
|
const center = useMemo<[number, number, number]>(() => {
|
||||||
() => wallSegmentCenter3D(wall, segment),
|
const [x, y, z] = wallSegmentCenter3D(wall, segment);
|
||||||
[wall, segment],
|
return [x + outwardOffset[0], y, z + outwardOffset[2]];
|
||||||
);
|
}, [wall, segment, outwardOffset]);
|
||||||
|
|
||||||
const rotY = useMemo(() => wallRotationY(wall), [wall]);
|
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;
|
if (segmentWidth <= 0 || segmentHeight <= 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -71,20 +129,52 @@ function WallSegmentMesh({
|
|||||||
rotation={[0, rotY, 0]}
|
rotation={[0, rotY, 0]}
|
||||||
castShadow
|
castShadow
|
||||||
receiveShadow
|
receiveShadow
|
||||||
material={isSelected ? wallSelectedMaterial : getWallMaterial(wallColor)}
|
material={isSelected ? wallSelectedMaterial : material}
|
||||||
|
geometry={geometry}
|
||||||
onClick={onSelect ? (e) => { e.stopPropagation(); onSelect(wall.id); } : undefined}
|
onClick={onSelect ? (e) => { e.stopPropagation(); onSelect(wall.id); } : undefined}
|
||||||
>
|
/>
|
||||||
<boxGeometry args={[segmentWidth, segmentHeight, thickness]} />
|
|
||||||
</mesh>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WallMesh({ wall, openings, wallHeight, wallColor = DEFAULT_WALL_COLOR, selectedIds, onSelect }: WallMeshProps) {
|
export function WallMesh({ wall, openings, wallHeight, wallColor = DEFAULT_WALL_COLOR, wallFinish = 'PAINT', selectedIds, onSelect, roomCentroid }: WallMeshProps) {
|
||||||
const segments = useMemo(
|
const segments = useMemo(
|
||||||
() => splitWallAroundOpenings(wall, openings, wallHeight),
|
() => splitWallAroundOpenings(wall, openings, wallHeight),
|
||||||
[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);
|
const isSelected = selectedIds.has(wall.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -95,9 +185,11 @@ export function WallMesh({ wall, openings, wallHeight, wallColor = DEFAULT_WALL_
|
|||||||
wall={wall}
|
wall={wall}
|
||||||
segment={segment}
|
segment={segment}
|
||||||
thickness={wall.thickness}
|
thickness={wall.thickness}
|
||||||
wallColor={wallColor}
|
material={material}
|
||||||
|
tileMeters={tileMeters}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
|
outwardOffset={outwardOffset}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</group>
|
</group>
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ interface WindowOpeningProps {
|
|||||||
|
|
||||||
const FRAME_COLOR = '#c0c0c0';
|
const FRAME_COLOR = '#c0c0c0';
|
||||||
const GLASS_COLOR = '#a8d8ea';
|
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) {
|
export function WindowOpening({ opening, wall, isSelected, onSelect }: WindowOpeningProps) {
|
||||||
const rotY = useMemo(() => wallRotationY(wall), [wall]);
|
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 frameColor = isSelected ? '#6fa8dc' : FRAME_COLOR;
|
||||||
const halfWidth = opening.width / 2;
|
const halfWidth = opening.width / 2;
|
||||||
const halfThick = wall.thickness / 2 + 0.005;
|
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 (
|
return (
|
||||||
<group
|
<group
|
||||||
@@ -35,30 +63,67 @@ export function WindowOpening({ opening, wall, isSelected, onSelect }: WindowOpe
|
|||||||
rotation={[0, rotY, 0]}
|
rotation={[0, rotY, 0]}
|
||||||
onClick={onSelect ? (e) => { e.stopPropagation(); onSelect(opening.id); } : undefined}
|
onClick={onSelect ? (e) => { 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 */}
|
||||||
|
<mesh position={[-halfWidth - slopeStripThickness / 2, 0, slopeFaceZ]} raycast={() => null}>
|
||||||
|
<boxGeometry args={[slopeStripThickness, opening.height, slopeDepth]} />
|
||||||
|
<meshStandardMaterial color={slopeColor} roughness={0.7} />
|
||||||
|
</mesh>
|
||||||
|
{/* Right jamb */}
|
||||||
|
<mesh position={[halfWidth + slopeStripThickness / 2, 0, slopeFaceZ]} raycast={() => null}>
|
||||||
|
<boxGeometry args={[slopeStripThickness, opening.height, slopeDepth]} />
|
||||||
|
<meshStandardMaterial color={slopeColor} roughness={0.7} />
|
||||||
|
</mesh>
|
||||||
|
{/* Head (top) */}
|
||||||
|
<mesh position={[0, opening.height / 2 + slopeStripThickness / 2, slopeFaceZ]} raycast={() => null}>
|
||||||
|
<boxGeometry args={[opening.width + slopeStripThickness * 2, slopeStripThickness, slopeDepth]} />
|
||||||
|
<meshStandardMaterial color={slopeColor} roughness={0.7} />
|
||||||
|
</mesh>
|
||||||
|
{/* Sill (bottom) */}
|
||||||
|
<mesh position={[0, -opening.height / 2 - slopeStripThickness / 2, slopeFaceZ]} raycast={() => null}>
|
||||||
|
<boxGeometry args={[opening.width + slopeStripThickness * 2, slopeStripThickness, slopeDepth]} />
|
||||||
|
<meshStandardMaterial color={slopeColor} roughness={0.7} />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* 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 */}
|
{/* Window frame — four sides */}
|
||||||
{/* Left */}
|
{/* Left */}
|
||||||
<mesh position={[-halfWidth, 0, 0]} castShadow>
|
<mesh position={[-halfWidth, 0, -slopeDepth]} castShadow>
|
||||||
<boxGeometry args={[FRAME_THICKNESS, opening.height, halfThick * 2]} />
|
<boxGeometry args={[frameThickness, opening.height, halfThick * 2]} />
|
||||||
<meshStandardMaterial color={frameColor} roughness={0.4} />
|
<meshStandardMaterial color={frameColor} roughness={0.4} />
|
||||||
</mesh>
|
</mesh>
|
||||||
{/* Right */}
|
{/* Right */}
|
||||||
<mesh position={[halfWidth, 0, 0]} castShadow>
|
<mesh position={[halfWidth, 0, -slopeDepth]} castShadow>
|
||||||
<boxGeometry args={[FRAME_THICKNESS, opening.height, halfThick * 2]} />
|
<boxGeometry args={[frameThickness, opening.height, halfThick * 2]} />
|
||||||
<meshStandardMaterial color={frameColor} roughness={0.4} />
|
<meshStandardMaterial color={frameColor} roughness={0.4} />
|
||||||
</mesh>
|
</mesh>
|
||||||
{/* Top */}
|
{/* Top */}
|
||||||
<mesh position={[0, opening.height / 2, 0]} castShadow>
|
<mesh position={[0, opening.height / 2, -slopeDepth]} castShadow>
|
||||||
<boxGeometry args={[opening.width + FRAME_THICKNESS, FRAME_THICKNESS, halfThick * 2]} />
|
<boxGeometry args={[opening.width + frameThickness, frameThickness, halfThick * 2]} />
|
||||||
<meshStandardMaterial color={frameColor} roughness={0.4} />
|
<meshStandardMaterial color={frameColor} roughness={0.4} />
|
||||||
</mesh>
|
</mesh>
|
||||||
{/* Bottom (sill) */}
|
{/* Bottom (sill) */}
|
||||||
<mesh position={[0, -opening.height / 2, 0]} castShadow>
|
<mesh position={[0, -opening.height / 2, -slopeDepth]} castShadow>
|
||||||
<boxGeometry args={[opening.width + FRAME_THICKNESS, FRAME_THICKNESS, halfThick * 2]} />
|
<boxGeometry args={[opening.width + frameThickness, frameThickness, halfThick * 2]} />
|
||||||
<meshStandardMaterial color={frameColor} roughness={0.4} />
|
<meshStandardMaterial color={frameColor} roughness={0.4} />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
{/* Glass pane */}
|
{/* Glass pane */}
|
||||||
<mesh position={[0, 0, 0]}>
|
<mesh position={[0, 0, -slopeDepth]}>
|
||||||
<planeGeometry args={[opening.width, opening.height]} />
|
<planeGeometry args={[opening.width, opening.height]} />
|
||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
color={GLASS_COLOR}
|
color={GLASS_COLOR}
|
||||||
@@ -70,17 +135,29 @@ export function WindowOpening({ opening, wall, isSelected, onSelect }: WindowOpe
|
|||||||
/>
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
{/* Center cross divider — vertical */}
|
{/* Vertical mullions — `gridCols - 1` internal dividers spaced
|
||||||
<mesh position={[0, 0, 0]} castShadow>
|
evenly between the left and right frame posts. */}
|
||||||
<boxGeometry args={[FRAME_THICKNESS * 0.7, opening.height, FRAME_THICKNESS * 0.7]} />
|
{Array.from({ length: gridCols - 1 }).map((_, i) => {
|
||||||
<meshStandardMaterial color={frameColor} roughness={0.4} />
|
const x = -halfWidth + ((i + 1) * opening.width) / gridCols;
|
||||||
</mesh>
|
return (
|
||||||
|
<mesh key={`vmul-${i}`} position={[x, 0, -slopeDepth]} castShadow>
|
||||||
|
<boxGeometry args={[mullionThickness, opening.height, mullionThickness]} />
|
||||||
|
<meshStandardMaterial color={frameColor} roughness={0.4} />
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Center cross divider — horizontal */}
|
{/* Horizontal mullions — `gridRows - 1` internal dividers spaced
|
||||||
<mesh position={[0, 0, 0]} castShadow>
|
evenly between the top and bottom frame rails. */}
|
||||||
<boxGeometry args={[opening.width, FRAME_THICKNESS * 0.7, FRAME_THICKNESS * 0.7]} />
|
{Array.from({ length: gridRows - 1 }).map((_, i) => {
|
||||||
<meshStandardMaterial color={frameColor} roughness={0.4} />
|
const y = -opening.height / 2 + ((i + 1) * opening.height) / gridRows;
|
||||||
</mesh>
|
return (
|
||||||
|
<mesh key={`hmul-${i}`} position={[0, y, -slopeDepth]} castShadow>
|
||||||
|
<boxGeometry args={[opening.width, mullionThickness, mullionThickness]} />
|
||||||
|
<meshStandardMaterial color={frameColor} roughness={0.4} />
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<FloorType, number> = {
|
||||||
|
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<Exclude<WallFinish, 'PAINT'>, 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<FloorType, PbrSet>();
|
||||||
|
const wallCache = new Map<Exclude<WallFinish, 'PAINT'>, 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<WallFinish, 'PAINT'>): 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 };
|
||||||
|
}
|
||||||
@@ -68,6 +68,19 @@ export interface WallSegment {
|
|||||||
* - A segment above the opening (from opening top to wall top)
|
* - 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
|
* - A segment below the opening (from floor to opening bottom) — only if elevationFromFloor > 0
|
||||||
* - Solid wall to the left and right of the opening
|
* - 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(
|
export function splitWallAroundOpenings(
|
||||||
wall: Wall,
|
wall: Wall,
|
||||||
@@ -76,20 +89,23 @@ export function splitWallAroundOpenings(
|
|||||||
): readonly WallSegment[] {
|
): readonly WallSegment[] {
|
||||||
const slices = getOpeningSlices(wall, openings);
|
const slices = getOpeningSlices(wall, openings);
|
||||||
const { length } = wallVector(wall);
|
const { length } = wallVector(wall);
|
||||||
|
const ext = wall.thickness;
|
||||||
|
|
||||||
if (slices.length === 0) {
|
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[] = [];
|
const segments: WallSegment[] = [];
|
||||||
|
|
||||||
// Full-height segments between openings
|
// Full-height segments between openings
|
||||||
let cursor = 0;
|
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
|
// Solid wall before this opening
|
||||||
if (slice.startAlongWall > cursor) {
|
if (slice.startAlongWall > cursor) {
|
||||||
segments.push({
|
segments.push({
|
||||||
startAlongWall: cursor,
|
// Extend the start outward only if this segment touches the wall start.
|
||||||
|
startAlongWall: cursor === 0 ? -ext : cursor,
|
||||||
endAlongWall: slice.startAlongWall,
|
endAlongWall: slice.startAlongWall,
|
||||||
bottomY: 0,
|
bottomY: 0,
|
||||||
topY: wallHeight,
|
topY: wallHeight,
|
||||||
@@ -124,7 +140,8 @@ export function splitWallAroundOpenings(
|
|||||||
if (cursor < length) {
|
if (cursor < length) {
|
||||||
segments.push({
|
segments.push({
|
||||||
startAlongWall: cursor,
|
startAlongWall: cursor,
|
||||||
endAlongWall: length,
|
// Extend the end outward — this segment touches the wall end.
|
||||||
|
endAlongWall: length + ext,
|
||||||
bottomY: 0,
|
bottomY: 0,
|
||||||
topY: wallHeight,
|
topY: wallHeight,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Point, Wall, WallOpening } from '@house-plan-maker/shared';
|
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 { findNearestWall, wallLength } from '../utils/wallUtils';
|
||||||
import { generateLocalId } from '../utils/geometry';
|
import { generateLocalId } from '../utils/geometry';
|
||||||
import { hasOverlap } from '../utils/openingUtils';
|
import { hasOverlap } from '../utils/openingUtils';
|
||||||
@@ -73,5 +74,19 @@ export function createDoorOpening(
|
|||||||
height,
|
height,
|
||||||
elevationFromFloor: 0,
|
elevationFromFloor: 0,
|
||||||
openDirection: 'LEFT',
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { Point, Wall, ElectricalItem, ElectricalType } from '@house-plan-maker/shared';
|
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 { findNearestWall, wallAngle } from '../utils/wallUtils';
|
||||||
import { generateLocalId } from '../utils/geometry';
|
import { generateLocalId } from '../utils/geometry';
|
||||||
import { DEFAULT_ELEVATIONS } from '../utils/projectionMapping';
|
import { DEFAULT_ELEVATIONS } from '../utils/projectionMapping';
|
||||||
|
import { normalizeAngleDegrees } from '../utils/angle';
|
||||||
import type { ElectricalSymbolDef } from '../symbols/electrical';
|
import type { ElectricalSymbolDef } from '../symbols/electrical';
|
||||||
|
|
||||||
/** Maximum snap distance to wall (meters). */
|
/** Maximum snap distance to wall (meters). */
|
||||||
@@ -31,7 +33,7 @@ export function computeElectricalPreview(
|
|||||||
return {
|
return {
|
||||||
x: nearest.projected.x,
|
x: nearest.projected.x,
|
||||||
y: nearest.projected.y,
|
y: nearest.projected.y,
|
||||||
rotation: (angle * 180) / Math.PI,
|
rotation: normalizeAngleDegrees((angle * 180) / Math.PI),
|
||||||
wallId: nearest.wall.id,
|
wallId: nearest.wall.id,
|
||||||
isValid: true,
|
isValid: true,
|
||||||
};
|
};
|
||||||
@@ -84,7 +86,10 @@ export function createElectricalItemFromPlacement(
|
|||||||
variant?: string,
|
variant?: string,
|
||||||
wallHeight?: number,
|
wallHeight?: number,
|
||||||
): ElectricalItem {
|
): ElectricalItem {
|
||||||
const metadata: Record<string, unknown> | 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<string, unknown> | null =
|
||||||
|
type !== 'OUTLET' && variant ? { variant } : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: generateLocalId(),
|
id: generateLocalId(),
|
||||||
@@ -95,6 +100,9 @@ export function createElectricalItemFromPlacement(
|
|||||||
wallId: preview.wallId,
|
wallId: preview.wallId,
|
||||||
elevationFromFloor: getDefaultElevation(type, wallHeight),
|
elevationFromFloor: getDefaultElevation(type, wallHeight),
|
||||||
rotation: preview.rotation,
|
rotation: preview.rotation,
|
||||||
|
count: 1,
|
||||||
|
positionAnchor: DEFAULT_POSITION_ANCHOR,
|
||||||
|
label: null,
|
||||||
metadata,
|
metadata,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Point, FurnitureItem } from '@house-plan-maker/shared';
|
import type { Point, FurnitureItem } from '@house-plan-maker/shared';
|
||||||
|
import { DEFAULT_POSITION_ANCHOR } from '@house-plan-maker/shared';
|
||||||
import { generateLocalId } from '../utils/geometry';
|
import { generateLocalId } from '../utils/geometry';
|
||||||
import type { FurnitureDef } from '../symbols/furniture';
|
import type { FurnitureDef } from '../symbols/furniture';
|
||||||
|
|
||||||
@@ -13,9 +14,8 @@ export interface FurniturePlacementPreview {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute furniture placement preview.
|
* Compute furniture placement preview.
|
||||||
* The x,y represents the top-left corner of the furniture piece.
|
* New furniture uses the default anchor (middle/middle), so the cursor world
|
||||||
* The cursor world point is treated as the desired center, so we offset
|
* point IS the (x, y) — no offset needed.
|
||||||
* by half-width and half-depth to get the top-left corner.
|
|
||||||
*/
|
*/
|
||||||
export function computeFurniturePreview(
|
export function computeFurniturePreview(
|
||||||
worldPoint: Point,
|
worldPoint: Point,
|
||||||
@@ -23,8 +23,8 @@ export function computeFurniturePreview(
|
|||||||
rotation: number = 0,
|
rotation: number = 0,
|
||||||
): FurniturePlacementPreview {
|
): FurniturePlacementPreview {
|
||||||
return {
|
return {
|
||||||
x: worldPoint.x - furnitureDef.width / 2,
|
x: worldPoint.x,
|
||||||
y: worldPoint.y - furnitureDef.depth / 2,
|
y: worldPoint.y,
|
||||||
width: furnitureDef.width,
|
width: furnitureDef.width,
|
||||||
depth: furnitureDef.depth,
|
depth: furnitureDef.depth,
|
||||||
rotation,
|
rotation,
|
||||||
@@ -34,12 +34,20 @@ export function computeFurniturePreview(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a FurnitureItem from placement data.
|
* 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(
|
export function createFurnitureItemFromPlacement(
|
||||||
roomId: string,
|
roomId: string,
|
||||||
preview: FurniturePlacementPreview,
|
preview: FurniturePlacementPreview,
|
||||||
furnitureDef: FurnitureDef,
|
furnitureDef: FurnitureDef,
|
||||||
): FurnitureItem {
|
): FurnitureItem {
|
||||||
|
// Shallow-copy the default metadata so later mutations on the item don't
|
||||||
|
// leak back into the static FurnitureDef table.
|
||||||
|
const metadata: Record<string, unknown> | null = furnitureDef.defaultMetadata
|
||||||
|
? { ...furnitureDef.defaultMetadata }
|
||||||
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: generateLocalId(),
|
id: generateLocalId(),
|
||||||
roomId,
|
roomId,
|
||||||
@@ -50,8 +58,22 @@ export function createFurnitureItemFromPlacement(
|
|||||||
depth: preview.depth,
|
depth: preview.depth,
|
||||||
height: furnitureDef.height,
|
height: furnitureDef.height,
|
||||||
rotation: preview.rotation,
|
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,
|
label: furnitureDef.label,
|
||||||
|
positionAnchor: DEFAULT_POSITION_ANCHOR,
|
||||||
|
metadata,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
import type { Point, Wall, WallOpening, ElectricalItem, FurnitureItem } from '@house-plan-maker/shared';
|
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 type { DragState } from '../types';
|
||||||
import { distance } from '../utils/geometry';
|
import { distance } from '../utils/geometry';
|
||||||
import { findNearestWall } from '../utils/wallUtils';
|
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. */
|
/** Hit-test radius in meters for selecting elements. */
|
||||||
const HIT_RADIUS = 0.15;
|
const HIT_RADIUS = 0.15;
|
||||||
|
|
||||||
@@ -58,8 +69,7 @@ export function hitTest(
|
|||||||
|
|
||||||
// Check furniture items (rotation-aware: transform point into item's local space)
|
// Check furniture items (rotation-aware: transform point into item's local space)
|
||||||
for (const item of furnitureItems) {
|
for (const item of furnitureItems) {
|
||||||
const cx = item.x + item.width / 2;
|
const { cx, cy } = furnitureCenter(item);
|
||||||
const cy = item.y + item.depth / 2;
|
|
||||||
// Translate point relative to item center
|
// Translate point relative to item center
|
||||||
const dx = worldPoint.x - cx;
|
const dx = worldPoint.x - cx;
|
||||||
const dy = worldPoint.y - cy;
|
const dy = worldPoint.y - cy;
|
||||||
@@ -151,9 +161,8 @@ export function elementsInRect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const item of furnitureItems) {
|
for (const item of furnitureItems) {
|
||||||
// x,y is top-left; use center point for selection-rect containment
|
// Use the bounding-box centre (anchor-aware) for selection-rect containment
|
||||||
const cx = item.x + item.width / 2;
|
const { cx, cy } = furnitureCenter(item);
|
||||||
const cy = item.y + item.depth / 2;
|
|
||||||
if (
|
if (
|
||||||
cx >= rect.x &&
|
cx >= rect.x &&
|
||||||
cx <= rect.x + rect.width &&
|
cx <= rect.x + rect.width &&
|
||||||
@@ -193,8 +202,25 @@ export function selectedBoundingBox(
|
|||||||
const dy = (wall.endY - wall.startY) / wallLen;
|
const dy = (wall.endY - wall.startY) / wallLen;
|
||||||
const cx = wall.startX + dx * opening.positionAlongWall;
|
const cx = wall.startX + dx * opening.positionAlongWall;
|
||||||
const cy = wall.startY + dy * opening.positionAlongWall;
|
const cy = wall.startY + dy * opening.positionAlongWall;
|
||||||
points.push({ x: cx - opening.width / 2, y: cy - opening.width / 2 });
|
// Compute the four corners of the oriented opening rectangle
|
||||||
points.push({ x: cx + opening.width / 2, y: cy + opening.width / 2 });
|
// (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;
|
continue;
|
||||||
@@ -209,9 +235,12 @@ export function selectedBoundingBox(
|
|||||||
|
|
||||||
const furn = furnitureItems.find((f) => f.id === id);
|
const furn = furnitureItems.find((f) => f.id === id);
|
||||||
if (furn) {
|
if (furn) {
|
||||||
// Compute rotated AABB from center + rotation
|
// Selection overlay must enclose the ROTATED visual — otherwise a
|
||||||
const cx = furn.x + furn.width / 2;
|
// thin rotated item (e.g. a curtain rotated 90°) would show a
|
||||||
const cy = furn.y + furn.depth / 2;
|
// 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 rad = (furn.rotation * Math.PI) / 180;
|
||||||
const cos = Math.abs(Math.cos(rad));
|
const cos = Math.abs(Math.cos(rad));
|
||||||
const sin = Math.abs(Math.sin(rad));
|
const sin = Math.abs(Math.sin(rad));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Point, Wall, WallOpening } from '@house-plan-maker/shared';
|
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 { findNearestWall, wallLength } from '../utils/wallUtils';
|
||||||
import { generateLocalId } from '../utils/geometry';
|
import { generateLocalId } from '../utils/geometry';
|
||||||
import { hasOverlap } from '../utils/openingUtils';
|
import { hasOverlap } from '../utils/openingUtils';
|
||||||
@@ -77,5 +78,13 @@ export function createWindowOpening(
|
|||||||
height,
|
height,
|
||||||
elevationFromFloor: elevation,
|
elevationFromFloor: elevation,
|
||||||
openDirection: 'LEFT',
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ export interface EditorState {
|
|||||||
/** Index into FURNITURE_DEFS for furniture tool. */
|
/** Index into FURNITURE_DEFS for furniture tool. */
|
||||||
readonly selectedFurnitureIndex: number | null;
|
readonly selectedFurnitureIndex: number | null;
|
||||||
readonly annotations: readonly Annotation[];
|
readonly annotations: readonly Annotation[];
|
||||||
|
/** Furniture IDs whose dimension/offset projection should be drawn on every wall view. */
|
||||||
|
readonly furnitureProjectionIds: ReadonlySet<string>;
|
||||||
|
/** Global multiplier applied to every furniture item's render opacity. */
|
||||||
|
readonly globalFurnitureOpacity: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Undo/Redo Commands ──
|
// ── Undo/Redo Commands ──
|
||||||
@@ -71,7 +75,7 @@ export interface EditorCommand {
|
|||||||
|
|
||||||
export type EditorAction =
|
export type EditorAction =
|
||||||
| { readonly type: 'SET_ROOM'; readonly room: RoomFull }
|
| { readonly type: 'SET_ROOM'; readonly room: RoomFull }
|
||||||
| { readonly type: 'UPDATE_ROOM_PROPS'; readonly props: Partial<Pick<RoomFull, 'floorType' | 'wallColor' | 'wallHeight' | 'plinthHeight' | 'plinthThickness'>> }
|
| { readonly type: 'UPDATE_ROOM_PROPS'; readonly props: Partial<Pick<RoomFull, 'floorType' | 'wallColor' | 'wallFinish' | 'wallHeight' | 'plinthHeight' | 'plinthThickness' | 'outletWidth' | 'outletHeight'>> }
|
||||||
| { readonly type: 'SET_WALLS'; readonly walls: readonly Wall[] }
|
| { readonly type: 'SET_WALLS'; readonly walls: readonly Wall[] }
|
||||||
| { readonly type: 'UPDATE_WALL'; readonly wall: Wall }
|
| { readonly type: 'UPDATE_WALL'; readonly wall: Wall }
|
||||||
| { readonly type: 'ADD_OPENING'; readonly opening: WallOpening }
|
| { readonly type: 'ADD_OPENING'; readonly opening: WallOpening }
|
||||||
@@ -90,6 +94,9 @@ export type EditorAction =
|
|||||||
| { readonly type: 'SET_TOOL'; readonly tool: EditorToolType }
|
| { readonly type: 'SET_TOOL'; readonly tool: EditorToolType }
|
||||||
| { readonly type: 'SET_ZOOM'; readonly zoom: number }
|
| { readonly type: 'SET_ZOOM'; readonly zoom: number }
|
||||||
| { readonly type: 'SET_PAN_OFFSET'; readonly offset: Point }
|
| { 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: 'SET_GRID_SIZE'; readonly gridSize: number }
|
||||||
| { readonly type: 'TOGGLE_GRID' }
|
| { readonly type: 'TOGGLE_GRID' }
|
||||||
| { readonly type: 'TOGGLE_SNAP' }
|
| { readonly type: 'TOGGLE_SNAP' }
|
||||||
@@ -108,6 +115,10 @@ export type EditorAction =
|
|||||||
| { readonly type: 'ADD_ANNOTATION'; readonly annotation: Annotation }
|
| { readonly type: 'ADD_ANNOTATION'; readonly annotation: Annotation }
|
||||||
| { readonly type: 'UPDATE_ANNOTATION'; readonly annotation: Annotation }
|
| { readonly type: 'UPDATE_ANNOTATION'; readonly annotation: Annotation }
|
||||||
| { readonly type: 'REMOVE_ANNOTATION'; readonly id: string }
|
| { 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
|
// Import
|
||||||
| {
|
| {
|
||||||
readonly type: 'IMPORT_ROOM';
|
readonly type: 'IMPORT_ROOM';
|
||||||
@@ -130,6 +141,9 @@ export type EditorAction =
|
|||||||
readonly openings: readonly WallOpening[];
|
readonly openings: readonly WallOpening[];
|
||||||
readonly electricalItems: readonly ElectricalItem[];
|
readonly electricalItems: readonly ElectricalItem[];
|
||||||
readonly furnitureItems: readonly FurnitureItem[];
|
readonly furnitureItems: readonly FurnitureItem[];
|
||||||
|
readonly annotations?: readonly Annotation[];
|
||||||
|
/** Map from old (pre-save) item IDs to new (server) item IDs. */
|
||||||
|
readonly idMap?: ReadonlyMap<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Alignment ──
|
// ── Alignment ──
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ function makeFurniture(overrides: Partial<FurnitureItem> = {}): FurnitureItem {
|
|||||||
rotation: 0,
|
rotation: 0,
|
||||||
elevationFromFloor: 0,
|
elevationFromFloor: 0,
|
||||||
label: null,
|
label: null,
|
||||||
|
positionAnchor: { horizontal: 'left', vertical: 'top' },
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,11 @@ describe('openingWorldPosition', () => {
|
|||||||
height: 2.1,
|
height: 2.1,
|
||||||
elevationFromFloor: 0,
|
elevationFromFloor: 0,
|
||||||
openDirection: 'LEFT',
|
openDirection: 'LEFT',
|
||||||
|
positionAnchor: { horizontal: 'middle', vertical: 'bottom' },
|
||||||
|
gridCols: 2,
|
||||||
|
gridRows: 2,
|
||||||
|
slopeDepth: 0,
|
||||||
|
frameThickness: 0.03,
|
||||||
};
|
};
|
||||||
const result = openingWorldPosition(opening, wall);
|
const result = openingWorldPosition(opening, wall);
|
||||||
expect(result.center.x).toBeCloseTo(5);
|
expect(result.center.x).toBeCloseTo(5);
|
||||||
@@ -120,6 +125,11 @@ describe('openingWorldPosition', () => {
|
|||||||
height: 1.2,
|
height: 1.2,
|
||||||
elevationFromFloor: 0.9,
|
elevationFromFloor: 0.9,
|
||||||
openDirection: 'LEFT',
|
openDirection: 'LEFT',
|
||||||
|
positionAnchor: { horizontal: 'middle', vertical: 'bottom' },
|
||||||
|
gridCols: 2,
|
||||||
|
gridRows: 2,
|
||||||
|
slopeDepth: 0,
|
||||||
|
frameThickness: 0.03,
|
||||||
};
|
};
|
||||||
const result = openingWorldPosition(opening, wall);
|
const result = openingWorldPosition(opening, wall);
|
||||||
expect(result.center).toEqual({ x: 3, y: 3 });
|
expect(result.center).toEqual({ x: 3, y: 3 });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,102 +1,74 @@
|
|||||||
import type { FurnitureItem } from '@house-plan-maker/shared';
|
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 id: string;
|
||||||
readonly cx: number;
|
readonly minX: number;
|
||||||
readonly cy: number;
|
readonly minY: number;
|
||||||
readonly halfW: number;
|
readonly maxX: number;
|
||||||
readonly halfD: number;
|
readonly maxY: number;
|
||||||
readonly cos: number;
|
readonly bottom: number;
|
||||||
readonly sin: number;
|
readonly top: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeOBB(item: FurnitureItem): OBB {
|
function computeAabb(item: FurnitureItem): AABB {
|
||||||
const rad = (item.rotation * Math.PI) / 180;
|
// 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 {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
cx: item.x + item.width / 2,
|
minX: cx - halfW,
|
||||||
cy: item.y + item.depth / 2,
|
minY: cy - halfD,
|
||||||
halfW: item.width / 2,
|
maxX: cx + halfW,
|
||||||
halfD: item.depth / 2,
|
maxY: cy + halfD,
|
||||||
cos: Math.cos(rad),
|
bottom: item.elevationFromFloor,
|
||||||
sin: Math.sin(rad),
|
top: item.elevationFromFloor + item.height,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the 4 corners of an OBB. */
|
function aabbOverlap(a: AABB, b: AABB): boolean {
|
||||||
function getCorners(obb: OBB): [number, number][] {
|
if (a.maxX <= b.minX || b.maxX <= a.minX) return false;
|
||||||
const { cx, cy, halfW, halfD, cos, sin } = obb;
|
if (a.maxY <= b.minY || b.maxY <= a.minY) return false;
|
||||||
// Local corners at (±halfW, ±halfD), rotated and translated
|
if (a.top <= b.bottom || b.top <= a.bottom) return false;
|
||||||
return [
|
return true;
|
||||||
[cx + halfW * cos - halfD * sin, cy + halfW * sin + halfD * cos],
|
|
||||||
[cx - halfW * cos - halfD * sin, cy - halfW * sin + halfD * cos],
|
|
||||||
[cx - halfW * cos + halfD * sin, cy - halfW * sin - halfD * cos],
|
|
||||||
[cx + halfW * cos + halfD * sin, cy + halfW * sin - halfD * cos],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Project corners onto an axis and return [min, max]. */
|
|
||||||
function projectOntoAxis(corners: [number, number][], ax: number, ay: number): [number, number] {
|
|
||||||
let min = Infinity;
|
|
||||||
let max = -Infinity;
|
|
||||||
for (const [x, y] of corners) {
|
|
||||||
const p = x * ax + y * ay;
|
|
||||||
if (p < min) min = p;
|
|
||||||
if (p > max) max = p;
|
|
||||||
}
|
|
||||||
return [min, max];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** SAT overlap test for two OBBs. */
|
|
||||||
function obbOverlap(a: OBB, b: OBB): boolean {
|
|
||||||
const cornersA = getCorners(a);
|
|
||||||
const cornersB = getCorners(b);
|
|
||||||
|
|
||||||
// 4 potential separating axes: 2 from each OBB's edges
|
|
||||||
const axes: [number, number][] = [
|
|
||||||
[a.cos, a.sin],
|
|
||||||
[-a.sin, a.cos],
|
|
||||||
[b.cos, b.sin],
|
|
||||||
[-b.sin, b.cos],
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const [ax, ay] of axes) {
|
|
||||||
const [minA, maxA] = projectOntoAxis(cornersA, ax, ay);
|
|
||||||
const [minB, maxB] = projectOntoAxis(cornersB, ax, ay);
|
|
||||||
if (maxA <= minB || maxB <= minA) {
|
|
||||||
return false; // Separating axis found — no overlap
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true; // No separating axis — overlapping
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find all furniture IDs that collide using proper OBB (rotation-aware) overlap.
|
* Find all furniture IDs that collide. Uses unrotated axis-aligned bounding
|
||||||
|
* boxes — see the AABB doc comment for the rationale.
|
||||||
*/
|
*/
|
||||||
export function findCollidingFurniture(
|
export function findCollidingFurniture(
|
||||||
items: readonly FurnitureItem[],
|
items: readonly FurnitureItem[],
|
||||||
): ReadonlySet<string> {
|
): ReadonlySet<string> {
|
||||||
if (items.length < 2) return new Set();
|
if (items.length < 2) return new Set();
|
||||||
|
|
||||||
const obbs = items.map(computeOBB);
|
const boxes = items.map(computeAabb);
|
||||||
const colliding = new Set<string>();
|
const colliding = new Set<string>();
|
||||||
|
|
||||||
for (let i = 0; i < obbs.length; i++) {
|
for (let i = 0; i < boxes.length; i++) {
|
||||||
for (let j = i + 1; j < obbs.length; j++) {
|
for (let j = i + 1; j < boxes.length; j++) {
|
||||||
// Check vertical overlap first (elevation + height)
|
if (aabbOverlap(boxes[i], boxes[j])) {
|
||||||
const a = items[i];
|
colliding.add(boxes[i].id);
|
||||||
const b = items[j];
|
colliding.add(boxes[j].id);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, unknown> | 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;
|
||||||
|
}
|
||||||
@@ -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';
|
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 ──
|
// ── Constants ──
|
||||||
|
|
||||||
/** Standard door height in meters. */
|
/** Standard door height in meters. */
|
||||||
@@ -58,6 +99,16 @@ export interface ProjectedElectrical {
|
|||||||
readonly item: ElectricalItem;
|
readonly item: ElectricalItem;
|
||||||
readonly position: ProjectedPosition;
|
readonly position: ProjectedPosition;
|
||||||
readonly elevation: number;
|
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. */
|
/** Information about a furniture item projected onto a wall. */
|
||||||
@@ -125,12 +176,18 @@ export function projectOpenings(
|
|||||||
wall: Wall,
|
wall: Wall,
|
||||||
openings: readonly WallOpening[],
|
openings: readonly WallOpening[],
|
||||||
): readonly ProjectedOpening[] {
|
): readonly ProjectedOpening[] {
|
||||||
const wallLen = wallLength(wall);
|
const axis = getProjectionAxis(wall);
|
||||||
|
const wallLen = axis.length;
|
||||||
return openings
|
return openings
|
||||||
.filter((o) => o.wallId === wall.id)
|
.filter((o) => o.wallId === wall.id)
|
||||||
.map((opening) => {
|
.map((opening) => {
|
||||||
const halfWidth = opening.width / 2;
|
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 isDoor = opening.type === 'DOOR';
|
||||||
const fromFloor = isDoor ? 0 : opening.elevationFromFloor;
|
const fromFloor = isDoor ? 0 : opening.elevationFromFloor;
|
||||||
@@ -155,8 +212,8 @@ export function projectElectricalItems(
|
|||||||
wall: Wall,
|
wall: Wall,
|
||||||
electricalItems: readonly ElectricalItem[],
|
electricalItems: readonly ElectricalItem[],
|
||||||
): readonly ProjectedElectrical[] {
|
): readonly ProjectedElectrical[] {
|
||||||
const { start, end } = wallStartEnd(wall);
|
const axis = getProjectionAxis(wall);
|
||||||
const wallLen = wallLength(wall);
|
const { start, end, length: wallLen } = axis;
|
||||||
|
|
||||||
if (wallLen === 0) return [];
|
if (wallLen === 0) return [];
|
||||||
|
|
||||||
@@ -194,6 +251,7 @@ export function projectElectricalItems(
|
|||||||
item,
|
item,
|
||||||
position: { alongWall: Math.max(0, Math.min(wallLen, alongWall)), fromFloor: elevation },
|
position: { alongWall: Math.max(0, Math.min(wallLen, alongWall)), fromFloor: elevation },
|
||||||
elevation,
|
elevation,
|
||||||
|
axisFlipped: axis.flipped,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -201,8 +259,59 @@ export function projectElectricalItems(
|
|||||||
// ── Furniture Projection ──
|
// ── Furniture Projection ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute the distance from the nearest edge of a furniture item to a wall.
|
* Project the ROTATED half-extents of a furniture item onto the wall axis
|
||||||
* Returns the gap between the item's closest edge and the wall line.
|
* 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(
|
function furnitureEdgeDistanceToWall(
|
||||||
item: FurnitureItem,
|
item: FurnitureItem,
|
||||||
@@ -215,26 +324,32 @@ function furnitureEdgeDistanceToWall(
|
|||||||
const dx = (end.x - start.x) / wallLen;
|
const dx = (end.x - start.x) / wallLen;
|
||||||
const dy = (end.y - start.y) / wallLen;
|
const dy = (end.y - start.y) / wallLen;
|
||||||
|
|
||||||
// x,y is top-left corner; compute center for distance calculation
|
// (item.x, item.y) is the anchored point on the rotated bounding box.
|
||||||
const cx = item.x + item.width / 2;
|
// Convert to box centre.
|
||||||
const cy = item.y + item.depth / 2;
|
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
|
// Vector from wall start to item centre
|
||||||
const vx = cx - start.x;
|
const dxC = cx - start.x;
|
||||||
const vy = cy - start.y;
|
const dyC = cy - start.y;
|
||||||
|
|
||||||
// Perpendicular distance from center to wall
|
// Perpendicular distance from centre to wall line
|
||||||
const centerDist = Math.abs(vx * (-dy) + vy * dx);
|
const centerDist = Math.abs(dxC * (-dy) + dyC * dx);
|
||||||
|
|
||||||
// Subtract the item's half-extent in the perpendicular direction
|
const { halfAlong, halfPerp } = rotatedHalfExtents(item, dx, dy);
|
||||||
// (approximation: use the larger of width/depth halves)
|
const edgeDist = Math.max(0, centerDist - halfPerp);
|
||||||
const halfExtent = Math.max(item.width, item.depth) / 2;
|
|
||||||
const edgeDist = Math.max(0, centerDist - halfExtent);
|
|
||||||
|
|
||||||
// Along-wall distance: item must overlap with the wall's length
|
// Along-wall extent: item (rotated) must overlap the wall's length.
|
||||||
const alongWall = vx * dx + vy * dy;
|
const alongWallCenter = dxC * dx + dyC * dy;
|
||||||
const halfWidth = Math.max(item.width, item.depth) / 2;
|
if (alongWallCenter < -halfAlong || alongWallCenter > wallLen + halfAlong) {
|
||||||
if (alongWall < -halfWidth || alongWall > wallLen + halfWidth) return Infinity;
|
return Infinity;
|
||||||
|
}
|
||||||
|
|
||||||
return edgeDist;
|
return edgeDist;
|
||||||
}
|
}
|
||||||
@@ -245,8 +360,8 @@ export function projectFurnitureItems(
|
|||||||
furnitureItems: readonly FurnitureItem[],
|
furnitureItems: readonly FurnitureItem[],
|
||||||
wallThreshold: number = 0.15,
|
wallThreshold: number = 0.15,
|
||||||
): readonly ProjectedFurniture[] {
|
): readonly ProjectedFurniture[] {
|
||||||
const { start, end } = wallStartEnd(wall);
|
const axis = getProjectionAxis(wall);
|
||||||
const wallLen = wallLength(wall);
|
const { start, end, length: wallLen } = axis;
|
||||||
if (wallLen === 0) return [];
|
if (wallLen === 0) return [];
|
||||||
|
|
||||||
const dx = (end.x - start.x) / wallLen;
|
const dx = (end.x - start.x) / wallLen;
|
||||||
@@ -258,22 +373,29 @@ export function projectFurnitureItems(
|
|||||||
return dist < wallThreshold;
|
return dist < wallThreshold;
|
||||||
})
|
})
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
// x,y is top-left corner; compute center for wall projection
|
// Convert anchored (x, y) to rotated bounding-box centre.
|
||||||
const cx = item.x + item.width / 2;
|
const offset = rotatedAnchorOffsetToCenter(
|
||||||
const cy = item.y + item.depth / 2;
|
item.positionAnchor,
|
||||||
const vx = cx - start.x;
|
item.width,
|
||||||
const vy = cy - start.y;
|
item.depth,
|
||||||
const alongWall = vx * dx + vy * dy;
|
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
|
// Silhouette width on the wall = rotated half-extent along the wall
|
||||||
// and height as the vertical extent
|
// direction, doubled. Matches what the 3D view shows.
|
||||||
const projectedWidth = item.width;
|
const { halfAlong } = rotatedHalfExtents(item, dx, dy);
|
||||||
|
const projectedWidth = halfAlong * 2;
|
||||||
const fromFloor = item.elevationFromFloor ?? 0;
|
const fromFloor = item.elevationFromFloor ?? 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
item,
|
item,
|
||||||
rect: {
|
rect: {
|
||||||
x: Math.max(0, alongWall - projectedWidth / 2),
|
x: alongWallCenter - halfAlong,
|
||||||
y: fromFloor,
|
y: fromFloor,
|
||||||
width: projectedWidth,
|
width: projectedWidth,
|
||||||
height: item.height,
|
height: item.height,
|
||||||
@@ -290,16 +412,20 @@ export function computePlinthSegments(
|
|||||||
openings: readonly WallOpening[],
|
openings: readonly WallOpening[],
|
||||||
plinthHeight: number,
|
plinthHeight: number,
|
||||||
): readonly PlinthSegment[] {
|
): readonly PlinthSegment[] {
|
||||||
const wallLen = wallLength(wall);
|
const axis = getProjectionAxis(wall);
|
||||||
|
const wallLen = axis.length;
|
||||||
if (wallLen <= 0 || plinthHeight <= 0) return [];
|
if (wallLen <= 0 || plinthHeight <= 0) return [];
|
||||||
|
|
||||||
// Collect door gaps (sorted by position)
|
// Collect door gaps (sorted by canonical projection position)
|
||||||
const doors = openings
|
const doors = openings
|
||||||
.filter((o) => o.wallId === wall.id && o.type === 'DOOR')
|
.filter((o) => o.wallId === wall.id && o.type === 'DOOR')
|
||||||
.map((o) => ({
|
.map((o) => {
|
||||||
start: Math.max(0, o.positionAlongWall - o.width / 2),
|
const projectedCenter = axis.flipped ? wallLen - o.positionAlongWall : o.positionAlongWall;
|
||||||
end: Math.min(wallLen, o.positionAlongWall + o.width / 2),
|
return {
|
||||||
}))
|
start: Math.max(0, projectedCenter - o.width / 2),
|
||||||
|
end: Math.min(wallLen, projectedCenter + o.width / 2),
|
||||||
|
};
|
||||||
|
})
|
||||||
.sort((a, b) => a.start - b.start);
|
.sort((a, b) => a.start - b.start);
|
||||||
|
|
||||||
if (doors.length === 0) {
|
if (doors.length === 0) {
|
||||||
|
|||||||
@@ -1,8 +1,27 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
import { Outlet, Link, NavLink, useMatches } from 'react-router';
|
import { Outlet, Link, NavLink, useMatches } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useTheme } from '../../contexts/ThemeContext';
|
import { useTheme } from '../../contexts/ThemeContext';
|
||||||
import styles from './app-shell.module.css';
|
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 {
|
interface CrumbHandle {
|
||||||
crumb?: string | ((data: unknown) => string);
|
crumb?: string | ((data: unknown) => string);
|
||||||
}
|
}
|
||||||
@@ -18,6 +37,17 @@ export function AppShell() {
|
|||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
const matches = useMatches() as MatchWithHandle[];
|
const matches = useMatches() as MatchWithHandle[];
|
||||||
|
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(() =>
|
||||||
|
readSidebarCollapsed(),
|
||||||
|
);
|
||||||
|
const toggleSidebar = useCallback(() => {
|
||||||
|
setSidebarCollapsed((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
writeSidebarCollapsed(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const crumbs = matches
|
const crumbs = matches
|
||||||
.filter((m) => m.handle?.crumb)
|
.filter((m) => m.handle?.crumb)
|
||||||
.map((m) => {
|
.map((m) => {
|
||||||
@@ -95,7 +125,24 @@ export function AppShell() {
|
|||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
{/* Sidebar (desktop) */}
|
{/* Sidebar (desktop) */}
|
||||||
<nav className={styles.sidebar} aria-label="Main navigation">
|
<nav
|
||||||
|
className={[
|
||||||
|
styles.sidebar,
|
||||||
|
sidebarCollapsed ? styles.sidebarCollapsed : undefined,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
aria-label="Main navigation"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.sidebarToggle}
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
title={sidebarCollapsed ? t('nav.expand') : t('nav.collapse')}
|
||||||
|
aria-label={sidebarCollapsed ? t('nav.expand') : t('nav.collapse')}
|
||||||
|
>
|
||||||
|
{sidebarCollapsed ? '\u25B6' : '\u25C0'}
|
||||||
|
</button>
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/"
|
to="/"
|
||||||
end
|
end
|
||||||
@@ -104,9 +151,10 @@ export function AppShell() {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')
|
.join(' ')
|
||||||
}
|
}
|
||||||
|
title={sidebarCollapsed ? t('nav.apartments') : undefined}
|
||||||
>
|
>
|
||||||
<span className={styles.navIcon} aria-hidden="true">▢</span>
|
<span className={styles.navIcon} aria-hidden="true">▢</span>
|
||||||
{t('nav.apartments')}
|
<span className={styles.navLabel}>{t('nav.apartments')}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,54 @@
|
|||||||
background-color: var(--color-bg-elevated);
|
background-color: var(--color-bg-elevated);
|
||||||
padding: var(--space-4) 0;
|
padding: var(--space-4) 0;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
transition: width var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarCollapsed {
|
||||||
|
width: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarToggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
margin: 0 var(--space-3) var(--space-3) auto;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarToggle:hover {
|
||||||
|
background-color: var(--color-bg-hover);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarCollapsed .sidebarToggle {
|
||||||
|
margin: 0 auto var(--space-3) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLabel {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarCollapsed .navLabel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarCollapsed .navItem {
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.navItem {
|
.navItem {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ interface RoomCardProps {
|
|||||||
apartmentId: string;
|
apartmentId: string;
|
||||||
onEdit: (room: Room) => void;
|
onEdit: (room: Room) => void;
|
||||||
onDelete: (room: Room) => void;
|
onDelete: (room: Room) => void;
|
||||||
|
onClone: (room: Room) => void;
|
||||||
|
cloneLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ShapePreview({ shape }: { shape: readonly Point[] }) {
|
function ShapePreview({ shape }: { shape: readonly Point[] }) {
|
||||||
@@ -66,8 +68,8 @@ function ShapePreview({ shape }: { shape: readonly Point[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RoomCard({ room, apartmentId, onEdit, onDelete }: RoomCardProps) {
|
export function RoomCard({ room, apartmentId, onEdit, onDelete, onClone, cloneLoading = false }: RoomCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
@@ -84,6 +86,13 @@ export function RoomCard({ room, apartmentId, onEdit, onDelete }: RoomCardProps)
|
|||||||
onDelete(room);
|
onDelete(room);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClone = (event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onClone(room);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cloneLabel = i18n.exists('roomCard.clone') ? t('roomCard.clone') : 'Clone';
|
||||||
|
|
||||||
const dimensions =
|
const dimensions =
|
||||||
room.width != null && room.height != null
|
room.width != null && room.height != null
|
||||||
? t('rooms.dimensions', { width: room.width, height: room.height })
|
? t('rooms.dimensions', { width: room.width, height: room.height })
|
||||||
@@ -109,6 +118,15 @@ export function RoomCard({ room, apartmentId, onEdit, onDelete }: RoomCardProps)
|
|||||||
<Button variant="ghost" size="sm" onClick={handleEdit} aria-label={t('roomCard.edit')}>
|
<Button variant="ghost" size="sm" onClick={handleEdit} aria-label={t('roomCard.edit')}>
|
||||||
{t('roomCard.edit')}
|
{t('roomCard.edit')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClone}
|
||||||
|
aria-label={cloneLabel}
|
||||||
|
disabled={cloneLoading}
|
||||||
|
>
|
||||||
|
{cloneLoading ? '…' : cloneLabel}
|
||||||
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={handleDelete} aria-label={t('roomCard.delete')}>
|
<Button variant="ghost" size="sm" onClick={handleDelete} aria-label={t('roomCard.delete')}>
|
||||||
{t('roomCard.delete')}
|
{t('roomCard.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
|
import { Modal } from './Modal';
|
||||||
|
import styles from './text-prompt-modal.module.css';
|
||||||
|
|
||||||
|
interface TextPromptModalProps {
|
||||||
|
readonly open: boolean;
|
||||||
|
readonly title: string;
|
||||||
|
readonly initialValue?: string;
|
||||||
|
readonly placeholder?: string;
|
||||||
|
readonly confirmLabel?: string;
|
||||||
|
readonly cancelLabel?: string;
|
||||||
|
readonly multiline?: boolean;
|
||||||
|
readonly onConfirm: (value: string) => void;
|
||||||
|
readonly onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight replacement for `window.prompt` — a controlled text input inside
|
||||||
|
* the shared Modal. Submits on Enter (or Cmd/Ctrl+Enter for multiline) and
|
||||||
|
* cancels on Escape (handled by Modal).
|
||||||
|
*/
|
||||||
|
export function TextPromptModal({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
initialValue = '',
|
||||||
|
placeholder,
|
||||||
|
confirmLabel = 'OK',
|
||||||
|
cancelLabel = 'Cancel',
|
||||||
|
multiline = false,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: TextPromptModalProps) {
|
||||||
|
const [value, setValue] = useState(initialValue);
|
||||||
|
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setValue(initialValue);
|
||||||
|
// Wait for the modal to mount, then focus + select the input so the
|
||||||
|
// user can type or replace immediately.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = inputRef.current;
|
||||||
|
if (el) {
|
||||||
|
el.focus();
|
||||||
|
el.select();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [open, initialValue]);
|
||||||
|
|
||||||
|
const handleConfirm = useCallback(() => {
|
||||||
|
onConfirm(value);
|
||||||
|
}, [onConfirm, value]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(event: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
if (event.key === 'Enter' && (!multiline || event.metaKey || event.ctrlKey)) {
|
||||||
|
event.preventDefault();
|
||||||
|
handleConfirm();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleConfirm, multiline],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onCancel}
|
||||||
|
title={title}
|
||||||
|
footer={
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button type="button" className={styles.button} onClick={onCancel}>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{multiline ? (
|
||||||
|
<textarea
|
||||||
|
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
|
||||||
|
className={styles.textarea}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
ref={inputRef as React.RefObject<HTMLInputElement>}
|
||||||
|
type="text"
|
||||||
|
className={styles.input}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
.input,
|
||||||
|
.textarea {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
line-height: 1.5;
|
||||||
|
transition: border-color var(--transition-fast),
|
||||||
|
box-shadow var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input::placeholder,
|
||||||
|
.textarea::placeholder {
|
||||||
|
color: var(--color-text-tertiary, var(--color-text-secondary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus,
|
||||||
|
.textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent-500, var(--color-focus-ring));
|
||||||
|
box-shadow: 0 0 0 3px var(--color-focus-ring-soft, rgba(99, 102, 241, 0.18));
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 96px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 80px;
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition-fast),
|
||||||
|
border-color var(--transition-fast),
|
||||||
|
color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover {
|
||||||
|
background-color: var(--color-bg-hover);
|
||||||
|
border-color: var(--color-border-strong, var(--color-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:focus-visible {
|
||||||
|
outline: 2px solid var(--color-focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonPrimary {
|
||||||
|
background-color: var(--color-accent-600);
|
||||||
|
border-color: var(--color-accent-600);
|
||||||
|
color: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonPrimary:hover {
|
||||||
|
background-color: var(--color-accent-700);
|
||||||
|
border-color: var(--color-accent-700);
|
||||||
|
}
|
||||||