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
478 lines
16 KiB
TypeScript
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>;
|
|
}
|
|
|