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; 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(); 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 ( { 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 ( {segments.map((segment, i) => ( ))} ); }