Files
editor/packages/nodes/src/roof-segment/floorplan-affordances.ts
T
Aymeric RabotandGitHub aab48e053f 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.
2026-06-11 13:03:34 -04:00

190 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
type AnyNodeId,
type FloorplanAffordance,
type FloorplanMoveTarget,
type RoofNode,
type RoofSegmentNode,
snapScalar,
useScene,
} from '@pascal-app/core'
import { getSegmentGridStep } from '@pascal-app/editor'
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
const MIN_ROOF_DIM = 1
type RoofSegmentResizePayload = { axis: 'x' | 'z'; side: 1 | -1 }
// Resolve world-space center + effective rotation of a roof segment by
// composing the parent roof's position + rotation with the segment's
// local position. Mirrors the floorplan builder's transform so handles
// and affordances stay glued to the rendered footprint.
function resolveSegmentFrame(
segment: RoofSegmentNode,
nodes: Record<AnyNodeId, unknown>,
): {
cx: number
cz: number
roofRot: number
effRot: number
cosRoof: number
sinRoof: number
} {
const roofId = (segment as unknown as { parentId?: AnyNodeId | null }).parentId
const roof = roofId ? (nodes[roofId] as RoofNode | undefined) : undefined
const roofPosX = roof?.position[0] ?? 0
const roofPosZ = roof?.position[2] ?? 0
// Floor-plan plots at `-rotation` so SVG-CW matches Three.js-CCW (see
// `buildRoofSegmentFloorplan` for the rationale). This frame mirrors
// the builder's transform so affordance cx/cz line up with where the
// segment actually renders, and the cursor projection in `effRot`
// works in the same coord system.
const roofRot = -(roof?.rotation ?? 0)
const cosRoof = Math.cos(roofRot)
const sinRoof = Math.sin(roofRot)
const localX = segment.position[0]
const localZ = segment.position[2]
const cx = roofPosX + localX * cosRoof - localZ * sinRoof
const cz = roofPosZ + localX * sinRoof + localZ * cosRoof
const effRot = roofRot + -(segment.rotation ?? 0)
return { cx, cz, roofRot, effRot, cosRoof, sinRoof }
}
/**
* Roof-segment width / depth drag (floor-plan). Mirrors the 3D
* `linear-resize` handles in `definition.ts` — `anchor: 'center'`
* means dragging outward on either +/-X (or +/-Z) edge grows the
* dimension by 2× the segment-local cursor offset while the segment's
* roof-local position stays put. Projects the plan cursor onto the
* segment's effective rotation (roof.rotation + segment.rotation) so
* the math survives any parent-roof rotation.
*/
export const roofSegmentResizeAffordance: FloorplanAffordance<RoofSegmentNode> = {
start({ node, payload, nodes, initialPlanPoint, gridSnapStep }) {
const { axis, side } = payload as RoofSegmentResizePayload
const segmentId = node.id as AnyNodeId
const initialValue = axis === 'x' ? node.width : node.depth
const { cx, cz, effRot } = resolveSegmentFrame(node, nodes)
const cosEff = Math.cos(effRot)
const sinEff = Math.sin(effRot)
// Project (planPoint - center) onto the segment's local X or Z axis
// (world directions of those axes are (cosEff, sinEff) and
// (-sinEff, cosEff)).
const projectLocalAxis = (px: number, pz: number): number => {
const dx = px - cx
const dz = pz - cz
return axis === 'x' ? dx * cosEff + dz * sinEff : -dx * sinEff + dz * cosEff
}
const initialLocal = projectLocalAxis(initialPlanPoint[0], initialPlanPoint[1])
let lastValue = initialValue
return {
affectedIds: [segmentId],
apply({ planPoint, modifiers }) {
const currentLocal = projectLocalAxis(planPoint[0], planPoint[1])
const delta = (currentLocal - initialLocal) * side
const rawValue = initialValue + 2 * delta
const snappedValue =
!modifiers.shiftKey && gridSnapStep > 0 ? snapScalar(rawValue, gridSnapStep) : rawValue
const newValue = Math.max(MIN_ROOF_DIM, snappedValue)
lastValue = newValue
useScene
.getState()
.updateNode(segmentId, axis === 'x' ? { width: newValue } : { depth: newValue })
},
canCommit() {
return true
},
commit() {
useScene
.getState()
.updateNode(segmentId, axis === 'x' ? { width: lastValue } : { depth: lastValue })
},
}
},
}
/**
* Roof-segment rotation drag (floor-plan). Sister to the 3D `arc-resize`
* handle. Same `- delta` convention as the 3D handle: the floor-plan
* builder plots the footprint at `-(roof.rotation + segment.rotation)`
* (see `buildRoofSegmentFloorplan`'s `rotation` local), so the 2D
* view rotates the same direction as 3D for the same `rotation` value,
* and the same cursor gesture writes the same sign in both views.
*/
export const roofSegmentRotateAffordance: FloorplanAffordance<RoofSegmentNode> = {
start({ node, nodes, initialPlanPoint }) {
const segmentId = node.id as AnyNodeId
const initialRotation = node.rotation ?? 0
const { cx, cz } = resolveSegmentFrame(node, nodes)
const initialAngle = Math.atan2(initialPlanPoint[1] - cz, initialPlanPoint[0] - cx)
let lastRotation = initialRotation
return {
affectedIds: [segmentId],
apply({ planPoint }) {
const currentAngle = Math.atan2(planPoint[1] - cz, planPoint[0] - cx)
let delta = currentAngle - initialAngle
while (delta > Math.PI) delta -= 2 * Math.PI
while (delta < -Math.PI) delta += 2 * Math.PI
lastRotation = initialRotation - delta
useScene.getState().updateNode(segmentId, { rotation: lastRotation })
},
canCommit() {
return true
},
commit() {
useScene.getState().updateNode(segmentId, { rotation: lastRotation })
},
}
},
}
/**
* Roof-segment body-move target (floor-plan). The generic Path 2 move
* fallback writes the cursor's plan position straight into `position`,
* which is wrong for roof segments because `position` is **roof-local**
* (the floorplan builder composes parent roof's transform to render).
* This target inverts the parent roof's transform so the segment moves
* to the cursor's WORLD-plan position, not to a roof-local interpretation
* of those world coords. Falls back to identity for orphaned segments.
*/
export const roofSegmentMoveTarget: FloorplanMoveTarget<RoofSegmentNode> = ({ node, nodes }) => {
const segmentId = node.id as AnyNodeId
const initialY = node.position[1]
const { cx, cz, roofRot, cosRoof, sinRoof } = resolveSegmentFrame(node, nodes)
const roofId = (node as unknown as { parentId?: AnyNodeId | null }).parentId
const roof = roofId ? (nodes[roofId] as RoofNode | undefined) : undefined
const roofPosX = roof?.position[0] ?? 0
const roofPosZ = roof?.position[2] ?? 0
const resolveCursor = createFloorplanCursorResolver({
original: [cx, cz],
metadata: node.metadata,
})
// Inverse of the forward transform `[cosRoof, -sinRoof; sinRoof, cosRoof]`
// is `[cosRoof, sinRoof; -sinRoof, cosRoof]`. Used to project world cursor
// back into roof-local coords.
void roofRot
let lastLocal: [number, number, number] = [node.position[0], node.position[1], node.position[2]]
return {
affectedIds: [segmentId],
apply({ planPoint, modifiers }) {
const step = getSegmentGridStep()
const snap = (value: number) => (modifiers.shiftKey ? value : snapScalar(value, step))
const worldPoint = resolveCursor(planPoint, { snap })
const dx = worldPoint[0] - roofPosX
const dz = worldPoint[1] - roofPosZ
let localX = dx * cosRoof + dz * sinRoof
let localZ = -dx * sinRoof + dz * cosRoof
lastLocal = [localX, initialY, localZ]
useScene.getState().updateNode(segmentId, { position: lastLocal })
},
canCommit() {
return true
},
commit() {
useScene.getState().updateNode(segmentId, { position: lastLocal })
},
}
}