From be0f491bbde8f4754cda843ccc0c64780f852c21 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 7 Jun 2026 18:18:46 -0400 Subject: [PATCH] Fix relative move drag offsets --- .../tools/elevator/move-elevator-tool.tsx | 21 +- .../tools/item/use-placement-coordinator.tsx | 36 ++- .../registry/move-registry-node-tool.tsx | 11 +- packages/nodes/src/box-vent/move-tool.tsx | 57 ++-- packages/nodes/src/building/move-tool.tsx | 24 +- packages/nodes/src/chimney/move-tool.tsx | 55 ++-- packages/nodes/src/column/move-tool.tsx | 8 +- packages/nodes/src/cupola/move-tool.tsx | 57 ++-- packages/nodes/src/door/move-tool.tsx | 222 +++++++--------- packages/nodes/src/dormer/move-tool.tsx | 6 +- .../nodes/src/dormer/use-dormer-placement.ts | 34 ++- packages/nodes/src/eyebrow-vent/move-tool.tsx | 57 ++-- packages/nodes/src/gutter/move-tool.tsx | 47 ++-- packages/nodes/src/item/move-tool.tsx | 1 + packages/nodes/src/ridge-vent/move-tool.tsx | 73 +++--- packages/nodes/src/shared/move-roof-tool.tsx | 69 +++-- .../nodes/src/shared/relative-roof-drag.ts | 116 +++++++++ packages/nodes/src/skylight/move-tool.tsx | 79 ++---- packages/nodes/src/solar-panel/move-tool.tsx | 62 ++--- packages/nodes/src/turbine-vent/move-tool.tsx | 57 ++-- packages/nodes/src/window/move-tool.tsx | 243 ++++++++---------- 21 files changed, 712 insertions(+), 623 deletions(-) create mode 100644 packages/nodes/src/shared/relative-roof-drag.ts diff --git a/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx b/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx index 5a82db0c..f98004fa 100644 --- a/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx +++ b/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx @@ -40,6 +40,7 @@ export function MoveElevatorTool({ const onCommittedRef = useRef(onCommitted) const historyPausedRef = useRef(false) const previousGridPosRef = useRef<[number, number] | null>(null) + const dragAnchorRef = useRef<[number, number] | null>(null) const previewPositionRef = useRef([ movingNode.position[0], movingNode.position[1], @@ -73,6 +74,8 @@ export function MoveElevatorTool({ } pauseHistory() + dragAnchorRef.current = null + previousGridPosRef.current = null const movingNodeId = (movingNode as { id?: ElevatorNode['id'] }).id const meta = @@ -128,8 +131,12 @@ export function MoveElevatorTool({ } const onGridMove = (event: GridEvent) => { - const gridX = Math.round(event.localPosition[0] * 2) / 2 - const gridZ = Math.round(event.localPosition[2] * 2) / 2 + const rawX = Math.round(event.localPosition[0] * 2) / 2 + const rawZ = Math.round(event.localPosition[2] * 2) / 2 + const anchor = dragAnchorRef.current ?? [rawX, rawZ] + dragAnchorRef.current = anchor + const gridX = movingNode.position[0] + (rawX - anchor[0]) + const gridZ = movingNode.position[2] + (rawZ - anchor[1]) const supportY = resolveElevatorSupportY({ buildingId: supportBuildingId, preferredLevelId: supportLevelId, @@ -151,15 +158,7 @@ export function MoveElevatorTool({ } const onGridClick = (event: GridEvent) => { - const gridX = Math.round(event.localPosition[0] * 2) / 2 - const gridZ = Math.round(event.localPosition[2] * 2) / 2 - const supportY = resolveElevatorSupportY({ - buildingId: supportBuildingId, - preferredLevelId: supportLevelId, - x: gridX, - z: gridZ, - }) - const nextPosition: ElevatorNode['position'] = [gridX, supportY, gridZ] + const nextPosition: ElevatorNode['position'] = [...previewPositionRef.current] wasCommitted = true clearPreview() diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index f854338d..b675a816 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -195,6 +195,8 @@ export interface PlacementCoordinatorConfig { initialState?: PlacementState /** Scale to use when lazily creating a draft (e.g. for wall/ceiling duplicates). Defaults to [1,1,1]. */ defaultScale?: [number, number, number] + /** Move-mode sessions for floor items keep the grabbed item offset from the first floor-plane hit. */ + preserveFloorDragOffset?: boolean } export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode { @@ -405,6 +407,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // building-local, matching the draft's grid position and the guide // layer's frame. let alignmentCandidates: AlignmentAnchor[] | null = null + let floorDragAnchor: [number, number] | null = null // Reset placement state placementState.current = configRef.current.initialState ?? { @@ -526,6 +529,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // ---- Init draft ---- configRef.current.initDraft(gridPosition.current) + const preserveFloorDragOffset = + configRef.current.preserveFloorDragOffset === true && + placementState.current.surface === 'floor' && + !asset.attachTo + const relativeFloorStart = preserveFloorDragOffset ? gridPosition.current.clone() : null // Sync cursor to the draft mesh's world position and rotation if (draftNode.current) { @@ -649,9 +657,31 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea detachItemSurfaceToFloor(event as unknown as ItemEvent) } - lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2]) + const floorEvent = + relativeFloorStart !== null + ? (() => { + const rawX = event.localPosition[0] + const rawZ = event.localPosition[2] + const anchor = floorDragAnchor ?? [rawX, rawZ] + floorDragAnchor = anchor + return { + ...event, + localPosition: [ + relativeFloorStart.x + (rawX - anchor[0]), + event.localPosition[1], + relativeFloorStart.z + (rawZ - anchor[1]), + ] as [number, number, number], + } + })() + : event + + lastRawPos.current.set( + floorEvent.localPosition[0], + floorEvent.localPosition[1], + floorEvent.localPosition[2], + ) if (!cursorGroupRef.current) return - const result = floorStrategy.move(getContext(), event) + const result = floorStrategy.move(getContext(), floorEvent) if (!result) return // Figma-style alignment snap layered on top of the floor strategy's @@ -663,7 +693,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const draft = draftNode.current let alignX = 0 let alignZ = 0 - const bypassAlign = event.nativeEvent?.altKey === true + const bypassAlign = floorEvent.nativeEvent?.altKey === true if (!bypassAlign && draft) { alignmentCandidates ??= collectAlignmentAnchors( useScene.getState().nodes, diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index 827578f0..4fcadbfb 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -125,6 +125,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { * commit position consistent with the visible cursor. */ const lastCursorRef = useRef<[number, number, number]>(originalPosition) + const dragAnchorRef = useRef<[number, number] | null>(null) /** * Becomes true on the first `grid:move` after this move arms. Commits are * ignored until then so a click that *armed* this move (e.g. the trailing @@ -166,6 +167,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { useEffect(() => { useScene.temporal.getState().pause() previousSnapRef.current = null + dragAnchorRef.current = null hasMovedRef.current = false rotationRef.current = originalRotationY shiftRef.current = false @@ -267,8 +269,13 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { ) const onGridMove = (event: GridEvent) => { - let x = snapToGridStep(event.localPosition[0]) - let z = snapToGridStep(event.localPosition[2]) + const rawX = event.localPosition[0] + const rawZ = event.localPosition[2] + const anchor = dragAnchorRef.current ?? [rawX, rawZ] + dragAnchorRef.current = anchor + + let x = originalPosition[0] + snapToGridStep(rawX - anchor[0]) + let z = originalPosition[2] + snapToGridStep(rawZ - anchor[1]) // Figma-style alignment snap layered on top of grid snap: when the // moving item's edge lines up (on X or Z) with another item's edge, diff --git a/packages/nodes/src/box-vent/move-tool.tsx b/packages/nodes/src/box-vent/move-tool.tsx index 312e3643..6f1cfd07 100644 --- a/packages/nodes/src/box-vent/move-tool.tsx +++ b/packages/nodes/src/box-vent/move-tool.tsx @@ -5,16 +5,18 @@ import { type BoxVentNode, emitter, type RoofEvent, - type RoofNode, type RoofSegmentNode, sceneRegistry, useScene, } from '@pascal-app/core' import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' -import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { + createRelativeRoofDrag, + type RelativeRoofDragTarget, + roofSegmentLocalToBuildingLocal, +} from '../shared/relative-roof-drag' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import BoxVentPreview from './preview' @@ -55,48 +57,39 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) { const ventObj = sceneRegistry.nodes.get(node.id) if (ventObj) ventObj.visible = false - const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => { - const buildingId = useViewer.getState().selection.buildingId - const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null - if (!buildingObj) return [wx, wy, wz] - const v = new THREE.Vector3(wx, wy, wz) - buildingObj.worldToLocal(v) - return [v.x, v.y, v.z] - } - let lastSnap: [number, number] | null = null + let lastTarget: RelativeRoofDragTarget | null = null + const roofDrag = createRelativeRoofDrag(original) const updatePreview = (event: RoofEvent) => { - const wx = event.position[0] - const wy = event.position[1] - const wz = event.position[2] + const target = roofDrag.resolve(event) + if (!target) return + lastTarget = target - const sx = Math.round(wx * 20) / 20 - const sz = Math.round(wz * 20) / 20 + const sx = Math.round(target.localX * 20) / 20 + const sz = Math.round(target.localZ * 20) / 20 if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) { triggerSFX('sfx:grid-snap') lastSnap = [sx, sz] } - const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz) - if (!hit) return - - const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment) + const normal = getAnalyticalNormal(target.localX, target.localZ, target.segment) setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) - setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) - setPreviewPos(worldToBuildingLocal(wx, wy, wz)) + setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0)) + setPreviewPos( + roofSegmentLocalToBuildingLocal(target.segment.id, [ + target.localX, + target.localY, + target.localZ, + ]), + ) event.stopPropagation() } const onRoofClick = (event: RoofEvent) => { - const hit = resolveRoofSegmentHit( - event.node as RoofNode, - event.position[0], - event.position[1], - event.position[2], - ) - if (!hit) return - const targetSegmentId = hit.segment.id as AnyNodeId + const target = lastTarget ?? roofDrag.resolve(event) + if (!target) return + const targetSegmentId = target.segment.id as AnyNodeId const st = useScene.getState() // Reparent if the cursor landed on a different segment than the @@ -124,7 +117,7 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) { st.updateNode(node.id as AnyNodeId, { roofSegmentId: targetSegmentId, parentId: targetSegmentId, - position: [hit.localX, hit.localY, hit.localZ], + position: [target.localX, target.localY, target.localZ], rotation: original.rotation, visible: true, metadata: {}, diff --git a/packages/nodes/src/building/move-tool.tsx b/packages/nodes/src/building/move-tool.tsx index d3cc99b3..c1196e77 100644 --- a/packages/nodes/src/building/move-tool.tsx +++ b/packages/nodes/src/building/move-tool.tsx @@ -17,6 +17,7 @@ const Y_AXIS = new THREE.Vector3(0, 1, 0) export function MoveBuildingContent({ node }: { node: BuildingNode }) { const previousGridPosRef = useRef<[number, number] | null>(null) + const dragAnchorRef = useRef<[number, number] | null>(null) // Stable refs so the effect never needs node in its dependency array const nodeIdRef = useRef(node.id) @@ -29,9 +30,8 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) { const pendingRotationRef = useRef(node.rotation[1] ?? 0) // Local-space offset from the building's origin to its bbox center. The - // floating drag button anchors at the bbox center, so we pin that point to - // the cursor during the drag — otherwise the raw origin (often nowhere near - // the visual center) would snap to the cursor and the building would jump. + // move preview preserves the first pointer-to-center delta, then uses this + // offset to write the origin while keeping rotation around the visual center. const centerOffsetLocalRef = useRef(new THREE.Vector3()) const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => { @@ -66,8 +66,15 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) { const offsetWork = new THREE.Vector3() const offsetAt = (rotationY: number) => offsetWork.copy(centerOffsetLocalRef.current).applyAxisAngle(Y_AXIS, rotationY) + const originalCenterOffset = offsetAt(originalRotationRef.current).clone() + const originalCenter: [number, number] = [ + originalPosition[0] + originalCenterOffset.x, + originalPosition[2] + originalCenterOffset.z, + ] useScene.temporal.getState().pause() + dragAnchorRef.current = null + previousGridPosRef.current = null // Publish the building's current pose to useLiveTransforms so the // floor-plan (and any other live consumers) can follow per-frame @@ -114,8 +121,12 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) { } const onGridMove = (event: GridEvent) => { - const gridX = Math.round(event.position[0] * 2) / 2 - const gridZ = Math.round(event.position[2] * 2) / 2 + const rawX = Math.round(event.position[0] * 2) / 2 + const rawZ = Math.round(event.position[2] * 2) / 2 + const anchor = dragAnchorRef.current ?? [rawX, rawZ] + dragAnchorRef.current = anchor + const gridX = originalCenter[0] + (rawX - anchor[0]) + const gridZ = originalCenter[1] + (rawZ - anchor[1]) if ( previousGridPosRef.current && @@ -138,8 +149,7 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) { } const onGridClick = (event: GridEvent) => { - const gridX = Math.round(event.position[0] * 2) / 2 - const gridZ = Math.round(event.position[2] * 2) / 2 + const [gridX, gridZ] = previousGridPosRef.current ?? originalCenter wasCommitted = true diff --git a/packages/nodes/src/chimney/move-tool.tsx b/packages/nodes/src/chimney/move-tool.tsx index ca57e0a9..c72908a5 100644 --- a/packages/nodes/src/chimney/move-tool.tsx +++ b/packages/nodes/src/chimney/move-tool.tsx @@ -6,7 +6,6 @@ import { ChimneyNode as ChimneyNodeSchema, emitter, type RoofEvent, - type RoofNode, type RoofSegmentNode, sceneRegistry, useScene, @@ -15,7 +14,7 @@ import { triggerSFX, useEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { createRelativeRoofDrag, type RelativeRoofDragTarget } from '../shared/relative-roof-drag' import ChimneyPreview from './preview' const tmpMatrix = new THREE.Matrix4() @@ -84,38 +83,36 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => { } } - const updatePreview = (event: RoofEvent) => { - const wx = event.position[0] - const wy = event.position[1] - const wz = event.position[2] + let lastTarget: RelativeRoofDragTarget | null = null + const roofDrag = createRelativeRoofDrag({ + position: [...node.position] as [number, number, number], + roofSegmentId: node.roofSegmentId, + }) - const sx = Math.round(wx * 20) / 20 - const sz = Math.round(wz * 20) / 20 + const updatePreview = (event: RoofEvent) => { + const target = roofDrag.resolve(event) + if (!target) return + lastTarget = target + + const sx = Math.round(target.localX * 20) / 20 + const sz = Math.round(target.localZ * 20) / 20 const prev = lastSnapRef.current if (!prev || prev[0] !== sx || prev[1] !== sz) { triggerSFX('sfx:grid-snap') lastSnapRef.current = [sx, sz] } - const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz) - if (!hit) return - - const xform = computeSegmentXform(hit.segment.id) + const xform = computeSegmentXform(target.segment.id) if (!xform) return setSegmentXform(xform) - setHitLocal([hit.localX, hit.localY, hit.localZ]) - setPreviewSegment(hit.segment) + setHitLocal([target.localX, target.localY, target.localZ]) + setPreviewSegment(target.segment) event.stopPropagation() } const onClick = (event: RoofEvent) => { - const hit = resolveRoofSegmentHit( - event.node as RoofNode, - event.position[0], - event.position[1], - event.position[2], - ) - if (!hit) return + const target = lastTarget ?? roofDrag.resolve(event) + if (!target) return const state = useScene.getState() // Strip the `isNew` flag — only used to mark a duplicate clone @@ -135,23 +132,23 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => { const committed = ChimneyNodeSchema.parse({ ...node, id: undefined as never, - roofSegmentId: hit.segment.id, - position: [hit.localX, hit.localY, hit.localZ], + roofSegmentId: target.segment.id, + position: [target.localX, target.localY, target.localZ], metadata: cleanedMeta, }) - state.createNode(committed, hit.segment.id as AnyNodeId) - state.dirtyNodes.add(hit.segment.id as AnyNodeId) + state.createNode(committed, target.segment.id as AnyNodeId) + state.dirtyNodes.add(target.segment.id as AnyNodeId) setSelection({ selectedIds: [committed.id] }) } else { const prevSegmentId = node.roofSegmentId as AnyNodeId | undefined state.updateNode(node.id as AnyNodeId, { - roofSegmentId: hit.segment.id, - parentId: hit.segment.id, - position: [hit.localX, hit.localY, hit.localZ], + roofSegmentId: target.segment.id, + parentId: target.segment.id, + position: [target.localX, target.localY, target.localZ], metadata: cleanedMeta, }) if (prevSegmentId) state.dirtyNodes.add(prevSegmentId) - state.dirtyNodes.add(hit.segment.id as AnyNodeId) + state.dirtyNodes.add(target.segment.id as AnyNodeId) setSelection({ selectedIds: [node.id] }) } setMovingNode(null) diff --git a/packages/nodes/src/column/move-tool.tsx b/packages/nodes/src/column/move-tool.tsx index 995425eb..25cea8de 100644 --- a/packages/nodes/src/column/move-tool.tsx +++ b/packages/nodes/src/column/move-tool.tsx @@ -70,6 +70,7 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { let rotationY = node.rotation // Latest previewed position, so an R/T press can re-apply at the spot. let lastPosition: [number, number, number] = node.position + let dragAnchor: [number, number] | null = null const meta = typeof node.metadata === 'object' && node.metadata !== null ? (node.metadata as Record) @@ -111,8 +112,11 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { const onGridMove = (event: GridEvent) => { hasMoved = true - let x = snapToGridStep(event.localPosition[0]) - let z = snapToGridStep(event.localPosition[2]) + const rawX = event.localPosition[0] + const rawZ = event.localPosition[2] + dragAnchor ??= [rawX, rawZ] + let x = node.position[0] + snapToGridStep(rawX - dragAnchor[0]) + let z = node.position[2] + snapToGridStep(rawZ - dragAnchor[1]) // Figma-style alignment snap on top of grid snap; Alt bypasses. The // guide connects to the candidate's nearest real anchor (resolver diff --git a/packages/nodes/src/cupola/move-tool.tsx b/packages/nodes/src/cupola/move-tool.tsx index ea1d6e6e..2d6f9b27 100644 --- a/packages/nodes/src/cupola/move-tool.tsx +++ b/packages/nodes/src/cupola/move-tool.tsx @@ -5,16 +5,18 @@ import { type CupolaNode, emitter, type RoofEvent, - type RoofNode, type RoofSegmentNode, sceneRegistry, useScene, } from '@pascal-app/core' import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' -import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { + createRelativeRoofDrag, + type RelativeRoofDragTarget, + roofSegmentLocalToBuildingLocal, +} from '../shared/relative-roof-drag' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import CupolaPreview from './preview' @@ -53,48 +55,39 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { const cupolaObj = sceneRegistry.nodes.get(node.id) if (cupolaObj) cupolaObj.visible = false - const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => { - const buildingId = useViewer.getState().selection.buildingId - const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null - if (!buildingObj) return [wx, wy, wz] - const v = new THREE.Vector3(wx, wy, wz) - buildingObj.worldToLocal(v) - return [v.x, v.y, v.z] - } - let lastSnap: [number, number] | null = null + let lastTarget: RelativeRoofDragTarget | null = null + const roofDrag = createRelativeRoofDrag(original) const updatePreview = (event: RoofEvent) => { - const wx = event.position[0] - const wy = event.position[1] - const wz = event.position[2] + const target = roofDrag.resolve(event) + if (!target) return + lastTarget = target - const sx = Math.round(wx * 20) / 20 - const sz = Math.round(wz * 20) / 20 + const sx = Math.round(target.localX * 20) / 20 + const sz = Math.round(target.localZ * 20) / 20 if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) { triggerSFX('sfx:grid-snap') lastSnap = [sx, sz] } - const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz) - if (!hit) return - - const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment) + const normal = getAnalyticalNormal(target.localX, target.localZ, target.segment) setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) - setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) - setPreviewPos(worldToBuildingLocal(wx, wy, wz)) + setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0)) + setPreviewPos( + roofSegmentLocalToBuildingLocal(target.segment.id, [ + target.localX, + target.localY, + target.localZ, + ]), + ) event.stopPropagation() } const onRoofClick = (event: RoofEvent) => { - const hit = resolveRoofSegmentHit( - event.node as RoofNode, - event.position[0], - event.position[1], - event.position[2], - ) - if (!hit) return - const targetSegmentId = hit.segment.id as AnyNodeId + const target = lastTarget ?? roofDrag.resolve(event) + if (!target) return + const targetSegmentId = target.segment.id as AnyNodeId const st = useScene.getState() const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined @@ -118,7 +111,7 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { st.updateNode(node.id as AnyNodeId, { roofSegmentId: targetSegmentId, parentId: targetSegmentId, - position: [hit.localX, hit.localY, hit.localZ], + position: [target.localX, target.localY, target.localZ], rotation: original.rotation, visible: true, metadata: {}, diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index 6256d706..66813b67 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -66,6 +66,18 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => } let currentWallId: string | null = movingDoorNode.parentId + let dragAnchor: { wallId: string; rawX: number; startX: number } | null = null + let lastTarget: { + wallNode: WallEvent['node'] + wallId: string + side: DoorNode['side'] + itemRotation: number + cursorRotation: number + clampedX: number + clampedY: number + valid: boolean + event: WallEvent + } | null = null const markWallDirty = (wallId: string | null) => { if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId) @@ -131,7 +143,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => } } - const onWallEnter = (event: WallEvent) => { + const resolveMoveTarget = (event: WallEvent) => { if (!isValidWallSideFace(event.normal)) return if (isCurvedWall(event.node)) { hideCursor() @@ -141,9 +153,18 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => const { side, itemRotation, cursorRotation } = getPlacementOrientation(event) + const rawLocalX = event.localPosition[0] + if (!dragAnchor || dragAnchor.wallId !== event.node.id) { + dragAnchor = { + wallId: event.node.id, + rawX: rawLocalX, + startX: event.node.id === original.parentId ? original.position[0] : rawLocalX, + } + } + const targetLocalX = dragAnchor.startX + (rawLocalX - dragAnchor.rawX) const localX = resolveWallSlideAlignment({ wallNode: event.node, - rawLocalX: event.localPosition[0], + rawLocalX: targetLocalX, width: movingDoorNode.width, candidates: alignmentCandidates, bypass: event.nativeEvent?.altKey === true, @@ -155,24 +176,6 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => movingDoorNode.height, ) - const prevWallId = currentWallId - currentWallId = event.node.id - - useScene.getState().updateNode(movingDoorNode.id, { - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - parentId: event.node.id, - wallId: event.node.id, - }) - useLiveTransforms.getState().set(movingDoorNode.id, { - position: [clampedX, clampedY, 0], - rotation: itemRotation, - }) - - if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId) - markWallDirtyThrottled(event.node.id) - const valid = !hasWallChildOverlap( event.node.id, clampedX, @@ -182,17 +185,62 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => movingDoorNode.id, ) + return { + wallNode: event.node, + wallId: event.node.id, + side, + itemRotation, + cursorRotation, + clampedX, + clampedY, + valid, + event, + } + } + + const applyPreview = (target: NonNullable) => { + if (currentWallId !== target.wallId) { + useScene.getState().updateNode(movingDoorNode.id, { + position: [target.clampedX, target.clampedY, 0], + rotation: [0, target.itemRotation, 0], + side: target.side, + parentId: target.wallId, + wallId: target.wallId, + }) + markWallDirty(currentWallId) + currentWallId = target.wallId + } else { + const doorMesh = sceneRegistry.nodes.get(movingDoorNode.id as AnyNodeId) + if (doorMesh) { + doorMesh.position.set(target.clampedX, target.clampedY, 0) + doorMesh.rotation.set(0, target.itemRotation, 0) + doorMesh.updateMatrixWorld(true) + } + } + useLiveTransforms.getState().set(movingDoorNode.id, { + position: [target.clampedX, target.clampedY, 0], + rotation: target.itemRotation, + }) + markWallDirtyThrottled(target.wallId) + updateCursor( wallLocalToWorld( - event.node, - clampedX, - clampedY, + target.wallNode, + target.clampedX, + target.clampedY, getLevelYOffset(), - getSlabElevation(event), + getSlabElevation(target.event), ), - cursorRotation, - valid, + target.cursorRotation, + target.valid, ) + } + + const onWallEnter = (event: WallEvent) => { + const target = resolveMoveTarget(event) + if (!target) return + lastTarget = target + applyPreview(target) event.stopPropagation() } @@ -204,69 +252,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => } if (event.node.parentId !== getLevelId()) return - const { side, itemRotation, cursorRotation } = getPlacementOrientation(event) - - const localX = resolveWallSlideAlignment({ - wallNode: event.node, - rawLocalX: event.localPosition[0], - width: movingDoorNode.width, - candidates: alignmentCandidates, - bypass: event.nativeEvent?.altKey === true, - }) - const { clampedX, clampedY } = clampToWall( - event.node, - localX, - movingDoorNode.width, - movingDoorNode.height, - ) - - if (currentWallId !== event.node.id) { - // Wall changed mid-move: must updateNode to reparent - useScene.getState().updateNode(movingDoorNode.id, { - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - parentId: event.node.id, - wallId: event.node.id, - }) - markWallDirty(currentWallId) - currentWallId = event.node.id - } else { - // Same wall: update Three.js mesh directly to avoid store churn - // collectCutoutBrushes reads cutoutMesh.matrixWorld, not scene store positions - const doorMesh = sceneRegistry.nodes.get(movingDoorNode.id as AnyNodeId) - if (doorMesh) { - doorMesh.position.set(clampedX, clampedY, 0) - doorMesh.rotation.set(0, itemRotation, 0) - doorMesh.updateMatrixWorld(true) - } - } - useLiveTransforms.getState().set(movingDoorNode.id, { - position: [clampedX, clampedY, 0], - rotation: itemRotation, - }) - markWallDirtyThrottled(event.node.id) - - const valid = !hasWallChildOverlap( - event.node.id, - clampedX, - clampedY, - movingDoorNode.width, - movingDoorNode.height, - movingDoorNode.id, - ) - - updateCursor( - wallLocalToWorld( - event.node, - clampedX, - clampedY, - getLevelYOffset(), - getSlabElevation(event), - ), - cursorRotation, - valid, - ) + const target = resolveMoveTarget(event) + if (!target) return + lastTarget = target + applyPreview(target) event.stopPropagation() } @@ -275,31 +264,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => if (isCurvedWall(event.node)) return if (event.node.parentId !== getLevelId()) return - const { side, itemRotation } = getPlacementOrientation(event) - - const localX = resolveWallSlideAlignment({ - wallNode: event.node, - rawLocalX: event.localPosition[0], - width: movingDoorNode.width, - candidates: alignmentCandidates, - bypass: event.nativeEvent?.altKey === true, - }) - const { clampedX, clampedY } = clampToWall( - event.node, - localX, - movingDoorNode.width, - movingDoorNode.height, - ) - - const valid = !hasWallChildOverlap( - event.node.id, - clampedX, - clampedY, - movingDoorNode.width, - movingDoorNode.height, - movingDoorNode.id, - ) - if (!valid) return + const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event) + if (!target?.valid) return let placedId: string @@ -311,13 +277,13 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => delete cloned.id const node = DoorNode.parse({ ...cloned, - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - wallId: event.node.id, - parentId: event.node.id, + position: [target.clampedX, target.clampedY, 0], + rotation: [0, target.itemRotation, 0], + side: target.side, + wallId: target.wallId, + parentId: target.wallId, }) - useScene.getState().createNode(node, event.node.id as AnyNodeId) + useScene.getState().createNode(node, target.wallId as AnyNodeId) placedId = node.id } else { useScene.getState().updateNode(movingDoorNode.id, { @@ -331,21 +297,21 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => useScene.temporal.getState().resume() useScene.getState().updateNode(movingDoorNode.id, { - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - parentId: event.node.id, - wallId: event.node.id, + position: [target.clampedX, target.clampedY, 0], + rotation: [0, target.itemRotation, 0], + side: target.side, + parentId: target.wallId, + wallId: target.wallId, metadata: {}, }) - if (original.parentId && original.parentId !== event.node.id) { + if (original.parentId && original.parentId !== target.wallId) { markWallDirty(original.parentId) } placedId = movingDoorNode.id } - markWallDirty(event.node.id) + markWallDirty(target.wallId) useLiveTransforms.getState().clear(movingDoorNode.id) useScene.temporal.getState().pause() @@ -359,6 +325,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => const onWallLeave = () => { hideCursor() useLiveTransforms.getState().clear(movingDoorNode.id) + dragAnchor = null + lastTarget = null if (isNew) return if (currentWallId && currentWallId !== original.parentId) { markWallDirty(currentWallId) diff --git a/packages/nodes/src/dormer/move-tool.tsx b/packages/nodes/src/dormer/move-tool.tsx index 0001aac2..150fdd95 100644 --- a/packages/nodes/src/dormer/move-tool.tsx +++ b/packages/nodes/src/dormer/move-tool.tsx @@ -51,6 +51,7 @@ const MoveDormerTool = ({ node }: { node: DormerNode }) => { const originalRotation = node.rotation ?? 0 const originalMetadata = node.metadata + // biome-ignore lint/correctness/useExhaustiveDependencies: capture-on-mount; meta is intentionally not re-read on changes. useEffect(() => { if (!isNew) { useScene.getState().updateNode(node.id as AnyNodeId, { @@ -71,11 +72,14 @@ const MoveDormerTool = ({ node }: { node: DormerNode }) => { }) } } - // biome-ignore lint/correctness/useExhaustiveDependencies: capture-on-mount; meta is intentionally not re-read on changes. }, [node.id, isNew]) const { activeBuildingId, segmentXform, hitLocal, ghostRotation } = useDormerPlacement({ initialRotation: originalRotation, + relativeStart: { + position: [...node.position] as [number, number, number], + roofSegmentId: node.roofSegmentId, + }, onCommit: (hit, rotation) => { const state = useScene.getState() diff --git a/packages/nodes/src/dormer/use-dormer-placement.ts b/packages/nodes/src/dormer/use-dormer-placement.ts index 97b31012..1d88fb43 100644 --- a/packages/nodes/src/dormer/use-dormer-placement.ts +++ b/packages/nodes/src/dormer/use-dormer-placement.ts @@ -10,6 +10,7 @@ import { triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef, useState } from 'react' import * as THREE from 'three' +import { createRelativeRoofDrag } from '../shared/relative-roof-drag' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { DORMER_PLACEMENT_ROTATION_STEP, DORMER_PLACEMENT_SNAP_M } from './geometry' @@ -50,6 +51,10 @@ export type DormerPlacementHit = { */ export function useDormerPlacement(opts: { initialRotation?: number + relativeStart?: { + position: [number, number, number] + roofSegmentId?: string + } onCommit: (hit: DormerPlacementHit, rotation: number) => void }): { activeBuildingId: string | undefined @@ -66,6 +71,7 @@ export function useDormerPlacement(opts: { // Mirror of `ghostRotation` so the click handler (registered once // inside useEffect) can read the latest value at commit time. const ghostRotationRef = useRef(opts.initialRotation ?? 0) + const relativeStartRef = useRef(opts.relativeStart) // Latest commit callback, captured via ref so the useEffect doesn't // need it in its dep list (we don't want to re-register listeners // every time the parent rerenders). @@ -90,9 +96,23 @@ export function useDormerPlacement(opts: { } } + const roofDrag = relativeStartRef.current + ? createRelativeRoofDrag(relativeStartRef.current) + : null + let lastRelativeHit: DormerPlacementHit | null = null + + const resolvePlacementHit = (event: RoofEvent): DormerPlacementHit | null => { + if (roofDrag) return roofDrag.resolve(event) + return resolveRoofSegmentHit( + event.node as RoofNode, + event.position[0], + event.position[1], + event.position[2], + ) + } + const updatePreview = (event: RoofEvent) => { const wx = event.position[0] - const wy = event.position[1] const wz = event.position[2] const sx = Math.round(wx / DORMER_PLACEMENT_SNAP_M) * DORMER_PLACEMENT_SNAP_M @@ -103,8 +123,9 @@ export function useDormerPlacement(opts: { lastSnapRef.current = [sx, sz] } - const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz) + const hit = resolvePlacementHit(event) if (!hit) return + if (roofDrag) lastRelativeHit = hit const xform = computeSegmentXform(hit.segment.id) if (!xform) return setSegmentXform(xform) @@ -118,12 +139,9 @@ export function useDormerPlacement(opts: { } const onClick = (event: RoofEvent) => { - const hit = resolveRoofSegmentHit( - event.node as RoofNode, - event.position[0], - event.position[1], - event.position[2], - ) + const hit = roofDrag + ? (lastRelativeHit ?? resolvePlacementHit(event)) + : resolvePlacementHit(event) if (!hit) return onCommitRef.current(hit, ghostRotationRef.current) triggerSFX('sfx:item-place') diff --git a/packages/nodes/src/eyebrow-vent/move-tool.tsx b/packages/nodes/src/eyebrow-vent/move-tool.tsx index 82b3ab33..036462d1 100644 --- a/packages/nodes/src/eyebrow-vent/move-tool.tsx +++ b/packages/nodes/src/eyebrow-vent/move-tool.tsx @@ -5,16 +5,18 @@ import { type EyebrowVentNode, emitter, type RoofEvent, - type RoofNode, type RoofSegmentNode, sceneRegistry, useScene, } from '@pascal-app/core' import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' -import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { + createRelativeRoofDrag, + type RelativeRoofDragTarget, + roofSegmentLocalToBuildingLocal, +} from '../shared/relative-roof-drag' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import EyebrowVentPreview from './preview' @@ -54,48 +56,39 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode }) const ventObj = sceneRegistry.nodes.get(node.id) if (ventObj) ventObj.visible = false - const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => { - const buildingId = useViewer.getState().selection.buildingId - const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null - if (!buildingObj) return [wx, wy, wz] - const v = new THREE.Vector3(wx, wy, wz) - buildingObj.worldToLocal(v) - return [v.x, v.y, v.z] - } - let lastSnap: [number, number] | null = null + let lastTarget: RelativeRoofDragTarget | null = null + const roofDrag = createRelativeRoofDrag(original) const updatePreview = (event: RoofEvent) => { - const wx = event.position[0] - const wy = event.position[1] - const wz = event.position[2] + const target = roofDrag.resolve(event) + if (!target) return + lastTarget = target - const sx = Math.round(wx * 20) / 20 - const sz = Math.round(wz * 20) / 20 + const sx = Math.round(target.localX * 20) / 20 + const sz = Math.round(target.localZ * 20) / 20 if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) { triggerSFX('sfx:grid-snap') lastSnap = [sx, sz] } - const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz) - if (!hit) return - - const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment) + const normal = getAnalyticalNormal(target.localX, target.localZ, target.segment) setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) - setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) - setPreviewPos(worldToBuildingLocal(wx, wy, wz)) + setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0)) + setPreviewPos( + roofSegmentLocalToBuildingLocal(target.segment.id, [ + target.localX, + target.localY, + target.localZ, + ]), + ) event.stopPropagation() } const onRoofClick = (event: RoofEvent) => { - const hit = resolveRoofSegmentHit( - event.node as RoofNode, - event.position[0], - event.position[1], - event.position[2], - ) - if (!hit) return - const targetSegmentId = hit.segment.id as AnyNodeId + const target = lastTarget ?? roofDrag.resolve(event) + if (!target) return + const targetSegmentId = target.segment.id as AnyNodeId const st = useScene.getState() const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined @@ -119,7 +112,7 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode }) st.updateNode(node.id as AnyNodeId, { roofSegmentId: targetSegmentId, parentId: targetSegmentId, - position: [hit.localX, hit.localY, hit.localZ], + position: [target.localX, target.localY, target.localZ], rotation: original.rotation, visible: true, metadata: {}, diff --git a/packages/nodes/src/gutter/move-tool.tsx b/packages/nodes/src/gutter/move-tool.tsx index a2813fc4..57295ba0 100644 --- a/packages/nodes/src/gutter/move-tool.tsx +++ b/packages/nodes/src/gutter/move-tool.tsx @@ -12,7 +12,7 @@ import { } from '@pascal-app/core' import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' import { useCallback, useEffect, useState } from 'react' -import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { createRelativeRoofDrag } from '../shared/relative-roof-drag' import { type EaveSnap, resolveEaveSnap } from './eave-snap' import GutterPreview from './preview' @@ -22,6 +22,11 @@ type PreviewTarget = { snap: EaveSnap } +type GutterDragTarget = { + segment: RoofSegmentNode + snap: EaveSnap +} + /** * Gutter move tool. Mirrors the ridge-vent move flow — ghost follows * the cursor over any roof segment, click commits the new position + @@ -65,23 +70,30 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) { if (gutterObj) gutterObj.visible = false let lastSnap: [number, number] | null = null + let lastTarget: GutterDragTarget | null = null + const roofDrag = createRelativeRoofDrag(original) + + const resolveTarget = (event: RoofEvent): GutterDragTarget | null => { + const target = roofDrag.resolve(event) + if (!target) return null + return { + segment: target.segment, + snap: resolveEaveSnap(target.segment, target.localX, target.localZ), + } + } const updatePreview = (event: RoofEvent) => { const roof = event.node as RoofNode - const hit = resolveRoofSegmentHit( - roof, - event.position[0], - event.position[1], - event.position[2], - ) - if (!hit) return + const target = resolveTarget(event) + if (!target) return + lastTarget = target // Same snap math as the placement tool — picking-up and putting- // down round-trip identically. roofType-aware: hip/flat picks // ±X or ±Z based on which slope the cursor is on; shed always // snaps to its low (+Z) eave; gable / gambrel / mansard / dutch // stay on ±Z. - const snap = resolveEaveSnap(hit.segment, hit.localX, hit.localZ) + const { snap } = target const sx = Math.round(snap.eaveX * 20) / 20 const sz = Math.round(snap.eaveZ * 20) / 20 @@ -96,8 +108,8 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) { rotation: roof.rotation ?? 0, }, segment: { - position: (hit.segment.position ?? [0, 0, 0]) as [number, number, number], - rotation: hit.segment.rotation ?? 0, + position: (target.segment.position ?? [0, 0, 0]) as [number, number, number], + rotation: target.segment.rotation ?? 0, }, snap, }) @@ -105,15 +117,10 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) { } const onRoofClick = (event: RoofEvent) => { - const hit = resolveRoofSegmentHit( - event.node as RoofNode, - event.position[0], - event.position[1], - event.position[2], - ) - if (!hit) return - const targetSegmentId = hit.segment.id as AnyNodeId - const snap = resolveEaveSnap(hit.segment, hit.localX, hit.localZ) + const target = lastTarget ?? resolveTarget(event) + if (!target) return + const targetSegmentId = target.segment.id as AnyNodeId + const { snap } = target const st = useScene.getState() const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined diff --git a/packages/nodes/src/item/move-tool.tsx b/packages/nodes/src/item/move-tool.tsx index 88fc002d..236047da 100644 --- a/packages/nodes/src/item/move-tool.tsx +++ b/packages/nodes/src/item/move-tool.tsx @@ -88,6 +88,7 @@ export function MoveItemTool({ node }: { node: ItemNode }) { : getInitialState(node), // Preserve the original item's scale so Y-position calculations use the correct height. defaultScale: isNew ? node.scale : undefined, + preserveFloorDragOffset: true, initDraft: (gridPosition) => { if (isNew) { // Duplicate: floor items get a draft immediately; wall/ceiling diff --git a/packages/nodes/src/ridge-vent/move-tool.tsx b/packages/nodes/src/ridge-vent/move-tool.tsx index f7890d6f..e72fa2df 100644 --- a/packages/nodes/src/ridge-vent/move-tool.tsx +++ b/packages/nodes/src/ridge-vent/move-tool.tsx @@ -5,18 +5,25 @@ import { emitter, type RidgeVentNode, type RoofEvent, - type RoofNode, type RoofSegmentNode, sceneRegistry, useScene, } from '@pascal-app/core' import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' -import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useState } from 'react' -import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { + createRelativeRoofDrag, + type RelativeRoofDragTarget, + roofSegmentLocalToBuildingLocal, +} from '../shared/relative-roof-drag' +import { getSurfaceY } from '../shared/roof-surface' import RidgeVentPreview from './preview' +type RidgeVentDragTarget = Pick & { + localY: number + localZ: 0 +} + /** * Ridge-vent move tool. Mirrors the box-vent move flow — ghost follows * the cursor over any roof segment, click commits the new position + @@ -51,46 +58,48 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) { const ventObj = sceneRegistry.nodes.get(node.id) if (ventObj) ventObj.visible = false - const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => { - const buildingId = useViewer.getState().selection.buildingId - const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null - if (!buildingObj) return [wx, wy, wz] - const v = new THREE.Vector3(wx, wy, wz) - buildingObj.worldToLocal(v) - return [v.x, v.y, v.z] + let lastSnap: [number, number] | null = null + let lastTarget: RidgeVentDragTarget | null = null + const roofDrag = createRelativeRoofDrag(original) + + const resolveTarget = (event: RoofEvent): RidgeVentDragTarget | null => { + const target = roofDrag.resolve(event) + if (!target) return null + return { + segment: target.segment, + localX: target.localX, + localY: getSurfaceY(target.localX, 0, target.segment), + localZ: 0, + } } - let lastSnap: [number, number] | null = null - const updatePreview = (event: RoofEvent) => { - const wx = event.position[0] - const wy = event.position[1] - const wz = event.position[2] + const target = resolveTarget(event) + if (!target) return + lastTarget = target - const sx = Math.round(wx * 20) / 20 - const sz = Math.round(wz * 20) / 20 + const sx = Math.round(target.localX * 20) / 20 + const sz = Math.round(target.localZ * 20) / 20 if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) { triggerSFX('sfx:grid-snap') lastSnap = [sx, sz] } - const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz) - if (!hit) return - - setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) - setPreviewPos(worldToBuildingLocal(wx, wy, wz)) + setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0)) + setPreviewPos( + roofSegmentLocalToBuildingLocal(target.segment.id, [ + target.localX, + target.localY, + target.localZ, + ]), + ) event.stopPropagation() } const onRoofClick = (event: RoofEvent) => { - const hit = resolveRoofSegmentHit( - event.node as RoofNode, - event.position[0], - event.position[1], - event.position[2], - ) - if (!hit) return - const targetSegmentId = hit.segment.id as AnyNodeId + const target = lastTarget ?? resolveTarget(event) + if (!target) return + const targetSegmentId = target.segment.id as AnyNodeId const st = useScene.getState() const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined @@ -114,7 +123,7 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) { st.updateNode(node.id as AnyNodeId, { roofSegmentId: targetSegmentId, parentId: targetSegmentId, - position: [hit.localX, hit.localY, hit.localZ], + position: [target.localX, target.localY, target.localZ], rotation: original.rotation, visible: true, metadata: {}, diff --git a/packages/nodes/src/shared/move-roof-tool.tsx b/packages/nodes/src/shared/move-roof-tool.tsx index 5ece7396..0352934d 100644 --- a/packages/nodes/src/shared/move-roof-tool.tsx +++ b/packages/nodes/src/shared/move-roof-tool.tsx @@ -41,6 +41,7 @@ export const MoveRoofTool: React.FC<{ }, []) const previousGridPosRef = useRef<[number, number] | null>(null) + const dragAnchorRef = useRef<[number, number] | null>(null) const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => { const obj = sceneRegistry.nodes.get(movingNode.id) @@ -78,6 +79,8 @@ export const MoveRoofTool: React.FC<{ useEffect(() => { useScene.temporal.getState().pause() + dragAnchorRef.current = null + previousGridPosRef.current = null const meta = typeof movingNode.metadata === 'object' && movingNode.metadata !== null @@ -255,6 +258,24 @@ export const MoveRoofTool: React.FC<{ 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) => { const y = event.position[1] @@ -263,29 +284,40 @@ export const MoveRoofTool: React.FC<{ walls: levelWalls, fences: levelFences, }) - // Layer alignment snap on top (top-level stair/roof). Recompute the - // world point from the aligned building-local point so it stays correct - // under building rotation. - const [lx, lz] = alignLocalPoint( + const [rawGridX, , rawGridZ] = localToWorldPoint(snappedLocal, y) + const [rawLocalX, rawLocalZ] = computeLocal( + rawGridX, + rawGridZ, + y, snappedLocal[0], snappedLocal[1], - event.nativeEvent?.altKey === true, ) - const [gridX, , gridZ] = localToWorldPoint([lx, lz], y) + const anchor = dragAnchorRef.current ?? [rawLocalX, rawLocalZ] + dragAnchorRef.current = anchor + + let localX = movingNode.position[0] + (rawLocalX - anchor[0]) + let localZ = movingNode.position[2] + (rawLocalZ - anchor[1]) + + if (alignTopLevel) { + const aligned = alignLocalPoint(localX, localZ, event.nativeEvent?.altKey === true) + localX = aligned[0] + localZ = aligned[1] + } if ( previousGridPosRef.current && - (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1]) + (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1]) ) { triggerSFX('sfx:grid-snap') } - previousGridPosRef.current = [gridX, gridZ] + previousGridPosRef.current = [localX, localZ] - const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz) lastLocalPosition = [localX, movingNode.position[1], localZ] const previewPosition = getPreviewPosition(lastLocalPosition) - setCursorWorldPos(isFloorPlaced ? previewPosition : [lx, event.localPosition[1], lz]) + setCursorWorldPos( + isFloorPlaced ? previewPosition : localPositionToToolLocal(lastLocalPosition), + ) // Directly update the Three.js mesh — no store update during drag const mesh = sceneRegistry.nodes.get(movingNode.id) @@ -302,26 +334,13 @@ export const MoveRoofTool: React.FC<{ // 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: isFloorPlaced ? lastLocalPosition : [gridX, y, gridZ], + position: lastLocalPosition, rotation: pendingRotation, }) } const onGridClick = (event: GridEvent) => { - const y = event.position[1] - const snappedLocal = snapFenceDraftPoint({ - point: [event.localPosition[0], event.localPosition[2]], - walls: levelWalls, - fences: levelFences, - }) - const [lx, lz] = alignLocalPoint( - snappedLocal[0], - snappedLocal[1], - event.nativeEvent?.altKey === true, - ) - const [gridX, , gridZ] = localToWorldPoint([lx, lz], y) - - const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz) + const [localX, , localZ] = lastLocalPosition useAlignmentGuides.getState().clear() wasCommitted = true diff --git a/packages/nodes/src/shared/relative-roof-drag.ts b/packages/nodes/src/shared/relative-roof-drag.ts new file mode 100644 index 00000000..2bd2b8e1 --- /dev/null +++ b/packages/nodes/src/shared/relative-roof-drag.ts @@ -0,0 +1,116 @@ +import { + type AnyNodeId, + type RoofEvent, + type RoofNode, + type RoofSegmentNode, + sceneRegistry, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import * as THREE from 'three' +import { type RoofSegmentHit, resolveRoofSegmentHit } from './roof-segment-hit' +import { getSurfaceY } from './roof-surface' + +export type RelativeRoofDragTarget = { + segment: RoofSegmentNode + localX: number + localY: number + localZ: number + hit: RoofSegmentHit +} + +type RelativeRoofDragState = { + segmentId: string + anchor: [number, number] + start: [number, number, number] + current: [number, number, number] + surfaceOffsetY: number +} + +export function roofSegmentLocalToBuildingLocal( + segmentId: string, + position: [number, number, number], +): [number, number, number] { + const segmentObj = sceneRegistry.nodes.get(segmentId as AnyNodeId) + if (!segmentObj) return position + + const point = segmentObj.localToWorld(new THREE.Vector3(...position)) + const buildingId = useViewer.getState().selection.buildingId + const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null + if (buildingObj) buildingObj.worldToLocal(point) + return [point.x, point.y, point.z] +} + +export function createRelativeRoofDrag(original: { + position: [number, number, number] + roofSegmentId?: string +}): { + resolve: (event: RoofEvent) => RelativeRoofDragTarget | null +} { + let state: RelativeRoofDragState | null = null + + const getPositionInSegment = ( + position: [number, number, number], + fromSegmentId: string | undefined, + segment: RoofSegmentNode, + ): [number, number, number] => { + if (fromSegmentId === segment.id) return position + + const fromSegmentObj = fromSegmentId + ? sceneRegistry.nodes.get(fromSegmentId as AnyNodeId) + : null + const targetSegmentObj = sceneRegistry.nodes.get(segment.id as AnyNodeId) + if (!(fromSegmentObj && targetSegmentObj)) return position + + const point = fromSegmentObj.localToWorld(new THREE.Vector3(...position)) + targetSegmentObj.worldToLocal(point) + return [point.x, point.y, point.z] + } + + const getStartPositionForSegment = ( + segment: RoofSegmentNode, + previousState: RelativeRoofDragState | null, + ): [number, number, number] => { + if (previousState) { + return getPositionInSegment(previousState.current, previousState.segmentId, segment) + } + + if (original.roofSegmentId === segment.id) return original.position + + return getPositionInSegment(original.position, original.roofSegmentId, segment) + } + + return { + resolve(event) { + const hit = resolveRoofSegmentHit( + event.node as RoofNode, + event.position[0], + event.position[1], + event.position[2], + ) + if (!hit) return null + + if (!state || state.segmentId !== hit.segment.id) { + const start = getStartPositionForSegment(hit.segment, state) + state = { + segmentId: hit.segment.id, + anchor: [hit.localX, hit.localZ], + start, + current: start, + surfaceOffsetY: start[1] - getSurfaceY(start[0], start[2], hit.segment), + } + } + + const localX = state.start[0] + (hit.localX - state.anchor[0]) + const localZ = state.start[2] + (hit.localZ - state.anchor[1]) + const localY = getSurfaceY(localX, localZ, hit.segment) + state.surfaceOffsetY + state.current = [localX, localY, localZ] + return { + segment: hit.segment, + localX, + localY, + localZ, + hit, + } + }, + } +} diff --git a/packages/nodes/src/skylight/move-tool.tsx b/packages/nodes/src/skylight/move-tool.tsx index 07a1e993..b93bfc4c 100644 --- a/packages/nodes/src/skylight/move-tool.tsx +++ b/packages/nodes/src/skylight/move-tool.tsx @@ -11,35 +11,16 @@ import { useScene, } from '@pascal-app/core' import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' -import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { + createRelativeRoofDrag, + type RelativeRoofDragTarget, + roofSegmentLocalToBuildingLocal, +} from '../shared/relative-roof-drag' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import SkylightPreview from './preview' -function resolveSegmentFromWorldPoint( - roof: RoofNode, - worldX: number, - worldY: number, - worldZ: number, - state: ReturnType, -): { segment: RoofSegmentNode; localX: number; localY: number; localZ: number } | null { - const worldPt = new THREE.Vector3(worldX, worldY, worldZ) - for (const childId of roof.children ?? []) { - const seg = state.nodes[childId as AnyNodeId] as RoofSegmentNode | undefined - if (seg?.type !== 'roof-segment') continue - const segObj = sceneRegistry.nodes.get(seg.id) - if (!segObj) continue - segObj.updateWorldMatrix(true, false) - const local = segObj.worldToLocal(worldPt.clone()) - if (Math.abs(local.x) <= seg.width / 2 && Math.abs(local.z) <= seg.depth / 2) { - return { segment: seg, localX: local.x, localY: local.y, localZ: local.z } - } - } - return null -} - export default function MoveSkylightTool({ node }: { node: SkylightNode }) { const exitMoveMode = useCallback(() => { useEditor.getState().setMovingNode(null) @@ -81,19 +62,10 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) { const skylightObj = sceneRegistry.nodes.get(node.id) if (skylightObj) skylightObj.visible = false - const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => { - const buildingId = useViewer.getState().selection.buildingId - const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null - if (buildingObj) { - const v = new THREE.Vector3(wx, wy, wz) - buildingObj.worldToLocal(v) - return [v.x, v.y, v.z] - } - return [wx, wy, wz] - } - let lastSnapX = 0 let lastSnapZ = 0 + let lastTarget: RelativeRoofDragTarget | null = null + const roofDrag = createRelativeRoofDrag(original) // Resolve which segment the cursor is over, then derive the same // preview transform stack the placement tool uses (`skylight/tool.tsx`): @@ -103,20 +75,22 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) { // same via its `if (!hit) return` guard. const updateFromHit = (event: RoofEvent) => { const roof = event.node as RoofNode - const hit = resolveRoofSegmentHit( - roof, - event.position[0], - event.position[1], - event.position[2], - ) - if (!hit) { + const target = roofDrag.resolve(event) + if (!target) { setHasHit(false) return false } - const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment) + lastTarget = target + const normal = getAnalyticalNormal(target.localX, target.localZ, target.segment) setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) - setPreviewYaw((roof.rotation ?? 0) + (hit.segment.rotation ?? 0)) - setPreviewPos(worldToBuildingLocal(event.position[0], event.position[1], event.position[2])) + setPreviewYaw((roof.rotation ?? 0) + (target.segment.rotation ?? 0)) + setPreviewPos( + roofSegmentLocalToBuildingLocal(target.segment.id, [ + target.localX, + target.localY, + target.localZ, + ]), + ) setHasHit(true) return true } @@ -139,19 +113,12 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) { } const onRoofClick = (event: RoofEvent) => { - const roof = event.node as RoofNode const st = useScene.getState() - const hit = resolveSegmentFromWorldPoint( - roof, - event.position[0], - event.position[1], - event.position[2], - st, - ) - if (!hit) return + const target = lastTarget ?? roofDrag.resolve(event) + if (!target) return - const targetSegmentId = hit.segment.id as AnyNodeId + const targetSegmentId = target.segment.id as AnyNodeId const finalRotation = original.rotation st.updateNode(node.id as AnyNodeId, { @@ -166,7 +133,7 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) { st.updateNode(node.id as AnyNodeId, { roofSegmentId: targetSegmentId, parentId: targetSegmentId, - position: [hit.localX, hit.localY, hit.localZ], + position: [target.localX, target.localY, target.localZ], rotation: finalRotation, visible: true, metadata: {}, diff --git a/packages/nodes/src/solar-panel/move-tool.tsx b/packages/nodes/src/solar-panel/move-tool.tsx index b181c610..c17973c7 100644 --- a/packages/nodes/src/solar-panel/move-tool.tsx +++ b/packages/nodes/src/solar-panel/move-tool.tsx @@ -4,17 +4,19 @@ import { type AnyNodeId, emitter, type RoofEvent, - type RoofNode, type RoofSegmentNode, type SolarPanelNode, sceneRegistry, useScene, } from '@pascal-app/core' import { EDITOR_LAYER, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' -import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { + createRelativeRoofDrag, + type RelativeRoofDragTarget, + roofSegmentLocalToBuildingLocal, +} from '../shared/relative-roof-drag' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' // MeshBasicMaterial: avoids the WebGPU "Color target has no corresponding @@ -86,27 +88,18 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) { const panelObj = sceneRegistry.nodes.get(node.id) if (panelObj) panelObj.visible = false - const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => { - const buildingId = useViewer.getState().selection.buildingId - const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null - if (buildingObj) { - const v = new THREE.Vector3(wx, wy, wz) - buildingObj.worldToLocal(v) - return [v.x, v.y, v.z] - } - return [wx, wy, wz] - } - let lastSnapX = 0 let lastSnapZ = 0 + let lastTarget: RelativeRoofDragTarget | null = null + const roofDrag = createRelativeRoofDrag(original) const updateGhost = (event: RoofEvent) => { - const wx = event.position[0] - const wy = event.position[1] - const wz = event.position[2] + const target = roofDrag.resolve(event) + if (!target) return + lastTarget = target - const sx = Math.round(wx * 20) / 20 - const sz = Math.round(wz * 20) / 20 + const sx = Math.round(target.localX * 20) / 20 + const sz = Math.round(target.localZ * 20) / 20 if (sx !== lastSnapX || sz !== lastSnapZ) { triggerSFX('sfx:grid-snap') lastSnapX = sx @@ -119,35 +112,32 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) { // because analytical normals are computed in segment-local space // and the yaw is applied explicitly, avoiding any world-vs-local // normal mismatch. - const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz) - if (!hit) return - - const segLocalNormal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment) + const segLocalNormal = getAnalyticalNormal(target.localX, target.localZ, target.segment) setPreviewSurfaceQuat(surfaceQuatFromNormal(segLocalNormal, new THREE.Quaternion())) - setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) - setPreviewPos(worldToBuildingLocal(wx, wy, wz)) + setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0)) + setPreviewPos( + roofSegmentLocalToBuildingLocal(target.segment.id, [ + target.localX, + target.localY, + target.localZ, + ]), + ) setHasHit(true) event.stopPropagation() } const onRoofClick = (event: RoofEvent) => { - const roof = event.node as RoofNode const st = useScene.getState() - const hit = resolveRoofSegmentHit( - roof, - event.position[0], - event.position[1], - event.position[2], - ) - if (!hit) return + const target = lastTarget ?? roofDrag.resolve(event) + if (!target) return - const targetSegmentId = hit.segment.id as AnyNodeId + const targetSegmentId = target.segment.id as AnyNodeId // Compute segment-local normal for the committed node so the // renderer's surfaceQuat + outer segment.rotation compose to // the same world orientation the ghost showed. - const segLocalNormal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment) + const segLocalNormal = getAnalyticalNormal(target.localX, target.localZ, target.segment) st.updateNode(node.id as AnyNodeId, { position: original.position, @@ -161,7 +151,7 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) { st.updateNode(node.id as AnyNodeId, { roofSegmentId: targetSegmentId, parentId: targetSegmentId, - position: [hit.localX, hit.localY, hit.localZ], + position: [target.localX, target.localY, target.localZ], rotation: original.rotation, // Segment-local normal — must stay consistent with getAnalyticalNormal // semantics so the renderer's surfaceQuat is in the correct frame. diff --git a/packages/nodes/src/turbine-vent/move-tool.tsx b/packages/nodes/src/turbine-vent/move-tool.tsx index 5db5615c..3f45e9bb 100644 --- a/packages/nodes/src/turbine-vent/move-tool.tsx +++ b/packages/nodes/src/turbine-vent/move-tool.tsx @@ -4,17 +4,19 @@ import { type AnyNodeId, emitter, type RoofEvent, - type RoofNode, type RoofSegmentNode, sceneRegistry, type TurbineVentNode, useScene, } from '@pascal-app/core' import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' -import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { + createRelativeRoofDrag, + type RelativeRoofDragTarget, + roofSegmentLocalToBuildingLocal, +} from '../shared/relative-roof-drag' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import TurbineVentPreview from './preview' @@ -54,48 +56,39 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode }) const ventObj = sceneRegistry.nodes.get(node.id) if (ventObj) ventObj.visible = false - const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => { - const buildingId = useViewer.getState().selection.buildingId - const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null - if (!buildingObj) return [wx, wy, wz] - const v = new THREE.Vector3(wx, wy, wz) - buildingObj.worldToLocal(v) - return [v.x, v.y, v.z] - } - let lastSnap: [number, number] | null = null + let lastTarget: RelativeRoofDragTarget | null = null + const roofDrag = createRelativeRoofDrag(original) const updatePreview = (event: RoofEvent) => { - const wx = event.position[0] - const wy = event.position[1] - const wz = event.position[2] + const target = roofDrag.resolve(event) + if (!target) return + lastTarget = target - const sx = Math.round(wx * 20) / 20 - const sz = Math.round(wz * 20) / 20 + const sx = Math.round(target.localX * 20) / 20 + const sz = Math.round(target.localZ * 20) / 20 if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) { triggerSFX('sfx:grid-snap') lastSnap = [sx, sz] } - const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz) - if (!hit) return - - const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment) + const normal = getAnalyticalNormal(target.localX, target.localZ, target.segment) setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) - setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) - setPreviewPos(worldToBuildingLocal(wx, wy, wz)) + setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0)) + setPreviewPos( + roofSegmentLocalToBuildingLocal(target.segment.id, [ + target.localX, + target.localY, + target.localZ, + ]), + ) event.stopPropagation() } const onRoofClick = (event: RoofEvent) => { - const hit = resolveRoofSegmentHit( - event.node as RoofNode, - event.position[0], - event.position[1], - event.position[2], - ) - if (!hit) return - const targetSegmentId = hit.segment.id as AnyNodeId + const target = lastTarget ?? roofDrag.resolve(event) + if (!target) return + const targetSegmentId = target.segment.id as AnyNodeId const st = useScene.getState() const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined @@ -119,7 +112,7 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode }) st.updateNode(node.id as AnyNodeId, { roofSegmentId: targetSegmentId, parentId: targetSegmentId, - position: [hit.localX, hit.localY, hit.localZ], + position: [target.localX, target.localY, target.localZ], rotation: original.rotation, visible: true, metadata: {}, diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 9d5ba082..031b795c 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -86,6 +86,24 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode } let currentWallId: string | null = movingWindowNode.parentId + let dragAnchor: { + wallId: string + rawX: number + rawY: number + startX: number + startY: number + } | null = null + let lastTarget: { + wallNode: WallEvent['node'] + wallId: string + side: WindowNode['side'] + itemRotation: number + cursorRotation: number + clampedX: number + clampedY: number + valid: boolean + event: WallEvent + } | null = null const markWallDirty = (wallId: string | null) => { if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId) @@ -140,7 +158,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44) } - const onWallEnter = (event: WallEvent) => { + const resolveMoveTarget = (event: WallEvent) => { if (!isValidWallSideFace(event.normal)) return if (isCurvedWall(event.node)) { hideCursor() @@ -153,40 +171,35 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const itemRotation = calculateItemRotation(event.normal) const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) + const rawLocalX = event.localPosition[0] + const rawLocalY = event.localPosition[1] + if (!dragAnchor || dragAnchor.wallId !== event.node.id) { + dragAnchor = { + wallId: event.node.id, + rawX: rawLocalX, + rawY: rawLocalY, + startX: event.node.id === original.parentId ? original.position[0] : rawLocalX, + startY: + event.node.id === original.parentId ? original.position[1] : snapToHalf(rawLocalY), + } + } + const targetLocalX = dragAnchor.startX + (rawLocalX - dragAnchor.rawX) + const targetLocalY = snapToHalf(dragAnchor.startY + (rawLocalY - dragAnchor.rawY)) const localX = resolveWallSlideAlignment({ wallNode: event.node, - rawLocalX: event.localPosition[0], + rawLocalX: targetLocalX, width: movingWindowNode.width, candidates: alignmentCandidates, bypass: event.nativeEvent?.altKey === true, }) - const localY = snapToHalf(event.localPosition[1]) const { clampedX, clampedY } = clampToWall( event.node, localX, - localY, + targetLocalY, movingWindowNode.width, movingWindowNode.height, ) - const prevWallId = currentWallId - currentWallId = event.node.id - - useScene.getState().updateNode(movingWindowNode.id, { - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - parentId: event.node.id, - wallId: event.node.id, - }) - useLiveTransforms.getState().set(movingWindowNode.id, { - position: [clampedX, clampedY, 0], - rotation: itemRotation, - }) - - if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId) - markWallDirtyThrottled(event.node.id) - const valid = !hasWallChildOverlap( event.node.id, clampedX, @@ -196,17 +209,62 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode movingWindowNode.id, ) + return { + wallNode: event.node, + wallId: event.node.id, + side, + itemRotation, + cursorRotation, + clampedX, + clampedY, + valid, + event, + } + } + + const applyPreview = (target: NonNullable) => { + if (currentWallId !== target.wallId) { + useScene.getState().updateNode(movingWindowNode.id, { + position: [target.clampedX, target.clampedY, 0], + rotation: [0, target.itemRotation, 0], + side: target.side, + parentId: target.wallId, + wallId: target.wallId, + }) + markWallDirty(currentWallId) + currentWallId = target.wallId + } else { + const windowMesh = sceneRegistry.nodes.get(movingWindowNode.id as AnyNodeId) + if (windowMesh) { + windowMesh.position.set(target.clampedX, target.clampedY, 0) + windowMesh.rotation.set(0, target.itemRotation, 0) + windowMesh.updateMatrixWorld(true) + } + } + useLiveTransforms.getState().set(movingWindowNode.id, { + position: [target.clampedX, target.clampedY, 0], + rotation: target.itemRotation, + }) + markWallDirtyThrottled(target.wallId) + updateCursor( wallLocalToWorld( - event.node, - clampedX, - clampedY, + target.wallNode, + target.clampedX, + target.clampedY, getLevelYOffset(), - getSlabElevation(event), + getSlabElevation(target.event), ), - cursorRotation, - valid, + target.cursorRotation, + target.valid, ) + } + + const onWallEnter = (event: WallEvent) => { + const target = resolveMoveTarget(event) + if (!target) return + lastTarget = target + applyPreview(target) event.stopPropagation() } @@ -219,73 +277,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode // Only interact with walls on the current level if (event.node.parentId !== getLevelId()) return - const side = getSideFromNormal(event.normal) - const itemRotation = calculateItemRotation(event.normal) - const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) - - const localX = resolveWallSlideAlignment({ - wallNode: event.node, - rawLocalX: event.localPosition[0], - width: movingWindowNode.width, - candidates: alignmentCandidates, - bypass: event.nativeEvent?.altKey === true, - }) - const localY = snapToHalf(event.localPosition[1]) - const { clampedX, clampedY } = clampToWall( - event.node, - localX, - localY, - movingWindowNode.width, - movingWindowNode.height, - ) - - if (currentWallId !== event.node.id) { - // Wall changed mid-move: must updateNode to reparent - useScene.getState().updateNode(movingWindowNode.id, { - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - parentId: event.node.id, - wallId: event.node.id, - }) - markWallDirty(currentWallId) - currentWallId = event.node.id - } else { - // Same wall: update Three.js mesh directly to avoid store churn - // collectCutoutBrushes reads cutoutMesh.matrixWorld, not scene store positions - const windowMesh = sceneRegistry.nodes.get(movingWindowNode.id as AnyNodeId) - if (windowMesh) { - windowMesh.position.set(clampedX, clampedY, 0) - windowMesh.rotation.set(0, itemRotation, 0) - windowMesh.updateMatrixWorld(true) - } - } - useLiveTransforms.getState().set(movingWindowNode.id, { - position: [clampedX, clampedY, 0], - rotation: itemRotation, - }) - markWallDirtyThrottled(event.node.id) - - const valid = !hasWallChildOverlap( - event.node.id, - clampedX, - clampedY, - movingWindowNode.width, - movingWindowNode.height, - movingWindowNode.id, - ) - - updateCursor( - wallLocalToWorld( - event.node, - clampedX, - clampedY, - getLevelYOffset(), - getSlabElevation(event), - ), - cursorRotation, - valid, - ) + const target = resolveMoveTarget(event) + if (!target) return + lastTarget = target + applyPreview(target) event.stopPropagation() } @@ -295,34 +290,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode // Only interact with walls on the current level if (event.node.parentId !== getLevelId()) return - const side = getSideFromNormal(event.normal) - const itemRotation = calculateItemRotation(event.normal) - - const localX = resolveWallSlideAlignment({ - wallNode: event.node, - rawLocalX: event.localPosition[0], - width: movingWindowNode.width, - candidates: alignmentCandidates, - bypass: event.nativeEvent?.altKey === true, - }) - const localY = snapToHalf(event.localPosition[1]) - const { clampedX, clampedY } = clampToWall( - event.node, - localX, - localY, - movingWindowNode.width, - movingWindowNode.height, - ) - - const valid = !hasWallChildOverlap( - event.node.id, - clampedX, - clampedY, - movingWindowNode.width, - movingWindowNode.height, - movingWindowNode.id, - ) - if (!valid) return + const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event) + if (!target?.valid) return let placedId: string @@ -341,13 +310,13 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const node = WindowNode.parse({ ...cloned, - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - wallId: event.node.id, - parentId: event.node.id, + position: [target.clampedX, target.clampedY, 0], + rotation: [0, target.itemRotation, 0], + side: target.side, + wallId: target.wallId, + parentId: target.wallId, }) - useScene.getState().createNode(node, event.node.id as AnyNodeId) + useScene.getState().createNode(node, target.wallId as AnyNodeId) placedId = node.id } else { // Move mode: restore original (clean baseline) + resume + updateNode @@ -363,21 +332,21 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode useScene.temporal.getState().resume() useScene.getState().updateNode(movingWindowNode.id, { - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - parentId: event.node.id, - wallId: event.node.id, + position: [target.clampedX, target.clampedY, 0], + rotation: [0, target.itemRotation, 0], + side: target.side, + parentId: target.wallId, + wallId: target.wallId, metadata: {}, }) - if (original.parentId && original.parentId !== event.node.id) { + if (original.parentId && original.parentId !== target.wallId) { markWallDirty(original.parentId) } placedId = movingWindowNode.id } - markWallDirty(event.node.id) + markWallDirty(target.wallId) useLiveTransforms.getState().clear(movingWindowNode.id) useScene.temporal.getState().pause() @@ -391,6 +360,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const onWallLeave = () => { hideCursor() useLiveTransforms.getState().clear(movingWindowNode.id) + dragAnchor = null + lastTarget = null if (isNew) return // No original to restore for duplicates // Move mode: restore to original position while off-wall if (currentWallId && currentWallId !== original.parentId) {