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(() => readCollapsed()); const toggleCollapsed = useCallback(() => { setCollapsed((prev) => { const next = !prev; writeCollapsed(next); return next; }); }, []); const header = (
{t('properties.title')}
); 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 (
); } if (selected.length === 0) { return (
{header}

{t('properties.noSelection')}

{t('properties.selectHint')}

{t('properties.roomInfo')}
{roomArea > 0 && ( )} {roomPerimeter > 0 && ( )} e.type === 'OUTLET').reduce((sum, e) => sum + Math.max(1, e.count), 0))} /> e.type === 'SWITCH').length)} /> {/* Stretch ceiling drop (натяжной потолок). Stored in meters, edited in cm for ergonomics. 0 = disabled. */} { const cm = parseFloat(v); if (!isNaN(cm) && cm >= 0 && cm <= 200) { dispatch({ type: 'UPDATE_ROOM_PROPS', props: { stretchCeilingOffset: cm / 100 }, }); } }} /> 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 } })} /> 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 } })} />
{t('properties.wallColor')} 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')} />
{/* Room-level outlet dimensions — used to draw outlet boundaries in all views (2D/3D/projection). Stored in meters; edited in cm. */} { const cm = parseFloat(v); if (!isNaN(cm) && cm > 0 && cm <= 100) { dispatch({ type: 'UPDATE_ROOM_PROPS', props: { outletWidth: cm / 100 } }); } }} /> { const cm = parseFloat(v); if (!isNaN(cm) && cm > 0 && cm <= 100) { dispatch({ type: 'UPDATE_ROOM_PROPS', props: { outletHeight: cm / 100 } }); } }} />
); } if (selected.length > 1) { return (
{header}

{t('properties.multipleSelected', { count: selected.length })}

); } const item = selected[0]; return (
{header} {item.type === 'wall' && ( { const original = item.data as Wall; const cmd: EditorCommand = { description: 'Update wall thickness', execute: () => updateWall(updated), undo: () => updateWall(original), }; execute(cmd); }} /> )} {item.type === 'opening' && ( { 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' && ( { const original = item.data as ElectricalItem; const cmd: EditorCommand = { description: 'Update electrical item', execute: () => updateElectrical(updated), undo: () => updateElectrical(original), }; execute(cmd); }} /> )} {item.type === 'furniture' && ( { 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') && ( )}
); } // ── 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 (
{attached.map((ann) => (
{ann.text}
))} { 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)} />
); } // ── 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 (
{t('properties.wall')}
); } // ── 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 (
{opening.type === 'DOOR' ? t('properties.door') : t('properties.window')}
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. */} { const n = parseFloat(v); if (!isNaN(n) && n >= 0) { onUpdate({ ...opening, frameThickness: Math.min(n, maxFrameThickness) }); } }} /> {opening.type === 'DOOR' && ( ({ value: dir, label: t(`properties.openDir.${dir}`), }))} onChange={handleOpenDirectionChange} /> )} {opening.type === 'WINDOW' && ( <> {/* 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. */} { const n = parseInt(v, 10); if (!isNaN(n) && n >= 1 && n <= 10) { onUpdate({ ...opening, gridCols: n }); } }} /> { 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. */} { const n = parseFloat(v); if (!isNaN(n) && n >= 0) { onUpdate({ ...opening, slopeDepth: Math.min(n, maxSlopeDepth) }); } }} /> )} {wall && ( )}
); } // ── Property Row Components ── interface PropertyRowProps { readonly label: string; readonly value: string; } function PropertyRow({ label, value }: PropertyRowProps) { return (
{label} {value}
); } interface CheckboxPropertyRowProps { readonly label: string; readonly checked: boolean; readonly onChange: (checked: boolean) => void; } function CheckboxPropertyRow({ label, checked, onChange }: CheckboxPropertyRowProps) { return ( ); } 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 (
{label}
setDraft(e.target.value)} onBlur={handleBlur} onKeyDown={handleKeyDown} autoFocus /> {unit && {unit}}
); } return (
{label}
); } // ── 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 (
{label}
); } // ── Select Property Row ── interface SelectPropertyRowProps { 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 (
{label} 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 }} />
); } function SelectPropertyRow({ label, value, options, onChange }: SelectPropertyRowProps) { return (
{label}
); } // ── 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 (
{displayTitle}
{!isOutlet && variant !== 'single' && } onUpdate({ ...item, label })} /> {isWallLight && ( 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' && ( { const num = parseFloat(v); if (!isNaN(num) && num >= 0.05 && num <= 2.0) { onUpdate({ ...item, metadata: { ...(item.metadata ?? {}), cordLength: num }, }); } }} /> )} {(isCeilingLight || isWallLight) && ( { const num = parseFloat(v); if (!isNaN(num) && num >= 0.05 && num <= 1.0) { onUpdate({ ...item, metadata: { ...(item.metadata ?? {}), lampSize: num }, }); } }} /> )} {isOutlet && ( { const n = parseInt(v, 10); if (!isNaN(n) && n >= 1 && n <= 20) onUpdate({ ...item, count: n }); }} /> )} {isOutlet && ( label={i18n.exists('properties.outletDirectionLabel') ? t('properties.outletDirectionLabel') : 'Direction'} value={outletDirection} options={outletDirectionOptions} onChange={(dir) => onUpdate({ ...item, metadata: { ...(item.metadata ?? {}), outletDirection: dir }, }) } /> )} {isOutlet && ( <> onUpdate({ ...item, metadata: { ...(item.metadata ?? {}), invertCoordX: checked }, }) } /> onUpdate({ ...item, metadata: { ...(item.metadata ?? {}), invertCoordY: checked }, }) } /> )} onUpdate({ ...item, positionAnchor })} /> {isWallMounted && ( <> )}
); } // ── 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 (
{displayLabel ?? item.type}
onUpdate({ ...item, label: preserveFurnitureMarkers(item.label, newDisplay) }) } /> {isTexturable && ( label={i18n.exists('properties.surfaceTexture') ? t('properties.surfaceTexture') : 'Surface'} value={currentTexture} options={textureOptions} onChange={(tex) => onUpdate({ ...item, metadata: { ...(item.metadata ?? {}), surfaceTexture: tex }, }) } /> )} onUpdate({ ...item, positionAnchor })} /> {item.type === 'CURTAIN' && } {item.type === 'BOOKCASE' && } {item.type === 'TV' && (
{t('properties.stand')}
)} {item.type === 'DIGITAL_PIANO' && (
{t('properties.stand')}
)}
); } 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 (
{label} { const next = parseFloat(e.target.value); updateFurniture({ ...item, opacity: Number.isFinite(next) ? next : 1 }); }} style={{ width: 100 }} /> {Math.round(value * 100)}%
); } 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 (
{label}
); } // ── 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) => { // 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 ( <> updateMetadata({ leftOpen: v })} /> updateMetadata({ rightOpen: v })} />
{colorLabel} updateMetadata({ fabricColor: e.target.value })} style={{ width: 32, height: 24, border: '1px solid var(--color-border)', borderRadius: 4, cursor: 'pointer', padding: 0 }} />
); } // ── 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) => { const next = { ...(item.metadata ?? {}), ...patch }; onUpdate({ ...item, metadata: next }); }, [item, onUpdate], ); return ( <> { const n = parseInt(v, 10); if (!isNaN(n) && n >= 1 && n <= 12) { updateMetadata({ shelfRows: n }); } }} />
{backPanelLabel}
); } interface CurtainOpenSliderProps { readonly label: string; readonly value: number; readonly onChange: (value: number) => void; } function CurtainOpenSlider({ label, value, onChange }: CurtainOpenSliderProps) { return (
{label} { const next = parseFloat(e.target.value); if (Number.isFinite(next)) onChange(next); }} style={{ width: 100 }} /> {Math.round(value * 100)}%
); } function formatM(meters: number): string { return `${Math.round(meters * 1000) / 1000}m`; } function formatCm(meters: number): string { return `${Math.round(meters * 100)}cm`; }