From 54dd5d0541479ceb4d8d4bd6afff4955d5461500 Mon Sep 17 00:00:00 2001 From: wass08 Date: Mon, 26 Jan 2026 19:52:53 +0900 Subject: [PATCH] fix wall mitering (three-mesh-csg) --- .../components/tools/item/item-tool.tsx | 103 ++++++------ bun.lock | 8 +- package.json | 2 + .../core/src/systems/wall/wall-system.tsx | 150 +++++++++++------- 4 files changed, 158 insertions(+), 105 deletions(-) diff --git a/apps/editor/components/tools/item/item-tool.tsx b/apps/editor/components/tools/item/item-tool.tsx index c8eb93af..7b19ee1c 100644 --- a/apps/editor/components/tools/item/item-tool.tsx +++ b/apps/editor/components/tools/item/item-tool.tsx @@ -41,15 +41,37 @@ const stripTransient = (meta: any) => { // ============================================================================ /** - * Calculate rotation angle from wall normal vector - * The normal points outward from the wall surface + * Calculate cursor rotation in WORLD space from wall normal and orientation */ -const calculateRotationFromNormal = (normal: [number, number, number] | undefined): number => { +const calculateCursorRotation = ( + normal: [number, number, number] | undefined, + wallStart: [number, number], + wallEnd: [number, number], +): number => { if (!normal) return 0 - // Calculate angle in X-Z plane (top-down view) - // atan2(z, x) gives the angle the vector makes with the positive X axis - // Add π/2 to align item's forward direction with the wall normal - return Math.atan2(normal[2], normal[0]) - Math.PI / 2 + + // Wall direction angle in world XZ plane + const wallAngle = Math.atan2(wallEnd[1] - wallStart[1], wallEnd[0] - wallStart[0]) + + // In local wall space, front face has normal.z < 0, back face has normal.z > 0 + if (normal[2] < 0) { + return -wallAngle + } else { + return Math.PI - wallAngle + } +} + +/** + * Calculate item rotation in WALL-LOCAL space from normal + * Items are children of the wall mesh, so their rotation is relative to wall's local space + */ +const calculateItemRotation = (normal: [number, number, number] | undefined): number => { + if (!normal) return 0 + + // In wall-local space: X along wall, Y up, Z perpendicular (thickness) + // Front face (normal.z < 0): item faces -Z local → rotation = 0 + // Back face (normal.z > 0): item faces +Z local → rotation = PI + return normal[2] < 0 ? 0 : Math.PI } /** @@ -67,38 +89,20 @@ const getSideFromNormal = (normal: [number, number, number] | undefined): 'front /** * Check if the normal indicates a valid wall side face (front or back) * Filters out top face and thickness edges - * @param normal - The face normal vector - * @param wallStart - Wall start point [x, z] - * @param wallEnd - Wall end point [x, z] + * + * In wall-local geometry space (after ExtrudeGeometry + rotateX): + * - X axis: along wall direction + * - Y axis: up (height) + * - Z axis: perpendicular to wall (thickness direction) + * + * So valid side faces have normals pointing in ±Z direction (local space) */ -const isValidWallSideFace = ( - normal: [number, number, number] | undefined, - wallStart: [number, number], - wallEnd: [number, number], -): boolean => { +const isValidWallSideFace = (normal: [number, number, number] | undefined): boolean => { if (!normal) return false - // Filter out top/bottom faces (normal pointing up or down) - if (Math.abs(normal[1]) > 0.3) return false - - // Calculate wall direction in X-Z plane - const wallDirX = wallEnd[0] - wallStart[0] - const wallDirZ = wallEnd[1] - wallStart[1] - const wallLength = Math.sqrt(wallDirX * wallDirX + wallDirZ * wallDirZ) - if (wallLength === 0) return false - - // Normalize wall direction - const normWallDirX = wallDirX / wallLength - const normWallDirZ = wallDirZ / wallLength - - // Dot product of normal (X-Z components) with wall direction - // Front/back faces: normal perpendicular to wall → dot ≈ 0 - // Thickness edges: normal parallel to wall → dot ≈ ±1 - const dotProduct = normal[0] * normWallDirX + normal[2] * normWallDirZ - - // If dot product is high, it's a thickness edge (normal parallel to wall) - // Allow only faces where normal is mostly perpendicular to wall direction - return Math.abs(dotProduct) < 0.5 + // Valid side faces have normals pointing in the local Z direction (perpendicular to wall) + // This filters out top faces (Y direction) and end caps/junctions (X direction) + return Math.abs(normal[2]) > 0.7 } export const ItemTool: React.FC = () => { @@ -223,8 +227,9 @@ export const ItemTool: React.FC = () => { draftItem.current?.asset.attachTo === 'wall' || draftItem.current?.asset.attachTo === 'wall-side' ) { + console.log('Wall enter:', event.node.id, event.normal) // Ignore top face and thickness edges - if (!isValidWallSideFace(event.normal, event.node.start, event.node.end)) return + if (!isValidWallSideFace(event.normal)) return event.stopPropagation() isOnWall.current = true @@ -232,7 +237,8 @@ export const ItemTool: React.FC = () => { // Determine side and rotation from normal const side = getSideFromNormal(event.normal) - const rotation = calculateRotationFromNormal(event.normal) + const itemRotation = calculateItemRotation(event.normal) + const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) gridPosition.current.set( Math.round(event.localPosition[0] * 2) / 2, @@ -241,15 +247,15 @@ export const ItemTool: React.FC = () => { ) draftItem.current.parentId = event.node.id draftItem.current.side = side - draftItem.current.rotation = [0, rotation, 0] + draftItem.current.rotation = [0, itemRotation, 0] useScene.getState().updateNode(draftItem.current.id, { position: [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z], parentId: event.node.id, side, - rotation: [0, rotation, 0], + rotation: [0, itemRotation, 0], }) - cursorRef.current.rotation.y = rotation + cursorRef.current.rotation.y = cursorRotation checkCanPlace() } } @@ -273,7 +279,7 @@ export const ItemTool: React.FC = () => { if (!isOnWall.current) return // Ignore top face and thickness edges - if (!isValidWallSideFace(event.normal, event.node.start, event.node.end)) return + if (!isValidWallSideFace(event.normal)) return event.stopPropagation() @@ -305,13 +311,14 @@ export const ItemTool: React.FC = () => { if (!draftItem.current) return // Ignore top face and thickness edges - if (!isValidWallSideFace(event.normal, event.node.start, event.node.end)) return + if (!isValidWallSideFace(event.normal)) return event.stopPropagation() // Determine side and rotation from normal const side = getSideFromNormal(event.normal) - const rotation = calculateRotationFromNormal(event.normal) + const itemRotation = calculateItemRotation(event.normal) + const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) gridPosition.current.set( Math.round(event.localPosition[0] * 2) / 2, @@ -323,11 +330,11 @@ export const ItemTool: React.FC = () => { Math.round(event.position[1] * 2) / 2, Math.round(event.position[2] * 2) / 2, ) - cursorRef.current.rotation.y = rotation + cursorRef.current.rotation.y = cursorRotation // Update draft item side and rotation draftItem.current.side = side - draftItem.current.rotation = [0, rotation, 0] + draftItem.current.rotation = [0, itemRotation, 0] const canPlace = checkCanPlace() if (draftItem.current && canPlace) { @@ -339,12 +346,12 @@ export const ItemTool: React.FC = () => { const draftItemMesh = sceneRegistry.nodes.get(draftItem.current.id) if (draftItemMesh) { draftItemMesh.position.copy(gridPosition.current) - draftItemMesh.rotation.y = rotation + draftItemMesh.rotation.y = itemRotation } useScene.getState().updateNode(draftItem.current.id, { side, - rotation: [0, rotation, 0], + rotation: [0, itemRotation, 0], }) useScene.getState().dirtyNodes.add(event.node.id) } diff --git a/bun.lock b/bun.lock index e7999def..585b15c8 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,8 @@ "@react-three/drei": "^10", "@react-three/fiber": "^9", "three": "^0.182.0", + "three-bvh-csg": "^0.0.17", + "three-mesh-bvh": "^0.9.7", "zustand": "^5.0.10", }, "devDependencies": { @@ -1008,7 +1010,9 @@ "three": ["three@0.182.0", "", {}, "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ=="], - "three-mesh-bvh": ["three-mesh-bvh@0.8.3", "", { "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg=="], + "three-bvh-csg": ["three-bvh-csg@0.0.17", "", { "peerDependencies": { "three": ">=0.151.0", "three-mesh-bvh": ">=0.6.6" } }, "sha512-iEkHDF8GRfGM6593Cuw8SnF1vfENCp46gIAtRzuL4nGXGWPcR1sbTBwM9ptDONb5twqlZp5WkAKya5aBKe2qcA=="], + + "three-mesh-bvh": ["three-mesh-bvh@0.9.7", "", { "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-EYSJbykeAjhVxwZjuUYq/kelIbqBoV9sbAgvZ+j1xCgZyNYSkr51WDJWS4WIfK2OX6YcjBGoTicX4RoOVQzx0g=="], "three-stdlib": ["three-stdlib@2.36.1", "", { "dependencies": { "@types/draco3d": "^1.4.0", "@types/offscreencanvas": "^2019.6.4", "@types/webxr": "^0.5.2", "draco3d": "^1.4.1", "fflate": "^0.6.9", "potpack": "^1.0.1" }, "peerDependencies": { "three": ">=0.128.0" } }, "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg=="], @@ -1120,6 +1124,8 @@ "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@react-three/drei/three-mesh-bvh": ["three-mesh-bvh@0.8.3", "", { "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg=="], + "@repo/ui/@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], diff --git a/package.json b/package.json index 6eeaa96d..e39ca48b 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "@react-three/drei": "^10", "@react-three/fiber": "^9", "three": "^0.182.0", + "three-bvh-csg": "^0.0.17", + "three-mesh-bvh": "^0.9.7", "zustand": "^5.0.10" }, "devDependencies": { diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/core/src/systems/wall/wall-system.tsx index 16873346..1c207182 100644 --- a/packages/core/src/systems/wall/wall-system.tsx +++ b/packages/core/src/systems/wall/wall-system.tsx @@ -1,16 +1,20 @@ import { useFrame } from '@react-three/fiber' import * as THREE from 'three' +import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' import type { AnyNode, AnyNodeId, WallNode } from '../../schema' import useScene from '../../store/use-scene' import { calculateLevelMiters, getAdjacentWallIds, - pointToKey, type Point2D, + pointToKey, type WallMiterData, } from './wall-mitering' +// Reusable CSG evaluator for better performance +const csgEvaluator = new Evaluator() + // ============================================================================ // WALL SYSTEM // ============================================================================ @@ -121,14 +125,14 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { } /** - * Generates extruded wall geometry with mitering (exactly like demo) + * Generates extruded wall geometry with mitering and cutouts * * Key insight from demo: polygon is built in WORLD coordinates first, * then we transform to wall-local for the 3D mesh. */ export function generateExtrudedWall( wallNode: WallNode, - _childrenNodes: AnyNode[], // TODO: Use for hole cutting (doors/windows) + childrenNodes: AnyNode[], miterData: WallMiterData, ) { const { junctionData } = miterData @@ -230,72 +234,106 @@ export function generateExtrudedWall( geometry.rotateX(-Math.PI / 2) geometry.computeVertexNormals() - return geometry + // Apply CSG subtraction for cutouts (doors/windows) + const cutoutBrushes = collectCutoutBrushes(wallNode, childrenNodes, thickness) + if (cutoutBrushes.length === 0) { + return geometry + } + + // Create wall brush from geometry + const wallBrush = new Brush(geometry) + wallBrush.updateMatrixWorld() + + // Subtract each cutout from the wall + let resultBrush = wallBrush + for (const cutoutBrush of cutoutBrushes) { + cutoutBrush.updateMatrixWorld() + const newResult = csgEvaluator.evaluate(resultBrush, cutoutBrush, SUBTRACTION) + if (resultBrush !== wallBrush) { + resultBrush.geometry.dispose() + } + resultBrush = newResult + } + + // Clean up + wallBrush.geometry.dispose() + for (const brush of cutoutBrushes) { + brush.geometry.dispose() + } + + const resultGeometry = resultBrush.geometry + resultGeometry.computeVertexNormals() + + return resultGeometry } /** - * Creates a Path from a cutout mesh for door/window holes - * TODO: Integrate with mitered wall geometry + * Collects cutout brushes from child items for CSG subtraction + * The cutout mesh is a plane, so we extrude it into a box that goes through the wall */ -function _createPathFromCutout( - cutoutMesh: THREE.Mesh, - wallStart: [number, number], - wallAngle: number, - wallWorldY: number, -): THREE.Path | null { - const geometry = cutoutMesh.geometry - if (!geometry) return null +function collectCutoutBrushes( + wallNode: WallNode, + childrenNodes: AnyNode[], + wallThickness: number, +): Brush[] { + const brushes: Brush[] = [] + const wallMesh = sceneRegistry.nodes.get(wallNode.id) as THREE.Mesh + if (!wallMesh) return brushes - const positions = geometry.attributes.position - if (!positions) return null + // Get wall's world matrix inverse to transform cutouts to wall-local space + wallMesh.updateMatrixWorld() + const wallMatrixInverse = wallMesh.matrixWorld.clone().invert() - cutoutMesh.updateWorldMatrix(true, false) + for (const child of childrenNodes) { + if (child.type !== 'item') continue - const uniquePoints: THREE.Vector2[] = [] - const seen = new Set() - const v3 = new THREE.Vector3() + const childMesh = sceneRegistry.nodes.get(child.id) + if (!childMesh) continue - const cosAngle = Math.cos(-wallAngle) - const sinAngle = Math.sin(-wallAngle) + const cutoutMesh = childMesh.getObjectByName('cutout') as THREE.Mesh + if (!cutoutMesh) continue - for (let i = 0; i < positions.count; i++) { - v3.fromBufferAttribute(positions, i) - v3.applyMatrix4(cutoutMesh.matrixWorld) + // Get the cutout's bounding box in world space + cutoutMesh.updateMatrixWorld() + const positions = cutoutMesh.geometry?.attributes?.position + if (!positions) continue - const worldX = v3.x - wallStart[0] - const worldZ = v3.z - wallStart[1] + // Calculate bounds in wall-local space + const v3 = new THREE.Vector3() + let minX = Infinity, + maxX = -Infinity + let minY = Infinity, + maxY = -Infinity - const localX = worldX * cosAngle - worldZ * sinAngle - const localY = v3.y - wallWorldY + for (let i = 0; i < positions.count; i++) { + v3.fromBufferAttribute(positions, i) + v3.applyMatrix4(cutoutMesh.matrixWorld) + v3.applyMatrix4(wallMatrixInverse) - const key = `${localX.toFixed(4)},${localY.toFixed(4)}` - if (!seen.has(key)) { - seen.add(key) - uniquePoints.push(new THREE.Vector2(localX, localY)) + minX = Math.min(minX, v3.x) + maxX = Math.max(maxX, v3.x) + minY = Math.min(minY, v3.y) + maxY = Math.max(maxY, v3.y) } + + if (!Number.isFinite(minX)) continue + + // Create a box geometry that extends through the wall thickness + const width = maxX - minX + const height = maxY - minY + const depth = wallThickness * 2 // Extend beyond wall to ensure clean cut + + const boxGeo = new THREE.BoxGeometry(width, height, depth) + // Position box at the center of the cutout + boxGeo.translate( + minX + width / 2, + minY + height / 2, + 0, // Center on Z axis (wall thickness direction) + ) + + const brush = new Brush(boxGeo) + brushes.push(brush) } - if (uniquePoints.length < 3) return null - - // Sort in counter-clockwise order - const centroid = new THREE.Vector2(0, 0) - for (const p of uniquePoints) { - centroid.add(p) - } - centroid.divideScalar(uniquePoints.length) - - uniquePoints.sort((a, b) => { - const angleA = Math.atan2(a.y - centroid.y, a.x - centroid.x) - const angleB = Math.atan2(b.y - centroid.y, b.x - centroid.x) - return angleA - angleB - }) - - const path = new THREE.Path() - path.moveTo(uniquePoints[0]?.x ?? 0, uniquePoints[0]?.y ?? 0) - for (let i = 1; i < uniquePoints.length; i++) { - path.lineTo(uniquePoints[i]?.x ?? 0, uniquePoints[i]?.y ?? 0) - } - path.closePath() - - return path + return brushes }