2026-02-17 16:22:45 +09:00

368 lines
12 KiB
TypeScript

import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'
import { computeBoundsTree } from 'three-mesh-bvh'
import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg'
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager'
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
import type { AnyNode, AnyNodeId, WallNode } from '../../schema'
import useScene from '../../store/use-scene'
import {
calculateLevelMiters,
getAdjacentWallIds,
type Point2D,
pointToKey,
type WallMiterData,
} from './wall-mitering'
// Reusable CSG evaluator for better performance
const csgEvaluator = new Evaluator()
// ============================================================================
// WALL SYSTEM
// ============================================================================
let useFrameNb = 0;
export const WallSystem = () => {
const dirtyNodes = useScene((state) => state.dirtyNodes)
const clearDirty = useScene((state) => state.clearDirty)
useFrame(() => {
if (dirtyNodes.size === 0) return
const nodes = useScene.getState().nodes
// Collect dirty walls and their levels
const dirtyWallsByLevel = new Map<string, Set<string>>()
useFrameNb += 1;
dirtyNodes.forEach((id) => {
const node = nodes[id]
if (!node || node.type !== 'wall') return
const levelId = node.parentId
if (!levelId) return
if (!dirtyWallsByLevel.has(levelId)) {
dirtyWallsByLevel.set(levelId, new Set())
}
dirtyWallsByLevel.get(levelId)!.add(id)
})
// Process each level that has dirty walls
for (const [levelId, dirtyWallIds] of dirtyWallsByLevel) {
const levelWalls = getLevelWalls(levelId)
const miterData = calculateLevelMiters(levelWalls)
// Update dirty walls
for (const wallId of dirtyWallIds) {
const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh
if (mesh) {
updateWallGeometry(wallId, miterData)
clearDirty(wallId as AnyNodeId)
}
// If mesh not found, keep it dirty for next frame
}
// Update adjacent walls that share junctions
const adjacentWallIds = getAdjacentWallIds(levelWalls, dirtyWallIds)
for (const wallId of adjacentWallIds) {
if (!dirtyWallIds.has(wallId)) {
const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh
if (mesh) {
updateWallGeometry(wallId, miterData)
}
}
}
}
})
return null
}
/**
* Gets all walls that belong to a level
*/
function getLevelWalls(levelId: string): WallNode[] {
const { nodes } = useScene.getState()
const level = nodes[levelId as AnyNodeId]
if (!level || level.type !== 'level') return []
const walls: WallNode[] = []
for (const childId of level.children) {
const child = nodes[childId]
if (child?.type === 'wall') {
walls.push(child as WallNode)
}
}
return walls
}
/**
* Updates the geometry for a single wall
*/
function updateWallGeometry(wallId: string, miterData: WallMiterData) {
const nodes = useScene.getState().nodes
const node = nodes[wallId as WallNode['id']]
if (!node || node.type !== 'wall') return
const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh
if (!mesh) return
const levelId = resolveLevelId(node, nodes)
const slabElevation = spatialGridManager.getSlabElevationForWall(levelId, node.start, node.end)
const childrenIds = node.children || []
const childrenNodes = childrenIds
.map((childId) => nodes[childId])
.filter((n): n is AnyNode => n !== undefined)
const newGeo = generateExtrudedWall(node, childrenNodes, miterData, slabElevation)
mesh.geometry.dispose()
mesh.geometry = newGeo
// Update collision mesh
const collisionMesh = mesh.getObjectByName('collision-mesh') as THREE.Mesh
if (collisionMesh) {
const collisionGeo = generateExtrudedWall(node, [], miterData, slabElevation)
collisionMesh.geometry.dispose()
collisionMesh.geometry = collisionGeo
}
mesh.position.set(node.start[0], 0, node.start[1])
const angle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0])
mesh.rotation.y = -angle
}
/**
* 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[],
miterData: WallMiterData,
slabElevation = 0,
) {
const { junctionData } = miterData
const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] }
const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] }
// Wall height is adjusted by slab elevation (positive reduces, negative increases)
const height = (wallNode.height ?? 2.5) - slabElevation
const thickness = wallNode.thickness ?? 0.1
const halfT = thickness / 2
// Wall direction and normal (exactly like demo)
const v = { x: wallEnd.x - wallStart.x, y: wallEnd.y - wallStart.y }
const L = Math.sqrt(v.x * v.x + v.y * v.y)
if (L < 1e-9) {
return new THREE.BufferGeometry()
}
const nUnit = { x: -v.y / L, y: v.x / L }
// Get junction data for start and end (exactly like demo)
const keyStart = pointToKey(wallStart)
const keyEnd = pointToKey(wallEnd)
const startJunction = junctionData.get(keyStart)?.get(wallNode.id)
const endJunction = junctionData.get(keyEnd)?.get(wallNode.id)
// Calculate polygon corners in world coordinates (exactly like demo)
// p_start_L = left side at start
// p_start_R = right side at start
// p_end_L = left side at end
// p_end_R = right side at end
const p_start_L: Point2D = startJunction?.left || {
x: wallStart.x + nUnit.x * halfT,
y: wallStart.y + nUnit.y * halfT,
}
const p_start_R: Point2D = startJunction?.right || {
x: wallStart.x - nUnit.x * halfT,
y: wallStart.y - nUnit.y * halfT,
}
// At end, SWAP left/right from junction data (exactly like demo)
// This is because junction stores left/right relative to OUTGOING direction,
// which is reversed at the end of the wall
const p_end_L: Point2D = endJunction?.right || {
x: wallEnd.x + nUnit.x * halfT,
y: wallEnd.y + nUnit.y * halfT,
}
const p_end_R: Point2D = endJunction?.left || {
x: wallEnd.x - nUnit.x * halfT,
y: wallEnd.y - nUnit.y * halfT,
}
// Build polygon points (exactly like demo)
// Order: start-right -> end-right -> [end center] -> end-left -> start-left -> [start center]
const polyPoints: Point2D[] = [p_start_R, p_end_R]
if (endJunction) {
polyPoints.push(wallEnd) // Add center vertex at junction
}
polyPoints.push(p_end_L, p_start_L)
if (startJunction) {
polyPoints.push(wallStart) // Add center vertex at junction
}
// Transform world coordinates to wall-local coordinates
// Wall-local: x along wall, z perpendicular (thickness direction)
const wallAngle = Math.atan2(v.y, v.x)
const cosA = Math.cos(-wallAngle)
const sinA = Math.sin(-wallAngle)
const worldToLocal = (worldPt: Point2D): { x: number; z: number } => {
const dx = worldPt.x - wallStart.x
const dy = worldPt.y - wallStart.y
return {
x: dx * cosA - dy * sinA,
z: dx * sinA + dy * cosA,
}
}
// Convert polygon to local coordinates
const localPoints = polyPoints.map(worldToLocal)
// Build THREE.js shape
// Shape uses (x, y) where we map: shape.x = local.x, shape.y = -local.z
// The negation is needed because after rotateX(-PI/2), shape.y becomes -geometry.z
const footprint = new THREE.Shape()
footprint.moveTo(localPoints[0]!.x, -localPoints[0]!.z)
for (let i = 1; i < localPoints.length; i++) {
footprint.lineTo(localPoints[i]!.x, -localPoints[i]!.z)
}
footprint.closePath()
// Extrude along Z by height
const geometry = new THREE.ExtrudeGeometry(footprint, {
depth: height,
bevelEnabled: false,
})
// Rotate so extrusion direction (Z) becomes height direction (Y)
geometry.rotateX(-Math.PI / 2)
// Translate by slab elevation (works for both positive and negative values)
if (slabElevation !== 0) {
geometry.translate(0, slabElevation, 0)
}
geometry.computeVertexNormals()
// Apply CSG subtraction for cutouts (doors/windows)
const cutoutBrushes = collectCutoutBrushes(wallNode, childrenNodes, thickness)
if (cutoutBrushes.length === 0) {
return geometry
}
// Create wall brush from geometry
// Pre-compute BVH with new API to avoid deprecation warning
geometry.computeBoundsTree = computeBoundsTree
geometry.computeBoundsTree({ maxLeafSize: 10 })
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
}
/**
* 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 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
// Get wall's world matrix inverse to transform cutouts to wall-local space
wallMesh.updateMatrixWorld()
const wallMatrixInverse = wallMesh.matrixWorld.clone().invert()
for (const child of childrenNodes) {
if (child.type !== 'item' && child.type !== 'window') continue
const childMesh = sceneRegistry.nodes.get(child.id)
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()
let minX = Infinity,
maxX = -Infinity
let minY = Infinity,
maxY = -Infinity
for (let i = 0; i < positions.count; i++) {
v3.fromBufferAttribute(positions, i)
v3.applyMatrix4(cutoutMesh.matrixWorld)
v3.applyMatrix4(wallMatrixInverse)
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)
)
// Pre-compute BVH with new API to avoid deprecation warning
boxGeo.computeBoundsTree = computeBoundsTree
boxGeo.computeBoundsTree({ maxLeafSize: 10 })
const brush = new Brush(boxGeo)
brushes.push(brush)
}
return brushes
}