feat: add outlet direction (horizontal/vertical), wall light styles, floor textures, and stretch ceiling

- Add configurable outlet direction (horizontal/vertical) stored in metadata
- Add wall light style variants (classic, pendant-globe, sconce-up, sconce-down)
- Add PBR floor textures including natural oak
- Add stretch ceiling offset support with DB migration
- Add furniture surface texture selection
- Add canvas theme colors utility for dark mode support
- Update projection views with improved rendering
- Add EN and RU translations for all new properties
This commit is contained in:
2026-04-12 20:52:49 +03:00
parent d8a914bf2a
commit 521ea5e85b
34 changed files with 1278 additions and 162 deletions
@@ -149,6 +149,8 @@
"toolbar.elec": "Elec",
"toolbar.furn": "Furn",
"toolbar.meas": "Meas",
"toolbar.stretchCeiling": "Ceiling",
"toolbar.toggleStretchCeiling": "Toggle stretch ceiling overlay",
"toolbar.toggleGrid": "Toggle grid",
"toolbar.toggleSnap": "Toggle snap",
"toolbar.toggleWalls": "Toggle walls layer",
@@ -222,6 +224,7 @@
"floor.WOOD_MEDIUM": "Medium Wood",
"floor.WOOD_DARK": "Dark Wood",
"floor.WOOD_HERRINGBONE": "Herringbone",
"floor.OAK_NATURAL": "Natural Oak",
"floor.TILE_WHITE": "White Tile",
"floor.TILE_GRAY": "Gray Tile",
"floor.LAMINATE": "Laminate",
@@ -242,6 +245,28 @@
"properties.outletWidth": "Outlet width",
"properties.outletHeight": "Outlet height",
"properties.outletCount": "Count",
"properties.outletDirectionLabel": "Direction",
"properties.outletDirection.horizontal": "Horizontal",
"properties.outletDirection.vertical": "Vertical",
"properties.stretchCeilingOffset": "Stretch ceiling drop",
"properties.wallLightStyleLabel": "Style",
"properties.wallLightStyle.classic": "Classic",
"properties.wallLightStyle.pendant-globe": "Pendant Globe",
"properties.wallLightStyle.sconce-up": "Sconce Up",
"properties.wallLightStyle.sconce-down": "Sconce Down",
"properties.cordLength": "Cord length",
"properties.lampSize": "Lamp size",
"properties.surfaceTexture": "Surface",
"furnitureTexture.NONE": "None (solid color)",
"furnitureTexture.WOOD_LIGHT": "Light Wood",
"furnitureTexture.WOOD_MEDIUM": "Medium Wood",
"furnitureTexture.WOOD_DARK": "Dark Wood",
"furnitureTexture.WOOD_HERRINGBONE": "Herringbone",
"furnitureTexture.OAK_NATURAL": "Natural Oak",
"furnitureTexture.LAMINATE": "Laminate",
"furnitureTexture.CONCRETE": "Concrete",
"properties.invertCoordX": "Invert X display",
"properties.invertCoordY": "Invert Y display",
"properties.anchor": "Anchor",
"anchor.left": "Left",
"anchor.middle": "Middle",
@@ -152,6 +152,8 @@
"toolbar.elec": "Элек",
"toolbar.furn": "Мебель",
"toolbar.meas": "Разм",
"toolbar.stretchCeiling": "Потолок",
"toolbar.toggleStretchCeiling": "Показать/скрыть натяжной потолок",
"toolbar.toggleGrid": "Переключить сетку",
"toolbar.toggleSnap": "Переключить привязку",
"toolbar.toggleWalls": "Переключить слой стен",
@@ -225,6 +227,7 @@
"floor.WOOD_MEDIUM": "Среднее дерево",
"floor.WOOD_DARK": "Тёмное дерево",
"floor.WOOD_HERRINGBONE": "Ёлочка",
"floor.OAK_NATURAL": "Натуральный дуб",
"floor.TILE_WHITE": "Белая плитка",
"floor.TILE_GRAY": "Серая плитка",
"floor.LAMINATE": "Ламинат",
@@ -245,6 +248,28 @@
"properties.outletWidth": "Ширина розетки",
"properties.outletHeight": "Высота розетки",
"properties.outletCount": "Количество",
"properties.outletDirectionLabel": "Направление",
"properties.outletDirection.horizontal": "Горизонтально",
"properties.outletDirection.vertical": "Вертикально",
"properties.stretchCeilingOffset": "Натяжной потолок (отступ)",
"properties.wallLightStyleLabel": "Стиль",
"properties.wallLightStyle.classic": "Классический",
"properties.wallLightStyle.pendant-globe": "Подвесной шар",
"properties.wallLightStyle.sconce-up": "Бра вверх",
"properties.wallLightStyle.sconce-down": "Бра вниз",
"properties.cordLength": "Длина шнура",
"properties.lampSize": "Размер светильника",
"properties.surfaceTexture": "Поверхность",
"furnitureTexture.NONE": "Нет (сплошной цвет)",
"furnitureTexture.WOOD_LIGHT": "Светлое дерево",
"furnitureTexture.WOOD_MEDIUM": "Среднее дерево",
"furnitureTexture.WOOD_DARK": "Тёмное дерево",
"furnitureTexture.WOOD_HERRINGBONE": "Ёлочка",
"furnitureTexture.OAK_NATURAL": "Натуральный дуб",
"furnitureTexture.LAMINATE": "Ламинат",
"furnitureTexture.CONCRETE": "Бетон",
"properties.invertCoordX": "Инвертировать X",
"properties.invertCoordY": "Инвертировать Y",
"properties.anchor": "Привязка",
"anchor.left": "Слева",
"anchor.middle": "По центру",
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

