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
This commit is contained in:
2026-04-12 20:52:49 +03:00
parent d8a914bf2a
commit 521ea5e85b
34 changed files with 1278 additions and 162 deletions
@@ -1,7 +1,9 @@
import { Group, Circle, Line, Rect, Text } from 'react-konva';
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;
@@ -37,6 +39,7 @@ export function ProjectionElectrical({
onClick,
onDragStart,
}: ProjectionElectricalProps) {
const colors = useCanvasColors();
const { position, item } = projected;
const displayFromFloor = isDragging && dragFromFloor != null ? dragFromFloor : position.fromFloor;
@@ -50,8 +53,8 @@ export function ProjectionElectrical({
padding,
);
const strokeColor = isSelected ? '#2563eb' : '#64748b';
const fillColor = isSelected ? '#dbeafe' : '#f1f5f9';
const strokeColor = isSelected ? colors.selectedStroke : colors.electricalStroke;
const fillColor = isSelected ? colors.selectedFill : colors.electricalFill;
const half = SYMBOL_SIZE / 2;
return (
@@ -71,7 +74,7 @@ export function ProjectionElectrical({
y={center.y - half - 2}
width={SYMBOL_SIZE + 4}
height={SYMBOL_SIZE + 4}
stroke="#2563eb"
stroke={colors.selectedStroke}
strokeWidth={1}
dash={[3, 3]}
fill="transparent"
@@ -79,6 +82,8 @@ export function ProjectionElectrical({
)}
{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;
@@ -100,15 +105,17 @@ export function ProjectionElectrical({
? 'left'
: 'middle'
: anchor.horizontal;
const totalW = safeCount * wPx;
// 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' ? hPx / 2 : anchor.vertical === 'bottom' ? -hPx / 2 : 0;
anchor.vertical === 'top' ? totalH / 2 : anchor.vertical === 'bottom' ? -totalH / 2 : 0;
const cx = center.x + offX;
const cy = center.y + offY;
const left = cx - totalW / 2;
const top = cy - hPx / 2;
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;
@@ -116,14 +123,15 @@ export function ProjectionElectrical({
return (
<>
{Array.from({ length: safeCount }).map((_, i) => {
const cellLeft = left + i * wPx;
const cellLeft = isVertical ? groupLeft : groupLeft + i * wPx;
const cellTop = isVertical ? groupTop + i * hPx : groupTop;
const cellCx = cellLeft + wPx / 2;
const cellCy = top + hPx / 2;
const cellCy = cellTop + hPx / 2;
return (
<Group key={i}>
<Rect
x={cellLeft}
y={top}
y={cellTop}
width={wPx}
height={hPx}
cornerRadius={Math.max(1, cellMin * 0.12)}
@@ -178,35 +186,110 @@ export function ProjectionElectrical({
/>
</>
)}
{item.type === 'LIGHT_WALL' && (
<>
{/* Wall light: semicircle shape */}
<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}
/>
</>
)}
{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
@@ -236,7 +319,7 @@ export function ProjectionElectrical({
}
align="center"
fontSize={8}
fill="#94a3b8"
fill={colors.electricalLabel}
/>
</Group>
);