fix wall mitering (three-mesh-csg)

This commit is contained in:
wass08
2026-01-26 19:52:53 +09:00
parent f244f864ba
commit 54dd5d0541
4 changed files with 158 additions and 105 deletions
+55 -48
View File
@@ -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)
}
+7 -1
View File
@@ -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=="],
+2
View File
@@ -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": {
+94 -56
View File
@@ -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<string>()
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
}