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;