@@ -28,6 +28,7 @@ import { computeFurniturePreview, createFurnitureItemFromPlacement } from './too
import { startMeasurement, updateMeasurement, finishMeasurement } from './tools/MeasureTool';
import { ELECTRICAL_SYMBOL_DEFS } from './symbols/electrical';
import { FURNITURE_DEFS } from './symbols/furniture';
import { useCanvasColors } from './utils/canvasThemeColors';
import { AnnotationLayer } from './layers/AnnotationLayer';
import { MeasureOverlayLayer } from './layers/MeasureOverlayLayer';
import { generateLocalId } from './utils/geometry';
@@ -53,6 +54,7 @@ const TOOL_CURSORS: Record<string, string> = {
export function EditorCanvas({ width, height, onStageRef }: EditorCanvasProps) {
const { t } = useTranslation();
const canvasColors = useCanvasColors();
const { zoom, panOffset, setZoom, setPanOffset } = useZoomPan();
const {
selectedIds,
@@ -169,7 +171,11 @@ export function EditorCanvas({ width, height, onStageRef }: EditorCanvasProps) {
const worldPoint = getWorldPoint(e);
if (activeTool === 'select') {
const hit = hitTest(worldPoint, openings, walls, electricalItems, furnitureItems);
// When furniture is (nearly) fully transparent it's effectively
// invisible — skip it in hit-testing so clicks fall through to
// elements underneath instead of selecting a ghost.
const hittableFurniture = globalFurnitureOpacity < 0.05 ? [] : furnitureItems;
const hit = hitTest(worldPoint, openings, walls, electricalItems, hittableFurniture);
if (hit) {
if (e.evt.shiftKey) {
@@ -464,7 +470,9 @@ export function EditorCanvas({ width, height, onStageRef }: EditorCanvasProps) {
// Only if the rectangle is non-trivial
if (rect.width > 0.01 || rect.height > 0.01) {
const ids = elementsInRect(rect, openings, walls, electricalItems, furnitureItems);
// Skip furniture when globally transparent — see hit-test above.
const selectableFurniture = globalFurnitureOpacity < 0.05 ? [] : furnitureItems;
const ids = elementsInRect(rect, openings, walls, electricalItems, selectableFurniture);
if (ids.size > 0) {
selectionDispatch({ type: 'SET_SELECTED', ids });
}
@@ -509,7 +517,7 @@ export function EditorCanvas({ width, height, onStageRef }: EditorCanvasProps) {
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
style={{ cursor, background: '#ffffff' }}
style={{ cursor, background: canvasColors.canvasBg }}
>
{/*
Konva renders one HTML <canvas> per <Layer>; performance recommends 3-5
@@ -193,6 +193,16 @@ export function EditorToolbar({ onSave, isSaving, onExport, onImport }: EditorTo
>
{t('toolbar.meas')}
</button>
<button
className={[
styles.toggleBtn,
layerVisibility.stretchCeiling ? styles.toggleBtnActive : '',
].join(' ')}
onClick={() => dispatch({ type: 'TOGGLE_LAYER', layer: 'stretchCeiling' })}
title={t('toolbar.toggleStretchCeiling')}
>
{t('toolbar.stretchCeiling')}
</button>
<label
title={t('toolbar.furnitureOpacity') ?? 'Furniture opacity'}
style={{
@@ -1,19 +1,29 @@
import { useMemo, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import type { Wall, WallOpening, ElectricalItem, FurnitureItem, DoorOpenDirection, FloorType, WallFinish, Annotation, PositionAnchor, HorizontalAnchor, VerticalAnchor } from '@house-plan-maker/shared';
import type { Wall, WallOpening, ElectricalItem, FurnitureItem, DoorOpenDirection, FloorType, WallFinish, Annotation, PositionAnchor, HorizontalAnchor, VerticalAnchor, WallLightStyle, FurnitureTexture, OutletDirection } 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 { DOOR_OPEN_DIRECTIONS, FLOOR_TYPES, WALL_FINISHES, HORIZONTAL_ANCHORS, VERTICAL_ANCHORS, WALL_LIGHT_STYLES, OUTLET_DIRECTIONS, FURNITURE_TEXTURES, TEXTURABLE_FURNITURE } 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 {
getElectricalVariant,
getWallLightStyle,
getWallLightCordLength,
getCeilingLampSize,
ELECTRICAL_SYMBOL_DEFS,
getOutletInvertCoordX,
getOutletInvertCoordY,
getOutletDirection,
} from './symbols/electrical';
import {
getCurtainLeftOpen,
getCurtainRightOpen,
getCurtainFabricColor,
} from './utils/curtainMetadata';
import { getFurnitureTexture } from './utils/furnitureTextureMetadata';
import type { EditorCommand } from './types';
import styles from './properties-panel.module.css';
@@ -141,6 +151,22 @@ export function PropertiesPanel() {
)}
<PropertyRow label={t('properties.wallHeight')} value={`${room.wallHeight}m`} />
<PropertyRow label={t('properties.plinthHeight')} value={`${Math.round(room.plinthHeight * 1000) / 10}cm`} />
{/* Stretch ceiling drop (натяжной потолок). Stored in meters,
edited in cm for ergonomics. 0 = disabled. */}
<EditablePropertyRow
label={t('properties.stretchCeilingOffset')}
value={String(Math.round((room.stretchCeilingOffset ?? 0) * 1000) / 10)}
unit="cm"
onCommit={(v) => {
const cm = parseFloat(v);
if (!isNaN(cm) && cm >= 0 && cm <= 200) {
dispatch({
type: 'UPDATE_ROOM_PROPS',
props: { stretchCeilingOffset: cm / 100 },
});
}
}}
/>
<SelectPropertyRow<FloorType>
label={t('properties.floorType')}
value={room.floorType}
@@ -661,6 +687,25 @@ function PropertyRow({ label, value }: PropertyRowProps) {
);
}
interface CheckboxPropertyRowProps {
readonly label: string;
readonly checked: boolean;
readonly onChange: (checked: boolean) => void;
}
function CheckboxPropertyRow({ label, checked, onChange }: CheckboxPropertyRowProps) {
return (
<label className={styles.row} style={{ cursor: 'pointer' }}>
<span className={styles.rowLabel}>{label}</span>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
</label>
);
}
interface EditablePropertyRowProps {
readonly label: string;
readonly value: string;
@@ -876,8 +921,45 @@ function ElectricalProperties({ item, onUpdate }: ElectricalPropertiesProps) {
const isWallMounted = item.wallId !== null;
const isOutlet = item.type === 'OUTLET';
const isWallLight = item.type === 'LIGHT_WALL';
const isCeilingLight = item.type === 'LIGHT_CEILING';
const wallLightStyle = getWallLightStyle(item.metadata);
const cordLength = getWallLightCordLength(item.metadata);
const ceilingLampSize = getCeilingLampSize(item.metadata);
const countLabel = i18n.exists('properties.outletCount') ? t('properties.outletCount') : 'Count';
const outletDirection = getOutletDirection(item.metadata);
const outletDirectionOptions = useMemo(
() =>
OUTLET_DIRECTIONS.map((d) => ({
value: d,
label: i18n.exists(`properties.outletDirection.${d}`)
? t(`properties.outletDirection.${d}`)
: d === 'horizontal'
? 'Horizontal'
: 'Vertical',
})),
[t, i18n],
);
const wallLightStyleOptions = useMemo(
() =>
WALL_LIGHT_STYLES.map((s) => ({
value: s,
label: i18n.exists(`properties.wallLightStyle.${s}`)
? t(`properties.wallLightStyle.${s}`)
: s === 'classic'
? 'Classic'
: s === 'pendant-globe'
? 'Pendant Globe'
: s === 'sconce-up'
? 'Sconce Up'
: 'Sconce Down',
})),
[t, i18n],
);
const handleXChange = useCallback(
(value: string) => {
const num = parseFloat(value);
@@ -927,6 +1009,51 @@ function ElectricalProperties({ item, onUpdate }: ElectricalPropertiesProps) {
placeholder={labelPlaceholder}
onChange={(label) => onUpdate({ ...item, label })}
/>
{isWallLight && (
<SelectPropertyRow<WallLightStyle>
label={i18n.exists('properties.wallLightStyleLabel') ? t('properties.wallLightStyleLabel') : 'Style'}
value={wallLightStyle}
options={wallLightStyleOptions}
onChange={(style) =>
onUpdate({
...item,
metadata: { ...(item.metadata ?? {}), wallLightStyle: style },
})
}
/>
)}
{isWallLight && wallLightStyle === 'pendant-globe' && (
<EditablePropertyRow
label={i18n.exists('properties.cordLength') ? t('properties.cordLength') : 'Cord length'}
value={String(Math.round(cordLength * 1000) / 1000)}
unit="m"
onCommit={(v) => {
const num = parseFloat(v);
if (!isNaN(num) && num >= 0.05 && num <= 2.0) {
onUpdate({
...item,
metadata: { ...(item.metadata ?? {}), cordLength: num },
});
}
}}
/>
)}
{(isCeilingLight || isWallLight) && (
<EditablePropertyRow
label={i18n.exists('properties.lampSize') ? t('properties.lampSize') : 'Lamp size'}
value={String(Math.round(ceilingLampSize * 1000) / 1000)}
unit="m"
onCommit={(v) => {
const num = parseFloat(v);
if (!isNaN(num) && num >= 0.05 && num <= 1.0) {
onUpdate({
...item,
metadata: { ...(item.metadata ?? {}), lampSize: num },
});
}
}}
/>
)}
{isOutlet && (
<EditablePropertyRow
label={countLabel}
@@ -937,6 +1064,43 @@ function ElectricalProperties({ item, onUpdate }: ElectricalPropertiesProps) {
}}
/>
)}
{isOutlet && (
<SelectPropertyRow<OutletDirection>
label={i18n.exists('properties.outletDirectionLabel') ? t('properties.outletDirectionLabel') : 'Direction'}
value={outletDirection}
options={outletDirectionOptions}
onChange={(dir) =>
onUpdate({
...item,
metadata: { ...(item.metadata ?? {}), outletDirection: dir },
})
}
/>
)}
{isOutlet && (
<>
<CheckboxPropertyRow
label={t('properties.invertCoordX')}
checked={getOutletInvertCoordX(item.metadata)}
onChange={(checked) =>
onUpdate({
...item,
metadata: { ...(item.metadata ?? {}), invertCoordX: checked },
})
}
/>
<CheckboxPropertyRow
label={t('properties.invertCoordY')}
checked={getOutletInvertCoordY(item.metadata)}
onChange={(checked) =>
onUpdate({
...item,
metadata: { ...(item.metadata ?? {}), invertCoordY: checked },
})
}
/>
</>
)}
<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} />
@@ -989,8 +1153,23 @@ function preserveFurnitureMarkers(originalLabel: string | null, newDisplay: stri
}
function FurnitureProperties({ item, onUpdate }: FurniturePropertiesProps) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const displayLabel = stripFurnitureMarkers(item.label);
const isTexturable = TEXTURABLE_FURNITURE.includes(item.type);
const currentTexture = getFurnitureTexture(item.metadata);
const textureOptions = useMemo(
() =>
FURNITURE_TEXTURES.map((tex) => ({
value: tex,
label: i18n.exists(`furnitureTexture.${tex}`)
? t(`furnitureTexture.${tex}`)
: tex === 'NONE'
? 'None (solid color)'
: tex.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
})),
[t, i18n],
);
// 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;
@@ -1064,6 +1243,19 @@ function FurnitureProperties({ item, onUpdate }: FurniturePropertiesProps) {
onUpdate({ ...item, label: preserveFurnitureMarkers(item.label, newDisplay) })
}
/>
{isTexturable && (
<SelectPropertyRow<FurnitureTexture>
label={i18n.exists('properties.surfaceTexture') ? t('properties.surfaceTexture') : 'Surface'}
value={currentTexture}
options={textureOptions}
onChange={(tex) =>
onUpdate({
...item,
metadata: { ...(item.metadata ?? {}), surfaceTexture: tex },
})
}
/>
)}
<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} />
@@ -85,6 +85,7 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
state.room.wallHeight !== saved.room.wallHeight ||
state.room.plinthHeight !== saved.room.plinthHeight ||
state.room.plinthThickness !== saved.room.plinthThickness ||
state.room.stretchCeilingOffset !== saved.room.stretchCeilingOffset ||
state.room.outletWidth !== saved.room.outletWidth ||
state.room.outletHeight !== saved.room.outletHeight ||
state.room.name !== saved.room.name;
@@ -100,6 +101,7 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
state.room.wallHeight,
state.room.plinthHeight,
state.room.plinthThickness,
state.room.stretchCeilingOffset,
state.room.outletWidth,
state.room.outletHeight,
state.room.name,
@@ -277,6 +279,7 @@ export function RoomEditorLayout({ roomId }: RoomEditorLayoutProps) {
wallHeight: state.room.wallHeight,
plinthHeight: state.room.plinthHeight,
plinthThickness: state.room.plinthThickness,
stretchCeilingOffset: state.room.stretchCeilingOffset,
outletWidth: state.room.outletWidth,
outletHeight: state.room.outletHeight,
});
@@ -73,7 +73,7 @@ function createInitialState(room: RoomFull): EditorState {
gridVisible: true,
snapEnabled: true,
snapGranularity: DEFAULT_GRID_SIZE,
layerVisibility: { walls: true, electrical: true, furniture: true, measurements: true, annotations: true },
layerVisibility: { walls: true, electrical: true, furniture: true, measurements: true, annotations: true, stretchCeiling: true },
selectedElectricalIndex: null,
selectedFurnitureIndex: null,
annotations: room.annotations ?? [],
@@ -1,6 +1,7 @@
import { memo, useMemo } from 'react';
import { Group, Line, Text, Rect } from 'react-konva';
import type { Point } from '@house-plan-maker/shared';
import { useCanvasColors } from '../utils/canvasThemeColors';
interface GridLayerProps {
readonly zoom: number;
@@ -10,15 +11,6 @@ interface GridLayerProps {
readonly gridSize: number;
readonly visible: boolean;
}
/** Color for thin grid lines. */
const GRID_LINE_COLOR = '#e0e0e0';
/** Color for major grid lines (every 1m). */
const MAJOR_GRID_LINE_COLOR = '#c0c0c0';
/** Color for ruler background. */
const RULER_BG_COLOR = '#f5f5f5';
/** Color for ruler text and ticks. */
const RULER_TEXT_COLOR = '#666';
/** Ruler size in pixels. */
const RULER_SIZE = 24;
@@ -30,6 +22,7 @@ export const GridLayer = memo(function GridLayer({
gridSize,
visible,
}: GridLayerProps) {
const colors = useCanvasColors();
const gridLines = useMemo(() => {
if (!visible) return { lines: [], majorLines: [] };
@@ -136,7 +129,7 @@ export const GridLayer = memo(function GridLayer({
<Line
key={`g-${i}`}
points={line.points}
stroke={GRID_LINE_COLOR}
stroke={colors.gridLine}
strokeWidth={0.5}
listening={false}
/>
@@ -146,7 +139,7 @@ export const GridLayer = memo(function GridLayer({
<Line
key={`gm-${i}`}
points={line.points}
stroke={MAJOR_GRID_LINE_COLOR}
stroke={colors.gridLineMajor}
strokeWidth={1}
listening={false}
/>
@@ -158,7 +151,7 @@ export const GridLayer = memo(function GridLayer({
y={0}
width={stageWidth}
height={RULER_SIZE}
fill={RULER_BG_COLOR}
fill={colors.rulerBg}
listening={false}
/>
{rulerMarks.hMarks.map((mark, i) => (
@@ -168,7 +161,7 @@ export const GridLayer = memo(function GridLayer({
y={4}
text={mark.label}
fontSize={9}
fill={RULER_TEXT_COLOR}
fill={colors.rulerText}
listening={false}
/>
))}
@@ -176,7 +169,7 @@ export const GridLayer = memo(function GridLayer({
<Line
key={`rht-${i}`}
points={[mark.screenX, RULER_SIZE - 6, mark.screenX, RULER_SIZE]}
stroke={RULER_TEXT_COLOR}
stroke={colors.rulerText}
strokeWidth={1}
listening={false}
/>
@@ -188,7 +181,7 @@ export const GridLayer = memo(function GridLayer({
y={0}
width={RULER_SIZE}
height={stageHeight}
fill={RULER_BG_COLOR}
fill={colors.rulerBg}
listening={false}
/>
{rulerMarks.vMarks.map((mark, i) => (
@@ -198,7 +191,7 @@ export const GridLayer = memo(function GridLayer({
y={mark.screenY - 4}
text={mark.label}
fontSize={9}
fill={RULER_TEXT_COLOR}
fill={colors.rulerText}
listening={false}
/>
))}
@@ -206,7 +199,7 @@ export const GridLayer = memo(function GridLayer({
<Line
key={`rvt-${i}`}
points={[RULER_SIZE - 6, mark.screenY, RULER_SIZE, mark.screenY]}
stroke={RULER_TEXT_COLOR}
stroke={colors.rulerText}
strokeWidth={1}
listening={false}
/>
@@ -218,7 +211,7 @@ export const GridLayer = memo(function GridLayer({
y={0}
width={RULER_SIZE}
height={RULER_SIZE}
fill={RULER_BG_COLOR}
fill={colors.rulerBg}
listening={false}
/>
</Group>
@@ -2,6 +2,7 @@ import { Group, Rect, Line, Text } from 'react-konva';
import type { ProjectedOpening } from '../utils/projectionMapping';
import { projectionToPixel } from '../utils/projectionMapping';
import type { DoorOpenDirection } from '@house-plan-maker/shared';
import { useCanvasColors } from '../utils/canvasThemeColors';
interface ProjectionDoorProps {
readonly projected: ProjectedOpening;
@@ -67,6 +68,7 @@ export function ProjectionDoor({
onClick,
onDragStart,
}: ProjectionDoorProps) {
const colors = useCanvasColors();
const { rect, opening } = projected;
const openDirection: DoorOpenDirection = opening.openDirection ?? 'LEFT';
@@ -102,7 +104,7 @@ export function ProjectionDoor({
y={topLeft.y - 2}
width={pxWidth + 4}
height={pxHeight + 4}
stroke="#2563eb"
stroke={colors.selectedStroke}
strokeWidth={1}
dash={[3, 3]}
fill="transparent"
@@ -114,20 +116,20 @@ export function ProjectionDoor({
y={topLeft.y}
width={pxWidth}
height={pxHeight}
fill="#ffffff"
stroke={isSelected ? '#2563eb' : '#64748b'}
fill={colors.canvasBg}
stroke={isSelected ? colors.selectedStroke : colors.electricalStroke}
strokeWidth={isSelected ? 2 : 1}
/>
{/* Door leaf line (vertical line showing hinge side) */}
<Line
points={leafPoints}
stroke="#94a3b8"
stroke={colors.dimensionLine}
strokeWidth={2}
/>
{/* Door swing indicator arc */}
<Line
points={arcPoints}
stroke="#94a3b8"
stroke={colors.dimensionLine}
strokeWidth={1}
dash={[4, 3]}
/>
@@ -139,7 +141,7 @@ export function ProjectionDoor({
text="D"
align="center"
fontSize={11}
fill="#64748b"
fill={colors.dimensionText}
/>
</Group>
);
@@ -1,7 +1,9 @@
import { Group, Circle, Line, Rect, Text } from 'react-konva';
import { Group, Circle, Line, Rect, Text, Arc } 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';
import { getWallLightStyle, getOutletDirection } from '../symbols/electrical';
import { useCanvasColors } from '../utils/canvasThemeColors';
interface ProjectionElectricalProps {
readonly projected: ProjectedElectrical;
@@ -37,6 +39,7 @@ export function ProjectionElectrical({
onClick,
onDragStart,
}: ProjectionElectricalProps) {
const colors = useCanvasColors();
const { position, item } = projected;
const displayFromFloor = isDragging && dragFromFloor != null ? dragFromFloor : position.fromFloor;
@@ -50,8 +53,8 @@ export function ProjectionElectrical({
padding,
);
const strokeColor = isSelected ? '#2563eb' : '#64748b';
const fillColor = isSelected ? '#dbeafe' : '#f1f5f9';
const strokeColor = isSelected ? colors.selectedStroke : colors.electricalStroke;
const fillColor = isSelected ? colors.selectedFill : colors.electricalFill;
const half = SYMBOL_SIZE / 2;
return (
@@ -71,7 +74,7 @@ export function ProjectionElectrical({
y={center.y - half - 2}
width={SYMBOL_SIZE + 4}
height={SYMBOL_SIZE + 4}
stroke="#2563eb"
stroke={colors.selectedStroke}
strokeWidth={1}
dash={[3, 3]}
fill="transparent"
@@ -79,6 +82,8 @@ export function ProjectionElectrical({
)}
{item.type === 'OUTLET' && (() => {
const safeCount = Math.max(1, Math.round(item.count));
const direction = getOutletDirection(item.metadata);
const isVertical = direction === 'vertical';
// Convert physical outlet dims to projection-pixel dims.
const wPx = outletWidth * scale;
const hPx = outletHeight * scale;
@@ -100,15 +105,17 @@ export function ProjectionElectrical({
? 'left'
: 'middle'
: anchor.horizontal;
const totalW = safeCount * wPx;
// Bounding box of the full outlet group depends on layout direction.
const totalW = isVertical ? wPx : safeCount * wPx;
const totalH = isVertical ? safeCount * hPx : hPx;
const offX =
mirroredHorizontal === 'left' ? totalW / 2 : mirroredHorizontal === 'right' ? -totalW / 2 : 0;
const offY =
anchor.vertical === 'top' ? hPx / 2 : anchor.vertical === 'bottom' ? -hPx / 2 : 0;
anchor.vertical === 'top' ? totalH / 2 : anchor.vertical === 'bottom' ? -totalH / 2 : 0;
const cx = center.x + offX;
const cy = center.y + offY;
const left = cx - totalW / 2;
const top = cy - hPx / 2;
const groupLeft = cx - totalW / 2;
const groupTop = cy - totalH / 2;
const cellMin = Math.min(wPx, hPx);
const faceR = cellMin * 0.32;
const prongL = cellMin * 0.18;
@@ -116,14 +123,15 @@ export function ProjectionElectrical({
return (
<>
{Array.from({ length: safeCount }).map((_, i) => {
const cellLeft = left + i * wPx;
const cellLeft = isVertical ? groupLeft : groupLeft + i * wPx;
const cellTop = isVertical ? groupTop + i * hPx : groupTop;
const cellCx = cellLeft + wPx / 2;
const cellCy = top + hPx / 2;
const cellCy = cellTop + hPx / 2;
return (
<Group key={i}>
<Rect
x={cellLeft}
y={top}
y={cellTop}
width={wPx}
height={hPx}
cornerRadius={Math.max(1, cellMin * 0.12)}
@@ -178,35 +186,110 @@ export function ProjectionElectrical({
/>
</>
)}
{item.type === 'LIGHT_WALL' && (
<>
{/* Wall light: semicircle shape */}
<Circle
x={center.x}
y={center.y}
radius={half}
fill="#fef9c3"
stroke={strokeColor}
strokeWidth={1.5}
/>
<Line
points={[
center.x - half, center.y,
center.x - half * 1.3, center.y + half * 0.8,
]}
stroke="#eab308"
strokeWidth={1}
/>
<Line
points={[
center.x + half, center.y,
center.x + half * 1.3, center.y + half * 0.8,
]}
stroke="#eab308"
strokeWidth={1}
/>
</>
)}
{item.type === 'LIGHT_WALL' && (() => {
const wallLightStyle = getWallLightStyle(item.metadata);
if (wallLightStyle === 'pendant-globe') {
// Pendant globe: gooseneck line + hanging cord + circle globe
const armTop = center.y - half * 1.8;
const cordBottom = center.y + half * 0.6;
return (
<>
{/* Wall mount dot */}
<Circle x={center.x} y={center.y} radius={3} fill="#b8860b" stroke={strokeColor} strokeWidth={1} />
{/* Gooseneck arm going up */}
<Line points={[center.x, center.y, center.x, armTop]} stroke="#b8860b" strokeWidth={1.5} />
{/* Arm curving outward */}
<Line points={[center.x, armTop, center.x + half * 0.8, armTop]} stroke="#b8860b" strokeWidth={1.5} />
{/* Cord hanging down */}
<Line points={[center.x + half * 0.8, armTop, center.x + half * 0.8, cordBottom]} stroke="#d4c090" strokeWidth={1} />
{/* Glass globe */}
<Circle
x={center.x + half * 0.8}
y={cordBottom + half * 0.5}
radius={half * 0.7}
fill="rgba(254, 249, 195, 0.4)"
stroke="#eab308"
strokeWidth={1}
/>
</>
);
}
if (wallLightStyle === 'sconce-up') {
// Sconce up: trapezoid pointing up
return (
<>
<Line
points={[
center.x - half * 0.8, center.y + half * 0.5,
center.x - half * 0.4, center.y - half,
center.x + half * 0.4, center.y - half,
center.x + half * 0.8, center.y + half * 0.5,
]}
closed
fill="#fef9c3"
stroke={strokeColor}
strokeWidth={1.5}
/>
{/* Light rays upward */}
<Line points={[center.x, center.y - half, center.x, center.y - half * 1.8]} stroke="#eab308" strokeWidth={1} />
<Line points={[center.x - half * 0.3, center.y - half, center.x - half * 0.5, center.y - half * 1.6]} stroke="#eab308" strokeWidth={1} />
<Line points={[center.x + half * 0.3, center.y - half, center.x + half * 0.5, center.y - half * 1.6]} stroke="#eab308" strokeWidth={1} />
</>
);
}
if (wallLightStyle === 'sconce-down') {
// Sconce down: trapezoid pointing down
return (
<>
<Line
points={[
center.x - half * 0.8, center.y - half * 0.5,
center.x - half * 0.4, center.y + half,
center.x + half * 0.4, center.y + half,
center.x + half * 0.8, center.y - half * 0.5,
]}
closed
fill="#fef9c3"
stroke={strokeColor}
strokeWidth={1.5}
/>
{/* Light rays downward */}
<Line points={[center.x, center.y + half, center.x, center.y + half * 1.8]} stroke="#eab308" strokeWidth={1} />
<Line points={[center.x - half * 0.3, center.y + half, center.x - half * 0.5, center.y + half * 1.6]} stroke="#eab308" strokeWidth={1} />
<Line points={[center.x + half * 0.3, center.y + half, center.x + half * 0.5, center.y + half * 1.6]} stroke="#eab308" strokeWidth={1} />
</>
);
}
// Classic: original circle with light rays
return (
<>
<Circle
x={center.x}
y={center.y}
radius={half}
fill="#fef9c3"
stroke={strokeColor}
strokeWidth={1.5}
/>
<Line
points={[
center.x - half, center.y,
center.x - half * 1.3, center.y + half * 0.8,
]}
stroke="#eab308"
strokeWidth={1}
/>
<Line
points={[
center.x + half, center.y,
center.x + half * 1.3, center.y + half * 0.8,
]}
stroke="#eab308"
strokeWidth={1}
/>
</>
);
})()}
{/* Fallback for other wall-mounted types */}
{item.type !== 'OUTLET' && item.type !== 'SWITCH' && item.type !== 'LIGHT_WALL' && (
<Rect
@@ -236,7 +319,7 @@ export function ProjectionElectrical({
}
align="center"
fontSize={8}
fill="#94a3b8"
fill={colors.electricalLabel}
/>
</Group>
);
@@ -1,6 +1,7 @@
import { Group, Rect, Text } from 'react-konva';
import type { ProjectedFurniture } from '../utils/projectionMapping';
import { projectionToPixel } from '../utils/projectionMapping';
import { useCanvasColors } from '../utils/canvasThemeColors';
interface ProjectionFurnitureProps {
readonly projected: ProjectedFurniture;
@@ -9,7 +10,8 @@ interface ProjectionFurnitureProps {
readonly padding: number;
readonly isSelected: boolean;
readonly globalOpacity?: number;
readonly onClick: () => void;
/** Omit to make the item non-interactive (events fall through). */
readonly onClick?: () => void;
}
const TYPE_COLORS: Record<string, string> = {
@@ -31,6 +33,7 @@ export function ProjectionFurniture({
globalOpacity = 1,
onClick,
}: ProjectionFurnitureProps) {
const colors = useCanvasColors();
const { rect, item } = projected;
const topLeft = projectionToPixel(rect.x, rect.y + rect.height, wallHeight, scale, padding);
@@ -40,15 +43,16 @@ export function ProjectionFurniture({
const color = TYPE_COLORS[item.type] ?? '#a0845c';
const itemOpacity = (item.opacity ?? 1) * globalOpacity;
const interactive = onClick !== undefined;
return (
<Group onClick={onClick} opacity={itemOpacity}>
<Group onClick={onClick} opacity={itemOpacity} listening={interactive}>
<Rect
x={topLeft.x}
y={topLeft.y}
width={pxWidth}
height={pxHeight}
fill={isSelected ? '#dbeafe' : color}
stroke={isSelected ? '#2563eb' : '#6b5b3a'}
fill={isSelected ? colors.selectedFill : color}
stroke={isSelected ? colors.selectedStroke : colors.furnitureStroke}
strokeWidth={isSelected ? 2 : 1}
opacity={0.7}
/>
@@ -60,7 +64,7 @@ export function ProjectionFurniture({
text={item.label ?? item.type}
align="center"
fontSize={9}
fill={isSelected ? '#1e40af' : '#3b2f1e'}
fill={isSelected ? colors.selectedText : colors.furnitureText}
ellipsis
/>
</Group>
@@ -1,5 +1,6 @@
import type { ReactNode } from 'react';
import { Group, Rect, Line, Text } from 'react-konva';
import { useCanvasColors } from '../utils/canvasThemeColors';
import type {
ProjectedOpening,
ProjectedElectrical,
@@ -7,6 +8,7 @@ import type {
} from '../utils/projectionMapping';
import { projectionToPixel } from '../utils/projectionMapping';
import { DEFAULT_OUTLET_WIDTH, DEFAULT_OUTLET_HEIGHT } from '@house-plan-maker/shared';
import { getOutletInvertCoordX, getOutletInvertCoordY } from '../symbols/electrical';
interface ProjectionMeasurementsProps {
readonly projectedOpenings: readonly ProjectedOpening[];
@@ -25,7 +27,7 @@ interface ProjectionMeasurementsProps {
/** Dimension line with arrows and text. */
function DimensionLine({
x1, y1, x2, y2, label, offset, horizontal,
x1, y1, x2, y2, label, offset, horizontal, lineColor, textColor,
}: {
readonly x1: number;
readonly y1: number;
@@ -34,6 +36,8 @@ function DimensionLine({
readonly label: string;
readonly offset: number;
readonly horizontal: boolean;
readonly lineColor: string;
readonly textColor: string;
}) {
const arrowSize = 4;
@@ -43,19 +47,19 @@ function DimensionLine({
return (
<Group>
{/* Extension lines */}
<Line points={[x1, y1, x1, lineY]} stroke="#94a3b8" strokeWidth={0.5} />
<Line points={[x2, y2, x2, lineY]} stroke="#94a3b8" strokeWidth={0.5} />
<Line points={[x1, y1, x1, lineY]} stroke={lineColor} strokeWidth={0.5} />
<Line points={[x2, y2, x2, lineY]} stroke={lineColor} strokeWidth={0.5} />
{/* Main line */}
<Line points={[x1, lineY, x2, lineY]} stroke="#94a3b8" strokeWidth={0.75} />
<Line points={[x1, lineY, x2, lineY]} stroke={lineColor} strokeWidth={0.75} />
{/* Arrows */}
<Line
points={[x1, lineY, x1 + arrowSize, lineY - arrowSize / 2, x1 + arrowSize, lineY + arrowSize / 2]}
fill="#94a3b8"
fill={lineColor}
closed
/>
<Line
points={[x2, lineY, x2 - arrowSize, lineY - arrowSize / 2, x2 - arrowSize, lineY + arrowSize / 2]}
fill="#94a3b8"
fill={lineColor}
closed
/>
{/* Label */}
@@ -66,7 +70,7 @@ function DimensionLine({
text={label}
align="center"
fontSize={9}
fill="#64748b"
fill={textColor}
/>
</Group>
);
@@ -77,17 +81,17 @@ function DimensionLine({
const midY = (y1 + y2) / 2;
return (
<Group>
<Line points={[x1, y1, lineX, y1]} stroke="#94a3b8" strokeWidth={0.5} />
<Line points={[x1, y2, lineX, y2]} stroke="#94a3b8" strokeWidth={0.5} />
<Line points={[lineX, y1, lineX, y2]} stroke="#94a3b8" strokeWidth={0.75} />
<Line points={[x1, y1, lineX, y1]} stroke={lineColor} strokeWidth={0.5} />
<Line points={[x1, y2, lineX, y2]} stroke={lineColor} strokeWidth={0.5} />
<Line points={[lineX, y1, lineX, y2]} stroke={lineColor} strokeWidth={0.75} />
<Line
points={[lineX, y1, lineX - arrowSize / 2, y1 + arrowSize, lineX + arrowSize / 2, y1 + arrowSize]}
fill="#94a3b8"
fill={lineColor}
closed
/>
<Line
points={[lineX, y2, lineX - arrowSize / 2, y2 - arrowSize, lineX + arrowSize / 2, y2 - arrowSize]}
fill="#94a3b8"
fill={lineColor}
closed
/>
<Text
@@ -95,7 +99,7 @@ function DimensionLine({
y={midY - 5}
text={label}
fontSize={9}
fill="#64748b"
fill={textColor}
/>
</Group>
);
@@ -118,6 +122,7 @@ export function ProjectionMeasurements({
outletHeight = DEFAULT_OUTLET_HEIGHT,
showWallDimensions = true,
}: ProjectionMeasurementsProps) {
const colors = useCanvasColors();
const elements: ReactNode[] = [];
if (showWallDimensions) {
@@ -134,6 +139,8 @@ export function ProjectionMeasurements({
label={formatM(wallLen)}
offset={18}
horizontal
lineColor={colors.dimensionLine}
textColor={colors.dimensionText}
/>,
);
@@ -150,6 +157,8 @@ export function ProjectionMeasurements({
label={formatM(wallHeight)}
offset={18}
horizontal={false}
lineColor={colors.dimensionLine}
textColor={colors.dimensionText}
/>,
);
}
@@ -171,6 +180,8 @@ export function ProjectionMeasurements({
label={formatM(rect.height)}
offset={-14}
horizontal={false}
lineColor={colors.dimensionLine}
textColor={colors.dimensionText}
/>,
);
@@ -187,6 +198,8 @@ export function ProjectionMeasurements({
label={formatM(rect.y)}
offset={-14}
horizontal={false}
lineColor={colors.dimensionLine}
textColor={colors.dimensionText}
/>,
);
}
@@ -203,6 +216,8 @@ export function ProjectionMeasurements({
label={formatM(rect.width)}
offset={-12}
horizontal
lineColor={colors.dimensionLine}
textColor={colors.dimensionText}
/>,
);
}
@@ -229,7 +244,14 @@ export function ProjectionMeasurements({
halfWidthPx = (safeCount * outletWidth * scale) / 2;
}
const coordLabel = `(${pe.position.alongWall.toFixed(2)}; ${pe.elevation.toFixed(2)})`;
// Outlets can opt into inverted coordinate read-outs per axis — useful
// when the user measures distances from the opposite wall edge or from
// the ceiling. Display-only: the stored position is untouched.
const invertX = pe.item.type === 'OUTLET' && getOutletInvertCoordX(pe.item.metadata);
const invertY = pe.item.type === 'OUTLET' && getOutletInvertCoordY(pe.item.metadata);
const displayX = invertX ? wallLen - pe.position.alongWall : pe.position.alongWall;
const displayY = invertY ? wallHeight - pe.elevation : pe.elevation;
const coordLabel = `(${displayX.toFixed(2)}; ${displayY.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.
@@ -241,7 +263,7 @@ export function ProjectionMeasurements({
y={labelY - 1}
width={labelWidth}
height={12}
fill="rgba(255, 255, 255, 0.85)"
fill={colors.coordLabelBg}
cornerRadius={2}
listening={false}
/>
@@ -250,7 +272,7 @@ export function ProjectionMeasurements({
y={labelY}
text={coordLabel}
fontSize={9}
fill="#475569"
fill={colors.coordLabelText}
listening={false}
/>
</Group>,
@@ -274,7 +296,7 @@ export function ProjectionMeasurements({
y={openingCenter.y - 22}
text={formatM(rect.x + rect.width / 2)}
fontSize={8}
fill="#94a3b8"
fill={colors.openingLabel}
align="center"
width={32}
/>,
@@ -313,6 +335,8 @@ export function ProjectionMeasurements({
label={formatM(rect.width)}
offset={32}
horizontal
lineColor={colors.dimensionLine}
textColor={colors.dimensionText}
/>,
);
if (rect.x > 0.001) {
@@ -327,6 +351,8 @@ export function ProjectionMeasurements({
label={formatM(rect.x)}
offset={46}
horizontal
lineColor={colors.dimensionLine}
textColor={colors.dimensionText}
/>,
);
}
@@ -342,6 +368,8 @@ export function ProjectionMeasurements({
label={formatM(rect.width)}
offset={14}
horizontal
lineColor={colors.dimensionLine}
textColor={colors.dimensionText}
/>,
);
// Inline start-offset (distance from wall start to the left edge),
@@ -359,6 +387,8 @@ export function ProjectionMeasurements({
label={formatM(rect.x)}
offset={-6}
horizontal
lineColor={colors.dimensionLine}
textColor={colors.dimensionText}
/>,
);
}
@@ -381,6 +411,8 @@ export function ProjectionMeasurements({
label={formatM(rect.height)}
offset={-32}
horizontal={false}
lineColor={colors.dimensionLine}
textColor={colors.dimensionText}
/>,
);
if (rect.y > 0.001) {
@@ -395,6 +427,8 @@ export function ProjectionMeasurements({
label={formatM(rect.y)}
offset={-46}
horizontal={false}
lineColor={colors.dimensionLine}
textColor={colors.dimensionText}
/>,
);
}
@@ -410,6 +444,8 @@ export function ProjectionMeasurements({
label={formatM(rect.height)}
offset={-14}
horizontal={false}
lineColor={colors.dimensionLine}
textColor={colors.dimensionText}
/>,
);
// Inline elevation (distance from floor to the bottom of the item),
@@ -427,6 +463,8 @@ export function ProjectionMeasurements({
label={formatM(rect.y)}
offset={6}
horizontal={false}
lineColor={colors.dimensionLine}
textColor={colors.dimensionText}
/>,
);
}
@@ -203,6 +203,7 @@ export function ProjectionPanel({ fullView = false, onStageRef }: ProjectionPane
globalFurnitureOpacity,
wallHeight: room.wallHeight,
plinthHeight: room.plinthHeight,
stretchCeilingOffset: layerVisibility.stretchCeiling ? room.stretchCeilingOffset : 0,
outletWidth: room.outletWidth,
outletHeight: room.outletHeight,
selectedIds,
@@ -1,6 +1,7 @@
import { Group, Rect, Line } from 'react-konva';
import type { ProjectedOpening } from '../utils/projectionMapping';
import { projectionToPixel } from '../utils/projectionMapping';
import { useCanvasColors } from '../utils/canvasThemeColors';
interface ProjectionWindowProps {
readonly projected: ProjectedOpening;
@@ -26,6 +27,7 @@ export function ProjectionWindow({
onClick,
onDragStart,
}: ProjectionWindowProps) {
const colors = useCanvasColors();
const { rect, opening } = projected;
const displayX = isDragging && dragAlongWall != null
@@ -54,7 +56,7 @@ export function ProjectionWindow({
y={topLeft.y - 2}
width={pxWidth + 4}
height={pxHeight + 4}
stroke="#2563eb"
stroke={colors.selectedStroke}
strokeWidth={1}
dash={[3, 3]}
fill="transparent"
@@ -67,7 +69,7 @@ export function ProjectionWindow({
width={pxWidth}
height={pxHeight}
fill="#dbeafe"
stroke={isSelected ? '#2563eb' : '#3b82f6'}
stroke={isSelected ? colors.selectedStroke : '#3b82f6'}
strokeWidth={isSelected ? 2.5 : 1.5}
/>
{/* Glass pane (inner rectangle) */}
@@ -21,6 +21,7 @@ import { ProjectionElectrical } from './ProjectionElectrical';
import { ProjectionFurniture } from './ProjectionFurniture';
import { ProjectionMeasurements } from './ProjectionMeasurements';
import type { EditorToolType } from '../types';
import { useCanvasColors } from '../utils/canvasThemeColors';
interface WallProjectionViewProps {
readonly wall: Wall;
@@ -32,6 +33,8 @@ interface WallProjectionViewProps {
readonly showMeasurements?: boolean;
readonly wallHeight: number;
readonly plinthHeight: number;
/** Distance (m) the stretch ceiling hangs below the structural ceiling. */
readonly stretchCeilingOffset?: number;
readonly outletWidth?: number;
readonly outletHeight?: number;
readonly selectedIds: ReadonlySet<string>;
@@ -51,8 +54,7 @@ interface WallProjectionViewProps {
const PADDING = 40;
const PLINTH_COLOR = '#8b7355';
const WALL_FILL = '#f8fafc';
const WALL_STROKE = '#334155';
// WALL_FILL and WALL_STROKE now come from useCanvasColors().
const FLOOR_COLOR = '#94a3b8';
const CEILING_COLOR = '#94a3b8';
@@ -88,6 +90,7 @@ export function WallProjectionView({
showMeasurements = true,
wallHeight,
plinthHeight,
stretchCeilingOffset = 0,
outletWidth,
outletHeight,
selectedIds,
@@ -105,6 +108,7 @@ export function WallProjectionView({
selectedElectricalType,
}: WallProjectionViewProps) {
const { t } = useTranslation();
const colors = useCanvasColors();
const stageRef = useRef<Konva.Stage | null>(null);
// Expose stage ref to parent for export
@@ -549,7 +553,7 @@ export function WallProjectionView({
y={0}
width={width}
height={height}
fill={isHighlighted ? '#eff6ff' : '#ffffff'}
fill={isHighlighted ? colors.canvasBgHighlight : colors.canvasBg}
/>
{/* Pan group: all content shifts with viewPan */}
@@ -574,7 +578,7 @@ export function WallProjectionView({
<Line
key={`gv-${w}`}
points={[px, PADDING, px, PADDING + wallHeight * effectiveScale]}
stroke={isMajor ? '#cbd5e1' : '#e2e8f0'}
stroke={isMajor ? colors.gridLineMajor : colors.gridLine}
strokeWidth={isMajor ? 0.8 : 0.4}
listening={false}
/>,
@@ -591,7 +595,7 @@ export function WallProjectionView({
<Line
key={`gh-${h}`}
points={[PADDING, py, PADDING + wallLen * effectiveScale, py]}
stroke={isMajor ? '#cbd5e1' : '#e2e8f0'}
stroke={isMajor ? colors.gridLineMajor : colors.gridLine}
strokeWidth={isMajor ? 0.8 : 0.4}
listening={false}
/>,
@@ -612,7 +616,7 @@ export function WallProjectionView({
<Line
key={`rt-${w}`}
points={[px, py, px, py + 6]}
stroke="#94a3b8"
stroke={colors.rulerText}
strokeWidth={0.8}
listening={false}
/>,
@@ -626,7 +630,7 @@ export function WallProjectionView({
text={`${w}`}
align="center"
fontSize={8}
fill="#94a3b8"
fill={colors.rulerText}
/>,
);
}
@@ -643,7 +647,7 @@ export function WallProjectionView({
<Line
key={`ht-${h}`}
points={[PADDING - 6, py, PADDING, py]}
stroke="#94a3b8"
stroke={colors.rulerText}
strokeWidth={0.8}
listening={false}
/>,
@@ -657,7 +661,7 @@ export function WallProjectionView({
text={`${h}`}
align="right"
fontSize={8}
fill="#94a3b8"
fill={colors.rulerText}
/>,
);
}
@@ -671,8 +675,8 @@ export function WallProjectionView({
y={topLeft.y}
width={(topRight.x - topLeft.x)}
height={(bottomLeft.y - topLeft.y)}
fill={WALL_FILL}
stroke={isHighlighted ? '#2563eb' : WALL_STROKE}
fill={colors.wallFill}
stroke={isHighlighted ? colors.selectedStroke : colors.wallStroke}
strokeWidth={isHighlighted ? 2 : 1.5}
onClick={handleWallBgClick}
/>
@@ -685,6 +689,46 @@ export function WallProjectionView({
dash={[6, 4]}
/>
{/* Stretch ceiling line — drawn *below* the structural ceiling at
`wallHeight - stretchCeilingOffset`. A tinted band fills the
dropped zone so the user can see how much vertical space is
lost. Only rendered when the offset is non-zero. */}
{stretchCeilingOffset > 0 && (() => {
const stretchY = Math.max(0, wallHeight - stretchCeilingOffset);
const stretchLeft = toPixel(0, stretchY);
const stretchRight = toPixel(wallLen, stretchY);
return (
<>
{/* Tinted fill for the dropped zone between the structural
ceiling and the stretch ceiling plane. */}
<Rect
x={topLeft.x}
y={topLeft.y}
width={topRight.x - topLeft.x}
height={stretchLeft.y - topLeft.y}
fill="#60a5fa"
opacity={0.18}
listening={false}
/>
<Line
points={[stretchLeft.x, stretchLeft.y, stretchRight.x, stretchRight.y]}
stroke="#2563eb"
strokeWidth={1.25}
dash={[4, 3]}
listening={false}
/>
<Text
x={stretchLeft.x + 4}
y={stretchLeft.y - 12}
text={`${t('properties.stretchCeilingOffset')}: ${(stretchCeilingOffset * 100).toFixed(1)}cm`}
fontSize={9}
fill="#2563eb"
listening={false}
/>
</>
);
})()}
{/* Floor line */}
<Line
points={[bottomLeft.x, bottomLeft.y, bottomRight.x, bottomRight.y]}
@@ -754,19 +798,25 @@ export function WallProjectionView({
);
})}
{/* Furniture items (rendered first so electrical overlays them) */}
{projectedFurniture.map((pf) => (
<ProjectionFurniture
key={pf.item.id}
projected={pf}
wallHeight={wallHeight}
scale={effectiveScale}
padding={PADDING}
isSelected={selectedIds.has(pf.item.id)}
globalOpacity={globalFurnitureOpacity}
onClick={() => onSelectElement(pf.item.id)}
/>
))}
{/* Furniture items (rendered first so electrical overlays them).
When global opacity is effectively zero the items are invisible,
so don't wire click handlers — clicks should fall through to
whatever is underneath (wall background, electrical, …). */}
{projectedFurniture.map((pf) => {
const furnitureInteractive = globalFurnitureOpacity >= 0.05;
return (
<ProjectionFurniture
key={pf.item.id}
projected={pf}
wallHeight={wallHeight}
scale={effectiveScale}
padding={PADDING}
isSelected={selectedIds.has(pf.item.id)}
globalOpacity={globalFurnitureOpacity}
onClick={furnitureInteractive ? () => onSelectElement(pf.item.id) : undefined}
/>
);
})}
{/* Electrical items (on top of furniture) */}
{projectedElectrical.map((pe) => {
@@ -38,3 +38,53 @@ export function getElectricalVariant(metadata: Record<string, unknown> | null):
}
return 'single';
}
/** Get the wall light style from an electrical item's metadata. */
export function getWallLightStyle(metadata: Record<string, unknown> | null): import('@house-plan-maker/shared').WallLightStyle {
if (metadata && typeof metadata['wallLightStyle'] === 'string') {
return metadata['wallLightStyle'] as import('@house-plan-maker/shared').WallLightStyle;
}
return 'classic';
}
/** Get the cord/rope length for pendant-style wall lights (meters). Default 0.3m. */
export function getWallLightCordLength(metadata: Record<string, unknown> | null): number {
if (metadata && typeof metadata['cordLength'] === 'number') {
return metadata['cordLength'] as number;
}
return 0.3;
}
/** Get the ceiling lamp diameter (meters). Default 0.16m. */
export function getCeilingLampSize(metadata: Record<string, unknown> | null): number {
if (metadata && typeof metadata['lampSize'] === 'number') {
return metadata['lampSize'] as number;
}
return 0.16;
}
/** Get the outlet direction from metadata. Default is 'horizontal'. */
export function getOutletDirection(metadata: Record<string, unknown> | null): import('@house-plan-maker/shared').OutletDirection {
if (metadata && metadata['outletDirection'] === 'vertical') {
return 'vertical';
}
return 'horizontal';
}
/**
* Whether the along-wall coordinate should be inverted (`wallLength - x`)
* when shown in projection labels / read-outs. Display-only: the stored
* position is untouched.
*/
export function getOutletInvertCoordX(metadata: Record<string, unknown> | null): boolean {
return metadata?.['invertCoordX'] === true;
}
/**
* Whether the from-floor coordinate should be inverted (`wallHeight - y`)
* when shown in projection labels / read-outs. Display-only: the stored
* elevation is untouched.
*/
export function getOutletInvertCoordY(metadata: Record<string, unknown> | null): boolean {
return metadata?.['invertCoordY'] === true;
}
@@ -1,7 +1,8 @@
import { useMemo } from 'react';
import * as THREE from 'three';
import type { ElectricalItem, ElectricalType, Point, Wall } from '@house-plan-maker/shared';
import type { ElectricalItem, ElectricalType, Point, Wall, WallLightStyle } from '@house-plan-maker/shared';
import { wallRotationY, positionAlongWall3D, wallVector, wallNormal } from './utils/wallGeometry';
import { getWallLightStyle, getWallLightCordLength, getCeilingLampSize, getOutletDirection } from '../symbols/electrical';
interface ElectricalMeshWithHeightProps {
readonly item: ElectricalItem;
@@ -32,12 +33,16 @@ function outletAnchorOffset(
outletWidth: number,
outletHeight: number,
): { readonly cx: number; readonly cy: number } {
const totalWidth = Math.max(1, item.count) * outletWidth;
const safeCount = Math.max(1, item.count);
const direction = getOutletDirection(item.metadata);
const isVertical = direction === 'vertical';
const totalWidth = isVertical ? outletWidth : safeCount * outletWidth;
const totalHeight = isVertical ? safeCount * outletHeight : outletHeight;
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;
const cy = v === 'top' ? -totalHeight / 2 : v === 'bottom' ? totalHeight / 2 : 0;
return { cx, cy };
}
@@ -75,6 +80,7 @@ function OutletMesh({
outletHeight,
centerX,
centerY,
isVertical,
}: {
readonly color: string;
readonly count: number;
@@ -82,6 +88,7 @@ function OutletMesh({
readonly outletHeight: number;
readonly centerX: number;
readonly centerY: number;
readonly isVertical: boolean;
}) {
const safeCount = Math.max(1, Math.round(count));
// Depth into the wall: stays small, just enough to be visible.
@@ -89,10 +96,11 @@ function OutletMesh({
return (
<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;
// Center index 0..N-1 around 0 along the layout axis.
const localX = isVertical ? 0 : (i - (safeCount - 1) / 2) * outletWidth;
const localY = isVertical ? (i - (safeCount - 1) / 2) * outletHeight : 0;
return (
<mesh key={i} position={[localX, 0, 0]} castShadow>
<mesh key={i} position={[localX, localY, 0]} castShadow>
<boxGeometry args={[outletWidth * 0.95, outletHeight * 0.95, depth]} />
<meshStandardMaterial color={color} roughness={0.3} />
</mesh>
@@ -129,18 +137,28 @@ function JunctionBoxMesh({ color }: { readonly color: string }) {
);
}
/** Ceiling light: disc or sphere hanging from ceiling */
function CeilingLightMesh({ color, wallHeight }: { readonly color: string; readonly wallHeight: number }) {
/** Ceiling light: disc or sphere hanging from ceiling, scalable by lampSize (diameter in meters). */
function CeilingLightMesh({ color, wallHeight, lampSize }: {
readonly color: string;
readonly wallHeight: number;
readonly lampSize: number;
}) {
// lampSize is the overall diameter; derive component sizes from it.
const radius = lampSize / 2;
const canopyRadius = radius * 0.625; // proportional canopy
const canopyHeight = Math.max(0.015, lampSize * 0.125);
const dropDistance = lampSize * 0.75; // how far shade hangs below canopy
return (
<group position={[0, wallHeight - 0.05, 0]}>
{/* Canopy */}
<mesh castShadow>
<cylinderGeometry args={[0.05, 0.05, 0.02, 16]} />
<cylinderGeometry args={[canopyRadius, canopyRadius, canopyHeight, 16]} />
<meshStandardMaterial color="#666666" roughness={0.4} />
</mesh>
{/* Shade / bulb */}
<mesh position={[0, -0.12, 0]}>
<sphereGeometry args={[0.08, 16, 16]} />
<mesh position={[0, -dropDistance, 0]}>
<sphereGeometry args={[radius, 16, 16]} />
<meshStandardMaterial
color={color}
emissive={color}
@@ -152,18 +170,22 @@ function CeilingLightMesh({ color, wallHeight }: { readonly color: string; reado
);
}
/** Wall light: half-sphere attached to wall */
function WallLightMesh({ color }: { readonly color: string }) {
/** Classic wall light: half-sphere attached to wall, scalable by lampSize (diameter in meters). */
function WallLightClassic({ color, lampSize }: { readonly color: string; readonly lampSize: number }) {
const scale = lampSize / 0.12; // 0.12 = default diameter (2 * 0.06 shade radius)
const plateSize = 0.10 * scale;
const shadeRadius = 0.06 * scale;
return (
<group>
{/* Base plate */}
<mesh castShadow>
<boxGeometry args={[0.10, 0.10, 0.02]} />
<boxGeometry args={[plateSize, plateSize, 0.02]} />
<meshStandardMaterial color="#666666" roughness={0.4} />
</mesh>
{/* Half sphere shade */}
<mesh position={[0, 0, 0.03]}>
<sphereGeometry args={[0.06, 16, 8, 0, Math.PI * 2, 0, Math.PI / 2]} />
<sphereGeometry args={[shadeRadius, 16, 8, 0, Math.PI * 2, 0, Math.PI / 2]} />
<meshStandardMaterial
color={color}
emissive={color}
@@ -176,6 +198,183 @@ function WallLightMesh({ color }: { readonly color: string }) {
);
}
/** Pendant globe wall light: gooseneck arm with hanging glass globe (adjustable cord and size). */
function WallLightPendantGlobe({ color, cordLength, lampSize }: {
readonly color: string;
readonly cordLength: number;
readonly lampSize: number;
}) {
const armColor = '#b8860b'; // brass/dark gold
const cordLen = Math.max(0.05, cordLength);
const scale = lampSize / 0.14; // 0.14 = default diameter (2 * 0.07 globe radius)
const globeRadius = 0.07 * scale;
const armHeight = 0.12 * scale;
const armReach = 0.06 * scale;
const mountRadius = 0.04 * scale;
const armThickness = 0.006 * scale;
const canopyTop = 0.02 * scale;
const canopyBot = 0.025 * scale;
const filamentRadius = 0.015 * scale;
return (
<group>
{/* Round wall mount plate */}
<mesh castShadow>
<cylinderGeometry args={[mountRadius, mountRadius, 0.015, 16]} />
<meshStandardMaterial color={armColor} roughness={0.3} metalness={0.6} />
</mesh>
{/* Vertical arm going up */}
<mesh position={[0, armHeight / 2, 0]} castShadow>
<cylinderGeometry args={[armThickness, armThickness, armHeight, 8]} />
<meshStandardMaterial color={armColor} roughness={0.3} metalness={0.6} />
</mesh>
{/* Curved gooseneck top (simplified as a horizontal segment) */}
<mesh position={[0, armHeight, armReach / 2]} rotation={[Math.PI / 2, 0, 0]} castShadow>
<cylinderGeometry args={[armThickness, armThickness, armReach, 8]} />
<meshStandardMaterial color={armColor} roughness={0.3} metalness={0.6} />
</mesh>
{/* Cord / rope hanging down */}
<mesh position={[0, armHeight - cordLen / 2, armReach]} castShadow>
<cylinderGeometry args={[0.003, 0.003, cordLen, 6]} />
<meshStandardMaterial color="#f5f0e0" roughness={0.8} />
</mesh>
{/* Globe canopy (brass ring at top of globe) */}
<mesh position={[0, armHeight - cordLen, armReach]}>
<cylinderGeometry args={[canopyTop, canopyBot, 0.015, 16]} />
<meshStandardMaterial color={armColor} roughness={0.3} metalness={0.6} />
</mesh>
{/* Glass globe */}
<mesh position={[0, armHeight - cordLen - globeRadius * 0.7, armReach]}>
<sphereGeometry args={[globeRadius, 24, 24]} />
<meshPhysicalMaterial
color="#ffffff"
transmission={0.85}
roughness={0.05}
thickness={0.5}
ior={1.5}
transparent
opacity={0.35}
side={THREE.DoubleSide}
/>
</mesh>
{/* Bulb filament inside globe */}
<mesh position={[0, armHeight - cordLen - globeRadius * 0.7, armReach]}>
<sphereGeometry args={[filamentRadius, 8, 8]} />
<meshStandardMaterial
color={color}
emissive={color}
emissiveIntensity={0.5}
roughness={0.2}
/>
</mesh>
</group>
);
}
/** Sconce pointing upward */
function WallLightSconceUp({ color, lampSize }: { readonly color: string; readonly lampSize: number }) {
const scale = lampSize / 0.10; // 0.10 = default diameter (2 * 0.05 cone radius)
const plateW = 0.08 * scale;
const plateH = 0.12 * scale;
const coneRadius = 0.05 * scale;
const coneHeight = 0.10 * scale;
const glowRadius = 0.02 * scale;
return (
<group>
{/* Base plate */}
<mesh castShadow>
<boxGeometry args={[plateW, plateH, 0.02]} />
<meshStandardMaterial color="#666666" roughness={0.4} />
</mesh>
{/* Upward cone shade */}
<mesh position={[0, coneHeight * 0.4, 0.03]} rotation={[0, 0, 0]}>
<coneGeometry args={[coneRadius, coneHeight, 16, 1, true]} />
<meshStandardMaterial
color="#e0d5c0"
roughness={0.5}
side={THREE.DoubleSide}
/>
</mesh>
{/* Light glow */}
<mesh position={[0, coneHeight * 0.6, 0.03]}>
<sphereGeometry args={[glowRadius, 8, 8]} />
<meshStandardMaterial
color={color}
emissive={color}
emissiveIntensity={0.4}
roughness={0.2}
/>
</mesh>
</group>
);
}
/** Sconce pointing downward */
function WallLightSconceDown({ color, lampSize }: { readonly color: string; readonly lampSize: number }) {
const scale = lampSize / 0.10; // 0.10 = default diameter (2 * 0.05 cone radius)
const plateW = 0.08 * scale;
const plateH = 0.12 * scale;
const coneRadius = 0.05 * scale;
const coneHeight = 0.10 * scale;
const glowRadius = 0.02 * scale;
return (
<group>
{/* Base plate */}
<mesh castShadow>
<boxGeometry args={[plateW, plateH, 0.02]} />
<meshStandardMaterial color="#666666" roughness={0.4} />
</mesh>
{/* Downward cone shade (rotated 180°) */}
<mesh position={[0, -coneHeight * 0.4, 0.03]} rotation={[Math.PI, 0, 0]}>
<coneGeometry args={[coneRadius, coneHeight, 16, 1, true]} />
<meshStandardMaterial
color="#e0d5c0"
roughness={0.5}
side={THREE.DoubleSide}
/>
</mesh>
{/* Light glow */}
<mesh position={[0, -coneHeight * 0.6, 0.03]}>
<sphereGeometry args={[glowRadius, 8, 8]} />
<meshStandardMaterial
color={color}
emissive={color}
emissiveIntensity={0.4}
roughness={0.2}
/>
</mesh>
</group>
);
}
/** Wall light: renders the appropriate style variant */
function WallLightMesh({ color, style, cordLength, lampSize }: {
readonly color: string;
readonly style: WallLightStyle;
readonly cordLength: number;
readonly lampSize: number;
}) {
switch (style) {
case 'pendant-globe':
return <WallLightPendantGlobe color={color} cordLength={cordLength} lampSize={lampSize} />;
case 'sconce-up':
return <WallLightSconceUp color={color} lampSize={lampSize} />;
case 'sconce-down':
return <WallLightSconceDown color={color} lampSize={lampSize} />;
case 'classic':
default:
return <WallLightClassic color={color} lampSize={lampSize} />;
}
}
/** Cable route: small orange marker */
function CableRouteMesh({ color }: { readonly color: string }) {
return (
@@ -296,13 +495,23 @@ export function ElectricalMeshWithHeight({
outletHeight={outletHeight}
centerX={offset.cx}
centerY={offset.cy}
isVertical={getOutletDirection(item.metadata) === 'vertical'}
/>
);
})()}
{item.type === 'SWITCH' && <SwitchMesh color={color} />}
{item.type === 'JUNCTION_BOX' && <JunctionBoxMesh color={color} />}
{item.type === 'LIGHT_CEILING' && <CeilingLightMesh color={color} wallHeight={wallHeight} />}
{item.type === 'LIGHT_WALL' && <WallLightMesh color={color} />}
{item.type === 'LIGHT_CEILING' && (
<CeilingLightMesh color={color} wallHeight={wallHeight} lampSize={getCeilingLampSize(item.metadata)} />
)}
{item.type === 'LIGHT_WALL' && (
<WallLightMesh
color={color}
style={getWallLightStyle(item.metadata)}
cordLength={getWallLightCordLength(item.metadata)}
lampSize={getCeilingLampSize(item.metadata)}
/>
)}
{item.type === 'CABLE_ROUTE' && <CableRouteMesh color={color} />}
</group>
);
@@ -7,6 +7,13 @@ interface FloorCeilingProps {
readonly shape: readonly Point[];
readonly wallHeight: number;
readonly floorType?: FloorType;
/**
* Distance (meters) a stretch ceiling hangs below the structural ceiling.
* When > 0 a semi-transparent tinted plane is drawn at
* `wallHeight - stretchCeilingOffset` plus a faint outline, so the user
* can see how much vertical space is lost to the suspended ceiling.
*/
readonly stretchCeilingOffset?: number;
}
/**
@@ -48,23 +55,120 @@ function buildFloorGeometry(shape: readonly Point[], tileMeters: number): THREE.
return geometry;
}
export function FloorCeiling({ shape, wallHeight: _wallHeight, floorType = 'CONCRETE' }: FloorCeilingProps) {
/**
* Build a flat ShapeGeometry for the room polygon, without any UV rescaling.
* Used for the stretch-ceiling overlay where the shape is tinted solid and
* UV tiling is irrelevant.
*/
function buildFlatShapeGeometry(shape: readonly Point[]): THREE.ShapeGeometry | null {
if (shape.length < 3) return null;
const threeShape = new THREE.Shape();
threeShape.moveTo(shape[0].x, shape[0].y);
for (let i = 1; i < shape.length; i++) {
threeShape.lineTo(shape[i].x, shape[i].y);
}
threeShape.closePath();
return new THREE.ShapeGeometry(threeShape);
}
/**
* Outline edge-pair list closing the room polygon, suitable for
* THREE.LineSegments. The floor mesh uses
* `rotation=[-π/2, 0, 0], scale=[1, -1, 1]`, which maps a local
* `(x_shape, y_shape)` vertex onto world `(x_shape, 0, y_shape)`. We
* replicate that mapping here so the outline sits exactly above the floor
* polygon instead of being mirrored onto the opposite side.
*/
function buildOutlineEdgePairs(shape: readonly Point[], y: number): Float32Array {
const n = shape.length;
const points = new Float32Array(n * 2 * 3);
for (let i = 0; i < n; i++) {
const a = shape[i];
const b = shape[(i + 1) % n];
const base = i * 6;
points[base + 0] = a.x;
points[base + 1] = y;
points[base + 2] = a.y;
points[base + 3] = b.x;
points[base + 4] = y;
points[base + 5] = b.y;
}
return points;
}
export function FloorCeiling({
shape,
wallHeight,
floorType = 'CONCRETE',
stretchCeilingOffset = 0,
}: FloorCeilingProps) {
const pbr = useMemo(() => getFloorPbr(floorType), [floorType]);
const geometry = useMemo(
() => buildFloorGeometry(shape, pbr.tileMeters),
[shape, pbr.tileMeters],
);
// Stretch ceiling plane geometry (only built when the feature is active).
const stretchGeometry = useMemo(
() => (stretchCeilingOffset > 0 ? buildFlatShapeGeometry(shape) : null),
[shape, stretchCeilingOffset],
);
const stretchY = Math.max(0, wallHeight - stretchCeilingOffset);
// Edge outline buffer for the stretch ceiling (closed polyline).
const outlineGeometry = useMemo(() => {
if (stretchCeilingOffset <= 0 || shape.length < 3) return null;
const geom = new THREE.BufferGeometry();
geom.setAttribute(
'position',
new THREE.BufferAttribute(buildOutlineEdgePairs(shape, stretchY), 3),
);
return geom;
}, [shape, stretchCeilingOffset, stretchY]);
if (!geometry) return null;
return (
<mesh
geometry={geometry}
material={pbr.material}
rotation={[-Math.PI / 2, 0, 0]}
scale={[1, -1, 1]}
position={[0, 0, 0]}
receiveShadow
/>
<>
<mesh
geometry={geometry}
material={pbr.material}
rotation={[-Math.PI / 2, 0, 0]}
scale={[1, -1, 1]}
position={[0, 0, 0]}
receiveShadow
/>
{/* Stretch ceiling — a soft blue tinted plane facing *downward* so it
is only visible from inside the room, plus a thin edge outline at
the same height. The plane uses depthWrite=false so items above
(e.g. pipes) still render correctly through it. */}
{stretchGeometry && (
// Same rotation+scale as the floor so the shape maps onto the same
// world (x, -y) plane, but raised to the stretch ceiling height.
// DoubleSide + depthWrite=false keeps it visible from both sides and
// avoids z-fighting with objects beneath it.
<mesh
geometry={stretchGeometry}
rotation={[-Math.PI / 2, 0, 0]}
scale={[1, -1, 1]}
position={[0, stretchY, 0]}
>
<meshBasicMaterial
color="#60a5fa"
transparent
opacity={0.22}
side={THREE.DoubleSide}
depthWrite={false}
/>
</mesh>
)}
{outlineGeometry && (
<lineSegments>
<bufferGeometry attach="geometry" {...outlineGeometry} />
<lineBasicMaterial attach="material" color="#2563eb" linewidth={1} transparent opacity={0.85} />
</lineSegments>
)}
</>
);
}
@@ -1,8 +1,10 @@
import { useEffect, useMemo, useRef } from 'react';
import * as THREE from 'three';
import type { FurnitureItem, FurnitureType } from '@house-plan-maker/shared';
import { rotatedAnchorOffsetToCenter } from '@house-plan-maker/shared';
import { rotatedAnchorOffsetToCenter, TEXTURABLE_FURNITURE } from '@house-plan-maker/shared';
import { getCurtainLeftOpen, getCurtainRightOpen, getCurtainFabricColor } from '../utils/curtainMetadata';
import { getFurnitureTexture } from '../utils/furnitureTextureMetadata';
import { getFurnitureSurfacePbr, computeTextureRepeat } from './utils/pbrTextures';
/**
* Below this opacity threshold, furniture meshes stop casting shadows. Cast
@@ -86,6 +88,37 @@ export function setFurnitureGlobalOpacity(_opacity: number): void {
// intentionally empty — per-item material clones own their opacity now
}
/**
* Returns a material for furniture surfaces: either a PBR textured material
* (when the item has a surfaceTexture set) or the solid-color fallback.
* The PBR material's UV repeat is scaled for the given surface dimensions.
*/
function useSurfaceMaterial(
item: FurnitureItem,
solidColor: string,
solidRoughness: number,
surfaceWidth: number,
surfaceDepth: number,
): THREE.MeshStandardMaterial {
return useMemo(() => {
if (!TEXTURABLE_FURNITURE.includes(item.type)) {
return getFurnitureMaterial(solidColor, solidRoughness);
}
const texture = getFurnitureTexture(item.metadata);
const pbr = getFurnitureSurfacePbr(texture);
if (!pbr) {
return getFurnitureMaterial(solidColor, solidRoughness);
}
// Clone the PBR material so UV repeat is per-item, not global.
const mat = pbr.material.clone();
const repeat = computeTextureRepeat(surfaceWidth, surfaceDepth, pbr.tileMeters);
if (mat.map) { mat.map = mat.map.clone(); mat.map.repeat.set(repeat.u, repeat.v); }
if (mat.normalMap) { mat.normalMap = mat.normalMap.clone(); mat.normalMap.repeat.set(repeat.u, repeat.v); }
if (mat.roughnessMap) { mat.roughnessMap = mat.roughnessMap.clone(); mat.roughnessMap.repeat.set(repeat.u, repeat.v); }
return mat;
}, [item.type, item.metadata, solidColor, solidRoughness, surfaceWidth, surfaceDepth]);
}
// ── Shared geometries for common shapes ──
const legGeometry = new THREE.CylinderGeometry(LEG_RADIUS, LEG_RADIUS, 1, LEG_SEGMENTS);
@@ -101,11 +134,13 @@ function BedMesh({ item, color }: { readonly item: FurnitureItem; readonly color
const frameHeight = item.height * 0.3;
const headboardHeight = item.height;
const mattressMaterial = useMemo(() => getFurnitureMaterial(color, 0.9), [color]);
const frameMaterial = useSurfaceMaterial(item, LEG_COLOR, 0.6, item.width, item.depth);
const headboardMaterial = useSurfaceMaterial(item, LEG_COLOR, 0.5, item.width, headboardHeight);
return (
<group>
{/* Frame */}
<mesh position={[0, frameHeight / 2, 0]} castShadow material={legMaterial}>
<mesh position={[0, frameHeight / 2, 0]} castShadow material={frameMaterial}>
<boxGeometry args={[item.width, frameHeight, item.depth]} />
</mesh>
{/* Mattress */}
@@ -113,7 +148,7 @@ function BedMesh({ item, color }: { readonly item: FurnitureItem; readonly color
<boxGeometry args={[item.width * 0.95, mattressHeight, item.depth * 0.95]} />
</mesh>
{/* Headboard */}
<mesh position={[0, headboardHeight / 2, -item.depth / 2 + 0.02]} castShadow material={legMaterialSmooth}>
<mesh position={[0, headboardHeight / 2, -item.depth / 2 + 0.02]} castShadow material={headboardMaterial}>
<boxGeometry args={[item.width, headboardHeight, 0.04]} />
</mesh>
</group>
@@ -125,7 +160,7 @@ function DeskMesh({ item, color }: { readonly item: FurnitureItem; readonly colo
const topThickness = 0.04;
const legHeight = item.height - topThickness;
const inset = 0.05;
const topMaterial = useMemo(() => getFurnitureMaterial(color, 0.5), [color]);
const topMaterial = useSurfaceMaterial(item, color, 0.5, item.width, item.depth);
return (
<group>
@@ -150,7 +185,7 @@ function DeskMesh({ item, color }: { readonly item: FurnitureItem; readonly colo
/** Wardrobe: tall box with slight door line */
function WardrobeMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) {
const bodyMaterial = useMemo(() => getFurnitureMaterial(color, 0.6), [color]);
const bodyMaterial = useSurfaceMaterial(item, color, 0.6, item.width, item.height);
return (
<group>
@@ -207,7 +242,7 @@ function TableMesh({ item, color }: { readonly item: FurnitureItem; readonly col
const topThickness = 0.03;
const legHeight = item.height - topThickness;
const inset = 0.05;
const topMaterial = useMemo(() => getFurnitureMaterial(color, 0.5), [color]);
const topMaterial = useSurfaceMaterial(item, color, 0.5, item.width, item.depth);
return (
<group>
@@ -234,7 +269,7 @@ function ChairMesh({ item, color }: { readonly item: FurnitureItem; readonly col
const seatThickness = 0.03;
const legHeight = seatHeight - seatThickness;
const inset = 0.03;
const chairMaterial = useMemo(() => getFurnitureMaterial(color, 0.6), [color]);
const chairMaterial = useSurfaceMaterial(item, color, 0.6, item.width, item.depth);
return (
<group>
@@ -263,7 +298,7 @@ function ChairMesh({ item, color }: { readonly item: FurnitureItem; readonly col
/** Shelf / Bookcase / Nightstand / Dresser / Other: simple box */
function SimpleBoxMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) {
const material = useMemo(() => getFurnitureMaterial(color, 0.6), [color]);
const material = useSurfaceMaterial(item, color, 0.6, item.width, item.depth);
return (
<mesh position={[0, item.height / 2, 0]} castShadow material={material}>
@@ -301,7 +336,7 @@ function BookcaseMesh({ item, color }: { readonly item: FurnitureItem; readonly
const hasBackPanel = typeof hasBackPanelRaw === 'boolean' ? hasBackPanelRaw : true;
const panelThickness = 0.02;
const material = useMemo(() => getFurnitureMaterial(color, 0.6), [color]);
const material = useSurfaceMaterial(item, color, 0.6, item.width, item.height);
return (
<group>
@@ -589,7 +624,7 @@ function CurtainMesh({ item, color: _defaultColor }: { readonly item: FurnitureI
* looking sparse.
*/
function CribMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) {
const frameMaterial = useMemo(() => getFurnitureMaterial(color, 0.55), [color]);
const frameMaterial = useSurfaceMaterial(item, color, 0.55, item.width, item.depth);
const mattressMaterial = useMemo(() => getFurnitureMaterial('#f4eadf', 0.9), []);
const mattressThick = 0.08;
@@ -710,7 +745,7 @@ function CribMesh({ item, color }: { readonly item: FurnitureItem; readonly colo
* upright panel.
*/
function DressingTableMesh({ item, color }: { readonly item: FurnitureItem; readonly color: string }) {
const bodyMaterial = useMemo(() => getFurnitureMaterial(color, 0.5), [color]);
const bodyMaterial = useSurfaceMaterial(item, color, 0.5, item.width, item.depth);
const mirrorMaterial = useMemo(() => getFurnitureMaterial('#b8d0d8', 0.05), []);
const mirrorFrameMaterial = useMemo(() => getFurnitureMaterial(color, 0.4), [color]);
@@ -183,7 +183,7 @@ export function Room3DView() {
}, [walls]);
return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<div style={{ position: 'absolute', inset: 0 }}>
<CameraPresetsUI
shape={shape}
wallHeight={wallHeight}
@@ -266,7 +266,14 @@ export function Room3DView() {
<NearestWallTracker walls={walls} onUpdate={setHiddenWallIds} />
{/* Floor */}
<FloorCeiling shape={shape} wallHeight={wallHeight} floorType={room.floorType} />
<FloorCeiling
shape={shape}
wallHeight={wallHeight}
floorType={room.floorType}
stretchCeilingOffset={
layerVisibility.stretchCeiling ? room.stretchCeilingOffset : 0
}
/>
{/* Walls (hide the one facing the camera) */}
{layerVisibility.walls && walls.map((wall) => (
@@ -1,5 +1,5 @@
import * as THREE from 'three';
import type { FloorType, WallFinish } from '@house-plan-maker/shared';
import type { FloorType, WallFinish, FurnitureTexture } from '@house-plan-maker/shared';
/**
* Loads and caches PBR texture sets (albedo + normal + roughness) for floor
@@ -28,6 +28,7 @@ const FLOOR_TILE_METERS: Record<FloorType, number> = {
WOOD_MEDIUM: 1.4,
WOOD_DARK: 1.4,
WOOD_HERRINGBONE: 1.0,
OAK_NATURAL: 1.4,
TILE_WHITE: 1.0,
TILE_GRAY: 1.0,
LAMINATE: 1.4,
@@ -107,6 +108,42 @@ export function getWallPbr(finish: Exclude<WallFinish, 'PAINT'>): PbrSet {
return entry;
}
// ── Furniture surface textures ──
//
// Reuses the floor texture assets. The tiling is tighter (0.4m default) so
// the wood grain looks appropriately scaled on desk/shelf/table surfaces
// rather than stretched.
const FURNITURE_TEXTURE_TILE: Record<Exclude<FurnitureTexture, 'NONE'>, number> = {
WOOD_LIGHT: 0.4,
WOOD_MEDIUM: 0.4,
WOOD_DARK: 0.4,
WOOD_HERRINGBONE: 0.3,
OAK_NATURAL: 0.4,
LAMINATE: 0.4,
CONCRETE: 0.6,
};
const furnitureCache = new Map<Exclude<FurnitureTexture, 'NONE'>, PbrSet>();
/**
* Get a PBR material for furniture surface textures. Reuses floor texture
* images but with tighter tiling appropriate for furniture-scale surfaces.
* Returns `null` for 'NONE' — the caller should fall back to the solid color.
*/
export function getFurnitureSurfacePbr(texture: FurnitureTexture): PbrSet | null {
if (texture === 'NONE') return null;
let entry = furnitureCache.get(texture);
if (!entry) {
entry = buildPbrSet(
`/textures/floors/${texture.toLowerCase()}`,
FURNITURE_TEXTURE_TILE[texture],
);
furnitureCache.set(texture, 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
+8 -1
View File
@@ -35,6 +35,13 @@ export interface LayerVisibility {
readonly furniture: boolean;
readonly measurements: boolean;
readonly annotations: boolean;
/**
* Whether the stretch-ceiling visualization (tinted plane + outline in 3D,
* tinted band + dashed line in projection) is rendered. The underlying
* `stretchCeilingOffset` value on the room is untouched when toggled —
* this flag is a view-only layer switch.
*/
readonly stretchCeiling: boolean;
}
export interface EditorState {
@@ -75,7 +82,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' | 'wallFinish' | 'wallHeight' | 'plinthHeight' | 'plinthThickness' | 'outletWidth' | 'outletHeight'>> }
| { readonly type: 'UPDATE_ROOM_PROPS'; readonly props: Partial<Pick<RoomFull, 'floorType' | 'wallColor' | 'wallFinish' | 'wallHeight' | 'plinthHeight' | 'plinthThickness' | 'stretchCeilingOffset' | 'outletWidth' | 'outletHeight'>> }
| { readonly type: 'SET_WALLS'; readonly walls: readonly Wall[] }
| { readonly type: 'UPDATE_WALL'; readonly wall: Wall }
| { readonly type: 'ADD_OPENING'; readonly opening: WallOpening }
@@ -0,0 +1,125 @@
import { useTheme } from '../../../contexts/ThemeContext';
import { useMemo } from 'react';
interface CanvasColors {
/** Stage / canvas background */
readonly canvasBg: string;
/** Highlighted canvas background (e.g. drag-over) */
readonly canvasBgHighlight: string;
/** Thin grid lines */
readonly gridLine: string;
/** Major (1 m) grid lines */
readonly gridLineMajor: string;
/** Ruler background strip */
readonly rulerBg: string;
/** Ruler text & ticks */
readonly rulerText: string;
/** Wall projection fill */
readonly wallFill: string;
/** Wall projection stroke */
readonly wallStroke: string;
/** Dimension / measurement lines */
readonly dimensionLine: string;
/** Dimension label text */
readonly dimensionText: string;
/** Coordinate-label pill background */
readonly coordLabelBg: string;
/** Coordinate-label pill text */
readonly coordLabelText: string;
/** Electrical symbol stroke (unselected) */
readonly electricalStroke: string;
/** Electrical symbol fill (unselected) */
readonly electricalFill: string;
/** Electrical type label text */
readonly electricalLabel: string;
/** Selected element stroke (shared) */
readonly selectedStroke: string;
/** Selected element fill (shared) */
readonly selectedFill: string;
/** Selected element text */
readonly selectedText: string;
/** Furniture default stroke */
readonly furnitureStroke: string;
/** Furniture default label text */
readonly furnitureText: string;
/** Opening position label text */
readonly openingLabel: string;
}
const LIGHT: CanvasColors = {
canvasBg: '#ffffff',
canvasBgHighlight: '#eff6ff',
gridLine: '#e2e8f0',
gridLineMajor: '#cbd5e1',
rulerBg: '#f5f5f5',
rulerText: '#666666',
wallFill: '#f8fafc',
wallStroke: '#334155',
dimensionLine: '#94a3b8',
dimensionText: '#64748b',
coordLabelBg: 'rgba(255, 255, 255, 0.85)',
coordLabelText: '#475569',
electricalStroke: '#64748b',
electricalFill: '#f1f5f9',
electricalLabel: '#94a3b8',
selectedStroke: '#2563eb',
selectedFill: '#dbeafe',
selectedText: '#1e40af',
furnitureStroke: '#6b5b3a',
furnitureText: '#3b2f1e',
openingLabel: '#94a3b8',
};
const DARK: CanvasColors = {
canvasBg: '#1a1b1e',
canvasBgHighlight: '#1e2a3a',
gridLine: '#2e3038',
gridLineMajor: '#3a3d46',
rulerBg: '#25262b',
rulerText: '#909296',
wallFill: '#2c2e33',
wallStroke: '#a0aec0',
dimensionLine: '#64748b',
dimensionText: '#94a3b8',
coordLabelBg: 'rgba(30, 31, 34, 0.9)',
coordLabelText: '#c1c2c5',
electricalStroke: '#94a3b8',
electricalFill: '#2c2e33',
electricalLabel: '#909296',
selectedStroke: '#3b82f6',
selectedFill: '#1e3a5f',
selectedText: '#93c5fd',
furnitureStroke: '#a0845c',
furnitureText: '#d4a574',
openingLabel: '#64748b',
};
/** Returns the canvas color palette for the current theme. */
export function useCanvasColors(): CanvasColors {
const { theme } = useTheme();
return useMemo(() => (theme === 'dark' ? DARK : LIGHT), [theme]);
}
@@ -0,0 +1,23 @@
/**
* Helpers for reading the surface texture selection out of a FurnitureItem's
* metadata bag. Kept standalone so both the 3D mesh and the properties panel
* can read/write the same shape without importing each other.
*
* Metadata key:
* - `surfaceTexture`: A FurnitureTexture value ('NONE' | 'WOOD_LIGHT' | …).
* When absent or 'NONE', the furniture uses its default solid-color material.
*/
import type { FurnitureTexture } from '@house-plan-maker/shared';
type MetadataBag = Record<string, unknown> | null | undefined;
/** Read the surface texture from furniture metadata. Returns 'NONE' by default. */
export function getFurnitureTexture(metadata: MetadataBag): FurnitureTexture {
if (!metadata) return 'NONE';
const raw = metadata['surfaceTexture'];
if (typeof raw === 'string') {
return raw as FurnitureTexture;
}
return 'NONE';
}
@@ -0,0 +1,3 @@
-- Distance (meters) the stretch ceiling hangs below the structural ceiling.
-- 0 = no stretch ceiling. Applied per room and used purely for visualization.
ALTER TABLE "Room" ADD COLUMN "stretchCeilingOffset" REAL NOT NULL DEFAULT 0;
+5
View File
@@ -27,6 +27,11 @@ model Room {
wallHeight Float @default(2.7)
plinthHeight Float @default(0.06)
plinthThickness Float @default(0.01)
/// Distance (meters) the stretch ceiling hangs *below* the structural
/// ceiling. 0 = no stretch ceiling. The ceiling plane is therefore at
/// `wallHeight - stretchCeilingOffset`. Display-only: does not affect
/// wall geometry, opening positions, or electrical elevations.
stretchCeilingOffset Float @default(0)
order Int @default(0)
posX Float @default(0)
posY Float @default(0)
+10
View File
@@ -67,6 +67,9 @@ const roomRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
wallHeight: input.wallHeight ?? 2.7,
plinthHeight: input.plinthHeight ?? 0.06,
plinthThickness: input.plinthThickness ?? 0.01,
...(input.stretchCeilingOffset !== undefined && {
stretchCeilingOffset: input.stretchCeilingOffset,
}),
order: input.order ?? 0,
// posX/posY added in schema migration; client regeneration required
...(input.posX !== undefined && { posX: input.posX }),
@@ -156,6 +159,9 @@ const roomRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
...(input.wallHeight !== undefined && { wallHeight: input.wallHeight }),
...(input.plinthHeight !== undefined && { plinthHeight: input.plinthHeight }),
...(input.plinthThickness !== undefined && { plinthThickness: input.plinthThickness }),
...(input.stretchCeilingOffset !== undefined && {
stretchCeilingOffset: input.stretchCeilingOffset,
}),
...(input.order !== undefined && { order: input.order }),
...(input.posX !== undefined && { posX: input.posX }),
...(input.posY !== undefined && { posY: input.posY }),
@@ -237,6 +243,8 @@ const roomRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
wallHeight: source.wallHeight,
plinthHeight: source.plinthHeight,
plinthThickness: source.plinthThickness,
stretchCeilingOffset:
(((source as Record<string, unknown>).stretchCeilingOffset as number | undefined) ?? 0),
order: nextOrder,
posX: source.posX,
posY: source.posY,
@@ -376,6 +384,7 @@ function toRoomResponse(room: {
wallHeight: number;
plinthHeight: number;
plinthThickness: number;
stretchCeilingOffset?: number | null;
order: number;
posX?: number | null;
posY?: number | null;
@@ -394,6 +403,7 @@ function toRoomResponse(room: {
wallHeight: room.wallHeight,
plinthHeight: room.plinthHeight,
plinthThickness: room.plinthThickness,
stretchCeilingOffset: room.stretchCeilingOffset ?? 0,
order: room.order,
posX: room.posX ?? 0,
posY: room.posY ?? 0,
+7
View File
@@ -41,6 +41,9 @@ export type {
VerticalAnchor,
PositionAnchor,
ElectricalType,
OutletDirection,
WallLightStyle,
FurnitureTexture,
ElectricalItem,
CreateElectricalItemDto,
UpdateElectricalItemDto,
@@ -62,6 +65,10 @@ export {
OPENING_TYPES,
DOOR_OPEN_DIRECTIONS,
ELECTRICAL_TYPES,
OUTLET_DIRECTIONS,
WALL_LIGHT_STYLES,
FURNITURE_TEXTURES,
TEXTURABLE_FURNITURE,
FURNITURE_TYPES,
HORIZONTAL_ANCHORS,
VERTICAL_ANCHORS,
@@ -17,6 +17,10 @@ export const createRoomSchema = z.object({
wallHeight: z.number().positive('Wall height must be positive').optional(),
plinthHeight: z.number().min(0, 'Plinth height must be non-negative').optional(),
plinthThickness: z.number().min(0, 'Plinth thickness must be non-negative').optional(),
stretchCeilingOffset: z
.number()
.min(0, 'Stretch ceiling offset must be non-negative')
.optional(),
order: z.number().int().min(0).optional(),
posX: z.number().optional(),
posY: z.number().optional(),
@@ -35,6 +39,10 @@ export const updateRoomSchema = z.object({
wallHeight: z.number().positive('Wall height must be positive').optional(),
plinthHeight: z.number().min(0, 'Plinth height must be non-negative').optional(),
plinthThickness: z.number().min(0, 'Plinth thickness must be non-negative').optional(),
stretchCeilingOffset: z
.number()
.min(0, 'Stretch ceiling offset must be non-negative')
.optional(),
order: z.number().int().min(0).optional(),
posX: z.number().optional(),
posY: z.number().optional(),
+40
View File
@@ -224,6 +224,17 @@ export const ELECTRICAL_TYPES = [
] as const;
export type ElectricalType = (typeof ELECTRICAL_TYPES)[number];
export const OUTLET_DIRECTIONS = ['horizontal', 'vertical'] as const;
export type OutletDirection = (typeof OUTLET_DIRECTIONS)[number];
export const WALL_LIGHT_STYLES = [
'classic',
'pendant-globe',
'sconce-up',
'sconce-down',
] as const;
export type WallLightStyle = (typeof WALL_LIGHT_STYLES)[number];
export interface ElectricalItem {
readonly id: string;
readonly roomId: string;
@@ -277,6 +288,35 @@ export interface UpdateElectricalItemDto {
readonly metadata?: Record<string, unknown> | null;
}
// ── FurnitureTexture ──
export const FURNITURE_TEXTURES = [
'NONE',
'WOOD_LIGHT',
'WOOD_MEDIUM',
'WOOD_DARK',
'WOOD_HERRINGBONE',
'OAK_NATURAL',
'LAMINATE',
'CONCRETE',
] as const;
export type FurnitureTexture = (typeof FURNITURE_TEXTURES)[number];
/** Furniture types that support surface texture selection. */
export const TEXTURABLE_FURNITURE: readonly FurnitureType[] = [
'DESK',
'TABLE',
'SHELF',
'NIGHTSTAND',
'DRESSER',
'DRESSING_TABLE',
'BOOKCASE',
'WARDROBE',
'CHAIR',
'BED',
'CRIB',
];
// ── FurnitureItem ──
export const FURNITURE_TYPES = [
+10
View File
@@ -11,6 +11,7 @@ export const FLOOR_TYPES = [
'WOOD_MEDIUM',
'WOOD_DARK',
'WOOD_HERRINGBONE',
'OAK_NATURAL',
'TILE_WHITE',
'TILE_GRAY',
'LAMINATE',
@@ -50,6 +51,13 @@ export interface Room {
readonly wallHeight: number;
readonly plinthHeight: number;
readonly plinthThickness: number;
/**
* Distance (meters) the stretch ceiling hangs below the structural ceiling.
* 0 means no stretch ceiling. The effective ceiling plane is at
* `wallHeight - stretchCeilingOffset`. Visualization only does not
* affect wall geometry, opening positions, or electrical elevations.
*/
readonly stretchCeilingOffset: number;
readonly order: number;
readonly posX: number;
readonly posY: number;
@@ -80,6 +88,7 @@ export interface CreateRoomDto {
readonly wallHeight?: number;
readonly plinthHeight?: number;
readonly plinthThickness?: number;
readonly stretchCeilingOffset?: number;
readonly order?: number;
readonly posX?: number;
readonly posY?: number;
@@ -98,6 +107,7 @@ export interface UpdateRoomDto {
readonly wallHeight?: number;
readonly plinthHeight?: number;
readonly plinthThickness?: number;
readonly stretchCeilingOffset?: number;
readonly order?: number;
readonly posX?: number;
readonly posY?: number;