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
@@ -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} />