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.
This commit is contained in:
2026-04-08 12:27:57 +03:00
parent aa8a874348
commit d8a914bf2a
116 changed files with 7324 additions and 1114 deletions
@@ -1,15 +1,26 @@
import type { ReactNode } from 'react';
import { Group, Line, Text } from 'react-konva';
import type { ProjectedOpening, ProjectedElectrical } from '../utils/projectionMapping';
import { Group, Rect, Line, Text } from 'react-konva';
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';
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. */
@@ -98,44 +109,50 @@ function formatM(meters: number): string {
export function ProjectionMeasurements({
projectedOpenings,
projectedElectrical,
projectedFurniture = [],
wallLength: wallLen,
wallHeight,
scale,
padding,
outletWidth = DEFAULT_OUTLET_WIDTH,
outletHeight = DEFAULT_OUTLET_HEIGHT,
showWallDimensions = true,
}: ProjectionMeasurementsProps) {
const elements: ReactNode[] = [];
// 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
/>,
);
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
/>,
);
// 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}
/>,
);
// 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}
/>,
);
}
// Opening dimensions: sill height for windows, door height for doors
for (const po of projectedOpenings) {
@@ -190,7 +207,12 @@ export function ProjectionMeasurements({
);
}
// Electrical item coordinate labels: (X; Y) near each item
// 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,
@@ -200,16 +222,38 @@ export function ProjectionMeasurements({
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;
}
const coordLabel = `(${pe.position.alongWall.toFixed(2)}; ${pe.elevation.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(
<Text
key={`elec-coord-${pe.item.id}`}
x={center.x + 10}
y={center.y - 4}
text={coordLabel}
fontSize={9}
fill="#64748b"
/>,
<Group key={`elec-coord-${pe.item.id}`}>
<Rect
x={labelX - 2}
y={labelY - 1}
width={labelWidth}
height={12}
fill="rgba(255, 255, 255, 0.85)"
cornerRadius={2}
listening={false}
/>
<Text
x={labelX}
y={labelY}
text={coordLabel}
fontSize={9}
fill="#475569"
listening={false}
/>
</Group>,
);
}
@@ -237,5 +281,159 @@ export function ProjectionMeasurements({
);
}
// 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
/>,
);
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
/>,
);
}
} 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
/>,
);
// 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
/>,
);
}
}
// ── 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}
/>,
);
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}
/>,
);
}
} 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}
/>,
);
// 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}
/>,
);
}
}
}
}
return <Group>{elements}</Group>;
}