d8a914bf2a
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.
198 lines
6.9 KiB
TypeScript
198 lines
6.9 KiB
TypeScript
import { useMemo } from 'react';
|
|
import * as THREE from 'three';
|
|
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';
|
|
|
|
// ── 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 getPaintMaterial(color: string): THREE.MeshStandardMaterial {
|
|
let mat = paintMaterialCache.get(color);
|
|
if (!mat) {
|
|
mat = new THREE.MeshStandardMaterial({ color, roughness: 0.85, side: THREE.DoubleSide });
|
|
paintMaterialCache.set(color, mat);
|
|
}
|
|
return mat;
|
|
}
|
|
|
|
const wallSelectedMaterial = new THREE.MeshStandardMaterial({
|
|
color: WALL_SELECTED_COLOR,
|
|
roughness: 0.7,
|
|
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,
|
|
material,
|
|
tileMeters,
|
|
isSelected,
|
|
onSelect,
|
|
outwardOffset,
|
|
}: {
|
|
readonly wall: Wall;
|
|
readonly segment: WallSegment;
|
|
readonly thickness: number;
|
|
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<[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 (
|
|
<mesh
|
|
position={center}
|
|
rotation={[0, rotY, 0]}
|
|
castShadow
|
|
receiveShadow
|
|
material={isSelected ? wallSelectedMaterial : material}
|
|
geometry={geometry}
|
|
onClick={onSelect ? (e) => { e.stopPropagation(); onSelect(wall.id); } : undefined}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<group>
|
|
{segments.map((segment, i) => (
|
|
<WallSegmentMesh
|
|
key={`${wall.id}-seg-${i}`}
|
|
wall={wall}
|
|
segment={segment}
|
|
thickness={wall.thickness}
|
|
material={material}
|
|
tileMeters={tileMeters}
|
|
isSelected={isSelected}
|
|
onSelect={onSelect}
|
|
outwardOffset={outwardOffset}
|
|
/>
|
|
))}
|
|
</group>
|
|
);
|
|
}
|