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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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