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:
@@ -135,6 +135,7 @@ export function generateCeilingGeometry(
|
||||
degenerate.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
|
||||
degenerate.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
|
||||
degenerate.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2))
|
||||
degenerate.setAttribute('uv2', new THREE.Float32BufferAttribute(new Float32Array(6), 2))
|
||||
return degenerate
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import * as THREE from 'three'
|
||||
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||
import { ADDITION, Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg'
|
||||
import { computeBoundsTree } from 'three-mesh-bvh'
|
||||
import { ensureRenderableGeometryAttributes } from '../../lib/csg-utils'
|
||||
|
||||
function csgGeometry(brush: Brush): THREE.BufferGeometry {
|
||||
return brush.geometry as unknown as THREE.BufferGeometry
|
||||
@@ -30,7 +31,7 @@ function csgMaterials(brush: Brush): THREE.Material[] {
|
||||
const csgEvaluator = new Evaluator()
|
||||
csgEvaluator.useGroups = true
|
||||
;(csgEvaluator as any).consolidateGroups = false // shared dummyMats across brushes causes consolidation to misalign groupIndices vs groupOrder indices → crash
|
||||
csgEvaluator.attributes = ['position', 'normal', 'uv']
|
||||
csgEvaluator.attributes = ['position', 'normal', 'uv', 'uv2']
|
||||
|
||||
function computeGeometryBoundsTree(geometry: THREE.BufferGeometry) {
|
||||
;(geometry as any).computeBoundsTree = computeBoundsTree
|
||||
@@ -38,6 +39,7 @@ function computeGeometryBoundsTree(geometry: THREE.BufferGeometry) {
|
||||
}
|
||||
|
||||
function prepareBrushForCSG(brush: Brush) {
|
||||
ensureRenderableGeometryAttributes(brush.geometry)
|
||||
computeGeometryBoundsTree(brush.geometry)
|
||||
brush.updateMatrixWorld()
|
||||
}
|
||||
@@ -176,6 +178,10 @@ export const RoofSystem = () => {
|
||||
'uv',
|
||||
new THREE.Float32BufferAttribute(new Float32Array(6), 2),
|
||||
)
|
||||
placeholder.setAttribute(
|
||||
'uv2',
|
||||
new THREE.Float32BufferAttribute(new Float32Array(6), 2),
|
||||
)
|
||||
computeGeometryBoundsTree(placeholder)
|
||||
mesh.geometry = placeholder
|
||||
}
|
||||
@@ -299,6 +305,7 @@ function subtractAccessoryCuts(
|
||||
welded.clearGroups()
|
||||
welded.addGroup(0, idxCount, 0)
|
||||
welded.computeVertexNormals()
|
||||
ensureRenderableGeometryAttributes(welded)
|
||||
computeGeometryBoundsTree(welded)
|
||||
const cut = new Brush(welded, dummyMats[0])
|
||||
cut.updateMatrixWorld()
|
||||
@@ -434,11 +441,16 @@ function updateMergedRoofGeometry(
|
||||
if (totalShinSlab && totalDeckSlab && totalWall && totalInner) {
|
||||
try {
|
||||
const finalShinTrimmed = csgEvaluator.evaluate(totalShinSlab, totalInner, SUBTRACTION)
|
||||
prepareBrushForCSG(finalShinTrimmed)
|
||||
const finalDeckTrimmed = csgEvaluator.evaluate(totalDeckSlab, totalInner, SUBTRACTION)
|
||||
prepareBrushForCSG(finalDeckTrimmed)
|
||||
const finalWallTrimmed = csgEvaluator.evaluate(totalWall, totalInner, SUBTRACTION)
|
||||
prepareBrushForCSG(finalWallTrimmed)
|
||||
|
||||
const shinDeck = csgEvaluator.evaluate(finalShinTrimmed, finalDeckTrimmed, ADDITION)
|
||||
prepareBrushForCSG(shinDeck)
|
||||
const combined = csgEvaluator.evaluate(shinDeck, finalWallTrimmed, ADDITION)
|
||||
prepareBrushForCSG(combined)
|
||||
|
||||
const resultGeo = csgGeometry(combined)
|
||||
if (geometryHasNaNPositions(resultGeo)) {
|
||||
@@ -472,7 +484,7 @@ function updateMergedRoofGeometry(
|
||||
}
|
||||
|
||||
resultGeo.computeVertexNormals()
|
||||
ensureUv2Attribute(resultGeo)
|
||||
ensureRenderableGeometryAttributes(resultGeo)
|
||||
mergedMesh.geometry.dispose()
|
||||
mergedMesh.geometry = resultGeo
|
||||
|
||||
@@ -765,6 +777,7 @@ export function getRoofSegmentBrushes(
|
||||
// when a group exists but covers no triangles (can happen after mergeVertices)
|
||||
geo.groups = geo.groups.filter((g) => g.count > 0)
|
||||
if (geo.groups.length === 0) return null
|
||||
ensureRenderableGeometryAttributes(geo)
|
||||
computeGeometryBoundsTree(geo)
|
||||
const brush = new Brush(geo, dummyMats)
|
||||
brush.updateMatrixWorld()
|
||||
@@ -810,7 +823,9 @@ export function getRoofSegmentBrushes(
|
||||
if (deckTopBrush && deckBotBrush && wallBrush && innerBrush && shinTopBrush && shinBotBrush) {
|
||||
try {
|
||||
const deckSlab = csgEvaluator.evaluate(deckTopBrush, deckBotBrush, SUBTRACTION)
|
||||
prepareBrushForCSG(deckSlab)
|
||||
const shinSlab = csgEvaluator.evaluate(shinTopBrush, shinBotBrush, SUBTRACTION)
|
||||
prepareBrushForCSG(shinSlab)
|
||||
|
||||
deckTopBrush.geometry.dispose()
|
||||
deckBotBrush.geometry.dispose()
|
||||
@@ -852,8 +867,11 @@ export function generateRoofSegmentGeometry(
|
||||
|
||||
try {
|
||||
const hollowWall = csgEvaluator.evaluate(wallBrush, innerBrush, SUBTRACTION)
|
||||
prepareBrushForCSG(hollowWall)
|
||||
const shinDeck = csgEvaluator.evaluate(shinSlab, deckSlab, ADDITION)
|
||||
prepareBrushForCSG(shinDeck)
|
||||
const combined = csgEvaluator.evaluate(shinDeck, hollowWall, ADDITION)
|
||||
prepareBrushForCSG(combined)
|
||||
|
||||
resultGeo = csgGeometry(combined)
|
||||
|
||||
@@ -889,7 +907,7 @@ export function generateRoofSegmentGeometry(
|
||||
innerBrush.geometry.dispose()
|
||||
|
||||
resultGeo.computeVertexNormals()
|
||||
ensureUv2Attribute(resultGeo)
|
||||
ensureRenderableGeometryAttributes(resultGeo)
|
||||
return resultGeo
|
||||
}
|
||||
|
||||
@@ -1296,7 +1314,7 @@ function createGeometryFromFaces(
|
||||
const mergedGeo = mergeVertices(geometry, 1e-4)
|
||||
geometry.dispose()
|
||||
|
||||
ensureUv2Attribute(mergedGeo)
|
||||
ensureRenderableGeometryAttributes(mergedGeo)
|
||||
return mergedGeo
|
||||
}
|
||||
|
||||
@@ -1330,13 +1348,6 @@ function pushRoofUv(uvs: number[], point: THREE.Vector3, normal: THREE.Vector3)
|
||||
uvs.push(_uvFaceNormal.z >= 0 ? point.x : -point.x, -point.y)
|
||||
}
|
||||
|
||||
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
|
||||
const uv = geometry.getAttribute('uv')
|
||||
if (!uv) return
|
||||
|
||||
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2))
|
||||
}
|
||||
|
||||
// ─── Skylight cutout ─────────────────────────────────────────────────
|
||||
export type SurfaceFrame = {
|
||||
point: THREE.Vector3
|
||||
|
||||
@@ -533,6 +533,7 @@ function createEmptyGeometry(): THREE.BufferGeometry {
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
|
||||
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
|
||||
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2))
|
||||
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(new Float32Array(6), 2))
|
||||
geometry.addGroup(0, 0, STAIR_TREAD_MATERIAL_INDEX)
|
||||
geometry.addGroup(0, 0, STAIR_SIDE_MATERIAL_INDEX)
|
||||
return geometry
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
|
||||
// depend on @types/bun so the import type is unresolved at compile time.
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { DoorNode, WindowNode } from '@pascal-app/core'
|
||||
import type * as THREE from 'three'
|
||||
import {
|
||||
buildOpeningCutoutGeometry,
|
||||
buildOpeningCutoutShape,
|
||||
hasFlatOpeningCutoutBottom,
|
||||
} from './opening-cutout-geometry'
|
||||
|
||||
function containsPoint(points: THREE.Vector2[], x: number, y: number) {
|
||||
return points.some((point) => Math.abs(point.x - x) < 1e-6 && Math.abs(point.y - y) < 1e-6)
|
||||
}
|
||||
|
||||
function getBounds(points: THREE.Vector2[]) {
|
||||
let minX = Number.POSITIVE_INFINITY
|
||||
let maxX = Number.NEGATIVE_INFINITY
|
||||
let minY = Number.POSITIVE_INFINITY
|
||||
let maxY = Number.NEGATIVE_INFINITY
|
||||
for (const point of points) {
|
||||
minX = Math.min(minX, point.x)
|
||||
maxX = Math.max(maxX, point.x)
|
||||
minY = Math.min(minY, point.y)
|
||||
maxY = Math.max(maxY, point.y)
|
||||
}
|
||||
return { minX, maxX, minY, maxY }
|
||||
}
|
||||
|
||||
describe('buildOpeningCutoutShape', () => {
|
||||
test('rectangle profile passes the rect through unchanged', () => {
|
||||
const door = DoorNode.parse({})
|
||||
const rect = { left: 1.2, right: 2.1, bottom: 0, top: 2.1 }
|
||||
|
||||
const points = buildOpeningCutoutShape(door, rect).getPoints()
|
||||
|
||||
expect(containsPoint(points, rect.left, rect.bottom)).toBe(true)
|
||||
expect(containsPoint(points, rect.right, rect.bottom)).toBe(true)
|
||||
expect(containsPoint(points, rect.right, rect.top)).toBe(true)
|
||||
expect(containsPoint(points, rect.left, rect.top)).toBe(true)
|
||||
|
||||
const bounds = getBounds(points)
|
||||
expect(bounds.minX).toBeCloseTo(rect.left, 9)
|
||||
expect(bounds.maxX).toBeCloseTo(rect.right, 9)
|
||||
expect(bounds.minY).toBeCloseTo(rect.bottom, 9)
|
||||
expect(bounds.maxY).toBeCloseTo(rect.top, 9)
|
||||
})
|
||||
|
||||
test('door rounded profile rounds only the top corners', () => {
|
||||
const door = DoorNode.parse({ openingShape: 'rounded', cornerRadius: 0.2 })
|
||||
const rect = { left: -0.45, right: 0.45, bottom: 0, top: 2.1 }
|
||||
|
||||
const points = buildOpeningCutoutShape(door, rect).getPoints()
|
||||
|
||||
expect(containsPoint(points, rect.left, rect.bottom)).toBe(true)
|
||||
expect(containsPoint(points, rect.right, rect.bottom)).toBe(true)
|
||||
expect(containsPoint(points, rect.left, rect.top)).toBe(false)
|
||||
expect(containsPoint(points, rect.right, rect.top)).toBe(false)
|
||||
expect(containsPoint(points, rect.left, rect.top - 0.2)).toBe(true)
|
||||
expect(containsPoint(points, rect.left + 0.2, rect.top)).toBe(true)
|
||||
expect(containsPoint(points, rect.right, rect.top - 0.2)).toBe(true)
|
||||
expect(containsPoint(points, rect.right - 0.2, rect.top)).toBe(true)
|
||||
})
|
||||
|
||||
test('window rounded profile rounds all four corners', () => {
|
||||
const window = WindowNode.parse({ openingShape: 'rounded', cornerRadius: 0.2 })
|
||||
const rect = { left: -0.75, right: 0.75, bottom: 0.9, top: 2.4 }
|
||||
|
||||
const points = buildOpeningCutoutShape(window, rect).getPoints()
|
||||
|
||||
expect(containsPoint(points, rect.left, rect.bottom)).toBe(false)
|
||||
expect(containsPoint(points, rect.right, rect.bottom)).toBe(false)
|
||||
expect(containsPoint(points, rect.left, rect.top)).toBe(false)
|
||||
expect(containsPoint(points, rect.right, rect.top)).toBe(false)
|
||||
expect(containsPoint(points, rect.left + 0.2, rect.bottom)).toBe(true)
|
||||
expect(containsPoint(points, rect.left, rect.bottom + 0.2)).toBe(true)
|
||||
expect(containsPoint(points, rect.right - 0.2, rect.top)).toBe(true)
|
||||
expect(containsPoint(points, rect.right, rect.top - 0.2)).toBe(true)
|
||||
|
||||
const bounds = getBounds(points)
|
||||
expect(bounds.minX).toBeCloseTo(rect.left, 9)
|
||||
expect(bounds.maxX).toBeCloseTo(rect.right, 9)
|
||||
expect(bounds.minY).toBeCloseTo(rect.bottom, 9)
|
||||
expect(bounds.maxY).toBeCloseTo(rect.top, 9)
|
||||
})
|
||||
|
||||
test('shared corner radius is clamped to the opening half-extent', () => {
|
||||
const window = WindowNode.parse({ openingShape: 'rounded', cornerRadius: 10 })
|
||||
const rect = { left: -0.75, right: 0.75, bottom: 0.9, top: 2.4 }
|
||||
|
||||
const points = buildOpeningCutoutShape(window, rect).getPoints()
|
||||
|
||||
// 1.5 × 1.5 opening → radius clamps to 0.75; arcs meet at edge midpoints.
|
||||
expect(containsPoint(points, rect.left, rect.bottom + 0.75)).toBe(true)
|
||||
expect(containsPoint(points, rect.right, rect.top - 0.75)).toBe(true)
|
||||
|
||||
const bounds = getBounds(points)
|
||||
expect(bounds.minX).toBeCloseTo(rect.left, 9)
|
||||
expect(bounds.maxX).toBeCloseTo(rect.right, 9)
|
||||
})
|
||||
|
||||
test('individual radii normalize when their sum exceeds the opening width', () => {
|
||||
const window = WindowNode.parse({
|
||||
openingShape: 'rounded',
|
||||
openingRadiusMode: 'individual',
|
||||
openingCornerRadii: [4, 4, 0, 0],
|
||||
})
|
||||
const rect = { left: -0.5, right: 0.5, bottom: 0, top: 2 }
|
||||
|
||||
const points = buildOpeningCutoutShape(window, rect).getPoints()
|
||||
|
||||
// Width 1 with top radii summing to 8 → scaled down to 0.5 each.
|
||||
expect(containsPoint(points, rect.left, rect.top - 0.5)).toBe(true)
|
||||
expect(containsPoint(points, rect.left + 0.5, rect.top)).toBe(true)
|
||||
expect(containsPoint(points, rect.left, rect.bottom)).toBe(true)
|
||||
expect(containsPoint(points, rect.right, rect.bottom)).toBe(true)
|
||||
})
|
||||
|
||||
test('arch profile springs at top - archHeight and peaks at the rect top', () => {
|
||||
const door = DoorNode.parse({ openingShape: 'arch', archHeight: 0.45 })
|
||||
const rect = { left: -0.45, right: 0.45, bottom: 0, top: 2.1 }
|
||||
|
||||
const points = buildOpeningCutoutShape(door, rect).getPoints()
|
||||
|
||||
expect(containsPoint(points, rect.left, rect.bottom)).toBe(true)
|
||||
expect(containsPoint(points, rect.right, rect.bottom)).toBe(true)
|
||||
expect(containsPoint(points, rect.right, rect.top - 0.45)).toBe(true)
|
||||
expect(containsPoint(points, 0, rect.top)).toBe(true)
|
||||
expect(containsPoint(points, rect.left, rect.top)).toBe(false)
|
||||
expect(containsPoint(points, rect.right, rect.top)).toBe(false)
|
||||
})
|
||||
|
||||
test('profiles are origin-agnostic — offset rects yield translated points', () => {
|
||||
const window = WindowNode.parse({ openingShape: 'rounded', cornerRadius: 0.2 })
|
||||
const centered = buildOpeningCutoutShape(window, {
|
||||
left: -0.75,
|
||||
right: 0.75,
|
||||
bottom: -0.75,
|
||||
top: 0.75,
|
||||
}).getPoints()
|
||||
const offset = buildOpeningCutoutShape(window, {
|
||||
left: 2.25,
|
||||
right: 3.75,
|
||||
bottom: 0.9,
|
||||
top: 2.4,
|
||||
}).getPoints()
|
||||
|
||||
for (const point of centered) {
|
||||
expect(containsPoint(offset, point.x + 3, point.y + 1.65)).toBe(true)
|
||||
}
|
||||
for (const point of offset) {
|
||||
expect(containsPoint(centered, point.x - 3, point.y - 1.65)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildOpeningCutoutGeometry', () => {
|
||||
test('extrudes the rect through the depth, centered on the mid-plane', () => {
|
||||
const door = DoorNode.parse({})
|
||||
const geometry = buildOpeningCutoutGeometry(
|
||||
door,
|
||||
{ left: -0.45, right: 0.45, bottom: -1.05, top: 1.05 },
|
||||
0.24,
|
||||
0.1,
|
||||
)
|
||||
|
||||
geometry.computeBoundingBox()
|
||||
// Float32 position buffer → ~1e-7 relative precision.
|
||||
const box = geometry.boundingBox!
|
||||
expect(box.min.x).toBeCloseTo(-0.45, 6)
|
||||
expect(box.max.x).toBeCloseTo(0.45, 6)
|
||||
expect(box.min.y).toBeCloseTo(-1.05, 6)
|
||||
expect(box.max.y).toBeCloseTo(1.05, 6)
|
||||
expect(box.min.z).toBeCloseTo(-0.12, 6)
|
||||
expect(box.max.z).toBeCloseTo(0.12, 6)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasFlatOpeningCutoutBottom', () => {
|
||||
test('flat for rectangles, arches, and door rounded (top-only radii)', () => {
|
||||
expect(hasFlatOpeningCutoutBottom(DoorNode.parse({}))).toBe(true)
|
||||
expect(hasFlatOpeningCutoutBottom(WindowNode.parse({ openingShape: 'arch' }))).toBe(true)
|
||||
expect(hasFlatOpeningCutoutBottom(DoorNode.parse({ openingShape: 'rounded' }))).toBe(true)
|
||||
})
|
||||
|
||||
test('rounded windows depend on their bottom radii', () => {
|
||||
expect(hasFlatOpeningCutoutBottom(WindowNode.parse({ openingShape: 'rounded' }))).toBe(false)
|
||||
expect(
|
||||
hasFlatOpeningCutoutBottom(WindowNode.parse({ openingShape: 'rounded', cornerRadius: 0 })),
|
||||
).toBe(true)
|
||||
expect(
|
||||
hasFlatOpeningCutoutBottom(
|
||||
WindowNode.parse({
|
||||
openingShape: 'rounded',
|
||||
openingRadiusMode: 'individual',
|
||||
openingCornerRadii: [0.2, 0.2, 0, 0],
|
||||
}),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
hasFlatOpeningCutoutBottom(
|
||||
WindowNode.parse({
|
||||
openingShape: 'rounded',
|
||||
openingRadiusMode: 'individual',
|
||||
openingCornerRadii: [0.2, 0.2, 0.2, 0.2],
|
||||
}),
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,224 @@
|
||||
import type { DoorNode, WindowNode } from '@pascal-app/core'
|
||||
import * as THREE from 'three'
|
||||
|
||||
export type OpeningCutoutNode = DoorNode | WindowNode
|
||||
|
||||
export type OpeningCutoutRect = {
|
||||
left: number
|
||||
right: number
|
||||
bottom: number
|
||||
top: number
|
||||
}
|
||||
|
||||
type CornerRadii = {
|
||||
topLeft: number
|
||||
topRight: number
|
||||
bottomRight: number
|
||||
bottomLeft: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure cutout profile for a shaped door / window opening. `rect` is in
|
||||
* the caller's coordinate frame — the wall CSG pipeline passes wall-local
|
||||
* coords, the roof-wall pipeline an origin-centered rect — so the same
|
||||
* radii / arch math serves both hosts.
|
||||
*/
|
||||
export function buildOpeningCutoutShape(
|
||||
opening: OpeningCutoutNode,
|
||||
rect: OpeningCutoutRect,
|
||||
): THREE.Shape {
|
||||
const { left, right, bottom, top } = rect
|
||||
const width = Math.max(right - left, 1e-6)
|
||||
const height = Math.max(top - bottom, 1e-6)
|
||||
const shape = new THREE.Shape()
|
||||
|
||||
if (opening.openingShape === 'arch') {
|
||||
const halfWidth = width / 2
|
||||
const centerX = (left + right) / 2
|
||||
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
|
||||
}
|
||||
|
||||
export function buildOpeningCutoutGeometry(
|
||||
opening: OpeningCutoutNode,
|
||||
rect: OpeningCutoutRect,
|
||||
depth: number,
|
||||
wallThickness: number,
|
||||
): THREE.BufferGeometry {
|
||||
const shape = buildOpeningCutoutShape(opening, rect)
|
||||
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)
|
||||
return geometry
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the cutout profile's bottom edge is a flat chord. Cuts whose
|
||||
* bottom sits coplanar with the host wall base get extended slightly
|
||||
* downward to keep CSG away from coplanar faces — but only a flat chord
|
||||
* may extend; shifting a rounded bottom would distort the profile.
|
||||
*/
|
||||
export function hasFlatOpeningCutoutBottom(opening: OpeningCutoutNode): boolean {
|
||||
if (opening.openingShape !== 'rounded' || opening.type !== 'window') return true
|
||||
|
||||
if (opening.openingRadiusMode === 'individual') {
|
||||
const [, , bottomRight = 0, bottomLeft = 0] = opening.openingCornerRadii ?? [
|
||||
0.15, 0.15, 0.15, 0.15,
|
||||
]
|
||||
return bottomRight <= 1e-6 && bottomLeft <= 1e-6
|
||||
}
|
||||
|
||||
return Math.max(opening.cornerRadius ?? 0.15, 0) <= 1e-6
|
||||
}
|
||||
|
||||
function getRoundedOpeningRadii(
|
||||
opening: OpeningCutoutNode,
|
||||
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()
|
||||
}
|
||||
@@ -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