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,7 +511,17 @@ export function EditorCanvas({ width, height, onStageRef }: EditorCanvasProps) {
onMouseUp={handleMouseUp}
style={{ cursor, background: '#ffffff' }}
>
{/* Layer 1: Grid + rulers */}
{/*
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.
*/}
{/* Background canvas — grid + rulers (rarely interacted with) */}
<Layer listening={false}>
<GridLayer
zoom={zoom}
panOffset={panOffset}
@@ -511,8 +530,10 @@ export function EditorCanvas({ width, height, onStageRef }: EditorCanvasProps) {
gridSize={gridSize}
visible={gridVisible}
/>
</Layer>
{/* Layer 2: Walls + room fill */}
{/* Content canvas room geometry, items, annotations, measurements */}
<Layer>
{layerVisibility.walls && (
<WallLayer
walls={walls}
@@ -524,7 +545,6 @@ export function EditorCanvas({ width, height, onStageRef }: EditorCanvasProps) {
/>
)}
{/* Layer 3: Openings (doors + windows) */}
<OpeningLayer
openings={openings}
walls={walls}
@@ -535,25 +555,25 @@ export function EditorCanvas({ width, height, onStageRef }: EditorCanvasProps) {
preview={openingPreview}
/>
{/* Layer 4: Electrical */}
<ElectricalLayer
items={electricalItems}
zoom={zoom}
panOffset={panOffset}
selectedIds={selectedIds}
visible={layerVisibility.electrical}
outletWidth={room.outletWidth}
outletHeight={room.outletHeight}
/>
{/* Layer 5: Furniture */}
<FurnitureLayer
items={furnitureItems}
zoom={zoom}
panOffset={panOffset}
selectedIds={selectedIds}
visible={layerVisibility.furniture}
globalOpacity={globalFurnitureOpacity}
/>
{/* Layer 6: Measurements */}
{layerVisibility.measurements && (
<MeasurementLayer
walls={walls}
@@ -565,7 +585,6 @@ export function EditorCanvas({ width, height, onStageRef }: EditorCanvasProps) {
/>
)}
{/* Layer 7: Room labels */}
{layerVisibility.measurements && (
<RoomLabelLayer
roomName={room.name}
@@ -575,7 +594,6 @@ export function EditorCanvas({ width, height, onStageRef }: EditorCanvasProps) {
/>
)}
{/* Layer 8: Annotations */}
{layerVisibility.annotations && (
<AnnotationLayer
annotations={annotations}
@@ -589,33 +607,39 @@ export function EditorCanvas({ width, height, onStageRef }: EditorCanvasProps) {
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 });
}
}}
onDoubleClick={(id) => setEditingAnnotationId(id)}
/>
)}
</Layer>
{/* Layer 9: Measure overlay */}
{/* Overlay canvas — transient interaction feedback (measure tool, selection) */}
<Layer listening={false}>
<MeasureOverlayLayer
measurement={measureState}
zoom={zoom}
panOffset={panOffset}
/>
{/* Layer 10: Selection overlay */}
<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
type="button"
style={{ fontSize: 11, padding: '0 4px' }}
onClick={() => setEditing({ kind: 'edit', annotation: ann })}
aria-label={t('annotation.edit') ?? 'Edit'}
>
</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: '12px',
fontSize: 12,
border: '1px solid var(--color-border)',
borderRadius: '4px',
borderRadius: 4,
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,
});
}
}}
onClick={() => setEditing({ kind: 'add' })}
>
{t('properties.addNote')}
</button>
</div>
)}
<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)}
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}
>
{/* 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 (
<Group x={x} y={y} rotation={rotation} listening={false}>
<Rect
x={x - width / 2}
y={y - depth / 2}
x={-width / 2}
y={-depth / 2}
width={width}
height={depth}
rotation={rotation}
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,18 +48,139 @@ 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>
) : (
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}>
{cat.items.map(({ def, index }) => (
{group.items.map(({ def, index }) => (
<button
key={index}
type="button"
className={[
styles.itemBtn,
selectedIndex === index ? styles.itemBtnActive : '',
@@ -72,13 +188,34 @@ export function ElectricalPalette({ selectedIndex, onSelect }: ElectricalPalette
onClick={() => handleSelect(index)}
title={def.label}
>
<span className={styles.itemIcon}>{cat.icon}</span>
<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,11 +51,117 @@ 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) => (
<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}
@@ -33,6 +172,28 @@ export function FurniturePalette({ selectedIndex, onSelect }: FurniturePalettePr
))}
</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>
);
}
@@ -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
{def.width}×{def.depth}m
</span>
</div>
</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' && (
{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 (
<>
{/* IEC outlet symbol: circle with two horizontal lines */}
<Circle
x={center.x}
y={center.y}
radius={half}
{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.5}
strokeWidth={1.25}
/>
<Circle
x={cellCx}
y={cellCy}
radius={faceR}
stroke={strokeColor}
strokeWidth={1.25}
fill="transparent"
/>
<Line
points={[center.x - 3, center.y - 2, center.x + 3, center.y - 2]}
points={[cellCx - prongG, cellCy - prongL, cellCx - prongG, cellCy + prongL]}
stroke={strokeColor}
strokeWidth={1.5}
strokeWidth={1.25}
/>
<Line
points={[center.x - 3, center.y + 2, center.x + 3, center.y + 2]}
points={[cellCx + prongG, cellCy - prongL, cellCx + prongG, cellCy + prongL]}
stroke={strokeColor}
strokeWidth={1.5}
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,13 +109,18 @@ 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[] = [];
if (showWallDimensions) {
// Wall width dimension (along bottom)
const floorLeft = projectionToPixel(0, 0, wallHeight, scale, padding);
const floorRight = projectionToPixel(wallLen, 0, wallHeight, scale, padding);
@@ -136,6 +152,7 @@ export function ProjectionMeasurements({
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(
<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
key={`elec-coord-${pe.item.id}`}
x={center.x + 10}
y={center.y - 4}
x={labelX}
y={labelY}
text={coordLabel}
fontSize={9}
fill="#64748b"
/>,
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) */}
{/* 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
points={[
topLeft.x + frameInset,
topLeft.y + pxHeight / 2,
topLeft.x + pxWidth - frameInset,
topLeft.y + pxHeight / 2,
]}
key={`vmul-${i}`}
points={[x, innerTop, x, innerTop + innerHeight]}
stroke="#3b82f6"
strokeWidth={1}
/>
{/* Vertical mullion (center divider) */}
/>,
);
}
for (let i = 1; i < rows; i++) {
const y = innerTop + (innerHeight * i) / rows;
lines.push(
<Line
points={[
topLeft.x + pxWidth / 2,
topLeft.y + frameInset,
topLeft.x + pxWidth / 2,
topLeft.y + pxHeight - frameInset,
]}
key={`hmul-${i}`}
points={[innerLeft, y, innerLeft + innerWidth, y]}
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}
/>
/>,
);
}
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 && (
{/* 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>
);
})()}
{/* Measurements (and per-furniture projection overlay, which renders
independently so it stays visible even when measurements are hidden) */}
<ProjectionMeasurements
projectedOpenings={projectedOpenings}
projectedElectrical={projectedElectrical}
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 */}
{/* 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
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.face} ${styles.faceTop}`}
onClick={() => onPreset('birds-eye')}
title="Bird's Eye"
>
{label}
Top
</button>
))}
<button
type="button"
className={`${styles.face} ${styles.faceFront}`}
onClick={() => onPreset('eye-level')}
title="Eye Level"
>
Eye
</button>
<div className={`${styles.face} ${styles.faceBack}`} />
<div className={`${styles.face} ${styles.faceRight}`} />
<div className={`${styles.face} ${styles.faceLeft}`} />
<div className={`${styles.face} ${styles.faceBottom}`} />
</div>
</div>
{/* Corner pins live outside .scene so they're never sucked into the
cube's 3D perspective transform. */}
<div className={styles.pins}>
<button
type="button"
className={`${styles.corner} ${styles.cornerNW}`}
onClick={() => onPreset('corner-nw')}
title="NW Corner"
aria-label="NW Corner"
>
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,14 +62,44 @@ 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]} />
<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>
);
}
/** Switch: slightly taller rectangular box on wall */
@@ -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;
// 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);
}
uv.needsUpdate = true;
}
const texture = new THREE.CanvasTexture(canvas);
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(2, 2);
return texture;
return geometry;
}
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,
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],
);
ctx.stroke();
}
// Plank gap
ctx.fillStyle = 'rgba(0,0,0,0.15)';
ctx.fillRect(0, y, size, 1);
// Vertical joint (staggered)
const jointX = (i % 2 === 0) ? size * 0.4 : size * 0.7;
ctx.fillRect(jointX, y, 1, plankHeight);
}
}
function drawHerringbone(ctx: CanvasRenderingContext2D, size: number, c1: string, c2: string, c3: string) {
ctx.fillStyle = c3;
ctx.fillRect(0, 0, size, size);
const plankW = size / 4;
const plankH = size / 8;
for (let row = -2; row < size / plankH + 2; row++) {
for (let col = -2; col < size / plankW + 2; col++) {
const isEven = (row + col) % 2 === 0;
ctx.fillStyle = isEven ? c1 : c2;
ctx.save();
const cx = col * plankW;
const cy = row * plankH;
ctx.translate(cx + plankW / 2, cy + plankH / 2);
ctx.rotate(isEven ? Math.PI / 4 : -Math.PI / 4);
ctx.fillRect(-plankW / 2, -plankH / 4, plankW, plankH / 2);
ctx.strokeStyle = 'rgba(0,0,0,0.1)';
ctx.lineWidth = 0.5;
ctx.strokeRect(-plankW / 2, -plankH / 4, plankW, plankH / 2);
ctx.restore();
}
}
}
function drawTiles(ctx: CanvasRenderingContext2D, size: number, c1: string, c2: string, grout: string) {
const tileSize = size / 4;
// Grout background
ctx.fillStyle = grout;
ctx.fillRect(0, 0, size, size);
const groutWidth = 3;
for (let row = 0; row < 4; row++) {
for (let col = 0; col < 4; col++) {
ctx.fillStyle = (row + col) % 2 === 0 ? c1 : c2;
ctx.fillRect(
col * tileSize + groutWidth / 2,
row * tileSize + groutWidth / 2,
tileSize - groutWidth,
tileSize - groutWidth,
);
}
}
}
function drawConcrete(ctx: CanvasRenderingContext2D, size: number) {
ctx.fillStyle = '#d4d0cc';
ctx.fillRect(0, 0, size, size);
// Noise texture
for (let i = 0; i < 5000; i++) {
const x = Math.random() * size;
const y = Math.random() * size;
const brightness = 180 + Math.random() * 40;
ctx.fillStyle = `rgb(${brightness},${brightness - 5},${brightness - 10})`;
ctx.fillRect(x, y, 2, 2);
}
}
const textureCache = new Map<FloorType, THREE.CanvasTexture>();
function getFloorTexture(floorType: FloorType): THREE.CanvasTexture {
let tex = textureCache.get(floorType);
if (!tex) {
tex = createFloorTexture(floorType);
textureCache.set(floorType, tex);
}
return tex;
}
export function FloorCeiling({ shape, wallHeight, floorType = 'CONCRETE' }: FloorCeilingProps) {
const floorGeometry = useMemo(() => createPolygonGeometry(shape), [shape]);
const texture = useMemo(() => getFloorTexture(floorType), [floorType]);
if (!floorGeometry) return null;
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]} />
{/* 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]} />
{/* 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 westeast on
* the floor plan, instead of mirrored.
*
* Diagonal walls keep their natural startend orientation.
*
* @returns the canonical start, end, length and whether the axis is flipped
* relative to the wall's stored startend.
*/
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 startend 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