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,33 +1,51 @@
import { useMemo } from 'react';
import * as THREE from 'three';
import type { Wall, WallOpening } from '@house-plan-maker/shared';
import type { Point, Wall, WallFinish, WallOpening } from '@house-plan-maker/shared';
import {
splitWallAroundOpenings,
wallRotationY,
wallSegmentCenter3D,
wallNormal,
type WallSegment,
} from './utils/wallGeometry';
import { getWallPbr } from './utils/pbrTextures';
interface WallMeshProps {
readonly wall: Wall;
readonly openings: readonly WallOpening[];
readonly wallHeight: number;
readonly wallColor?: string;
readonly wallFinish?: WallFinish;
readonly selectedIds: ReadonlySet<string>;
readonly onSelect?: (id: string) => void;
/**
* Centroid of the room polygon (in 2D editor coords). The wall's
* `startX/Y..endX/Y` line represents the **inner** edge of the wall (this
* matches how the 2D WallLayer renders walls — it draws an outer boundary
* offset outward by `thickness`). In 3D, however, a box geometry is
* centered on the line by default, so the wall would bleed `thickness/2`
* into the room and collide with furniture sitting against it. We push
* each wall segment outward (away from the centroid) by `thickness/2` so
* the inner face stays on the wall line.
*/
readonly roomCentroid?: Point;
}
const DEFAULT_WALL_COLOR = '#f0ebe3';
const WALL_SELECTED_COLOR = '#b8d4e3';
// ── Wall material cache ──
const wallMaterialCache = new Map<string, THREE.MeshStandardMaterial>();
// ── PAINT material cache (one per color) ──
//
// PAINT is the only finish that uses `wallColor`; the textured finishes
// (PLASTER/BRICK/CONCRETE/WOOD_PANEL/WALLPAPER) ignore color and apply a
// shared PBR material loaded lazily from getWallPbr().
const paintMaterialCache = new Map<string, THREE.MeshStandardMaterial>();
function getWallMaterial(color: string): THREE.MeshStandardMaterial {
let mat = wallMaterialCache.get(color);
function getPaintMaterial(color: string): THREE.MeshStandardMaterial {
let mat = paintMaterialCache.get(color);
if (!mat) {
mat = new THREE.MeshStandardMaterial({ color, roughness: 0.7, side: THREE.DoubleSide });
wallMaterialCache.set(color, mat);
mat = new THREE.MeshStandardMaterial({ color, roughness: 0.85, side: THREE.DoubleSide });
paintMaterialCache.set(color, mat);
}
return mat;
}
@@ -38,31 +56,71 @@ const wallSelectedMaterial = new THREE.MeshStandardMaterial({
side: THREE.DoubleSide,
});
/**
* Build a BoxGeometry for a wall segment and rescale its UVs so a textured
* wall finish tiles every `tileMeters` of physical surface instead of
* stretching one tile across the whole segment. The default BoxGeometry has
* UVs in the 0..1 range per face — multiplying by `(width/tile, height/tile)`
* gives the desired tile density and `wrapS/wrapT = RepeatWrapping` on the
* texture handles the modulo. The UV scale is baked into the geometry so a
* single shared material instance can serve walls of any size.
*/
function buildSegmentGeometry(
width: number,
height: number,
thickness: number,
tileMeters: number | null,
): THREE.BoxGeometry {
const geometry = new THREE.BoxGeometry(width, height, thickness);
if (tileMeters != null) {
const u = Math.max(1, width / tileMeters);
const v = Math.max(1, height / tileMeters);
const uv = geometry.attributes.uv;
if (uv) {
for (let i = 0; i < uv.count; i++) {
uv.setXY(i, uv.getX(i) * u, uv.getY(i) * v);
}
uv.needsUpdate = true;
}
}
return geometry;
}
function WallSegmentMesh({
wall,
segment,
thickness,
wallColor,
material,
tileMeters,
isSelected,
onSelect,
outwardOffset,
}: {
readonly wall: Wall;
readonly segment: WallSegment;
readonly thickness: number;
readonly wallColor: string;
readonly material: THREE.MeshStandardMaterial;
readonly tileMeters: number | null;
readonly isSelected: boolean;
readonly onSelect?: (id: string) => void;
/** [dx, 0, dz] offset to push the box from "centered on wall line" to "outer side of wall line". */
readonly outwardOffset: readonly [number, number, number];
}) {
const segmentWidth = segment.endAlongWall - segment.startAlongWall;
const segmentHeight = segment.topY - segment.bottomY;
const center = useMemo(
() => wallSegmentCenter3D(wall, segment),
[wall, segment],
);
const center = useMemo<[number, number, number]>(() => {
const [x, y, z] = wallSegmentCenter3D(wall, segment);
return [x + outwardOffset[0], y, z + outwardOffset[2]];
}, [wall, segment, outwardOffset]);
const rotY = useMemo(() => wallRotationY(wall), [wall]);
const geometry = useMemo(
() => buildSegmentGeometry(segmentWidth, segmentHeight, thickness, tileMeters),
[segmentWidth, segmentHeight, thickness, tileMeters],
);
if (segmentWidth <= 0 || segmentHeight <= 0) return null;
return (
@@ -71,20 +129,52 @@ function WallSegmentMesh({
rotation={[0, rotY, 0]}
castShadow
receiveShadow
material={isSelected ? wallSelectedMaterial : getWallMaterial(wallColor)}
material={isSelected ? wallSelectedMaterial : material}
geometry={geometry}
onClick={onSelect ? (e) => { e.stopPropagation(); onSelect(wall.id); } : undefined}
>
<boxGeometry args={[segmentWidth, segmentHeight, thickness]} />
</mesh>
/>
);
}
export function WallMesh({ wall, openings, wallHeight, wallColor = DEFAULT_WALL_COLOR, selectedIds, onSelect }: WallMeshProps) {
export function WallMesh({ wall, openings, wallHeight, wallColor = DEFAULT_WALL_COLOR, wallFinish = 'PAINT', selectedIds, onSelect, roomCentroid }: WallMeshProps) {
const segments = useMemo(
() => splitWallAroundOpenings(wall, openings, wallHeight),
[wall, openings, wallHeight],
);
// Resolve the finish to a material + tile size. PAINT uses the per-color
// cache (no UV rescale needed); textured finishes load a shared PBR set.
const { material, tileMeters } = useMemo(() => {
if (wallFinish === 'PAINT') {
return { material: getPaintMaterial(wallColor), tileMeters: null as number | null };
}
const pbr = getWallPbr(wallFinish);
return { material: pbr.material, tileMeters: pbr.tileMeters };
}, [wallFinish, wallColor]);
// Compute the outward (away-from-room-centroid) offset along the wall's
// perpendicular normal. Without this the wall box straddles the wall line
// and the inner half-thickness collides with furniture placed against the
// wall. The 2D editor draws walls extending entirely outward from the
// shape — this matches that semantic.
const outwardOffset = useMemo<[number, number, number]>(() => {
if (!roomCentroid) return [0, 0, 0];
const { nx, ny } = wallNormal(wall);
if (nx === 0 && ny === 0) return [0, 0, 0];
// Wall midpoint, used to decide which side of the wall is "outside".
const midX = (wall.startX + wall.endX) / 2;
const midY = (wall.startY + wall.endY) / 2;
// Vector from centroid to wall midpoint = outward direction (pre-normal-projection).
const outX = midX - roomCentroid.x;
const outY = midY - roomCentroid.y;
// Sign of normal-along-outward tells us whether to flip.
const dot = nx * outX + ny * outY;
const sign = dot >= 0 ? 1 : -1;
const half = wall.thickness / 2;
// 2D y-axis maps to 3D z-axis.
return [sign * nx * half, 0, sign * ny * half];
}, [wall, roomCentroid]);
const isSelected = selectedIds.has(wall.id);
return (
@@ -95,9 +185,11 @@ export function WallMesh({ wall, openings, wallHeight, wallColor = DEFAULT_WALL_
wall={wall}
segment={segment}
thickness={wall.thickness}
wallColor={wallColor}
material={material}
tileMeters={tileMeters}
isSelected={isSelected}
onSelect={onSelect}
outwardOffset={outwardOffset}
/>
))}
</group>