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,6 +1,7 @@
import { Group, Circle, Line, Rect, Text } from 'react-konva';
import type { ProjectedElectrical } from '../utils/projectionMapping';
import { projectionToPixel } from '../utils/projectionMapping';
import { DEFAULT_OUTLET_WIDTH, DEFAULT_OUTLET_HEIGHT } from '@house-plan-maker/shared';
interface ProjectionElectricalProps {
readonly projected: ProjectedElectrical;
@@ -11,6 +12,10 @@ interface ProjectionElectricalProps {
readonly isDragging?: boolean;
readonly dragFromFloor?: number;
readonly dragAlongWall?: number;
/** Physical width of a single outlet face plate (meters). */
readonly outletWidth?: number;
/** Physical height of a single outlet face plate (meters). */
readonly outletHeight?: number;
readonly onClick: () => void;
readonly onDragStart?: (itemId: string, evt: MouseEvent) => void;
}
@@ -27,6 +32,8 @@ export function ProjectionElectrical({
isDragging = false,
dragFromFloor,
dragAlongWall,
outletWidth = DEFAULT_OUTLET_WIDTH,
outletHeight = DEFAULT_OUTLET_HEIGHT,
onClick,
onDragStart,
}: ProjectionElectricalProps) {
@@ -70,29 +77,84 @@ export function ProjectionElectrical({
fill="transparent"
/>
)}
{item.type === 'OUTLET' && (
<>
{/* IEC outlet symbol: circle with two horizontal lines */}
<Circle
x={center.x}
y={center.y}
radius={half}
fill={fillColor}
stroke={strokeColor}
strokeWidth={1.5}
/>
<Line
points={[center.x - 3, center.y - 2, center.x + 3, center.y - 2]}
stroke={strokeColor}
strokeWidth={1.5}
/>
<Line
points={[center.x - 3, center.y + 2, center.x + 3, center.y + 2]}
stroke={strokeColor}
strokeWidth={1.5}
/>
</>
)}
{item.type === 'OUTLET' && (() => {
const safeCount = Math.max(1, Math.round(item.count));
// Convert physical outlet dims to projection-pixel dims.
const wPx = outletWidth * scale;
const hPx = outletHeight * scale;
// Anchor offset to bounding-box center, in projection pixels.
// Horizontal axis = along-wall (positive right), vertical axis = up the wall.
// In screen coords +y is down, so vertical='top' anchor means center is BELOW (positive y).
//
// When the projection axis is flipped (the canonical direction
// runs opposite to the wall's stored start→end), we mirror the
// horizontal anchor so "left" still refers to the same physical
// side of the wall in both 3D and projection views. Without this
// an outlet anchored "left" on a flipped wall would appear on
// opposite sides of the two views.
const anchor = item.positionAnchor;
const mirroredHorizontal = projected.axisFlipped
? anchor.horizontal === 'left'
? 'right'
: anchor.horizontal === 'right'
? 'left'
: 'middle'
: anchor.horizontal;
const totalW = safeCount * wPx;
const offX =
mirroredHorizontal === 'left' ? totalW / 2 : mirroredHorizontal === 'right' ? -totalW / 2 : 0;
const offY =
anchor.vertical === 'top' ? hPx / 2 : anchor.vertical === 'bottom' ? -hPx / 2 : 0;
const cx = center.x + offX;
const cy = center.y + offY;
const left = cx - totalW / 2;
const top = cy - hPx / 2;
const cellMin = Math.min(wPx, hPx);
const faceR = cellMin * 0.32;
const prongL = cellMin * 0.18;
const prongG = cellMin * 0.12;
return (
<>
{Array.from({ length: safeCount }).map((_, i) => {
const cellLeft = left + i * wPx;
const cellCx = cellLeft + wPx / 2;
const cellCy = top + hPx / 2;
return (
<Group key={i}>
<Rect
x={cellLeft}
y={top}
width={wPx}
height={hPx}
cornerRadius={Math.max(1, cellMin * 0.12)}
fill={fillColor}
stroke={strokeColor}
strokeWidth={1.25}
/>
<Circle
x={cellCx}
y={cellCy}
radius={faceR}
stroke={strokeColor}
strokeWidth={1.25}
fill="transparent"
/>
<Line
points={[cellCx - prongG, cellCy - prongL, cellCx - prongG, cellCy + prongL]}
stroke={strokeColor}
strokeWidth={1.25}
/>
<Line
points={[cellCx + prongG, cellCy - prongL, cellCx + prongG, cellCy + prongL]}
stroke={strokeColor}
strokeWidth={1.25}
/>
</Group>
);
})}
</>
);
})()}
{item.type === 'SWITCH' && (
<>
{/* IEC switch symbol: circle with diagonal line */}
@@ -157,12 +219,21 @@ export function ProjectionElectrical({
strokeWidth={1.5}
/>
)}
{/* Type label below symbol */}
{/* Type label below symbol — uses the user's custom label if set,
otherwise falls back to the short type code. */}
<Text
x={center.x - 20}
x={center.x - 30}
y={center.y + half + 2}
width={40}
text={item.type === 'OUTLET' ? 'OUT' : item.type === 'SWITCH' ? 'SW' : 'WL'}
width={60}
text={
item.label && item.label.trim().length > 0
? item.label
: item.type === 'OUTLET'
? 'OUT'
: item.type === 'SWITCH'
? 'SW'
: 'WL'
}
align="center"
fontSize={8}
fill="#94a3b8"