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
+10 -3
View File
@@ -36,6 +36,7 @@ type FloorPlacementAlignmentArgs = {
gridStep: number
candidates: Parameters<typeof resolveAlignment>[0]['candidates']
bypassAlignment?: boolean
bypassGrid?: boolean
rotationY?: number
}
@@ -45,18 +46,23 @@ export function getLevelLocalSnappedPosition(
levelId: string,
event: FloorPlacementClickTriggerEvent,
gridStep: number,
bypassGrid = false,
): [number, number, number] {
const levelObject = sceneRegistry.nodes.get(levelId)
if (!levelObject) {
const rawPoint = 'node' in event ? event.position : event.localPosition
const [sx, sz] = snapPointToGrid([rawPoint[0], rawPoint[2]], gridStep)
const [sx, sz] = bypassGrid
? [rawPoint[0], rawPoint[2]]
: snapPointToGrid([rawPoint[0], rawPoint[2]], gridStep)
return [sx, 0, sz]
}
worldVector.set(event.position[0], event.position[1], event.position[2])
levelObject.updateWorldMatrix(true, false)
levelObject.worldToLocal(worldVector)
const [sx, sz] = snapPointToGrid([worldVector.x, worldVector.z], gridStep)
const [sx, sz] = bypassGrid
? [worldVector.x, worldVector.z]
: snapPointToGrid([worldVector.x, worldVector.z], gridStep)
return [sx, 0, sz]
}
@@ -67,9 +73,10 @@ export function resolveAlignedFloorPlacement({
gridStep,
candidates,
bypassAlignment = false,
bypassGrid = false,
rotationY = 0,
}: FloorPlacementAlignmentArgs) {
const [sx, sz] = snapPointToGrid([rawX, rawZ], gridStep)
const [sx, sz] = bypassGrid ? [rawX, rawZ] : snapPointToGrid([rawX, rawZ], gridStep)
let ax = sx
let az = sz
+7 -1
View File
@@ -293,6 +293,7 @@ export const MoveRoofTool: React.FC<{
point: [event.localPosition[0], event.localPosition[2]],
walls: levelWalls,
fences: levelFences,
bypassSnap: event.nativeEvent?.shiftKey === true,
})
const [rawGridX, , rawGridZ] = localToWorldPoint(snappedLocal, y)
const [rawLocalX, rawLocalZ] = computeLocal(
@@ -312,12 +313,17 @@ export const MoveRoofTool: React.FC<{
let [localX, localZ] = resolved.point
if (alignTopLevel) {
const aligned = alignLocalPoint(localX, localZ, event.nativeEvent?.altKey === true)
const aligned = alignLocalPoint(
localX,
localZ,
event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
)
localX = aligned[0]
localZ = aligned[1]
}
if (
event.nativeEvent?.shiftKey !== true &&
previousGridPosRef.current &&
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
) {
@@ -24,6 +24,7 @@ export function createPlaceholderGeometry(groupCount = 0): BufferGeometry {
geometry.setAttribute('position', new Float32BufferAttribute(new Float32Array(9), 3))
geometry.setAttribute('normal', new Float32BufferAttribute(new Float32Array(9), 3))
geometry.setAttribute('uv', new Float32BufferAttribute(new Float32Array(6), 2))
geometry.setAttribute('uv2', new Float32BufferAttribute(new Float32Array(6), 2))
for (let group = 0; group < groupCount; group++) {
geometry.addGroup(0, 0, group)
}
@@ -104,7 +104,7 @@ export function createPolygonCentroidMoveTarget(args: {
let dx = target[0] - originalCenter[0]
let dz = target[1] - originalCenter[1]
if (!modifiers.altKey && candidates.length > 0) {
if (!(modifiers.altKey || modifiers.shiftKey) && candidates.length > 0) {
const result = resolveAlignment({
moving: polygonAnchors(id, translatePolygon(originalPolygon, dx, dz)),
candidates,
@@ -0,0 +1,43 @@
import { describe, expect, test } from 'bun:test'
import type { RoofSegmentNode } from '@pascal-app/core'
import { getDownSlopeYaw } from './roof-surface'
const fixtureSegment = (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: 2.5,
pitch: (Math.atan2(2, 3) * 180) / Math.PI,
wallThickness: 0.1,
deckThickness: 0.1,
overhang: 0.3,
shingleThickness: 0.05,
...overrides,
}) as RoofSegmentNode
describe('getDownSlopeYaw', () => {
test('gable +z face: local +z already points down-slope (yaw 0)', () => {
expect(getDownSlopeYaw(0, 1, fixtureSegment())).toBeCloseTo(0)
})
test('gable z face: half-turn so +z faces the z eave (yaw π)', () => {
expect(getDownSlopeYaw(0, -1, fixtureSegment())).toBeCloseTo(Math.PI)
})
test('hip +x face yaws +π/2', () => {
expect(getDownSlopeYaw(2, 0, fixtureSegment({ roofType: 'hip' }))).toBeCloseTo(Math.PI / 2)
})
test('hip x face yaws −π/2', () => {
expect(getDownSlopeYaw(-2, 0, fixtureSegment({ roofType: 'hip' }))).toBeCloseTo(-Math.PI / 2)
})
test('flat segment has no down-slope direction (yaw 0)', () => {
expect(getDownSlopeYaw(0, 0, fixtureSegment({ roofType: 'flat' }))).toBe(0)
})
})
+12
View File
@@ -137,3 +137,15 @@ export function surfaceQuatFromNormal(normal: THREE.Vector3, out: THREE.Quaterni
const m = new THREE.Matrix4().makeBasis(right, normal, forward)
return out.setFromRotationMatrix(m)
}
// Yaw (about the surface normal, composed AFTER `surfaceQuatFromNormal`)
// that points the node's local +Z down the slope. The analytical normals
// are axis-aligned (n.x or n.z is 0), and in the +X-projected basis above
// the down-slope direction decomposes to atan2(n.x · n.y, n.z): +Z face
// → 0, Z → π, +X → +π/2, X → −π/2. Kept next to `surfaceQuatFromNormal`
// so the two stay in lockstep — the formula is only valid for its basis.
export function getDownSlopeYaw(lx: number, lz: number, seg: RoofSegmentNode): number {
const n = getAnalyticalNormal(lx, lz, seg)
if (n.x === 0 && n.z === 0) return 0
return Math.atan2(n.x * n.y, n.z)
}
@@ -1,26 +1,22 @@
import type { RoofSegmentNode, RoofWallFaceId } from '@pascal-app/core'
import type { DoorNode, RoofSegmentNode, WindowNode } from '@pascal-app/core'
import { getRoofWallFaceFrame, roofFacePointToSegment } from '@pascal-app/core'
import { buildOpeningCutoutGeometry, hasFlatOpeningCutoutBottom } from '@pascal-app/viewer'
import * as THREE from 'three'
type RoofWallOpening = {
roofSegmentId?: string
roofFace?: RoofWallFaceId
position: [number, number, number]
width: number
height: number
}
/**
* CSG cut for a door / window hosted on a roof-segment wall face
* (`capabilities.roofAccessory.buildCut`). A box through the wall
* (`capabilities.roofAccessory.buildCut`). The cut goes through the wall
* mid-plane, derived from the CURRENT host geometry (the opening stores
* face-local coords), so the hole follows segment resizes for free.
* Plain rectangles cut a box; shaped openings (arch / rounded /
* frameless `opening` kind) reuse the wall pipeline's cutout profile so
* roof-hosted holes match wall-hosted ones.
*
* Returns null for wall-hosted openings: their cut is handled by the
* wall system's own cutout pipeline.
*/
export function buildRoofWallOpeningCut(
node: RoofWallOpening,
node: DoorNode | WindowNode,
hostSegment: RoofSegmentNode,
): THREE.BufferGeometry | null {
if (!node.roofSegmentId || !node.roofFace) return null
@@ -32,8 +28,10 @@ export function buildRoofWallOpeningCut(
// A door's cut bottom is coplanar with the wall brush base — extend it
// slightly downward so three-bvh-csg never has to clip coplanar faces.
// Only a flat bottom chord may extend; a rounded bottom is never
// coplanar and shifting it would distort the profile.
const bottom = node.position[1] - node.height / 2
const bottomPad = bottom < 0.005 ? 0.02 : 0
const bottomPad = bottom < 0.005 && hasFlatOpeningCutoutBottom(node) ? 0.02 : 0
const center = roofFacePointToSegment(hostSegment, node.roofFace, [
node.position[0],
@@ -42,9 +40,35 @@ export function buildRoofWallOpeningCut(
])
const { yaw } = getRoofWallFaceFrame(hostSegment, node.roofFace)
const geo = new THREE.BoxGeometry(node.width, node.height + bottomPad, depth)
geo.translate(0, -bottomPad / 2, 0)
const geo = buildCutGeometry(node, wallThickness, depth, bottomPad)
geo.rotateY(yaw)
geo.translate(center[0], center[1], center[2])
return geo
}
function buildCutGeometry(
node: DoorNode | WindowNode,
wallThickness: number,
depth: number,
bottomPad: number,
): THREE.BufferGeometry {
const shaped =
node.openingKind === 'opening' ||
node.openingShape === 'arch' ||
node.openingShape === 'rounded'
if (!shaped) {
const geo = new THREE.BoxGeometry(node.width, node.height + bottomPad, depth)
geo.translate(0, -bottomPad / 2, 0)
return geo
}
const halfWidth = node.width / 2
const halfHeight = node.height / 2
return buildOpeningCutoutGeometry(
node,
{ left: -halfWidth, right: halfWidth, bottom: -halfHeight - bottomPad, top: halfHeight },
depth,
wallThickness,
)
}
@@ -21,7 +21,8 @@ const MIN_AXIS_COMPONENT = 0.5
* runs along and map it to the along-wall coordinate that lands the opening on
* it. Falls back to the half-metre snap when nothing aligns, and clears the
* guide on bypass / no-match. Returns the localX to use (X-clamped to the wall
* given `width`). `bypass` (Alt) disables alignment.
* given `width`). `bypass` disables alignment; `bypassSnap` also skips the
* half-metre fallback.
*/
export function resolveWallSlideAlignment(args: {
wallNode: WallNode
@@ -29,9 +30,10 @@ export function resolveWallSlideAlignment(args: {
width: number
candidates: readonly AlignmentAnchor[]
bypass: boolean
bypassSnap?: boolean
}): number {
const { wallNode, rawLocalX, width, candidates, bypass } = args
const base = snapToHalf(rawLocalX)
const { wallNode, rawLocalX, width, candidates, bypass, bypassSnap = false } = args
const base = bypassSnap ? rawLocalX : snapToHalf(rawLocalX)
if (bypass || candidates.length === 0) {
useAlignmentGuides.getState().clear()
return base