Files
house-plan-maker/apps/client/src/components/editor/layers/WallLayer.tsx
T
alexei.dolgolyov d8a914bf2a feat: editor improvements and collapsible sidebars
Add collapse/expand toggle for the AppShell navigation sidebar and the
editor properties panel (both persisted to localStorage). Bundles other
in-progress editor work including position anchors, outlet sizing, PBR
textures, window slope/frame depth, curtain metadata, and various 2D/3D
rendering tweaks.
2026-04-08 12:27:57 +03:00

237 lines
6.6 KiB
TypeScript

import { memo, useMemo } from 'react';
import { Line, Group } from 'react-konva';
import type { Point, Wall } from '@house-plan-maker/shared';
import { polygonCentroid } from '../utils/geometry';
interface WallLayerProps {
readonly walls: readonly Wall[];
readonly roomShape: readonly Point[];
readonly zoom: number;
readonly panOffset: Point;
readonly selectedIds: ReadonlySet<string>;
readonly plinthThickness?: number;
}
/** Wall fill color. */
const WALL_FILL = '#495057';
/** Wall stroke color. */
const WALL_STROKE = '#343a40';
/** Room interior fill color. */
const ROOM_FILL = '#f8f9fa';
/** Room interior stroke. */
const ROOM_STROKE = '#dee2e6';
/** Selected wall highlight. */
const WALL_SELECTED_STROKE = '#4c6ef5';
/**
* Offset a polygon outward by a fixed distance.
* Uses the angle bisector at each vertex to push the point outward.
*/
function offsetPolygonOutward(shape: readonly Point[], thickness: number, centroid: Point): Point[] {
const n = shape.length;
if (n < 3) return [...shape];
const result: Point[] = [];
for (let i = 0; i < n; i++) {
const prev = shape[(i - 1 + n) % n];
const curr = shape[i];
const next = shape[(i + 1) % n];
// Edge vectors
const e1x = curr.x - prev.x;
const e1y = curr.y - prev.y;
const e2x = next.x - curr.x;
const e2y = next.y - curr.y;
// Outward normals for each edge (perpendicular, pointing away from centroid)
let n1x = -e1y;
let n1y = e1x;
// Normalize
const len1 = Math.sqrt(n1x * n1x + n1y * n1y) || 1;
n1x /= len1;
n1y /= len1;
let n2x = -e2y;
let n2y = e2x;
const len2 = Math.sqrt(n2x * n2x + n2y * n2y) || 1;
n2x /= len2;
n2y /= len2;
// Ensure normals point away from centroid
const midX = curr.x;
const midY = curr.y;
const toCenterX = centroid.x - midX;
const toCenterY = centroid.y - midY;
if (n1x * toCenterX + n1y * toCenterY > 0) {
n1x = -n1x;
n1y = -n1y;
}
if (n2x * toCenterX + n2y * toCenterY > 0) {
n2x = -n2x;
n2y = -n2y;
}
// Average bisector direction
let bx = n1x + n2x;
let by = n1y + n2y;
const bLen = Math.sqrt(bx * bx + by * by);
if (bLen < 0.001) {
// Parallel edges — just use one normal
bx = n1x;
by = n1y;
} else {
bx /= bLen;
by /= bLen;
// Scale by 1/cos(half-angle) to maintain thickness at the corner
const cosHalf = bx * n1x + by * n1y;
const scale = cosHalf > 0.1 ? 1 / cosHalf : 1;
bx *= Math.min(scale, 3); // cap to prevent extreme spikes
by *= Math.min(scale, 3);
}
result.push({
x: curr.x + bx * thickness,
y: curr.y + by * thickness,
});
}
return result;
}
/** Plinth color. */
const PLINTH_COLOR = '#8b7355';
export const WallLayer = memo(function WallLayer({
walls,
roomShape,
zoom,
panOffset,
selectedIds,
plinthThickness = 0.01,
}: WallLayerProps) {
// Get wall thickness (use first wall's thickness as representative)
const wallThickness = walls.length > 0 ? walls[0].thickness : 0.1;
// Convert room shape to screen coordinates
const roomShapeScreen = useMemo(() => {
if (roomShape.length < 3) return [];
return roomShape.flatMap((p) => [
p.x * zoom + panOffset.x,
p.y * zoom + panOffset.y,
]);
}, [roomShape, zoom, panOffset]);
// Compute outer wall boundary (room shape offset outward by wall thickness)
const outerWallScreen = useMemo(() => {
if (roomShape.length < 3) return [];
const centroid = polygonCentroid(roomShape);
const outer = offsetPolygonOutward(roomShape, wallThickness, centroid);
return outer.flatMap((p) => [
p.x * zoom + panOffset.x,
p.y * zoom + panOffset.y,
]);
}, [roomShape, wallThickness, zoom, panOffset]);
// Compute inner plinth boundary (room shape offset inward by plinth thickness)
const plinthScreen = useMemo(() => {
if (roomShape.length < 3 || plinthThickness <= 0) return [];
const centroid = polygonCentroid(roomShape);
// Offset inward (toward centroid) = negative outward offset
const inner = offsetPolygonOutward(roomShape, -plinthThickness, centroid);
return inner.flatMap((p) => [
p.x * zoom + panOffset.x,
p.y * zoom + panOffset.y,
]);
}, [roomShape, plinthThickness, zoom, panOffset]);
// Selected wall segments for highlighting
const selectedWallSegments = useMemo(() => {
return walls
.filter((w) => selectedIds.has(w.id))
.map((wall) => {
const points = [
wall.startX * zoom + panOffset.x,
wall.startY * zoom + panOffset.y,
wall.endX * zoom + panOffset.x,
wall.endY * zoom + panOffset.y,
];
return { wall, points };
});
}, [walls, selectedIds, zoom, panOffset]);
return (
<Group>
{/* Room interior fill */}
{roomShapeScreen.length >= 6 && (
<Line
points={roomShapeScreen}
closed
fill={ROOM_FILL}
stroke={ROOM_STROKE}
strokeWidth={1}
listening={false}
/>
)}
{/* Outer wall boundary — single continuous polygon, no corner gaps */}
{outerWallScreen.length >= 6 && (
<Group listening={false}>
{/* Outer fill */}
<Line
points={outerWallScreen}
closed
fill={WALL_FILL}
stroke={WALL_STROKE}
strokeWidth={1}
listening={false}
/>
{/* Cut out room interior by drawing room shape on top with room fill */}
<Line
points={roomShapeScreen}
closed
fill={ROOM_FILL}
stroke={WALL_STROKE}
strokeWidth={1}
listening={false}
/>
</Group>
)}
{/* Plinth strip along inside of walls */}
{plinthScreen.length >= 6 && roomShapeScreen.length >= 6 && (
<Group listening={false}>
{/* Draw room shape filled with plinth color */}
<Line
points={roomShapeScreen}
closed
fill={PLINTH_COLOR}
listening={false}
/>
{/* Cut out inner area (room minus plinth) */}
<Line
points={plinthScreen}
closed
fill={ROOM_FILL}
listening={false}
/>
</Group>
)}
{/* Selected wall highlights */}
{selectedWallSegments.map(({ wall, points }) => (
<Line
key={`sel-${wall.id}`}
points={points}
stroke={WALL_SELECTED_STROKE}
strokeWidth={wallThickness * zoom + 2}
lineCap="square"
listening={false}
/>
))}
</Group>
);
});