Files
house-plan-maker/apps/client/src/components/editor/projection/ProjectionMeasurements.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

478 lines
16 KiB
TypeScript

import type { ReactNode } from 'react';
import { Group, Rect, Line, Text } from 'react-konva';
import { useCanvasColors } from '../utils/canvasThemeColors';
import type {
ProjectedOpening,
ProjectedElectrical,
ProjectedFurniture,
} from '../utils/projectionMapping';
import { projectionToPixel } from '../utils/projectionMapping';
import { DEFAULT_OUTLET_WIDTH, DEFAULT_OUTLET_HEIGHT } from '@house-plan-maker/shared';
import { getOutletInvertCoordX, getOutletInvertCoordY } from '../symbols/electrical';
interface ProjectionMeasurementsProps {
readonly projectedOpenings: readonly ProjectedOpening[];
readonly projectedElectrical: readonly ProjectedElectrical[];
readonly projectedFurniture?: readonly ProjectedFurniture[];
readonly wallLength: number;
readonly wallHeight: number;
readonly scale: number;
readonly padding: number;
readonly outletWidth?: number;
readonly outletHeight?: number;
/** When false, the wall-level dimensions/labels are skipped (useful when the
* measurements layer is toggled off but per-item overlays should still draw). */
readonly showWallDimensions?: boolean;
}
/** Dimension line with arrows and text. */
function DimensionLine({
x1, y1, x2, y2, label, offset, horizontal, lineColor, textColor,
}: {
readonly x1: number;
readonly y1: number;
readonly x2: number;
readonly y2: number;
readonly label: string;
readonly offset: number;
readonly horizontal: boolean;
readonly lineColor: string;
readonly textColor: string;
}) {
const arrowSize = 4;
if (horizontal) {
const lineY = y1 + offset;
const midX = (x1 + x2) / 2;
return (
<Group>
{/* Extension lines */}
<Line points={[x1, y1, x1, lineY]} stroke={lineColor} strokeWidth={0.5} />
<Line points={[x2, y2, x2, lineY]} stroke={lineColor} strokeWidth={0.5} />
{/* Main line */}
<Line points={[x1, lineY, x2, lineY]} stroke={lineColor} strokeWidth={0.75} />
{/* Arrows */}
<Line
points={[x1, lineY, x1 + arrowSize, lineY - arrowSize / 2, x1 + arrowSize, lineY + arrowSize / 2]}
fill={lineColor}
closed
/>
<Line
points={[x2, lineY, x2 - arrowSize, lineY - arrowSize / 2, x2 - arrowSize, lineY + arrowSize / 2]}
fill={lineColor}
closed
/>
{/* Label */}
<Text
x={midX - 20}
y={lineY - 12}
width={40}
text={label}
align="center"
fontSize={9}
fill={textColor}
/>
</Group>
);
}
// Vertical dimension
const lineX = x1 + offset;
const midY = (y1 + y2) / 2;
return (
<Group>
<Line points={[x1, y1, lineX, y1]} stroke={lineColor} strokeWidth={0.5} />
<Line points={[x1, y2, lineX, y2]} stroke={lineColor} strokeWidth={0.5} />
<Line points={[lineX, y1, lineX, y2]} stroke={lineColor} strokeWidth={0.75} />
<Line
points={[lineX, y1, lineX - arrowSize / 2, y1 + arrowSize, lineX + arrowSize / 2, y1 + arrowSize]}
fill={lineColor}
closed
/>
<Line
points={[lineX, y2, lineX - arrowSize / 2, y2 - arrowSize, lineX + arrowSize / 2, y2 - arrowSize]}
fill={lineColor}
closed
/>
<Text
x={lineX + 3}
y={midY - 5}
text={label}
fontSize={9}
fill={textColor}
/>
</Group>
);
}
function formatM(meters: number): string {
return `${meters.toFixed(2)}m`;
}
/** Render measurement annotations on a wall projection view. */
export function ProjectionMeasurements({
projectedOpenings,
projectedElectrical,
projectedFurniture = [],
wallLength: wallLen,
wallHeight,
scale,
padding,
outletWidth = DEFAULT_OUTLET_WIDTH,
outletHeight = DEFAULT_OUTLET_HEIGHT,
showWallDimensions = true,
}: ProjectionMeasurementsProps) {
const colors = useCanvasColors();
const elements: ReactNode[] = [];
if (showWallDimensions) {
// Wall width dimension (along bottom)
const floorLeft = projectionToPixel(0, 0, wallHeight, scale, padding);
const floorRight = projectionToPixel(wallLen, 0, wallHeight, scale, padding);
elements.push(
<DimensionLine
key="wall-width"
x1={floorLeft.x}
y1={floorLeft.y}
x2={floorRight.x}
y2={floorRight.y}
label={formatM(wallLen)}
offset={18}
horizontal
lineColor={colors.dimensionLine}
textColor={colors.dimensionText}
/>,
);
// Wall height dimension (along right side)
const topRight = projectionToPixel(wallLen, wallHeight, wallHeight, scale, padding);
const bottomRight = projectionToPixel(wallLen, 0, wallHeight, scale, padding);
elements.push(
<DimensionLine
key="wall-height"
x1={topRight.x}
y1={topRight.y}
x2={bottomRight.x}
y2={bottomRight.y}
label={formatM(wallHeight)}
offset={18}
horizontal={false}
lineColor={colors.dimensionLine}
textColor={colors.dimensionText}
/>,
);
}
// Opening dimensions: sill height for windows, door height for doors
for (const po of projectedOpenings) {
const { rect, opening } = po;
const topLeft = projectionToPixel(rect.x, rect.y + rect.height, wallHeight, scale, padding);
const bottomLeft = projectionToPixel(rect.x, rect.y, wallHeight, scale, padding);
// Height annotation (vertical, left side of opening)
elements.push(
<DimensionLine
key={`opening-h-${opening.id}`}
x1={topLeft.x}
y1={topLeft.y}
x2={bottomLeft.x}
y2={bottomLeft.y}
label={formatM(rect.height)}
offset={-14}
horizontal={false}
lineColor={colors.dimensionLine}
textColor={colors.dimensionText}
/>,
);
// Sill height for windows
if (opening.type === 'WINDOW' && rect.y > 0.01) {
const floorBelow = projectionToPixel(rect.x, 0, wallHeight, scale, padding);
elements.push(
<DimensionLine
key={`sill-${opening.id}`}
x1={bottomLeft.x}
y1={bottomLeft.y}
x2={floorBelow.x}
y2={floorBelow.y}
label={formatM(rect.y)}
offset={-14}
horizontal={false}
lineColor={colors.dimensionLine}
textColor={colors.dimensionText}
/>,
);
}
// Width annotation (horizontal, above opening)
const topRight2 = projectionToPixel(rect.x + rect.width, rect.y + rect.height, wallHeight, scale, padding);
elements.push(
<DimensionLine
key={`opening-w-${opening.id}`}
x1={topLeft.x}
y1={topLeft.y}
x2={topRight2.x}
y2={topRight2.y}
label={formatM(rect.width)}
offset={-12}
horizontal
lineColor={colors.dimensionLine}
textColor={colors.dimensionText}
/>,
);
}
// Electrical item coordinate labels: (X; Y) near each item.
// For OUTLET groups with count > 1, the symbol bounding box extends to
// either side of the anchor by `count * outletWidth / 2`. Push the label
// past the right edge of the box (plus a small gap) so it doesn't overlap
// the outlet face plates. A semi-opaque background pill keeps it readable
// even when it sits over a wall stripe or other UI.
for (const pe of projectedElectrical) {
const center = projectionToPixel(
pe.position.alongWall,
pe.position.fromFloor,
wallHeight,
scale,
padding,
);
// Half-width of the visible symbol along the wall axis, in pixels.
let halfWidthPx = 8; // default for non-outlet symbols (~SYMBOL_SIZE/2 + margin)
if (pe.item.type === 'OUTLET') {
const safeCount = Math.max(1, Math.round(pe.item.count));
halfWidthPx = (safeCount * outletWidth * scale) / 2;
}
// Outlets can opt into inverted coordinate read-outs per axis — useful
// when the user measures distances from the opposite wall edge or from
// the ceiling. Display-only: the stored position is untouched.
const invertX = pe.item.type === 'OUTLET' && getOutletInvertCoordX(pe.item.metadata);
const invertY = pe.item.type === 'OUTLET' && getOutletInvertCoordY(pe.item.metadata);
const displayX = invertX ? wallLen - pe.position.alongWall : pe.position.alongWall;
const displayY = invertY ? wallHeight - pe.elevation : pe.elevation;
const coordLabel = `(${displayX.toFixed(2)}; ${displayY.toFixed(2)})`;
const labelX = center.x + halfWidthPx + 6;
const labelY = center.y - 6;
// Rough text-width estimate (monospace-ish): ~5.5px per char at fontSize 9.
const labelWidth = coordLabel.length * 5.5 + 4;
elements.push(
<Group key={`elec-coord-${pe.item.id}`}>
<Rect
x={labelX - 2}
y={labelY - 1}
width={labelWidth}
height={12}
fill={colors.coordLabelBg}
cornerRadius={2}
listening={false}
/>
<Text
x={labelX}
y={labelY}
text={coordLabel}
fontSize={9}
fill={colors.coordLabelText}
listening={false}
/>
</Group>,
);
}
// Opening horizontal position labels
for (const po of projectedOpenings) {
const { rect, opening } = po;
const openingCenter = projectionToPixel(
rect.x + rect.width / 2,
rect.y + rect.height,
wallHeight,
scale,
padding,
);
elements.push(
<Text
key={`opening-x-${opening.id}`}
x={openingCenter.x - 16}
y={openingCenter.y - 22}
text={formatM(rect.x + rect.width / 2)}
fontSize={8}
fill={colors.openingLabel}
align="center"
width={32}
/>,
);
}
// Per-furniture dimension overlay (only when toggled on).
// Coordinates are only projected onto an axis when the item is *near* that
// axis — otherwise the extension lines would sprawl across the whole view
// and add more noise than information.
const NEAR_AXIS = 0.1; // meters
for (const pf of projectedFurniture) {
if (!pf.item.showProjection) continue;
const { rect, item } = pf;
{
const isNearFloor = rect.y <= NEAR_AXIS;
const isNearLeft = rect.x <= NEAR_AXIS;
// ── Horizontal axis (along-wall) projection ──
// If the item touches the floor we extend the dimension all the way to
// the bottom ruler; otherwise we draw the width dimension just below
// the item itself.
const wLeft = projectionToPixel(rect.x, rect.y, wallHeight, scale, padding);
const wRight = projectionToPixel(rect.x + rect.width, rect.y, wallHeight, scale, padding);
if (isNearFloor) {
const wLeftFloor = projectionToPixel(rect.x, 0, wallHeight, scale, padding);
const wRightFloor = projectionToPixel(rect.x + rect.width, 0, wallHeight, scale, padding);
elements.push(
<DimensionLine
key={`furn-w-${item.id}`}
x1={wLeftFloor.x}
y1={wLeftFloor.y}
x2={wRightFloor.x}
y2={wRightFloor.y}
label={formatM(rect.width)}
offset={32}
horizontal
lineColor={colors.dimensionLine}
textColor={colors.dimensionText}
/>,
);
if (rect.x > 0.001) {
const oLeft = projectionToPixel(0, 0, wallHeight, scale, padding);
elements.push(
<DimensionLine
key={`furn-off-${item.id}`}
x1={oLeft.x}
y1={oLeft.y}
x2={wLeftFloor.x}
y2={wLeftFloor.y}
label={formatM(rect.x)}
offset={46}
horizontal
lineColor={colors.dimensionLine}
textColor={colors.dimensionText}
/>,
);
}
} else {
// Inline width dimension drawn just below the bottom edge of the item
elements.push(
<DimensionLine
key={`furn-w-${item.id}`}
x1={wLeft.x}
y1={wLeft.y}
x2={wRight.x}
y2={wRight.y}
label={formatM(rect.width)}
offset={14}
horizontal
lineColor={colors.dimensionLine}
textColor={colors.dimensionText}
/>,
);
// Inline start-offset (distance from wall start to the left edge),
// shown as a thin extension line + label so the user still sees the
// horizontal position when the item is not on the floor.
if (rect.x > 0.001) {
const oLeft = projectionToPixel(0, rect.y, wallHeight, scale, padding);
elements.push(
<DimensionLine
key={`furn-off-${item.id}`}
x1={oLeft.x}
y1={oLeft.y}
x2={wLeft.x}
y2={wLeft.y}
label={formatM(rect.x)}
offset={-6}
horizontal
lineColor={colors.dimensionLine}
textColor={colors.dimensionText}
/>,
);
}
}
// ── Vertical axis (height) projection ──
// If the item touches the left edge of the wall we extend to the left
// ruler; otherwise we draw the height dimension just to the left of
// the item itself.
const hBottom = projectionToPixel(rect.x, rect.y, wallHeight, scale, padding);
const hTop = projectionToPixel(rect.x, rect.y + rect.height, wallHeight, scale, padding);
if (isNearLeft) {
elements.push(
<DimensionLine
key={`furn-h-${item.id}`}
x1={hTop.x}
y1={hTop.y}
x2={hBottom.x}
y2={hBottom.y}
label={formatM(rect.height)}
offset={-32}
horizontal={false}
lineColor={colors.dimensionLine}
textColor={colors.dimensionText}
/>,
);
if (rect.y > 0.001) {
const eFloor = projectionToPixel(rect.x, 0, wallHeight, scale, padding);
elements.push(
<DimensionLine
key={`furn-elev-${item.id}`}
x1={hBottom.x}
y1={hBottom.y}
x2={eFloor.x}
y2={eFloor.y}
label={formatM(rect.y)}
offset={-46}
horizontal={false}
lineColor={colors.dimensionLine}
textColor={colors.dimensionText}
/>,
);
}
} else {
// Inline height dimension drawn just to the left of the item
elements.push(
<DimensionLine
key={`furn-h-${item.id}`}
x1={hTop.x}
y1={hTop.y}
x2={hBottom.x}
y2={hBottom.y}
label={formatM(rect.height)}
offset={-14}
horizontal={false}
lineColor={colors.dimensionLine}
textColor={colors.dimensionText}
/>,
);
// Inline elevation (distance from floor to the bottom of the item),
// so wall-mounted items still show their vertical position.
if (rect.y > 0.001) {
const eFloor = projectionToPixel(rect.x + rect.width, 0, wallHeight, scale, padding);
const eBottom = projectionToPixel(rect.x + rect.width, rect.y, wallHeight, scale, padding);
elements.push(
<DimensionLine
key={`furn-elev-${item.id}`}
x1={eBottom.x}
y1={eBottom.y}
x2={eFloor.x}
y2={eFloor.y}
label={formatM(rect.y)}
offset={6}
horizontal={false}
lineColor={colors.dimensionLine}
textColor={colors.dimensionText}
/>,
);
}
}
}
}
return <Group>{elements}</Group>;
}