Files
house-plan-maker/apps/client/src/components/editor/projection/ProjectionElectrical.tsx
T
alexei.dolgolyov 521ea5e85b feat: add outlet direction (horizontal/vertical), wall light styles, floor textures, and stretch ceiling
- Add configurable outlet direction (horizontal/vertical) stored in metadata
- Add wall light style variants (classic, pendant-globe, sconce-up, sconce-down)
- Add PBR floor textures including natural oak
- Add stretch ceiling offset support with DB migration
- Add furniture surface texture selection
- Add canvas theme colors utility for dark mode support
- Update projection views with improved rendering
- Add EN and RU translations for all new properties
2026-04-12 20:52:49 +03:00

327 lines
12 KiB
TypeScript

import { Group, Circle, Line, Rect, Text, Arc } from 'react-konva';
import type { ProjectedElectrical } from '../utils/projectionMapping';
import { projectionToPixel } from '../utils/projectionMapping';
import { DEFAULT_OUTLET_WIDTH, DEFAULT_OUTLET_HEIGHT } from '@house-plan-maker/shared';
import { getWallLightStyle, getOutletDirection } from '../symbols/electrical';
import { useCanvasColors } from '../utils/canvasThemeColors';
interface ProjectionElectricalProps {
readonly projected: ProjectedElectrical;
readonly wallHeight: number;
readonly scale: number;
readonly padding: number;
readonly isSelected: boolean;
readonly isDragging?: boolean;
readonly dragFromFloor?: number;
readonly dragAlongWall?: number;
/** Physical width of a single outlet face plate (meters). */
readonly outletWidth?: number;
/** Physical height of a single outlet face plate (meters). */
readonly outletHeight?: number;
readonly onClick: () => void;
readonly onDragStart?: (itemId: string, evt: MouseEvent) => void;
}
const SYMBOL_SIZE = 12;
/** Render a wall-mounted electrical item in wall elevation view. */
export function ProjectionElectrical({
projected,
wallHeight,
scale,
padding,
isSelected,
isDragging = false,
dragFromFloor,
dragAlongWall,
outletWidth = DEFAULT_OUTLET_WIDTH,
outletHeight = DEFAULT_OUTLET_HEIGHT,
onClick,
onDragStart,
}: ProjectionElectricalProps) {
const colors = useCanvasColors();
const { position, item } = projected;
const displayFromFloor = isDragging && dragFromFloor != null ? dragFromFloor : position.fromFloor;
const displayAlongWall = isDragging && dragAlongWall != null ? dragAlongWall : position.alongWall;
const center = projectionToPixel(
displayAlongWall,
displayFromFloor,
wallHeight,
scale,
padding,
);
const strokeColor = isSelected ? colors.selectedStroke : colors.electricalStroke;
const fillColor = isSelected ? colors.selectedFill : colors.electricalFill;
const half = SYMBOL_SIZE / 2;
return (
<Group
onClick={onClick}
onMouseDown={(e) => {
if (onDragStart && e.evt.button === 0) {
onDragStart(item.id, e.evt);
}
}}
style={{ cursor: onDragStart ? 'grab' : 'default' }}
>
{/* Drag ghost outline */}
{isDragging && (
<Rect
x={center.x - half - 2}
y={center.y - half - 2}
width={SYMBOL_SIZE + 4}
height={SYMBOL_SIZE + 4}
stroke={colors.selectedStroke}
strokeWidth={1}
dash={[3, 3]}
fill="transparent"
/>
)}
{item.type === 'OUTLET' && (() => {
const safeCount = Math.max(1, Math.round(item.count));
const direction = getOutletDirection(item.metadata);
const isVertical = direction === 'vertical';
// Convert physical outlet dims to projection-pixel dims.
const wPx = outletWidth * scale;
const hPx = outletHeight * scale;
// Anchor offset to bounding-box center, in projection pixels.
// Horizontal axis = along-wall (positive right), vertical axis = up the wall.
// In screen coords +y is down, so vertical='top' anchor means center is BELOW (positive y).
//
// When the projection axis is flipped (the canonical direction
// runs opposite to the wall's stored start→end), we mirror the
// horizontal anchor so "left" still refers to the same physical
// side of the wall in both 3D and projection views. Without this
// an outlet anchored "left" on a flipped wall would appear on
// opposite sides of the two views.
const anchor = item.positionAnchor;
const mirroredHorizontal = projected.axisFlipped
? anchor.horizontal === 'left'
? 'right'
: anchor.horizontal === 'right'
? 'left'
: 'middle'
: anchor.horizontal;
// Bounding box of the full outlet group depends on layout direction.
const totalW = isVertical ? wPx : safeCount * wPx;
const totalH = isVertical ? safeCount * hPx : hPx;
const offX =
mirroredHorizontal === 'left' ? totalW / 2 : mirroredHorizontal === 'right' ? -totalW / 2 : 0;
const offY =
anchor.vertical === 'top' ? totalH / 2 : anchor.vertical === 'bottom' ? -totalH / 2 : 0;
const cx = center.x + offX;
const cy = center.y + offY;
const groupLeft = cx - totalW / 2;
const groupTop = cy - totalH / 2;
const cellMin = Math.min(wPx, hPx);
const faceR = cellMin * 0.32;
const prongL = cellMin * 0.18;
const prongG = cellMin * 0.12;
return (
<>
{Array.from({ length: safeCount }).map((_, i) => {
const cellLeft = isVertical ? groupLeft : groupLeft + i * wPx;
const cellTop = isVertical ? groupTop + i * hPx : groupTop;
const cellCx = cellLeft + wPx / 2;
const cellCy = cellTop + hPx / 2;
return (
<Group key={i}>
<Rect
x={cellLeft}
y={cellTop}
width={wPx}
height={hPx}
cornerRadius={Math.max(1, cellMin * 0.12)}
fill={fillColor}
stroke={strokeColor}
strokeWidth={1.25}
/>
<Circle
x={cellCx}
y={cellCy}
radius={faceR}
stroke={strokeColor}
strokeWidth={1.25}
fill="transparent"
/>
<Line
points={[cellCx - prongG, cellCy - prongL, cellCx - prongG, cellCy + prongL]}
stroke={strokeColor}
strokeWidth={1.25}
/>
<Line
points={[cellCx + prongG, cellCy - prongL, cellCx + prongG, cellCy + prongL]}
stroke={strokeColor}
strokeWidth={1.25}
/>
</Group>
);
})}
</>
);
})()}
{item.type === 'SWITCH' && (
<>
{/* IEC switch symbol: circle with diagonal line */}
<Circle
x={center.x}
y={center.y}
radius={half}
fill={fillColor}
stroke={strokeColor}
strokeWidth={1.5}
/>
<Line
points={[
center.x - half + 2,
center.y + half - 2,
center.x + half - 2,
center.y - half + 2,
]}
stroke={strokeColor}
strokeWidth={1.5}
/>
</>
)}
{item.type === 'LIGHT_WALL' && (() => {
const wallLightStyle = getWallLightStyle(item.metadata);
if (wallLightStyle === 'pendant-globe') {
// Pendant globe: gooseneck line + hanging cord + circle globe
const armTop = center.y - half * 1.8;
const cordBottom = center.y + half * 0.6;
return (
<>
{/* Wall mount dot */}
<Circle x={center.x} y={center.y} radius={3} fill="#b8860b" stroke={strokeColor} strokeWidth={1} />
{/* Gooseneck arm going up */}
<Line points={[center.x, center.y, center.x, armTop]} stroke="#b8860b" strokeWidth={1.5} />
{/* Arm curving outward */}
<Line points={[center.x, armTop, center.x + half * 0.8, armTop]} stroke="#b8860b" strokeWidth={1.5} />
{/* Cord hanging down */}
<Line points={[center.x + half * 0.8, armTop, center.x + half * 0.8, cordBottom]} stroke="#d4c090" strokeWidth={1} />
{/* Glass globe */}
<Circle
x={center.x + half * 0.8}
y={cordBottom + half * 0.5}
radius={half * 0.7}
fill="rgba(254, 249, 195, 0.4)"
stroke="#eab308"
strokeWidth={1}
/>
</>
);
}
if (wallLightStyle === 'sconce-up') {
// Sconce up: trapezoid pointing up
return (
<>
<Line
points={[
center.x - half * 0.8, center.y + half * 0.5,
center.x - half * 0.4, center.y - half,
center.x + half * 0.4, center.y - half,
center.x + half * 0.8, center.y + half * 0.5,
]}
closed
fill="#fef9c3"
stroke={strokeColor}
strokeWidth={1.5}
/>
{/* Light rays upward */}
<Line points={[center.x, center.y - half, center.x, center.y - half * 1.8]} stroke="#eab308" strokeWidth={1} />
<Line points={[center.x - half * 0.3, center.y - half, center.x - half * 0.5, center.y - half * 1.6]} stroke="#eab308" strokeWidth={1} />
<Line points={[center.x + half * 0.3, center.y - half, center.x + half * 0.5, center.y - half * 1.6]} stroke="#eab308" strokeWidth={1} />
</>
);
}
if (wallLightStyle === 'sconce-down') {
// Sconce down: trapezoid pointing down
return (
<>
<Line
points={[
center.x - half * 0.8, center.y - half * 0.5,
center.x - half * 0.4, center.y + half,
center.x + half * 0.4, center.y + half,
center.x + half * 0.8, center.y - half * 0.5,
]}
closed
fill="#fef9c3"
stroke={strokeColor}
strokeWidth={1.5}
/>
{/* Light rays downward */}
<Line points={[center.x, center.y + half, center.x, center.y + half * 1.8]} stroke="#eab308" strokeWidth={1} />
<Line points={[center.x - half * 0.3, center.y + half, center.x - half * 0.5, center.y + half * 1.6]} stroke="#eab308" strokeWidth={1} />
<Line points={[center.x + half * 0.3, center.y + half, center.x + half * 0.5, center.y + half * 1.6]} stroke="#eab308" strokeWidth={1} />
</>
);
}
// Classic: original circle with light rays
return (
<>
<Circle
x={center.x}
y={center.y}
radius={half}
fill="#fef9c3"
stroke={strokeColor}
strokeWidth={1.5}
/>
<Line
points={[
center.x - half, center.y,
center.x - half * 1.3, center.y + half * 0.8,
]}
stroke="#eab308"
strokeWidth={1}
/>
<Line
points={[
center.x + half, center.y,
center.x + half * 1.3, center.y + half * 0.8,
]}
stroke="#eab308"
strokeWidth={1}
/>
</>
);
})()}
{/* Fallback for other wall-mounted types */}
{item.type !== 'OUTLET' && item.type !== 'SWITCH' && item.type !== 'LIGHT_WALL' && (
<Rect
x={center.x - half}
y={center.y - half}
width={SYMBOL_SIZE}
height={SYMBOL_SIZE}
fill={fillColor}
stroke={strokeColor}
strokeWidth={1.5}
/>
)}
{/* Type label below symbol — uses the user's custom label if set,
otherwise falls back to the short type code. */}
<Text
x={center.x - 30}
y={center.y + half + 2}
width={60}
text={
item.label && item.label.trim().length > 0
? item.label
: item.type === 'OUTLET'
? 'OUT'
: item.type === 'SWITCH'
? 'SW'
: 'WL'
}
align="center"
fontSize={8}
fill={colors.electricalLabel}
/>
</Group>
);
}