Qiaochu Hu 7d4c8b6bd4
fix: resolve state management issues and memory leaks (#152)
This commit fixes several issues in the core state management:

1. Fix deleteNodesAction recursive call issue
   - Previously, the function recursively called get().deleteNodes()
     inside the set() callback, which could cause state inconsistencies.
   - Now collects all descendant IDs first in a separate pass, then
     deletes them all in a single batch operation.

2. Fix memory leak in use-scene temporal tracking
   - Module-level variables (prevPastLength, prevFutureLength,
     prevNodesSnapshot) persisted across store re-creations.
   - Now cleared in unloadScene() to release stale node references.
   - Added clearTemporalTracking() helper function.

3. Remove unused variable in wall-system
   - Removed debug variable 'useFrameNb' that was declared but never used.

4. Fix requestAnimationFrame in updateNodesAction
   - Previously used RAF without cleanup, causing multiple queued
     callbacks when updates happened rapidly.
   - Now uses a single tracked RAF with cancellation support.

Co-authored-by: hobostay <hobostay@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 00:30:16 -04:00

318 lines
10 KiB
TypeScript

import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'
import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg'
import { computeBoundsTree } from 'three-mesh-bvh'
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 { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint'
import {
calculateLevelMiters,
getAdjacentWallIds,
type Point2D,
type WallMiterData,
} from './wall-mitering'
// Reusable CSG evaluator for better performance
const csgEvaluator = new Evaluator()
// ============================================================================
// WALL SYSTEM
// ============================================================================
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>>()
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)
}
}
}
}
}, 4)
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], slabElevation, 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 wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] }
const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] }
// Positive slab: shift the whole wall up (full height preserved)
// Negative slab: extend wall downward so top stays fixed at wallNode.height
const wallHeight = wallNode.height ?? DEFAULT_WALL_HEIGHT
const height = slabElevation > 0 ? wallHeight : wallHeight - slabElevation
const thickness = getWallThickness(wallNode)
// 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 polyPoints = getWallPlanFootprint(wallNode, miterData)
if (polyPoints.length < 3) {
return new THREE.BufferGeometry()
}
// 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)
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' && child.type !== 'door') 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 = Number.POSITIVE_INFINITY,
maxX = Number.NEGATIVE_INFINITY
let minY = Number.POSITIVE_INFINITY,
maxY = Number.NEGATIVE_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
}