fix(editor): harden editor interactions and WebGPU rendering
Fix editor bug sweep regressions, WebGPU CSG/material crashes, Shift snap bypass behavior, arrow handle drag projection, and the wall preview null guard covered by the Sentry follow-up PRs.
This commit is contained in:
@@ -28,9 +28,12 @@ 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 { ensureRenderableGeometryAttributes, prepareBrushForCSG } from '../../lib/csg-utils'
|
||||
import { buildOpeningCutoutGeometry } from './opening-cutout-geometry'
|
||||
|
||||
// Reusable CSG evaluator for better performance
|
||||
const csgEvaluator = new Evaluator()
|
||||
csgEvaluator.attributes = ['position', 'normal', 'uv', 'uv2']
|
||||
const CURVED_WALL_3D_ENDPOINT_INSET = 0.0015
|
||||
const WALL_FACE_NORMAL_Y_EPSILON = 0.6
|
||||
const WALL_FACE_EDGE_DISTANCE_EPSILON = 0.003
|
||||
@@ -52,13 +55,6 @@ type TaggedWallBoundaryEdge = {
|
||||
tag: WallBoundaryEdgeTag
|
||||
}
|
||||
|
||||
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
|
||||
const uv = geometry.getAttribute('uv')
|
||||
if (!uv) return
|
||||
|
||||
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2))
|
||||
}
|
||||
|
||||
function insetCurvedWallBoundaryPointsFor3D(
|
||||
wall: WallNode,
|
||||
boundaryPoints: ReturnType<typeof getWallMiterBoundaryPoints>,
|
||||
@@ -671,7 +667,7 @@ export function generateExtrudedWall(
|
||||
geometry.rotateX(-Math.PI / 2)
|
||||
geometry.computeVertexNormals()
|
||||
assignWallMaterialGroups(geometry, wallNode, boundaryEdges)
|
||||
ensureUv2Attribute(geometry)
|
||||
ensureRenderableGeometryAttributes(geometry)
|
||||
|
||||
// Apply CSG subtraction for cutouts (doors/windows)
|
||||
const cutoutBrushes = collectCutoutBrushes(wallNode, childrenNodes, thickness)
|
||||
@@ -681,6 +677,7 @@ export function generateExtrudedWall(
|
||||
|
||||
// Create wall brush from geometry
|
||||
// Pre-compute BVH with new API to avoid deprecation warning
|
||||
ensureRenderableGeometryAttributes(geometry)
|
||||
computeGeometryBoundsTree(geometry)
|
||||
|
||||
const wallBrush = new Brush(geometry)
|
||||
@@ -689,8 +686,9 @@ export function generateExtrudedWall(
|
||||
// Subtract each cutout from the wall
|
||||
let resultBrush = wallBrush
|
||||
for (const cutoutBrush of cutoutBrushes) {
|
||||
cutoutBrush.updateMatrixWorld()
|
||||
prepareBrushForCSG(cutoutBrush)
|
||||
const newResult = csgEvaluator.evaluate(resultBrush, cutoutBrush, SUBTRACTION)
|
||||
prepareBrushForCSG(newResult)
|
||||
if (resultBrush !== wallBrush) {
|
||||
csgGeometry(resultBrush).dispose()
|
||||
}
|
||||
@@ -706,7 +704,7 @@ export function generateExtrudedWall(
|
||||
const resultGeometry = csgGeometry(resultBrush)
|
||||
resultGeometry.computeVertexNormals()
|
||||
assignWallMaterialGroups(resultGeometry, wallNode, boundaryEdges)
|
||||
ensureUv2Attribute(resultGeometry)
|
||||
ensureRenderableGeometryAttributes(resultGeometry)
|
||||
|
||||
return resultGeometry
|
||||
}
|
||||
@@ -799,189 +797,23 @@ function collectCutoutBrushes(
|
||||
return brushes
|
||||
}
|
||||
|
||||
type ShapedOpeningNode = DoorNode | WindowNode
|
||||
type CornerRadii = {
|
||||
topLeft: number
|
||||
topRight: number
|
||||
bottomRight: number
|
||||
bottomLeft: number
|
||||
}
|
||||
|
||||
function createShapedOpeningCutoutBrush(opening: ShapedOpeningNode, wallThickness: number): Brush {
|
||||
const shape = createShapedOpeningCutoutShape(opening)
|
||||
const depth = wallThickness * 2
|
||||
const bevelSize =
|
||||
opening.openingShape === 'rounded'
|
||||
? Math.min(
|
||||
Math.max(opening.openingRevealRadius ?? 0.025, 0),
|
||||
Math.max(wallThickness * 0.45, 0.001),
|
||||
Math.max((opening.cornerRadius ?? 0.15) * 0.45, 0.001),
|
||||
)
|
||||
: 0
|
||||
const geometry = new THREE.ExtrudeGeometry(shape, {
|
||||
depth,
|
||||
bevelEnabled: bevelSize > 0,
|
||||
bevelSegments: bevelSize > 0 ? 8 : 0,
|
||||
bevelSize,
|
||||
bevelThickness: bevelSize,
|
||||
curveSegments: 24,
|
||||
})
|
||||
|
||||
geometry.translate(0, 0, -depth / 2)
|
||||
function createShapedOpeningCutoutBrush(
|
||||
opening: DoorNode | WindowNode,
|
||||
wallThickness: number,
|
||||
): Brush {
|
||||
const halfWidth = opening.width / 2
|
||||
const geometry = buildOpeningCutoutGeometry(
|
||||
opening,
|
||||
{
|
||||
left: opening.position[0] - halfWidth,
|
||||
right: opening.position[0] + halfWidth,
|
||||
bottom: opening.position[1] - opening.height / 2,
|
||||
top: opening.position[1] + opening.height / 2,
|
||||
},
|
||||
wallThickness * 2,
|
||||
wallThickness,
|
||||
)
|
||||
computeGeometryBoundsTree(geometry)
|
||||
|
||||
return new Brush(geometry)
|
||||
}
|
||||
|
||||
function createShapedOpeningCutoutShape(opening: ShapedOpeningNode): THREE.Shape {
|
||||
const halfWidth = opening.width / 2
|
||||
const bottom = opening.position[1] - opening.height / 2
|
||||
const top = opening.position[1] + opening.height / 2
|
||||
const centerX = opening.position[0]
|
||||
const left = centerX - halfWidth
|
||||
const right = centerX + halfWidth
|
||||
const width = Math.max(opening.width, 1e-6)
|
||||
const height = Math.max(opening.height, 1e-6)
|
||||
const shape = new THREE.Shape()
|
||||
|
||||
if (opening.openingShape === 'arch') {
|
||||
const archHeight = Math.min(Math.max(opening.archHeight ?? width / 2, 0.01), height)
|
||||
const springY = top - archHeight
|
||||
const segments = 32
|
||||
|
||||
shape.moveTo(left, bottom)
|
||||
shape.lineTo(right, bottom)
|
||||
shape.lineTo(right, springY)
|
||||
for (let index = 1; index <= segments; index += 1) {
|
||||
const x = right + (left - right) * (index / segments)
|
||||
const normalizedX = Math.min(Math.abs((x - centerX) / halfWidth), 1)
|
||||
const y = springY + archHeight * Math.sqrt(Math.max(1 - normalizedX * normalizedX, 0))
|
||||
shape.lineTo(x, y)
|
||||
}
|
||||
shape.lineTo(left, bottom)
|
||||
shape.closePath()
|
||||
return shape
|
||||
}
|
||||
|
||||
if (opening.openingShape === 'rounded') {
|
||||
const radii = getRoundedOpeningRadii(opening, width, height)
|
||||
applyRoundedOpeningShape(shape, left, right, bottom, top, radii)
|
||||
return shape
|
||||
}
|
||||
|
||||
shape.moveTo(left, bottom)
|
||||
shape.lineTo(right, bottom)
|
||||
shape.lineTo(right, top)
|
||||
shape.lineTo(left, top)
|
||||
shape.closePath()
|
||||
return shape
|
||||
}
|
||||
|
||||
function getRoundedOpeningRadii(
|
||||
opening: ShapedOpeningNode,
|
||||
width: number,
|
||||
height: number,
|
||||
): CornerRadii {
|
||||
if (opening.type !== 'window') {
|
||||
if (opening.openingRadiusMode === 'individual') {
|
||||
const [topLeft = 0, topRight = 0] = opening.openingTopRadii ?? [0.15, 0.15]
|
||||
|
||||
return normalizeCornerRadii(
|
||||
{
|
||||
topLeft: Math.max(topLeft, 0),
|
||||
topRight: Math.max(topRight, 0),
|
||||
bottomRight: 0,
|
||||
bottomLeft: 0,
|
||||
},
|
||||
width,
|
||||
height,
|
||||
)
|
||||
}
|
||||
|
||||
const maxRadius = Math.min(width / 2, height)
|
||||
const radius = Math.min(Math.max(opening.cornerRadius ?? 0.15, 0), maxRadius)
|
||||
return { topLeft: radius, topRight: radius, bottomRight: 0, bottomLeft: 0 }
|
||||
}
|
||||
|
||||
if (opening.openingRadiusMode === 'individual') {
|
||||
const [topLeft = 0, topRight = 0, bottomRight = 0, bottomLeft = 0] =
|
||||
opening.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15]
|
||||
|
||||
return normalizeCornerRadii(
|
||||
{
|
||||
topLeft: Math.max(topLeft, 0),
|
||||
topRight: Math.max(topRight, 0),
|
||||
bottomRight: Math.max(bottomRight, 0),
|
||||
bottomLeft: Math.max(bottomLeft, 0),
|
||||
},
|
||||
width,
|
||||
height,
|
||||
)
|
||||
}
|
||||
|
||||
const maxRadius = Math.min(width / 2, height / 2)
|
||||
const radius = Math.min(Math.max(opening.cornerRadius ?? 0.15, 0), maxRadius)
|
||||
return { topLeft: radius, topRight: radius, bottomRight: radius, bottomLeft: radius }
|
||||
}
|
||||
|
||||
function normalizeCornerRadii(radii: CornerRadii, width: number, height: number): CornerRadii {
|
||||
const next = { ...radii }
|
||||
const maxScale = Math.min(
|
||||
1,
|
||||
width / Math.max(next.topLeft + next.topRight, 1e-6),
|
||||
width / Math.max(next.bottomLeft + next.bottomRight, 1e-6),
|
||||
height / Math.max(next.topLeft + next.bottomLeft, 1e-6),
|
||||
height / Math.max(next.topRight + next.bottomRight, 1e-6),
|
||||
)
|
||||
|
||||
if (maxScale < 1) {
|
||||
next.topLeft *= maxScale
|
||||
next.topRight *= maxScale
|
||||
next.bottomRight *= maxScale
|
||||
next.bottomLeft *= maxScale
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
function applyRoundedOpeningShape(
|
||||
shape: THREE.Shape,
|
||||
left: number,
|
||||
right: number,
|
||||
bottom: number,
|
||||
top: number,
|
||||
radii: CornerRadii,
|
||||
) {
|
||||
const { topLeft, topRight, bottomRight, bottomLeft } = radii
|
||||
|
||||
shape.moveTo(left + bottomLeft, bottom)
|
||||
shape.lineTo(right - bottomRight, bottom)
|
||||
if (bottomRight > 1e-6) {
|
||||
shape.absarc(right - bottomRight, bottom + bottomRight, bottomRight, -Math.PI / 2, 0, false)
|
||||
} else {
|
||||
shape.lineTo(right, bottom)
|
||||
}
|
||||
|
||||
shape.lineTo(right, top - topRight)
|
||||
if (topRight > 1e-6) {
|
||||
shape.absarc(right - topRight, top - topRight, topRight, 0, Math.PI / 2, false)
|
||||
} else {
|
||||
shape.lineTo(right, top)
|
||||
}
|
||||
|
||||
shape.lineTo(left + topLeft, top)
|
||||
if (topLeft > 1e-6) {
|
||||
shape.absarc(left + topLeft, top - topLeft, topLeft, Math.PI / 2, Math.PI, false)
|
||||
} else {
|
||||
shape.lineTo(left, top)
|
||||
}
|
||||
|
||||
shape.lineTo(left, bottom + bottomLeft)
|
||||
if (bottomLeft > 1e-6) {
|
||||
shape.absarc(left + bottomLeft, bottom + bottomLeft, bottomLeft, Math.PI, Math.PI * 1.5, false)
|
||||
} else {
|
||||
shape.lineTo(left, bottom)
|
||||
}
|
||||
|
||||
shape.closePath()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user