Files
editor/packages/nodes/src/shared/move-roof-tool.tsx
T

520 lines
19 KiB
TypeScript

import {
type AnyNodeId,
collectAlignmentAnchors,
emitter,
type FenceNode,
type GridEvent,
type LevelNode,
movingAlignmentAnchors,
nodeRegistry,
type RoofNode,
type RoofSegmentNode,
resolveAlignment,
type StairNode,
type StairSegmentNode,
sceneRegistry,
useLiveTransforms,
useScene,
type WallNode,
} from '@pascal-app/core'
import {
CursorSphere,
commitFreshPlacementSubtree,
consumePlacementDragRelease,
DragBoundingBox,
getFloorStackPreviewPosition,
resolvePlanarCursorPosition,
snapFenceDraftPoint,
stripPlacementMetadataFlags,
triggerSFX,
useAlignmentGuides,
useEditor,
useFreshPlacementVisibility,
type WallPlanPoint,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import * as THREE from 'three'
/** Figma-style alignment-snap threshold (meters), matching the other tools. */
const ALIGNMENT_THRESHOLD_M = 0.08
export const MoveRoofTool: React.FC<{
node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode
}> = ({ node: movingNode }) => {
const {
isFreshPlacement,
previewVisible: cursorVisible,
revealFreshPlacement,
useAbsoluteCursorPlacement,
} = useFreshPlacementVisibility({
node: movingNode,
enabled: movingNode.type === 'roof' || movingNode.type === 'stair',
})
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
const previousGridPosRef = useRef<[number, number] | null>(null)
const dragAnchorRef = useRef<[number, number] | null>(null)
const [previewRotation, setPreviewRotation] = useState<number>(movingNode.rotation as number)
const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => {
const obj = sceneRegistry.nodes.get(movingNode.id)
if (obj) {
const worldPos = obj.getWorldPosition(new THREE.Vector3())
// Cursor renders inside the building-local ToolManager group, so convert
// world → building-local to honor any building rotation.
const buildingId = useViewer.getState().selection.buildingId
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
if (buildingObj) buildingObj.worldToLocal(worldPos)
return [worldPos.x, worldPos.y, worldPos.z]
}
// Fallback if not registered (e.g. newly created duplicate without mesh yet)
if (
(movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') &&
movingNode.parentId
) {
const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId]
if (parentNode && 'position' in parentNode && 'rotation' in parentNode) {
const parentAngle = parentNode.rotation as number
const px = parentNode.position[0] as number
const py = parentNode.position[1] as number
const pz = parentNode.position[2] as number
const lx = movingNode.position[0]
const ly = movingNode.position[1]
const lz = movingNode.position[2]
const wx = lx * Math.cos(parentAngle) - lz * Math.sin(parentAngle) + px
const wz = lx * Math.sin(parentAngle) + lz * Math.cos(parentAngle) + pz
return [wx, py + ly, wz]
}
}
return [movingNode.position[0], movingNode.position[1], movingNode.position[2]]
})
useEffect(() => {
useScene.temporal.getState().pause()
dragAnchorRef.current = null
previousGridPosRef.current = null
const isNew = isFreshPlacement
const committedMeta = stripPlacementMetadataFlags(movingNode.metadata) as RoofNode['metadata']
const original = {
position: [...movingNode.position] as [number, number, number],
rotation: movingNode.rotation,
parentId: movingNode.parentId,
metadata: movingNode.metadata,
}
// Track whether the move was committed so cleanup knows whether to revert.
// We avoid setting isTransient on the store to prevent RoofSystem from
// resetting the mesh position (it resets on dirty) and from triggering
// expensive merged-mesh CSG rebuilds on every frame.
let wasCommitted = false
let wasCancelled = false
let hasMoved = false
// Track pending rotation — no store updates during drag
let pendingRotation: number = movingNode.rotation as number
let lastLocalPosition: [number, number, number] = [
movingNode.position[0],
movingNode.position[1],
movingNode.position[2],
]
// For roof-segment moves: the selection was cleared before entering move mode,
// so isSelected=false on the parent roof, hiding individual segment meshes and
// showing only the merged mesh. We directly flip Three.js visibility so the
// user sees the individual segment tracking the cursor.
let segmentWrapperGroup: THREE.Object3D | null = null
let mergedRoofMesh: THREE.Object3D | null = null
if (movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') {
const segmentMesh = sceneRegistry.nodes.get(movingNode.id)
if (segmentMesh?.parent) {
// segmentMesh.parent = <group visible={isSelected}> wrapper in Roof/StairRenderer
// segmentMesh.parent.parent = the registered roof/stair group
segmentWrapperGroup = segmentMesh.parent
const mergedName = movingNode.type === 'stair-segment' ? 'merged-stair' : 'merged-roof'
mergedRoofMesh = segmentMesh.parent.parent?.getObjectByName(mergedName) ?? null
segmentWrapperGroup.visible = true
if (mergedRoofMesh) mergedRoofMesh.visible = false
}
}
const resolveLevelId = () => {
if (movingNode.type === 'roof' || movingNode.type === 'stair') {
return movingNode.parentId ?? null
}
if (
(movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') &&
movingNode.parentId
) {
const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId]
return parentNode && 'parentId' in parentNode ? (parentNode.parentId ?? null) : null
}
return null
}
const levelId = resolveLevelId()
const isFloorPlaced = nodeRegistry.get(movingNode.type)?.capabilities?.floorPlaced !== undefined
const getPreviewPosition = (
position: [number, number, number],
rotation = pendingRotation,
): [number, number, number] => {
if (!isFloorPlaced) return position
return getFloorStackPreviewPosition({
node: movingNode,
position,
rotation,
levelId,
nodes: useScene.getState().nodes,
})
}
const levelNode =
levelId && useScene.getState().nodes[levelId as AnyNodeId]?.type === 'level'
? (useScene.getState().nodes[levelId as AnyNodeId] as LevelNode)
: null
const levelChildren = levelNode?.children ?? []
const levelWalls = levelChildren
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
.filter((node): node is WallNode => node?.type === 'wall')
const levelFences = levelChildren
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
.filter((node): node is FenceNode => node?.type === 'fence')
const buildingId = useViewer.getState().selection.buildingId
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
// Alignment for top-level stair / roof only. Segments live in parent-local
// space (a different frame from the building-local candidate pool / guide
// layer), so we leave them on the plain grid+corner snap. Both stair and
// roof align by their footprint bounding-box corners.
const alignTopLevel = movingNode.type === 'stair' || movingNode.type === 'roof'
const alignmentCandidates = alignTopLevel
? collectAlignmentAnchors(
useScene.getState().nodes,
movingNode.id,
movingNode.type === 'stair' ? levelId : undefined,
)
: []
const alignLocalPoint = (lx: number, lz: number, bypass: boolean): [number, number] => {
if (!alignTopLevel || bypass || alignmentCandidates.length === 0) {
useAlignmentGuides.getState().clear()
return [lx, lz]
}
const moving =
movingNode.type === 'stair' || movingNode.type === 'roof'
? movingAlignmentAnchors(movingNode, useScene.getState().nodes, lx, lz, pendingRotation)
: []
const ar = resolveAlignment({
moving:
moving.length > 0 ? moving : [{ nodeId: movingNode.id, kind: 'corner', x: lx, z: lz }],
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
useAlignmentGuides.getState().set(ar.guides)
return ar.snap ? [lx + ar.snap.dx, lz + ar.snap.dz] : [lx, lz]
}
const localToWorldPoint = (localPoint: WallPlanPoint, y: number): [number, number, number] => {
if (buildingObj) {
const worldPoint = buildingObj.localToWorld(
new THREE.Vector3(localPoint[0], y, localPoint[1]),
)
return [worldPoint.x, worldPoint.y, worldPoint.z]
}
return [localPoint[0], y, localPoint[1]]
}
const computeLocal = (
gridX: number,
gridZ: number,
y: number,
buildingLocalX: number,
buildingLocalZ: number,
): [number, number] => {
// Segments have a transformed parent (stair/roof). Convert world → parent-local
// via Three.js hierarchy so the segment's stored position stays parent-relative.
if (
(movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') &&
movingNode.parentId
) {
const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId]
if (parentNode && 'position' in parentNode && 'rotation' in parentNode) {
const parentObj = sceneRegistry.nodes.get(movingNode.parentId)
if (parentObj) {
const worldVec = new THREE.Vector3(gridX, y, gridZ)
parentObj.worldToLocal(worldVec)
return [worldVec.x, worldVec.z]
}
const dx = gridX - (parentNode.position[0] as number)
const dz = gridZ - (parentNode.position[2] as number)
const angle = -(parentNode.rotation as number)
return [
dx * Math.cos(angle) - dz * Math.sin(angle),
dx * Math.sin(angle) + dz * Math.cos(angle),
]
}
}
// Stair/roof live directly in the level — their stored position is building-local.
// event.localPosition is already building-local, so using it handles building rotation.
return [buildingLocalX, buildingLocalZ]
}
const localPositionToToolLocal = (
position: [number, number, number],
): [number, number, number] => {
if (
(movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') &&
movingNode.parentId
) {
const parentObj = sceneRegistry.nodes.get(movingNode.parentId)
if (parentObj) {
const point = parentObj.localToWorld(new THREE.Vector3(...position))
if (buildingObj) buildingObj.worldToLocal(point)
return [point.x, point.y, point.z]
}
}
return position
}
const onGridMove = (event: GridEvent) => {
hasMoved = true
revealFreshPlacement()
const y = event.position[1]
const snappedLocal = snapFenceDraftPoint({
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(
rawGridX,
rawGridZ,
y,
snappedLocal[0],
snappedLocal[1],
)
const resolved = resolvePlanarCursorPosition({
cursor: [rawLocalX, rawLocalZ],
original: [movingNode.position[0], movingNode.position[2]],
anchor: dragAnchorRef.current,
mode: useAbsoluteCursorPlacement ? 'absolute' : 'relative',
})
dragAnchorRef.current = resolved.anchor
let [localX, localZ] = resolved.point
if (alignTopLevel) {
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])
) {
triggerSFX('sfx:grid-snap')
}
previousGridPosRef.current = [localX, localZ]
lastLocalPosition = [localX, movingNode.position[1], localZ]
const previewPosition = getPreviewPosition(lastLocalPosition)
setCursorWorldPos(
isFloorPlaced ? previewPosition : localPositionToToolLocal(lastLocalPosition),
)
// Directly update the Three.js mesh — no store update during drag
const mesh = sceneRegistry.nodes.get(movingNode.id)
if (mesh) {
if (isFloorPlaced) {
mesh.position.set(...previewPosition)
} else {
mesh.position.x = localX
mesh.position.z = localZ
}
}
// Publish canonical position so the 2D floorplan can track the drag.
// Floor-placed parents (stairs) stay in their committed local frame;
// the lifted Y remains presentation-only in the 3D view.
useLiveTransforms.getState().set(movingNode.id, {
position: lastLocalPosition,
rotation: pendingRotation,
})
}
const onGridClick = (event: GridEvent) => {
if (wasCommitted) return
if (!hasMoved) return
const [localX, , localZ] = lastLocalPosition
useAlignmentGuides.getState().clear()
wasCommitted = true
let committedId = movingNode.id as AnyNodeId
if (isNew) {
committedId =
commitFreshPlacementSubtree(movingNode.id as AnyNodeId, {
position: [localX, movingNode.position[1], localZ],
rotation: pendingRotation,
metadata: committedMeta,
visible: true,
}) ?? committedId
} else {
// The store still holds the original values (we didn't update during drag).
// Resume temporal and apply the final state as a single undoable step.
useScene.temporal.getState().resume()
useScene.getState().updateNode(movingNode.id, {
position: [localX, movingNode.position[1], localZ],
rotation: pendingRotation,
metadata: committedMeta,
})
useScene.temporal.getState().pause()
}
triggerSFX('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [committedId] })
useLiveTransforms.getState().clear(movingNode.id)
useEditor.getState().setMovingNodeOrigin('3d')
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
onGridClick({ nativeEvent: event } as unknown as GridEvent)
}
const onCancel = () => {
wasCancelled = true
useLiveTransforms.getState().clear(movingNode.id)
useAlignmentGuides.getState().clear()
if (isNew) {
useScene.getState().deleteNode(movingNode.id)
} else {
useScene.getState().updateNode(movingNode.id, {
position: original.position,
rotation: original.rotation,
metadata: original.metadata,
})
}
useScene.temporal.getState().resume()
exitMoveMode()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
const ROTATION_STEP = Math.PI / 4
let rotationDelta = 0
if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
if (rotationDelta !== 0) {
event.preventDefault()
triggerSFX('sfx:item-rotate')
pendingRotation += rotationDelta
setPreviewRotation(pendingRotation)
// Directly update the Three.js mesh — no store update during drag
const mesh = sceneRegistry.nodes.get(movingNode.id)
if (mesh) {
mesh.rotation.y = pendingRotation
if (isFloorPlaced) {
const previewPosition = getPreviewPosition(lastLocalPosition, pendingRotation)
mesh.position.set(...previewPosition)
setCursorWorldPos(previewPosition)
}
}
// Update live transform rotation for 2D floorplan
const currentLive = useLiveTransforms.getState().get(movingNode.id)
if (currentLive) {
useLiveTransforms.getState().set(movingNode.id, {
...currentLive,
rotation: pendingRotation,
})
}
}
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => {
// Restore segment wrapper visibility (React will re-sync on next render)
if (segmentWrapperGroup) segmentWrapperGroup.visible = false
if (mergedRoofMesh) mergedRoofMesh.visible = true
// Clear ephemeral live transform + any alignment guides
useLiveTransforms.getState().clear(movingNode.id)
useAlignmentGuides.getState().clear()
// Skip restore when the 2D floor-plan overlay claimed teardown
// ownership — same contract `FloorplanRegistryMoveOverlay` uses to
// decide whether to revert its own apply() writes. Without this,
// a stair / roof move committed in the floor plan unmounts this
// tool with `wasCommitted === false` (this tool's own grid-click
// never fired), and the restore below stomps the just-committed
// position back to the snapshot.
const finalisedBy2D = useEditor.getState().movingNodeOrigin === '2d'
if (!(wasCommitted || wasCancelled || isNew || finalisedBy2D)) {
useScene.getState().updateNode(movingNode.id, {
position: original.position,
rotation: original.rotation,
metadata: original.metadata,
})
}
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
}
}, [movingNode, exitMoveMode, isFreshPlacement, revealFreshPlacement, useAbsoluteCursorPlacement])
// Green footprint box during whole-stair / whole-roof moves. Skipped for
// segments — their cursor lives in parent-local space and the bounding box
// would render in the wrong frame.
const showBoundingBox = movingNode.type === 'stair' || movingNode.type === 'roof'
return (
<group visible={cursorVisible}>
<CursorSphere position={cursorWorldPos} showTooltip={false} />
{showBoundingBox && (
<DragBoundingBox
nodeId={movingNode.id}
position={cursorWorldPos}
rotationY={previewRotation}
/>
)}
</group>
)
}
export default MoveRoofTool