Files
house-plan-maker/apps/client/src/components/editor/PropertiesPanel.tsx
T
alexei.dolgolyov 5929ba6bbb feat: add WALL_CABLE electrical type and room outlet/switch count stats
Add wall cable item — a bare cable exit from the wall for direct
consumer connection without an outlet. Includes 2D symbol (circle +
cable stub), 3D mesh (round plate + protruding cable), and palette entry.

Also add outlet and switch count metrics to the room info section in
the properties panel.
2026-04-13 11:16:54 +03:00

1554 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
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, 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,
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';
const PROPERTIES_COLLAPSED_KEY = 'editor.propertiesPanel.collapsed';
function readCollapsed(): boolean {
try {
return localStorage.getItem(PROPERTIES_COLLAPSED_KEY) === 'true';
} catch {
return false;
}
}
function writeCollapsed(value: boolean): void {
try {
localStorage.setItem(PROPERTIES_COLLAPSED_KEY, String(value));
} catch {
/* ignore quota / disabled storage */
}
}
export function PropertiesPanel() {
const { t } = useTranslation();
const { state, dispatch, updateOpening, updateElectrical, updateFurniture, updateWall } = useEditor();
const { execute } = useUndoRedo();
const { selectedIds, walls, openings, electricalItems, furnitureItems, room } = state;
const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsed());
const toggleCollapsed = useCallback(() => {
setCollapsed((prev) => {
const next = !prev;
writeCollapsed(next);
return next;
});
}, []);
const header = (
<div className={styles.header}>
<span>{t('properties.title')}</span>
<button
type="button"
className={styles.collapseBtn}
onClick={toggleCollapsed}
title={t('properties.collapse')}
aria-label={t('properties.collapse')}
>
{'\u25B6'}
</button>
</div>
);
const roomArea = useMemo(
() => room.shape.length >= 3 ? polygonArea(room.shape) : 0,
[room.shape],
);
const roomPerimeter = useMemo(
() => room.shape.length >= 2 ? polygonPerimeter(room.shape) : 0,
[room.shape],
);
// Find selected elements
const selected = useMemo(() => {
const items: {
type: 'wall' | 'opening' | 'electrical' | 'furniture';
data: Wall | WallOpening | ElectricalItem | FurnitureItem;
}[] = [];
for (const id of selectedIds) {
const wall = walls.find((w) => w.id === id);
if (wall) {
items.push({ type: 'wall', data: wall });
continue;
}
const opening = openings.find((o) => o.id === id);
if (opening) {
items.push({ type: 'opening', data: opening });
continue;
}
const elec = electricalItems.find((e) => e.id === id);
if (elec) {
items.push({ type: 'electrical', data: elec });
continue;
}
const furn = furnitureItems.find((f) => f.id === id);
if (furn) {
items.push({ type: 'furniture', data: furn });
}
}
return items;
}, [selectedIds, walls, openings, electricalItems, furnitureItems]);
if (collapsed) {
return (
<div className={styles.panelCollapsed}>
<button
type="button"
className={styles.collapseBtn}
onClick={toggleCollapsed}
title={t('properties.expand')}
aria-label={t('properties.expand')}
>
{'\u25C0'}
</button>
</div>
);
}
if (selected.length === 0) {
return (
<div className={styles.panel}>
{header}
<div className={styles.empty}>
<p className={styles.emptyText}>{t('properties.noSelection')}</p>
<p className={styles.emptyHint}>{t('properties.selectHint')}</p>
</div>
<div className={styles.section}>
<div className={styles.sectionTitle}>{t('properties.roomInfo')}</div>
<PropertyRow label={t('properties.name')} value={room.name} />
{roomArea > 0 && (
<PropertyRow label={t('properties.area')} value={`${(Math.round(roomArea * 100) / 100).toFixed(2)} m\u00B2`} />
)}
{roomPerimeter > 0 && (
<PropertyRow label={t('properties.perimeter')} value={`${(Math.round(roomPerimeter * 100) / 100).toFixed(2)} m`} />
)}
<PropertyRow label={t('properties.wallHeight')} value={`${room.wallHeight}m`} />
<PropertyRow label={t('properties.plinthHeight')} value={`${Math.round(room.plinthHeight * 1000) / 10}cm`} />
<PropertyRow
label={t('properties.outletCountStat')}
value={String(electricalItems.filter((e) => e.type === 'OUTLET').reduce((sum, e) => sum + Math.max(1, e.count), 0))}
/>
<PropertyRow
label={t('properties.switchCountStat')}
value={String(electricalItems.filter((e) => e.type === 'SWITCH').length)}
/>
{/* 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}
options={FLOOR_TYPES.map((ft) => ({
value: ft,
label: t(`floor.${ft}`),
}))}
onChange={(v) => dispatch({ type: 'UPDATE_ROOM_PROPS', props: { floorType: v } })}
/>
<SelectPropertyRow<WallFinish>
label={t('properties.wallFinish')}
value={room.wallFinish ?? 'PAINT'}
options={WALL_FINISHES.map((wf) => ({
value: wf,
label: t(`wallFinish.${wf}`),
}))}
onChange={(v) => dispatch({ type: 'UPDATE_ROOM_PROPS', props: { wallFinish: v } })}
/>
<div className={styles.row}>
<span className={styles.rowLabel}>{t('properties.wallColor')}</span>
<input
type="color"
value={room.wallColor ?? '#f5f0eb'}
onChange={(e) => dispatch({ type: 'UPDATE_ROOM_PROPS', props: { wallColor: e.target.value } })}
style={{ width: 32, height: 24, border: '1px solid var(--color-border)', borderRadius: 4, cursor: 'pointer', padding: 0 }}
// Wall color only renders on the PAINT finish — when a textured
// finish is selected the value is still editable so the user
// can pre-pick a colour for when they switch back. Tooltip
// explains when it won't be visible.
title={(room.wallFinish ?? 'PAINT') !== 'PAINT' ? t('properties.wallColorPaintOnly') : t('properties.wallColor')}
/>
</div>
<PropertyRow label={t('properties.walls')} value={String(walls.length)} />
<PropertyRow label={t('properties.openings')} value={String(openings.length)} />
{/* Room-level outlet dimensions — used to draw outlet boundaries in
all views (2D/3D/projection). Stored in meters; edited in cm. */}
<EditablePropertyRow
label={t('properties.outletWidth')}
value={String(Math.round(room.outletWidth * 1000) / 10)}
unit="cm"
onCommit={(v) => {
const cm = parseFloat(v);
if (!isNaN(cm) && cm > 0 && cm <= 100) {
dispatch({ type: 'UPDATE_ROOM_PROPS', props: { outletWidth: cm / 100 } });
}
}}
/>
<EditablePropertyRow
label={t('properties.outletHeight')}
value={String(Math.round(room.outletHeight * 1000) / 10)}
unit="cm"
onCommit={(v) => {
const cm = parseFloat(v);
if (!isNaN(cm) && cm > 0 && cm <= 100) {
dispatch({ type: 'UPDATE_ROOM_PROPS', props: { outletHeight: cm / 100 } });
}
}}
/>
</div>
</div>
);
}
if (selected.length > 1) {
return (
<div className={styles.panel}>
{header}
<div className={styles.empty}>
<p className={styles.emptyText}>{t('properties.multipleSelected', { count: selected.length })}</p>
</div>
</div>
);
}
const item = selected[0];
return (
<div className={styles.panel}>
{header}
{item.type === 'wall' && (
<WallProperties
wall={item.data as Wall}
onUpdate={(updated) => {
const original = item.data as Wall;
const cmd: EditorCommand = {
description: 'Update wall thickness',
execute: () => updateWall(updated),
undo: () => updateWall(original),
};
execute(cmd);
}}
/>
)}
{item.type === 'opening' && (
<OpeningProperties
opening={item.data as WallOpening}
walls={walls}
onUpdate={(updated) => {
const original = item.data as WallOpening;
const cmd: EditorCommand = {
description: `Update ${updated.type.toLowerCase()}`,
execute: () => updateOpening(updated),
undo: () => updateOpening(original),
};
execute(cmd);
}}
/>
)}
{item.type === 'electrical' && (
<ElectricalProperties
item={item.data as ElectricalItem}
onUpdate={(updated) => {
const original = item.data as ElectricalItem;
const cmd: EditorCommand = {
description: 'Update electrical item',
execute: () => updateElectrical(updated),
undo: () => updateElectrical(original),
};
execute(cmd);
}}
/>
)}
{item.type === 'furniture' && (
<FurnitureProperties
item={item.data as FurnitureItem}
onUpdate={(updated) => {
const original = item.data as FurnitureItem;
const cmd: EditorCommand = {
description: 'Update furniture item',
execute: () => updateFurniture(updated),
undo: () => updateFurniture(original),
};
execute(cmd);
}}
/>
)}
{/* Add note / edit attached annotations for any item */}
{(item.type === 'electrical' || item.type === 'furniture') && (
<ItemAnnotationManager itemId={item.data.id} roomId={room.id} />
)}
</div>
);
}
// ── Attached annotation manager ──
interface ItemAnnotationManagerProps {
readonly itemId: string;
readonly roomId: string;
}
function ItemAnnotationManager({ itemId, roomId }: ItemAnnotationManagerProps) {
const { t } = useTranslation();
const { state, addAnnotation, updateAnnotation, removeAnnotation } = useEditor();
const attached = useMemo(
() => state.annotations.filter((a) => a.attachedToId === itemId),
[state.annotations, itemId],
);
const [editing, setEditing] = useState<{ kind: 'add' } | { kind: 'edit'; annotation: Annotation } | null>(null);
return (
<div style={{ padding: '4px 8px', display: 'flex', flexDirection: 'column', gap: 4 }}>
{attached.map((ann) => (
<div
key={ann.id}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
fontSize: 12,
padding: '2px 6px',
border: '1px solid var(--color-border)',
borderRadius: 4,
background: 'var(--color-bg)',
}}
>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{ann.text}
</span>
<button
type="button"
style={{ fontSize: 11, padding: '0 4px' }}
onClick={() => setEditing({ kind: 'edit', annotation: ann })}
aria-label={t('annotation.edit') ?? 'Edit'}
>
</button>
<button
type="button"
style={{ fontSize: 11, padding: '0 4px' }}
onClick={() => removeAnnotation(ann.id)}
aria-label={t('annotation.delete') ?? 'Delete'}
>
</button>
</div>
))}
<button
type="button"
style={{
width: '100%',
padding: '4px 8px',
fontSize: 12,
border: '1px solid var(--color-border)',
borderRadius: 4,
background: 'var(--color-bg)',
color: 'var(--color-text-secondary)',
cursor: 'pointer',
}}
onClick={() => setEditing({ kind: 'add' })}
>
{t('properties.addNote')}
</button>
<TextPromptModal
open={editing != null}
title={t('annotation.editPrompt')}
initialValue={editing?.kind === 'edit' ? editing.annotation.text : ''}
onConfirm={(value) => {
const trimmed = value.trim();
if (!editing) return;
if (editing.kind === 'add') {
if (trimmed) {
addAnnotation({
id: generateLocalId(),
roomId,
x: 0.3,
y: -0.2,
text: trimmed,
fontSize: 12,
attachedToId: itemId,
});
}
} else if (trimmed && trimmed !== editing.annotation.text) {
updateAnnotation({ ...editing.annotation, text: trimmed });
}
setEditing(null);
}}
onCancel={() => setEditing(null)}
/>
</div>
);
}
// ── Wall Properties ──
interface WallPropertiesProps {
readonly wall: Wall;
readonly onUpdate: (wall: Wall) => void;
}
function WallProperties({ wall, onUpdate }: WallPropertiesProps) {
const { t } = useTranslation();
const len = wallLength(wall);
const handleThicknessChange = useCallback(
(value: string) => {
const cm = parseFloat(value);
if (!isNaN(cm) && cm > 0 && cm <= 100) {
onUpdate({ ...wall, thickness: cm / 100 });
}
},
[wall, onUpdate],
);
return (
<div className={styles.section}>
<div className={styles.sectionTitle}>{t('properties.wall')}</div>
<PropertyRow label={t('properties.length')} value={formatM(len)} />
<EditablePropertyRow
label={t('properties.thickness')}
value={String(Math.round(wall.thickness * 100))}
unit="cm"
onCommit={handleThicknessChange}
/>
<PropertyRow label={t('properties.startX')} value={formatM(wall.startX)} />
<PropertyRow label={t('properties.startY')} value={formatM(wall.startY)} />
<PropertyRow label={t('properties.endX')} value={formatM(wall.endX)} />
<PropertyRow label={t('properties.endY')} value={formatM(wall.endY)} />
<PropertyRow label={t('properties.direction')} value={wall.direction} />
</div>
);
}
// ── Opening Properties ──
interface OpeningPropertiesProps {
readonly opening: WallOpening;
readonly walls: readonly Wall[];
readonly onUpdate: (opening: WallOpening) => void;
}
function OpeningProperties({ opening, walls, onUpdate }: OpeningPropertiesProps) {
const { t, i18n } = useTranslation();
const wall = walls.find((w) => w.id === opening.wallId);
const wLen = wall ? wallLength(wall) : 0;
const gridColsLabel = i18n.exists('properties.windowGridCols')
? t('properties.windowGridCols')
: 'Grid columns';
const gridRowsLabel = i18n.exists('properties.windowGridRows')
? t('properties.windowGridRows')
: 'Grid rows';
const slopeDepthLabel = i18n.exists('properties.windowSlopeDepth')
? t('properties.windowSlopeDepth')
: 'Reveal depth (slope)';
const frameThicknessLabel = i18n.exists('properties.openingFrameThickness')
? t('properties.openingFrameThickness')
: 'Frame thickness';
// Slope projects outward from the window into and through the wall
// toward the exterior, so it isn't bounded by wall thickness — only by
// the schema-level cap (2 m), which is plenty for any realistic reveal.
const maxSlopeDepth = 2;
// Frame thickness is bounded by the schema cap (0.5 m); deeper than that
// would dwarf even oversize doors and is almost certainly a typo.
const maxFrameThickness = 0.5;
// Openings always store canonical (positionAlongWall=center, elevationFromFloor=bottom).
// The anchor on an opening is a view-only preference: it controls how the
// numbers are displayed and edited in this panel, but does not move the
// physical opening. Toggling anchor only changes which edge of the opening
// the displayed values refer to.
const anchor = opening.positionAnchor;
const handleWidthChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num) && num > 0 && num < wLen) {
onUpdate({ ...opening, width: num });
}
},
[opening, onUpdate, wLen],
);
const handleHeightChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num) && num > 0) {
onUpdate({ ...opening, height: num });
}
},
[opening, onUpdate],
);
// Convert canonical (center along wall, bottom from floor) into the value
// displayed in the panel based on the user's anchor preference.
const displayPosition = Math.round((() => {
if (anchor.horizontal === 'left') return opening.positionAlongWall - opening.width / 2;
if (anchor.horizontal === 'right') return opening.positionAlongWall + opening.width / 2;
return opening.positionAlongWall;
})() * 1000) / 1000;
const displayElevation = Math.round((() => {
if (anchor.vertical === 'top') return opening.elevationFromFloor + opening.height;
if (anchor.vertical === 'middle') return opening.elevationFromFloor + opening.height / 2;
return opening.elevationFromFloor;
})() * 1000) / 1000;
const handlePositionChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num)) {
// Convert anchored value back to canonical center position.
let centerPos = num;
if (anchor.horizontal === 'left') centerPos = num + opening.width / 2;
else if (anchor.horizontal === 'right') centerPos = num - opening.width / 2;
if (centerPos >= 0 && centerPos <= wLen) {
onUpdate({ ...opening, positionAlongWall: centerPos });
}
}
},
[opening, onUpdate, wLen, anchor.horizontal],
);
const handleElevationChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num)) {
// Convert anchored vertical value back to canonical bottom-edge.
let bottom = num;
if (anchor.vertical === 'top') bottom = num - opening.height;
else if (anchor.vertical === 'middle') bottom = num - opening.height / 2;
if (bottom >= 0) {
onUpdate({ ...opening, elevationFromFloor: bottom });
}
}
},
[opening, onUpdate, anchor.vertical],
);
const handleOpenDirectionChange = useCallback(
(direction: DoorOpenDirection) => {
onUpdate({ ...opening, openDirection: direction });
},
[opening, onUpdate],
);
return (
<div className={styles.section}>
<div className={styles.sectionTitle}>
{opening.type === 'DOOR' ? t('properties.door') : t('properties.window')}
</div>
<EditablePropertyRow
label={t('properties.width')}
value={String(opening.width)}
unit="m"
onCommit={handleWidthChange}
/>
<EditablePropertyRow
label={t('properties.height')}
value={String(opening.height)}
unit="m"
onCommit={handleHeightChange}
/>
<EditablePropertyRow
label={t('properties.position')}
value={String(displayPosition)}
unit="m"
onCommit={handlePositionChange}
/>
<PositionAnchorEditor
anchor={anchor}
onChange={(positionAnchor) => onUpdate({ ...opening, positionAnchor })}
/>
{/* Frame member thickness — applies to both doors and windows. Clamped
to the schema cap so a typo can't produce a wall-sized frame. */}
<EditablePropertyRow
label={frameThicknessLabel}
value={String(opening.frameThickness)}
unit="m"
onCommit={(v) => {
const n = parseFloat(v);
if (!isNaN(n) && n >= 0) {
onUpdate({ ...opening, frameThickness: Math.min(n, maxFrameThickness) });
}
}}
/>
{opening.type === 'DOOR' && (
<SelectPropertyRow
label={t('properties.openDirection')}
value={opening.openDirection}
options={DOOR_OPEN_DIRECTIONS.map((dir) => ({
value: dir,
label: t(`properties.openDir.${dir}`),
}))}
onChange={handleOpenDirectionChange}
/>
)}
{opening.type === 'WINDOW' && (
<>
<EditablePropertyRow
label={t('properties.elevation')}
value={String(displayElevation)}
unit="m"
onCommit={handleElevationChange}
/>
{/* Grid subdivision: N columns × M rows of panes. The 3D and
projection views render (cols-1) vertical mullions and
(rows-1) horizontal mullions. Clamp to [1, 10] so a user
typo can't produce a 1000-mullion window. */}
<EditablePropertyRow
label={gridColsLabel}
value={String(opening.gridCols)}
onCommit={(v) => {
const n = parseInt(v, 10);
if (!isNaN(n) && n >= 1 && n <= 10) {
onUpdate({ ...opening, gridCols: n });
}
}}
/>
<EditablePropertyRow
label={gridRowsLabel}
value={String(opening.gridRows)}
onCommit={(v) => {
const n = parseInt(v, 10);
if (!isNaN(n) && n >= 1 && n <= 10) {
onUpdate({ ...opening, gridRows: n });
}
}}
/>
{/* Reveal (откос) depth — how far the angled jamb panels protrude
from the window frame into the room. 0 = flush, no slope. The
renderer clamps the value so it cannot exceed half the wall
thickness; we mirror that clamp in the input handler so a
typo can't push the window out the back of the wall. */}
<EditablePropertyRow
label={slopeDepthLabel}
value={String(opening.slopeDepth)}
unit="m"
onCommit={(v) => {
const n = parseFloat(v);
if (!isNaN(n) && n >= 0) {
onUpdate({ ...opening, slopeDepth: Math.min(n, maxSlopeDepth) });
}
}}
/>
</>
)}
{wall && (
<PropertyRow label={t('properties.wallLength')} value={formatM(wLen)} />
)}
</div>
);
}
// ── Property Row Components ──
interface PropertyRowProps {
readonly label: string;
readonly value: string;
}
function PropertyRow({ label, value }: PropertyRowProps) {
return (
<div className={styles.row}>
<span className={styles.rowLabel}>{label}</span>
<span className={styles.rowValue}>{value}</span>
</div>
);
}
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;
readonly unit?: string;
readonly onCommit: (value: string) => void;
}
function EditablePropertyRow({ label, value, unit, onCommit }: EditablePropertyRowProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const handleBlur = useCallback(() => {
setEditing(false);
if (draft !== value) {
onCommit(draft);
}
}, [draft, value, onCommit]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleBlur();
} else if (e.key === 'Escape') {
setDraft(value);
setEditing(false);
}
},
[handleBlur, value],
);
if (editing) {
return (
<div className={styles.row}>
<span className={styles.rowLabel}>{label}</span>
<div className={styles.editRow}>
<input
className={styles.editInput}
type="text"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
autoFocus
/>
{unit && <span className={styles.editUnit}>{unit}</span>}
</div>
</div>
);
}
return (
<div className={styles.row}>
<span className={styles.rowLabel}>{label}</span>
<button
className={styles.editableValue}
onClick={() => {
setDraft(value);
setEditing(true);
}}
>
{value}{unit ? ` ${unit}` : ''}
</button>
</div>
);
}
// ── Position Anchor Editor ──
//
// Renders two side-by-side select boxes that edit `positionAnchor`. Used for
// every placeable item (electrical, furniture, openings).
interface PositionAnchorEditorProps {
readonly anchor: PositionAnchor;
readonly onChange: (anchor: PositionAnchor) => void;
}
function PositionAnchorEditor({ anchor, onChange }: PositionAnchorEditorProps) {
const { t, i18n } = useTranslation();
const label = i18n.exists('properties.anchor') ? t('properties.anchor') : 'Anchor';
const labelFor = (v: HorizontalAnchor | VerticalAnchor): string => {
const key = `anchor.${v}`;
return i18n.exists(key) ? t(key) : v;
};
return (
<div className={styles.row}>
<span className={styles.rowLabel}>{label}</span>
<span style={{ display: 'flex', gap: 4, flex: 1, justifyContent: 'flex-end' }}>
<select
className={styles.selectInput}
value={anchor.horizontal}
onChange={(e) => onChange({ ...anchor, horizontal: e.target.value as HorizontalAnchor })}
style={{ flex: 1, minWidth: 0 }}
>
{HORIZONTAL_ANCHORS.map((h) => (
<option key={h} value={h}>{labelFor(h)}</option>
))}
</select>
<select
className={styles.selectInput}
value={anchor.vertical}
onChange={(e) => onChange({ ...anchor, vertical: e.target.value as VerticalAnchor })}
style={{ flex: 1, minWidth: 0 }}
>
{VERTICAL_ANCHORS.map((v) => (
<option key={v} value={v}>{labelFor(v)}</option>
))}
</select>
</span>
</div>
);
}
// ── Select Property Row ──
interface SelectPropertyRowProps<T extends string> {
readonly label: string;
readonly value: T;
readonly options: readonly { readonly value: T; readonly label: string }[];
readonly onChange: (value: T) => void;
}
// ── Label override row ──
//
// Editable text input that overrides the default symbol/furniture label.
// Empty string clears the override (stored as `null`); the placeholder
// shows the default the item would fall back to.
interface LabelOverrideRowProps {
readonly value: string | null;
readonly placeholder: string;
readonly onChange: (value: string | null) => void;
}
function LabelOverrideRow({ value, placeholder, onChange }: LabelOverrideRowProps) {
const { t, i18n } = useTranslation();
const [draft, setDraft] = useState(value ?? '');
const [editing, setEditing] = useState(false);
// Sync external changes (selecting a different item) into the draft when
// the input is not currently being edited.
if (!editing && draft !== (value ?? '')) {
setDraft(value ?? '');
}
const commit = useCallback(() => {
setEditing(false);
const trimmed = draft.trim();
const next = trimmed.length > 0 ? trimmed : null;
if (next !== value) {
onChange(next);
}
}, [draft, value, onChange]);
const label = i18n.exists('properties.customLabel') ? t('properties.customLabel') : 'Title';
return (
<div className={styles.row}>
<span className={styles.rowLabel}>{label}</span>
<input
type="text"
className={styles.editInput}
value={draft}
placeholder={placeholder}
onFocus={() => setEditing(true)}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') {
(e.target as HTMLInputElement).blur();
} else if (e.key === 'Escape') {
setDraft(value ?? '');
setEditing(false);
}
}}
style={{ flex: 1, minWidth: 0 }}
/>
</div>
);
}
function SelectPropertyRow<T extends string>({ label, value, options, onChange }: SelectPropertyRowProps<T>) {
return (
<div className={styles.row}>
<span className={styles.rowLabel}>{label}</span>
<select
className={styles.selectInput}
value={value}
onChange={(e) => onChange(e.target.value as T)}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
}
// ── Electrical Properties ──
interface ElectricalPropertiesProps {
readonly item: ElectricalItem;
readonly onUpdate: (item: ElectricalItem) => void;
}
function ElectricalProperties({ item, onUpdate }: ElectricalPropertiesProps) {
const { t, i18n } = useTranslation();
const variant = getElectricalVariant(item.metadata);
const def = ELECTRICAL_SYMBOL_DEFS.find(
(d) => d.type === item.type && (d.variant ?? 'single') === (item.type === 'OUTLET' ? undefined : variant),
);
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);
if (!isNaN(num)) onUpdate({ ...item, x: num });
},
[item, onUpdate],
);
const handleYChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num)) onUpdate({ ...item, y: num });
},
[item, onUpdate],
);
const handleRotationChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num)) onUpdate({ ...item, rotation: normalizeAngleDegrees(num) });
},
[item, onUpdate],
);
const handleElevationChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num) && num >= 0) {
onUpdate({ ...item, elevationFromFloor: num });
}
},
[item, onUpdate],
);
// Section title shows the user's custom label if set, otherwise the
// default symbol-def label or raw type code as fallback.
const displayTitle = item.label ?? def?.label ?? item.type;
const labelPlaceholder = def?.label ?? item.type;
return (
<div className={styles.section}>
<div className={styles.sectionTitle}>{displayTitle}</div>
<PropertyRow label={t('properties.type')} value={item.type} />
{!isOutlet && variant !== 'single' && <PropertyRow label={t('properties.variant')} value={variant} />}
<LabelOverrideRow
value={item.label}
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}
value={String(item.count)}
onCommit={(v) => {
const n = parseInt(v, 10);
if (!isNaN(n) && n >= 1 && n <= 20) onUpdate({ ...item, count: n });
}}
/>
)}
{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} />
<PositionAnchorEditor
anchor={item.positionAnchor}
onChange={(positionAnchor) => onUpdate({ ...item, positionAnchor })}
/>
{isWallMounted && (
<>
<PropertyRow label={t('properties.wallMounted')} value={t('properties.yes')} />
<EditablePropertyRow
label={t('properties.elevation')}
value={String(Math.round((item.elevationFromFloor ?? 0) * 1000) / 1000)}
unit="m"
onCommit={handleElevationChange}
/>
</>
)}
</div>
);
}
// ── Furniture Properties ──
interface FurniturePropertiesProps {
readonly item: FurnitureItem;
readonly onUpdate: (item: FurnitureItem) => void;
}
/**
* Strip the legacy `[no-stand]` marker from a furniture label so the input
* field shows only the user-visible name. The marker is a single-purpose
* boolean stored in the label string for TVs to disable the stand mesh.
*/
function stripFurnitureMarkers(label: string | null): string | null {
if (!label) return null;
const cleaned = label.replace('[no-stand]', '').trim();
return cleaned.length > 0 ? cleaned : null;
}
/**
* Re-attach any markers that the original label carried after the user
* edited the visible portion. Currently only `[no-stand]` is preserved.
*/
function preserveFurnitureMarkers(originalLabel: string | null, newDisplay: string | null): string | null {
const hadNoStand = originalLabel?.includes('[no-stand]') ?? false;
if (!hadNoStand) return newDisplay;
const trimmed = (newDisplay ?? '').trim();
return trimmed.length > 0 ? `${trimmed} [no-stand]` : '[no-stand]';
}
function FurnitureProperties({ item, onUpdate }: FurniturePropertiesProps) {
const { t, 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;
const handleXChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num)) onUpdate({ ...item, x: num });
},
[item, onUpdate],
);
const handleYChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num)) onUpdate({ ...item, y: num });
},
[item, onUpdate],
);
const handleWidthChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num) && num > 0) onUpdate({ ...item, width: num });
},
[item, onUpdate],
);
const handleDepthChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num) && num > 0) onUpdate({ ...item, depth: num });
},
[item, onUpdate],
);
const handleHeightChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num) && num > 0) onUpdate({ ...item, height: num });
},
[item, onUpdate],
);
const handleElevationChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num) && num >= 0) onUpdate({ ...item, elevationFromFloor: num });
},
[item, onUpdate],
);
const handleRotationChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num)) onUpdate({ ...item, rotation: normalizeAngleDegrees(num) });
},
[item, onUpdate],
);
return (
<div className={styles.section}>
<div className={styles.sectionTitle}>
{displayLabel ?? item.type}
</div>
<PropertyRow label={t('properties.type')} value={item.type} />
<LabelOverrideRow
value={displayLabel}
placeholder={labelPlaceholder}
onChange={(newDisplay) =>
onUpdate({ ...item, label: preserveFurnitureMarkers(item.label, newDisplay) })
}
/>
{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} />
<EditablePropertyRow label={t('properties.depth')} value={String(item.depth)} unit="m" onCommit={handleDepthChange} />
<EditablePropertyRow label={t('properties.height')} value={String(item.height)} unit="m" onCommit={handleHeightChange} />
<EditablePropertyRow label={t('properties.elevation')} value={String(Math.round(item.elevationFromFloor * 1000) / 1000)} unit="m" onCommit={handleElevationChange} />
<EditablePropertyRow label={t('properties.rotation')} value={String(Math.round(item.rotation))} unit={"\u00b0"} onCommit={handleRotationChange} />
<PositionAnchorEditor
anchor={item.positionAnchor}
onChange={(positionAnchor) => onUpdate({ ...item, positionAnchor })}
/>
<FurnitureOpacitySlider item={item} />
<FurnitureProjectionToggle item={item} />
{item.type === 'CURTAIN' && <CurtainControls item={item} onUpdate={onUpdate} />}
{item.type === 'BOOKCASE' && <BookcaseControls item={item} onUpdate={onUpdate} />}
{item.type === 'TV' && (
<div className={styles.row}>
<span className={styles.rowLabel}>{t('properties.stand')}</span>
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={!item.label?.includes('[no-stand]')}
onChange={(e) => {
const hasStand = e.target.checked;
const cleanLabel = (item.label ?? '').replace('[no-stand]', '').trim();
onUpdate({
...item,
label: hasStand ? (cleanLabel || null) : `${cleanLabel} [no-stand]`.trim(),
});
}}
/>
{t('properties.yes')}
</label>
</div>
)}
{item.type === 'DIGITAL_PIANO' && (
<div className={styles.row}>
<span className={styles.rowLabel}>{t('properties.stand')}</span>
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', cursor: 'pointer' }}>
<input
type="checkbox"
// Missing value means "default on" — matches the preset default.
checked={(item.metadata?.['hasStand'] as boolean | undefined) ?? true}
onChange={(e) => {
const next = { ...(item.metadata ?? {}), hasStand: e.target.checked };
onUpdate({ ...item, metadata: next });
}}
/>
{t('properties.yes')}
</label>
</div>
)}
</div>
);
}
function FurnitureOpacitySlider({ item }: { readonly item: FurnitureItem }) {
const { t, i18n } = useTranslation();
const { updateFurniture } = useEditor();
const value = item.opacity ?? 1;
const label = i18n.exists('properties.opacity') ? t('properties.opacity') : 'Opacity';
return (
<div className={styles.row}>
<span className={styles.rowLabel}>{label}</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 6, flex: 1, justifyContent: 'flex-end' }}>
<input
type="range"
min={0}
max={1}
step={0.05}
value={value}
onChange={(e) => {
const next = parseFloat(e.target.value);
updateFurniture({ ...item, opacity: Number.isFinite(next) ? next : 1 });
}}
style={{ width: 100 }}
/>
<span style={{ minWidth: 32, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
{Math.round(value * 100)}%
</span>
</span>
</div>
);
}
function FurnitureProjectionToggle({ item }: { readonly item: FurnitureItem }) {
const { t, i18n } = useTranslation();
const { updateFurniture } = useEditor();
const enabled = item.showProjection ?? false;
const label = i18n.exists('properties.showProjection')
? t('properties.showProjection')
: 'Show on wall projection';
return (
<div className={styles.row}>
<span className={styles.rowLabel}>{label}</span>
<label style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
<input
type="checkbox"
checked={enabled}
onChange={() => updateFurniture({ ...item, showProjection: !enabled })}
/>
</label>
</div>
);
}
// ── Curtain-specific controls ──
//
// Curtains store their state in the item's metadata bag:
// - `leftOpen`, `rightOpen` (0..1) — per-side retraction
// - `openAmount` (legacy symmetric) — still honoured by the helpers as a
// fallback for either side when a per-side key is missing
// - `fabricColor` (hex)
//
// Editing either side writes the explicit per-side key. We also clear the
// legacy `openAmount` on first edit so the new per-side values don't get
// shadowed by a stale symmetric value on subsequent reads.
interface CurtainControlsProps {
readonly item: FurnitureItem;
readonly onUpdate: (item: FurnitureItem) => void;
}
function CurtainControls({ item, onUpdate }: CurtainControlsProps) {
const { t, i18n } = useTranslation();
const leftOpen = getCurtainLeftOpen(item.metadata);
const rightOpen = getCurtainRightOpen(item.metadata);
const fabricColor = getCurtainFabricColor(item.metadata);
const updateMetadata = useCallback(
(patch: Record<string, unknown>) => {
// When a per-side key is being written, drop the legacy symmetric
// `openAmount` so it doesn't keep overriding the new per-side values
// on subsequent reads via the fallback in `curtainMetadata.ts`.
const base = { ...(item.metadata ?? {}) };
if ('leftOpen' in patch || 'rightOpen' in patch) {
delete base['openAmount'];
}
const next = { ...base, ...patch };
onUpdate({ ...item, metadata: next });
},
[item, onUpdate],
);
const leftLabel = i18n.exists('properties.curtainLeftOpen')
? t('properties.curtainLeftOpen')
: 'Left open';
const rightLabel = i18n.exists('properties.curtainRightOpen')
? t('properties.curtainRightOpen')
: 'Right open';
const colorLabel = i18n.exists('properties.curtainFabricColor')
? t('properties.curtainFabricColor')
: 'Fabric color';
return (
<>
<CurtainOpenSlider
label={leftLabel}
value={leftOpen}
onChange={(v) => updateMetadata({ leftOpen: v })}
/>
<CurtainOpenSlider
label={rightLabel}
value={rightOpen}
onChange={(v) => updateMetadata({ rightOpen: v })}
/>
<div className={styles.row}>
<span className={styles.rowLabel}>{colorLabel}</span>
<input
type="color"
value={fabricColor}
onChange={(e) => updateMetadata({ fabricColor: e.target.value })}
style={{ width: 32, height: 24, border: '1px solid var(--color-border)', borderRadius: 4, cursor: 'pointer', padding: 0 }}
/>
</div>
</>
);
}
// ── Bookcase controls ──
//
// A bookcase has two editable per-item properties stored in its
// metadata bag:
// - `shelfRows`: number of storage compartments (integer, 112).
// The 3D mesh draws one more horizontal board than this number
// (top + bottom + internal dividers).
// - `hasBackPanel`: whether the unit has a solid back panel. An
// "open bookshelf" that can double as a room divider omits it.
// Missing values fall back to the legacy behaviour: auto-derive the
// row count from the item's height and always draw the back panel.
interface BookcaseControlsProps {
readonly item: FurnitureItem;
readonly onUpdate: (item: FurnitureItem) => void;
}
function BookcaseControls({ item, onUpdate }: BookcaseControlsProps) {
const { t, i18n } = useTranslation();
const rowsLabel = i18n.exists('properties.shelfRows')
? t('properties.shelfRows')
: 'Shelf rows';
const backPanelLabel = i18n.exists('properties.hasBackPanel')
? t('properties.hasBackPanel')
: 'Back panel';
const metadataRows = item.metadata?.['shelfRows'];
const currentRows =
typeof metadataRows === 'number' && metadataRows >= 1
? Math.round(metadataRows)
: Math.max(2, Math.round(item.height / 0.35));
const metadataHasBack = item.metadata?.['hasBackPanel'];
const hasBack = typeof metadataHasBack === 'boolean' ? metadataHasBack : true;
const updateMetadata = useCallback(
(patch: Record<string, unknown>) => {
const next = { ...(item.metadata ?? {}), ...patch };
onUpdate({ ...item, metadata: next });
},
[item, onUpdate],
);
return (
<>
<EditablePropertyRow
label={rowsLabel}
value={String(currentRows)}
onCommit={(v) => {
const n = parseInt(v, 10);
if (!isNaN(n) && n >= 1 && n <= 12) {
updateMetadata({ shelfRows: n });
}
}}
/>
<div className={styles.row}>
<span className={styles.rowLabel}>{backPanelLabel}</span>
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={hasBack}
onChange={(e) => updateMetadata({ hasBackPanel: e.target.checked })}
/>
{t('properties.yes')}
</label>
</div>
</>
);
}
interface CurtainOpenSliderProps {
readonly label: string;
readonly value: number;
readonly onChange: (value: number) => void;
}
function CurtainOpenSlider({ label, value, onChange }: CurtainOpenSliderProps) {
return (
<div className={styles.row}>
<span className={styles.rowLabel}>{label}</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 6, flex: 1, justifyContent: 'flex-end' }}>
<input
type="range"
min={0}
max={1}
step={0.05}
value={value}
onChange={(e) => {
const next = parseFloat(e.target.value);
if (Number.isFinite(next)) onChange(next);
}}
style={{ width: 100 }}
/>
<span style={{ minWidth: 32, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
{Math.round(value * 100)}%
</span>
</span>
</div>
);
}
function formatM(meters: number): string {
return `${Math.round(meters * 1000) / 1000}m`;
}
function formatCm(meters: number): string {
return `${Math.round(meters * 100)}cm`;
}