From 53720d664f2c2ad12a43e67d5573d29c230cd893 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 21 Apr 2026 11:41:31 +0530 Subject: [PATCH] Align roof and slab dragging to snap targets --- .../components/tools/roof/move-roof-tool.tsx | 74 +++++++++++++++---- .../components/tools/slab/move-slab-tool.tsx | 34 +++++++-- 2 files changed, 88 insertions(+), 20 deletions(-) diff --git a/packages/editor/src/components/tools/roof/move-roof-tool.tsx b/packages/editor/src/components/tools/roof/move-roof-tool.tsx index 72d9220c..502ff0aa 100644 --- a/packages/editor/src/components/tools/roof/move-roof-tool.tsx +++ b/packages/editor/src/components/tools/roof/move-roof-tool.tsx @@ -1,7 +1,9 @@ import { type AnyNodeId, emitter, + type FenceNode, type GridEvent, + type LevelNode, type RoofNode, type RoofSegmentNode, type StairNode, @@ -9,13 +11,16 @@ import { sceneRegistry, useLiveTransforms, useScene, + type WallNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' import * as THREE from 'three' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' +import { snapFenceDraftPoint } from '../fence/fence-drafting' import { CursorSphere } from '../shared/cursor-sphere' +import type { WallPlanPoint } from '../wall/wall-drafting' export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode @@ -118,6 +123,46 @@ export const MoveRoofTool: React.FC<{ } } + 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 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 + + 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, @@ -155,21 +200,21 @@ export const MoveRoofTool: React.FC<{ } const onGridMove = (event: GridEvent) => { - const gridX = Math.round(event.position[0] * 2) / 2 - const gridZ = Math.round(event.position[2] * 2) / 2 const y = event.position[1] - if ( - previousGridPosRef.current && - (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1]) - ) { + const snappedLocal = snapFenceDraftPoint({ + point: [event.localPosition[0], event.localPosition[2]], + walls: levelWalls, + fences: levelFences, + }) + const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y) + + if (previousGridPosRef.current && (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])) { sfxEmitter.emit('sfx:grid-snap') } previousGridPosRef.current = [gridX, gridZ] - // Cursor is inside the building-local ToolManager group — use local position - const lx = Math.round(event.localPosition[0] * 2) / 2 - const lz = Math.round(event.localPosition[2] * 2) / 2 + const [lx, lz] = snappedLocal setCursorWorldPos([lx, event.localPosition[1], lz]) const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz) @@ -189,11 +234,14 @@ export const MoveRoofTool: React.FC<{ } const onGridClick = (event: GridEvent) => { - const gridX = Math.round(event.position[0] * 2) / 2 // world, for computeLocal - const gridZ = Math.round(event.position[2] * 2) / 2 const y = event.position[1] - const lx = Math.round(event.localPosition[0] * 2) / 2 - const lz = Math.round(event.localPosition[2] * 2) / 2 + const snappedLocal = snapFenceDraftPoint({ + point: [event.localPosition[0], event.localPosition[2]], + walls: levelWalls, + fences: levelFences, + }) + const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y) + const [lx, lz] = snappedLocal const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz) diff --git a/packages/editor/src/components/tools/slab/move-slab-tool.tsx b/packages/editor/src/components/tools/slab/move-slab-tool.tsx index 101920a4..d7baebcc 100644 --- a/packages/editor/src/components/tools/slab/move-slab-tool.tsx +++ b/packages/editor/src/components/tools/slab/move-slab-tool.tsx @@ -1,17 +1,23 @@ 'use client' -import { type AnyNodeId, emitter, type GridEvent, useScene, type SlabNode } from '@pascal-app/core' +import { + type AnyNodeId, + emitter, + type FenceNode, + type GridEvent, + type LevelNode, + type SlabNode, + useScene, + type WallNode, +} from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' +import { snapFenceDraftPoint } from '../fence/fence-drafting' import { CursorSphere } from '../shared/cursor-sphere' -function snap(value: number) { - return Math.round(value * 2) / 2 -} - function translatePolygon( polygon: Array<[number, number]>, deltaX: number, @@ -56,6 +62,17 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => { useEffect(() => { const originalPolygon = originalPolygonRef.current const originalHoles = originalHolesRef.current + const levelNode = + node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level' + ? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode) + : null + const levelChildren = levelNode?.children ?? [] + const levelWalls = levelChildren + .map((childId) => useScene.getState().nodes[childId as AnyNodeId]) + .filter((child): child is WallNode => child?.type === 'wall') + const levelFences = levelChildren + .map((childId) => useScene.getState().nodes[childId as AnyNodeId]) + .filter((child): child is FenceNode => child?.type === 'fence') useScene.temporal.getState().pause() let wasCommitted = false @@ -80,8 +97,11 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => { } const onGridMove = (event: GridEvent) => { - const localX = snap(event.localPosition[0]) - const localZ = snap(event.localPosition[2]) + const [localX, localZ] = snapFenceDraftPoint({ + point: [event.localPosition[0], event.localPosition[2]], + walls: levelWalls, + fences: levelFences, + }) if ( previousGridPosRef.current &&