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