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
|
* Calculate cursor rotation in WORLD space from wall normal and orientation
|
||||||
* The normal points outward from the wall surface
|
|
||||||
*/
|
*/
|
||||||
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
|
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
|
// Wall direction angle in world XZ plane
|
||||||
// Add π/2 to align item's forward direction with the wall normal
|
const wallAngle = Math.atan2(wallEnd[1] - wallStart[1], wallEnd[0] - wallStart[0])
|
||||||
return Math.atan2(normal[2], normal[0]) - Math.PI / 2
|
|
||||||
|
// 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)
|
* Check if the normal indicates a valid wall side face (front or back)
|
||||||
* Filters out top face and thickness edges
|
* Filters out top face and thickness edges
|
||||||
* @param normal - The face normal vector
|
*
|
||||||
* @param wallStart - Wall start point [x, z]
|
* In wall-local geometry space (after ExtrudeGeometry + rotateX):
|
||||||
* @param wallEnd - Wall end point [x, z]
|
* - 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 = (
|
const isValidWallSideFace = (normal: [number, number, number] | undefined): boolean => {
|
||||||
normal: [number, number, number] | undefined,
|
|
||||||
wallStart: [number, number],
|
|
||||||
wallEnd: [number, number],
|
|
||||||
): boolean => {
|
|
||||||
if (!normal) return false
|
if (!normal) return false
|
||||||
|
|
||||||
// Filter out top/bottom faces (normal pointing up or down)
|
// Valid side faces have normals pointing in the local Z direction (perpendicular to wall)
|
||||||
if (Math.abs(normal[1]) > 0.3) return false
|
// This filters out top faces (Y direction) and end caps/junctions (X direction)
|
||||||
|
return Math.abs(normal[2]) > 0.7
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ItemTool: React.FC = () => {
|
export const ItemTool: React.FC = () => {
|
||||||
@@ -223,8 +227,9 @@ export const ItemTool: React.FC = () => {
|
|||||||
draftItem.current?.asset.attachTo === 'wall' ||
|
draftItem.current?.asset.attachTo === 'wall' ||
|
||||||
draftItem.current?.asset.attachTo === 'wall-side'
|
draftItem.current?.asset.attachTo === 'wall-side'
|
||||||
) {
|
) {
|
||||||
|
console.log('Wall enter:', event.node.id, event.normal)
|
||||||
// Ignore top face and thickness edges
|
// Ignore top face and thickness edges
|
||||||
if (!isValidWallSideFace(event.normal, event.node.start, event.node.end)) return
|
if (!isValidWallSideFace(event.normal)) return
|
||||||
|
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
isOnWall.current = true
|
isOnWall.current = true
|
||||||
@@ -232,7 +237,8 @@ export const ItemTool: React.FC = () => {
|
|||||||
|
|
||||||
// Determine side and rotation from normal
|
// Determine side and rotation from normal
|
||||||
const side = getSideFromNormal(event.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(
|
gridPosition.current.set(
|
||||||
Math.round(event.localPosition[0] * 2) / 2,
|
Math.round(event.localPosition[0] * 2) / 2,
|
||||||
@@ -241,15 +247,15 @@ export const ItemTool: React.FC = () => {
|
|||||||
)
|
)
|
||||||
draftItem.current.parentId = event.node.id
|
draftItem.current.parentId = event.node.id
|
||||||
draftItem.current.side = side
|
draftItem.current.side = side
|
||||||
draftItem.current.rotation = [0, rotation, 0]
|
draftItem.current.rotation = [0, itemRotation, 0]
|
||||||
|
|
||||||
useScene.getState().updateNode(draftItem.current.id, {
|
useScene.getState().updateNode(draftItem.current.id, {
|
||||||
position: [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
|
position: [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
|
||||||
parentId: event.node.id,
|
parentId: event.node.id,
|
||||||
side,
|
side,
|
||||||
rotation: [0, rotation, 0],
|
rotation: [0, itemRotation, 0],
|
||||||
})
|
})
|
||||||
cursorRef.current.rotation.y = rotation
|
cursorRef.current.rotation.y = cursorRotation
|
||||||
checkCanPlace()
|
checkCanPlace()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -273,7 +279,7 @@ export const ItemTool: React.FC = () => {
|
|||||||
if (!isOnWall.current) return
|
if (!isOnWall.current) return
|
||||||
|
|
||||||
// Ignore top face and thickness edges
|
// Ignore top face and thickness edges
|
||||||
if (!isValidWallSideFace(event.normal, event.node.start, event.node.end)) return
|
if (!isValidWallSideFace(event.normal)) return
|
||||||
|
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
|
||||||
@@ -305,13 +311,14 @@ export const ItemTool: React.FC = () => {
|
|||||||
if (!draftItem.current) return
|
if (!draftItem.current) return
|
||||||
|
|
||||||
// Ignore top face and thickness edges
|
// Ignore top face and thickness edges
|
||||||
if (!isValidWallSideFace(event.normal, event.node.start, event.node.end)) return
|
if (!isValidWallSideFace(event.normal)) return
|
||||||
|
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
|
||||||
// Determine side and rotation from normal
|
// Determine side and rotation from normal
|
||||||
const side = getSideFromNormal(event.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(
|
gridPosition.current.set(
|
||||||
Math.round(event.localPosition[0] * 2) / 2,
|
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[1] * 2) / 2,
|
||||||
Math.round(event.position[2] * 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
|
// Update draft item side and rotation
|
||||||
draftItem.current.side = side
|
draftItem.current.side = side
|
||||||
draftItem.current.rotation = [0, rotation, 0]
|
draftItem.current.rotation = [0, itemRotation, 0]
|
||||||
|
|
||||||
const canPlace = checkCanPlace()
|
const canPlace = checkCanPlace()
|
||||||
if (draftItem.current && canPlace) {
|
if (draftItem.current && canPlace) {
|
||||||
@@ -339,12 +346,12 @@ export const ItemTool: React.FC = () => {
|
|||||||
const draftItemMesh = sceneRegistry.nodes.get(draftItem.current.id)
|
const draftItemMesh = sceneRegistry.nodes.get(draftItem.current.id)
|
||||||
if (draftItemMesh) {
|
if (draftItemMesh) {
|
||||||
draftItemMesh.position.copy(gridPosition.current)
|
draftItemMesh.position.copy(gridPosition.current)
|
||||||
draftItemMesh.rotation.y = rotation
|
draftItemMesh.rotation.y = itemRotation
|
||||||
}
|
}
|
||||||
|
|
||||||
useScene.getState().updateNode(draftItem.current.id, {
|
useScene.getState().updateNode(draftItem.current.id, {
|
||||||
side,
|
side,
|
||||||
rotation: [0, rotation, 0],
|
rotation: [0, itemRotation, 0],
|
||||||
})
|
})
|
||||||
useScene.getState().dirtyNodes.add(event.node.id)
|
useScene.getState().dirtyNodes.add(event.node.id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
"@react-three/drei": "^10",
|
"@react-three/drei": "^10",
|
||||||
"@react-three/fiber": "^9",
|
"@react-three/fiber": "^9",
|
||||||
"three": "^0.182.0",
|
"three": "^0.182.0",
|
||||||
|
"three-bvh-csg": "^0.0.17",
|
||||||
|
"three-mesh-bvh": "^0.9.7",
|
||||||
"zustand": "^5.0.10",
|
"zustand": "^5.0.10",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1008,7 +1010,9 @@
|
|||||||
|
|
||||||
"three": ["three@0.182.0", "", {}, "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ=="],
|
"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=="],
|
"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=="],
|
"@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=="],
|
"@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=="],
|
"@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/drei": "^10",
|
||||||
"@react-three/fiber": "^9",
|
"@react-three/fiber": "^9",
|
||||||
"three": "^0.182.0",
|
"three": "^0.182.0",
|
||||||
|
"three-bvh-csg": "^0.0.17",
|
||||||
|
"three-mesh-bvh": "^0.9.7",
|
||||||
"zustand": "^5.0.10"
|
"zustand": "^5.0.10"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg'
|
||||||
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
||||||
import type { AnyNode, AnyNodeId, WallNode } from '../../schema'
|
import type { AnyNode, AnyNodeId, WallNode } from '../../schema'
|
||||||
import useScene from '../../store/use-scene'
|
import useScene from '../../store/use-scene'
|
||||||
import {
|
import {
|
||||||
calculateLevelMiters,
|
calculateLevelMiters,
|
||||||
getAdjacentWallIds,
|
getAdjacentWallIds,
|
||||||
pointToKey,
|
|
||||||
type Point2D,
|
type Point2D,
|
||||||
|
pointToKey,
|
||||||
type WallMiterData,
|
type WallMiterData,
|
||||||
} from './wall-mitering'
|
} from './wall-mitering'
|
||||||
|
|
||||||
|
// Reusable CSG evaluator for better performance
|
||||||
|
const csgEvaluator = new Evaluator()
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// WALL SYSTEM
|
// 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,
|
* Key insight from demo: polygon is built in WORLD coordinates first,
|
||||||
* then we transform to wall-local for the 3D mesh.
|
* then we transform to wall-local for the 3D mesh.
|
||||||
*/
|
*/
|
||||||
export function generateExtrudedWall(
|
export function generateExtrudedWall(
|
||||||
wallNode: WallNode,
|
wallNode: WallNode,
|
||||||
_childrenNodes: AnyNode[], // TODO: Use for hole cutting (doors/windows)
|
childrenNodes: AnyNode[],
|
||||||
miterData: WallMiterData,
|
miterData: WallMiterData,
|
||||||
) {
|
) {
|
||||||
const { junctionData } = miterData
|
const { junctionData } = miterData
|
||||||
@@ -230,72 +234,106 @@ export function generateExtrudedWall(
|
|||||||
geometry.rotateX(-Math.PI / 2)
|
geometry.rotateX(-Math.PI / 2)
|
||||||
geometry.computeVertexNormals()
|
geometry.computeVertexNormals()
|
||||||
|
|
||||||
|
// Apply CSG subtraction for cutouts (doors/windows)
|
||||||
|
const cutoutBrushes = collectCutoutBrushes(wallNode, childrenNodes, thickness)
|
||||||
|
if (cutoutBrushes.length === 0) {
|
||||||
return geometry
|
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
|
* Collects cutout brushes from child items for CSG subtraction
|
||||||
* TODO: Integrate with mitered wall geometry
|
* The cutout mesh is a plane, so we extrude it into a box that goes through the wall
|
||||||
*/
|
*/
|
||||||
function _createPathFromCutout(
|
function collectCutoutBrushes(
|
||||||
cutoutMesh: THREE.Mesh,
|
wallNode: WallNode,
|
||||||
wallStart: [number, number],
|
childrenNodes: AnyNode[],
|
||||||
wallAngle: number,
|
wallThickness: number,
|
||||||
wallWorldY: number,
|
): Brush[] {
|
||||||
): THREE.Path | null {
|
const brushes: Brush[] = []
|
||||||
const geometry = cutoutMesh.geometry
|
const wallMesh = sceneRegistry.nodes.get(wallNode.id) as THREE.Mesh
|
||||||
if (!geometry) return null
|
if (!wallMesh) return brushes
|
||||||
|
|
||||||
const positions = geometry.attributes.position
|
// Get wall's world matrix inverse to transform cutouts to wall-local space
|
||||||
if (!positions) return null
|
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 childMesh = sceneRegistry.nodes.get(child.id)
|
||||||
const seen = new Set<string>()
|
if (!childMesh) continue
|
||||||
|
|
||||||
|
const cutoutMesh = childMesh.getObjectByName('cutout') as THREE.Mesh
|
||||||
|
if (!cutoutMesh) continue
|
||||||
|
|
||||||
|
// Get the cutout's bounding box in world space
|
||||||
|
cutoutMesh.updateMatrixWorld()
|
||||||
|
const positions = cutoutMesh.geometry?.attributes?.position
|
||||||
|
if (!positions) continue
|
||||||
|
|
||||||
|
// Calculate bounds in wall-local space
|
||||||
const v3 = new THREE.Vector3()
|
const v3 = new THREE.Vector3()
|
||||||
|
let minX = Infinity,
|
||||||
const cosAngle = Math.cos(-wallAngle)
|
maxX = -Infinity
|
||||||
const sinAngle = Math.sin(-wallAngle)
|
let minY = Infinity,
|
||||||
|
maxY = -Infinity
|
||||||
|
|
||||||
for (let i = 0; i < positions.count; i++) {
|
for (let i = 0; i < positions.count; i++) {
|
||||||
v3.fromBufferAttribute(positions, i)
|
v3.fromBufferAttribute(positions, i)
|
||||||
v3.applyMatrix4(cutoutMesh.matrixWorld)
|
v3.applyMatrix4(cutoutMesh.matrixWorld)
|
||||||
|
v3.applyMatrix4(wallMatrixInverse)
|
||||||
|
|
||||||
const worldX = v3.x - wallStart[0]
|
minX = Math.min(minX, v3.x)
|
||||||
const worldZ = v3.z - wallStart[1]
|
maxX = Math.max(maxX, v3.x)
|
||||||
|
minY = Math.min(minY, v3.y)
|
||||||
const localX = worldX * cosAngle - worldZ * sinAngle
|
maxY = Math.max(maxY, v3.y)
|
||||||
const localY = v3.y - wallWorldY
|
|
||||||
|
|
||||||
const key = `${localX.toFixed(4)},${localY.toFixed(4)}`
|
|
||||||
if (!seen.has(key)) {
|
|
||||||
seen.add(key)
|
|
||||||
uniquePoints.push(new THREE.Vector2(localX, localY))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uniquePoints.length < 3) return null
|
if (!Number.isFinite(minX)) continue
|
||||||
|
|
||||||
// Sort in counter-clockwise order
|
// Create a box geometry that extends through the wall thickness
|
||||||
const centroid = new THREE.Vector2(0, 0)
|
const width = maxX - minX
|
||||||
for (const p of uniquePoints) {
|
const height = maxY - minY
|
||||||
centroid.add(p)
|
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)
|
||||||
}
|
}
|
||||||
centroid.divideScalar(uniquePoints.length)
|
|
||||||
|
|
||||||
uniquePoints.sort((a, b) => {
|
return brushes
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user