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",
|
||||
|
||||
"nav.apartments": "Apartments",
|
||||
"nav.collapse": "Collapse sidebar",
|
||||
"nav.expand": "Expand sidebar",
|
||||
|
||||
"breadcrumb.apartments": "Apartments",
|
||||
"breadcrumb.apartmentDetails": "Apartment Details",
|
||||
@@ -108,6 +110,13 @@
|
||||
|
||||
"roomCard.edit": "Edit",
|
||||
"roomCard.delete": "Delete",
|
||||
"roomCard.clone": "Clone",
|
||||
"view3d.lightControls": "Light",
|
||||
"view3d.azimuth": "Azimuth",
|
||||
"view3d.elevation": "Elevation",
|
||||
"view3d.intensity": "Intensity",
|
||||
"view3d.reset": "Reset",
|
||||
"view3d.doorsOpen": "Show doors open",
|
||||
|
||||
"common.cancel": "Cancel",
|
||||
"common.delete": "Delete",
|
||||
@@ -162,6 +171,8 @@
|
||||
"toolbar.distributeV": "Distribute vertical",
|
||||
|
||||
"properties.title": "Properties",
|
||||
"properties.collapse": "Collapse panel",
|
||||
"properties.expand": "Expand panel",
|
||||
"properties.area": "Area",
|
||||
"properties.perimeter": "Perimeter",
|
||||
"properties.noSelection": "No element selected",
|
||||
@@ -197,6 +208,14 @@
|
||||
"properties.yes": "Yes",
|
||||
"properties.depth": "Depth",
|
||||
"properties.wallColor": "Wall color",
|
||||
"properties.wallFinish": "Wall finish",
|
||||
"properties.wallColorPaintOnly": "Wall color only applies to the Paint finish",
|
||||
"wallFinish.PAINT": "Paint",
|
||||
"wallFinish.PLASTER": "Plaster",
|
||||
"wallFinish.BRICK": "Brick",
|
||||
"wallFinish.CONCRETE": "Concrete",
|
||||
"wallFinish.WOOD_PANEL": "Wood panel",
|
||||
"wallFinish.WALLPAPER": "Wallpaper",
|
||||
"properties.floorType": "Floor",
|
||||
"floor.CONCRETE": "Concrete",
|
||||
"floor.WOOD_LIGHT": "Light Wood",
|
||||
@@ -207,6 +226,31 @@
|
||||
"floor.TILE_GRAY": "Gray Tile",
|
||||
"floor.LAMINATE": "Laminate",
|
||||
"properties.addNote": "Add note",
|
||||
"properties.showProjection": "Show on wall projection",
|
||||
"properties.opacity": "Opacity",
|
||||
"properties.customLabel": "Title",
|
||||
"properties.windowGridCols": "Grid columns",
|
||||
"properties.windowGridRows": "Grid rows",
|
||||
"properties.windowSlopeDepth": "Reveal depth",
|
||||
"properties.openingFrameThickness": "Frame thickness",
|
||||
"properties.shelfRows": "Shelf rows",
|
||||
"properties.hasBackPanel": "Back panel",
|
||||
"properties.curtainOpen": "Open",
|
||||
"properties.curtainLeftOpen": "Left open",
|
||||
"properties.curtainRightOpen": "Right open",
|
||||
"properties.curtainFabricColor": "Fabric color",
|
||||
"properties.outletWidth": "Outlet width",
|
||||
"properties.outletHeight": "Outlet height",
|
||||
"properties.outletCount": "Count",
|
||||
"properties.anchor": "Anchor",
|
||||
"anchor.left": "Left",
|
||||
"anchor.middle": "Middle",
|
||||
"anchor.right": "Right",
|
||||
"anchor.top": "Top",
|
||||
"anchor.bottom": "Bottom",
|
||||
"toolbar.furnitureOpacity": "Furniture opacity",
|
||||
"annotation.edit": "Edit",
|
||||
"annotation.delete": "Delete",
|
||||
"properties.stand": "Stand",
|
||||
"properties.openDirection": "Open direction",
|
||||
"properties.openDir.LEFT": "Left",
|
||||
@@ -226,6 +270,18 @@
|
||||
"electrical.cable": "Cable",
|
||||
|
||||
"furniture.title": "Furniture",
|
||||
"furniture.searchPlaceholder": "Search furniture\u2026",
|
||||
"furniture.noResults": "No matching furniture",
|
||||
"electrical.searchPlaceholder": "Search electrical\u2026",
|
||||
"electrical.noResults": "No matching items",
|
||||
"furnitureCategory.all": "All",
|
||||
"furnitureCategory.sleeping": "Sleeping",
|
||||
"furnitureCategory.seating": "Seating",
|
||||
"furnitureCategory.tables": "Tables",
|
||||
"furnitureCategory.storage": "Storage",
|
||||
"furnitureCategory.electronics": "Electronics",
|
||||
"furnitureCategory.climate": "Climate",
|
||||
"furnitureCategory.decor": "Decor",
|
||||
|
||||
"cableLength.label": "Cable length:",
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"furniture.other": "Другое",
|
||||
|
||||
"nav.apartments": "Квартиры",
|
||||
"nav.collapse": "Свернуть боковую панель",
|
||||
"nav.expand": "Развернуть боковую панель",
|
||||
|
||||
"breadcrumb.apartments": "Квартиры",
|
||||
"breadcrumb.apartmentDetails": "Детали квартиры",
|
||||
@@ -111,6 +113,13 @@
|
||||
|
||||
"roomCard.edit": "Изменить",
|
||||
"roomCard.delete": "Удалить",
|
||||
"roomCard.clone": "Дублировать",
|
||||
"view3d.lightControls": "Свет",
|
||||
"view3d.azimuth": "Азимут",
|
||||
"view3d.elevation": "Высота",
|
||||
"view3d.intensity": "Интенсивность",
|
||||
"view3d.reset": "Сброс",
|
||||
"view3d.doorsOpen": "Показать двери открытыми",
|
||||
|
||||
"common.cancel": "Отмена",
|
||||
"common.delete": "Удалить",
|
||||
@@ -165,6 +174,8 @@
|
||||
"toolbar.distributeV": "Распределить по вертикали",
|
||||
|
||||
"properties.title": "Свойства",
|
||||
"properties.collapse": "Свернуть панель",
|
||||
"properties.expand": "Развернуть панель",
|
||||
"properties.area": "Площадь",
|
||||
"properties.perimeter": "Периметр",
|
||||
"properties.noSelection": "Элемент не выбран",
|
||||
@@ -200,6 +211,14 @@
|
||||
"properties.yes": "Да",
|
||||
"properties.depth": "Глубина",
|
||||
"properties.wallColor": "Цвет стен",
|
||||
"properties.wallFinish": "Отделка стен",
|
||||
"properties.wallColorPaintOnly": "Цвет применяется только к покраске",
|
||||
"wallFinish.PAINT": "Покраска",
|
||||
"wallFinish.PLASTER": "Штукатурка",
|
||||
"wallFinish.BRICK": "Кирпич",
|
||||
"wallFinish.CONCRETE": "Бетон",
|
||||
"wallFinish.WOOD_PANEL": "Деревянная панель",
|
||||
"wallFinish.WALLPAPER": "Обои",
|
||||
"properties.floorType": "Пол",
|
||||
"floor.CONCRETE": "Бетон",
|
||||
"floor.WOOD_LIGHT": "Светлое дерево",
|
||||
@@ -210,6 +229,31 @@
|
||||
"floor.TILE_GRAY": "Серая плитка",
|
||||
"floor.LAMINATE": "Ламинат",
|
||||
"properties.addNote": "Добавить заметку",
|
||||
"properties.showProjection": "Показать на проекции стены",
|
||||
"properties.opacity": "Прозрачность",
|
||||
"properties.customLabel": "Название",
|
||||
"properties.windowGridCols": "Сетка: столбцы",
|
||||
"properties.windowGridRows": "Сетка: строки",
|
||||
"properties.windowSlopeDepth": "Глубина откоса",
|
||||
"properties.openingFrameThickness": "Толщина рамы",
|
||||
"properties.shelfRows": "Количество полок",
|
||||
"properties.hasBackPanel": "Задняя стенка",
|
||||
"properties.curtainOpen": "Раскрытие",
|
||||
"properties.curtainLeftOpen": "Левая створка",
|
||||
"properties.curtainRightOpen": "Правая створка",
|
||||
"properties.curtainFabricColor": "Цвет ткани",
|
||||
"properties.outletWidth": "Ширина розетки",
|
||||
"properties.outletHeight": "Высота розетки",
|
||||
"properties.outletCount": "Количество",
|
||||
"properties.anchor": "Привязка",
|
||||
"anchor.left": "Слева",
|
||||
"anchor.middle": "По центру",
|
||||
"anchor.right": "Справа",
|
||||
"anchor.top": "Сверху",
|
||||
"anchor.bottom": "Снизу",
|
||||
"toolbar.furnitureOpacity": "Прозрачность мебели",
|
||||
"annotation.edit": "Изменить",
|
||||
"annotation.delete": "Удалить",
|
||||
"properties.stand": "Подставка",
|
||||
"properties.openDirection": "Направление открытия",
|
||||
"properties.openDir.LEFT": "Влево",
|
||||
@@ -229,6 +273,18 @@
|
||||
"electrical.cable": "Кабель",
|
||||
|
||||
"furniture.title": "Мебель",
|
||||
"furniture.searchPlaceholder": "Поиск мебели\u2026",
|
||||
"furniture.noResults": "Ничего не найдено",
|
||||
"electrical.searchPlaceholder": "Поиск элементов\u2026",
|
||||
"electrical.noResults": "Ничего не найдено",
|
||||
"furnitureCategory.all": "Все",
|
||||
"furnitureCategory.sleeping": "Спальня",
|
||||
"furnitureCategory.seating": "Сиденья",
|
||||
"furnitureCategory.tables": "Столы",
|
||||
"furnitureCategory.storage": "Хранение",
|
||||
"furnitureCategory.electronics": "Электроника",
|
||||
"furnitureCategory.climate": "Климат",
|
||||
"furnitureCategory.decor": "Декор",
|
||||
|
||||
"cableLength.label": "Длина кабеля:",
|
||||
|
||||
|
||||
|
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,
|
||||
BatchSyncElectricalDto,
|
||||
BatchSyncFurnitureDto,
|
||||
BatchSyncAnnotationsDto,
|
||||
Annotation,
|
||||
ApiResponse,
|
||||
ApiListResponse,
|
||||
ApiErrorResponse,
|
||||
@@ -157,6 +159,13 @@ export async function deleteRoom(id: string): Promise<void> {
|
||||
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 ──
|
||||
|
||||
export async function bulkUpdateWalls(
|
||||
@@ -337,4 +346,18 @@ export async function batchSyncFurniture(
|
||||
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 };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Stage } from 'react-konva';
|
||||
import { Stage, Layer } from 'react-konva';
|
||||
import type Konva from 'konva';
|
||||
import type { Point } from '@house-plan-maker/shared';
|
||||
import { useZoomPan, useSelection, useSceneData } from './context/EditorContext';
|
||||
@@ -31,6 +31,7 @@ import { FURNITURE_DEFS } from './symbols/furniture';
|
||||
import { AnnotationLayer } from './layers/AnnotationLayer';
|
||||
import { MeasureOverlayLayer } from './layers/MeasureOverlayLayer';
|
||||
import { generateLocalId } from './utils/geometry';
|
||||
import { TextPromptModal } from '../ui/TextPromptModal';
|
||||
import type { EditorCommand, MeasurementState } from './types';
|
||||
|
||||
interface EditorCanvasProps {
|
||||
@@ -72,6 +73,7 @@ export function EditorCanvas({ width, height, onStageRef }: EditorCanvasProps) {
|
||||
selectedElectricalIndex,
|
||||
selectedFurnitureIndex,
|
||||
annotations,
|
||||
globalFurnitureOpacity,
|
||||
dispatch: sceneDispatch,
|
||||
addOpening,
|
||||
addElectrical,
|
||||
@@ -124,6 +126,13 @@ export function EditorCanvas({ width, height, onStageRef }: EditorCanvasProps) {
|
||||
// ── Opening placement preview ──
|
||||
const [openingPreview, setOpeningPreview] = useState<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 ��─
|
||||
const [measureState, setMeasureState] = useState<MeasurementState | null>(null);
|
||||
const isMeasuringRef = useRef(false);
|
||||
@@ -502,120 +511,135 @@ export function EditorCanvas({ width, height, onStageRef }: EditorCanvasProps) {
|
||||
onMouseUp={handleMouseUp}
|
||||
style={{ cursor, background: '#ffffff' }}
|
||||
>
|
||||
{/* Layer 1: Grid + rulers */}
|
||||
<GridLayer
|
||||
zoom={zoom}
|
||||
panOffset={panOffset}
|
||||
stageWidth={width}
|
||||
stageHeight={height}
|
||||
gridSize={gridSize}
|
||||
visible={gridVisible}
|
||||
/>
|
||||
{/*
|
||||
Konva renders one HTML <canvas> per <Layer>; performance recommends 3-5
|
||||
layers max. The previous tree had 10 Layers — one per logical group —
|
||||
which fired runtime warnings on Stage. We now collapse them into 3
|
||||
actual canvases (background, content, overlay) and use Group internally
|
||||
for each logical "layer". Visibility/listening props are preserved on
|
||||
the Group roots.
|
||||
*/}
|
||||
|
||||
{/* Layer 2: Walls + room fill */}
|
||||
{layerVisibility.walls && (
|
||||
<WallLayer
|
||||
walls={walls}
|
||||
roomShape={room.shape}
|
||||
{/* Background canvas — grid + rulers (rarely interacted with) */}
|
||||
<Layer listening={false}>
|
||||
<GridLayer
|
||||
zoom={zoom}
|
||||
panOffset={panOffset}
|
||||
selectedIds={selectedIds}
|
||||
plinthThickness={room.plinthThickness}
|
||||
stageWidth={width}
|
||||
stageHeight={height}
|
||||
gridSize={gridSize}
|
||||
visible={gridVisible}
|
||||
/>
|
||||
)}
|
||||
</Layer>
|
||||
|
||||
{/* Layer 3: Openings (doors + windows) */}
|
||||
<OpeningLayer
|
||||
openings={openings}
|
||||
walls={walls}
|
||||
roomShape={room.shape}
|
||||
zoom={zoom}
|
||||
panOffset={panOffset}
|
||||
selectedIds={selectedIds}
|
||||
preview={openingPreview}
|
||||
/>
|
||||
{/* Content canvas — room geometry, items, annotations, measurements */}
|
||||
<Layer>
|
||||
{layerVisibility.walls && (
|
||||
<WallLayer
|
||||
walls={walls}
|
||||
roomShape={room.shape}
|
||||
zoom={zoom}
|
||||
panOffset={panOffset}
|
||||
selectedIds={selectedIds}
|
||||
plinthThickness={room.plinthThickness}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Layer 4: Electrical */}
|
||||
<ElectricalLayer
|
||||
items={electricalItems}
|
||||
zoom={zoom}
|
||||
panOffset={panOffset}
|
||||
selectedIds={selectedIds}
|
||||
visible={layerVisibility.electrical}
|
||||
/>
|
||||
|
||||
{/* Layer 5: Furniture */}
|
||||
<FurnitureLayer
|
||||
items={furnitureItems}
|
||||
zoom={zoom}
|
||||
panOffset={panOffset}
|
||||
selectedIds={selectedIds}
|
||||
visible={layerVisibility.furniture}
|
||||
/>
|
||||
|
||||
{/* Layer 6: Measurements */}
|
||||
{layerVisibility.measurements && (
|
||||
<MeasurementLayer
|
||||
walls={walls}
|
||||
<OpeningLayer
|
||||
openings={openings}
|
||||
zoom={zoom}
|
||||
panOffset={panOffset}
|
||||
selectedIds={selectedIds}
|
||||
roomShape={room.shape}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Layer 7: Room labels */}
|
||||
{layerVisibility.measurements && (
|
||||
<RoomLabelLayer
|
||||
roomName={room.name}
|
||||
walls={walls}
|
||||
roomShape={room.shape}
|
||||
zoom={zoom}
|
||||
panOffset={panOffset}
|
||||
selectedIds={selectedIds}
|
||||
preview={openingPreview}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Layer 8: Annotations */}
|
||||
{layerVisibility.annotations && (
|
||||
<AnnotationLayer
|
||||
annotations={annotations}
|
||||
electricalItems={electricalItems}
|
||||
furnitureItems={furnitureItems}
|
||||
<ElectricalLayer
|
||||
items={electricalItems}
|
||||
zoom={zoom}
|
||||
panOffset={panOffset}
|
||||
selectedIds={selectedIds}
|
||||
onSelect={(id) => selectElement(id)}
|
||||
onDragEnd={(id, x, y) => {
|
||||
const ann = annotations.find((a) => a.id === id);
|
||||
if (ann) updateAnnotation({ ...ann, x, y });
|
||||
}}
|
||||
onDoubleClick={(id) => {
|
||||
const ann = annotations.find((a) => a.id === id);
|
||||
if (!ann) return;
|
||||
const newText = window.prompt(t('annotation.editPrompt'), ann.text);
|
||||
if (newText != null && newText !== ann.text) {
|
||||
updateAnnotation({ ...ann, text: newText });
|
||||
}
|
||||
}}
|
||||
visible={layerVisibility.electrical}
|
||||
outletWidth={room.outletWidth}
|
||||
outletHeight={room.outletHeight}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Layer 9: Measure overlay */}
|
||||
<MeasureOverlayLayer
|
||||
measurement={measureState}
|
||||
zoom={zoom}
|
||||
panOffset={panOffset}
|
||||
/>
|
||||
<FurnitureLayer
|
||||
items={furnitureItems}
|
||||
zoom={zoom}
|
||||
panOffset={panOffset}
|
||||
selectedIds={selectedIds}
|
||||
visible={layerVisibility.furniture}
|
||||
globalOpacity={globalFurnitureOpacity}
|
||||
/>
|
||||
|
||||
{/* Layer 10: Selection overlay */}
|
||||
<SelectionLayer
|
||||
zoom={zoom}
|
||||
panOffset={panOffset}
|
||||
selectionBox={selBox}
|
||||
dragRect={dragRect}
|
||||
/>
|
||||
{layerVisibility.measurements && (
|
||||
<MeasurementLayer
|
||||
walls={walls}
|
||||
openings={openings}
|
||||
zoom={zoom}
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export function EditorToolbar({ onSave, isSaving, onExport, onImport }: EditorTo
|
||||
const { state, setTool, setZoom, dispatch } = useEditor();
|
||||
const { undo, redo, canUndo, canRedo } = useUndoRedo();
|
||||
|
||||
const { activeTool, zoom, gridVisible, snapEnabled, layerVisibility } = state;
|
||||
const { activeTool, zoom, gridVisible, snapEnabled, layerVisibility, globalFurnitureOpacity } = state;
|
||||
|
||||
const zoomPercent = Math.round((zoom / 100) * 100);
|
||||
|
||||
@@ -193,6 +193,37 @@ export function EditorToolbar({ onSave, isSaving, onExport, onImport }: EditorTo
|
||||
>
|
||||
{t('toolbar.meas')}
|
||||
</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>
|
||||
|
||||
{/* Alignment tools — visible when 2+ items selected */}
|
||||
|
||||
@@ -1,21 +1,70 @@
|
||||
import { useMemo, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Wall, WallOpening, ElectricalItem, FurnitureItem, DoorOpenDirection, FloorType } from '@house-plan-maker/shared';
|
||||
import { DOOR_OPEN_DIRECTIONS, FLOOR_TYPES } from '@house-plan-maker/shared';
|
||||
import type { Wall, WallOpening, ElectricalItem, FurnitureItem, DoorOpenDirection, FloorType, WallFinish, Annotation, PositionAnchor, HorizontalAnchor, VerticalAnchor } from '@house-plan-maker/shared';
|
||||
import { TextPromptModal } from '../ui/TextPromptModal';
|
||||
import { DOOR_OPEN_DIRECTIONS, FLOOR_TYPES, WALL_FINISHES, HORIZONTAL_ANCHORS, VERTICAL_ANCHORS } from '@house-plan-maker/shared';
|
||||
import { useEditor } from './context/EditorContext';
|
||||
import { useUndoRedo } from './context/UndoRedoContext';
|
||||
import { wallLength } from './utils/wallUtils';
|
||||
import { polygonArea, polygonPerimeter, generateLocalId } from './utils/geometry';
|
||||
import { normalizeAngleDegrees } from './utils/angle';
|
||||
import { getElectricalVariant, ELECTRICAL_SYMBOL_DEFS } from './symbols/electrical';
|
||||
import {
|
||||
getCurtainLeftOpen,
|
||||
getCurtainRightOpen,
|
||||
getCurtainFabricColor,
|
||||
} from './utils/curtainMetadata';
|
||||
import type { EditorCommand } from './types';
|
||||
import styles from './properties-panel.module.css';
|
||||
|
||||
const PROPERTIES_COLLAPSED_KEY = 'editor.propertiesPanel.collapsed';
|
||||
|
||||
function readCollapsed(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(PROPERTIES_COLLAPSED_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCollapsed(value: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(PROPERTIES_COLLAPSED_KEY, String(value));
|
||||
} catch {
|
||||
/* ignore quota / disabled storage */
|
||||
}
|
||||
}
|
||||
|
||||
export function PropertiesPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { state, dispatch, updateOpening, updateElectrical, updateFurniture, updateWall, addAnnotation } = useEditor();
|
||||
const { state, dispatch, updateOpening, updateElectrical, updateFurniture, updateWall } = useEditor();
|
||||
const { execute } = useUndoRedo();
|
||||
const { selectedIds, walls, openings, electricalItems, furnitureItems, room } = state;
|
||||
|
||||
const [collapsed, setCollapsed] = useState<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(
|
||||
() => room.shape.length >= 3 ? polygonArea(room.shape) : 0,
|
||||
[room.shape],
|
||||
@@ -57,10 +106,26 @@ export function PropertiesPanel() {
|
||||
return items;
|
||||
}, [selectedIds, walls, openings, electricalItems, furnitureItems]);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<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) {
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.header}>{t('properties.title')}</div>
|
||||
{header}
|
||||
<div className={styles.empty}>
|
||||
<p className={styles.emptyText}>{t('properties.noSelection')}</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 } })}
|
||||
/>
|
||||
<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}>
|
||||
<span className={styles.rowLabel}>{t('properties.wallColor')}</span>
|
||||
<input
|
||||
@@ -92,10 +166,39 @@ export function PropertiesPanel() {
|
||||
value={room.wallColor ?? '#f5f0eb'}
|
||||
onChange={(e) => dispatch({ type: 'UPDATE_ROOM_PROPS', props: { wallColor: e.target.value } })}
|
||||
style={{ width: 32, height: 24, border: '1px solid var(--color-border)', borderRadius: 4, cursor: 'pointer', padding: 0 }}
|
||||
// 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>
|
||||
<PropertyRow label={t('properties.walls')} value={String(walls.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>
|
||||
);
|
||||
@@ -104,7 +207,7 @@ export function PropertiesPanel() {
|
||||
if (selected.length > 1) {
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.header}>{t('properties.title')}</div>
|
||||
{header}
|
||||
<div className={styles.empty}>
|
||||
<p className={styles.emptyText}>{t('properties.multipleSelected', { count: selected.length })}</p>
|
||||
</div>
|
||||
@@ -116,7 +219,7 @@ export function PropertiesPanel() {
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.header}>{t('properties.title')}</div>
|
||||
{header}
|
||||
{item.type === 'wall' && (
|
||||
<WallProperties
|
||||
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') && (
|
||||
<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
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 8px',
|
||||
fontSize: '12px',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '4px',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => {
|
||||
const text = window.prompt(t('annotation.editPrompt'), '');
|
||||
if (text) {
|
||||
addAnnotation({
|
||||
id: generateLocalId(),
|
||||
roomId: room.id,
|
||||
x: 0.3,
|
||||
y: -0.2,
|
||||
text,
|
||||
fontSize: 12,
|
||||
attachedToId: item.data.id,
|
||||
});
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
style={{ fontSize: 11, padding: '0 4px' }}
|
||||
onClick={() => setEditing({ kind: 'edit', annotation: ann })}
|
||||
aria-label={t('annotation.edit') ?? 'Edit'}
|
||||
>
|
||||
{t('properties.addNote')}
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style={{ fontSize: 11, padding: '0 4px' }}
|
||||
onClick={() => removeAnnotation(ann.id)}
|
||||
aria-label={t('annotation.delete') ?? 'Delete'}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -260,9 +434,35 @@ interface OpeningPropertiesProps {
|
||||
}
|
||||
|
||||
function OpeningProperties({ opening, walls, onUpdate }: OpeningPropertiesProps) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const wall = walls.find((w) => w.id === opening.wallId);
|
||||
const wLen = wall ? wallLength(wall) : 0;
|
||||
const gridColsLabel = i18n.exists('properties.windowGridCols')
|
||||
? t('properties.windowGridCols')
|
||||
: 'Grid columns';
|
||||
const gridRowsLabel = i18n.exists('properties.windowGridRows')
|
||||
? t('properties.windowGridRows')
|
||||
: 'Grid rows';
|
||||
const slopeDepthLabel = i18n.exists('properties.windowSlopeDepth')
|
||||
? t('properties.windowSlopeDepth')
|
||||
: 'Reveal depth (slope)';
|
||||
const frameThicknessLabel = i18n.exists('properties.openingFrameThickness')
|
||||
? t('properties.openingFrameThickness')
|
||||
: 'Frame thickness';
|
||||
// Slope projects outward from the window into and through the wall
|
||||
// toward the exterior, so it isn't bounded by wall thickness — only by
|
||||
// the schema-level cap (2 m), which is plenty for any realistic reveal.
|
||||
const maxSlopeDepth = 2;
|
||||
// Frame thickness is bounded by the schema cap (0.5 m); deeper than that
|
||||
// would dwarf even oversize doors and is almost certainly a typo.
|
||||
const maxFrameThickness = 0.5;
|
||||
|
||||
// Openings always store canonical (positionAlongWall=center, elevationFromFloor=bottom).
|
||||
// The anchor on an opening is a view-only preference: it controls how the
|
||||
// numbers are displayed and edited in this panel, but does not move the
|
||||
// physical opening. Toggling anchor only changes which edge of the opening
|
||||
// the displayed values refer to.
|
||||
const anchor = opening.positionAnchor;
|
||||
|
||||
const handleWidthChange = useCallback(
|
||||
(value: string) => {
|
||||
@@ -284,31 +484,50 @@ function OpeningProperties({ opening, walls, onUpdate }: OpeningPropertiesProps)
|
||||
[opening, onUpdate],
|
||||
);
|
||||
|
||||
// Position displayed as left edge offset, stored as center
|
||||
const displayPosition = Math.round((opening.positionAlongWall - opening.width / 2) * 1000) / 1000;
|
||||
// Convert canonical (center along wall, bottom from floor) into the value
|
||||
// displayed in the panel based on the user's anchor preference.
|
||||
const displayPosition = Math.round((() => {
|
||||
if (anchor.horizontal === 'left') return opening.positionAlongWall - opening.width / 2;
|
||||
if (anchor.horizontal === 'right') return opening.positionAlongWall + opening.width / 2;
|
||||
return opening.positionAlongWall;
|
||||
})() * 1000) / 1000;
|
||||
|
||||
const displayElevation = Math.round((() => {
|
||||
if (anchor.vertical === 'top') return opening.elevationFromFloor + opening.height;
|
||||
if (anchor.vertical === 'middle') return opening.elevationFromFloor + opening.height / 2;
|
||||
return opening.elevationFromFloor;
|
||||
})() * 1000) / 1000;
|
||||
|
||||
const handlePositionChange = useCallback(
|
||||
(value: string) => {
|
||||
const num = parseFloat(value);
|
||||
if (!isNaN(num) && num >= 0) {
|
||||
// Convert left edge offset back to center position
|
||||
const centerPos = num + opening.width / 2;
|
||||
if (centerPos <= wLen) {
|
||||
if (!isNaN(num)) {
|
||||
// Convert anchored value back to canonical center position.
|
||||
let centerPos = num;
|
||||
if (anchor.horizontal === 'left') centerPos = num + opening.width / 2;
|
||||
else if (anchor.horizontal === 'right') centerPos = num - opening.width / 2;
|
||||
if (centerPos >= 0 && centerPos <= wLen) {
|
||||
onUpdate({ ...opening, positionAlongWall: centerPos });
|
||||
}
|
||||
}
|
||||
},
|
||||
[opening, onUpdate, wLen],
|
||||
[opening, onUpdate, wLen, anchor.horizontal],
|
||||
);
|
||||
|
||||
const handleElevationChange = useCallback(
|
||||
(value: string) => {
|
||||
const num = parseFloat(value);
|
||||
if (!isNaN(num) && num >= 0) {
|
||||
onUpdate({ ...opening, elevationFromFloor: num });
|
||||
if (!isNaN(num)) {
|
||||
// Convert anchored vertical value back to canonical bottom-edge.
|
||||
let bottom = num;
|
||||
if (anchor.vertical === 'top') bottom = num - opening.height;
|
||||
else if (anchor.vertical === 'middle') bottom = num - opening.height / 2;
|
||||
if (bottom >= 0) {
|
||||
onUpdate({ ...opening, elevationFromFloor: bottom });
|
||||
}
|
||||
}
|
||||
},
|
||||
[opening, onUpdate],
|
||||
[opening, onUpdate, anchor.vertical],
|
||||
);
|
||||
|
||||
const handleOpenDirectionChange = useCallback(
|
||||
@@ -337,10 +556,27 @@ function OpeningProperties({ opening, walls, onUpdate }: OpeningPropertiesProps)
|
||||
/>
|
||||
<EditablePropertyRow
|
||||
label={t('properties.position')}
|
||||
value={String(Math.max(0, displayPosition))}
|
||||
value={String(displayPosition)}
|
||||
unit="m"
|
||||
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' && (
|
||||
<SelectPropertyRow
|
||||
label={t('properties.openDirection')}
|
||||
@@ -353,12 +589,54 @@ function OpeningProperties({ opening, walls, onUpdate }: OpeningPropertiesProps)
|
||||
/>
|
||||
)}
|
||||
{opening.type === 'WINDOW' && (
|
||||
<EditablePropertyRow
|
||||
label={t('properties.elevation')}
|
||||
value={String(opening.elevationFromFloor)}
|
||||
unit="m"
|
||||
onCommit={handleElevationChange}
|
||||
/>
|
||||
<>
|
||||
<EditablePropertyRow
|
||||
label={t('properties.elevation')}
|
||||
value={String(displayElevation)}
|
||||
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 && (
|
||||
<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 ──
|
||||
|
||||
interface SelectPropertyRowProps<T extends string> {
|
||||
@@ -458,6 +782,65 @@ interface SelectPropertyRowProps<T extends string> {
|
||||
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>) {
|
||||
return (
|
||||
<div className={styles.row}>
|
||||
@@ -485,13 +868,15 @@ interface ElectricalPropertiesProps {
|
||||
}
|
||||
|
||||
function ElectricalProperties({ item, onUpdate }: ElectricalPropertiesProps) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const variant = getElectricalVariant(item.metadata);
|
||||
const def = ELECTRICAL_SYMBOL_DEFS.find(
|
||||
(d) => d.type === item.type && (d.variant ?? 'single') === variant,
|
||||
(d) => d.type === item.type && (d.variant ?? 'single') === (item.type === 'OUTLET' ? undefined : variant),
|
||||
);
|
||||
|
||||
const isWallMounted = item.wallId !== null;
|
||||
const isOutlet = item.type === 'OUTLET';
|
||||
const countLabel = i18n.exists('properties.outletCount') ? t('properties.outletCount') : 'Count';
|
||||
|
||||
const handleXChange = useCallback(
|
||||
(value: string) => {
|
||||
@@ -512,7 +897,7 @@ function ElectricalProperties({ item, onUpdate }: ElectricalPropertiesProps) {
|
||||
const handleRotationChange = useCallback(
|
||||
(value: string) => {
|
||||
const num = parseFloat(value);
|
||||
if (!isNaN(num)) onUpdate({ ...item, rotation: num % 360 });
|
||||
if (!isNaN(num)) onUpdate({ ...item, rotation: normalizeAngleDegrees(num) });
|
||||
},
|
||||
[item, onUpdate],
|
||||
);
|
||||
@@ -527,16 +912,38 @@ function ElectricalProperties({ item, onUpdate }: ElectricalPropertiesProps) {
|
||||
[item, onUpdate],
|
||||
);
|
||||
|
||||
// Section title shows the user's custom label if set, otherwise the
|
||||
// default symbol-def label or raw type code as fallback.
|
||||
const displayTitle = item.label ?? def?.label ?? item.type;
|
||||
const labelPlaceholder = def?.label ?? item.type;
|
||||
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>
|
||||
{def?.label ?? item.type}
|
||||
</div>
|
||||
<div className={styles.sectionTitle}>{displayTitle}</div>
|
||||
<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.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} />
|
||||
<PositionAnchorEditor
|
||||
anchor={item.positionAnchor}
|
||||
onChange={(positionAnchor) => onUpdate({ ...item, positionAnchor })}
|
||||
/>
|
||||
{isWallMounted && (
|
||||
<>
|
||||
<PropertyRow label={t('properties.wallMounted')} value={t('properties.yes')} />
|
||||
@@ -559,8 +966,34 @@ interface FurniturePropertiesProps {
|
||||
readonly onUpdate: (item: FurnitureItem) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the legacy `[no-stand]` marker from a furniture label so the input
|
||||
* field shows only the user-visible name. The marker is a single-purpose
|
||||
* boolean stored in the label string for TVs to disable the stand mesh.
|
||||
*/
|
||||
function stripFurnitureMarkers(label: string | null): string | null {
|
||||
if (!label) return null;
|
||||
const cleaned = label.replace('[no-stand]', '').trim();
|
||||
return cleaned.length > 0 ? cleaned : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-attach any markers that the original label carried after the user
|
||||
* edited the visible portion. Currently only `[no-stand]` is preserved.
|
||||
*/
|
||||
function preserveFurnitureMarkers(originalLabel: string | null, newDisplay: string | null): string | null {
|
||||
const hadNoStand = originalLabel?.includes('[no-stand]') ?? false;
|
||||
if (!hadNoStand) return newDisplay;
|
||||
const trimmed = (newDisplay ?? '').trim();
|
||||
return trimmed.length > 0 ? `${trimmed} [no-stand]` : '[no-stand]';
|
||||
}
|
||||
|
||||
function FurnitureProperties({ item, onUpdate }: FurniturePropertiesProps) {
|
||||
const { t } = useTranslation();
|
||||
const displayLabel = stripFurnitureMarkers(item.label);
|
||||
// Furniture's "default" label for the placeholder is the type code; we
|
||||
// don't have access to the original FurnitureDef from a placed item.
|
||||
const labelPlaceholder = item.type;
|
||||
|
||||
const handleXChange = useCallback(
|
||||
(value: string) => {
|
||||
@@ -613,7 +1046,7 @@ function FurnitureProperties({ item, onUpdate }: FurniturePropertiesProps) {
|
||||
const handleRotationChange = useCallback(
|
||||
(value: string) => {
|
||||
const num = parseFloat(value);
|
||||
if (!isNaN(num)) onUpdate({ ...item, rotation: num % 360 });
|
||||
if (!isNaN(num)) onUpdate({ ...item, rotation: normalizeAngleDegrees(num) });
|
||||
},
|
||||
[item, onUpdate],
|
||||
);
|
||||
@@ -621,9 +1054,16 @@ function FurnitureProperties({ item, onUpdate }: FurniturePropertiesProps) {
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>
|
||||
{item.label ?? item.type}
|
||||
{displayLabel ?? item.type}
|
||||
</div>
|
||||
<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.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} />
|
||||
@@ -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.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} />
|
||||
<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' && (
|
||||
<div className={styles.row}>
|
||||
<span className={styles.rowLabel}>{t('properties.stand')}</span>
|
||||
@@ -651,6 +1099,247 @@ function FurnitureProperties({ item, onUpdate }: FurniturePropertiesProps) {
|
||||
</label>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type Konva from 'konva';
|
||||
import { useEditor } from './context/EditorContext';
|
||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
|
||||
import { boundingBox } from './utils/geometry';
|
||||
import { normalizeAngleDegrees } from './utils/angle';
|
||||
import { EditorCanvas } from './EditorCanvas';
|
||||
import { EditorToolbar } from './EditorToolbar';
|
||||
import { PropertiesPanel } from './PropertiesPanel';
|
||||
@@ -24,6 +25,8 @@ import {
|
||||
batchSyncOpenings,
|
||||
batchSyncElectrical,
|
||||
batchSyncFurniture,
|
||||
batchSyncAnnotations,
|
||||
updateRoom,
|
||||
} from '../../api/client';
|
||||
import type {
|
||||
CreateWallOpeningDto,
|
||||
@@ -32,6 +35,8 @@ import type {
|
||||
UpdateElectricalItemDto,
|
||||
CreateFurnitureItemDto,
|
||||
UpdateFurnitureItemDto,
|
||||
CreateAnnotationDto,
|
||||
UpdateAnnotationDto,
|
||||
} from '@house-plan-maker/shared';
|
||||
import styles from './room-editor-layout.module.css';
|
||||
|
||||
@@ -51,7 +56,10 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('2d');
|
||||
const [showExport, setShowExport] = useState(false);
|
||||
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 ──
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
@@ -60,6 +68,7 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
||||
openings: state.openings,
|
||||
electricalItems: state.electricalItems,
|
||||
furnitureItems: state.furnitureItems,
|
||||
room: state.room,
|
||||
});
|
||||
|
||||
// Mark dirty when state diverges from last saved snapshot
|
||||
@@ -69,9 +78,33 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
||||
state.walls !== saved.walls ||
|
||||
state.openings !== saved.openings ||
|
||||
state.electricalItems !== saved.electricalItems ||
|
||||
state.furnitureItems !== saved.furnitureItems;
|
||||
state.furnitureItems !== saved.furnitureItems ||
|
||||
state.room.floorType !== saved.room.floorType ||
|
||||
state.room.wallColor !== saved.room.wallColor ||
|
||||
state.room.wallFinish !== saved.room.wallFinish ||
|
||||
state.room.wallHeight !== saved.room.wallHeight ||
|
||||
state.room.plinthHeight !== saved.room.plinthHeight ||
|
||||
state.room.plinthThickness !== saved.room.plinthThickness ||
|
||||
state.room.outletWidth !== saved.room.outletWidth ||
|
||||
state.room.outletHeight !== saved.room.outletHeight ||
|
||||
state.room.name !== saved.room.name;
|
||||
setIsDirty(dirty);
|
||||
}, [state.walls, state.openings, state.electricalItems, state.furnitureItems]);
|
||||
}, [
|
||||
state.walls,
|
||||
state.openings,
|
||||
state.electricalItems,
|
||||
state.furnitureItems,
|
||||
state.room.floorType,
|
||||
state.room.wallColor,
|
||||
state.room.wallFinish,
|
||||
state.room.wallHeight,
|
||||
state.room.plinthHeight,
|
||||
state.room.plinthThickness,
|
||||
state.room.outletWidth,
|
||||
state.room.outletHeight,
|
||||
state.room.name,
|
||||
state.room,
|
||||
]);
|
||||
|
||||
// Warn on browser close / refresh
|
||||
useEffect(() => {
|
||||
@@ -123,10 +156,21 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
||||
const container = canvasContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const commitSize = (w: number, h: number): void => {
|
||||
const width = Math.floor(w);
|
||||
const height = Math.floor(h);
|
||||
if (width <= 0 || height <= 0) return;
|
||||
// Skip no-op updates so the auto-fit effect doesn't re-run on every
|
||||
// ResizeObserver tick that doesn't actually change the pixel size.
|
||||
setCanvasSize((prev) => {
|
||||
if (prev && prev.width === width && prev.height === height) return prev;
|
||||
return { width, height };
|
||||
});
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { width, height } = entry.contentRect;
|
||||
setCanvasSize({ width: Math.floor(width), height: Math.floor(height) });
|
||||
commitSize(entry.contentRect.width, entry.contentRect.height);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -134,15 +178,23 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
||||
|
||||
// Initial size
|
||||
const rect = container.getBoundingClientRect();
|
||||
setCanvasSize({ width: Math.floor(rect.width), height: Math.floor(rect.height) });
|
||||
commitSize(rect.width, rect.height);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// ── Center room in canvas on first mount ──
|
||||
const hasCenteredRef = useRef(false);
|
||||
// ── Auto-fit the room into the 2D canvas ──
|
||||
// Fires once the container has been measured and the room shape is
|
||||
// available, and again whenever either changes. Skips no-op reruns where
|
||||
// the canvas and room already match the last fit signature so we don't
|
||||
// flicker through multiple frames on open.
|
||||
const hasUserAdjustedViewRef = useRef(false);
|
||||
const lastFitSignatureRef = useRef<string>('');
|
||||
const lastDispatchedViewRef = useRef<{ zoom: number; panX: number; panY: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasCenteredRef.current) return;
|
||||
if (viewMode !== '2d') return;
|
||||
if (!canvasSize) return;
|
||||
if (canvasSize.width <= 100 || canvasSize.height <= 100) return;
|
||||
if (state.room.shape.length === 0) return;
|
||||
|
||||
@@ -151,7 +203,14 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
||||
const roomH = bbox.maxY - bbox.minY;
|
||||
if (roomW <= 0 || roomH <= 0) return;
|
||||
|
||||
// Fit room in canvas with some padding
|
||||
const signature = `${canvasSize.width}x${canvasSize.height}|${bbox.minX},${bbox.minY},${bbox.maxX},${bbox.maxY}`;
|
||||
|
||||
// Already fit at this signature? Nothing to do.
|
||||
if (lastFitSignatureRef.current === signature) return;
|
||||
// User moved the camera → don't clobber their view until the room or
|
||||
// canvas actually changes dimensions (which gives a new signature).
|
||||
if (hasUserAdjustedViewRef.current && lastFitSignatureRef.current !== '') return;
|
||||
|
||||
const padding = 80;
|
||||
const scaleX = (canvasSize.width - padding * 2) / roomW;
|
||||
const scaleY = (canvasSize.height - padding * 2) / roomH;
|
||||
@@ -162,10 +221,27 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
||||
const panX = canvasSize.width / 2 - centerX * fitZoom;
|
||||
const panY = canvasSize.height / 2 - centerY * fitZoom;
|
||||
|
||||
dispatch({ type: 'SET_ZOOM', zoom: fitZoom });
|
||||
dispatch({ type: 'SET_PAN_OFFSET', offset: { x: panX, y: panY } });
|
||||
hasCenteredRef.current = true;
|
||||
}, [canvasSize, state.room.shape, dispatch]);
|
||||
lastDispatchedViewRef.current = { zoom: fitZoom, panX, panY };
|
||||
// Single atomic reducer pass — produces one new state, not two, so the
|
||||
// ZoomPanContext can't emit an intermediate (newZoom, oldPan) frame.
|
||||
dispatch({ type: 'SET_VIEW', zoom: fitZoom, offset: { x: panX, y: panY } });
|
||||
lastFitSignatureRef.current = signature;
|
||||
}, [viewMode, canvasSize, state.room.shape, dispatch]);
|
||||
|
||||
// Detect *manual* zoom/pan. Comparing against the values we just
|
||||
// dispatched prevents the auto-fit itself from flipping the flag.
|
||||
useEffect(() => {
|
||||
const last = lastDispatchedViewRef.current;
|
||||
if (!last) return;
|
||||
const EPS = 0.5;
|
||||
const cameFromAutoFit =
|
||||
Math.abs(state.zoom - last.zoom) < EPS &&
|
||||
Math.abs(state.panOffset.x - last.panX) < EPS &&
|
||||
Math.abs(state.panOffset.y - last.panY) < EPS;
|
||||
if (!cameFromAutoFit) {
|
||||
hasUserAdjustedViewRef.current = true;
|
||||
}
|
||||
}, [state.zoom, state.panOffset]);
|
||||
|
||||
// ── Re-measure canvas when switching back to 2D view ──
|
||||
useEffect(() => {
|
||||
@@ -192,6 +268,19 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
||||
setSaveError(null);
|
||||
|
||||
try {
|
||||
// 0. Save room-level properties (floor, wall color, heights, name)
|
||||
await updateRoom(roomId, {
|
||||
name: state.room.name,
|
||||
floorType: state.room.floorType,
|
||||
wallColor: state.room.wallColor,
|
||||
wallFinish: state.room.wallFinish,
|
||||
wallHeight: state.room.wallHeight,
|
||||
plinthHeight: state.room.plinthHeight,
|
||||
plinthThickness: state.room.plinthThickness,
|
||||
outletWidth: state.room.outletWidth,
|
||||
outletHeight: state.room.outletHeight,
|
||||
});
|
||||
|
||||
// 1. Save walls first (bulk replace) to get server-assigned wall IDs
|
||||
const wallDtos = state.walls.map((w) => ({
|
||||
startX: w.startX,
|
||||
@@ -237,6 +326,11 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
||||
height: opening.height,
|
||||
elevationFromFloor: opening.elevationFromFloor,
|
||||
openDirection: opening.openDirection,
|
||||
positionAnchor: opening.positionAnchor,
|
||||
gridCols: opening.gridCols,
|
||||
gridRows: opening.gridRows,
|
||||
slopeDepth: opening.slopeDepth,
|
||||
frameThickness: opening.frameThickness,
|
||||
});
|
||||
}
|
||||
// No updates or deletes needed — cascade already removed all server openings
|
||||
@@ -255,7 +349,10 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
||||
y: elec.y,
|
||||
wallId: serverWallId,
|
||||
elevationFromFloor: elec.elevationFromFloor,
|
||||
rotation: elec.rotation,
|
||||
rotation: normalizeAngleDegrees(elec.rotation ?? 0),
|
||||
count: elec.count,
|
||||
positionAnchor: elec.positionAnchor,
|
||||
label: elec.label,
|
||||
metadata: elec.metadata,
|
||||
});
|
||||
}
|
||||
@@ -280,10 +377,19 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
||||
rotation: furn.rotation,
|
||||
elevationFromFloor: furn.elevationFromFloor,
|
||||
label: furn.label,
|
||||
showProjection: furn.showProjection ?? false,
|
||||
opacity: furn.opacity ?? 1,
|
||||
positionAnchor: furn.positionAnchor,
|
||||
metadata: furn.metadata ?? null,
|
||||
});
|
||||
} else if (serverFurnIds.has(furn.id)) {
|
||||
const serverFurn = freshRoom.furnitureItems.find((f) => f.id === furn.id);
|
||||
if (serverFurn) {
|
||||
const anchorChanged =
|
||||
serverFurn.positionAnchor.horizontal !== furn.positionAnchor.horizontal ||
|
||||
serverFurn.positionAnchor.vertical !== furn.positionAnchor.vertical;
|
||||
const metadataChanged =
|
||||
JSON.stringify(serverFurn.metadata ?? null) !== JSON.stringify(furn.metadata ?? null);
|
||||
const hasChanges =
|
||||
serverFurn.x !== furn.x ||
|
||||
serverFurn.y !== furn.y ||
|
||||
@@ -292,7 +398,11 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
||||
serverFurn.height !== furn.height ||
|
||||
serverFurn.rotation !== furn.rotation ||
|
||||
serverFurn.elevationFromFloor !== furn.elevationFromFloor ||
|
||||
serverFurn.label !== furn.label;
|
||||
serverFurn.label !== furn.label ||
|
||||
(serverFurn.showProjection ?? false) !== (furn.showProjection ?? false) ||
|
||||
(serverFurn.opacity ?? 1) !== (furn.opacity ?? 1) ||
|
||||
anchorChanged ||
|
||||
metadataChanged;
|
||||
if (hasChanges) {
|
||||
furnUpdate.push({
|
||||
id: furn.id,
|
||||
@@ -303,9 +413,13 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
||||
width: furn.width,
|
||||
depth: furn.depth,
|
||||
height: furn.height,
|
||||
rotation: furn.rotation,
|
||||
rotation: normalizeAngleDegrees(furn.rotation ?? 0),
|
||||
elevationFromFloor: furn.elevationFromFloor,
|
||||
label: furn.label,
|
||||
showProjection: furn.showProjection ?? false,
|
||||
opacity: furn.opacity ?? 1,
|
||||
positionAnchor: furn.positionAnchor,
|
||||
metadata: furn.metadata ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -319,7 +433,9 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Execute all 3 batch calls in parallel — responses contain final server state
|
||||
// 6. Execute the 3 element batch calls in parallel — responses contain
|
||||
// final server state. Annotations need to wait until after this so we
|
||||
// can remap their attachedToId through the new server-side ids.
|
||||
const [syncedOpenings, syncedElectrical, syncedFurniture] = await Promise.all([
|
||||
batchSyncOpenings(roomId, {
|
||||
create: openingsCreate,
|
||||
@@ -338,13 +454,132 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
||||
}),
|
||||
]);
|
||||
|
||||
// 7. Sync state with server-assigned IDs (single dispatch, no flicker)
|
||||
// 7. Build an id map (old local id → new server id) so the reducer can
|
||||
// preserve the user's selection across the bulk-replace save flow.
|
||||
// The server batch endpoints return items in non-deterministic order, so
|
||||
// we match by content, then consume each match exactly once.
|
||||
const idMap = new Map<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({
|
||||
type: 'SYNC_SAVE',
|
||||
walls: serverWalls,
|
||||
openings: syncedOpenings,
|
||||
electricalItems: syncedElectrical,
|
||||
furnitureItems: syncedFurniture,
|
||||
annotations: syncedAnnotations,
|
||||
idMap,
|
||||
});
|
||||
|
||||
// Mark state as clean after successful save
|
||||
@@ -353,6 +588,7 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
||||
openings: syncedOpenings,
|
||||
electricalItems: syncedElectrical,
|
||||
furnitureItems: syncedFurniture,
|
||||
room: state.room,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : t('editor.error.load');
|
||||
@@ -361,7 +597,7 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
||||
setIsSaving(false);
|
||||
isSavingRef.current = false;
|
||||
}
|
||||
}, [roomId, state.walls, state.openings, state.electricalItems, state.furnitureItems, dispatch]);
|
||||
}, [roomId, state.walls, state.openings, state.electricalItems, state.furnitureItems, state.annotations, state.room, dispatch]);
|
||||
|
||||
// ── Auto-save with ref-based debounce ──
|
||||
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -544,11 +780,16 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
|
||||
className={styles.canvasContainer}
|
||||
style={viewMode !== '2d' ? { position: 'absolute', width: 0, height: 0, overflow: 'hidden', pointerEvents: 'none' } : undefined}
|
||||
>
|
||||
<EditorCanvas
|
||||
width={canvasSize.width}
|
||||
height={canvasSize.height}
|
||||
onStageRef={handleMainStageRef}
|
||||
/>
|
||||
{/* Only mount the Konva stage once the container has been
|
||||
measured — rendering at a seed 800×600 and then re-rendering
|
||||
at the real size causes a visible flicker on open. */}
|
||||
{canvasSize && (
|
||||
<EditorCanvas
|
||||
width={canvasSize.width}
|
||||
height={canvasSize.height}
|
||||
onStageRef={handleMainStageRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{viewMode === '2d' && (
|
||||
<CableLengthStatus electricalItems={state.electricalItems} />
|
||||
|
||||
@@ -76,7 +76,9 @@ function createInitialState(room: RoomFull): EditorState {
|
||||
layerVisibility: { walls: true, electrical: true, furniture: true, measurements: true, annotations: true },
|
||||
selectedElectricalIndex: null,
|
||||
selectedFurnitureIndex: null,
|
||||
annotations: [],
|
||||
annotations: room.annotations ?? [],
|
||||
furnitureProjectionIds: new Set(),
|
||||
globalFurnitureOpacity: 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,6 +95,7 @@ function editorReducer(state: EditorState, action: EditorAction): EditorState {
|
||||
openings: existingMatch ? room.openings : [],
|
||||
electricalItems: room.electricalItems,
|
||||
furnitureItems: room.furnitureItems,
|
||||
annotations: room.annotations ?? state.annotations,
|
||||
};
|
||||
}
|
||||
case 'UPDATE_ROOM_PROPS':
|
||||
@@ -177,6 +180,8 @@ function editorReducer(state: EditorState, action: EditorAction): EditorState {
|
||||
return { ...state, zoom: action.zoom };
|
||||
case 'SET_PAN_OFFSET':
|
||||
return { ...state, panOffset: action.offset };
|
||||
case 'SET_VIEW':
|
||||
return { ...state, zoom: action.zoom, panOffset: action.offset };
|
||||
case 'SET_GRID_SIZE':
|
||||
return { ...state, gridSize: action.gridSize };
|
||||
case 'TOGGLE_GRID':
|
||||
@@ -251,18 +256,43 @@ function editorReducer(state: EditorState, action: EditorAction): EditorState {
|
||||
annotations: state.annotations.filter((a) => a.id !== action.id),
|
||||
selectedIds: removeFromSet(state.selectedIds, action.id),
|
||||
};
|
||||
case 'TOGGLE_FURNITURE_PROJECTION': {
|
||||
const next = new Set(state.furnitureProjectionIds);
|
||||
if (next.has(action.id)) next.delete(action.id);
|
||||
else next.add(action.id);
|
||||
return { ...state, furnitureProjectionIds: next };
|
||||
}
|
||||
case 'SET_GLOBAL_FURNITURE_OPACITY': {
|
||||
const clamped = Math.min(1, Math.max(0, action.opacity));
|
||||
return { ...state, globalFurnitureOpacity: clamped };
|
||||
}
|
||||
// ── Import ──
|
||||
case 'SYNC_SAVE': {
|
||||
// Build set of all new IDs to prune stale selections
|
||||
// Build set of all new IDs so we can prune any selection that did not survive
|
||||
const newIds = new Set<string>();
|
||||
for (const w of action.walls) newIds.add(w.id);
|
||||
for (const o of action.openings) newIds.add(o.id);
|
||||
for (const e of action.electricalItems) newIds.add(e.id);
|
||||
for (const f of action.furnitureItems) newIds.add(f.id);
|
||||
// Keep only selected IDs that still exist in the new data
|
||||
const prunedSelection = new Set<string>();
|
||||
// Remap selected IDs through the id map (so freshly created server items
|
||||
// stay selected). Fall back to the original id when no mapping is given.
|
||||
const idMap = action.idMap;
|
||||
const remappedSelection = new Set<string>();
|
||||
for (const id of state.selectedIds) {
|
||||
if (newIds.has(id)) prunedSelection.add(id);
|
||||
const next = idMap?.get(id) ?? id;
|
||||
if (newIds.has(next)) remappedSelection.add(next);
|
||||
}
|
||||
// Use server annotations when provided; otherwise just remap attached ids
|
||||
// for the existing client-only annotation list.
|
||||
let remappedAnnotations = action.annotations
|
||||
? [...action.annotations]
|
||||
: state.annotations;
|
||||
if (idMap) {
|
||||
remappedAnnotations = remappedAnnotations.map((a) =>
|
||||
a.attachedToId && idMap.has(a.attachedToId)
|
||||
? { ...a, attachedToId: idMap.get(a.attachedToId)! }
|
||||
: a,
|
||||
);
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
@@ -270,7 +300,8 @@ function editorReducer(state: EditorState, action: EditorAction): EditorState {
|
||||
openings: action.openings,
|
||||
electricalItems: action.electricalItems,
|
||||
furnitureItems: action.furnitureItems,
|
||||
selectedIds: prunedSelection,
|
||||
selectedIds: remappedSelection,
|
||||
annotations: remappedAnnotations,
|
||||
};
|
||||
}
|
||||
case 'IMPORT_ROOM':
|
||||
@@ -445,6 +476,8 @@ interface SceneDataContextValue {
|
||||
readonly electricalItems: readonly ElectricalItem[];
|
||||
readonly furnitureItems: readonly FurnitureItem[];
|
||||
readonly annotations: readonly Annotation[];
|
||||
readonly furnitureProjectionIds: ReadonlySet<string>;
|
||||
readonly globalFurnitureOpacity: number;
|
||||
readonly gridSize: number;
|
||||
readonly gridVisible: boolean;
|
||||
readonly snapEnabled: boolean;
|
||||
@@ -467,6 +500,7 @@ interface SceneDataContextValue {
|
||||
addAnnotation(annotation: Annotation): void;
|
||||
updateAnnotation(annotation: Annotation): void;
|
||||
removeAnnotation(id: string): void;
|
||||
toggleFurnitureProjection(id: string): void;
|
||||
copySelected(): void;
|
||||
pasteClipboard(): void;
|
||||
}
|
||||
@@ -499,6 +533,7 @@ interface EditorContextValue {
|
||||
addAnnotation(annotation: Annotation): void;
|
||||
updateAnnotation(annotation: Annotation): void;
|
||||
removeAnnotation(id: string): void;
|
||||
toggleFurnitureProjection(id: string): void;
|
||||
copySelected(): void;
|
||||
pasteClipboard(): void;
|
||||
}
|
||||
@@ -615,6 +650,10 @@ export function EditorProvider({ room, children }: EditorProviderProps) {
|
||||
(id: string) => dispatch({ type: 'REMOVE_ANNOTATION', id }),
|
||||
[],
|
||||
);
|
||||
const toggleFurnitureProjection = useCallback(
|
||||
(id: string) => dispatch({ type: 'TOGGLE_FURNITURE_PROJECTION', id }),
|
||||
[],
|
||||
);
|
||||
|
||||
// ── Clipboard (ref-based so copy reads current state without closures) ──
|
||||
const clipboardRef = useRef<{
|
||||
@@ -712,6 +751,8 @@ export function EditorProvider({ room, children }: EditorProviderProps) {
|
||||
electricalItems: state.electricalItems,
|
||||
furnitureItems: state.furnitureItems,
|
||||
annotations: state.annotations,
|
||||
furnitureProjectionIds: state.furnitureProjectionIds,
|
||||
globalFurnitureOpacity: state.globalFurnitureOpacity,
|
||||
gridSize: state.gridSize,
|
||||
gridVisible: state.gridVisible,
|
||||
snapEnabled: state.snapEnabled,
|
||||
@@ -734,6 +775,7 @@ export function EditorProvider({ room, children }: EditorProviderProps) {
|
||||
addAnnotation,
|
||||
updateAnnotation,
|
||||
removeAnnotation,
|
||||
toggleFurnitureProjection,
|
||||
copySelected,
|
||||
pasteClipboard,
|
||||
}),
|
||||
@@ -744,6 +786,8 @@ export function EditorProvider({ room, children }: EditorProviderProps) {
|
||||
state.electricalItems,
|
||||
state.furnitureItems,
|
||||
state.annotations,
|
||||
state.furnitureProjectionIds,
|
||||
state.globalFurnitureOpacity,
|
||||
state.gridSize,
|
||||
state.gridVisible,
|
||||
state.snapEnabled,
|
||||
@@ -765,6 +809,7 @@ export function EditorProvider({ room, children }: EditorProviderProps) {
|
||||
addAnnotation,
|
||||
updateAnnotation,
|
||||
removeAnnotation,
|
||||
toggleFurnitureProjection,
|
||||
copySelected,
|
||||
pasteClipboard,
|
||||
],
|
||||
@@ -796,6 +841,7 @@ export function EditorProvider({ room, children }: EditorProviderProps) {
|
||||
addAnnotation,
|
||||
updateAnnotation,
|
||||
removeAnnotation,
|
||||
toggleFurnitureProjection,
|
||||
copySelected,
|
||||
pasteClipboard,
|
||||
}),
|
||||
|
||||
@@ -306,6 +306,15 @@ export function importRoomFromJson(json: string): ImportResult {
|
||||
height: assertField(o, 'height', isNumber, `openings[${i}]`),
|
||||
elevationFromFloor: assertField(o, 'elevationFromFloor', isNumber, `openings[${i}]`),
|
||||
openDirection: (isString((o as Record<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;
|
||||
}
|
||||
|
||||
const importedCount = isNumber(e.count) && e.count >= 1 ? Math.round(e.count) : 1;
|
||||
electricalItems.push({
|
||||
id: generateLocalId(),
|
||||
roomId,
|
||||
@@ -351,6 +361,9 @@ export function importRoomFromJson(json: string): ImportResult {
|
||||
wallId,
|
||||
elevationFromFloor: e.elevationFromFloor === null ? null : assertField(e, 'elevationFromFloor', isNumber, `electricalItems[${i}]`),
|
||||
rotation: assertField(e, 'rotation', isNumber, `electricalItems[${i}]`),
|
||||
count: importedCount,
|
||||
positionAnchor: { horizontal: 'middle', vertical: 'middle' },
|
||||
label: typeof e.label === 'string' ? e.label : null,
|
||||
metadata: validatedMetadata,
|
||||
});
|
||||
}
|
||||
@@ -386,6 +399,11 @@ export function importRoomFromJson(json: string): ImportResult {
|
||||
rotation: assertField(f, 'rotation', isNumber, `furnitureItems[${i}]`),
|
||||
elevationFromFloor: isNumber((f as Record<string, unknown>).elevationFromFloor) ? (f as Record<string, unknown>).elevationFromFloor as number : 0,
|
||||
label: (label as string | null) ?? null,
|
||||
// Imported furniture defaults to the legacy top-left anchor so the
|
||||
// (x, y) values from the export file (which were saved as top-left)
|
||||
// continue to refer to the same physical point.
|
||||
positionAnchor: { horizontal: 'left', vertical: 'top' },
|
||||
metadata: isRecord(f.metadata) ? (f.metadata as Record<string, unknown>) : null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Layer, Text, Rect, Group, Line } from 'react-konva';
|
||||
import { Text, Rect, Group, Line } from 'react-konva';
|
||||
import type { Point, Annotation, ElectricalItem, FurnitureItem } from '@house-plan-maker/shared';
|
||||
import { rotatedAnchorOffsetToCenter } from '@house-plan-maker/shared';
|
||||
|
||||
interface AnnotationLayerProps {
|
||||
readonly annotations: readonly Annotation[];
|
||||
@@ -17,9 +18,19 @@ interface AnnotationLayerProps {
|
||||
|
||||
const DEFAULT_FONT_SIZE = 14;
|
||||
const DEFAULT_COLOR = '#333333';
|
||||
const LINK_COLOR = '#2563eb';
|
||||
const SELECTED_COLOR = '#4c6ef5';
|
||||
const SELECTION_PADDING = 4;
|
||||
|
||||
// Match plain http(s) URLs only — anything else stays a regular annotation.
|
||||
// Anchored to start/end so a label like "see http://x" isn't treated as a
|
||||
// link (we want the whole text to be the URL).
|
||||
const URL_PATTERN = /^https?:\/\/\S+$/i;
|
||||
|
||||
function isUrlAnnotation(text: string): boolean {
|
||||
return URL_PATTERN.test(text.trim());
|
||||
}
|
||||
|
||||
function toScreen(point: Point, zoom: number, panOffset: Point): { x: number; y: number } {
|
||||
return {
|
||||
x: point.x * zoom + panOffset.x,
|
||||
@@ -46,7 +57,13 @@ export const AnnotationLayer = memo(function AnnotationLayer({
|
||||
map.set(item.id, { x: item.x, y: item.y });
|
||||
}
|
||||
for (const item of furnitureItems) {
|
||||
map.set(item.id, { x: item.x + item.width / 2, y: item.y + item.depth / 2 });
|
||||
const offset = rotatedAnchorOffsetToCenter(
|
||||
item.positionAnchor,
|
||||
item.width,
|
||||
item.depth,
|
||||
item.rotation,
|
||||
);
|
||||
map.set(item.id, { x: item.x + offset.dx, y: item.y + offset.dy });
|
||||
}
|
||||
return map;
|
||||
}, [electricalItems, furnitureItems]);
|
||||
@@ -57,7 +74,7 @@ export const AnnotationLayer = memo(function AnnotationLayer({
|
||||
}, [annotations, visible]);
|
||||
|
||||
return (
|
||||
<Layer visible={visible}>
|
||||
<Group visible={visible}>
|
||||
{renderedAnnotations.map((annotation) => {
|
||||
// Resolve position: if attached, offset from parent item
|
||||
let worldX = annotation.x;
|
||||
@@ -76,7 +93,15 @@ export const AnnotationLayer = memo(function AnnotationLayer({
|
||||
const screen = toScreen({ x: worldX, y: worldY }, zoom, panOffset);
|
||||
const isSelected = selectedIds.has(annotation.id);
|
||||
const fontSize = annotation.fontSize ?? DEFAULT_FONT_SIZE;
|
||||
const color = isSelected ? SELECTED_COLOR : (annotation.color ?? DEFAULT_COLOR);
|
||||
const isLink = isUrlAnnotation(annotation.text);
|
||||
// Link annotations get a distinctive blue tint when not selected so
|
||||
// users can spot them; selection still wins to keep the affordance
|
||||
// consistent with non-link annotations.
|
||||
const color = isSelected
|
||||
? SELECTED_COLOR
|
||||
: isLink
|
||||
? (annotation.color ?? LINK_COLOR)
|
||||
: (annotation.color ?? DEFAULT_COLOR);
|
||||
|
||||
return (
|
||||
<Group key={annotation.id}>
|
||||
@@ -108,7 +133,19 @@ export const AnnotationLayer = memo(function AnnotationLayer({
|
||||
}
|
||||
onDragEnd?.(annotation.id, newX, newY);
|
||||
}}
|
||||
onClick={() => onSelect?.(annotation.id)}
|
||||
onClick={(e) => {
|
||||
// Ctrl/Cmd-click on a URL annotation opens it in a new tab.
|
||||
// We swallow the event so it doesn't also trigger selection
|
||||
// or upstream stage handlers (which would deselect the link
|
||||
// immediately on focus loss). Plain clicks fall through to
|
||||
// the regular select handler.
|
||||
if (isLink && (e.evt.ctrlKey || e.evt.metaKey)) {
|
||||
e.cancelBubble = true;
|
||||
window.open(annotation.text.trim(), '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
onSelect?.(annotation.id);
|
||||
}}
|
||||
onDblClick={() => onDoubleClick?.(annotation.id)}
|
||||
>
|
||||
{/* Background */}
|
||||
@@ -143,6 +180,6 @@ export const AnnotationLayer = memo(function AnnotationLayer({
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Layer>
|
||||
</Group>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Layer, Group, Circle } from 'react-konva';
|
||||
import { Group, Circle } from 'react-konva';
|
||||
import type { Point, ElectricalItem } from '@house-plan-maker/shared';
|
||||
import { anchorOffsetToCenter, DEFAULT_OUTLET_WIDTH, DEFAULT_OUTLET_HEIGHT } from '@house-plan-maker/shared';
|
||||
import {
|
||||
SingleOutletSymbol,
|
||||
DoubleOutletSymbol,
|
||||
GroundedOutletSymbol,
|
||||
OutletSymbol,
|
||||
SingleSwitchSymbol,
|
||||
DoubleSwitchSymbol,
|
||||
DimmerSwitchSymbol,
|
||||
@@ -23,6 +22,10 @@ interface ElectricalLayerProps {
|
||||
readonly panOffset: Point;
|
||||
readonly selectedIds: ReadonlySet<string>;
|
||||
readonly visible?: boolean;
|
||||
/** Physical width of a single outlet face plate (meters). Used to size outlet boundaries. */
|
||||
readonly outletWidth?: number;
|
||||
/** Physical height of a single outlet face plate (meters). Used to size outlet boundaries. */
|
||||
readonly outletHeight?: number;
|
||||
}
|
||||
|
||||
const ELECTRICAL_COLOR = '#d63384';
|
||||
@@ -43,8 +46,13 @@ export const ElectricalLayer = memo(function ElectricalLayer({
|
||||
panOffset,
|
||||
selectedIds,
|
||||
visible = true,
|
||||
outletWidth = DEFAULT_OUTLET_WIDTH,
|
||||
outletHeight = DEFAULT_OUTLET_HEIGHT,
|
||||
}: ElectricalLayerProps) {
|
||||
const scale = Math.max(0.6, Math.min(1.5, zoom / 100));
|
||||
// Convert real-world outlet dimensions to screen pixels for the current zoom.
|
||||
const outletWidthPx = outletWidth * zoom;
|
||||
const outletHeightPx = outletHeight * zoom;
|
||||
|
||||
const renderedItems = useMemo(() => {
|
||||
if (!visible) return [];
|
||||
@@ -57,7 +65,7 @@ export const ElectricalLayer = memo(function ElectricalLayer({
|
||||
}, [items, visible]);
|
||||
|
||||
return (
|
||||
<Layer listening={false} visible={visible}>
|
||||
<Group listening={false} visible={visible}>
|
||||
{/* Cable routes first (below symbols) */}
|
||||
{cableItems.map((item) => {
|
||||
const waypoints = getCableWaypoints(item);
|
||||
@@ -83,25 +91,40 @@ export const ElectricalLayer = memo(function ElectricalLayer({
|
||||
const color = isSelected ? SELECTED_COLOR : ELECTRICAL_COLOR;
|
||||
const variant = getElectricalVariant(item.metadata);
|
||||
|
||||
// Bounding box for outlets is count * outletWidth × outletHeight; for
|
||||
// other symbols anchor offset is irrelevant (legacy symbols are point-
|
||||
// based) but we still respect a non-default anchor by treating the
|
||||
// symbol bounding box as a unit cell so the math degenerates to zero
|
||||
// when anchor is middle/middle.
|
||||
const bboxWidthPx =
|
||||
item.type === 'OUTLET' ? Math.max(1, item.count) * outletWidthPx : 0;
|
||||
const bboxHeightPx = item.type === 'OUTLET' ? outletHeightPx : 0;
|
||||
const offset = anchorOffsetToCenter(item.positionAnchor, bboxWidthPx, bboxHeightPx);
|
||||
|
||||
return (
|
||||
<Group key={item.id}>
|
||||
{/* Light coverage circle (only for selected light fixtures) */}
|
||||
{isSelected && renderLightCoverage(item, zoom, panOffset)}
|
||||
|
||||
{/* Symbol */}
|
||||
{renderElectricalSymbol(
|
||||
item.type,
|
||||
{renderElectricalSymbol({
|
||||
type: item.type,
|
||||
variant,
|
||||
screen.x,
|
||||
screen.y,
|
||||
item.rotation,
|
||||
count: item.count,
|
||||
x: screen.x,
|
||||
y: screen.y,
|
||||
rotation: item.rotation,
|
||||
color,
|
||||
scale,
|
||||
)}
|
||||
outletWidthPx,
|
||||
outletHeightPx,
|
||||
centerOffsetX: offset.dx,
|
||||
centerOffsetY: offset.dy,
|
||||
})}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Layer>
|
||||
</Group>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -130,25 +153,38 @@ function renderLightCoverage(
|
||||
);
|
||||
}
|
||||
|
||||
function renderElectricalSymbol(
|
||||
type: string,
|
||||
variant: string,
|
||||
x: number,
|
||||
y: number,
|
||||
rotation: number,
|
||||
color: string,
|
||||
scale: number,
|
||||
): React.ReactNode {
|
||||
interface RenderSymbolArgs {
|
||||
readonly type: string;
|
||||
readonly variant: string;
|
||||
readonly count: number;
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly rotation: number;
|
||||
readonly color: string;
|
||||
readonly scale: number;
|
||||
readonly outletWidthPx: number;
|
||||
readonly outletHeightPx: number;
|
||||
readonly centerOffsetX: number;
|
||||
readonly centerOffsetY: number;
|
||||
}
|
||||
|
||||
function renderElectricalSymbol(args: RenderSymbolArgs): React.ReactNode {
|
||||
const { type, variant, count, x, y, rotation, color, scale } = args;
|
||||
switch (type) {
|
||||
case 'OUTLET':
|
||||
switch (variant) {
|
||||
case 'double':
|
||||
return <DoubleOutletSymbol x={x} y={y} rotation={rotation} color={color} scale={scale} />;
|
||||
case 'grounded':
|
||||
return <GroundedOutletSymbol x={x} y={y} rotation={rotation} color={color} scale={scale} />;
|
||||
default:
|
||||
return <SingleOutletSymbol x={x} y={y} rotation={rotation} color={color} scale={scale} />;
|
||||
}
|
||||
return (
|
||||
<OutletSymbol
|
||||
x={x}
|
||||
y={y}
|
||||
rotation={rotation}
|
||||
color={color}
|
||||
count={count}
|
||||
outletWidthPx={args.outletWidthPx}
|
||||
outletHeightPx={args.outletHeightPx}
|
||||
centerOffsetX={args.centerOffsetX}
|
||||
centerOffsetY={args.centerOffsetY}
|
||||
/>
|
||||
);
|
||||
case 'SWITCH':
|
||||
switch (variant) {
|
||||
case 'double':
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Layer, Group, Rect, Line } from 'react-konva';
|
||||
import { Group, Rect, Line } from 'react-konva';
|
||||
import type { Point, FurnitureItem } from '@house-plan-maker/shared';
|
||||
import { rotatedAnchorOffsetToCenter } from '@house-plan-maker/shared';
|
||||
import { BedSilhouette } from '../symbols/furniture/BedSilhouette';
|
||||
import { DeskSilhouette } from '../symbols/furniture/DeskSilhouette';
|
||||
import { WardrobeSilhouette } from '../symbols/furniture/WardrobeSilhouette';
|
||||
@@ -17,6 +18,8 @@ interface FurnitureLayerProps {
|
||||
readonly panOffset: Point;
|
||||
readonly selectedIds: ReadonlySet<string>;
|
||||
readonly visible?: boolean;
|
||||
/** Global multiplier applied to every furniture item's opacity. */
|
||||
readonly globalOpacity?: number;
|
||||
}
|
||||
|
||||
const FURNITURE_COLOR = '#495057';
|
||||
@@ -39,15 +42,25 @@ export const FurnitureLayer = memo(function FurnitureLayer({
|
||||
panOffset,
|
||||
selectedIds,
|
||||
visible = true,
|
||||
globalOpacity = 1,
|
||||
}: FurnitureLayerProps) {
|
||||
const collidingIds = useMemo(() => findCollidingFurniture(items), [items]);
|
||||
|
||||
return (
|
||||
<Layer listening={false} visible={visible}>
|
||||
<Group listening={false} visible={visible}>
|
||||
{items.map((item) => {
|
||||
// x,y is the top-left corner; compute center for silhouette rendering
|
||||
const centerX = item.x + item.width / 2;
|
||||
const centerY = item.y + item.depth / 2;
|
||||
// (x, y) is the anchored point on the ROTATED visual; convert to
|
||||
// bounding-box center using the rotation-aware helper so "left"
|
||||
// tracks the visual left edge regardless of how the item is
|
||||
// rotated. Reduces to (0, 0) for the default middle/middle anchor.
|
||||
const offset = rotatedAnchorOffsetToCenter(
|
||||
item.positionAnchor,
|
||||
item.width,
|
||||
item.depth,
|
||||
item.rotation,
|
||||
);
|
||||
const centerX = item.x + offset.dx;
|
||||
const centerY = item.y + offset.dy;
|
||||
const screenCenter = toScreen({ x: centerX, y: centerY }, zoom, panOffset);
|
||||
const isSelected = selectedIds.has(item.id);
|
||||
const isColliding = collidingIds.has(item.id);
|
||||
@@ -57,8 +70,9 @@ export const FurnitureLayer = memo(function FurnitureLayer({
|
||||
const color = isColliding ? COLLISION_COLOR : isSelected ? SELECTED_COLOR : FURNITURE_COLOR;
|
||||
const fillColor = isColliding ? COLLISION_FILL : isSelected ? SELECTED_FILL : FURNITURE_FILL;
|
||||
|
||||
const opacity = (item.opacity ?? 1) * globalOpacity;
|
||||
return (
|
||||
<Group key={item.id}>
|
||||
<Group key={item.id} opacity={opacity}>
|
||||
{renderFurnitureSilhouette(
|
||||
item.type,
|
||||
screenCenter.x,
|
||||
@@ -81,7 +95,7 @@ export const FurnitureLayer = memo(function FurnitureLayer({
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Layer>
|
||||
</Group>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -156,20 +170,31 @@ function renderFurnitureSilhouette(
|
||||
return <WardrobeSilhouette {...props} />;
|
||||
case 'TV':
|
||||
return <TvSilhouette {...props} />;
|
||||
default:
|
||||
// Generic rectangle for OTHER / unknown
|
||||
default: {
|
||||
// Generic rectangle fallback for types without a custom silhouette
|
||||
// (OTHER, RADIATOR, WALL_COLLAGE, CURTAIN, …).
|
||||
//
|
||||
// Rotation MUST pivot around the item's center. Konva's `<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 (
|
||||
<Rect
|
||||
x={x - width / 2}
|
||||
y={y - depth / 2}
|
||||
width={width}
|
||||
height={depth}
|
||||
rotation={rotation}
|
||||
stroke={color}
|
||||
strokeWidth={1.5}
|
||||
fill={fillColor}
|
||||
listening={false}
|
||||
/>
|
||||
<Group x={x} y={y} rotation={rotation} listening={false}>
|
||||
<Rect
|
||||
x={-width / 2}
|
||||
y={-depth / 2}
|
||||
width={width}
|
||||
height={depth}
|
||||
stroke={color}
|
||||
strokeWidth={1.5}
|
||||
fill={fillColor}
|
||||
listening={false}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Layer, Line, Text, Rect } from 'react-konva';
|
||||
import { Group, Line, Text, Rect } from 'react-konva';
|
||||
import type { Point } from '@house-plan-maker/shared';
|
||||
|
||||
interface GridLayerProps {
|
||||
@@ -129,7 +129,7 @@ export const GridLayer = memo(function GridLayer({
|
||||
}, [zoom, panOffset, stageWidth, stageHeight]);
|
||||
|
||||
return (
|
||||
<Layer listening={false}>
|
||||
<Group listening={false}>
|
||||
{/* Grid lines */}
|
||||
{visible &&
|
||||
gridLines.lines.map((line, i) => (
|
||||
@@ -221,7 +221,7 @@ export const GridLayer = memo(function GridLayer({
|
||||
fill={RULER_BG_COLOR}
|
||||
listening={false}
|
||||
/>
|
||||
</Layer>
|
||||
</Group>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo } from 'react';
|
||||
import { Layer, Line, Text, Circle } from 'react-konva';
|
||||
import { Group, Line, Text, Circle } from 'react-konva';
|
||||
import type { Point } from '@house-plan-maker/shared';
|
||||
import type { MeasurementState } from '../types';
|
||||
|
||||
@@ -24,7 +24,7 @@ export const MeasureOverlayLayer = memo(function MeasureOverlayLayer({
|
||||
zoom,
|
||||
panOffset,
|
||||
}: MeasureOverlayLayerProps) {
|
||||
if (!measurement) return <Layer listening={false} />;
|
||||
if (!measurement) return <Group listening={false} />;
|
||||
|
||||
const start = toScreen(measurement.startPoint, zoom, panOffset);
|
||||
const end = toScreen(measurement.endPoint, zoom, panOffset);
|
||||
@@ -38,7 +38,7 @@ export const MeasureOverlayLayer = memo(function MeasureOverlayLayer({
|
||||
: `${(distanceM * 100).toFixed(1)} cm`;
|
||||
|
||||
return (
|
||||
<Layer listening={false}>
|
||||
<Group listening={false}>
|
||||
<Line
|
||||
points={[start.x, start.y, end.x, end.y]}
|
||||
stroke={MEASURE_COLOR}
|
||||
@@ -69,6 +69,6 @@ export const MeasureOverlayLayer = memo(function MeasureOverlayLayer({
|
||||
padding={2}
|
||||
/>
|
||||
)}
|
||||
</Layer>
|
||||
</Group>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Layer, Line, Text, Group } from 'react-konva';
|
||||
import { Line, Text, Group } from 'react-konva';
|
||||
import type { Point, Wall, WallOpening } from '@house-plan-maker/shared';
|
||||
import { wallLength, wallAngle, wallStartEnd } from '../utils/wallUtils';
|
||||
|
||||
@@ -28,22 +28,15 @@ export const MeasurementLayer = memo(function MeasurementLayer({
|
||||
}: MeasurementLayerProps) {
|
||||
// Hide measurements at very low zoom levels
|
||||
if (zoom < MIN_ZOOM_FOR_MEASUREMENTS) {
|
||||
return <Layer listening={false} />;
|
||||
return <Group listening={false} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layer listening={false}>
|
||||
{/* Wall length annotations */}
|
||||
{walls.map((wall) => (
|
||||
<WallMeasurement
|
||||
key={`wm-${wall.id}`}
|
||||
wall={wall}
|
||||
zoom={zoom}
|
||||
panOffset={panOffset}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Room overall dimensions */}
|
||||
<Group listening={false}>
|
||||
{/* Outer room dimensions — one horizontal + one vertical label outside
|
||||
the room bounding box. The former per-wall inner labels were
|
||||
removed because they duplicated these numbers on every wall of a
|
||||
rectangular room. */}
|
||||
{roomShape.length >= 3 && (
|
||||
<RoomDimensions
|
||||
roomShape={roomShape}
|
||||
@@ -68,48 +61,11 @@ export const MeasurementLayer = memo(function MeasurementLayer({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Layer>
|
||||
</Group>
|
||||
);
|
||||
});
|
||||
|
||||
// ── Wall length annotation ──
|
||||
|
||||
interface WallMeasurementProps {
|
||||
readonly wall: Wall;
|
||||
readonly zoom: number;
|
||||
readonly panOffset: Point;
|
||||
}
|
||||
|
||||
function WallMeasurement({ wall, zoom, panOffset }: WallMeasurementProps) {
|
||||
const len = wallLength(wall);
|
||||
if (len < 0.01) return null;
|
||||
|
||||
const { start, end } = wallStartEnd(wall);
|
||||
const angle = wallAngle(wall);
|
||||
|
||||
// Midpoint of wall in screen coords
|
||||
const midX = ((start.x + end.x) / 2) * zoom + panOffset.x;
|
||||
const midY = ((start.y + end.y) / 2) * zoom + panOffset.y;
|
||||
|
||||
// Offset perpendicular to wall
|
||||
const offsetX = -Math.sin(angle) * MEASUREMENT_OFFSET;
|
||||
const offsetY = Math.cos(angle) * MEASUREMENT_OFFSET;
|
||||
|
||||
const label = formatMeasurement(len);
|
||||
|
||||
return (
|
||||
<Text
|
||||
x={midX + offsetX - 20}
|
||||
y={midY + offsetY - 5}
|
||||
text={label}
|
||||
fontSize={MEASUREMENT_FONT_SIZE}
|
||||
fill={MEASUREMENT_COLOR}
|
||||
listening={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Room overall dimensions ──
|
||||
// ── Room overall dimensions (outer bbox labels) ──
|
||||
|
||||
interface RoomDimensionsProps {
|
||||
readonly roomShape: readonly Point[];
|
||||
@@ -159,7 +115,6 @@ function RoomDimensions({ roomShape, zoom, panOffset }: RoomDimensionsProps) {
|
||||
strokeWidth={1}
|
||||
listening={false}
|
||||
/>
|
||||
{/* End ticks */}
|
||||
<Line
|
||||
points={[hStartX, topY - 4, hStartX, topY + 4]}
|
||||
stroke={MEASUREMENT_COLOR}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { openingWorldPosition, wallAngle } from '../utils/wallUtils';
|
||||
import { polygonCentroid } from '../utils/geometry';
|
||||
@@ -79,7 +79,7 @@ export const OpeningLayer = memo(function OpeningLayer({
|
||||
);
|
||||
|
||||
return (
|
||||
<Layer>
|
||||
<Group>
|
||||
{renderedOpenings.map(({ opening, wall, pos, isSelected }) => {
|
||||
const screenCenter = toScreen(pos.center, zoom, panOffset);
|
||||
const angle = wallAngle(wall);
|
||||
@@ -140,7 +140,7 @@ export const OpeningLayer = memo(function OpeningLayer({
|
||||
panOffset={panOffset}
|
||||
/>
|
||||
)}
|
||||
</Layer>
|
||||
</Group>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -162,8 +162,12 @@ function DoorSymbol({ x, y, angleDeg, halfWidthPx, wallThicknessPx, isSelected,
|
||||
const color = isSelected ? SELECTED_COLOR : DOOR_COLOR;
|
||||
const doorWidthPx = halfWidthPx * 2;
|
||||
|
||||
const isRight = openDirection === 'RIGHT';
|
||||
const isOutward = openDirection === 'OUTWARD';
|
||||
// The four enum values encode the two orthogonal axes (hinge side × swing
|
||||
// direction) — this mapping keeps LEFT/RIGHT/OUTWARD intact and gives
|
||||
// INWARD its own visual (right hinge swinging outward) so it no longer
|
||||
// collides with LEFT.
|
||||
const isRight = openDirection === 'RIGHT' || openDirection === 'INWARD';
|
||||
const isOutward = openDirection === 'OUTWARD' || openDirection === 'INWARD';
|
||||
|
||||
// Mirror the entire door group for RIGHT hinge
|
||||
const groupScaleX = isRight ? -1 : 1;
|
||||
@@ -317,6 +321,11 @@ function PreviewSymbol({ wall, positionAlongWall, width, type, isValid: _isValid
|
||||
height: 0,
|
||||
elevationFromFloor: 0,
|
||||
openDirection: 'LEFT',
|
||||
positionAnchor: { horizontal: 'middle', vertical: 'bottom' },
|
||||
gridCols: 2,
|
||||
gridRows: 2,
|
||||
slopeDepth: 0,
|
||||
frameThickness: 0.03,
|
||||
};
|
||||
|
||||
const pos = openingWorldPosition(tempOpening, wall);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Layer, Group, Rect, Text } from 'react-konva';
|
||||
import { Group, Rect, Text } from 'react-konva';
|
||||
import type { Point } from '@house-plan-maker/shared';
|
||||
import { polygonArea, polygonCentroid } from '../utils/geometry';
|
||||
import { polygonArea, boundingBox } from '../utils/geometry';
|
||||
|
||||
interface RoomLabelLayerProps {
|
||||
readonly roomName: string;
|
||||
@@ -31,37 +31,41 @@ export function RoomLabelLayer({
|
||||
[roomShape],
|
||||
);
|
||||
|
||||
const centroid = useMemo(
|
||||
() => (roomShape.length >= 3 ? polygonCentroid(roomShape) : null),
|
||||
const bbox = useMemo(
|
||||
() => (roomShape.length >= 2 ? boundingBox(roomShape) : null),
|
||||
[roomShape],
|
||||
);
|
||||
|
||||
if (!centroid || zoom < MIN_ZOOM_FOR_LABELS) {
|
||||
return <Layer listening={false} />;
|
||||
if (!bbox || zoom < MIN_ZOOM_FOR_LABELS) {
|
||||
return <Group listening={false} />;
|
||||
}
|
||||
|
||||
const areaText = `${(Math.round(area * 100) / 100).toFixed(1)} m\u00B2`;
|
||||
|
||||
// Position in screen coordinates
|
||||
const screenX = centroid.x * zoom + panOffset.x;
|
||||
const screenY = centroid.y * zoom + panOffset.y;
|
||||
|
||||
// Estimate text widths (approximate: ~7px per char at font size 13, ~6px at 11)
|
||||
const nameWidth = roomName.length * 7.5;
|
||||
const areaWidth = areaText.length * 6.5;
|
||||
// Generous text-width estimate: bold font + multibyte glyphs (Cyrillic, etc.)
|
||||
// can exceed Latin averages, so we over-allocate to avoid line wrapping.
|
||||
const nameWidth = roomName.length * (LABEL_FONT_SIZE * 0.75) + 6;
|
||||
const areaWidth = areaText.length * (AREA_FONT_SIZE * 0.7) + 6;
|
||||
const maxWidth = Math.max(nameWidth, areaWidth);
|
||||
const totalHeight = LABEL_FONT_SIZE + LINE_SPACING + AREA_FONT_SIZE;
|
||||
|
||||
const bgWidth = maxWidth + BG_PADDING_X * 2;
|
||||
const bgHeight = totalHeight + BG_PADDING_Y * 2;
|
||||
|
||||
// Anchor the badge just outside the room's top-left corner so it stays out of
|
||||
// the way of any walls, openings, or items inside the room.
|
||||
const cornerScreenX = bbox.minX * zoom + panOffset.x;
|
||||
const cornerScreenY = bbox.minY * zoom + panOffset.y;
|
||||
const screenX = cornerScreenX;
|
||||
const screenY = cornerScreenY - bgHeight - 6;
|
||||
|
||||
return (
|
||||
<Layer listening={false}>
|
||||
<Group listening={false}>
|
||||
<Group x={screenX} y={screenY}>
|
||||
{/* Semi-transparent background */}
|
||||
<Rect
|
||||
x={-bgWidth / 2}
|
||||
y={-bgHeight / 2}
|
||||
x={0}
|
||||
y={0}
|
||||
width={bgWidth}
|
||||
height={bgHeight}
|
||||
fill={BG_COLOR}
|
||||
@@ -70,28 +74,28 @@ export function RoomLabelLayer({
|
||||
/>
|
||||
{/* Room name */}
|
||||
<Text
|
||||
x={-maxWidth / 2}
|
||||
y={-bgHeight / 2 + BG_PADDING_Y}
|
||||
width={maxWidth}
|
||||
x={BG_PADDING_X}
|
||||
y={BG_PADDING_Y}
|
||||
text={roomName}
|
||||
fontSize={LABEL_FONT_SIZE}
|
||||
fontStyle="bold"
|
||||
fill={LABEL_COLOR}
|
||||
align="center"
|
||||
align="left"
|
||||
wrap="none"
|
||||
listening={false}
|
||||
/>
|
||||
{/* Area */}
|
||||
<Text
|
||||
x={-maxWidth / 2}
|
||||
y={-bgHeight / 2 + BG_PADDING_Y + LABEL_FONT_SIZE + LINE_SPACING}
|
||||
width={maxWidth}
|
||||
x={BG_PADDING_X}
|
||||
y={BG_PADDING_Y + LABEL_FONT_SIZE + LINE_SPACING}
|
||||
text={areaText}
|
||||
fontSize={AREA_FONT_SIZE}
|
||||
fill={AREA_COLOR}
|
||||
align="center"
|
||||
align="left"
|
||||
wrap="none"
|
||||
listening={false}
|
||||
/>
|
||||
</Group>
|
||||
</Layer>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo } from 'react';
|
||||
import { Layer, Rect } from 'react-konva';
|
||||
import { Group, Rect } from 'react-konva';
|
||||
import type { Point } from '@house-plan-maker/shared';
|
||||
|
||||
interface SelectionLayerProps {
|
||||
@@ -35,7 +35,7 @@ export const SelectionLayer = memo(function SelectionLayer({
|
||||
dragRect,
|
||||
}: SelectionLayerProps) {
|
||||
return (
|
||||
<Layer listening={false}>
|
||||
<Group listening={false}>
|
||||
{/* Selection bounding box with resize handles */}
|
||||
{selectionBox && (
|
||||
<SelectionBoundingBox
|
||||
@@ -53,7 +53,7 @@ export const SelectionLayer = memo(function SelectionLayer({
|
||||
panOffset={panOffset}
|
||||
/>
|
||||
)}
|
||||
</Layer>
|
||||
</Group>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Layer, Line, Group } from 'react-konva';
|
||||
import { Line, Group } from 'react-konva';
|
||||
import type { Point, Wall } from '@house-plan-maker/shared';
|
||||
import { polygonCentroid } from '../utils/geometry';
|
||||
|
||||
@@ -163,7 +163,7 @@ export const WallLayer = memo(function WallLayer({
|
||||
}, [walls, selectedIds, zoom, panOffset]);
|
||||
|
||||
return (
|
||||
<Layer>
|
||||
<Group>
|
||||
{/* Room interior fill */}
|
||||
{roomShapeScreen.length >= 6 && (
|
||||
<Line
|
||||
@@ -231,6 +231,6 @@ export const WallLayer = memo(function WallLayer({
|
||||
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 {
|
||||
ELECTRICAL_SYMBOL_DEFS,
|
||||
type ElectricalSymbolDef,
|
||||
} from '../symbols/electrical';
|
||||
import styles from './electrical-palette.module.css';
|
||||
import styles from './item-picker.module.css';
|
||||
|
||||
interface ElectricalPaletteProps {
|
||||
readonly selectedIndex: number | null;
|
||||
readonly onSelect: (index: number) => void;
|
||||
}
|
||||
|
||||
interface CategoryGroup {
|
||||
readonly name: string;
|
||||
readonly nameKey: string;
|
||||
readonly icon: string;
|
||||
readonly items: readonly { readonly def: ElectricalSymbolDef; readonly index: number }[];
|
||||
interface IndexedDef {
|
||||
readonly def: ElectricalSymbolDef;
|
||||
readonly index: number;
|
||||
}
|
||||
|
||||
const CATEGORY_META: Record<string, { nameKey: string; icon: string }> = {
|
||||
outlet: { nameKey: 'electrical.outlets', icon: '\u26A1' },
|
||||
switch: { nameKey: 'electrical.switches', icon: '\u{1F50C}' },
|
||||
junction: { nameKey: 'electrical.junction', icon: '\u2B1C' },
|
||||
light: { nameKey: 'electrical.lights', icon: '\u{1F4A1}' },
|
||||
cable: { nameKey: 'electrical.cable', icon: '\u{1F517}' },
|
||||
interface CategoryGroup {
|
||||
readonly category: string;
|
||||
readonly items: readonly IndexedDef[];
|
||||
}
|
||||
|
||||
/**
|
||||
* UI metadata per electrical category. The `category` field on
|
||||
* ElectricalSymbolDef drives this — adding a new category requires only
|
||||
* a new entry here and the matching i18n key in the locale file.
|
||||
*/
|
||||
const CATEGORY_META: Record<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) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const categories = useMemo<readonly CategoryGroup[]>(() => {
|
||||
const groups = new Map<string, { readonly def: ElectricalSymbolDef; readonly index: number }[]>();
|
||||
|
||||
ELECTRICAL_SYMBOL_DEFS.forEach((def, index) => {
|
||||
const list = groups.get(def.category) ?? [];
|
||||
list.push({ def, index });
|
||||
groups.set(def.category, list);
|
||||
});
|
||||
|
||||
return Array.from(groups.entries()).map(([cat, items]) => ({
|
||||
name: cat,
|
||||
nameKey: CATEGORY_META[cat]?.nameKey ?? cat,
|
||||
icon: CATEGORY_META[cat]?.icon ?? '',
|
||||
items,
|
||||
}));
|
||||
}, []);
|
||||
const { t, i18n } = useTranslation();
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeCategory, setActiveCategory] = useState<CategoryFilter>('all');
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(index: number) => {
|
||||
@@ -53,32 +48,174 @@ export function ElectricalPalette({ selectedIndex, onSelect }: ElectricalPalette
|
||||
[onSelect],
|
||||
);
|
||||
|
||||
// Pre-compute the indexed list once. Indexes here MUST match positions in
|
||||
// ELECTRICAL_SYMBOL_DEFS so the picker continues to satisfy the
|
||||
// EditorCanvas contract: `ELECTRICAL_SYMBOL_DEFS[selectedElectricalIndex]`.
|
||||
const allIndexed = useMemo<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 (
|
||||
<div className={styles.palette}>
|
||||
<div className={styles.header}>{t('electrical.title')}</div>
|
||||
{categories.map((cat) => (
|
||||
<div key={cat.name} className={styles.category}>
|
||||
<div className={styles.categoryTitle}>
|
||||
{cat.icon} {t(cat.nameKey)}
|
||||
|
||||
<div className={styles.searchRow}>
|
||||
<span className={styles.searchIcon} aria-hidden>
|
||||
{'\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 className={styles.itemGrid}>
|
||||
{cat.items.map(({ def, index }) => (
|
||||
<button
|
||||
key={index}
|
||||
className={[
|
||||
styles.itemBtn,
|
||||
selectedIndex === index ? styles.itemBtnActive : '',
|
||||
].join(' ')}
|
||||
onClick={() => handleSelect(index)}
|
||||
title={def.label}
|
||||
>
|
||||
<span className={styles.itemIcon}>{cat.icon}</span>
|
||||
<span className={styles.itemLabel}>{def.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
visibleGroups.map((group) => {
|
||||
const meta = CATEGORY_META[group.category];
|
||||
const icon = meta?.icon ?? '';
|
||||
return (
|
||||
<div key={group.category} className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<span aria-hidden>{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 }) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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 { FURNITURE_DEFS, type FurnitureDef } from '../symbols/furniture';
|
||||
import styles from './furniture-palette.module.css';
|
||||
import {
|
||||
FURNITURE_DEFS,
|
||||
FURNITURE_CATEGORIES,
|
||||
type FurnitureDef,
|
||||
type FurnitureCategory,
|
||||
} from '../symbols/furniture';
|
||||
import styles from './item-picker.module.css';
|
||||
|
||||
interface FurniturePaletteProps {
|
||||
readonly selectedIndex: number | null;
|
||||
readonly onSelect: (index: number) => void;
|
||||
}
|
||||
|
||||
interface IndexedDef {
|
||||
readonly def: FurnitureDef;
|
||||
readonly index: number;
|
||||
}
|
||||
|
||||
interface CategoryGroup {
|
||||
readonly category: FurnitureCategory;
|
||||
readonly items: readonly IndexedDef[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Display metadata for each furniture category. The icon is purely visual
|
||||
* (the chip + section header use it). i18n keys live in `furnitureCategory.*`.
|
||||
*/
|
||||
const CATEGORY_META: Record<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) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeCategory, setActiveCategory] = useState<CategoryFilter>('all');
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(index: number) => {
|
||||
@@ -18,24 +51,152 @@ export function FurniturePalette({ selectedIndex, onSelect }: FurniturePalettePr
|
||||
[onSelect],
|
||||
);
|
||||
|
||||
// Pre-compute the indexed list once. Indexes here MUST match positions in
|
||||
// FURNITURE_DEFS so the picker continues to satisfy the EditorCanvas
|
||||
// contract: `FURNITURE_DEFS[selectedFurnitureIndex]`.
|
||||
const allIndexed = useMemo<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 (
|
||||
<div className={styles.palette}>
|
||||
<div className={styles.header}>{t('furniture.title')}</div>
|
||||
<div className={styles.itemList}>
|
||||
{FURNITURE_DEFS.map((def, index) => (
|
||||
<FurnitureItemBtn
|
||||
key={index}
|
||||
def={def}
|
||||
index={index}
|
||||
isActive={selectedIndex === index}
|
||||
onSelect={handleSelect}
|
||||
|
||||
<div className={styles.searchRow}>
|
||||
<span className={styles.searchIcon} aria-hidden>
|
||||
{'\u{1F50D}'}
|
||||
</span>
|
||||
<input
|
||||
className={styles.searchInput}
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
readonly def: FurnitureDef;
|
||||
readonly index: number;
|
||||
@@ -46,20 +207,16 @@ interface FurnitureItemBtnProps {
|
||||
function FurnitureItemBtn({ def, index, isActive, onSelect }: FurnitureItemBtnProps) {
|
||||
return (
|
||||
<button
|
||||
className={[
|
||||
styles.itemBtn,
|
||||
isActive ? styles.itemBtnActive : '',
|
||||
].join(' ')}
|
||||
type="button"
|
||||
className={[styles.itemBtn, isActive ? styles.itemBtnActive : ''].join(' ')}
|
||||
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>
|
||||
<div className={styles.itemInfo}>
|
||||
<span className={styles.itemLabel}>{def.label}</span>
|
||||
<span className={styles.itemDims}>
|
||||
{def.width}m x {def.depth}m
|
||||
</span>
|
||||
</div>
|
||||
<span className={styles.itemLabel}>{def.label}</span>
|
||||
<span className={styles.itemDims}>
|
||||
{def.width}×{def.depth}m
|
||||
</span>
|
||||
</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 type { ProjectedElectrical } from '../utils/projectionMapping';
|
||||
import { projectionToPixel } from '../utils/projectionMapping';
|
||||
import { DEFAULT_OUTLET_WIDTH, DEFAULT_OUTLET_HEIGHT } from '@house-plan-maker/shared';
|
||||
|
||||
interface ProjectionElectricalProps {
|
||||
readonly projected: ProjectedElectrical;
|
||||
@@ -11,6 +12,10 @@ interface ProjectionElectricalProps {
|
||||
readonly isDragging?: boolean;
|
||||
readonly dragFromFloor?: number;
|
||||
readonly dragAlongWall?: number;
|
||||
/** Physical width of a single outlet face plate (meters). */
|
||||
readonly outletWidth?: number;
|
||||
/** Physical height of a single outlet face plate (meters). */
|
||||
readonly outletHeight?: number;
|
||||
readonly onClick: () => void;
|
||||
readonly onDragStart?: (itemId: string, evt: MouseEvent) => void;
|
||||
}
|
||||
@@ -27,6 +32,8 @@ export function ProjectionElectrical({
|
||||
isDragging = false,
|
||||
dragFromFloor,
|
||||
dragAlongWall,
|
||||
outletWidth = DEFAULT_OUTLET_WIDTH,
|
||||
outletHeight = DEFAULT_OUTLET_HEIGHT,
|
||||
onClick,
|
||||
onDragStart,
|
||||
}: ProjectionElectricalProps) {
|
||||
@@ -70,29 +77,84 @@ export function ProjectionElectrical({
|
||||
fill="transparent"
|
||||
/>
|
||||
)}
|
||||
{item.type === 'OUTLET' && (
|
||||
<>
|
||||
{/* IEC outlet symbol: circle with two horizontal lines */}
|
||||
<Circle
|
||||
x={center.x}
|
||||
y={center.y}
|
||||
radius={half}
|
||||
fill={fillColor}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<Line
|
||||
points={[center.x - 3, center.y - 2, center.x + 3, center.y - 2]}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<Line
|
||||
points={[center.x - 3, center.y + 2, center.x + 3, center.y + 2]}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{item.type === 'OUTLET' && (() => {
|
||||
const safeCount = Math.max(1, Math.round(item.count));
|
||||
// Convert physical outlet dims to projection-pixel dims.
|
||||
const wPx = outletWidth * scale;
|
||||
const hPx = outletHeight * scale;
|
||||
// Anchor offset to bounding-box center, in projection pixels.
|
||||
// Horizontal axis = along-wall (positive right), vertical axis = up the wall.
|
||||
// In screen coords +y is down, so vertical='top' anchor means center is BELOW (positive y).
|
||||
//
|
||||
// When the projection axis is flipped (the canonical direction
|
||||
// runs opposite to the wall's stored start→end), we mirror the
|
||||
// horizontal anchor so "left" still refers to the same physical
|
||||
// side of the wall in both 3D and projection views. Without this
|
||||
// an outlet anchored "left" on a flipped wall would appear on
|
||||
// opposite sides of the two views.
|
||||
const anchor = item.positionAnchor;
|
||||
const mirroredHorizontal = projected.axisFlipped
|
||||
? anchor.horizontal === 'left'
|
||||
? 'right'
|
||||
: anchor.horizontal === 'right'
|
||||
? 'left'
|
||||
: 'middle'
|
||||
: anchor.horizontal;
|
||||
const totalW = safeCount * wPx;
|
||||
const offX =
|
||||
mirroredHorizontal === 'left' ? totalW / 2 : mirroredHorizontal === 'right' ? -totalW / 2 : 0;
|
||||
const offY =
|
||||
anchor.vertical === 'top' ? hPx / 2 : anchor.vertical === 'bottom' ? -hPx / 2 : 0;
|
||||
const cx = center.x + offX;
|
||||
const cy = center.y + offY;
|
||||
const left = cx - totalW / 2;
|
||||
const top = cy - hPx / 2;
|
||||
const cellMin = Math.min(wPx, hPx);
|
||||
const faceR = cellMin * 0.32;
|
||||
const prongL = cellMin * 0.18;
|
||||
const prongG = cellMin * 0.12;
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: safeCount }).map((_, i) => {
|
||||
const cellLeft = left + i * wPx;
|
||||
const cellCx = cellLeft + wPx / 2;
|
||||
const cellCy = top + hPx / 2;
|
||||
return (
|
||||
<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' && (
|
||||
<>
|
||||
{/* IEC switch symbol: circle with diagonal line */}
|
||||
@@ -157,12 +219,21 @@ export function ProjectionElectrical({
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
{/* Type label below symbol */}
|
||||
{/* Type label below symbol — uses the user's custom label if set,
|
||||
otherwise falls back to the short type code. */}
|
||||
<Text
|
||||
x={center.x - 20}
|
||||
x={center.x - 30}
|
||||
y={center.y + half + 2}
|
||||
width={40}
|
||||
text={item.type === 'OUTLET' ? 'OUT' : item.type === 'SWITCH' ? 'SW' : 'WL'}
|
||||
width={60}
|
||||
text={
|
||||
item.label && item.label.trim().length > 0
|
||||
? item.label
|
||||
: item.type === 'OUTLET'
|
||||
? 'OUT'
|
||||
: item.type === 'SWITCH'
|
||||
? 'SW'
|
||||
: 'WL'
|
||||
}
|
||||
align="center"
|
||||
fontSize={8}
|
||||
fill="#94a3b8"
|
||||
|
||||
@@ -8,6 +8,7 @@ interface ProjectionFurnitureProps {
|
||||
readonly scale: number;
|
||||
readonly padding: number;
|
||||
readonly isSelected: boolean;
|
||||
readonly globalOpacity?: number;
|
||||
readonly onClick: () => void;
|
||||
}
|
||||
|
||||
@@ -27,6 +28,7 @@ export function ProjectionFurniture({
|
||||
scale,
|
||||
padding,
|
||||
isSelected,
|
||||
globalOpacity = 1,
|
||||
onClick,
|
||||
}: ProjectionFurnitureProps) {
|
||||
const { rect, item } = projected;
|
||||
@@ -37,8 +39,9 @@ export function ProjectionFurniture({
|
||||
|
||||
const color = TYPE_COLORS[item.type] ?? '#a0845c';
|
||||
|
||||
const itemOpacity = (item.opacity ?? 1) * globalOpacity;
|
||||
return (
|
||||
<Group onClick={onClick}>
|
||||
<Group onClick={onClick} opacity={itemOpacity}>
|
||||
<Rect
|
||||
x={topLeft.x}
|
||||
y={topLeft.y}
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Group, Line, Text } from 'react-konva';
|
||||
import type { ProjectedOpening, ProjectedElectrical } from '../utils/projectionMapping';
|
||||
import { Group, Rect, Line, Text } from 'react-konva';
|
||||
import type {
|
||||
ProjectedOpening,
|
||||
ProjectedElectrical,
|
||||
ProjectedFurniture,
|
||||
} from '../utils/projectionMapping';
|
||||
import { projectionToPixel } from '../utils/projectionMapping';
|
||||
import { DEFAULT_OUTLET_WIDTH, DEFAULT_OUTLET_HEIGHT } from '@house-plan-maker/shared';
|
||||
|
||||
interface ProjectionMeasurementsProps {
|
||||
readonly projectedOpenings: readonly ProjectedOpening[];
|
||||
readonly projectedElectrical: readonly ProjectedElectrical[];
|
||||
readonly projectedFurniture?: readonly ProjectedFurniture[];
|
||||
readonly wallLength: number;
|
||||
readonly wallHeight: number;
|
||||
readonly scale: 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. */
|
||||
@@ -98,44 +109,50 @@ function formatM(meters: number): string {
|
||||
export function ProjectionMeasurements({
|
||||
projectedOpenings,
|
||||
projectedElectrical,
|
||||
projectedFurniture = [],
|
||||
wallLength: wallLen,
|
||||
wallHeight,
|
||||
scale,
|
||||
padding,
|
||||
outletWidth = DEFAULT_OUTLET_WIDTH,
|
||||
outletHeight = DEFAULT_OUTLET_HEIGHT,
|
||||
showWallDimensions = true,
|
||||
}: ProjectionMeasurementsProps) {
|
||||
const elements: ReactNode[] = [];
|
||||
|
||||
// Wall width dimension (along bottom)
|
||||
const floorLeft = projectionToPixel(0, 0, wallHeight, scale, padding);
|
||||
const floorRight = projectionToPixel(wallLen, 0, wallHeight, scale, padding);
|
||||
elements.push(
|
||||
<DimensionLine
|
||||
key="wall-width"
|
||||
x1={floorLeft.x}
|
||||
y1={floorLeft.y}
|
||||
x2={floorRight.x}
|
||||
y2={floorRight.y}
|
||||
label={formatM(wallLen)}
|
||||
offset={18}
|
||||
horizontal
|
||||
/>,
|
||||
);
|
||||
if (showWallDimensions) {
|
||||
// Wall width dimension (along bottom)
|
||||
const floorLeft = projectionToPixel(0, 0, wallHeight, scale, padding);
|
||||
const floorRight = projectionToPixel(wallLen, 0, wallHeight, scale, padding);
|
||||
elements.push(
|
||||
<DimensionLine
|
||||
key="wall-width"
|
||||
x1={floorLeft.x}
|
||||
y1={floorLeft.y}
|
||||
x2={floorRight.x}
|
||||
y2={floorRight.y}
|
||||
label={formatM(wallLen)}
|
||||
offset={18}
|
||||
horizontal
|
||||
/>,
|
||||
);
|
||||
|
||||
// Wall height dimension (along right side)
|
||||
const topRight = projectionToPixel(wallLen, wallHeight, wallHeight, scale, padding);
|
||||
const bottomRight = projectionToPixel(wallLen, 0, wallHeight, scale, padding);
|
||||
elements.push(
|
||||
<DimensionLine
|
||||
key="wall-height"
|
||||
x1={topRight.x}
|
||||
y1={topRight.y}
|
||||
x2={bottomRight.x}
|
||||
y2={bottomRight.y}
|
||||
label={formatM(wallHeight)}
|
||||
offset={18}
|
||||
horizontal={false}
|
||||
/>,
|
||||
);
|
||||
// Wall height dimension (along right side)
|
||||
const topRight = projectionToPixel(wallLen, wallHeight, wallHeight, scale, padding);
|
||||
const bottomRight = projectionToPixel(wallLen, 0, wallHeight, scale, padding);
|
||||
elements.push(
|
||||
<DimensionLine
|
||||
key="wall-height"
|
||||
x1={topRight.x}
|
||||
y1={topRight.y}
|
||||
x2={bottomRight.x}
|
||||
y2={bottomRight.y}
|
||||
label={formatM(wallHeight)}
|
||||
offset={18}
|
||||
horizontal={false}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
// Opening dimensions: sill height for windows, door height for doors
|
||||
for (const po of projectedOpenings) {
|
||||
@@ -190,7 +207,12 @@ export function ProjectionMeasurements({
|
||||
);
|
||||
}
|
||||
|
||||
// Electrical item coordinate labels: (X; Y) near each item
|
||||
// Electrical item coordinate labels: (X; Y) near each item.
|
||||
// For OUTLET groups with count > 1, the symbol bounding box extends to
|
||||
// either side of the anchor by `count * outletWidth / 2`. Push the label
|
||||
// past the right edge of the box (plus a small gap) so it doesn't overlap
|
||||
// the outlet face plates. A semi-opaque background pill keeps it readable
|
||||
// even when it sits over a wall stripe or other UI.
|
||||
for (const pe of projectedElectrical) {
|
||||
const center = projectionToPixel(
|
||||
pe.position.alongWall,
|
||||
@@ -200,16 +222,38 @@ export function ProjectionMeasurements({
|
||||
padding,
|
||||
);
|
||||
|
||||
// Half-width of the visible symbol along the wall axis, in pixels.
|
||||
let halfWidthPx = 8; // default for non-outlet symbols (~SYMBOL_SIZE/2 + margin)
|
||||
if (pe.item.type === 'OUTLET') {
|
||||
const safeCount = Math.max(1, Math.round(pe.item.count));
|
||||
halfWidthPx = (safeCount * outletWidth * scale) / 2;
|
||||
}
|
||||
|
||||
const coordLabel = `(${pe.position.alongWall.toFixed(2)}; ${pe.elevation.toFixed(2)})`;
|
||||
const labelX = center.x + halfWidthPx + 6;
|
||||
const labelY = center.y - 6;
|
||||
// Rough text-width estimate (monospace-ish): ~5.5px per char at fontSize 9.
|
||||
const labelWidth = coordLabel.length * 5.5 + 4;
|
||||
elements.push(
|
||||
<Text
|
||||
key={`elec-coord-${pe.item.id}`}
|
||||
x={center.x + 10}
|
||||
y={center.y - 4}
|
||||
text={coordLabel}
|
||||
fontSize={9}
|
||||
fill="#64748b"
|
||||
/>,
|
||||
<Group key={`elec-coord-${pe.item.id}`}>
|
||||
<Rect
|
||||
x={labelX - 2}
|
||||
y={labelY - 1}
|
||||
width={labelWidth}
|
||||
height={12}
|
||||
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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ElectricalItem, WallOpening } from '@house-plan-maker/shared';
|
||||
import type { ElectricalItem, WallOpening, Annotation } from '@house-plan-maker/shared';
|
||||
import { useEditor } from '../context/EditorContext';
|
||||
import { wallDirectionKey } from '../utils/projectionMapping';
|
||||
import { wallStartEnd } from '../utils/wallUtils';
|
||||
import { wallDirectionKey, getProjectionAxis } from '../utils/projectionMapping';
|
||||
import { generateLocalId } from '../utils/geometry';
|
||||
import { normalizeAngleDegrees } from '../utils/angle';
|
||||
import { TextPromptModal } from '../../ui/TextPromptModal';
|
||||
import { getDefaultElevation } from '../tools/ElectricalTool';
|
||||
import { ELECTRICAL_SYMBOL_DEFS } from '../symbols/electrical';
|
||||
import { WallProjectionView } from './WallProjectionView';
|
||||
@@ -19,13 +20,14 @@ interface ProjectionPanelProps {
|
||||
|
||||
export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPanelProps = {}) {
|
||||
const { t } = useTranslation();
|
||||
const { state, selectElement, updateElectrical, updateOpening, addElectrical } = useEditor();
|
||||
const { state, selectElement, updateElectrical, updateOpening, addElectrical, updateAnnotation } = useEditor();
|
||||
const {
|
||||
walls,
|
||||
openings,
|
||||
electricalItems,
|
||||
furnitureItems,
|
||||
annotations,
|
||||
globalFurnitureOpacity,
|
||||
room,
|
||||
selectedIds,
|
||||
activeTool,
|
||||
@@ -120,6 +122,21 @@ export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPane
|
||||
[updateOpening],
|
||||
);
|
||||
|
||||
const handleUpdateAnnotation = useCallback(
|
||||
(annotation: Annotation) => {
|
||||
updateAnnotation(annotation);
|
||||
},
|
||||
[updateAnnotation],
|
||||
);
|
||||
|
||||
// ── Annotation editing modal (shared by all wall views) ──
|
||||
const [editingAnnotationId, setEditingAnnotationId] = useState<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 ──
|
||||
const handlePlaceElectrical = useCallback(
|
||||
(wallId: string, alongWall: number, fromFloor: number) => {
|
||||
@@ -131,10 +148,9 @@ export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPane
|
||||
if (!wall) return;
|
||||
|
||||
// Convert projection coordinates (alongWall) back to room 2D coordinates
|
||||
const { start, end } = wallStartEnd(wall);
|
||||
const wallLen = Math.sqrt(
|
||||
(end.x - start.x) ** 2 + (end.y - start.y) ** 2,
|
||||
);
|
||||
// using the canonical projection axis (so south/west walls aren't mirrored).
|
||||
const axis = getProjectionAxis(wall);
|
||||
const { start, end, length: wallLen } = axis;
|
||||
if (wallLen === 0) return;
|
||||
|
||||
const dx = (end.x - start.x) / wallLen;
|
||||
@@ -147,8 +163,10 @@ export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPane
|
||||
? fromFloor
|
||||
: getDefaultElevation(symbolDef.type, room.wallHeight);
|
||||
|
||||
// Outlets no longer use a metadata variant (count is the source of
|
||||
// truth). Other electrical types still pass variant through metadata.
|
||||
const metadata: Record<string, unknown> | null =
|
||||
symbolDef.variant ? { variant: symbolDef.variant } : null;
|
||||
symbolDef.type !== 'OUTLET' && symbolDef.variant ? { variant: symbolDef.variant } : null;
|
||||
|
||||
const newItem: ElectricalItem = {
|
||||
id: generateLocalId(),
|
||||
@@ -158,7 +176,10 @@ export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPane
|
||||
y,
|
||||
wallId: symbolDef.wallMounted ? wallId : null,
|
||||
elevationFromFloor: elevation,
|
||||
rotation: (Math.atan2(dy, dx) * 180) / Math.PI,
|
||||
rotation: normalizeAngleDegrees((Math.atan2(dy, dx) * 180) / Math.PI),
|
||||
count: 1,
|
||||
positionAnchor: { horizontal: 'middle', vertical: 'middle' },
|
||||
label: null,
|
||||
metadata,
|
||||
};
|
||||
|
||||
@@ -179,13 +200,18 @@ export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPane
|
||||
electricalItems: layerVisibility.electrical ? electricalItems : [],
|
||||
furnitureItems: layerVisibility.furniture ? furnitureItems : [],
|
||||
annotations: layerVisibility.annotations ? annotations : [],
|
||||
globalFurnitureOpacity,
|
||||
wallHeight: room.wallHeight,
|
||||
plinthHeight: room.plinthHeight,
|
||||
outletWidth: room.outletWidth,
|
||||
outletHeight: room.outletHeight,
|
||||
selectedIds,
|
||||
onSelectElement: handleSelectElement,
|
||||
onStageRef,
|
||||
onUpdateElectrical: handleUpdateElectrical,
|
||||
onUpdateOpening: handleUpdateOpening,
|
||||
onUpdateAnnotation: handleUpdateAnnotation,
|
||||
onEditAnnotation: handleEditAnnotation,
|
||||
onPlaceElectrical: handlePlaceElectrical,
|
||||
showMeasurements: layerVisibility.measurements,
|
||||
activeTool,
|
||||
@@ -294,6 +320,18 @@ export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPane
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,51 +80,42 @@ export function ProjectionWindow({
|
||||
stroke="#93c5fd"
|
||||
strokeWidth={0.5}
|
||||
/>
|
||||
{/* Horizontal mullion (center divider) */}
|
||||
<Line
|
||||
points={[
|
||||
topLeft.x + frameInset,
|
||||
topLeft.y + pxHeight / 2,
|
||||
topLeft.x + pxWidth - frameInset,
|
||||
topLeft.y + pxHeight / 2,
|
||||
]}
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
{/* Vertical mullion (center divider) */}
|
||||
<Line
|
||||
points={[
|
||||
topLeft.x + pxWidth / 2,
|
||||
topLeft.y + frameInset,
|
||||
topLeft.x + pxWidth / 2,
|
||||
topLeft.y + pxHeight - frameInset,
|
||||
]}
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
{/* Glass cross lines for indication */}
|
||||
<Line
|
||||
points={[
|
||||
topLeft.x + frameInset,
|
||||
topLeft.y + frameInset,
|
||||
topLeft.x + pxWidth / 2,
|
||||
topLeft.y + pxHeight / 2,
|
||||
]}
|
||||
stroke="#93c5fd"
|
||||
strokeWidth={0.5}
|
||||
opacity={0.6}
|
||||
/>
|
||||
<Line
|
||||
points={[
|
||||
topLeft.x + pxWidth - frameInset,
|
||||
topLeft.y + frameInset,
|
||||
topLeft.x + pxWidth / 2,
|
||||
topLeft.y + pxHeight / 2,
|
||||
]}
|
||||
stroke="#93c5fd"
|
||||
strokeWidth={0.5}
|
||||
opacity={0.6}
|
||||
/>
|
||||
{/* Internal mullions — N×M grid. Rendered as lines spanning the
|
||||
glass area; `gridCols - 1` verticals + `gridRows - 1`
|
||||
horizontals. Defaults to 2×2 for legacy windows without an
|
||||
explicit grid set. */}
|
||||
{(() => {
|
||||
const cols = Math.max(1, Math.min(10, Math.round(opening.gridCols ?? 2)));
|
||||
const rows = Math.max(1, Math.min(10, Math.round(opening.gridRows ?? 2)));
|
||||
const innerLeft = topLeft.x + frameInset;
|
||||
const innerTop = topLeft.y + frameInset;
|
||||
const innerWidth = pxWidth - frameInset * 2;
|
||||
const innerHeight = pxHeight - frameInset * 2;
|
||||
const lines: React.ReactNode[] = [];
|
||||
for (let i = 1; i < cols; i++) {
|
||||
const x = innerLeft + (innerWidth * i) / cols;
|
||||
lines.push(
|
||||
<Line
|
||||
key={`vmul-${i}`}
|
||||
points={[x, innerTop, x, innerTop + innerHeight]}
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={1}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
for (let i = 1; i < rows; i++) {
|
||||
const y = innerTop + (innerHeight * i) / rows;
|
||||
lines.push(
|
||||
<Line
|
||||
key={`hmul-${i}`}
|
||||
points={[innerLeft, y, innerLeft + innerWidth, y]}
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={1}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
return lines;
|
||||
})()}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Stage, Layer, Rect, Line, Text, Group } from 'react-konva';
|
||||
import type Konva from 'konva';
|
||||
import type { Wall, WallOpening, ElectricalItem, FurnitureItem, ElectricalType, Annotation } from '@house-plan-maker/shared';
|
||||
import { wallLength as computeWallLength, wallStartEnd } from '../utils/wallUtils';
|
||||
import { wallLength as computeWallLength } from '../utils/wallUtils';
|
||||
import {
|
||||
projectionScale,
|
||||
projectionToPixel,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
projectFurnitureItems,
|
||||
computePlinthSegments,
|
||||
wallDirectionLabel,
|
||||
getProjectionAxis,
|
||||
} from '../utils/projectionMapping';
|
||||
import { ProjectionDoor } from './ProjectionDoor';
|
||||
import { ProjectionWindow } from './ProjectionWindow';
|
||||
@@ -27,9 +28,12 @@ interface WallProjectionViewProps {
|
||||
readonly electricalItems: readonly ElectricalItem[];
|
||||
readonly furnitureItems: readonly FurnitureItem[];
|
||||
readonly annotations: readonly Annotation[];
|
||||
readonly globalFurnitureOpacity?: number;
|
||||
readonly showMeasurements?: boolean;
|
||||
readonly wallHeight: number;
|
||||
readonly plinthHeight: number;
|
||||
readonly outletWidth?: number;
|
||||
readonly outletHeight?: number;
|
||||
readonly selectedIds: ReadonlySet<string>;
|
||||
readonly isHighlighted: boolean;
|
||||
readonly onSelectElement: (id: string) => void;
|
||||
@@ -38,6 +42,8 @@ interface WallProjectionViewProps {
|
||||
readonly onStageRef?: (wallId: string, stage: Konva.Stage | null) => void;
|
||||
readonly onUpdateElectrical?: (item: ElectricalItem) => void;
|
||||
readonly onUpdateOpening?: (opening: WallOpening) => void;
|
||||
readonly onUpdateAnnotation?: (annotation: Annotation) => void;
|
||||
readonly onEditAnnotation?: (annotationId: string) => void;
|
||||
readonly onPlaceElectrical?: (wallId: string, alongWall: number, fromFloor: number) => void;
|
||||
readonly activeTool?: EditorToolType;
|
||||
readonly selectedElectricalType?: ElectricalType | null;
|
||||
@@ -60,6 +66,15 @@ interface DragInfo {
|
||||
readonly itemId: string;
|
||||
readonly startPixelX: number;
|
||||
readonly startPixelY: number;
|
||||
/**
|
||||
* Projection-coord offset between the cursor and the item's stored
|
||||
* anchor at drag start. Subtracted from the live cursor position so
|
||||
* the drag respects where inside the item the user grabbed instead of
|
||||
* teleporting the anchor to the cursor (which made non-middle anchors
|
||||
* jump on drag start).
|
||||
*/
|
||||
readonly offsetAlongWall: number;
|
||||
readonly offsetFromFloor: number;
|
||||
readonly exceeded: boolean;
|
||||
}
|
||||
|
||||
@@ -69,9 +84,12 @@ export function WallProjectionView({
|
||||
electricalItems,
|
||||
furnitureItems,
|
||||
annotations,
|
||||
globalFurnitureOpacity = 1,
|
||||
showMeasurements = true,
|
||||
wallHeight,
|
||||
plinthHeight,
|
||||
outletWidth,
|
||||
outletHeight,
|
||||
selectedIds,
|
||||
isHighlighted,
|
||||
onSelectElement,
|
||||
@@ -80,6 +98,8 @@ export function WallProjectionView({
|
||||
onStageRef,
|
||||
onUpdateElectrical,
|
||||
onUpdateOpening,
|
||||
onUpdateAnnotation,
|
||||
onEditAnnotation,
|
||||
onPlaceElectrical,
|
||||
activeTool,
|
||||
selectedElectricalType,
|
||||
@@ -102,6 +122,7 @@ export function WallProjectionView({
|
||||
viewPanRef.current = viewPan;
|
||||
|
||||
const wallLen = computeWallLength(wall);
|
||||
const projectionAxis = useMemo(() => getProjectionAxis(wall), [wall]);
|
||||
const baseScale = projectionScale(wallLen, wallHeight, width, height, PADDING);
|
||||
const effectiveScale = baseScale * viewZoom;
|
||||
|
||||
@@ -111,6 +132,32 @@ export function WallProjectionView({
|
||||
const [dragElectricalAlongWall, setDragElectricalAlongWall] = useState<{ itemId: string; alongWall: number } | null>(null);
|
||||
const [dragOpeningAlongWall, setDragOpeningAlongWall] = useState<{ openingId: string; alongWall: number } | null>(null);
|
||||
|
||||
// ── Measure tool state ──
|
||||
// Stored in projection coordinates: `alongWall` = meters from the
|
||||
// canonical wall start, `fromFloor` = meters from the floor. Distance
|
||||
// is the Euclidean distance between the two endpoints in those units,
|
||||
// i.e. real-world metres on the wall surface.
|
||||
interface ProjectionMeasurement {
|
||||
readonly start: { alongWall: number; fromFloor: number };
|
||||
readonly end: { alongWall: number; fromFloor: number };
|
||||
}
|
||||
const [measurement, setMeasurement] = useState<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) ──
|
||||
const projectedOpenings = useMemo(
|
||||
() => projectOpenings(wall, openings),
|
||||
@@ -144,26 +191,66 @@ export function WallProjectionView({
|
||||
// ── Electrical drag start ──
|
||||
const handleElectricalDragStart = useCallback((itemId: string, evt: MouseEvent) => {
|
||||
if (!onUpdateElectrical) return;
|
||||
const item = electricalItems.find((i) => i.id === itemId);
|
||||
if (!item) return;
|
||||
const pointer = getStagePointer(evt);
|
||||
if (!pointer) return;
|
||||
const proj = pixelToProjection(
|
||||
pointer.x - viewPanRef.current.x,
|
||||
pointer.y - viewPanRef.current.y,
|
||||
wallHeight,
|
||||
effectiveScale,
|
||||
PADDING,
|
||||
);
|
||||
// Project the item's stored (x, y) anchor onto the canonical wall axis
|
||||
// so we can compute the cursor's offset from that anchor at drag start.
|
||||
const wLen = wallLen || 1;
|
||||
const dx = (projectionAxis.end.x - projectionAxis.start.x) / wLen;
|
||||
const dy = (projectionAxis.end.y - projectionAxis.start.y) / wLen;
|
||||
const itemAlongWall = (item.x - projectionAxis.start.x) * dx + (item.y - projectionAxis.start.y) * dy;
|
||||
const itemFromFloor = item.elevationFromFloor ?? 0;
|
||||
dragRef.current = {
|
||||
kind: 'electrical-elevation',
|
||||
itemId,
|
||||
startPixelX: evt.clientX,
|
||||
startPixelY: evt.clientY,
|
||||
offsetAlongWall: proj.alongWall - itemAlongWall,
|
||||
offsetFromFloor: proj.fromFloor - itemFromFloor,
|
||||
exceeded: false,
|
||||
};
|
||||
}, [onUpdateElectrical]);
|
||||
}, [onUpdateElectrical, electricalItems, getStagePointer, projectionAxis, wallLen, wallHeight, effectiveScale]);
|
||||
|
||||
// ── Opening drag start ──
|
||||
const handleOpeningDragStart = useCallback((openingId: string, evt: MouseEvent) => {
|
||||
if (!onUpdateOpening) return;
|
||||
const opening = openings.find((o) => o.id === openingId);
|
||||
if (!opening) return;
|
||||
const pointer = getStagePointer(evt);
|
||||
if (!pointer) return;
|
||||
const proj = pixelToProjection(
|
||||
pointer.x - viewPanRef.current.x,
|
||||
pointer.y - viewPanRef.current.y,
|
||||
wallHeight,
|
||||
effectiveScale,
|
||||
PADDING,
|
||||
);
|
||||
// Opening's projected alongWall takes axis flipping into account so
|
||||
// the offset stays consistent with how the symbol is rendered.
|
||||
const projectedAlongWall = projectionAxis.flipped
|
||||
? (wallLen - opening.positionAlongWall)
|
||||
: opening.positionAlongWall;
|
||||
dragRef.current = {
|
||||
kind: 'opening-position',
|
||||
itemId: openingId,
|
||||
startPixelX: evt.clientX,
|
||||
startPixelY: evt.clientY,
|
||||
offsetAlongWall: proj.alongWall - projectedAlongWall,
|
||||
// Openings only drag horizontally for now, but populate the field for
|
||||
// type completeness so the DragInfo invariant holds.
|
||||
offsetFromFloor: 0,
|
||||
exceeded: false,
|
||||
};
|
||||
}, [onUpdateOpening]);
|
||||
}, [onUpdateOpening, openings, getStagePointer, projectionAxis, wallLen, wallHeight, effectiveScale]);
|
||||
|
||||
// ── Zoom handler ──
|
||||
const handleWheel = useCallback((e: Konva.KonvaEventObject<WheelEvent>) => {
|
||||
@@ -200,6 +287,34 @@ export function WallProjectionView({
|
||||
|
||||
// ── Pan handlers ──
|
||||
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
|
||||
const inPlacementMode = activeTool === 'electrical' && selectedElectricalType != null;
|
||||
if (e.evt.button === 1 || (e.evt.button === 0 && !inPlacementMode)) {
|
||||
@@ -221,7 +336,7 @@ export function WallProjectionView({
|
||||
if (e.evt.button === 0 && e.target === e.currentTarget?.getStage()?.findOne('.wall-bg')) {
|
||||
// This is handled in the wall-bg rect click
|
||||
}
|
||||
}, [viewPan]);
|
||||
}, [activeTool, selectedElectricalType, viewPan, wallHeight, effectiveScale, getStagePointer]);
|
||||
|
||||
const handleMouseMove = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
// Handle panning
|
||||
@@ -235,6 +350,29 @@ export function WallProjectionView({
|
||||
return;
|
||||
}
|
||||
|
||||
// Update live measurement endpoint while the mouse is held down.
|
||||
if (isMeasuringRef.current) {
|
||||
const pointer = getStagePointer(e.evt);
|
||||
if (pointer) {
|
||||
const proj = pixelToProjection(
|
||||
pointer.x - viewPanRef.current.x,
|
||||
pointer.y - viewPanRef.current.y,
|
||||
wallHeight,
|
||||
effectiveScale,
|
||||
PADDING,
|
||||
);
|
||||
setMeasurement((prev) =>
|
||||
prev
|
||||
? {
|
||||
start: prev.start,
|
||||
end: { alongWall: proj.alongWall, fromFloor: proj.fromFloor },
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle dragging
|
||||
const drag = dragRef.current;
|
||||
if (!drag) return;
|
||||
@@ -254,22 +392,28 @@ export function WallProjectionView({
|
||||
|
||||
const proj = pixelToProjection(pointer.x - viewPanRef.current.x, pointer.y - viewPanRef.current.y, wallHeight, effectiveScale, PADDING);
|
||||
|
||||
// Subtract the grab offset so the item moves with the cursor delta
|
||||
// instead of teleporting its anchor to the cursor. This matters for
|
||||
// non-middle anchors and for grabbing near an item edge.
|
||||
const targetAlongWall = proj.alongWall - drag.offsetAlongWall;
|
||||
const targetFromFloor = proj.fromFloor - drag.offsetFromFloor;
|
||||
|
||||
if (drag.kind === 'electrical-elevation') {
|
||||
if (e.evt.ctrlKey || e.evt.metaKey) {
|
||||
// Ctrl+drag: move horizontally along wall
|
||||
const clampedAlongWall = Math.max(0, Math.min(wallLen, proj.alongWall));
|
||||
const clampedAlongWall = Math.max(0, Math.min(wallLen, targetAlongWall));
|
||||
setDragElectricalAlongWall({ itemId: drag.itemId, alongWall: clampedAlongWall });
|
||||
setDragElectricalFromFloor(null);
|
||||
} else {
|
||||
// Normal drag: move vertically (elevation)
|
||||
const clampedFromFloor = Math.max(0, Math.min(wallHeight, proj.fromFloor));
|
||||
const clampedFromFloor = Math.max(0, Math.min(wallHeight, targetFromFloor));
|
||||
setDragElectricalFromFloor({ itemId: drag.itemId, fromFloor: clampedFromFloor });
|
||||
setDragElectricalAlongWall(null);
|
||||
}
|
||||
} else if (drag.kind === 'opening-position') {
|
||||
const opening = openings.find((o) => o.id === drag.itemId);
|
||||
const halfWidth = opening ? opening.width / 2 : 0;
|
||||
const clampedAlongWall = Math.max(halfWidth, Math.min(wallLen - halfWidth, proj.alongWall));
|
||||
const clampedAlongWall = Math.max(halfWidth, Math.min(wallLen - halfWidth, targetAlongWall));
|
||||
setDragOpeningAlongWall({ openingId: drag.itemId, alongWall: clampedAlongWall });
|
||||
}
|
||||
}, [getStagePointer, wallHeight, effectiveScale, wallLen, openings]);
|
||||
@@ -277,6 +421,21 @@ export function WallProjectionView({
|
||||
const handleMouseUp = useCallback(() => {
|
||||
isPanningRef.current = false;
|
||||
|
||||
// Release the measurement drag but keep the rendered line on screen
|
||||
// so the user can read the value. The next left-click on empty space
|
||||
// in measure mode will start a fresh measurement.
|
||||
if (isMeasuringRef.current) {
|
||||
isMeasuringRef.current = false;
|
||||
// Clear a zero-length measurement (single click without drag).
|
||||
setMeasurement((prev) => {
|
||||
if (!prev) return prev;
|
||||
const dAlong = prev.end.alongWall - prev.start.alongWall;
|
||||
const dUp = prev.end.fromFloor - prev.start.fromFloor;
|
||||
if (Math.hypot(dAlong, dUp) < 0.01) return null;
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
|
||||
const drag = dragRef.current;
|
||||
if (!drag) return;
|
||||
|
||||
@@ -295,8 +454,8 @@ export function WallProjectionView({
|
||||
const item = electricalItems.find((i) => i.id === drag.itemId);
|
||||
if (item) {
|
||||
if (dragElectricalAlongWall) {
|
||||
// Horizontal drag: compute new x,y from alongWall position on the wall
|
||||
const { start, end } = wallStartEnd(wall);
|
||||
// Horizontal drag: compute new x,y from canonical alongWall position
|
||||
const { start, end } = projectionAxis;
|
||||
const wLen = wallLen || 1;
|
||||
const t = dragElectricalAlongWall.alongWall / wLen;
|
||||
onUpdateElectrical({
|
||||
@@ -314,9 +473,13 @@ export function WallProjectionView({
|
||||
} else if (drag.kind === 'opening-position' && dragOpeningAlongWall && onUpdateOpening) {
|
||||
const opening = openings.find((o) => o.id === drag.itemId);
|
||||
if (opening) {
|
||||
// Map canonical projection position back to storage (relative to wall.start)
|
||||
const storedPos = projectionAxis.flipped
|
||||
? wallLen - dragOpeningAlongWall.alongWall
|
||||
: dragOpeningAlongWall.alongWall;
|
||||
onUpdateOpening({
|
||||
...opening,
|
||||
positionAlongWall: Math.round(dragOpeningAlongWall.alongWall * 100) / 100,
|
||||
positionAlongWall: Math.round(storedPos * 100) / 100,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -324,7 +487,7 @@ export function WallProjectionView({
|
||||
setDragElectricalFromFloor(null);
|
||||
setDragElectricalAlongWall(null);
|
||||
setDragOpeningAlongWall(null);
|
||||
}, [dragElectricalFromFloor, dragElectricalAlongWall, dragOpeningAlongWall, electricalItems, openings, wall, wallLen, onUpdateElectrical, onUpdateOpening]);
|
||||
}, [dragElectricalFromFloor, dragElectricalAlongWall, dragOpeningAlongWall, electricalItems, openings, projectionAxis, wallLen, onUpdateElectrical, onUpdateOpening]);
|
||||
|
||||
// ── Handle click on wall background for placement ──
|
||||
const handleWallBgClick = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
@@ -343,11 +506,14 @@ export function WallProjectionView({
|
||||
onPlaceElectrical(wall.id, proj.alongWall, proj.fromFloor);
|
||||
}, [activeTool, selectedElectricalType, onPlaceElectrical, getStagePointer, wallHeight, effectiveScale, wallLen, wall.id]);
|
||||
|
||||
// ── Reset zoom when wall changes ──
|
||||
// ── Reset zoom when the *physical* wall changes ──
|
||||
// We key on the wall's start/end coords rather than id so a save (which
|
||||
// re-creates walls with new ids) does not wipe the user's pan/zoom.
|
||||
const wallKey = `${wall.startX},${wall.startY},${wall.endX},${wall.endY}`;
|
||||
useEffect(() => {
|
||||
setViewZoom(1);
|
||||
setViewPan({ x: 0, y: 0 });
|
||||
}, [wall.id]);
|
||||
}, [wallKey]);
|
||||
|
||||
// ── Coordinate helpers ──
|
||||
const toPixel = useCallback(
|
||||
@@ -597,6 +763,7 @@ export function WallProjectionView({
|
||||
scale={effectiveScale}
|
||||
padding={PADDING}
|
||||
isSelected={selectedIds.has(pf.item.id)}
|
||||
globalOpacity={globalFurnitureOpacity}
|
||||
onClick={() => onSelectElement(pf.item.id)}
|
||||
/>
|
||||
))}
|
||||
@@ -616,25 +783,81 @@ export function WallProjectionView({
|
||||
isDragging={isDraggingV || isDraggingH}
|
||||
dragFromFloor={isDraggingV ? dragElectricalFromFloor?.fromFloor : undefined}
|
||||
dragAlongWall={isDraggingH ? dragElectricalAlongWall?.alongWall : undefined}
|
||||
outletWidth={outletWidth}
|
||||
outletHeight={outletHeight}
|
||||
onClick={() => onSelectElement(pe.item.id)}
|
||||
onDragStart={onUpdateElectrical ? handleElectricalDragStart : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Measurements */}
|
||||
{showMeasurements && (
|
||||
<ProjectionMeasurements
|
||||
projectedOpenings={projectedOpenings}
|
||||
projectedElectrical={projectedElectrical}
|
||||
wallLength={wallLen}
|
||||
wallHeight={wallHeight}
|
||||
scale={effectiveScale}
|
||||
padding={PADDING}
|
||||
/>
|
||||
)}
|
||||
{/* Measure tool overlay — a dashed line from start to end with a
|
||||
distance label. Rendered above the items but below annotations.
|
||||
Dimensions are in wall-surface metres (√((Δalong)² + (Δup)²)). */}
|
||||
{measurement && (() => {
|
||||
const startPx = projectionToPixel(
|
||||
measurement.start.alongWall,
|
||||
measurement.start.fromFloor,
|
||||
wallHeight,
|
||||
effectiveScale,
|
||||
PADDING,
|
||||
);
|
||||
const endPx = projectionToPixel(
|
||||
measurement.end.alongWall,
|
||||
measurement.end.fromFloor,
|
||||
wallHeight,
|
||||
effectiveScale,
|
||||
PADDING,
|
||||
);
|
||||
const dAlong = measurement.end.alongWall - measurement.start.alongWall;
|
||||
const dUp = measurement.end.fromFloor - measurement.start.fromFloor;
|
||||
const distM = Math.hypot(dAlong, dUp);
|
||||
const midX = (startPx.x + endPx.x) / 2;
|
||||
const midY = (startPx.y + endPx.y) / 2;
|
||||
const label = distM >= 1 ? `${distM.toFixed(2)} m` : `${(distM * 100).toFixed(1)} cm`;
|
||||
return (
|
||||
<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
|
||||
.filter((ann) => {
|
||||
if (!ann.attachedToId) return false;
|
||||
@@ -642,7 +865,6 @@ export function WallProjectionView({
|
||||
projectedFurniture.some((pf) => pf.item.id === ann.attachedToId);
|
||||
})
|
||||
.map((ann) => {
|
||||
// Find parent item position in projection coords
|
||||
const elec = projectedElectrical.find((pe) => pe.item.id === ann.attachedToId);
|
||||
const furn = projectedFurniture.find((pf) => pf.item.id === ann.attachedToId);
|
||||
let anchorAlongWall = 0;
|
||||
@@ -655,34 +877,80 @@ export function WallProjectionView({
|
||||
anchorFromFloor = furn.rect.y + furn.rect.height;
|
||||
}
|
||||
const anchorPx = projectionToPixel(anchorAlongWall, anchorFromFloor, wallHeight, effectiveScale, PADDING);
|
||||
// Offset annotation slightly
|
||||
const textX = anchorPx.x + ann.x * effectiveScale;
|
||||
const textY = anchorPx.y + ann.y * effectiveScale;
|
||||
// Use the dedicated projection offset (defaulting to a small offset
|
||||
// up & to the right of the anchor) so dragging in this view does
|
||||
// not corrupt the floor-plan offset stored in ann.x/ann.y.
|
||||
const projOffsetX = ann.projectionOffsetX ?? 0.3;
|
||||
const projOffsetY = ann.projectionOffsetY ?? -0.4;
|
||||
const textX = anchorPx.x + projOffsetX * effectiveScale;
|
||||
const textY = anchorPx.y + projOffsetY * effectiveScale;
|
||||
const isSelected = selectedIds.has(ann.id);
|
||||
const fontSize = ann.fontSize ?? 10;
|
||||
const boxWidth = ann.text.length * (fontSize * 0.6) + 6;
|
||||
const boxHeight = fontSize + 4;
|
||||
// URL detection mirrors the floor-plan AnnotationLayer so links
|
||||
// get the same blue tint and Ctrl/Cmd-click affordance in both
|
||||
// views. Anchored to start/end so "see http://x" stays plain.
|
||||
const annText = ann.text.trim();
|
||||
const isLink = /^https?:\/\/\S+$/i.test(annText);
|
||||
const linkColor = '#2563eb';
|
||||
const textFill = isSelected
|
||||
? '#2563eb'
|
||||
: isLink
|
||||
? (ann.color ?? linkColor)
|
||||
: (ann.color ?? '#334155');
|
||||
return (
|
||||
<Group key={ann.id}>
|
||||
<Line
|
||||
points={[anchorPx.x, anchorPx.y, textX, textY]}
|
||||
stroke="#94a3b8"
|
||||
strokeWidth={0.5}
|
||||
points={[anchorPx.x, anchorPx.y, textX + boxWidth / 2, textY + boxHeight / 2]}
|
||||
stroke={isSelected ? '#2563eb' : '#94a3b8'}
|
||||
strokeWidth={0.6}
|
||||
dash={[2, 2]}
|
||||
listening={false}
|
||||
/>
|
||||
<Rect
|
||||
x={textX - 2}
|
||||
y={textY - 1}
|
||||
width={ann.text.length * 7 + 4}
|
||||
height={12}
|
||||
fill="rgba(255,255,255,0.9)"
|
||||
cornerRadius={2}
|
||||
listening={false}
|
||||
/>
|
||||
<Text
|
||||
<Group
|
||||
x={textX}
|
||||
y={textY}
|
||||
text={ann.text}
|
||||
fontSize={10}
|
||||
fill={ann.color ?? '#334155'}
|
||||
/>
|
||||
draggable={onUpdateAnnotation != null}
|
||||
onClick={(e) => {
|
||||
if (isLink && (e.evt.ctrlKey || e.evt.metaKey)) {
|
||||
e.cancelBubble = true;
|
||||
window.open(annText, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
onSelectElement(ann.id);
|
||||
}}
|
||||
onDblClick={() => onEditAnnotation?.(ann.id)}
|
||||
onDragEnd={(e) => {
|
||||
if (!onUpdateAnnotation) return;
|
||||
// The Group's parent is the pan group, so node.x()/y() are
|
||||
// already in the same coordinate system as anchorPx (no
|
||||
// need to compensate for viewPan).
|
||||
const newOffsetX = (e.target.x() - anchorPx.x) / effectiveScale;
|
||||
const newOffsetY = (e.target.y() - anchorPx.y) / effectiveScale;
|
||||
onUpdateAnnotation({
|
||||
...ann,
|
||||
projectionOffsetX: Math.round(newOffsetX * 1000) / 1000,
|
||||
projectionOffsetY: Math.round(newOffsetY * 1000) / 1000,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
@@ -17,6 +21,40 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.collapseBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
transition: background-color var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.collapseBtn:hover {
|
||||
background-color: var(--color-bg-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.panelCollapsed {
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
background-color: var(--color-bg-elevated);
|
||||
border-left: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: var(--space-6) var(--space-4);
|
||||
text-align: center;
|
||||
|
||||
@@ -1,77 +1,100 @@
|
||||
import { Group, Circle, Line } from 'react-konva';
|
||||
import { Group, Rect, Circle, Line } from 'react-konva';
|
||||
|
||||
interface OutletSymbolProps {
|
||||
/** Screen-space position of the anchor point (already includes pan/zoom + anchor offset). */
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
/** Item rotation in degrees. Applied around the local origin. */
|
||||
readonly rotation: number;
|
||||
readonly color: string;
|
||||
readonly scale: number;
|
||||
/** Number of individual outlets in the group (>= 1). */
|
||||
readonly count: number;
|
||||
/** Physical width of a single outlet face plate, expressed in screen pixels (m * zoom). */
|
||||
readonly outletWidthPx: number;
|
||||
/** Physical height of a single outlet face plate, expressed in screen pixels (m * zoom). */
|
||||
readonly outletHeightPx: number;
|
||||
/**
|
||||
* Local-space offset (pre-rotation) from the (x, y) anchor to the geometric
|
||||
* center of the bounding box. The bounding box has width = count * outletWidthPx
|
||||
* and height = outletHeightPx, arranged horizontally along the local x-axis.
|
||||
*/
|
||||
readonly centerOffsetX: number;
|
||||
readonly centerOffsetY: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* IEC 60617 outlet symbol variants.
|
||||
* Base: circle with two parallel prongs.
|
||||
* Outlet symbol — renders `count` adjacent face plates side-by-side along the
|
||||
* local x-axis (which aligns with the wall when the item is wall-mounted).
|
||||
* Each face plate is drawn as a rounded rectangle with a small "outlet face"
|
||||
* (circle + two prongs) centered inside it. Sized in real-world units so the
|
||||
* boundaries reflect the room's configured outlet dimensions.
|
||||
*/
|
||||
export function OutletSymbol({
|
||||
x,
|
||||
y,
|
||||
rotation,
|
||||
color,
|
||||
count,
|
||||
outletWidthPx,
|
||||
outletHeightPx,
|
||||
centerOffsetX,
|
||||
centerOffsetY,
|
||||
}: OutletSymbolProps) {
|
||||
const safeCount = Math.max(1, Math.round(count));
|
||||
const totalWidth = safeCount * outletWidthPx;
|
||||
// Top-left corner of the bounding box in local space (relative to the anchor).
|
||||
const left = centerOffsetX - totalWidth / 2;
|
||||
const top = centerOffsetY - outletHeightPx / 2;
|
||||
|
||||
/** Single outlet: circle + two vertical prongs. */
|
||||
export function SingleOutletSymbol({ x, y, rotation, color, scale }: OutletSymbolProps) {
|
||||
const r = 8 * scale;
|
||||
const prongLen = 4 * scale;
|
||||
const prongGap = 3 * scale;
|
||||
// Inner outlet face geometry: shrink to fit the smaller dimension of one cell.
|
||||
const cellMin = Math.min(outletWidthPx, outletHeightPx);
|
||||
const faceRadius = cellMin * 0.32;
|
||||
const prongLen = cellMin * 0.18;
|
||||
const prongGap = cellMin * 0.12;
|
||||
|
||||
return (
|
||||
<Group x={x} y={y} rotation={rotation}>
|
||||
<Circle radius={r} stroke={color} strokeWidth={1.5} fill="transparent" listening={false} />
|
||||
<Line points={[-prongGap, -prongLen, -prongGap, prongLen]} stroke={color} strokeWidth={1.5} listening={false} />
|
||||
<Line points={[prongGap, -prongLen, prongGap, prongLen]} stroke={color} strokeWidth={1.5} listening={false} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Double outlet: two overlapping circles + prongs. */
|
||||
export function DoubleOutletSymbol({ x, y, rotation, color, scale }: OutletSymbolProps) {
|
||||
const r = 8 * scale;
|
||||
const offset = 6 * scale;
|
||||
const prongLen = 3 * scale;
|
||||
const prongGap = 2.5 * scale;
|
||||
|
||||
return (
|
||||
<Group x={x} y={y} rotation={rotation}>
|
||||
{/* Left outlet */}
|
||||
<Group x={-offset}>
|
||||
<Circle radius={r} stroke={color} strokeWidth={1.5} fill="transparent" listening={false} />
|
||||
<Line points={[-prongGap, -prongLen, -prongGap, prongLen]} stroke={color} strokeWidth={1.5} listening={false} />
|
||||
<Line points={[prongGap, -prongLen, prongGap, prongLen]} stroke={color} strokeWidth={1.5} listening={false} />
|
||||
</Group>
|
||||
{/* Right outlet */}
|
||||
<Group x={offset}>
|
||||
<Circle radius={r} stroke={color} strokeWidth={1.5} fill="transparent" listening={false} />
|
||||
<Line points={[-prongGap, -prongLen, -prongGap, prongLen]} stroke={color} strokeWidth={1.5} listening={false} />
|
||||
<Line points={[prongGap, -prongLen, prongGap, prongLen]} stroke={color} strokeWidth={1.5} listening={false} />
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Grounded outlet: circle + two prongs + earth symbol (horizontal line + ground lines below). */
|
||||
export function GroundedOutletSymbol({ x, y, rotation, color, scale }: OutletSymbolProps) {
|
||||
const r = 8 * scale;
|
||||
const prongLen = 3 * scale;
|
||||
const prongGap = 3 * scale;
|
||||
const earthY = 5 * scale;
|
||||
const earthW = 4 * scale;
|
||||
|
||||
return (
|
||||
<Group x={x} y={y} rotation={rotation}>
|
||||
<Circle radius={r} stroke={color} strokeWidth={1.5} fill="transparent" listening={false} />
|
||||
{/* Prongs */}
|
||||
<Line points={[-prongGap, -prongLen, -prongGap, prongLen - 1]} stroke={color} strokeWidth={1.5} listening={false} />
|
||||
<Line points={[prongGap, -prongLen, prongGap, prongLen - 1]} stroke={color} strokeWidth={1.5} listening={false} />
|
||||
{/* Earth symbol — vertical line down + three horizontal lines */}
|
||||
<Line points={[0, prongLen, 0, earthY]} stroke={color} strokeWidth={1.5} listening={false} />
|
||||
<Line points={[-earthW, earthY, earthW, earthY]} stroke={color} strokeWidth={1.5} listening={false} />
|
||||
<Line points={[-earthW * 0.6, earthY + 2 * scale, earthW * 0.6, earthY + 2 * scale]} stroke={color} strokeWidth={1.2} listening={false} />
|
||||
<Line points={[-earthW * 0.3, earthY + 4 * scale, earthW * 0.3, earthY + 4 * scale]} stroke={color} strokeWidth={1} listening={false} />
|
||||
<Group x={x} y={y} rotation={rotation} listening={false}>
|
||||
{Array.from({ length: safeCount }).map((_, i) => {
|
||||
const cellLeft = left + i * outletWidthPx;
|
||||
const cellCenterX = cellLeft + outletWidthPx / 2;
|
||||
const cellCenterY = top + outletHeightPx / 2;
|
||||
return (
|
||||
<Group key={i}>
|
||||
<Rect
|
||||
x={cellLeft}
|
||||
y={top}
|
||||
width={outletWidthPx}
|
||||
height={outletHeightPx}
|
||||
cornerRadius={Math.max(1, cellMin * 0.12)}
|
||||
stroke={color}
|
||||
strokeWidth={1.25}
|
||||
fill="rgba(255, 255, 255, 0.55)"
|
||||
listening={false}
|
||||
/>
|
||||
<Circle
|
||||
x={cellCenterX}
|
||||
y={cellCenterY}
|
||||
radius={faceRadius}
|
||||
stroke={color}
|
||||
strokeWidth={1.25}
|
||||
fill="transparent"
|
||||
listening={false}
|
||||
/>
|
||||
<Line
|
||||
points={[cellCenterX - prongGap, cellCenterY - prongLen, cellCenterX - prongGap, cellCenterY + prongLen]}
|
||||
stroke={color}
|
||||
strokeWidth={1.25}
|
||||
listening={false}
|
||||
/>
|
||||
<Line
|
||||
points={[cellCenterX + prongGap, cellCenterY - prongLen, cellCenterX + prongGap, cellCenterY + prongLen]}
|
||||
stroke={color}
|
||||
strokeWidth={1.25}
|
||||
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 { JunctionBoxSymbol } from './JunctionBoxSymbol';
|
||||
export { CeilingLightSymbol } from './CeilingLightSymbol';
|
||||
@@ -21,9 +21,7 @@ export interface ElectricalSymbolDef {
|
||||
}
|
||||
|
||||
export const ELECTRICAL_SYMBOL_DEFS: readonly ElectricalSymbolDef[] = [
|
||||
{ type: 'OUTLET', label: 'Single Outlet', category: 'outlet', wallMounted: true, variant: 'single' },
|
||||
{ type: 'OUTLET', label: 'Double Outlet', category: 'outlet', wallMounted: true, variant: 'double' },
|
||||
{ type: 'OUTLET', label: 'Grounded Outlet', category: 'outlet', wallMounted: true, variant: 'grounded' },
|
||||
{ type: 'OUTLET', label: 'Outlet', category: 'outlet', wallMounted: true },
|
||||
{ type: 'SWITCH', label: 'Single Switch', category: 'switch', wallMounted: true, variant: 'single' },
|
||||
{ type: 'SWITCH', label: 'Double Switch', category: 'switch', wallMounted: true, variant: 'double' },
|
||||
{ type: 'SWITCH', label: 'Dimmer Switch', category: 'switch', wallMounted: true, variant: 'dimmer' },
|
||||
@@ -33,7 +31,7 @@ export const ELECTRICAL_SYMBOL_DEFS: readonly ElectricalSymbolDef[] = [
|
||||
{ type: 'CABLE_ROUTE', label: 'Cable Route', category: 'cable', wallMounted: false },
|
||||
];
|
||||
|
||||
/** Get the variant from an electrical item's metadata. */
|
||||
/** Get the variant from an electrical item's metadata. Used by switches; outlets use `count`. */
|
||||
export function getElectricalVariant(metadata: Record<string, unknown> | null): string {
|
||||
if (metadata && typeof metadata['variant'] === 'string') {
|
||||
return metadata['variant'];
|
||||
|
||||
@@ -9,38 +9,152 @@ export { TvSilhouette } from './TvSilhouette';
|
||||
|
||||
import type { FurnitureType } from '@house-plan-maker/shared';
|
||||
|
||||
/**
|
||||
* Logical grouping for the furniture picker. The category controls which
|
||||
* tab/section an item appears under and is purely a UI concern — the data
|
||||
* model only cares about `type`.
|
||||
*/
|
||||
export const FURNITURE_CATEGORIES = [
|
||||
'sleeping',
|
||||
'seating',
|
||||
'tables',
|
||||
'storage',
|
||||
'electronics',
|
||||
'climate',
|
||||
'decor',
|
||||
] as const;
|
||||
export type FurnitureCategory = (typeof FURNITURE_CATEGORIES)[number];
|
||||
|
||||
/** Default dimensions for each furniture type (width x depth x height in meters). */
|
||||
export interface FurnitureDef {
|
||||
readonly type: FurnitureType;
|
||||
readonly category: FurnitureCategory;
|
||||
readonly label: string;
|
||||
readonly width: number;
|
||||
readonly depth: number;
|
||||
readonly height: number;
|
||||
readonly icon: string;
|
||||
/**
|
||||
* Type-specific default metadata applied when a new item is placed from
|
||||
* this preset. Used for plants to seed `{ variant, flowerColor }`, for
|
||||
* curtains to seed `{ openAmount, fabricColor }` when new presets need
|
||||
* non-default state, etc. The shape is opaque to the picker — readers
|
||||
* on the mesh side look for the keys they care about.
|
||||
*/
|
||||
readonly defaultMetadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const FURNITURE_DEFS: readonly FurnitureDef[] = [
|
||||
{ type: 'BED', label: 'Single Bed', width: 1.0, depth: 2.0, height: 0.5, icon: '\u{1F6CF}' },
|
||||
{ type: 'BED', label: 'Double Bed', width: 1.4, depth: 2.0, height: 0.5, icon: '\u{1F6CF}' },
|
||||
{ type: 'BED', label: 'Queen Bed', width: 1.6, depth: 2.0, height: 0.5, icon: '\u{1F6CF}' },
|
||||
{ type: 'BED', label: 'King Bed', width: 1.8, depth: 2.0, height: 0.5, icon: '\u{1F6CF}' },
|
||||
{ type: 'DESK', label: 'Desk', width: 1.2, depth: 0.6, height: 0.75, icon: '\u{1F4BC}' },
|
||||
{ type: 'WARDROBE', label: 'Wardrobe (S)', width: 1.0, depth: 0.6, height: 2.0, icon: '\u{1F3EA}' },
|
||||
{ type: 'WARDROBE', label: 'Wardrobe (M)', width: 1.5, depth: 0.6, height: 2.0, icon: '\u{1F3EA}' },
|
||||
{ type: 'WARDROBE', label: 'Wardrobe (L)', width: 2.0, depth: 0.6, height: 2.0, icon: '\u{1F3EA}' },
|
||||
{ type: 'SOFA', label: 'Sofa', width: 2.0, depth: 0.9, height: 0.8, icon: '\u{1FA91}' },
|
||||
{ type: 'TABLE', label: 'Dining Table', width: 1.2, depth: 0.8, height: 0.75, icon: '\u{1F37D}' },
|
||||
{ type: 'CHAIR', label: 'Chair', width: 0.45, depth: 0.45, height: 0.85, icon: '\u{1FA91}' },
|
||||
{ type: 'SHELF', label: 'Tall Shelf', width: 0.8, depth: 0.3, height: 1.8, icon: '\u{1F4DA}' },
|
||||
{ type: 'SHELF', label: 'Wall Shelf 60', width: 0.6, depth: 0.25, height: 0.04, icon: '\u{1F4DA}' },
|
||||
{ type: 'SHELF', label: 'Wall Shelf 80', width: 0.8, depth: 0.25, height: 0.04, icon: '\u{1F4DA}' },
|
||||
{ type: 'SHELF', label: 'Wall Shelf 120', width: 1.2, depth: 0.25, height: 0.04, icon: '\u{1F4DA}' },
|
||||
{ type: 'NIGHTSTAND', label: 'Nightstand', width: 0.5, depth: 0.4, height: 0.5, icon: '\u{1F4E6}' },
|
||||
{ type: 'DRESSER', label: 'Dresser', width: 1.0, depth: 0.5, height: 0.8, icon: '\u{1F3EA}' },
|
||||
{ type: 'BOOKCASE', label: 'Bookcase', width: 0.8, depth: 0.3, height: 2.0, icon: '\u{1F4DA}' },
|
||||
{ type: 'TV', label: 'TV 32"', width: 0.73, depth: 0.08, height: 0.43, icon: '\u{1F4FA}' },
|
||||
{ type: 'TV', label: 'TV 43"', width: 0.97, depth: 0.08, height: 0.57, icon: '\u{1F4FA}' },
|
||||
{ type: 'TV', label: 'TV 55"', width: 1.24, depth: 0.08, height: 0.72, icon: '\u{1F4FA}' },
|
||||
{ type: 'TV', label: 'TV 65"', width: 1.46, depth: 0.08, height: 0.84, icon: '\u{1F4FA}' },
|
||||
{ type: 'AC_UNIT', label: 'AC Unit', width: 0.85, depth: 0.2, height: 0.3, icon: '\u{2744}' },
|
||||
{ type: 'BED', category: 'sleeping', label: 'Single Bed', width: 1.0, depth: 2.0, height: 0.5, icon: '\u{1F6CF}' },
|
||||
{ type: 'BED', category: 'sleeping', label: 'Double Bed', width: 1.4, depth: 2.0, height: 0.5, icon: '\u{1F6CF}' },
|
||||
{ type: 'BED', category: 'sleeping', label: 'Queen Bed', width: 1.6, depth: 2.0, height: 0.5, icon: '\u{1F6CF}' },
|
||||
{ type: 'BED', category: 'sleeping', label: 'King Bed', width: 1.8, depth: 2.0, height: 0.5, icon: '\u{1F6CF}' },
|
||||
// Cribs — a baby bed with slatted rails. Standard EU/US sizes: full-size
|
||||
// ~70×130cm and compact ~60×120cm. Total height includes the top rail
|
||||
// (~95cm from floor) which is what the 3D mesh draws the slats up to.
|
||||
{ type: 'CRIB', category: 'sleeping', label: 'Crib (Standard)', width: 0.72, depth: 1.32, height: 0.95, icon: '\u{1F476}' },
|
||||
{ type: 'CRIB', category: 'sleeping', label: 'Crib (Compact)', width: 0.6, depth: 1.2, height: 0.95, icon: '\u{1F476}' },
|
||||
{ type: 'NIGHTSTAND', category: 'sleeping', label: 'Nightstand', width: 0.5, depth: 0.4, height: 0.5, icon: '\u{1F4E6}' },
|
||||
{ type: 'SOFA', category: 'seating', label: 'Sofa', width: 2.0, depth: 0.9, height: 0.8, icon: '\u{1FA91}' },
|
||||
{ type: 'CHAIR', category: 'seating', label: 'Chair', width: 0.45, depth: 0.45, height: 0.85, icon: '\u{1FA91}' },
|
||||
// Office chairs — ergonomic task chair with wheeled 5-star base, gas
|
||||
// lift, padded seat and tall backrest. Total height is top of the
|
||||
// backrest; seat pan sits at ~45% of total height. Three presets:
|
||||
// compact task chair, standard, and tall executive.
|
||||
{ type: 'OFFICE_CHAIR', category: 'seating', label: 'Office Chair', width: 0.6, depth: 0.6, height: 1.05, icon: '\u{1FA91}' },
|
||||
{ type: 'OFFICE_CHAIR', category: 'seating', label: 'Office Chair (Task)', width: 0.55, depth: 0.55, height: 0.95, icon: '\u{1FA91}' },
|
||||
{ type: 'OFFICE_CHAIR', category: 'seating', label: 'Office Chair (Executive)', width: 0.68, depth: 0.68, height: 1.2, icon: '\u{1FA91}' },
|
||||
{ type: 'DESK', category: 'tables', label: 'Desk', width: 1.2, depth: 0.6, height: 0.75, icon: '\u{1F4BC}' },
|
||||
{ type: 'TABLE', category: 'tables', label: 'Dining Table', width: 1.2, depth: 0.8, height: 0.75, icon: '\u{1F37D}' },
|
||||
{ type: 'WARDROBE', category: 'storage', label: 'Wardrobe (S)', width: 1.0, depth: 0.6, height: 2.0, icon: '\u{1F3EA}' },
|
||||
{ type: 'WARDROBE', category: 'storage', label: 'Wardrobe (M)', width: 1.5, depth: 0.6, height: 2.0, icon: '\u{1F3EA}' },
|
||||
{ type: 'WARDROBE', category: 'storage', label: 'Wardrobe (L)', width: 2.0, depth: 0.6, height: 2.0, icon: '\u{1F3EA}' },
|
||||
{ type: 'DRESSER', category: 'storage', label: 'Dresser', width: 1.0, depth: 0.5, height: 0.8, icon: '\u{1F3EA}' },
|
||||
// Dressing table / vanity — desk-like base with an upright mirror. The
|
||||
// height includes the mirror (total ~1.5m) and the mesh derives the desk
|
||||
// slab thickness from `height * 0.45`.
|
||||
{ type: 'DRESSING_TABLE', category: 'storage', label: 'Dressing Table', width: 1.0, depth: 0.4, height: 1.5, icon: '\u{1F484}' },
|
||||
{ type: 'DRESSING_TABLE', category: 'storage', label: 'Dressing Table (L)', width: 1.3, depth: 0.45, height: 1.6, icon: '\u{1F484}' },
|
||||
{ type: 'SHELF', category: 'storage', label: 'Tall Shelf', width: 0.8, depth: 0.3, height: 1.8, icon: '\u{1F4DA}' },
|
||||
{ type: 'SHELF', category: 'storage', label: 'Wall Shelf 60', width: 0.6, depth: 0.25, height: 0.04, icon: '\u{1F4DA}' },
|
||||
{ type: 'SHELF', category: 'storage', label: 'Wall Shelf 80', width: 0.8, depth: 0.25, height: 0.04, icon: '\u{1F4DA}' },
|
||||
{ type: 'SHELF', category: 'storage', label: 'Wall Shelf 120', width: 1.2, depth: 0.25, height: 0.04, icon: '\u{1F4DA}' },
|
||||
{ type: 'BOOKCASE', category: 'storage', label: 'Bookcase', width: 0.8, depth: 0.3, height: 2.0, icon: '\u{1F4DA}' },
|
||||
// Open bookshelves — no back panel, so the shelf contents are
|
||||
// visible from both sides and the unit reads as a room divider.
|
||||
// Row count is user-configurable via the properties panel and
|
||||
// stored in `metadata.shelfRows`. Presets cover common
|
||||
// 3/4/5-cube sizes.
|
||||
{ type: 'BOOKCASE', category: 'storage', label: 'Open Bookshelf 3', width: 0.8, depth: 0.3, height: 1.2, icon: '\u{1F4D6}', defaultMetadata: { shelfRows: 3, hasBackPanel: false } },
|
||||
{ type: 'BOOKCASE', category: 'storage', label: 'Open Bookshelf 4', width: 0.8, depth: 0.3, height: 1.6, icon: '\u{1F4D6}', defaultMetadata: { shelfRows: 4, hasBackPanel: false } },
|
||||
{ type: 'BOOKCASE', category: 'storage', label: 'Open Bookshelf 5', width: 0.8, depth: 0.3, height: 2.0, icon: '\u{1F4D6}', defaultMetadata: { shelfRows: 5, hasBackPanel: false } },
|
||||
{ type: 'BOOKCASE', category: 'storage', label: 'Open Bookshelf (Wide)', width: 1.6, depth: 0.3, height: 2.0, icon: '\u{1F4D6}', defaultMetadata: { shelfRows: 5, hasBackPanel: false } },
|
||||
{ type: 'TV', category: 'electronics', label: 'TV 32"', width: 0.73, depth: 0.08, height: 0.43, icon: '\u{1F4FA}' },
|
||||
{ type: 'TV', category: 'electronics', label: 'TV 43"', width: 0.97, depth: 0.08, height: 0.57, icon: '\u{1F4FA}' },
|
||||
{ type: 'TV', category: 'electronics', label: 'TV 55"', width: 1.24, depth: 0.08, height: 0.72, icon: '\u{1F4FA}' },
|
||||
{ type: 'TV', category: 'electronics', label: 'TV 65"', width: 1.46, depth: 0.08, height: 0.84, icon: '\u{1F4FA}' },
|
||||
// Digital pianos. Width ≈ number of keys × ~2.4cm + 5cm for chassis
|
||||
// sides; total height includes an X-frame stand when `hasStand` is on.
|
||||
// Default stand height brings the chassis to ~72cm (standard playing
|
||||
// height); turn the stand off in the properties panel to place the
|
||||
// piano on a table. 88-key is the full-range preset; 76 and 61 are
|
||||
// common portable sizes.
|
||||
{ type: 'DIGITAL_PIANO', category: 'electronics', label: 'Digital Piano 88', width: 1.3, depth: 0.32, height: 0.85, icon: '\u{1F3B9}', defaultMetadata: { hasStand: true } },
|
||||
{ type: 'DIGITAL_PIANO', category: 'electronics', label: 'Digital Piano 76', width: 1.15, depth: 0.3, height: 0.85, icon: '\u{1F3B9}', defaultMetadata: { hasStand: true } },
|
||||
{ type: 'DIGITAL_PIANO', category: 'electronics', label: 'Digital Piano 61', width: 0.95, depth: 0.28, height: 0.85, icon: '\u{1F3B9}', defaultMetadata: { hasStand: true } },
|
||||
// Speakers — compact shelf monitors and tall floor-standing towers.
|
||||
// Shelf dimensions are typical 2-way bookshelf (~20×25×35cm); floor
|
||||
// towers are 3-way with a deeper cabinet and ~1m height.
|
||||
{ type: 'SPEAKER', category: 'electronics', label: 'Shelf Speaker (S)', width: 0.18, depth: 0.22, height: 0.3, icon: '\u{1F50A}', defaultMetadata: { variant: 'shelf' } },
|
||||
{ type: 'SPEAKER', category: 'electronics', label: 'Shelf Speaker (M)', width: 0.22, depth: 0.28, height: 0.38, icon: '\u{1F50A}', defaultMetadata: { variant: 'shelf' } },
|
||||
{ type: 'SPEAKER', category: 'electronics', label: 'Shelf Speaker (L)', width: 0.26, depth: 0.32, height: 0.44, icon: '\u{1F50A}', defaultMetadata: { variant: 'shelf' } },
|
||||
{ type: 'SPEAKER', category: 'electronics', label: 'Floor Speaker', width: 0.25, depth: 0.35, height: 1.0, icon: '\u{1F50A}', defaultMetadata: { variant: 'floor' } },
|
||||
{ type: 'SPEAKER', category: 'electronics', label: 'Floor Speaker (L)', width: 0.3, depth: 0.4, height: 1.2, icon: '\u{1F50A}', defaultMetadata: { variant: 'floor' } },
|
||||
// PC tower / desktop case. Dimensions match typical ATX mid-tower (~20cm
|
||||
// wide, ~45cm deep, ~45cm tall) and mini-tower variants. Sits on the
|
||||
// floor or under a desk — default elevation 0.
|
||||
{ type: 'PC_TOWER', category: 'electronics', label: 'PC Tower (Mid)', width: 0.2, depth: 0.45, height: 0.45, icon: '\u{1F5A5}' },
|
||||
{ type: 'PC_TOWER', category: 'electronics', label: 'PC Tower (Full)', width: 0.22, depth: 0.5, height: 0.55, icon: '\u{1F5A5}' },
|
||||
{ type: 'PC_TOWER', category: 'electronics', label: 'PC Tower (Mini)', width: 0.18, depth: 0.38, height: 0.38, icon: '\u{1F5A5}' },
|
||||
{ type: 'AC_UNIT', category: 'climate', label: 'AC Unit', width: 0.85, depth: 0.2, height: 0.3, icon: '\u{2744}' },
|
||||
// Standard panel-type room radiators. Width varies by section count;
|
||||
// the 3D mesh derives the fin count from the width so the visual
|
||||
// automatically tracks the chosen size. Depth is fairly thin (~10cm)
|
||||
// and height matches typical 50cm under-window or 60cm wall units.
|
||||
{ type: 'RADIATOR', category: 'climate', label: 'Radiator 60', width: 0.6, depth: 0.1, height: 0.5, icon: '\u{1F525}' },
|
||||
{ type: 'RADIATOR', category: 'climate', label: 'Radiator 100', width: 1.0, depth: 0.1, height: 0.5, icon: '\u{1F525}' },
|
||||
{ type: 'RADIATOR', category: 'climate', label: 'Radiator 140', width: 1.4, depth: 0.1, height: 0.5, icon: '\u{1F525}' },
|
||||
// Wall photo collage — flat decorative panel mounted on a wall. Default
|
||||
// elevation 1.4m (eye level) is set in FurnitureTool when placed.
|
||||
{ type: 'WALL_COLLAGE', category: 'decor', label: 'Wall Collage 3x2', width: 1.0, depth: 0.02, height: 0.7, icon: '\u{1F5BC}' },
|
||||
{ type: 'WALL_COLLAGE', category: 'decor', label: 'Wall Collage 4x3', width: 1.4, depth: 0.02, height: 1.0, icon: '\u{1F5BC}' },
|
||||
{ type: 'WALL_COLLAGE', category: 'decor', label: 'Wall Collage 5x2', width: 1.5, depth: 0.02, height: 0.6, icon: '\u{1F5BC}' },
|
||||
// Curtains — pleated fabric hanging from a rod. Height defaults to a
|
||||
// typical 2.2m window drop; width varies by window size. Depth includes
|
||||
// the pleat bulge (~10cm) so the item doesn't clip into the wall.
|
||||
{ type: 'CURTAIN', category: 'decor', label: 'Curtain 120', width: 1.2, depth: 0.1, height: 2.2, icon: '\u{1FA9F}' },
|
||||
{ type: 'CURTAIN', category: 'decor', label: 'Curtain 160', width: 1.6, depth: 0.1, height: 2.2, icon: '\u{1FA9F}' },
|
||||
{ type: 'CURTAIN', category: 'decor', label: 'Curtain 200', width: 2.0, depth: 0.1, height: 2.2, icon: '\u{1FA9F}' },
|
||||
// Plants — terracotta pot + foliage. Variant controls the mesh style:
|
||||
// - `bush` → spherical leafy top (default if variant omitted)
|
||||
// - `tall` → trunk + canopy (ficus / indoor tree)
|
||||
// - `flower` → coloured blossom on a short stem
|
||||
// flowerColor is used only by the flower variant.
|
||||
{ type: 'PLANT', category: 'decor', label: 'Potted Plant (S)', width: 0.22, depth: 0.22, height: 0.45, icon: '\u{1FAB4}', defaultMetadata: { variant: 'bush' } },
|
||||
{ type: 'PLANT', category: 'decor', label: 'Potted Plant (M)', width: 0.3, depth: 0.3, height: 0.7, icon: '\u{1FAB4}', defaultMetadata: { variant: 'bush' } },
|
||||
{ type: 'PLANT', category: 'decor', label: 'Floor Plant (Ficus)', width: 0.4, depth: 0.4, height: 1.5, icon: '\u{1F333}', defaultMetadata: { variant: 'tall' } },
|
||||
{ type: 'PLANT', category: 'decor', label: 'Floor Plant (Tall)', width: 0.45, depth: 0.45, height: 1.8, icon: '\u{1F333}', defaultMetadata: { variant: 'tall' } },
|
||||
{ type: 'PLANT', category: 'decor', label: 'Pink Flower', width: 0.2, depth: 0.2, height: 0.4, icon: '\u{1F337}', defaultMetadata: { variant: 'flower', flowerColor: '#e05570' } },
|
||||
{ type: 'PLANT', category: 'decor', label: 'Yellow Flower', width: 0.2, depth: 0.2, height: 0.4, icon: '\u{1F33B}', defaultMetadata: { variant: 'flower', flowerColor: '#f0c040' } },
|
||||
{ type: 'PLANT', category: 'decor', label: 'Red Flower', width: 0.2, depth: 0.2, height: 0.4, icon: '\u{1F339}', defaultMetadata: { variant: 'flower', flowerColor: '#c8394a' } },
|
||||
{ type: 'PLANT', category: 'decor', label: 'White Flower', width: 0.2, depth: 0.2, height: 0.4, icon: '\u{1F33C}', defaultMetadata: { variant: 'flower', flowerColor: '#f4f4ee' } },
|
||||
// Mirrors — framed reflective panels. Two variants:
|
||||
// - wall: thin panel, wall-mounted, default elevation ~1.2m (eye level)
|
||||
// - floor: full-length with an A-frame stand, sits on the floor
|
||||
// Depth reflects the physical thickness including any stand legs.
|
||||
{ type: 'MIRROR', category: 'decor', label: 'Wall Mirror (S)', width: 0.6, depth: 0.04, height: 0.9, icon: '\u{1FA9E}', defaultMetadata: { variant: 'wall' } },
|
||||
{ type: 'MIRROR', category: 'decor', label: 'Wall Mirror (M)', width: 0.8, depth: 0.04, height: 1.2, icon: '\u{1FA9E}', defaultMetadata: { variant: 'wall' } },
|
||||
{ type: 'MIRROR', category: 'decor', label: 'Wall Mirror (L)', width: 1.0, depth: 0.04, height: 1.5, icon: '\u{1FA9E}', defaultMetadata: { variant: 'wall' } },
|
||||
{ type: 'MIRROR', category: 'decor', label: 'Floor Mirror', width: 0.5, depth: 0.4, height: 1.6, icon: '\u{1FA9E}', defaultMetadata: { variant: 'floor' } },
|
||||
{ type: 'MIRROR', category: 'decor', label: 'Floor Mirror (L)', width: 0.6, depth: 0.5, height: 1.8, icon: '\u{1FA9E}', defaultMetadata: { variant: 'floor' } },
|
||||
];
|
||||
|
||||
@@ -1,8 +1,66 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useThree } from '@react-three/fiber';
|
||||
import { useFrame, useThree } from '@react-three/fiber';
|
||||
import { OrbitControls } from '@react-three/drei';
|
||||
import type { Point } from '@house-plan-maker/shared';
|
||||
import { boundingBox } from '../utils/geometry';
|
||||
import styles from './camera-view-cube.module.css';
|
||||
|
||||
export interface CameraOrientation {
|
||||
/** Rotation around the world Y axis, in degrees. 0 = looking south (toward +Z). */
|
||||
readonly yawDeg: number;
|
||||
/** Rotation around the world X axis, in degrees. Negative = looking down. */
|
||||
readonly pitchDeg: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* CameraOrientationTracker reports the live camera direction (yaw + pitch)
|
||||
* to the parent so the view-cube widget can mirror it. Uses useFrame so we
|
||||
* pick up OrbitControls damping without subscribing to its onChange event.
|
||||
*/
|
||||
export function CameraOrientationTracker({
|
||||
onChange,
|
||||
}: {
|
||||
readonly onChange: (orientation: CameraOrientation) => void;
|
||||
}) {
|
||||
const { camera, controls } = useThree() as unknown as {
|
||||
camera: import('three').Camera;
|
||||
controls?: { target?: { x: number; y: number; z: number } };
|
||||
};
|
||||
const lastReportRef = useRef<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 {
|
||||
readonly shape: readonly Point[];
|
||||
@@ -64,48 +122,97 @@ function computePresets(
|
||||
export function CameraPresetsUI({
|
||||
shape: _shape,
|
||||
wallHeight: _wallHeight,
|
||||
orientation,
|
||||
onPreset,
|
||||
}: {
|
||||
readonly shape: readonly Point[];
|
||||
readonly wallHeight: number;
|
||||
readonly orientation?: CameraOrientation;
|
||||
readonly onPreset: (preset: CameraPreset) => void;
|
||||
}) {
|
||||
const presetLabels: readonly { key: CameraPreset; label: string }[] = [
|
||||
{ key: 'birds-eye', label: 'Bird\'s Eye' },
|
||||
{ key: 'eye-level', label: 'Eye Level' },
|
||||
{ key: 'corner-ne', label: 'NE Corner' },
|
||||
{ key: 'corner-nw', label: 'NW Corner' },
|
||||
{ key: 'corner-se', label: 'SE Corner' },
|
||||
{ key: 'corner-sw', label: 'SW Corner' },
|
||||
];
|
||||
// A small CSS-3D "view cube" widget. Top face = bird's eye, front face =
|
||||
// eye level, and the four corner pins map to the corner presets.
|
||||
// The cube rotates live with the Three.js camera so it doubles as an
|
||||
// orientation indicator.
|
||||
//
|
||||
// Mapping: a camera looking down at the scene gives pitch ≈ -90° and we
|
||||
// want the cube to show its top. The cube's `rotateX` flips the top face
|
||||
// toward the viewer when positive, so we use -pitch. Yaw maps directly so
|
||||
// turning the camera right rotates the cube right.
|
||||
const cubeRotateX = orientation ? -orientation.pitchDeg : -26;
|
||||
const cubeRotateY = orientation ? orientation.yawDeg : -32;
|
||||
const cubeStyle = {
|
||||
transform: `rotateX(${cubeRotateX}deg) rotateY(${cubeRotateY}deg)`,
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
zIndex: 10,
|
||||
}}>
|
||||
{presetLabels.map(({ key, label }) => (
|
||||
<div className={styles.wrapper} aria-label="Camera view selector">
|
||||
<div className={styles.scene}>
|
||||
<div className={styles.cube} style={cubeStyle}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.face} ${styles.faceTop}`}
|
||||
onClick={() => onPreset('birds-eye')}
|
||||
title="Bird's Eye"
|
||||
>
|
||||
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
|
||||
key={key}
|
||||
onClick={() => onPreset(key)}
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
fontSize: '12px',
|
||||
background: '#fff',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
type="button"
|
||||
className={`${styles.corner} ${styles.cornerNW}`}
|
||||
onClick={() => onPreset('corner-nw')}
|
||||
title="NW Corner"
|
||||
aria-label="NW Corner"
|
||||
>
|
||||
{label}
|
||||
NW
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,17 +10,24 @@ interface DoorOpeningProps {
|
||||
readonly wall: Wall;
|
||||
readonly isSelected: boolean;
|
||||
readonly onSelect?: (id: string) => void;
|
||||
/**
|
||||
* When true, render the door panel ajar at ~30° to indicate swing
|
||||
* direction. When false, the panel sits flush with the frame (closed).
|
||||
* Toggled globally via the 3D view's HUD; not persisted on the opening.
|
||||
*/
|
||||
readonly isOpen?: boolean;
|
||||
}
|
||||
|
||||
const FRAME_COLOR = '#8b7355';
|
||||
const DOOR_PANEL_COLOR = '#a0522d';
|
||||
const FRAME_THICKNESS = 0.03;
|
||||
/** Fallback frame thickness for legacy openings without an explicit value. */
|
||||
const DEFAULT_FRAME_THICKNESS = 0.03;
|
||||
const DOOR_PANEL_THICKNESS = 0.04;
|
||||
|
||||
/** Angle (radians) the door panel is shown ajar, to indicate swing direction. */
|
||||
/** Angle (radians) the door panel is shown ajar when "open" mode is on. */
|
||||
const DOOR_AJAR_ANGLE = Math.PI / 6; // 30 degrees
|
||||
|
||||
export function DoorOpening({ opening, wall, isSelected, onSelect }: DoorOpeningProps) {
|
||||
export function DoorOpening({ opening, wall, isSelected, onSelect, isOpen = true }: DoorOpeningProps) {
|
||||
const rotY = useMemo(() => wallRotationY(wall), [wall]);
|
||||
|
||||
const [cx, cz] = useMemo(
|
||||
@@ -31,23 +38,28 @@ export function DoorOpening({ opening, wall, isSelected, onSelect }: DoorOpening
|
||||
const frameColor = isSelected ? '#6fa8dc' : FRAME_COLOR;
|
||||
const halfWidth = opening.width / 2;
|
||||
const halfThick = wall.thickness / 2 + 0.005;
|
||||
// Per-opening frame thickness with a sane fallback for old data.
|
||||
const frameThickness = Math.max(0, opening.frameThickness ?? DEFAULT_FRAME_THICKNESS);
|
||||
|
||||
// Door panel rotation based on open direction
|
||||
const openDir = opening.openDirection ?? 'LEFT';
|
||||
const isRight = openDir === 'RIGHT';
|
||||
const isInward = openDir === 'INWARD';
|
||||
|
||||
// Effective ajar angle is 0 when the door is shown closed.
|
||||
const ajarAngle = isOpen ? DOOR_AJAR_ANGLE : 0;
|
||||
|
||||
// Hinge position along the X axis (local frame coordinates)
|
||||
const hingeX = isRight ? halfWidth : -halfWidth;
|
||||
// Swing angle sign: inward swings in +Z, others swing in -Z
|
||||
const swingSign = isInward ? 1 : -1;
|
||||
const panelRotY = swingSign * DOOR_AJAR_ANGLE * (isRight ? -1 : 1);
|
||||
const panelRotY = swingSign * ajarAngle * (isRight ? -1 : 1);
|
||||
// Panel center offset from hinge (half the door width along local X after rotation)
|
||||
const panelHalfW = opening.width / 2;
|
||||
const panelOffsetX = isRight
|
||||
? -panelHalfW * Math.cos(DOOR_AJAR_ANGLE)
|
||||
: panelHalfW * Math.cos(DOOR_AJAR_ANGLE);
|
||||
const panelOffsetZ = swingSign * panelHalfW * Math.sin(DOOR_AJAR_ANGLE);
|
||||
? -panelHalfW * Math.cos(ajarAngle)
|
||||
: panelHalfW * Math.cos(ajarAngle);
|
||||
const panelOffsetZ = swingSign * panelHalfW * Math.sin(ajarAngle);
|
||||
|
||||
return (
|
||||
<group
|
||||
@@ -57,19 +69,19 @@ export function DoorOpening({ opening, wall, isSelected, onSelect }: DoorOpening
|
||||
>
|
||||
{/* Left frame post */}
|
||||
<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} />
|
||||
</mesh>
|
||||
|
||||
{/* Right frame post */}
|
||||
<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} />
|
||||
</mesh>
|
||||
|
||||
{/* Top frame bar (lintel) */}
|
||||
<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} />
|
||||
</mesh>
|
||||
|
||||
@@ -79,7 +91,7 @@ export function DoorOpening({ opening, wall, isSelected, onSelect }: DoorOpening
|
||||
rotation={[0, panelRotY, 0]}
|
||||
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} />
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import type { ElectricalItem, ElectricalType, Wall } from '@house-plan-maker/shared';
|
||||
import type { ElectricalItem, ElectricalType, Point, Wall } from '@house-plan-maker/shared';
|
||||
import { wallRotationY, positionAlongWall3D, wallVector, wallNormal } from './utils/wallGeometry';
|
||||
|
||||
interface ElectricalMeshWithHeightProps {
|
||||
@@ -9,6 +9,36 @@ interface ElectricalMeshWithHeightProps {
|
||||
readonly wallHeight: number;
|
||||
readonly isSelected: boolean;
|
||||
readonly onSelect?: (id: string) => void;
|
||||
/** Physical width of a single outlet face plate (meters). */
|
||||
readonly outletWidth: number;
|
||||
/** Physical height of a single outlet face plate (meters). */
|
||||
readonly outletHeight: number;
|
||||
/**
|
||||
* Centroid of the room polygon (2D editor coords). Used to pick the
|
||||
* inward-pointing side of a wall so wall-mounted items (outlets,
|
||||
* switches, wall lights) sit on the room-facing face regardless of
|
||||
* which direction `wallNormal` happens to return.
|
||||
*/
|
||||
readonly roomCentroid?: Point;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the local-space offset from the anchored origin (item.x, elevation,
|
||||
* item.y projected onto wall) to the geometric center of the outlet group's
|
||||
* bounding box. Local axes: +x along wall, +y vertical (up).
|
||||
*/
|
||||
function outletAnchorOffset(
|
||||
item: ElectricalItem,
|
||||
outletWidth: number,
|
||||
outletHeight: number,
|
||||
): { readonly cx: number; readonly cy: number } {
|
||||
const totalWidth = Math.max(1, item.count) * outletWidth;
|
||||
const h = item.positionAnchor.horizontal;
|
||||
const v = item.positionAnchor.vertical;
|
||||
const cx = h === 'left' ? totalWidth / 2 : h === 'right' ? -totalWidth / 2 : 0;
|
||||
// Note: in 3D +y is up, so 'top' anchor means the center is BELOW (negative y).
|
||||
const cy = v === 'top' ? -outletHeight / 2 : v === 'bottom' ? outletHeight / 2 : 0;
|
||||
return { cx, cy };
|
||||
}
|
||||
|
||||
const ELECTRICAL_COLORS: Record<ElectricalType, string> = {
|
||||
@@ -32,13 +62,43 @@ function findWallInMap(wallId: string | null, wallMap: ReadonlyMap<string, Wall>
|
||||
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 (
|
||||
<mesh castShadow>
|
||||
<boxGeometry args={[0.08, 0.08, 0.02]} />
|
||||
<meshStandardMaterial color={color} roughness={0.3} />
|
||||
</mesh>
|
||||
<group position={[centerX, centerY, 0]}>
|
||||
{Array.from({ length: safeCount }).map((_, i) => {
|
||||
// Center index 0..N-1 around 0 along local x.
|
||||
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(
|
||||
item: ElectricalItem,
|
||||
wall: Wall | null,
|
||||
roomCentroid: Point | null,
|
||||
): [number, number, number] {
|
||||
return useMemo<[number, number, number]>(() => {
|
||||
if (item.type === 'LIGHT_CEILING') {
|
||||
@@ -146,14 +207,33 @@ function useElectricalPosition(
|
||||
const t = length > 0 ? (dx * wallDx + dy * wallDy) / (length * length) : 0;
|
||||
const clampedT = Math.max(0, Math.min(1, t));
|
||||
const [wx, wz] = positionAlongWall3D(wall, clampedT * length);
|
||||
const offset = wall.thickness / 2 + 0.015;
|
||||
|
||||
// The wall's stored line is its INNER face (WallMesh shifts the box
|
||||
// outward by thickness/2 so the inner face sits on the polygon edge).
|
||||
// We want the outlet flush against that inner face, protruding a
|
||||
// small gap (~1.5cm) INTO the room. `wallNormal` returns one of the
|
||||
// two perpendiculars without knowing which side is the room interior;
|
||||
// use the centroid to pick the inward-pointing sign. This is the same
|
||||
// trick WallMesh uses to decide its outward shift.
|
||||
let inwardSign = 1;
|
||||
if (roomCentroid) {
|
||||
const midX = (wall.startX + wall.endX) / 2;
|
||||
const midY = (wall.startY + wall.endY) / 2;
|
||||
// Vector from wall midpoint to centroid points INTO the room.
|
||||
const toCentroidX = roomCentroid.x - midX;
|
||||
const toCentroidY = roomCentroid.y - midY;
|
||||
// Positive dot product → normal already points inward; otherwise flip.
|
||||
inwardSign = nx * toCentroidX + ny * toCentroidY >= 0 ? 1 : -1;
|
||||
}
|
||||
|
||||
const offset = 0.015 * inwardSign;
|
||||
const elevation = item.elevationFromFloor ?? 1.2;
|
||||
return [wx + nx * offset, elevation, wz + ny * offset];
|
||||
}
|
||||
|
||||
const elevation = item.elevationFromFloor ?? 0.3;
|
||||
return [item.x, elevation, item.y];
|
||||
}, [item, wall]);
|
||||
}, [item, wall, roomCentroid]);
|
||||
}
|
||||
|
||||
export function ElectricalMeshWithHeight({
|
||||
@@ -162,6 +242,9 @@ export function ElectricalMeshWithHeight({
|
||||
wallHeight,
|
||||
isSelected,
|
||||
onSelect,
|
||||
outletWidth,
|
||||
outletHeight,
|
||||
roomCentroid,
|
||||
}: ElectricalMeshWithHeightProps) {
|
||||
const color = isSelected ? SELECTED_COLOR : ELECTRICAL_COLORS[item.type];
|
||||
const wall = useMemo(() => {
|
||||
@@ -190,7 +273,7 @@ export function ElectricalMeshWithHeight({
|
||||
}
|
||||
return null;
|
||||
}, [item, wallMap]);
|
||||
const position = useElectricalPosition(item, wall);
|
||||
const position = useElectricalPosition(item, wall, roomCentroid ?? null);
|
||||
|
||||
const rotY = useMemo(() => {
|
||||
if (wall) return wallRotationY(wall);
|
||||
@@ -203,7 +286,19 @@ export function ElectricalMeshWithHeight({
|
||||
rotation={[0, rotY, 0]}
|
||||
onClick={onSelect ? (e) => { e.stopPropagation(); onSelect(item.id); } : undefined}
|
||||
>
|
||||
{item.type === 'OUTLET' && <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 === 'JUNCTION_BOX' && <JunctionBoxMesh color={color} />}
|
||||
{item.type === 'LIGHT_CEILING' && <CeilingLightMesh color={color} wallHeight={wallHeight} />}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import type { Point, FloorType } from '@house-plan-maker/shared';
|
||||
import { getFloorPbr } from './utils/pbrTextures';
|
||||
|
||||
interface FloorCeilingProps {
|
||||
readonly shape: readonly Point[];
|
||||
@@ -8,7 +9,20 @@ interface FloorCeilingProps {
|
||||
readonly floorType?: FloorType;
|
||||
}
|
||||
|
||||
function createPolygonGeometry(shape: readonly Point[]): THREE.ShapeGeometry | null {
|
||||
/**
|
||||
* Build a ShapeGeometry from the room polygon and rescale its UVs so the
|
||||
* floor's PBR texture tiles every `tileMeters` instead of stretching once
|
||||
* over the full room. ShapeGeometry's default UVs are the shape coordinates
|
||||
* directly (in meters), so dividing by `tileMeters` yields a UV that
|
||||
* advances by 1.0 every `tileMeters` along each axis — exactly what
|
||||
* `wrapS/wrapT = RepeatWrapping` needs to tile the texture.
|
||||
*
|
||||
* This puts the tiling on the geometry side, which means a single shared
|
||||
* material instance can be reused across rooms of different sizes without
|
||||
* mutating the material's texture.repeat (which would affect every mesh
|
||||
* sharing the material).
|
||||
*/
|
||||
function buildFloorGeometry(shape: readonly Point[], tileMeters: number): THREE.ShapeGeometry | null {
|
||||
if (shape.length < 3) return null;
|
||||
|
||||
const threeShape = new THREE.Shape();
|
||||
@@ -18,178 +32,39 @@ function createPolygonGeometry(shape: readonly Point[]): THREE.ShapeGeometry | n
|
||||
}
|
||||
threeShape.closePath();
|
||||
|
||||
return new THREE.ShapeGeometry(threeShape);
|
||||
}
|
||||
const geometry = new THREE.ShapeGeometry(threeShape);
|
||||
|
||||
/** Generate a procedural floor texture on a canvas. */
|
||||
function createFloorTexture(floorType: FloorType): THREE.CanvasTexture {
|
||||
const size = 512;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
switch (floorType) {
|
||||
case 'WOOD_LIGHT':
|
||||
drawWoodPlanks(ctx, size, '#d4b896', '#c4a87a', '#b89868');
|
||||
break;
|
||||
case 'WOOD_MEDIUM':
|
||||
drawWoodPlanks(ctx, size, '#a07850', '#8c6840', '#785830');
|
||||
break;
|
||||
case 'WOOD_DARK':
|
||||
drawWoodPlanks(ctx, size, '#5c3c28', '#4c3020', '#3c2418');
|
||||
break;
|
||||
case 'WOOD_HERRINGBONE':
|
||||
drawHerringbone(ctx, size, '#b08860', '#9a7850', '#8a6840');
|
||||
break;
|
||||
case 'TILE_WHITE':
|
||||
drawTiles(ctx, size, '#f0f0f0', '#e0e0e0', '#d8d8d8');
|
||||
break;
|
||||
case 'TILE_GRAY':
|
||||
drawTiles(ctx, size, '#a0a0a0', '#909090', '#888888');
|
||||
break;
|
||||
case 'LAMINATE':
|
||||
drawWoodPlanks(ctx, size, '#c8b090', '#b8a080', '#a89070');
|
||||
break;
|
||||
case 'CONCRETE':
|
||||
default:
|
||||
drawConcrete(ctx, size);
|
||||
break;
|
||||
}
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.wrapS = THREE.RepeatWrapping;
|
||||
texture.wrapT = THREE.RepeatWrapping;
|
||||
texture.repeat.set(2, 2);
|
||||
return texture;
|
||||
}
|
||||
|
||||
function drawWoodPlanks(ctx: CanvasRenderingContext2D, size: number, c1: string, c2: string, c3: string) {
|
||||
const plankHeight = size / 6;
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const y = i * plankHeight;
|
||||
// Alternate plank colors
|
||||
ctx.fillStyle = i % 2 === 0 ? c1 : c2;
|
||||
ctx.fillRect(0, y, size, plankHeight);
|
||||
|
||||
// Grain lines
|
||||
ctx.strokeStyle = c3;
|
||||
ctx.lineWidth = 0.5;
|
||||
for (let g = 0; g < 8; g++) {
|
||||
const gy = y + Math.random() * plankHeight;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, gy);
|
||||
ctx.bezierCurveTo(
|
||||
size * 0.3, gy + (Math.random() - 0.5) * 3,
|
||||
size * 0.7, gy + (Math.random() - 0.5) * 3,
|
||||
size, gy,
|
||||
);
|
||||
ctx.stroke();
|
||||
// Rescale UVs in place. Default ShapeGeometry UVs equal (x, y) coordinates
|
||||
// in meters; dividing by tileMeters gives the desired tile density.
|
||||
const uv = geometry.attributes.uv;
|
||||
if (uv) {
|
||||
const scale = 1 / tileMeters;
|
||||
for (let i = 0; i < uv.count; i++) {
|
||||
uv.setXY(i, uv.getX(i) * scale, uv.getY(i) * scale);
|
||||
}
|
||||
|
||||
// Plank gap
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.15)';
|
||||
ctx.fillRect(0, y, size, 1);
|
||||
|
||||
// Vertical joint (staggered)
|
||||
const jointX = (i % 2 === 0) ? size * 0.4 : size * 0.7;
|
||||
ctx.fillRect(jointX, y, 1, plankHeight);
|
||||
uv.needsUpdate = true;
|
||||
}
|
||||
|
||||
return geometry;
|
||||
}
|
||||
|
||||
function drawHerringbone(ctx: CanvasRenderingContext2D, size: number, c1: string, c2: string, c3: string) {
|
||||
ctx.fillStyle = c3;
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
export function FloorCeiling({ shape, wallHeight: _wallHeight, floorType = 'CONCRETE' }: FloorCeilingProps) {
|
||||
const pbr = useMemo(() => getFloorPbr(floorType), [floorType]);
|
||||
const geometry = useMemo(
|
||||
() => buildFloorGeometry(shape, pbr.tileMeters),
|
||||
[shape, pbr.tileMeters],
|
||||
);
|
||||
|
||||
const plankW = size / 4;
|
||||
const plankH = size / 8;
|
||||
|
||||
for (let row = -2; row < size / plankH + 2; row++) {
|
||||
for (let col = -2; col < size / plankW + 2; col++) {
|
||||
const isEven = (row + col) % 2 === 0;
|
||||
ctx.fillStyle = isEven ? c1 : c2;
|
||||
|
||||
ctx.save();
|
||||
const cx = col * plankW;
|
||||
const cy = row * plankH;
|
||||
ctx.translate(cx + plankW / 2, cy + plankH / 2);
|
||||
ctx.rotate(isEven ? Math.PI / 4 : -Math.PI / 4);
|
||||
ctx.fillRect(-plankW / 2, -plankH / 4, plankW, plankH / 2);
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.1)';
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.strokeRect(-plankW / 2, -plankH / 4, plankW, plankH / 2);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawTiles(ctx: CanvasRenderingContext2D, size: number, c1: string, c2: string, grout: string) {
|
||||
const tileSize = size / 4;
|
||||
|
||||
// Grout background
|
||||
ctx.fillStyle = grout;
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
|
||||
const groutWidth = 3;
|
||||
|
||||
for (let row = 0; row < 4; row++) {
|
||||
for (let col = 0; col < 4; col++) {
|
||||
ctx.fillStyle = (row + col) % 2 === 0 ? c1 : c2;
|
||||
ctx.fillRect(
|
||||
col * tileSize + groutWidth / 2,
|
||||
row * tileSize + groutWidth / 2,
|
||||
tileSize - groutWidth,
|
||||
tileSize - groutWidth,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawConcrete(ctx: CanvasRenderingContext2D, size: number) {
|
||||
ctx.fillStyle = '#d4d0cc';
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
|
||||
// Noise texture
|
||||
for (let i = 0; i < 5000; i++) {
|
||||
const x = Math.random() * size;
|
||||
const y = Math.random() * size;
|
||||
const brightness = 180 + Math.random() * 40;
|
||||
ctx.fillStyle = `rgb(${brightness},${brightness - 5},${brightness - 10})`;
|
||||
ctx.fillRect(x, y, 2, 2);
|
||||
}
|
||||
}
|
||||
|
||||
const textureCache = new Map<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;
|
||||
if (!geometry) return null;
|
||||
|
||||
return (
|
||||
<mesh
|
||||
geometry={floorGeometry}
|
||||
geometry={geometry}
|
||||
material={pbr.material}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
scale={[1, -1, 1]}
|
||||
position={[0, 0, 0]}
|
||||
receiveShadow
|
||||
>
|
||||
<meshStandardMaterial
|
||||
map={texture}
|
||||
side={THREE.DoubleSide}
|
||||
roughness={0.8}
|
||||
/>
|
||||
</mesh>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useMemo } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import type { Wall, WallOpening } from '@house-plan-maker/shared';
|
||||
import type { Point, Wall, WallOpening } from '@house-plan-maker/shared';
|
||||
import {
|
||||
getOpeningSlices,
|
||||
wallVector,
|
||||
wallNormal,
|
||||
wallRotationY,
|
||||
positionAlongWall3D,
|
||||
} from './utils/wallGeometry';
|
||||
@@ -13,6 +14,8 @@ interface PlinthMeshProps {
|
||||
readonly openings: readonly WallOpening[];
|
||||
readonly plinthHeight: number;
|
||||
readonly plinthThickness: number;
|
||||
/** See WallMesh — same outward shift so the plinth stays aligned with the wall. */
|
||||
readonly roomCentroid?: Point;
|
||||
}
|
||||
|
||||
const PLINTH_COLOR = '#d4c5b2';
|
||||
@@ -63,7 +66,7 @@ function computePlinthSegments(
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function PlinthMesh({ wall, openings, plinthHeight, plinthThickness }: PlinthMeshProps) {
|
||||
export function PlinthMesh({ wall, openings, plinthHeight, plinthThickness, roomCentroid }: PlinthMeshProps) {
|
||||
const segments = useMemo(
|
||||
() => computePlinthSegments(wall, openings),
|
||||
[wall, openings],
|
||||
@@ -71,6 +74,20 @@ export function PlinthMesh({ wall, openings, plinthHeight, plinthThickness }: Pl
|
||||
|
||||
const rotY = useMemo(() => wallRotationY(wall), [wall]);
|
||||
|
||||
// Same outward shift as the wall — see WallMesh.outwardOffset.
|
||||
const outwardOffset = useMemo<[number, number]>(() => {
|
||||
if (!roomCentroid) return [0, 0];
|
||||
const { nx, ny } = wallNormal(wall);
|
||||
if (nx === 0 && ny === 0) return [0, 0];
|
||||
const midX = (wall.startX + wall.endX) / 2;
|
||||
const midY = (wall.startY + wall.endY) / 2;
|
||||
const outX = midX - roomCentroid.x;
|
||||
const outY = midY - roomCentroid.y;
|
||||
const sign = nx * outX + ny * outY >= 0 ? 1 : -1;
|
||||
const half = wall.thickness / 2;
|
||||
return [sign * nx * half, sign * ny * half];
|
||||
}, [wall, roomCentroid]);
|
||||
|
||||
if (plinthHeight <= 0 || plinthThickness <= 0) return null;
|
||||
|
||||
return (
|
||||
@@ -85,7 +102,7 @@ export function PlinthMesh({ wall, openings, plinthHeight, plinthThickness }: Pl
|
||||
return (
|
||||
<mesh
|
||||
key={`${wall.id}-plinth-${i}`}
|
||||
position={[cx, plinthHeight / 2, cz]}
|
||||
position={[cx + outwardOffset[0], plinthHeight / 2, cz + outwardOffset[1]]}
|
||||
rotation={[0, rotY, 0]}
|
||||
castShadow
|
||||
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 { PerspectiveCamera } from '@react-three/drei';
|
||||
import { PerspectiveCamera, Environment } from '@react-three/drei';
|
||||
import * as THREE from 'three';
|
||||
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 { WallMesh } from './WallMesh';
|
||||
import { DoorOpening } from './DoorOpening';
|
||||
@@ -12,7 +14,13 @@ import { FurnitureMesh } from './FurnitureMesh';
|
||||
import { ElectricalMeshWithHeight } from './ElectricalMesh';
|
||||
import { PlinthMesh } from './PlinthMesh';
|
||||
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';
|
||||
|
||||
@@ -65,11 +73,54 @@ function NearestWallTracker({ walls, onUpdate }: { readonly walls: readonly Wall
|
||||
* Renders inside a @react-three/fiber Canvas with orbit controls.
|
||||
*/
|
||||
export function Room3DView() {
|
||||
const { t, i18n } = useTranslation();
|
||||
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 [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) => {
|
||||
setActivePreset(preset);
|
||||
@@ -77,6 +128,12 @@ export function Room3DView() {
|
||||
setTimeout(() => setActivePreset(null), 100);
|
||||
}, []);
|
||||
|
||||
// Reset the camera to the default (Bird's Eye) view. Bound to the Canvas
|
||||
// element's onDoubleClick so double-tapping empty space snaps back.
|
||||
const handleResetView = useCallback(() => {
|
||||
handlePreset('birds-eye');
|
||||
}, [handlePreset]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
dispatch({ type: 'SET_SELECTED', ids: new Set([id]) });
|
||||
@@ -99,6 +156,13 @@ export function Room3DView() {
|
||||
[openings],
|
||||
);
|
||||
|
||||
// Centroid of the room polygon — used to push wall + plinth meshes outward
|
||||
// so they don't bleed into the room interior. See WallMesh for the why.
|
||||
const roomCentroid = useMemo(
|
||||
() => (shape.length >= 3 ? polygonCentroid(shape) : { x: 0, y: 0 }),
|
||||
[shape],
|
||||
);
|
||||
|
||||
// Compute bird's eye camera position from room bounds
|
||||
const initialCameraPos = useMemo((): [number, number, number] => {
|
||||
if (shape.length < 3) return [0, 10, 0.01];
|
||||
@@ -123,12 +187,32 @@ export function Room3DView() {
|
||||
<CameraPresetsUI
|
||||
shape={shape}
|
||||
wallHeight={wallHeight}
|
||||
orientation={cameraOrientation}
|
||||
onPreset={handlePreset}
|
||||
/>
|
||||
<Canvas
|
||||
shadows
|
||||
style={{ width: '100%', height: '100%', background: '#e8ecf0' }}
|
||||
gl={{ antialias: true, preserveDrawingBuffer: true }}
|
||||
// `shadows="percentage"` selects PCFShadowMap — the non-deprecated
|
||||
// replacement for the default PCFSoftShadowMap that Three.js removed
|
||||
// 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}>
|
||||
{/* Camera + Controls */}
|
||||
@@ -138,23 +222,45 @@ export function Room3DView() {
|
||||
wallHeight={wallHeight}
|
||||
activePreset={activePreset}
|
||||
/>
|
||||
<CameraOrientationTracker onChange={setCameraOrientation} />
|
||||
|
||||
{/* Lighting */}
|
||||
<ambientLight intensity={0.5} />
|
||||
{/* Image-based lighting from a pre-baked HDR environment is now
|
||||
the PRIMARY light source. IBL integrates light from every
|
||||
direction so walls facing any orientation receive roughly
|
||||
equal illumination — that's what we want for aligned wall
|
||||
brightness. `environmentIntensity` was bumped from 0.35 to
|
||||
0.85 to dominate over the weaker directional lights.
|
||||
`background={false}` keeps the canvas grey. */}
|
||||
<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
|
||||
position={[10, 15, 10]}
|
||||
intensity={1.0}
|
||||
position={sunPosition}
|
||||
intensity={sunIntensity}
|
||||
castShadow
|
||||
shadow-mapSize-width={2048}
|
||||
shadow-mapSize-height={2048}
|
||||
shadow-mapSize-width={4096}
|
||||
shadow-mapSize-height={4096}
|
||||
shadow-camera-near={0.5}
|
||||
shadow-camera-far={50}
|
||||
shadow-camera-left={-15}
|
||||
shadow-camera-right={15}
|
||||
shadow-camera-top={15}
|
||||
shadow-camera-bottom={-15}
|
||||
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 */}
|
||||
<NearestWallTracker walls={walls} onUpdate={setHiddenWallIds} />
|
||||
@@ -171,8 +277,10 @@ export function Room3DView() {
|
||||
openings={openings}
|
||||
wallHeight={wallHeight}
|
||||
wallColor={room.wallColor}
|
||||
wallFinish={room.wallFinish}
|
||||
selectedIds={selectedIds}
|
||||
onSelect={handleSelect}
|
||||
roomCentroid={roomCentroid}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
@@ -186,6 +294,7 @@ export function Room3DView() {
|
||||
openings={openings}
|
||||
plinthHeight={plinthHeight}
|
||||
plinthThickness={plinthThickness}
|
||||
roomCentroid={roomCentroid}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
@@ -201,6 +310,7 @@ export function Room3DView() {
|
||||
wall={wall}
|
||||
isSelected={selectedIds.has(door.id)}
|
||||
onSelect={handleSelect}
|
||||
isOpen={doorsOpen}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -227,6 +337,7 @@ export function Room3DView() {
|
||||
item={item}
|
||||
isSelected={selectedIds.has(item.id)}
|
||||
onSelect={handleSelect}
|
||||
globalOpacity={globalFurnitureOpacity}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -239,6 +350,9 @@ export function Room3DView() {
|
||||
wallHeight={wallHeight}
|
||||
isSelected={selectedIds.has(item.id)}
|
||||
onSelect={handleSelect}
|
||||
outletWidth={room.outletWidth}
|
||||
outletHeight={room.outletHeight}
|
||||
roomCentroid={roomCentroid}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -254,6 +368,144 @@ export function Room3DView() {
|
||||
{/* ContactShadows removed — floor is handled by FloorCeiling */}
|
||||
</Suspense>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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 * as THREE from 'three';
|
||||
import type { Wall, WallOpening } from '@house-plan-maker/shared';
|
||||
import type { Point, Wall, WallFinish, WallOpening } from '@house-plan-maker/shared';
|
||||
import {
|
||||
splitWallAroundOpenings,
|
||||
wallRotationY,
|
||||
wallSegmentCenter3D,
|
||||
wallNormal,
|
||||
type WallSegment,
|
||||
} from './utils/wallGeometry';
|
||||
import { getWallPbr } from './utils/pbrTextures';
|
||||
|
||||
interface WallMeshProps {
|
||||
readonly wall: Wall;
|
||||
readonly openings: readonly WallOpening[];
|
||||
readonly wallHeight: number;
|
||||
readonly wallColor?: string;
|
||||
readonly wallFinish?: WallFinish;
|
||||
readonly selectedIds: ReadonlySet<string>;
|
||||
readonly onSelect?: (id: string) => void;
|
||||
/**
|
||||
* Centroid of the room polygon (in 2D editor coords). The wall's
|
||||
* `startX/Y..endX/Y` line represents the **inner** edge of the wall (this
|
||||
* matches how the 2D WallLayer renders walls — it draws an outer boundary
|
||||
* offset outward by `thickness`). In 3D, however, a box geometry is
|
||||
* centered on the line by default, so the wall would bleed `thickness/2`
|
||||
* into the room and collide with furniture sitting against it. We push
|
||||
* each wall segment outward (away from the centroid) by `thickness/2` so
|
||||
* the inner face stays on the wall line.
|
||||
*/
|
||||
readonly roomCentroid?: Point;
|
||||
}
|
||||
|
||||
const DEFAULT_WALL_COLOR = '#f0ebe3';
|
||||
const WALL_SELECTED_COLOR = '#b8d4e3';
|
||||
|
||||
// ── Wall material cache ──
|
||||
const wallMaterialCache = new Map<string, THREE.MeshStandardMaterial>();
|
||||
// ── PAINT material cache (one per color) ──
|
||||
//
|
||||
// PAINT is the only finish that uses `wallColor`; the textured finishes
|
||||
// (PLASTER/BRICK/CONCRETE/WOOD_PANEL/WALLPAPER) ignore color and apply a
|
||||
// shared PBR material loaded lazily from getWallPbr().
|
||||
const paintMaterialCache = new Map<string, THREE.MeshStandardMaterial>();
|
||||
|
||||
function getWallMaterial(color: string): THREE.MeshStandardMaterial {
|
||||
let mat = wallMaterialCache.get(color);
|
||||
function getPaintMaterial(color: string): THREE.MeshStandardMaterial {
|
||||
let mat = paintMaterialCache.get(color);
|
||||
if (!mat) {
|
||||
mat = new THREE.MeshStandardMaterial({ color, roughness: 0.7, side: THREE.DoubleSide });
|
||||
wallMaterialCache.set(color, mat);
|
||||
mat = new THREE.MeshStandardMaterial({ color, roughness: 0.85, side: THREE.DoubleSide });
|
||||
paintMaterialCache.set(color, mat);
|
||||
}
|
||||
return mat;
|
||||
}
|
||||
@@ -38,31 +56,71 @@ const wallSelectedMaterial = new THREE.MeshStandardMaterial({
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
|
||||
/**
|
||||
* Build a BoxGeometry for a wall segment and rescale its UVs so a textured
|
||||
* wall finish tiles every `tileMeters` of physical surface instead of
|
||||
* stretching one tile across the whole segment. The default BoxGeometry has
|
||||
* UVs in the 0..1 range per face — multiplying by `(width/tile, height/tile)`
|
||||
* gives the desired tile density and `wrapS/wrapT = RepeatWrapping` on the
|
||||
* texture handles the modulo. The UV scale is baked into the geometry so a
|
||||
* single shared material instance can serve walls of any size.
|
||||
*/
|
||||
function buildSegmentGeometry(
|
||||
width: number,
|
||||
height: number,
|
||||
thickness: number,
|
||||
tileMeters: number | null,
|
||||
): THREE.BoxGeometry {
|
||||
const geometry = new THREE.BoxGeometry(width, height, thickness);
|
||||
if (tileMeters != null) {
|
||||
const u = Math.max(1, width / tileMeters);
|
||||
const v = Math.max(1, height / tileMeters);
|
||||
const uv = geometry.attributes.uv;
|
||||
if (uv) {
|
||||
for (let i = 0; i < uv.count; i++) {
|
||||
uv.setXY(i, uv.getX(i) * u, uv.getY(i) * v);
|
||||
}
|
||||
uv.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
return geometry;
|
||||
}
|
||||
|
||||
function WallSegmentMesh({
|
||||
wall,
|
||||
segment,
|
||||
thickness,
|
||||
wallColor,
|
||||
material,
|
||||
tileMeters,
|
||||
isSelected,
|
||||
onSelect,
|
||||
outwardOffset,
|
||||
}: {
|
||||
readonly wall: Wall;
|
||||
readonly segment: WallSegment;
|
||||
readonly thickness: number;
|
||||
readonly wallColor: string;
|
||||
readonly material: THREE.MeshStandardMaterial;
|
||||
readonly tileMeters: number | null;
|
||||
readonly isSelected: boolean;
|
||||
readonly onSelect?: (id: string) => void;
|
||||
/** [dx, 0, dz] offset to push the box from "centered on wall line" to "outer side of wall line". */
|
||||
readonly outwardOffset: readonly [number, number, number];
|
||||
}) {
|
||||
const segmentWidth = segment.endAlongWall - segment.startAlongWall;
|
||||
const segmentHeight = segment.topY - segment.bottomY;
|
||||
|
||||
const center = useMemo(
|
||||
() => wallSegmentCenter3D(wall, segment),
|
||||
[wall, segment],
|
||||
);
|
||||
const center = useMemo<[number, number, number]>(() => {
|
||||
const [x, y, z] = wallSegmentCenter3D(wall, segment);
|
||||
return [x + outwardOffset[0], y, z + outwardOffset[2]];
|
||||
}, [wall, segment, outwardOffset]);
|
||||
|
||||
const rotY = useMemo(() => wallRotationY(wall), [wall]);
|
||||
|
||||
const geometry = useMemo(
|
||||
() => buildSegmentGeometry(segmentWidth, segmentHeight, thickness, tileMeters),
|
||||
[segmentWidth, segmentHeight, thickness, tileMeters],
|
||||
);
|
||||
|
||||
if (segmentWidth <= 0 || segmentHeight <= 0) return null;
|
||||
|
||||
return (
|
||||
@@ -71,20 +129,52 @@ function WallSegmentMesh({
|
||||
rotation={[0, rotY, 0]}
|
||||
castShadow
|
||||
receiveShadow
|
||||
material={isSelected ? wallSelectedMaterial : getWallMaterial(wallColor)}
|
||||
material={isSelected ? wallSelectedMaterial : material}
|
||||
geometry={geometry}
|
||||
onClick={onSelect ? (e) => { e.stopPropagation(); onSelect(wall.id); } : undefined}
|
||||
>
|
||||
<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(
|
||||
() => splitWallAroundOpenings(wall, openings, wallHeight),
|
||||
[wall, openings, wallHeight],
|
||||
);
|
||||
|
||||
// Resolve the finish to a material + tile size. PAINT uses the per-color
|
||||
// cache (no UV rescale needed); textured finishes load a shared PBR set.
|
||||
const { material, tileMeters } = useMemo(() => {
|
||||
if (wallFinish === 'PAINT') {
|
||||
return { material: getPaintMaterial(wallColor), tileMeters: null as number | null };
|
||||
}
|
||||
const pbr = getWallPbr(wallFinish);
|
||||
return { material: pbr.material, tileMeters: pbr.tileMeters };
|
||||
}, [wallFinish, wallColor]);
|
||||
|
||||
// Compute the outward (away-from-room-centroid) offset along the wall's
|
||||
// perpendicular normal. Without this the wall box straddles the wall line
|
||||
// and the inner half-thickness collides with furniture placed against the
|
||||
// wall. The 2D editor draws walls extending entirely outward from the
|
||||
// shape — this matches that semantic.
|
||||
const outwardOffset = useMemo<[number, number, number]>(() => {
|
||||
if (!roomCentroid) return [0, 0, 0];
|
||||
const { nx, ny } = wallNormal(wall);
|
||||
if (nx === 0 && ny === 0) return [0, 0, 0];
|
||||
// Wall midpoint, used to decide which side of the wall is "outside".
|
||||
const midX = (wall.startX + wall.endX) / 2;
|
||||
const midY = (wall.startY + wall.endY) / 2;
|
||||
// Vector from centroid to wall midpoint = outward direction (pre-normal-projection).
|
||||
const outX = midX - roomCentroid.x;
|
||||
const outY = midY - roomCentroid.y;
|
||||
// Sign of normal-along-outward tells us whether to flip.
|
||||
const dot = nx * outX + ny * outY;
|
||||
const sign = dot >= 0 ? 1 : -1;
|
||||
const half = wall.thickness / 2;
|
||||
// 2D y-axis maps to 3D z-axis.
|
||||
return [sign * nx * half, 0, sign * ny * half];
|
||||
}, [wall, roomCentroid]);
|
||||
|
||||
const isSelected = selectedIds.has(wall.id);
|
||||
|
||||
return (
|
||||
@@ -95,9 +185,11 @@ export function WallMesh({ wall, openings, wallHeight, wallColor = DEFAULT_WALL_
|
||||
wall={wall}
|
||||
segment={segment}
|
||||
thickness={wall.thickness}
|
||||
wallColor={wallColor}
|
||||
material={material}
|
||||
tileMeters={tileMeters}
|
||||
isSelected={isSelected}
|
||||
onSelect={onSelect}
|
||||
outwardOffset={outwardOffset}
|
||||
/>
|
||||
))}
|
||||
</group>
|
||||
|
||||
@@ -15,7 +15,8 @@ interface WindowOpeningProps {
|
||||
|
||||
const FRAME_COLOR = '#c0c0c0';
|
||||
const GLASS_COLOR = '#a8d8ea';
|
||||
const FRAME_THICKNESS = 0.03;
|
||||
/** Fallback frame thickness for legacy openings without an explicit value. */
|
||||
const DEFAULT_FRAME_THICKNESS = 0.03;
|
||||
|
||||
export function WindowOpening({ opening, wall, isSelected, onSelect }: WindowOpeningProps) {
|
||||
const rotY = useMemo(() => wallRotationY(wall), [wall]);
|
||||
@@ -28,6 +29,33 @@ export function WindowOpening({ opening, wall, isSelected, onSelect }: WindowOpe
|
||||
const frameColor = isSelected ? '#6fa8dc' : FRAME_COLOR;
|
||||
const halfWidth = opening.width / 2;
|
||||
const halfThick = wall.thickness / 2 + 0.005;
|
||||
// Per-opening frame thickness with a sane fallback for old data. The
|
||||
// mullion thickness is derived from the frame thickness so the grid
|
||||
// dividers stay visually proportional regardless of the user's choice.
|
||||
const frameThickness = Math.max(0, opening.frameThickness ?? DEFAULT_FRAME_THICKNESS);
|
||||
|
||||
// Window reveal (откос). The slope panels project OUTWARD from the
|
||||
// window frame's outer face, away from the room interior, lining the wall
|
||||
// opening as the wall extends toward the exterior. `0` keeps the window
|
||||
// flush. The schema cap of 2 m is plenty so we don't clamp again here.
|
||||
const slopeDepth = Math.max(0, opening.slopeDepth ?? 0);
|
||||
const slopeColor = isSelected ? '#9bbfdc' : '#e8e0d4';
|
||||
// Visible jamb thickness — substantial enough to read as an architectural
|
||||
// element rather than a hairline.
|
||||
const slopeStripThickness = 0.025;
|
||||
// Centre of the slope slab in window-local z. The wall's room-facing side
|
||||
// sits at -halfThick in window-local coords (verified empirically), so the
|
||||
// panels grow outward into the wall by spanning -halfThick to
|
||||
// -halfThick - slopeDepth.
|
||||
const slopeFaceZ = -halfThick - slopeDepth / 2;
|
||||
|
||||
// Grid subdivision — clamp to sensible bounds. `cols` verticals produce
|
||||
// `cols-1` internal mullions; same for rows. Default to a 2×2 grid
|
||||
// (single vertical + single horizontal mullion) to match the classic
|
||||
// look for windows without an explicit grid set.
|
||||
const gridCols = Math.max(1, Math.min(10, Math.round(opening.gridCols ?? 2)));
|
||||
const gridRows = Math.max(1, Math.min(10, Math.round(opening.gridRows ?? 2)));
|
||||
const mullionThickness = frameThickness * 0.55;
|
||||
|
||||
return (
|
||||
<group
|
||||
@@ -35,30 +63,67 @@ export function WindowOpening({ opening, wall, isSelected, onSelect }: WindowOpe
|
||||
rotation={[0, rotY, 0]}
|
||||
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 */}
|
||||
{/* Left */}
|
||||
<mesh position={[-halfWidth, 0, 0]} castShadow>
|
||||
<boxGeometry args={[FRAME_THICKNESS, opening.height, halfThick * 2]} />
|
||||
<mesh position={[-halfWidth, 0, -slopeDepth]} castShadow>
|
||||
<boxGeometry args={[frameThickness, opening.height, halfThick * 2]} />
|
||||
<meshStandardMaterial color={frameColor} roughness={0.4} />
|
||||
</mesh>
|
||||
{/* Right */}
|
||||
<mesh position={[halfWidth, 0, 0]} castShadow>
|
||||
<boxGeometry args={[FRAME_THICKNESS, opening.height, halfThick * 2]} />
|
||||
<mesh position={[halfWidth, 0, -slopeDepth]} castShadow>
|
||||
<boxGeometry args={[frameThickness, opening.height, halfThick * 2]} />
|
||||
<meshStandardMaterial color={frameColor} roughness={0.4} />
|
||||
</mesh>
|
||||
{/* Top */}
|
||||
<mesh position={[0, opening.height / 2, 0]} castShadow>
|
||||
<boxGeometry args={[opening.width + FRAME_THICKNESS, FRAME_THICKNESS, halfThick * 2]} />
|
||||
<mesh position={[0, opening.height / 2, -slopeDepth]} castShadow>
|
||||
<boxGeometry args={[opening.width + frameThickness, frameThickness, halfThick * 2]} />
|
||||
<meshStandardMaterial color={frameColor} roughness={0.4} />
|
||||
</mesh>
|
||||
{/* Bottom (sill) */}
|
||||
<mesh position={[0, -opening.height / 2, 0]} castShadow>
|
||||
<boxGeometry args={[opening.width + FRAME_THICKNESS, FRAME_THICKNESS, halfThick * 2]} />
|
||||
<mesh position={[0, -opening.height / 2, -slopeDepth]} castShadow>
|
||||
<boxGeometry args={[opening.width + frameThickness, frameThickness, halfThick * 2]} />
|
||||
<meshStandardMaterial color={frameColor} roughness={0.4} />
|
||||
</mesh>
|
||||
|
||||
{/* Glass pane */}
|
||||
<mesh position={[0, 0, 0]}>
|
||||
<mesh position={[0, 0, -slopeDepth]}>
|
||||
<planeGeometry args={[opening.width, opening.height]} />
|
||||
<meshStandardMaterial
|
||||
color={GLASS_COLOR}
|
||||
@@ -70,17 +135,29 @@ export function WindowOpening({ opening, wall, isSelected, onSelect }: WindowOpe
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Center cross divider — vertical */}
|
||||
<mesh position={[0, 0, 0]} castShadow>
|
||||
<boxGeometry args={[FRAME_THICKNESS * 0.7, opening.height, FRAME_THICKNESS * 0.7]} />
|
||||
<meshStandardMaterial color={frameColor} roughness={0.4} />
|
||||
</mesh>
|
||||
{/* Vertical mullions — `gridCols - 1` internal dividers spaced
|
||||
evenly between the left and right frame posts. */}
|
||||
{Array.from({ length: gridCols - 1 }).map((_, i) => {
|
||||
const x = -halfWidth + ((i + 1) * opening.width) / gridCols;
|
||||
return (
|
||||
<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 */}
|
||||
<mesh position={[0, 0, 0]} castShadow>
|
||||
<boxGeometry args={[opening.width, FRAME_THICKNESS * 0.7, FRAME_THICKNESS * 0.7]} />
|
||||
<meshStandardMaterial color={frameColor} roughness={0.4} />
|
||||
</mesh>
|
||||
{/* Horizontal mullions — `gridRows - 1` internal dividers spaced
|
||||
evenly between the top and bottom frame rails. */}
|
||||
{Array.from({ length: gridRows - 1 }).map((_, i) => {
|
||||
const y = -opening.height / 2 + ((i + 1) * opening.height) / gridRows;
|
||||
return (
|
||||
<mesh key={`hmul-${i}`} position={[0, y, -slopeDepth]} castShadow>
|
||||
<boxGeometry args={[opening.width, mullionThickness, mullionThickness]} />
|
||||
<meshStandardMaterial color={frameColor} roughness={0.4} />
|
||||
</mesh>
|
||||
);
|
||||
})}
|
||||
</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 below the opening (from floor to opening bottom) — only if elevationFromFloor > 0
|
||||
* - Solid wall to the left and right of the opening
|
||||
*
|
||||
* The segments that touch the physical wall ends (start=0 or end=length) are
|
||||
* extended along the wall's own axis by the full `thickness`. Combined with
|
||||
* the outward perpendicular shift applied in WallMesh, this is the exact
|
||||
* amount needed for two perpendicular walls' boxes to share their corner
|
||||
* cube without leaving a vertical gap at the inside or outside corner.
|
||||
*
|
||||
* For non-90 corners this creates a small overlap rather than a gap, which
|
||||
* is the right tradeoff: overlap is invisible (same material, no z-fighting
|
||||
* artifacts because the meshes are coplanar at the corner), gap is not.
|
||||
*
|
||||
* Inner segments (those bordering an opening) are not extended — extending
|
||||
* them would close the opening.
|
||||
*/
|
||||
export function splitWallAroundOpenings(
|
||||
wall: Wall,
|
||||
@@ -76,20 +89,23 @@ export function splitWallAroundOpenings(
|
||||
): readonly WallSegment[] {
|
||||
const slices = getOpeningSlices(wall, openings);
|
||||
const { length } = wallVector(wall);
|
||||
const ext = wall.thickness;
|
||||
|
||||
if (slices.length === 0) {
|
||||
return [{ startAlongWall: 0, endAlongWall: length, bottomY: 0, topY: wallHeight }];
|
||||
return [{ startAlongWall: -ext, endAlongWall: length + ext, bottomY: 0, topY: wallHeight }];
|
||||
}
|
||||
|
||||
const segments: WallSegment[] = [];
|
||||
|
||||
// Full-height segments between openings
|
||||
let cursor = 0;
|
||||
for (const slice of slices) {
|
||||
for (let i = 0; i < slices.length; i++) {
|
||||
const slice = slices[i];
|
||||
// Solid wall before this opening
|
||||
if (slice.startAlongWall > cursor) {
|
||||
segments.push({
|
||||
startAlongWall: cursor,
|
||||
// Extend the start outward only if this segment touches the wall start.
|
||||
startAlongWall: cursor === 0 ? -ext : cursor,
|
||||
endAlongWall: slice.startAlongWall,
|
||||
bottomY: 0,
|
||||
topY: wallHeight,
|
||||
@@ -124,7 +140,8 @@ export function splitWallAroundOpenings(
|
||||
if (cursor < length) {
|
||||
segments.push({
|
||||
startAlongWall: cursor,
|
||||
endAlongWall: length,
|
||||
// Extend the end outward — this segment touches the wall end.
|
||||
endAlongWall: length + ext,
|
||||
bottomY: 0,
|
||||
topY: wallHeight,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Point, Wall, WallOpening } from '@house-plan-maker/shared';
|
||||
import { DEFAULT_POSITION_ANCHOR } from '@house-plan-maker/shared';
|
||||
import { findNearestWall, wallLength } from '../utils/wallUtils';
|
||||
import { generateLocalId } from '../utils/geometry';
|
||||
import { hasOverlap } from '../utils/openingUtils';
|
||||
@@ -73,5 +74,19 @@ export function createDoorOpening(
|
||||
height,
|
||||
elevationFromFloor: 0,
|
||||
openDirection: 'LEFT',
|
||||
// Openings store canonical (positionAlongWall = center, elevationFromFloor = bottom).
|
||||
// The default anchor matches that convention; the user may change it in
|
||||
// the properties panel as a view-only display preference.
|
||||
positionAnchor: { ...DEFAULT_POSITION_ANCHOR, vertical: 'bottom' },
|
||||
// Doors don't use grid subdivision — set to 1×1 so the renderer
|
||||
// draws no internal mullions. Default kept for uniformity with the
|
||||
// shared WallOpening type.
|
||||
gridCols: 1,
|
||||
gridRows: 1,
|
||||
// Doors ignore slopeDepth (window-only field); kept at 0 for type completeness.
|
||||
slopeDepth: 0,
|
||||
// Default frame thickness matches the legacy constant in DoorOpening.tsx
|
||||
// so newly placed doors look identical to existing ones until edited.
|
||||
frameThickness: 0.03,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Point, Wall, ElectricalItem, ElectricalType } from '@house-plan-maker/shared';
|
||||
import { DEFAULT_POSITION_ANCHOR } from '@house-plan-maker/shared';
|
||||
import { findNearestWall, wallAngle } from '../utils/wallUtils';
|
||||
import { generateLocalId } from '../utils/geometry';
|
||||
import { DEFAULT_ELEVATIONS } from '../utils/projectionMapping';
|
||||
import { normalizeAngleDegrees } from '../utils/angle';
|
||||
import type { ElectricalSymbolDef } from '../symbols/electrical';
|
||||
|
||||
/** Maximum snap distance to wall (meters). */
|
||||
@@ -31,7 +33,7 @@ export function computeElectricalPreview(
|
||||
return {
|
||||
x: nearest.projected.x,
|
||||
y: nearest.projected.y,
|
||||
rotation: (angle * 180) / Math.PI,
|
||||
rotation: normalizeAngleDegrees((angle * 180) / Math.PI),
|
||||
wallId: nearest.wall.id,
|
||||
isValid: true,
|
||||
};
|
||||
@@ -84,7 +86,10 @@ export function createElectricalItemFromPlacement(
|
||||
variant?: string,
|
||||
wallHeight?: number,
|
||||
): ElectricalItem {
|
||||
const metadata: Record<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 {
|
||||
id: generateLocalId(),
|
||||
@@ -95,6 +100,9 @@ export function createElectricalItemFromPlacement(
|
||||
wallId: preview.wallId,
|
||||
elevationFromFloor: getDefaultElevation(type, wallHeight),
|
||||
rotation: preview.rotation,
|
||||
count: 1,
|
||||
positionAnchor: DEFAULT_POSITION_ANCHOR,
|
||||
label: null,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Point, FurnitureItem } from '@house-plan-maker/shared';
|
||||
import { DEFAULT_POSITION_ANCHOR } from '@house-plan-maker/shared';
|
||||
import { generateLocalId } from '../utils/geometry';
|
||||
import type { FurnitureDef } from '../symbols/furniture';
|
||||
|
||||
@@ -13,9 +14,8 @@ export interface FurniturePlacementPreview {
|
||||
|
||||
/**
|
||||
* Compute furniture placement preview.
|
||||
* The x,y represents the top-left corner of the furniture piece.
|
||||
* The cursor world point is treated as the desired center, so we offset
|
||||
* by half-width and half-depth to get the top-left corner.
|
||||
* New furniture uses the default anchor (middle/middle), so the cursor world
|
||||
* point IS the (x, y) — no offset needed.
|
||||
*/
|
||||
export function computeFurniturePreview(
|
||||
worldPoint: Point,
|
||||
@@ -23,8 +23,8 @@ export function computeFurniturePreview(
|
||||
rotation: number = 0,
|
||||
): FurniturePlacementPreview {
|
||||
return {
|
||||
x: worldPoint.x - furnitureDef.width / 2,
|
||||
y: worldPoint.y - furnitureDef.depth / 2,
|
||||
x: worldPoint.x,
|
||||
y: worldPoint.y,
|
||||
width: furnitureDef.width,
|
||||
depth: furnitureDef.depth,
|
||||
rotation,
|
||||
@@ -34,12 +34,20 @@ export function computeFurniturePreview(
|
||||
|
||||
/**
|
||||
* Create a FurnitureItem from placement data.
|
||||
* Propagates the FurnitureDef's defaultMetadata into item.metadata so
|
||||
* presets like plant variants or flower colours apply on first render.
|
||||
*/
|
||||
export function createFurnitureItemFromPlacement(
|
||||
roomId: string,
|
||||
preview: FurniturePlacementPreview,
|
||||
furnitureDef: FurnitureDef,
|
||||
): FurnitureItem {
|
||||
// Shallow-copy the default metadata so later mutations on the item don't
|
||||
// leak back into the static FurnitureDef table.
|
||||
const metadata: Record<string, unknown> | null = furnitureDef.defaultMetadata
|
||||
? { ...furnitureDef.defaultMetadata }
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: generateLocalId(),
|
||||
roomId,
|
||||
@@ -50,8 +58,22 @@ export function createFurnitureItemFromPlacement(
|
||||
depth: preview.depth,
|
||||
height: furnitureDef.height,
|
||||
rotation: preview.rotation,
|
||||
elevationFromFloor: furnitureDef.type === 'AC_UNIT' ? 2.2 : furnitureDef.height <= 0.05 ? 1.2 : 0,
|
||||
elevationFromFloor:
|
||||
furnitureDef.type === 'AC_UNIT'
|
||||
? 2.2
|
||||
: furnitureDef.type === 'WALL_COLLAGE'
|
||||
? 1.4 // eye level — user can adjust in the panel
|
||||
: furnitureDef.type === 'RADIATOR'
|
||||
? 0.12 // small clearance from the floor (typical)
|
||||
: furnitureDef.type === 'MIRROR' &&
|
||||
(furnitureDef.defaultMetadata?.['variant'] as string | undefined) === 'wall'
|
||||
? Math.max(0, 1.5 - furnitureDef.height / 2) // hang so centre sits around eye level
|
||||
: furnitureDef.height <= 0.05
|
||||
? 1.2
|
||||
: 0,
|
||||
label: furnitureDef.label,
|
||||
positionAnchor: DEFAULT_POSITION_ANCHOR,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import type { Point, Wall, WallOpening, ElectricalItem, FurnitureItem } from '@house-plan-maker/shared';
|
||||
import { rotatedAnchorOffsetToCenter } from '@house-plan-maker/shared';
|
||||
import type { DragState } from '../types';
|
||||
import { distance } from '../utils/geometry';
|
||||
import { findNearestWall } from '../utils/wallUtils';
|
||||
|
||||
function furnitureCenter(item: FurnitureItem): { cx: number; cy: number } {
|
||||
const offset = rotatedAnchorOffsetToCenter(
|
||||
item.positionAnchor,
|
||||
item.width,
|
||||
item.depth,
|
||||
item.rotation,
|
||||
);
|
||||
return { cx: item.x + offset.dx, cy: item.y + offset.dy };
|
||||
}
|
||||
|
||||
/** Hit-test radius in meters for selecting elements. */
|
||||
const HIT_RADIUS = 0.15;
|
||||
|
||||
@@ -58,8 +69,7 @@ export function hitTest(
|
||||
|
||||
// Check furniture items (rotation-aware: transform point into item's local space)
|
||||
for (const item of furnitureItems) {
|
||||
const cx = item.x + item.width / 2;
|
||||
const cy = item.y + item.depth / 2;
|
||||
const { cx, cy } = furnitureCenter(item);
|
||||
// Translate point relative to item center
|
||||
const dx = worldPoint.x - cx;
|
||||
const dy = worldPoint.y - cy;
|
||||
@@ -151,9 +161,8 @@ export function elementsInRect(
|
||||
}
|
||||
|
||||
for (const item of furnitureItems) {
|
||||
// x,y is top-left; use center point for selection-rect containment
|
||||
const cx = item.x + item.width / 2;
|
||||
const cy = item.y + item.depth / 2;
|
||||
// Use the bounding-box centre (anchor-aware) for selection-rect containment
|
||||
const { cx, cy } = furnitureCenter(item);
|
||||
if (
|
||||
cx >= rect.x &&
|
||||
cx <= rect.x + rect.width &&
|
||||
@@ -193,8 +202,25 @@ export function selectedBoundingBox(
|
||||
const dy = (wall.endY - wall.startY) / wallLen;
|
||||
const cx = wall.startX + dx * opening.positionAlongWall;
|
||||
const cy = wall.startY + dy * opening.positionAlongWall;
|
||||
points.push({ x: cx - opening.width / 2, y: cy - opening.width / 2 });
|
||||
points.push({ x: cx + opening.width / 2, y: cy + opening.width / 2 });
|
||||
// Compute the four corners of the oriented opening rectangle
|
||||
// (along-wall = opening.width, perpendicular = wall.thickness),
|
||||
// then push them all so the resulting AABB is the correct
|
||||
// world-axis box for the rotated symbol. The previous version
|
||||
// used `opening.width/2` on both axes, producing a square that
|
||||
// ballooned out from the wall on long windows.
|
||||
const halfW = opening.width / 2;
|
||||
const halfT = wall.thickness / 2;
|
||||
// Wall normal (perpendicular to direction).
|
||||
const nx = -dy;
|
||||
const ny = dx;
|
||||
for (const su of [-halfW, halfW]) {
|
||||
for (const sv of [-halfT, halfT]) {
|
||||
points.push({
|
||||
x: cx + dx * su + nx * sv,
|
||||
y: cy + dy * su + ny * sv,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
@@ -209,9 +235,12 @@ export function selectedBoundingBox(
|
||||
|
||||
const furn = furnitureItems.find((f) => f.id === id);
|
||||
if (furn) {
|
||||
// Compute rotated AABB from center + rotation
|
||||
const cx = furn.x + furn.width / 2;
|
||||
const cy = furn.y + furn.depth / 2;
|
||||
// Selection overlay must enclose the ROTATED visual — otherwise a
|
||||
// thin rotated item (e.g. a curtain rotated 90°) would show a
|
||||
// horizontal dashed box while the visible rectangle is vertical.
|
||||
// Compute the world-axis AABB of the rotated `width × depth`
|
||||
// rectangle centred on the item's (rotated) centre.
|
||||
const { cx, cy } = furnitureCenter(furn);
|
||||
const rad = (furn.rotation * Math.PI) / 180;
|
||||
const cos = Math.abs(Math.cos(rad));
|
||||
const sin = Math.abs(Math.sin(rad));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Point, Wall, WallOpening } from '@house-plan-maker/shared';
|
||||
import { DEFAULT_POSITION_ANCHOR } from '@house-plan-maker/shared';
|
||||
import { findNearestWall, wallLength } from '../utils/wallUtils';
|
||||
import { generateLocalId } from '../utils/geometry';
|
||||
import { hasOverlap } from '../utils/openingUtils';
|
||||
@@ -77,5 +78,13 @@ export function createWindowOpening(
|
||||
height,
|
||||
elevationFromFloor: elevation,
|
||||
openDirection: 'LEFT',
|
||||
positionAnchor: { ...DEFAULT_POSITION_ANCHOR, vertical: 'bottom' },
|
||||
// Default to a classic 2×2 grid (one vertical + one horizontal mullion).
|
||||
// The user can change this in the properties panel after placing.
|
||||
gridCols: 2,
|
||||
gridRows: 2,
|
||||
// No reveal slope by default — user can opt in via the properties panel.
|
||||
slopeDepth: 0,
|
||||
frameThickness: 0.03,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -57,6 +57,10 @@ export interface EditorState {
|
||||
/** Index into FURNITURE_DEFS for furniture tool. */
|
||||
readonly selectedFurnitureIndex: number | null;
|
||||
readonly annotations: readonly Annotation[];
|
||||
/** Furniture IDs whose dimension/offset projection should be drawn on every wall view. */
|
||||
readonly furnitureProjectionIds: ReadonlySet<string>;
|
||||
/** Global multiplier applied to every furniture item's render opacity. */
|
||||
readonly globalFurnitureOpacity: number;
|
||||
}
|
||||
|
||||
// ── Undo/Redo Commands ──
|
||||
@@ -71,7 +75,7 @@ export interface EditorCommand {
|
||||
|
||||
export type EditorAction =
|
||||
| { readonly type: 'SET_ROOM'; readonly room: RoomFull }
|
||||
| { readonly type: 'UPDATE_ROOM_PROPS'; readonly props: Partial<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: 'UPDATE_WALL'; readonly wall: Wall }
|
||||
| { readonly type: 'ADD_OPENING'; readonly opening: WallOpening }
|
||||
@@ -90,6 +94,9 @@ export type EditorAction =
|
||||
| { readonly type: 'SET_TOOL'; readonly tool: EditorToolType }
|
||||
| { readonly type: 'SET_ZOOM'; readonly zoom: number }
|
||||
| { readonly type: 'SET_PAN_OFFSET'; readonly offset: Point }
|
||||
// Atomic zoom + pan update. Used by auto-fit so we don't flicker through
|
||||
// an intermediate (newZoom, oldPan) state between two separate dispatches.
|
||||
| { readonly type: 'SET_VIEW'; readonly zoom: number; readonly offset: Point }
|
||||
| { readonly type: 'SET_GRID_SIZE'; readonly gridSize: number }
|
||||
| { readonly type: 'TOGGLE_GRID' }
|
||||
| { readonly type: 'TOGGLE_SNAP' }
|
||||
@@ -108,6 +115,10 @@ export type EditorAction =
|
||||
| { readonly type: 'ADD_ANNOTATION'; readonly annotation: Annotation }
|
||||
| { readonly type: 'UPDATE_ANNOTATION'; readonly annotation: Annotation }
|
||||
| { readonly type: 'REMOVE_ANNOTATION'; readonly id: string }
|
||||
// Furniture projection toggle (per-item dimension/offset overlay on wall views)
|
||||
| { readonly type: 'TOGGLE_FURNITURE_PROJECTION'; readonly id: string }
|
||||
// Global furniture opacity (client-side display setting, multiplied with item.opacity)
|
||||
| { readonly type: 'SET_GLOBAL_FURNITURE_OPACITY'; readonly opacity: number }
|
||||
// Import
|
||||
| {
|
||||
readonly type: 'IMPORT_ROOM';
|
||||
@@ -130,6 +141,9 @@ export type EditorAction =
|
||||
readonly openings: readonly WallOpening[];
|
||||
readonly electricalItems: readonly ElectricalItem[];
|
||||
readonly furnitureItems: readonly FurnitureItem[];
|
||||
readonly annotations?: readonly Annotation[];
|
||||
/** Map from old (pre-save) item IDs to new (server) item IDs. */
|
||||
readonly idMap?: ReadonlyMap<string, string>;
|
||||
};
|
||||
|
||||
// ── Alignment ──
|
||||
|
||||
@@ -15,6 +15,7 @@ function makeFurniture(overrides: Partial<FurnitureItem> = {}): FurnitureItem {
|
||||
rotation: 0,
|
||||
elevationFromFloor: 0,
|
||||
label: null,
|
||||
positionAnchor: { horizontal: 'left', vertical: 'top' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,6 +100,11 @@ describe('openingWorldPosition', () => {
|
||||
height: 2.1,
|
||||
elevationFromFloor: 0,
|
||||
openDirection: 'LEFT',
|
||||
positionAnchor: { horizontal: 'middle', vertical: 'bottom' },
|
||||
gridCols: 2,
|
||||
gridRows: 2,
|
||||
slopeDepth: 0,
|
||||
frameThickness: 0.03,
|
||||
};
|
||||
const result = openingWorldPosition(opening, wall);
|
||||
expect(result.center.x).toBeCloseTo(5);
|
||||
@@ -120,6 +125,11 @@ describe('openingWorldPosition', () => {
|
||||
height: 1.2,
|
||||
elevationFromFloor: 0.9,
|
||||
openDirection: 'LEFT',
|
||||
positionAnchor: { horizontal: 'middle', vertical: 'bottom' },
|
||||
gridCols: 2,
|
||||
gridRows: 2,
|
||||
slopeDepth: 0,
|
||||
frameThickness: 0.03,
|
||||
};
|
||||
const result = openingWorldPosition(opening, wall);
|
||||
expect(result.center).toEqual({ x: 3, y: 3 });
|
||||
|
||||
@@ -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 { rotatedAnchorOffsetToCenter } from '@house-plan-maker/shared';
|
||||
|
||||
interface OBB {
|
||||
/**
|
||||
* Axis-aligned bounding box for a furniture item, in world (room) coordinates.
|
||||
*
|
||||
* Per the project rule "rotation only affects visuals and hitbox, positioning
|
||||
* always uses the initial coordinate system", collision detection — which is
|
||||
* a positional concern — operates on the UNROTATED rectangle. A rotated bed
|
||||
* is treated as if it were axis-aligned for the purpose of overlap testing.
|
||||
*/
|
||||
interface AABB {
|
||||
readonly id: string;
|
||||
readonly cx: number;
|
||||
readonly cy: number;
|
||||
readonly halfW: number;
|
||||
readonly halfD: number;
|
||||
readonly cos: number;
|
||||
readonly sin: number;
|
||||
readonly minX: number;
|
||||
readonly minY: number;
|
||||
readonly maxX: number;
|
||||
readonly maxY: number;
|
||||
readonly bottom: number;
|
||||
readonly top: number;
|
||||
}
|
||||
|
||||
function computeOBB(item: FurnitureItem): OBB {
|
||||
const rad = (item.rotation * Math.PI) / 180;
|
||||
function computeAabb(item: FurnitureItem): AABB {
|
||||
// Center is rotation-aware via the anchor helper. The AABB itself is
|
||||
// still the unrotated `width × depth` rectangle (per the project rule
|
||||
// that positioning math uses the unrotated extents), but its position
|
||||
// tracks the rotated anchor offset.
|
||||
const offset = rotatedAnchorOffsetToCenter(
|
||||
item.positionAnchor,
|
||||
item.width,
|
||||
item.depth,
|
||||
item.rotation,
|
||||
);
|
||||
const cx = item.x + offset.dx;
|
||||
const cy = item.y + offset.dy;
|
||||
const halfW = item.width / 2;
|
||||
const halfD = item.depth / 2;
|
||||
return {
|
||||
id: item.id,
|
||||
cx: item.x + item.width / 2,
|
||||
cy: item.y + item.depth / 2,
|
||||
halfW: item.width / 2,
|
||||
halfD: item.depth / 2,
|
||||
cos: Math.cos(rad),
|
||||
sin: Math.sin(rad),
|
||||
minX: cx - halfW,
|
||||
minY: cy - halfD,
|
||||
maxX: cx + halfW,
|
||||
maxY: cy + halfD,
|
||||
bottom: item.elevationFromFloor,
|
||||
top: item.elevationFromFloor + item.height,
|
||||
};
|
||||
}
|
||||
|
||||
/** Get the 4 corners of an OBB. */
|
||||
function getCorners(obb: OBB): [number, number][] {
|
||||
const { cx, cy, halfW, halfD, cos, sin } = obb;
|
||||
// Local corners at (±halfW, ±halfD), rotated and translated
|
||||
return [
|
||||
[cx + halfW * cos - halfD * sin, cy + halfW * sin + halfD * cos],
|
||||
[cx - halfW * cos - halfD * sin, cy - halfW * sin + halfD * cos],
|
||||
[cx - halfW * cos + halfD * sin, cy - halfW * sin - halfD * cos],
|
||||
[cx + halfW * cos + halfD * sin, cy + halfW * sin - halfD * cos],
|
||||
];
|
||||
}
|
||||
|
||||
/** Project corners onto an axis and return [min, max]. */
|
||||
function projectOntoAxis(corners: [number, number][], ax: number, ay: number): [number, number] {
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const [x, y] of corners) {
|
||||
const p = x * ax + y * ay;
|
||||
if (p < min) min = p;
|
||||
if (p > max) max = p;
|
||||
}
|
||||
return [min, max];
|
||||
}
|
||||
|
||||
/** SAT overlap test for two OBBs. */
|
||||
function obbOverlap(a: OBB, b: OBB): boolean {
|
||||
const cornersA = getCorners(a);
|
||||
const cornersB = getCorners(b);
|
||||
|
||||
// 4 potential separating axes: 2 from each OBB's edges
|
||||
const axes: [number, number][] = [
|
||||
[a.cos, a.sin],
|
||||
[-a.sin, a.cos],
|
||||
[b.cos, b.sin],
|
||||
[-b.sin, b.cos],
|
||||
];
|
||||
|
||||
for (const [ax, ay] of axes) {
|
||||
const [minA, maxA] = projectOntoAxis(cornersA, ax, ay);
|
||||
const [minB, maxB] = projectOntoAxis(cornersB, ax, ay);
|
||||
if (maxA <= minB || maxB <= minA) {
|
||||
return false; // Separating axis found — no overlap
|
||||
}
|
||||
}
|
||||
|
||||
return true; // No separating axis — overlapping
|
||||
function aabbOverlap(a: AABB, b: AABB): boolean {
|
||||
if (a.maxX <= b.minX || b.maxX <= a.minX) return false;
|
||||
if (a.maxY <= b.minY || b.maxY <= a.minY) return false;
|
||||
if (a.top <= b.bottom || b.top <= a.bottom) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all furniture IDs that collide using proper OBB (rotation-aware) overlap.
|
||||
* Find all furniture IDs that collide. Uses unrotated axis-aligned bounding
|
||||
* boxes — see the AABB doc comment for the rationale.
|
||||
*/
|
||||
export function findCollidingFurniture(
|
||||
items: readonly FurnitureItem[],
|
||||
): ReadonlySet<string> {
|
||||
if (items.length < 2) return new Set();
|
||||
|
||||
const obbs = items.map(computeOBB);
|
||||
const boxes = items.map(computeAabb);
|
||||
const colliding = new Set<string>();
|
||||
|
||||
for (let i = 0; i < obbs.length; i++) {
|
||||
for (let j = i + 1; j < obbs.length; j++) {
|
||||
// Check vertical overlap first (elevation + height)
|
||||
const a = items[i];
|
||||
const b = items[j];
|
||||
const aBottom = a.elevationFromFloor;
|
||||
const aTop = a.elevationFromFloor + a.height;
|
||||
const bBottom = b.elevationFromFloor;
|
||||
const bTop = b.elevationFromFloor + b.height;
|
||||
if (aTop <= bBottom || bTop <= aBottom) continue; // no vertical overlap
|
||||
|
||||
// Then check 2D footprint overlap
|
||||
if (obbOverlap(obbs[i], obbs[j])) {
|
||||
colliding.add(obbs[i].id);
|
||||
colliding.add(obbs[j].id);
|
||||
for (let i = 0; i < boxes.length; i++) {
|
||||
for (let j = i + 1; j < boxes.length; j++) {
|
||||
if (aabbOverlap(boxes[i], boxes[j])) {
|
||||
colliding.add(boxes[i].id);
|
||||
colliding.add(boxes[j].id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
// ── Projection axis (canonical orientation for elevation views) ──
|
||||
|
||||
/**
|
||||
* Pick a canonical orientation for the projection X axis.
|
||||
*
|
||||
* For axis-aligned walls, the projection axis is oriented so that it matches the
|
||||
* floor plan's positive X (for horizontal walls) or positive Y (for vertical walls)
|
||||
* direction. This means a south wall is shown left-to-right matching west→east on
|
||||
* the floor plan, instead of mirrored.
|
||||
*
|
||||
* Diagonal walls keep their natural start→end orientation.
|
||||
*
|
||||
* @returns the canonical start, end, length and whether the axis is flipped
|
||||
* relative to the wall's stored start→end.
|
||||
*/
|
||||
export interface ProjectionAxis {
|
||||
readonly start: Point;
|
||||
readonly end: Point;
|
||||
readonly length: number;
|
||||
/** True when the canonical axis runs opposite the wall's stored start→end. */
|
||||
readonly flipped: boolean;
|
||||
}
|
||||
|
||||
export function getProjectionAxis(wall: Wall): ProjectionAxis {
|
||||
const { start, end } = wallStartEnd(wall);
|
||||
const length = wallLength(wall);
|
||||
const dx = end.x - start.x;
|
||||
const dy = end.y - start.y;
|
||||
const ax = Math.abs(dx);
|
||||
const ay = Math.abs(dy);
|
||||
const isHorizontal = ax >= ay;
|
||||
|
||||
// Want horizontal walls to go +X, vertical walls to go +Y.
|
||||
const flipped = isHorizontal ? dx < 0 : dy < 0;
|
||||
if (flipped) {
|
||||
return { start: end, end: start, length, flipped: true };
|
||||
}
|
||||
return { start, end, length, flipped: false };
|
||||
}
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
/** Standard door height in meters. */
|
||||
@@ -58,6 +99,16 @@ export interface ProjectedElectrical {
|
||||
readonly item: ElectricalItem;
|
||||
readonly position: ProjectedPosition;
|
||||
readonly elevation: number;
|
||||
/**
|
||||
* True when the wall's stored start→end direction is opposite the
|
||||
* canonical projection axis (see `getProjectionAxis`). The renderer uses
|
||||
* this to mirror the horizontal anchor label: "left" in the canonical
|
||||
* projection view corresponds to "right" in the wall's natural frame
|
||||
* (which is what 3D uses). Without this flag, a left-anchored outlet on
|
||||
* a flipped wall would appear on opposite sides of the 3D and projection
|
||||
* views.
|
||||
*/
|
||||
readonly axisFlipped: boolean;
|
||||
}
|
||||
|
||||
/** Information about a furniture item projected onto a wall. */
|
||||
@@ -125,12 +176,18 @@ export function projectOpenings(
|
||||
wall: Wall,
|
||||
openings: readonly WallOpening[],
|
||||
): readonly ProjectedOpening[] {
|
||||
const wallLen = wallLength(wall);
|
||||
const axis = getProjectionAxis(wall);
|
||||
const wallLen = axis.length;
|
||||
return openings
|
||||
.filter((o) => o.wallId === wall.id)
|
||||
.map((opening) => {
|
||||
const halfWidth = opening.width / 2;
|
||||
const leftEdge = opening.positionAlongWall - halfWidth;
|
||||
// Map storage position (relative to wall.start) to projection position
|
||||
// (relative to canonical start). When the axis is flipped, mirror it.
|
||||
const projectedCenter = axis.flipped
|
||||
? wallLen - opening.positionAlongWall
|
||||
: opening.positionAlongWall;
|
||||
const leftEdge = projectedCenter - halfWidth;
|
||||
|
||||
const isDoor = opening.type === 'DOOR';
|
||||
const fromFloor = isDoor ? 0 : opening.elevationFromFloor;
|
||||
@@ -155,8 +212,8 @@ export function projectElectricalItems(
|
||||
wall: Wall,
|
||||
electricalItems: readonly ElectricalItem[],
|
||||
): readonly ProjectedElectrical[] {
|
||||
const { start, end } = wallStartEnd(wall);
|
||||
const wallLen = wallLength(wall);
|
||||
const axis = getProjectionAxis(wall);
|
||||
const { start, end, length: wallLen } = axis;
|
||||
|
||||
if (wallLen === 0) return [];
|
||||
|
||||
@@ -194,6 +251,7 @@ export function projectElectricalItems(
|
||||
item,
|
||||
position: { alongWall: Math.max(0, Math.min(wallLen, alongWall)), fromFloor: elevation },
|
||||
elevation,
|
||||
axisFlipped: axis.flipped,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -201,8 +259,59 @@ export function projectElectricalItems(
|
||||
// ── Furniture Projection ──
|
||||
|
||||
/**
|
||||
* Compute the distance from the nearest edge of a furniture item to a wall.
|
||||
* Returns the gap between the item's closest edge and the wall line.
|
||||
* Project the ROTATED half-extents of a furniture item onto the wall axis
|
||||
* and the wall-perpendicular axis.
|
||||
*
|
||||
* Rotation pivots around the rectangle centre, so the local axes become:
|
||||
* u = ( cos r, sin r) // width direction in world coords
|
||||
* v = (-sin r, cos r) // depth direction in world coords
|
||||
*
|
||||
* Projecting (w/2)·u and (d/2)·v onto the wall direction (and its
|
||||
* perpendicular) gives the support of the rotated bounding box along each
|
||||
* axis. This is what the user sees as the silhouette in 3D and top-down
|
||||
* views, so the projection view on the wall elevation must match it.
|
||||
*
|
||||
* History: an earlier iteration used unrotated extents under the rule
|
||||
* "rotation only affects visuals and hitbox, positioning uses the initial
|
||||
* coordinate system". That turned out to be wrong for projection because
|
||||
* projection-onto-a-wall IS a visual operation — it literally asks "what
|
||||
* does the rotated shape look like from this wall?" — so rotated extents
|
||||
* are the correct answer.
|
||||
*/
|
||||
function rotatedHalfExtents(
|
||||
item: FurnitureItem,
|
||||
wallDirX: number,
|
||||
wallDirY: number,
|
||||
): { halfAlong: number; halfPerp: number } {
|
||||
const r = ((item.rotation ?? 0) * Math.PI) / 180;
|
||||
const cos = Math.cos(r);
|
||||
const sin = Math.sin(r);
|
||||
const halfW = item.width / 2;
|
||||
const halfD = item.depth / 2;
|
||||
|
||||
// Rotated half-axis vectors in world space.
|
||||
const ux = cos * halfW;
|
||||
const uy = sin * halfW;
|
||||
const vx = -sin * halfD;
|
||||
const vy = cos * halfD;
|
||||
|
||||
// Wall-perpendicular unit vector (rotated 90° CCW from the wall direction).
|
||||
const perpX = -wallDirY;
|
||||
const perpY = wallDirX;
|
||||
|
||||
const halfAlong =
|
||||
Math.abs(ux * wallDirX + uy * wallDirY) + Math.abs(vx * wallDirX + vy * wallDirY);
|
||||
const halfPerp =
|
||||
Math.abs(ux * perpX + uy * perpY) + Math.abs(vx * perpX + vy * perpY);
|
||||
|
||||
return { halfAlong, halfPerp };
|
||||
}
|
||||
|
||||
/**
|
||||
* Distance from the nearest edge of a furniture item to a wall. Uses the
|
||||
* ROTATED bounding box so that a rotated item parallel to the wall is
|
||||
* picked up by the proximity filter when its edge is close, not when its
|
||||
* unrotated footprint happens to touch.
|
||||
*/
|
||||
function furnitureEdgeDistanceToWall(
|
||||
item: FurnitureItem,
|
||||
@@ -215,26 +324,32 @@ function furnitureEdgeDistanceToWall(
|
||||
const dx = (end.x - start.x) / wallLen;
|
||||
const dy = (end.y - start.y) / wallLen;
|
||||
|
||||
// x,y is top-left corner; compute center for distance calculation
|
||||
const cx = item.x + item.width / 2;
|
||||
const cy = item.y + item.depth / 2;
|
||||
// (item.x, item.y) is the anchored point on the rotated bounding box.
|
||||
// Convert to box centre.
|
||||
const offset = rotatedAnchorOffsetToCenter(
|
||||
item.positionAnchor,
|
||||
item.width,
|
||||
item.depth,
|
||||
item.rotation,
|
||||
);
|
||||
const cx = item.x + offset.dx;
|
||||
const cy = item.y + offset.dy;
|
||||
|
||||
// Vector from wall start to item center
|
||||
const vx = cx - start.x;
|
||||
const vy = cy - start.y;
|
||||
// Vector from wall start to item centre
|
||||
const dxC = cx - start.x;
|
||||
const dyC = cy - start.y;
|
||||
|
||||
// Perpendicular distance from center to wall
|
||||
const centerDist = Math.abs(vx * (-dy) + vy * dx);
|
||||
// Perpendicular distance from centre to wall line
|
||||
const centerDist = Math.abs(dxC * (-dy) + dyC * dx);
|
||||
|
||||
// Subtract the item's half-extent in the perpendicular direction
|
||||
// (approximation: use the larger of width/depth halves)
|
||||
const halfExtent = Math.max(item.width, item.depth) / 2;
|
||||
const edgeDist = Math.max(0, centerDist - halfExtent);
|
||||
const { halfAlong, halfPerp } = rotatedHalfExtents(item, dx, dy);
|
||||
const edgeDist = Math.max(0, centerDist - halfPerp);
|
||||
|
||||
// Along-wall distance: item must overlap with the wall's length
|
||||
const alongWall = vx * dx + vy * dy;
|
||||
const halfWidth = Math.max(item.width, item.depth) / 2;
|
||||
if (alongWall < -halfWidth || alongWall > wallLen + halfWidth) return Infinity;
|
||||
// Along-wall extent: item (rotated) must overlap the wall's length.
|
||||
const alongWallCenter = dxC * dx + dyC * dy;
|
||||
if (alongWallCenter < -halfAlong || alongWallCenter > wallLen + halfAlong) {
|
||||
return Infinity;
|
||||
}
|
||||
|
||||
return edgeDist;
|
||||
}
|
||||
@@ -245,8 +360,8 @@ export function projectFurnitureItems(
|
||||
furnitureItems: readonly FurnitureItem[],
|
||||
wallThreshold: number = 0.15,
|
||||
): readonly ProjectedFurniture[] {
|
||||
const { start, end } = wallStartEnd(wall);
|
||||
const wallLen = wallLength(wall);
|
||||
const axis = getProjectionAxis(wall);
|
||||
const { start, end, length: wallLen } = axis;
|
||||
if (wallLen === 0) return [];
|
||||
|
||||
const dx = (end.x - start.x) / wallLen;
|
||||
@@ -258,22 +373,29 @@ export function projectFurnitureItems(
|
||||
return dist < wallThreshold;
|
||||
})
|
||||
.map((item) => {
|
||||
// x,y is top-left corner; compute center for wall projection
|
||||
const cx = item.x + item.width / 2;
|
||||
const cy = item.y + item.depth / 2;
|
||||
const vx = cx - start.x;
|
||||
const vy = cy - start.y;
|
||||
const alongWall = vx * dx + vy * dy;
|
||||
// Convert anchored (x, y) to rotated bounding-box centre.
|
||||
const offset = rotatedAnchorOffsetToCenter(
|
||||
item.positionAnchor,
|
||||
item.width,
|
||||
item.depth,
|
||||
item.rotation,
|
||||
);
|
||||
const cx = item.x + offset.dx;
|
||||
const cy = item.y + offset.dy;
|
||||
const dxC = cx - start.x;
|
||||
const dyC = cy - start.y;
|
||||
const alongWallCenter = dxC * dx + dyC * dy;
|
||||
|
||||
// For wall projection, use the item's depth as the "width" we see from the side
|
||||
// and height as the vertical extent
|
||||
const projectedWidth = item.width;
|
||||
// Silhouette width on the wall = rotated half-extent along the wall
|
||||
// direction, doubled. Matches what the 3D view shows.
|
||||
const { halfAlong } = rotatedHalfExtents(item, dx, dy);
|
||||
const projectedWidth = halfAlong * 2;
|
||||
const fromFloor = item.elevationFromFloor ?? 0;
|
||||
|
||||
return {
|
||||
item,
|
||||
rect: {
|
||||
x: Math.max(0, alongWall - projectedWidth / 2),
|
||||
x: alongWallCenter - halfAlong,
|
||||
y: fromFloor,
|
||||
width: projectedWidth,
|
||||
height: item.height,
|
||||
@@ -290,16 +412,20 @@ export function computePlinthSegments(
|
||||
openings: readonly WallOpening[],
|
||||
plinthHeight: number,
|
||||
): readonly PlinthSegment[] {
|
||||
const wallLen = wallLength(wall);
|
||||
const axis = getProjectionAxis(wall);
|
||||
const wallLen = axis.length;
|
||||
if (wallLen <= 0 || plinthHeight <= 0) return [];
|
||||
|
||||
// Collect door gaps (sorted by position)
|
||||
// Collect door gaps (sorted by canonical projection position)
|
||||
const doors = openings
|
||||
.filter((o) => o.wallId === wall.id && o.type === 'DOOR')
|
||||
.map((o) => ({
|
||||
start: Math.max(0, o.positionAlongWall - o.width / 2),
|
||||
end: Math.min(wallLen, o.positionAlongWall + o.width / 2),
|
||||
}))
|
||||
.map((o) => {
|
||||
const projectedCenter = axis.flipped ? wallLen - o.positionAlongWall : o.positionAlongWall;
|
||||
return {
|
||||
start: Math.max(0, projectedCenter - o.width / 2),
|
||||
end: Math.min(wallLen, projectedCenter + o.width / 2),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.start - b.start);
|
||||
|
||||
if (doors.length === 0) {
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Outlet, Link, NavLink, useMatches } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import styles from './app-shell.module.css';
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = 'appShell.sidebar.collapsed';
|
||||
|
||||
function readSidebarCollapsed(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeSidebarCollapsed(value: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(value));
|
||||
} catch {
|
||||
/* ignore quota / disabled storage */
|
||||
}
|
||||
}
|
||||
|
||||
interface CrumbHandle {
|
||||
crumb?: string | ((data: unknown) => string);
|
||||
}
|
||||
@@ -18,6 +37,17 @@ export function AppShell() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const matches = useMatches() as MatchWithHandle[];
|
||||
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(() =>
|
||||
readSidebarCollapsed(),
|
||||
);
|
||||
const toggleSidebar = useCallback(() => {
|
||||
setSidebarCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
writeSidebarCollapsed(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const crumbs = matches
|
||||
.filter((m) => m.handle?.crumb)
|
||||
.map((m) => {
|
||||
@@ -95,7 +125,24 @@ export function AppShell() {
|
||||
{/* Body */}
|
||||
<div className={styles.body}>
|
||||
{/* 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
|
||||
to="/"
|
||||
end
|
||||
@@ -104,9 +151,10 @@ export function AppShell() {
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
title={sidebarCollapsed ? t('nav.apartments') : undefined}
|
||||
>
|
||||
<span className={styles.navIcon} aria-hidden="true">▢</span>
|
||||
{t('nav.apartments')}
|
||||
<span className={styles.navLabel}>{t('nav.apartments')}</span>
|
||||
</NavLink>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -133,6 +133,54 @@
|
||||
background-color: var(--color-bg-elevated);
|
||||
padding: var(--space-4) 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 {
|
||||
|
||||
@@ -10,6 +10,8 @@ interface RoomCardProps {
|
||||
apartmentId: string;
|
||||
onEdit: (room: Room) => void;
|
||||
onDelete: (room: Room) => void;
|
||||
onClone: (room: Room) => void;
|
||||
cloneLoading?: boolean;
|
||||
}
|
||||
|
||||
function ShapePreview({ shape }: { shape: readonly Point[] }) {
|
||||
@@ -66,8 +68,8 @@ function ShapePreview({ shape }: { shape: readonly Point[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function RoomCard({ room, apartmentId, onEdit, onDelete }: RoomCardProps) {
|
||||
const { t } = useTranslation();
|
||||
export function RoomCard({ room, apartmentId, onEdit, onDelete, onClone, cloneLoading = false }: RoomCardProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleClick = () => {
|
||||
@@ -84,6 +86,13 @@ export function RoomCard({ room, apartmentId, onEdit, onDelete }: RoomCardProps)
|
||||
onDelete(room);
|
||||
};
|
||||
|
||||
const handleClone = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
onClone(room);
|
||||
};
|
||||
|
||||
const cloneLabel = i18n.exists('roomCard.clone') ? t('roomCard.clone') : 'Clone';
|
||||
|
||||
const dimensions =
|
||||
room.width != null && room.height != null
|
||||
? 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')}>
|
||||
{t('roomCard.edit')}
|
||||
</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')}>
|
||||
{t('roomCard.delete')}
|
||||
</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);
|
||||
}
|
||||