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:
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user