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:
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user