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.
This commit is contained in:
2026-04-08 12:27:57 +03:00
parent aa8a874348
commit d8a914bf2a
116 changed files with 7324 additions and 1114 deletions
@@ -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": "Длина кабеля:",
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+23
View File
@@ -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, 112).
// 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>
/>
);
}
File diff suppressed because it is too large Load Diff
@@ -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,
};
}
+15 -1
View File
@@ -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) {
+50 -2
View File
@@ -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">&#9634;</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 {
+20 -2
View File
@@ -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);
}

Some files were not shown because too many files have changed in this diff Show More