d8a914bf2a
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.
237 lines
6.6 KiB
TypeScript
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>
|
|
);
|
|
});
|