521ea5e85b
- 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
327 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|