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