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.
This commit is contained in:
2026-04-13 11:16:54 +03:00
parent 521ea5e85b
commit 5929ba6bbb
7 changed files with 59 additions and 2 deletions
@@ -242,6 +242,8 @@
"properties.curtainLeftOpen": "Left open", "properties.curtainLeftOpen": "Left open",
"properties.curtainRightOpen": "Right open", "properties.curtainRightOpen": "Right open",
"properties.curtainFabricColor": "Fabric color", "properties.curtainFabricColor": "Fabric color",
"properties.outletCountStat": "Outlets",
"properties.switchCountStat": "Switches",
"properties.outletWidth": "Outlet width", "properties.outletWidth": "Outlet width",
"properties.outletHeight": "Outlet height", "properties.outletHeight": "Outlet height",
"properties.outletCount": "Count", "properties.outletCount": "Count",
@@ -293,6 +295,7 @@
"electrical.junction": "Junction", "electrical.junction": "Junction",
"electrical.lights": "Lights", "electrical.lights": "Lights",
"electrical.cable": "Cable", "electrical.cable": "Cable",
"electrical.wallCable": "Wall Cable",
"furniture.title": "Furniture", "furniture.title": "Furniture",
"furniture.searchPlaceholder": "Search furniture\u2026", "furniture.searchPlaceholder": "Search furniture\u2026",
@@ -245,6 +245,8 @@
"properties.curtainLeftOpen": "Левая створка", "properties.curtainLeftOpen": "Левая створка",
"properties.curtainRightOpen": "Правая створка", "properties.curtainRightOpen": "Правая створка",
"properties.curtainFabricColor": "Цвет ткани", "properties.curtainFabricColor": "Цвет ткани",
"properties.outletCountStat": "Розетки",
"properties.switchCountStat": "Выключатели",
"properties.outletWidth": "Ширина розетки", "properties.outletWidth": "Ширина розетки",
"properties.outletHeight": "Высота розетки", "properties.outletHeight": "Высота розетки",
"properties.outletCount": "Количество", "properties.outletCount": "Количество",
@@ -296,6 +298,7 @@
"electrical.junction": "Распределительная коробка", "electrical.junction": "Распределительная коробка",
"electrical.lights": "Освещение", "electrical.lights": "Освещение",
"electrical.cable": "Кабель", "electrical.cable": "Кабель",
"electrical.wallCable": "Кабель из стены",
"furniture.title": "Мебель", "furniture.title": "Мебель",
"furniture.searchPlaceholder": "Поиск мебели\u2026", "furniture.searchPlaceholder": "Поиск мебели\u2026",
@@ -151,6 +151,14 @@ export function PropertiesPanel() {
)} )}
<PropertyRow label={t('properties.wallHeight')} value={`${room.wallHeight}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.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, {/* Stretch ceiling drop (натяжной потолок). Stored in meters,
edited in cm for ergonomics. 0 = disabled. */} edited in cm for ergonomics. 0 = disabled. */}
<EditablePropertyRow <EditablePropertyRow
@@ -290,8 +290,27 @@ export function ProjectionElectrical({
</> </>
); );
})()} })()}
{item.type === 'WALL_CABLE' && (
<>
{/* Wall plate circle */}
<Circle
x={center.x}
y={center.y}
radius={half * 0.7}
fill={fillColor}
stroke={strokeColor}
strokeWidth={1.5}
/>
{/* Cable stub hanging down */}
<Line
points={[center.x, center.y + half * 0.7, center.x, center.y + half * 1.6]}
stroke="#555555"
strokeWidth={2}
/>
</>
)}
{/* Fallback for other wall-mounted types */} {/* Fallback for other wall-mounted types */}
{item.type !== 'OUTLET' && item.type !== 'SWITCH' && item.type !== 'LIGHT_WALL' && ( {item.type !== 'OUTLET' && item.type !== 'SWITCH' && item.type !== 'LIGHT_WALL' && item.type !== 'WALL_CABLE' && (
<Rect <Rect
x={center.x - half} x={center.x - half}
y={center.y - half} y={center.y - half}
@@ -315,7 +334,9 @@ export function ProjectionElectrical({
? 'OUT' ? 'OUT'
: item.type === 'SWITCH' : item.type === 'SWITCH'
? 'SW' ? 'SW'
: 'WL' : item.type === 'WALL_CABLE'
? 'CBL'
: 'WL'
} }
align="center" align="center"
fontSize={8} fontSize={8}
@@ -29,6 +29,7 @@ export const ELECTRICAL_SYMBOL_DEFS: readonly ElectricalSymbolDef[] = [
{ type: 'LIGHT_CEILING', label: 'Ceiling Light', category: 'light', wallMounted: false, coverageRadius: 2.0 }, { type: 'LIGHT_CEILING', label: 'Ceiling Light', category: 'light', wallMounted: false, coverageRadius: 2.0 },
{ type: 'LIGHT_WALL', label: 'Wall Light', category: 'light', wallMounted: true, coverageRadius: 1.5 }, { type: 'LIGHT_WALL', label: 'Wall Light', category: 'light', wallMounted: true, coverageRadius: 1.5 },
{ type: 'CABLE_ROUTE', label: 'Cable Route', category: 'cable', wallMounted: false }, { type: 'CABLE_ROUTE', label: 'Cable Route', category: 'cable', wallMounted: false },
{ type: 'WALL_CABLE', label: 'Wall Cable', category: 'cable', wallMounted: true },
]; ];
/** Get the variant from an electrical item's metadata. Used by switches; outlets use `count`. */ /** Get the variant from an electrical item's metadata. Used by switches; outlets use `count`. */
@@ -53,6 +53,7 @@ const ELECTRICAL_COLORS: Record<ElectricalType, string> = {
LIGHT_CEILING: '#fff8dc', LIGHT_CEILING: '#fff8dc',
LIGHT_WALL: '#fff8dc', LIGHT_WALL: '#fff8dc',
CABLE_ROUTE: '#ff6b35', CABLE_ROUTE: '#ff6b35',
WALL_CABLE: '#c0c0c0',
}; };
const SELECTED_COLOR = '#6fa8dc'; const SELECTED_COLOR = '#6fa8dc';
@@ -375,6 +376,24 @@ function WallLightMesh({ color, style, cordLength, lampSize }: {
} }
} }
/** Wall cable: round plate on wall with a cable stub protruding into the room. */
function WallCableMesh({ color }: { readonly color: string }) {
return (
<group>
{/* Wall plate */}
<mesh castShadow>
<cylinderGeometry args={[0.03, 0.03, 0.01, 16]} />
<meshStandardMaterial color={color} roughness={0.4} />
</mesh>
{/* Cable stub protruding from the wall */}
<mesh position={[0, 0, 0.025]} rotation={[Math.PI / 2, 0, 0]} castShadow>
<cylinderGeometry args={[0.006, 0.006, 0.04, 8]} />
<meshStandardMaterial color="#333333" roughness={0.7} />
</mesh>
</group>
);
}
/** Cable route: small orange marker */ /** Cable route: small orange marker */
function CableRouteMesh({ color }: { readonly color: string }) { function CableRouteMesh({ color }: { readonly color: string }) {
return ( return (
@@ -513,6 +532,7 @@ export function ElectricalMeshWithHeight({
/> />
)} )}
{item.type === 'CABLE_ROUTE' && <CableRouteMesh color={color} />} {item.type === 'CABLE_ROUTE' && <CableRouteMesh color={color} />}
{item.type === 'WALL_CABLE' && <WallCableMesh color={color} />}
</group> </group>
); );
} }
+1
View File
@@ -221,6 +221,7 @@ export const ELECTRICAL_TYPES = [
'LIGHT_CEILING', 'LIGHT_CEILING',
'LIGHT_WALL', 'LIGHT_WALL',
'CABLE_ROUTE', 'CABLE_ROUTE',
'WALL_CABLE',
] as const; ] as const;
export type ElectricalType = (typeof ELECTRICAL_TYPES)[number]; export type ElectricalType = (typeof ELECTRICAL_TYPES)[number];