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:
Aymeric Rabot
2026-06-11 13:03:34 -04:00
committed by GitHub
parent 2d2dba5dba
commit aab48e053f
111 changed files with 2211 additions and 955 deletions
+7
View File
@@ -136,6 +136,13 @@ export {
type StairBodyMaterials,
} from './systems/stair/stair-materials'
export { StairSystem } from './systems/stair/stair-system'
// Pure opening-cutout profile math shared by the wall CSG pipeline and
// roof-wall opening cuts in `@pascal-app/nodes` — keeps shaped holes
// (arch / rounded / frameless opening) identical across both hosts.
export {
buildOpeningCutoutGeometry,
hasFlatOpeningCutoutBottom,
} from './systems/wall/opening-cutout-geometry'
export { WallCutout } from './systems/wall/wall-cutout'
export { getVisibleWallMaterials } from './systems/wall/wall-materials'
// Wall internals re-exported so `@pascal-app/nodes`' registry-driven wall
+73
View File
@@ -0,0 +1,73 @@
// @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 * as THREE from 'three'
import { ensureRenderableGeometryAttributes } from './csg-utils'
describe('ensureRenderableGeometryAttributes', () => {
test('fills missing render attributes to match position count', () => {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute(
'position',
new THREE.Float32BufferAttribute(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]), 3),
)
ensureRenderableGeometryAttributes(geometry)
expect(geometry.getAttribute('position')?.count).toBe(3)
expect(geometry.getAttribute('normal')?.count).toBe(3)
expect(geometry.getAttribute('uv')?.count).toBe(3)
expect(geometry.getAttribute('uv2')?.count).toBe(3)
})
test('replaces render attributes with the wrong item size', () => {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute(
'position',
new THREE.Float32BufferAttribute(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]), 3),
)
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array([0, 0, 0]), 1))
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(new Float32Array([0, 0, 0]), 1))
ensureRenderableGeometryAttributes(geometry)
expect(geometry.getAttribute('uv')?.itemSize).toBe(2)
expect(geometry.getAttribute('uv2')?.itemSize).toBe(2)
expect(geometry.getAttribute('uv')?.count).toBe(3)
expect(geometry.getAttribute('uv2')?.count).toBe(3)
})
test('copies uv into uv2 without depending on backing array layout', () => {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute(
'position',
new THREE.Float32BufferAttribute(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]), 3),
)
geometry.setAttribute(
'uv',
new THREE.InterleavedBufferAttribute(
new THREE.InterleavedBuffer(new Float32Array([0, 0, 7, 1, 0, 8, 0, 1, 9]), 3),
2,
0,
),
)
ensureRenderableGeometryAttributes(geometry)
const uv2 = geometry.getAttribute('uv2')
expect(Array.from(uv2.array)).toEqual([0, 0, 1, 0, 0, 1])
})
test('replaces empty geometries with a degenerate renderable triangle', () => {
const geometry = new THREE.BufferGeometry()
ensureRenderableGeometryAttributes(geometry)
expect(geometry.getIndex()).toBeNull()
expect(geometry.groups).toHaveLength(0)
expect(geometry.getAttribute('position')?.count).toBe(3)
expect(geometry.getAttribute('normal')?.count).toBe(3)
expect(geometry.getAttribute('uv')?.count).toBe(3)
expect(geometry.getAttribute('uv2')?.count).toBe(3)
})
})
+77 -3
View File
@@ -1,4 +1,4 @@
import type * as THREE from 'three'
import * as THREE from 'three'
import { type Brush, Evaluator } from 'three-bvh-csg'
import { computeBoundsTree } from 'three-mesh-bvh'
@@ -10,8 +10,81 @@ import { computeBoundsTree } from 'three-mesh-bvh'
* in `@pascal-app/nodes` import these through the public surface.
*/
function zeroAttribute(count: number, itemSize: number) {
return new THREE.Float32BufferAttribute(new Float32Array(count * itemSize), itemSize)
}
function upNormalAttribute(count: number) {
const values = new Float32Array(count * 3)
for (let index = 0; index < count; index += 1) {
values[index * 3 + 1] = 1
}
return new THREE.Float32BufferAttribute(values, 3)
}
function ensureAttributeCount(
geometry: THREE.BufferGeometry,
name: string,
itemSize: number,
count: number,
) {
const attribute = geometry.getAttribute(name)
if (attribute?.count === count && attribute.itemSize === itemSize) return
geometry.setAttribute(name, zeroAttribute(count, itemSize))
}
function copyVec2Attribute(attribute: THREE.BufferAttribute | THREE.InterleavedBufferAttribute) {
const values = new Float32Array(attribute.count * 2)
for (let index = 0; index < attribute.count; index += 1) {
values[index * 2] = attribute.getX(index)
values[index * 2 + 1] = attribute.getY(index)
}
return new THREE.Float32BufferAttribute(values, 2)
}
export function ensureRenderableGeometryAttributes(
geometry: THREE.BufferGeometry,
): THREE.BufferGeometry {
const position = geometry.getAttribute('position')
if (!position || position.count === 0 || position.itemSize !== 3) {
geometry.setIndex(null)
geometry.clearGroups()
geometry.setAttribute('position', zeroAttribute(3, 3))
geometry.setAttribute('normal', upNormalAttribute(3))
geometry.setAttribute('uv', zeroAttribute(3, 2))
geometry.setAttribute('uv2', zeroAttribute(3, 2))
return geometry
}
const count = position.count
const normal = geometry.getAttribute('normal')
if (normal?.count !== count || normal.itemSize !== 3) {
geometry.deleteAttribute('normal')
try {
geometry.computeVertexNormals()
} catch {
geometry.deleteAttribute('normal')
}
}
const computedNormal = geometry.getAttribute('normal')
if (computedNormal?.count !== count || computedNormal.itemSize !== 3) {
geometry.setAttribute('normal', upNormalAttribute(count))
}
ensureAttributeCount(geometry, 'uv', 2, count)
const uv = geometry.getAttribute('uv')
const uv2 = geometry.getAttribute('uv2')
if (uv2?.count !== count || uv2.itemSize !== 2) {
geometry.setAttribute('uv2', copyVec2Attribute(uv))
}
return geometry
}
export function csgGeometry(brush: Brush): THREE.BufferGeometry {
return brush.geometry as unknown as THREE.BufferGeometry
return ensureRenderableGeometryAttributes(brush.geometry as unknown as THREE.BufferGeometry)
}
export function csgMaterials(brush: Brush): THREE.Material[] {
@@ -22,7 +95,7 @@ export function csgMaterials(brush: Brush): THREE.Material[] {
export const csgEvaluator = new Evaluator()
csgEvaluator.useGroups = true
;(csgEvaluator as unknown as { consolidateGroups: boolean }).consolidateGroups = false
csgEvaluator.attributes = ['position', 'normal', 'uv']
csgEvaluator.attributes = ['position', 'normal', 'uv', 'uv2']
export function computeGeometryBoundsTree(geometry: THREE.BufferGeometry) {
;(geometry as unknown as { computeBoundsTree: typeof computeBoundsTree }).computeBoundsTree =
@@ -33,6 +106,7 @@ export function computeGeometryBoundsTree(geometry: THREE.BufferGeometry) {
}
export function prepareBrushForCSG(brush: Brush) {
ensureRenderableGeometryAttributes(brush.geometry)
computeGeometryBoundsTree(brush.geometry)
brush.updateMatrixWorld()
}
+13 -6
View File
@@ -86,10 +86,14 @@ export const glassMaterial = new MeshLambertNodeMaterial({
side: THREE.FrontSide,
})
function resolveNodeMaterialSide(side: THREE.Side): THREE.Side {
return side === THREE.DoubleSide ? THREE.FrontSide : side
}
const sideMap: Record<MaterialProperties['side'], THREE.Side> = {
front: THREE.FrontSide,
back: THREE.BackSide,
double: THREE.DoubleSide,
double: THREE.FrontSide,
}
const materialCache = new Map<string, THREE.Material>()
@@ -366,12 +370,13 @@ function applyMaterialMapProperties(
}
material.transparent = mapProperties.transparent
material.opacity = mapProperties.opacity
material.side =
material.side = resolveNodeMaterialSide(
mapProperties.side === 0
? THREE.FrontSide
: mapProperties.side === 1
? THREE.BackSide
: THREE.DoubleSide
: THREE.DoubleSide,
)
applyTexturePropertiesToMaterial(material, mapProperties)
material.needsUpdate = true
}
@@ -487,10 +492,11 @@ export function createDefaultMaterial(
shading: RenderShading = 'rendered',
side: THREE.Side = THREE.FrontSide,
): THREE.Material {
const resolvedSide = resolveNodeMaterialSide(side)
if (shading === 'solid') {
return new MeshLambertNodeMaterial({
color,
side,
side: resolvedSide,
})
}
@@ -498,7 +504,7 @@ export function createDefaultMaterial(
color,
roughness,
metalness: 0,
side,
side: resolvedSide,
})
}
@@ -532,7 +538,8 @@ export function createSurfaceRoleMaterial(
// on both gable faces on the first frame). Callers that need both sides
// visible (e.g. dormer back gable) must rotate the host mesh 180° so the
// FrontSide faces the viewer.
const resolvedSide = role === 'glazing' ? THREE.FrontSide : side
const resolvedSide =
role === 'glazing' ? THREE.FrontSide : resolveNodeMaterialSide(side ?? THREE.FrontSide)
const cacheKey = `${role}-${preset}-${resolvedSide}-${sceneThemeId ?? 'base'}`
const cached = surfaceRoleMaterialCache.get(cacheKey)
if (cached) return cached
@@ -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()
}
+24 -192
View File
@@ -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()
}