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,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>
|
||||
|
||||
Reference in New Issue
Block a user