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
@@ -1,4 +1,6 @@
import { describe, expect, test } from 'bun:test'
import { getRoofSegmentSurfaceY, type RoofSegmentNode } from '@pascal-app/core'
import { getDormerExposedFaces } from '../csg-geometry'
import {
buildDormerGhostGeometry,
dormerSupportsArch,
@@ -41,3 +43,74 @@ describe('windowShape predicates', () => {
expect(dormerSupportsCornerRadii(DormerNode.parse({ windowShape: 'rectangle' }))).toBe(false)
})
})
const hostSegment = (overrides?: Partial<RoofSegmentNode>): RoofSegmentNode =>
({
object: 'node',
id: 'rseg_fixture',
type: 'roof-segment',
parentId: null,
visible: true,
metadata: {},
position: [0, 0, 0],
rotation: 0,
roofType: 'gable',
width: 8,
depth: 6,
wallHeight: 0.5,
pitch: 40,
wallThickness: 0.1,
deckThickness: 0.1,
overhang: 0.3,
shingleThickness: 0.05,
...overrides,
}) as RoofSegmentNode
// Default-dims dormer resting on the host surface at (x, z) — mirrors
// `useDormerPlacement`, which anchors dormer-local Y=0 at the cursor's
// surface height.
const dormerAt = (segment: RoofSegmentNode, x: number, z: number, rotation = 0) =>
DormerNode.parse({ position: [x, getRoofSegmentSurfaceY(segment, x, z), z], rotation })
describe('getDormerExposedFaces', () => {
test('default dormer mid-slope on the default 40° gable shows the down-slope window', () => {
const seg = hostSegment()
expect(getDormerExposedFaces(dormerAt(seg, 0, 1.5), seg)).toEqual({ front: true, back: false })
})
test('35° gable mid-slope stays exposed (centre datum, not window bottom)', () => {
const seg = hostSegment({ pitch: 35 })
expect(getDormerExposedFaces(dormerAt(seg, 0, 1.5), seg).front).toBe(true)
})
test('eave band: face hanging past the structural eave keeps the window (no plateau)', () => {
const seg = hostSegment()
expect(getDormerExposedFaces(dormerAt(seg, 0, 2.8), seg).front).toBe(true)
})
test('on the Z slope the back face is the exposed one', () => {
const seg = hostSegment()
expect(getDormerExposedFaces(dormerAt(seg, 0, -1.5), seg)).toEqual({ front: false, back: true })
})
test('hip end-slope: face X feeds the max(fx, fz) profile', () => {
const seg = hostSegment({ roofType: 'hip' })
expect(getDormerExposedFaces(dormerAt(seg, 2.5, 0, Math.PI / 2), seg)).toEqual({
front: true,
back: false,
})
})
test('~10° pitch buries the window on both faces', () => {
const seg = hostSegment({ pitch: 10 })
expect(getDormerExposedFaces(dormerAt(seg, 0, 1.5), seg)).toEqual({ front: false, back: false })
})
test('a π yaw swaps which face is down-slope', () => {
const seg = hostSegment()
expect(getDormerExposedFaces(dormerAt(seg, 0, 1.5, Math.PI), seg)).toEqual({
front: false,
back: true,
})
})
})
+51 -55
View File
@@ -1,7 +1,7 @@
import {
type DormerNode,
getActiveRoofHeight,
getPitchFromActiveRoofHeight,
getRoofSegmentSurfaceY,
ROOF_SHAPE_DEFAULTS,
type RoofSegmentNode,
} from '@pascal-app/core'
@@ -191,73 +191,61 @@ function createDormerWindowCutGeometry(
return new THREE.BoxGeometry(w, h, depth)
}
// Exposure datum: a face shows its window when the window CENTER clears
// the host's structural surface line (≥ half the window visible).
// Gating on the window BOTTOM suppressed the default window on the
// default 40° roof (break-even ≈ 36.7° pitch) and across the whole
// lower-slope/overhang band. A partially buried window reads as a
// window meeting the roof line: the host shingle shell occludes the
// buried frame from outside (the dormer roof cut only clears the inner
// cavity, 5cm short of the gable face), and the glass panes span the
// full opening so the wall cut never reads as a see-through hole. The
// margin only absorbs float noise at the grazing boundary — suppress
// only when the window is truly unplaceable.
const WINDOW_CENTER_MIN_CLEARANCE = 0.01
/**
* Which gable faces of a dormer have a *fully visible window opening*
* (not clipped by the host roof slope). "front" = mesh-local +Z,
* "back" = mesh-local Z (after the +π/2 yaw bake for non-shed roofs).
* Which gable faces of a dormer have a visible window opening.
* "front" = mesh-local +Z, "back" = mesh-local Z (after the +π/2 yaw
* bake for non-shed roofs).
*
* The criterion is window-bottom-above-slope, not wall-top-above-slope:
* the dormer wall extends well below the window into the skirt that's
* buried inside the roof, so checking just "does any wall poke above
* the slope" is far too lenient — a dormer whose eave barely clears
* the roof would pass even though the entire window (which sits inside
* the skirt, well below the eave) is buried. Switching to the window
* bottom collapses both the CSG window-cut decision (which calls into
* this function in `generateDormerGeometry`) and the live render gate
* (window-assembly.tsx) onto the right line: the window only renders
* where it's actually visible from outside.
* Each face centre is lifted into segment-local X *and* Z (the yaw
* matters, and on hip hosts the end slopes fall along X) and compared
* against the host's canonical per-type surface line via
* `getRoofSegmentSurfaceY`, which extrapolates past the structural
* eave instead of plateauing at the wall top — a face hanging in free
* air past the eave keeps dropping. Gates both the CSG window-cut
* decision (`generateDormerGeometry`) and the live render
* (window-assembly.tsx).
*/
export function getDormerExposedFaces(
dormer: DormerNode,
hostSegment: RoofSegmentNode,
): { front: boolean; back: boolean } {
const halfDepth = dormer.depth / 2
const dormerZ = dormer.position[2] ?? 0
const dormerX = dormer.position[0] ?? 0
const dormerY = dormer.position[1] ?? 0
const dormerZ = dormer.position[2] ?? 0
const rot = dormer.rotation ?? 0
// Gable-face centres in segment-local Z (accounts for dormer yaw).
const frontZ = dormerZ + halfDepth * Math.cos(rot)
const backZ = dormerZ - halfDepth * Math.cos(rot)
// Gable-face centres in segment-local X/Z (accounts for dormer yaw).
const faceDX = halfDepth * Math.sin(rot)
const faceDZ = halfDepth * Math.cos(rot)
// Window bottom in dormer-local Y. Mirrors `getDormerSkirtWindowDims`
// so both functions read the same window position. The window sits
// in the skirt below the eave (dormer-local Y=0), so `centerY` is
// typically negative; subtracting half the window height lands us at
// the bottom edge.
// Window centre in segment-local Y. Mirrors `getDormerSkirtWindowDims`
// so both functions read the same window position: dormer-local Y=0
// sits at `dormer.position[1]` and the window centre sits in the
// skirt at -(skirtH / 2) + windowOffsetY.
const skirtH = dormerSkirtHeight(dormer)
const winH = Math.max(0, dormer.windowHeight ?? 0)
const winOffsetY = dormer.windowOffsetY ?? 0
const windowCenterDormerY = -(skirtH / 2) + winOffsetY
const windowBottomDormerY = windowCenterDormerY - winH / 2
// Lift into segment-local Y: dormer-local Y=0 sits at `dormer.position[1]`.
const windowBottomSegY = dormerY + windowBottomDormerY
const windowCenterSegY = dormerY - skirtH / 2 + (dormer.windowOffsetY ?? 0)
const hostWh = hostSegment.wallHeight ?? 0.5
const hostRh = getActiveRoofHeight(hostSegment)
const hostDepth = hostSegment.depth ?? 4
const clears = (faceX: number, faceZ: number): boolean =>
windowCenterSegY - getRoofSegmentSurfaceY(hostSegment, faceX, faceZ) >
WINDOW_CENTER_MIN_CLEARANCE
const roofHeightAtZ = (segZ: number): number => {
const hostType = hostSegment.roofType ?? 'gable'
if (hostType === 'flat') return hostWh
if (hostType === 'shed') {
const t = Math.max(0, Math.min(1, (segZ + hostDepth / 2) / Math.max(hostDepth, 0.01)))
return hostWh + hostRh * (1 - t)
}
const halfD = Math.max(hostDepth / 2, 0.01)
const t = Math.max(0, Math.min(1, Math.abs(segZ) / halfD))
return hostWh + hostRh * (1 - t)
}
// A face is "exposed" only if the *window bottom* clears the host
// slope at that face's Z by a meaningful amount — borderline cases
// (slope grazing the window bottom) suppress the window so we don't
// render a partially-clipped frame poking out of the roof. 5cm
// matches the threshold the prior wall-top check used.
const minPokeOut = 0.05
return {
front: windowBottomSegY - roofHeightAtZ(frontZ) > minPokeOut,
back: windowBottomSegY - roofHeightAtZ(backZ) > minPokeOut,
front: clears(dormerX + faceDX, dormerZ + faceDZ),
back: clears(dormerX - faceDX, dormerZ - faceDZ),
}
}
@@ -352,12 +340,15 @@ export function generateDormerGeometry(
dormerBrushes.innerBrush,
SUBTRACTION,
) as Brush
prepareBrushForCSG(hollowWall)
const shinDeck = csgEvaluator.evaluate(
dormerBrushes.shinSlab,
dormerBrushes.deckSlab,
ADDITION,
) as Brush
prepareBrushForCSG(shinDeck)
dormerSolid = csgEvaluator.evaluate(shinDeck, hollowWall, ADDITION) as Brush
prepareBrushForCSG(dormerSolid)
hollowWall.geometry.dispose()
shinDeck.geometry.dispose()
@@ -376,7 +367,9 @@ export function generateDormerGeometry(
hostBrushes.deckSlab,
ADDITION,
) as Brush
prepareBrushForCSG(wallPlusDeck)
hostSolid = csgEvaluator.evaluate(wallPlusDeck, hostBrushes.shinSlab, ADDITION) as Brush
prepareBrushForCSG(hostSolid)
wallPlusDeck.geometry.dispose()
hostBrushes.deckSlab.geometry.dispose()
hostBrushes.shinSlab.geometry.dispose()
@@ -393,8 +386,9 @@ export function generateDormerGeometry(
groundBoxGeo.addGroup(0, indexCount, 0)
computeGeometryBoundsTree(groundBoxGeo)
const groundBrush = new Brush(groundBoxGeo, roofCsgDummyMats[0])
groundBrush.updateMatrixWorld()
prepareBrushForCSG(groundBrush)
const fullTrim = csgEvaluator.evaluate(hostSolid, groundBrush, ADDITION) as Brush
prepareBrushForCSG(fullTrim)
hostSolid.geometry.dispose()
groundBrush.geometry.dispose()
hostSolid = fullTrim
@@ -416,6 +410,7 @@ export function generateDormerGeometry(
prepareBrushForCSG(hostSolid)
const trimmed = csgEvaluator.evaluate(dormerSolid, hostSolid, SUBTRACTION) as Brush
prepareBrushForCSG(trimmed)
dormerSolid.geometry.dispose()
hostSolid.geometry.dispose()
hostSolid = null
@@ -447,8 +442,9 @@ export function generateDormerGeometry(
cutGeo.addGroup(0, idxCount, 0)
computeGeometryBoundsTree(cutGeo)
const brush = new Brush(cutGeo, roofCsgDummyMats[0])
brush.updateMatrixWorld()
prepareBrushForCSG(brush)
const result = csgEvaluator.evaluate(dormerSolid!, brush, SUBTRACTION) as Brush
prepareBrushForCSG(result)
dormerSolid!.geometry.dispose()
brush.geometry.dispose()
dormerSolid = result
@@ -557,7 +553,7 @@ export function buildDormerCutShape(
// ends up along mesh-(-Z) and the extrusion ends up along mesh-X.
//
// `getRoofSegmentBrushes`'s shed slope puts the peak at z=-d/2
// and the eave at z=+d/2 (matching the `roofHeightAtZ` helper).
// and the eave at z=+d/2 (matching `getRoofSegmentSurfaceY`).
// After the +π/2 rotation, shape-X=+hd → mesh-Z=-hd, so place the
// PEAK at shape-X=+hd and the EAVE at shape-X=-hd to keep the cut
// aligned with the dormer body's actual slope direction.
@@ -118,7 +118,7 @@ export function useDormerPlacement(opts: {
const sx = Math.round(wx / DORMER_PLACEMENT_SNAP_M) * DORMER_PLACEMENT_SNAP_M
const sz = Math.round(wz / DORMER_PLACEMENT_SNAP_M) * DORMER_PLACEMENT_SNAP_M
const prev = lastSnapRef.current
if (!prev || prev[0] !== sx || prev[1] !== sz) {
if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) {
triggerSFX('sfx:grid-snap')
lastSnapRef.current = [sx, sz]
}
@@ -118,12 +118,10 @@ const DormerWindowAssembly = ({
// non-zero yaw needs to recompute exposure to know which gable
// is now poking above the slope.
node.rotation,
// Window position + height feed `getDormerExposedFaces` now that
// it's gating on window-bottom-above-slope (not wall-top-above-
// slope) — dragging the window down via inspector or the new
// window-height/offset handles must re-evaluate which gable
// still has a fully-visible opening.
node.windowHeight,
// The window's vertical placement feeds `getDormerExposedFaces`
// (gates on the window CENTER clearing the host slope) — dragging
// the window down via inspector or the offset handle must
// re-evaluate which gable still exposes the opening.
node.windowOffsetY,
node.wallSkirtHeight,
],