5929ba6bbb
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.
1554 lines
52 KiB
TypeScript
1554 lines
52 KiB
TypeScript
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, 1–12).
|
||
// 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`;
|
||
}
|