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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user