From 65a7fcb74b0e22e40a688c6e6ba7772a0293f7dd Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 18 Jun 2026 08:11:57 -0400 Subject: [PATCH] feat(paint-slots): world-space planar UVs for walls (seamless tiling) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walls used THREE's ExtrudeGeometry UVs, which restart at each wall's local start/end — so finishes seamed at wall joins, showed a mid-face axis-switch stripe, and didn't line up with the roof gable. Re-project the render mesh's UVs in WORLD space (1 unit = 1 m), matching roof-system's pushRoofUv exactly: vertical faces U = ±worldX/Z, V = 1 - worldY. De-indexes so each triangle uses its own face normal (no edge seams). Applied only to the render mesh; collision and floorplan geometry keep the original UVs. Now walls tile continuously across segments and meet the gable seamlessly. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../viewer/src/systems/wall/wall-system.tsx | 77 ++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 840075a0..8ebe59be 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -572,7 +572,17 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { return { ...effective, position: live.position } }) - const newGeo = generateExtrudedWall(node, childrenNodes, miterData, slabElevation) + const builtGeo = generateExtrudedWall(node, childrenNodes, miterData, slabElevation) + const wallAngle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0]) + // World transform the render mesh will apply (position + Y-rotation below). + // Reproduce it here so the UVs can be projected in WORLD space — see + // `applyWorldPlanarWallUVs`. + const wallWorldMatrix = new THREE.Matrix4().compose( + new THREE.Vector3(node.start[0], slabElevation, node.start[1]), + new THREE.Quaternion().setFromAxisAngle(WALL_UV_Y_AXIS, -wallAngle), + WALL_UV_UNIT_SCALE, + ) + const newGeo = applyWorldPlanarWallUVs(builtGeo, wallWorldMatrix) mesh.geometry.dispose() mesh.geometry = newGeo @@ -589,6 +599,71 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { mesh.rotation.y = -angle } +const WALL_UV_Y_AXIS = new THREE.Vector3(0, 1, 0) +const WALL_UV_UNIT_SCALE = new THREE.Vector3(1, 1, 1) + +/** + * Re-project a wall's UVs in WORLD space (1 UV unit = 1 m) so the finish tiles + * continuously across adjacent walls and lines up with the roof gable above — + * instead of THREE's `ExtrudeGeometry` UVs, which restart at each wall's own + * start/end. Matches `roof-system`'s `pushRoofUv` projection exactly: vertical + * faces use `U = ±worldX/Z` (the axis across the face normal) and `V = 1 - + * worldY`; the thin top/bottom caps use `(worldX, worldZ)`. De-indexes first so + * every triangle projects by its own face normal (no shared-vertex seams at + * edges). Applied only to the render mesh; collision/floorplan geometry is + * untouched. + */ +function applyWorldPlanarWallUVs( + geometry: THREE.BufferGeometry, + worldMatrix: THREE.Matrix4, +): THREE.BufferGeometry { + const target = geometry.index ? geometry.toNonIndexed() : geometry + if (target !== geometry) geometry.dispose() + + const position = target.getAttribute('position') + if (!position || position.count === 0) return target + + const a = new THREE.Vector3() + const b = new THREE.Vector3() + const c = new THREE.Vector3() + const normal = new THREE.Vector3() + const edgeAB = new THREE.Vector3() + const edgeAC = new THREE.Vector3() + const uvs = new Float32Array(position.count * 2) + + for (let i = 0; i < position.count; i += 3) { + a.fromBufferAttribute(position, i).applyMatrix4(worldMatrix) + b.fromBufferAttribute(position, i + 1).applyMatrix4(worldMatrix) + c.fromBufferAttribute(position, i + 2).applyMatrix4(worldMatrix) + edgeAB.subVectors(b, a) + edgeAC.subVectors(c, a) + normal.crossVectors(edgeAB, edgeAC).normalize() + + const absX = Math.abs(normal.x) + const absY = Math.abs(normal.y) + const absZ = Math.abs(normal.z) + + for (let k = 0; k < 3; k += 1) { + const p = k === 0 ? a : k === 1 ? b : c + let u: number + let v: number + if (absY >= absX && absY >= absZ) { + u = p.x + v = p.z + } else { + v = 1 - p.y + u = absX >= absZ ? (normal.x >= 0 ? p.z : -p.z) : normal.z >= 0 ? p.x : -p.x + } + uvs[(i + k) * 2] = u + uvs[(i + k) * 2 + 1] = v + } + } + + target.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)) + target.setAttribute('uv2', new THREE.Float32BufferAttribute(uvs.slice(), 2)) + return target +} + /** * Generates extruded wall geometry with mitering and cutouts *