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
+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