fix wall mitering (three-mesh-csg)
This commit is contained in:
@@ -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,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=="],
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user