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, 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(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 = 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, }) 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) localX = aligned[0] localZ = aligned[1] } if ( 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 (!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 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) 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) } }, [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 ( {showBoundingBox && ( )} ) } export default MoveRoofTool