Fix relative move drag offsets

This commit is contained in:
Aymeric Rabot
2026-06-07 18:18:46 -04:00
parent b2e7a090d6
commit be0f491bbd
21 changed files with 712 additions and 623 deletions
@@ -40,6 +40,7 @@ export function MoveElevatorTool({
const onCommittedRef = useRef(onCommitted) const onCommittedRef = useRef(onCommitted)
const historyPausedRef = useRef(false) const historyPausedRef = useRef(false)
const previousGridPosRef = useRef<[number, number] | null>(null) const previousGridPosRef = useRef<[number, number] | null>(null)
const dragAnchorRef = useRef<[number, number] | null>(null)
const previewPositionRef = useRef<ElevatorNode['position']>([ const previewPositionRef = useRef<ElevatorNode['position']>([
movingNode.position[0], movingNode.position[0],
movingNode.position[1], movingNode.position[1],
@@ -73,6 +74,8 @@ export function MoveElevatorTool({
} }
pauseHistory() pauseHistory()
dragAnchorRef.current = null
previousGridPosRef.current = null
const movingNodeId = (movingNode as { id?: ElevatorNode['id'] }).id const movingNodeId = (movingNode as { id?: ElevatorNode['id'] }).id
const meta = const meta =
@@ -128,8 +131,12 @@ export function MoveElevatorTool({
} }
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
const gridX = Math.round(event.localPosition[0] * 2) / 2 const rawX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.localPosition[2] * 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({ const supportY = resolveElevatorSupportY({
buildingId: supportBuildingId, buildingId: supportBuildingId,
preferredLevelId: supportLevelId, preferredLevelId: supportLevelId,
@@ -151,15 +158,7 @@ export function MoveElevatorTool({
} }
const onGridClick = (event: GridEvent) => { const onGridClick = (event: GridEvent) => {
const gridX = Math.round(event.localPosition[0] * 2) / 2 const nextPosition: ElevatorNode['position'] = [...previewPositionRef.current]
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]
wasCommitted = true wasCommitted = true
clearPreview() clearPreview()
@@ -195,6 +195,8 @@ export interface PlacementCoordinatorConfig {
initialState?: PlacementState initialState?: PlacementState
/** Scale to use when lazily creating a draft (e.g. for wall/ceiling duplicates). Defaults to [1,1,1]. */ /** Scale to use when lazily creating a draft (e.g. for wall/ceiling duplicates). Defaults to [1,1,1]. */
defaultScale?: [number, number, number] 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 { 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 // building-local, matching the draft's grid position and the guide
// layer's frame. // layer's frame.
let alignmentCandidates: AlignmentAnchor[] | null = null let alignmentCandidates: AlignmentAnchor[] | null = null
let floorDragAnchor: [number, number] | null = null
// Reset placement state // Reset placement state
placementState.current = configRef.current.initialState ?? { placementState.current = configRef.current.initialState ?? {
@@ -526,6 +529,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// ---- Init draft ---- // ---- Init draft ----
configRef.current.initDraft(gridPosition.current) 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 // Sync cursor to the draft mesh's world position and rotation
if (draftNode.current) { if (draftNode.current) {
@@ -649,9 +657,31 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
detachItemSurfaceToFloor(event as unknown as ItemEvent) 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 if (!cursorGroupRef.current) return
const result = floorStrategy.move(getContext(), event) const result = floorStrategy.move(getContext(), floorEvent)
if (!result) return if (!result) return
// Figma-style alignment snap layered on top of the floor strategy's // 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 const draft = draftNode.current
let alignX = 0 let alignX = 0
let alignZ = 0 let alignZ = 0
const bypassAlign = event.nativeEvent?.altKey === true const bypassAlign = floorEvent.nativeEvent?.altKey === true
if (!bypassAlign && draft) { if (!bypassAlign && draft) {
alignmentCandidates ??= collectAlignmentAnchors( alignmentCandidates ??= collectAlignmentAnchors(
useScene.getState().nodes, useScene.getState().nodes,
@@ -125,6 +125,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
* commit position consistent with the visible cursor. * commit position consistent with the visible cursor.
*/ */
const lastCursorRef = useRef<[number, number, number]>(originalPosition) 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 * 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 * 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(() => { useEffect(() => {
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
previousSnapRef.current = null previousSnapRef.current = null
dragAnchorRef.current = null
hasMovedRef.current = false hasMovedRef.current = false
rotationRef.current = originalRotationY rotationRef.current = originalRotationY
shiftRef.current = false shiftRef.current = false
@@ -267,8 +269,13 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
) )
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
let x = snapToGridStep(event.localPosition[0]) const rawX = event.localPosition[0]
let z = snapToGridStep(event.localPosition[2]) 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 // 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, // moving item's edge lines up (on X or Z) with another item's edge,
+25 -32
View File
@@ -5,16 +5,18 @@ import {
type BoxVentNode, type BoxVentNode,
emitter, emitter,
type RoofEvent, type RoofEvent,
type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
sceneRegistry, sceneRegistry,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import * as THREE from 'three' 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 { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
import BoxVentPreview from './preview' import BoxVentPreview from './preview'
@@ -55,48 +57,39 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
const ventObj = sceneRegistry.nodes.get(node.id) const ventObj = sceneRegistry.nodes.get(node.id)
if (ventObj) ventObj.visible = false 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 lastSnap: [number, number] | null = null
let lastTarget: RelativeRoofDragTarget | null = null
const roofDrag = createRelativeRoofDrag(original)
const updatePreview = (event: RoofEvent) => { const updatePreview = (event: RoofEvent) => {
const wx = event.position[0] const target = roofDrag.resolve(event)
const wy = event.position[1] if (!target) return
const wz = event.position[2] lastTarget = target
const sx = Math.round(wx * 20) / 20 const sx = Math.round(target.localX * 20) / 20
const sz = Math.round(wz * 20) / 20 const sz = Math.round(target.localZ * 20) / 20
if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) { if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
lastSnap = [sx, sz] lastSnap = [sx, sz]
} }
const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz) const normal = getAnalyticalNormal(target.localX, target.localZ, target.segment)
if (!hit) return
const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment)
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0))
setPreviewPos(worldToBuildingLocal(wx, wy, wz)) setPreviewPos(
roofSegmentLocalToBuildingLocal(target.segment.id, [
target.localX,
target.localY,
target.localZ,
]),
)
event.stopPropagation() event.stopPropagation()
} }
const onRoofClick = (event: RoofEvent) => { const onRoofClick = (event: RoofEvent) => {
const hit = resolveRoofSegmentHit( const target = lastTarget ?? roofDrag.resolve(event)
event.node as RoofNode, if (!target) return
event.position[0], const targetSegmentId = target.segment.id as AnyNodeId
event.position[1],
event.position[2],
)
if (!hit) return
const targetSegmentId = hit.segment.id as AnyNodeId
const st = useScene.getState() const st = useScene.getState()
// Reparent if the cursor landed on a different segment than the // 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, { st.updateNode(node.id as AnyNodeId, {
roofSegmentId: targetSegmentId, roofSegmentId: targetSegmentId,
parentId: targetSegmentId, parentId: targetSegmentId,
position: [hit.localX, hit.localY, hit.localZ], position: [target.localX, target.localY, target.localZ],
rotation: original.rotation, rotation: original.rotation,
visible: true, visible: true,
metadata: {}, metadata: {},
+17 -7
View File
@@ -17,6 +17,7 @@ const Y_AXIS = new THREE.Vector3(0, 1, 0)
export function MoveBuildingContent({ node }: { node: BuildingNode }) { export function MoveBuildingContent({ node }: { node: BuildingNode }) {
const previousGridPosRef = useRef<[number, number] | null>(null) 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 // Stable refs so the effect never needs node in its dependency array
const nodeIdRef = useRef(node.id) const nodeIdRef = useRef(node.id)
@@ -29,9 +30,8 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
const pendingRotationRef = useRef<number>(node.rotation[1] ?? 0) const pendingRotationRef = useRef<number>(node.rotation[1] ?? 0)
// Local-space offset from the building's origin to its bbox center. The // 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 // move preview preserves the first pointer-to-center delta, then uses this
// the cursor during the drag — otherwise the raw origin (often nowhere near // offset to write the origin while keeping rotation around the visual center.
// the visual center) would snap to the cursor and the building would jump.
const centerOffsetLocalRef = useRef<THREE.Vector3>(new THREE.Vector3()) const centerOffsetLocalRef = useRef<THREE.Vector3>(new THREE.Vector3())
const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => { const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => {
@@ -66,8 +66,15 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
const offsetWork = new THREE.Vector3() const offsetWork = new THREE.Vector3()
const offsetAt = (rotationY: number) => const offsetAt = (rotationY: number) =>
offsetWork.copy(centerOffsetLocalRef.current).applyAxisAngle(Y_AXIS, rotationY) 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() useScene.temporal.getState().pause()
dragAnchorRef.current = null
previousGridPosRef.current = null
// Publish the building's current pose to useLiveTransforms so the // Publish the building's current pose to useLiveTransforms so the
// floor-plan (and any other live consumers) can follow per-frame // 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 onGridMove = (event: GridEvent) => {
const gridX = Math.round(event.position[0] * 2) / 2 const rawX = Math.round(event.position[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 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 ( if (
previousGridPosRef.current && previousGridPosRef.current &&
@@ -138,8 +149,7 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
} }
const onGridClick = (event: GridEvent) => { const onGridClick = (event: GridEvent) => {
const gridX = Math.round(event.position[0] * 2) / 2 const [gridX, gridZ] = previousGridPosRef.current ?? originalCenter
const gridZ = Math.round(event.position[2] * 2) / 2
wasCommitted = true wasCommitted = true
+26 -29
View File
@@ -6,7 +6,6 @@ import {
ChimneyNode as ChimneyNodeSchema, ChimneyNode as ChimneyNodeSchema,
emitter, emitter,
type RoofEvent, type RoofEvent,
type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
sceneRegistry, sceneRegistry,
useScene, useScene,
@@ -15,7 +14,7 @@ import { triggerSFX, useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three' 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' import ChimneyPreview from './preview'
const tmpMatrix = new THREE.Matrix4() const tmpMatrix = new THREE.Matrix4()
@@ -84,38 +83,36 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => {
} }
} }
const updatePreview = (event: RoofEvent) => { let lastTarget: RelativeRoofDragTarget | null = null
const wx = event.position[0] const roofDrag = createRelativeRoofDrag({
const wy = event.position[1] position: [...node.position] as [number, number, number],
const wz = event.position[2] roofSegmentId: node.roofSegmentId,
})
const sx = Math.round(wx * 20) / 20 const updatePreview = (event: RoofEvent) => {
const sz = Math.round(wz * 20) / 20 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 const prev = lastSnapRef.current
if (!prev || prev[0] !== sx || prev[1] !== sz) { if (!prev || prev[0] !== sx || prev[1] !== sz) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
lastSnapRef.current = [sx, sz] lastSnapRef.current = [sx, sz]
} }
const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz) const xform = computeSegmentXform(target.segment.id)
if (!hit) return
const xform = computeSegmentXform(hit.segment.id)
if (!xform) return if (!xform) return
setSegmentXform(xform) setSegmentXform(xform)
setHitLocal([hit.localX, hit.localY, hit.localZ]) setHitLocal([target.localX, target.localY, target.localZ])
setPreviewSegment(hit.segment) setPreviewSegment(target.segment)
event.stopPropagation() event.stopPropagation()
} }
const onClick = (event: RoofEvent) => { const onClick = (event: RoofEvent) => {
const hit = resolveRoofSegmentHit( const target = lastTarget ?? roofDrag.resolve(event)
event.node as RoofNode, if (!target) return
event.position[0],
event.position[1],
event.position[2],
)
if (!hit) return
const state = useScene.getState() const state = useScene.getState()
// Strip the `isNew` flag — only used to mark a duplicate clone // Strip the `isNew` flag — only used to mark a duplicate clone
@@ -135,23 +132,23 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => {
const committed = ChimneyNodeSchema.parse({ const committed = ChimneyNodeSchema.parse({
...node, ...node,
id: undefined as never, id: undefined as never,
roofSegmentId: hit.segment.id, roofSegmentId: target.segment.id,
position: [hit.localX, hit.localY, hit.localZ], position: [target.localX, target.localY, target.localZ],
metadata: cleanedMeta, metadata: cleanedMeta,
}) })
state.createNode(committed, hit.segment.id as AnyNodeId) state.createNode(committed, target.segment.id as AnyNodeId)
state.dirtyNodes.add(hit.segment.id as AnyNodeId) state.dirtyNodes.add(target.segment.id as AnyNodeId)
setSelection({ selectedIds: [committed.id] }) setSelection({ selectedIds: [committed.id] })
} else { } else {
const prevSegmentId = node.roofSegmentId as AnyNodeId | undefined const prevSegmentId = node.roofSegmentId as AnyNodeId | undefined
state.updateNode(node.id as AnyNodeId, { state.updateNode(node.id as AnyNodeId, {
roofSegmentId: hit.segment.id, roofSegmentId: target.segment.id,
parentId: hit.segment.id, parentId: target.segment.id,
position: [hit.localX, hit.localY, hit.localZ], position: [target.localX, target.localY, target.localZ],
metadata: cleanedMeta, metadata: cleanedMeta,
}) })
if (prevSegmentId) state.dirtyNodes.add(prevSegmentId) 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] }) setSelection({ selectedIds: [node.id] })
} }
setMovingNode(null) setMovingNode(null)
+6 -2
View File
@@ -70,6 +70,7 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
let rotationY = node.rotation let rotationY = node.rotation
// Latest previewed position, so an R/T press can re-apply at the spot. // Latest previewed position, so an R/T press can re-apply at the spot.
let lastPosition: [number, number, number] = node.position let lastPosition: [number, number, number] = node.position
let dragAnchor: [number, number] | null = null
const meta = const meta =
typeof node.metadata === 'object' && node.metadata !== null typeof node.metadata === 'object' && node.metadata !== null
? (node.metadata as Record<string, unknown>) ? (node.metadata as Record<string, unknown>)
@@ -111,8 +112,11 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
hasMoved = true hasMoved = true
let x = snapToGridStep(event.localPosition[0]) const rawX = event.localPosition[0]
let z = snapToGridStep(event.localPosition[2]) 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 // Figma-style alignment snap on top of grid snap; Alt bypasses. The
// guide connects to the candidate's nearest real anchor (resolver // guide connects to the candidate's nearest real anchor (resolver
+25 -32
View File
@@ -5,16 +5,18 @@ import {
type CupolaNode, type CupolaNode,
emitter, emitter,
type RoofEvent, type RoofEvent,
type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
sceneRegistry, sceneRegistry,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import * as THREE from 'three' 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 { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
import CupolaPreview from './preview' import CupolaPreview from './preview'
@@ -53,48 +55,39 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
const cupolaObj = sceneRegistry.nodes.get(node.id) const cupolaObj = sceneRegistry.nodes.get(node.id)
if (cupolaObj) cupolaObj.visible = false 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 lastSnap: [number, number] | null = null
let lastTarget: RelativeRoofDragTarget | null = null
const roofDrag = createRelativeRoofDrag(original)
const updatePreview = (event: RoofEvent) => { const updatePreview = (event: RoofEvent) => {
const wx = event.position[0] const target = roofDrag.resolve(event)
const wy = event.position[1] if (!target) return
const wz = event.position[2] lastTarget = target
const sx = Math.round(wx * 20) / 20 const sx = Math.round(target.localX * 20) / 20
const sz = Math.round(wz * 20) / 20 const sz = Math.round(target.localZ * 20) / 20
if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) { if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
lastSnap = [sx, sz] lastSnap = [sx, sz]
} }
const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz) const normal = getAnalyticalNormal(target.localX, target.localZ, target.segment)
if (!hit) return
const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment)
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0))
setPreviewPos(worldToBuildingLocal(wx, wy, wz)) setPreviewPos(
roofSegmentLocalToBuildingLocal(target.segment.id, [
target.localX,
target.localY,
target.localZ,
]),
)
event.stopPropagation() event.stopPropagation()
} }
const onRoofClick = (event: RoofEvent) => { const onRoofClick = (event: RoofEvent) => {
const hit = resolveRoofSegmentHit( const target = lastTarget ?? roofDrag.resolve(event)
event.node as RoofNode, if (!target) return
event.position[0], const targetSegmentId = target.segment.id as AnyNodeId
event.position[1],
event.position[2],
)
if (!hit) return
const targetSegmentId = hit.segment.id as AnyNodeId
const st = useScene.getState() const st = useScene.getState()
const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined
@@ -118,7 +111,7 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
st.updateNode(node.id as AnyNodeId, { st.updateNode(node.id as AnyNodeId, {
roofSegmentId: targetSegmentId, roofSegmentId: targetSegmentId,
parentId: targetSegmentId, parentId: targetSegmentId,
position: [hit.localX, hit.localY, hit.localZ], position: [target.localX, target.localY, target.localZ],
rotation: original.rotation, rotation: original.rotation,
visible: true, visible: true,
metadata: {}, metadata: {},
+95 -127
View File
@@ -66,6 +66,18 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
} }
let currentWallId: string | null = movingDoorNode.parentId 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) => { const markWallDirty = (wallId: string | null) => {
if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId) 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 (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) { if (isCurvedWall(event.node)) {
hideCursor() hideCursor()
@@ -141,9 +153,18 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
const { side, itemRotation, cursorRotation } = getPlacementOrientation(event) 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({ const localX = resolveWallSlideAlignment({
wallNode: event.node, wallNode: event.node,
rawLocalX: event.localPosition[0], rawLocalX: targetLocalX,
width: movingDoorNode.width, width: movingDoorNode.width,
candidates: alignmentCandidates, candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true, bypass: event.nativeEvent?.altKey === true,
@@ -155,24 +176,6 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
movingDoorNode.height, 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( const valid = !hasWallChildOverlap(
event.node.id, event.node.id,
clampedX, clampedX,
@@ -182,17 +185,62 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
movingDoorNode.id, movingDoorNode.id,
) )
return {
wallNode: event.node,
wallId: event.node.id,
side,
itemRotation,
cursorRotation,
clampedX,
clampedY,
valid,
event,
}
}
const applyPreview = (target: NonNullable<typeof lastTarget>) => {
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( updateCursor(
wallLocalToWorld( wallLocalToWorld(
event.node, target.wallNode,
clampedX, target.clampedX,
clampedY, target.clampedY,
getLevelYOffset(), getLevelYOffset(),
getSlabElevation(event), getSlabElevation(target.event),
), ),
cursorRotation, target.cursorRotation,
valid, target.valid,
) )
}
const onWallEnter = (event: WallEvent) => {
const target = resolveMoveTarget(event)
if (!target) return
lastTarget = target
applyPreview(target)
event.stopPropagation() event.stopPropagation()
} }
@@ -204,69 +252,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
} }
if (event.node.parentId !== getLevelId()) return if (event.node.parentId !== getLevelId()) return
const { side, itemRotation, cursorRotation } = getPlacementOrientation(event) const target = resolveMoveTarget(event)
if (!target) return
const localX = resolveWallSlideAlignment({ lastTarget = target
wallNode: event.node, applyPreview(target)
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,
)
event.stopPropagation() event.stopPropagation()
} }
@@ -275,31 +264,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
if (isCurvedWall(event.node)) return if (isCurvedWall(event.node)) return
if (event.node.parentId !== getLevelId()) return if (event.node.parentId !== getLevelId()) return
const { side, itemRotation } = getPlacementOrientation(event) const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event)
if (!target?.valid) return
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
let placedId: string let placedId: string
@@ -311,13 +277,13 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
delete cloned.id delete cloned.id
const node = DoorNode.parse({ const node = DoorNode.parse({
...cloned, ...cloned,
position: [clampedX, clampedY, 0], position: [target.clampedX, target.clampedY, 0],
rotation: [0, itemRotation, 0], rotation: [0, target.itemRotation, 0],
side, side: target.side,
wallId: event.node.id, wallId: target.wallId,
parentId: event.node.id, parentId: target.wallId,
}) })
useScene.getState().createNode(node, event.node.id as AnyNodeId) useScene.getState().createNode(node, target.wallId as AnyNodeId)
placedId = node.id placedId = node.id
} else { } else {
useScene.getState().updateNode(movingDoorNode.id, { useScene.getState().updateNode(movingDoorNode.id, {
@@ -331,21 +297,21 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
useScene.getState().updateNode(movingDoorNode.id, { useScene.getState().updateNode(movingDoorNode.id, {
position: [clampedX, clampedY, 0], position: [target.clampedX, target.clampedY, 0],
rotation: [0, itemRotation, 0], rotation: [0, target.itemRotation, 0],
side, side: target.side,
parentId: event.node.id, parentId: target.wallId,
wallId: event.node.id, wallId: target.wallId,
metadata: {}, metadata: {},
}) })
if (original.parentId && original.parentId !== event.node.id) { if (original.parentId && original.parentId !== target.wallId) {
markWallDirty(original.parentId) markWallDirty(original.parentId)
} }
placedId = movingDoorNode.id placedId = movingDoorNode.id
} }
markWallDirty(event.node.id) markWallDirty(target.wallId)
useLiveTransforms.getState().clear(movingDoorNode.id) useLiveTransforms.getState().clear(movingDoorNode.id)
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
@@ -359,6 +325,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
const onWallLeave = () => { const onWallLeave = () => {
hideCursor() hideCursor()
useLiveTransforms.getState().clear(movingDoorNode.id) useLiveTransforms.getState().clear(movingDoorNode.id)
dragAnchor = null
lastTarget = null
if (isNew) return if (isNew) return
if (currentWallId && currentWallId !== original.parentId) { if (currentWallId && currentWallId !== original.parentId) {
markWallDirty(currentWallId) markWallDirty(currentWallId)
+5 -1
View File
@@ -51,6 +51,7 @@ const MoveDormerTool = ({ node }: { node: DormerNode }) => {
const originalRotation = node.rotation ?? 0 const originalRotation = node.rotation ?? 0
const originalMetadata = node.metadata const originalMetadata = node.metadata
// biome-ignore lint/correctness/useExhaustiveDependencies: capture-on-mount; meta is intentionally not re-read on changes.
useEffect(() => { useEffect(() => {
if (!isNew) { if (!isNew) {
useScene.getState().updateNode(node.id as AnyNodeId, { 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]) }, [node.id, isNew])
const { activeBuildingId, segmentXform, hitLocal, ghostRotation } = useDormerPlacement({ const { activeBuildingId, segmentXform, hitLocal, ghostRotation } = useDormerPlacement({
initialRotation: originalRotation, initialRotation: originalRotation,
relativeStart: {
position: [...node.position] as [number, number, number],
roofSegmentId: node.roofSegmentId,
},
onCommit: (hit, rotation) => { onCommit: (hit, rotation) => {
const state = useScene.getState() const state = useScene.getState()
@@ -10,6 +10,7 @@ import { triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { createRelativeRoofDrag } from '../shared/relative-roof-drag'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
import { DORMER_PLACEMENT_ROTATION_STEP, DORMER_PLACEMENT_SNAP_M } from './geometry' import { DORMER_PLACEMENT_ROTATION_STEP, DORMER_PLACEMENT_SNAP_M } from './geometry'
@@ -50,6 +51,10 @@ export type DormerPlacementHit = {
*/ */
export function useDormerPlacement(opts: { export function useDormerPlacement(opts: {
initialRotation?: number initialRotation?: number
relativeStart?: {
position: [number, number, number]
roofSegmentId?: string
}
onCommit: (hit: DormerPlacementHit, rotation: number) => void onCommit: (hit: DormerPlacementHit, rotation: number) => void
}): { }): {
activeBuildingId: string | undefined activeBuildingId: string | undefined
@@ -66,6 +71,7 @@ export function useDormerPlacement(opts: {
// Mirror of `ghostRotation` so the click handler (registered once // Mirror of `ghostRotation` so the click handler (registered once
// inside useEffect) can read the latest value at commit time. // inside useEffect) can read the latest value at commit time.
const ghostRotationRef = useRef(opts.initialRotation ?? 0) const ghostRotationRef = useRef(opts.initialRotation ?? 0)
const relativeStartRef = useRef(opts.relativeStart)
// Latest commit callback, captured via ref so the useEffect doesn't // 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 // need it in its dep list (we don't want to re-register listeners
// every time the parent rerenders). // 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 updatePreview = (event: RoofEvent) => {
const wx = event.position[0] const wx = event.position[0]
const wy = event.position[1]
const wz = event.position[2] const wz = event.position[2]
const sx = Math.round(wx / DORMER_PLACEMENT_SNAP_M) * DORMER_PLACEMENT_SNAP_M 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] lastSnapRef.current = [sx, sz]
} }
const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz) const hit = resolvePlacementHit(event)
if (!hit) return if (!hit) return
if (roofDrag) lastRelativeHit = hit
const xform = computeSegmentXform(hit.segment.id) const xform = computeSegmentXform(hit.segment.id)
if (!xform) return if (!xform) return
setSegmentXform(xform) setSegmentXform(xform)
@@ -118,12 +139,9 @@ export function useDormerPlacement(opts: {
} }
const onClick = (event: RoofEvent) => { const onClick = (event: RoofEvent) => {
const hit = resolveRoofSegmentHit( const hit = roofDrag
event.node as RoofNode, ? (lastRelativeHit ?? resolvePlacementHit(event))
event.position[0], : resolvePlacementHit(event)
event.position[1],
event.position[2],
)
if (!hit) return if (!hit) return
onCommitRef.current(hit, ghostRotationRef.current) onCommitRef.current(hit, ghostRotationRef.current)
triggerSFX('sfx:item-place') triggerSFX('sfx:item-place')
+25 -32
View File
@@ -5,16 +5,18 @@ import {
type EyebrowVentNode, type EyebrowVentNode,
emitter, emitter,
type RoofEvent, type RoofEvent,
type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
sceneRegistry, sceneRegistry,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import * as THREE from 'three' 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 { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
import EyebrowVentPreview from './preview' import EyebrowVentPreview from './preview'
@@ -54,48 +56,39 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
const ventObj = sceneRegistry.nodes.get(node.id) const ventObj = sceneRegistry.nodes.get(node.id)
if (ventObj) ventObj.visible = false 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 lastSnap: [number, number] | null = null
let lastTarget: RelativeRoofDragTarget | null = null
const roofDrag = createRelativeRoofDrag(original)
const updatePreview = (event: RoofEvent) => { const updatePreview = (event: RoofEvent) => {
const wx = event.position[0] const target = roofDrag.resolve(event)
const wy = event.position[1] if (!target) return
const wz = event.position[2] lastTarget = target
const sx = Math.round(wx * 20) / 20 const sx = Math.round(target.localX * 20) / 20
const sz = Math.round(wz * 20) / 20 const sz = Math.round(target.localZ * 20) / 20
if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) { if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
lastSnap = [sx, sz] lastSnap = [sx, sz]
} }
const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz) const normal = getAnalyticalNormal(target.localX, target.localZ, target.segment)
if (!hit) return
const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment)
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0))
setPreviewPos(worldToBuildingLocal(wx, wy, wz)) setPreviewPos(
roofSegmentLocalToBuildingLocal(target.segment.id, [
target.localX,
target.localY,
target.localZ,
]),
)
event.stopPropagation() event.stopPropagation()
} }
const onRoofClick = (event: RoofEvent) => { const onRoofClick = (event: RoofEvent) => {
const hit = resolveRoofSegmentHit( const target = lastTarget ?? roofDrag.resolve(event)
event.node as RoofNode, if (!target) return
event.position[0], const targetSegmentId = target.segment.id as AnyNodeId
event.position[1],
event.position[2],
)
if (!hit) return
const targetSegmentId = hit.segment.id as AnyNodeId
const st = useScene.getState() const st = useScene.getState()
const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined
@@ -119,7 +112,7 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
st.updateNode(node.id as AnyNodeId, { st.updateNode(node.id as AnyNodeId, {
roofSegmentId: targetSegmentId, roofSegmentId: targetSegmentId,
parentId: targetSegmentId, parentId: targetSegmentId,
position: [hit.localX, hit.localY, hit.localZ], position: [target.localX, target.localY, target.localZ],
rotation: original.rotation, rotation: original.rotation,
visible: true, visible: true,
metadata: {}, metadata: {},
+27 -20
View File
@@ -12,7 +12,7 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
import { useCallback, useEffect, useState } from 'react' 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 { type EaveSnap, resolveEaveSnap } from './eave-snap'
import GutterPreview from './preview' import GutterPreview from './preview'
@@ -22,6 +22,11 @@ type PreviewTarget = {
snap: EaveSnap snap: EaveSnap
} }
type GutterDragTarget = {
segment: RoofSegmentNode
snap: EaveSnap
}
/** /**
* Gutter move tool. Mirrors the ridge-vent move flow — ghost follows * Gutter move tool. Mirrors the ridge-vent move flow — ghost follows
* the cursor over any roof segment, click commits the new position + * 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 if (gutterObj) gutterObj.visible = false
let lastSnap: [number, number] | null = null 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 updatePreview = (event: RoofEvent) => {
const roof = event.node as RoofNode const roof = event.node as RoofNode
const hit = resolveRoofSegmentHit( const target = resolveTarget(event)
roof, if (!target) return
event.position[0], lastTarget = target
event.position[1],
event.position[2],
)
if (!hit) return
// Same snap math as the placement tool — picking-up and putting- // Same snap math as the placement tool — picking-up and putting-
// down round-trip identically. roofType-aware: hip/flat picks // down round-trip identically. roofType-aware: hip/flat picks
// ±X or ±Z based on which slope the cursor is on; shed always // ±X or ±Z based on which slope the cursor is on; shed always
// snaps to its low (+Z) eave; gable / gambrel / mansard / dutch // snaps to its low (+Z) eave; gable / gambrel / mansard / dutch
// stay on ±Z. // stay on ±Z.
const snap = resolveEaveSnap(hit.segment, hit.localX, hit.localZ) const { snap } = target
const sx = Math.round(snap.eaveX * 20) / 20 const sx = Math.round(snap.eaveX * 20) / 20
const sz = Math.round(snap.eaveZ * 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, rotation: roof.rotation ?? 0,
}, },
segment: { segment: {
position: (hit.segment.position ?? [0, 0, 0]) as [number, number, number], position: (target.segment.position ?? [0, 0, 0]) as [number, number, number],
rotation: hit.segment.rotation ?? 0, rotation: target.segment.rotation ?? 0,
}, },
snap, snap,
}) })
@@ -105,15 +117,10 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
} }
const onRoofClick = (event: RoofEvent) => { const onRoofClick = (event: RoofEvent) => {
const hit = resolveRoofSegmentHit( const target = lastTarget ?? resolveTarget(event)
event.node as RoofNode, if (!target) return
event.position[0], const targetSegmentId = target.segment.id as AnyNodeId
event.position[1], const { snap } = target
event.position[2],
)
if (!hit) return
const targetSegmentId = hit.segment.id as AnyNodeId
const snap = resolveEaveSnap(hit.segment, hit.localX, hit.localZ)
const st = useScene.getState() const st = useScene.getState()
const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined
+1
View File
@@ -88,6 +88,7 @@ export function MoveItemTool({ node }: { node: ItemNode }) {
: getInitialState(node), : getInitialState(node),
// Preserve the original item's scale so Y-position calculations use the correct height. // Preserve the original item's scale so Y-position calculations use the correct height.
defaultScale: isNew ? node.scale : undefined, defaultScale: isNew ? node.scale : undefined,
preserveFloorDragOffset: true,
initDraft: (gridPosition) => { initDraft: (gridPosition) => {
if (isNew) { if (isNew) {
// Duplicate: floor items get a draft immediately; wall/ceiling // Duplicate: floor items get a draft immediately; wall/ceiling
+41 -32
View File
@@ -5,18 +5,25 @@ import {
emitter, emitter,
type RidgeVentNode, type RidgeVentNode,
type RoofEvent, type RoofEvent,
type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
sceneRegistry, sceneRegistry,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import * as THREE from 'three' import {
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' createRelativeRoofDrag,
type RelativeRoofDragTarget,
roofSegmentLocalToBuildingLocal,
} from '../shared/relative-roof-drag'
import { getSurfaceY } from '../shared/roof-surface'
import RidgeVentPreview from './preview' import RidgeVentPreview from './preview'
type RidgeVentDragTarget = Pick<RelativeRoofDragTarget, 'segment' | 'localX'> & {
localY: number
localZ: 0
}
/** /**
* Ridge-vent move tool. Mirrors the box-vent move flow — ghost follows * Ridge-vent move tool. Mirrors the box-vent move flow — ghost follows
* the cursor over any roof segment, click commits the new position + * 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) const ventObj = sceneRegistry.nodes.get(node.id)
if (ventObj) ventObj.visible = false if (ventObj) ventObj.visible = false
const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => { let lastSnap: [number, number] | null = null
const buildingId = useViewer.getState().selection.buildingId let lastTarget: RidgeVentDragTarget | null = null
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null const roofDrag = createRelativeRoofDrag(original)
if (!buildingObj) return [wx, wy, wz]
const v = new THREE.Vector3(wx, wy, wz) const resolveTarget = (event: RoofEvent): RidgeVentDragTarget | null => {
buildingObj.worldToLocal(v) const target = roofDrag.resolve(event)
return [v.x, v.y, v.z] 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 updatePreview = (event: RoofEvent) => {
const wx = event.position[0] const target = resolveTarget(event)
const wy = event.position[1] if (!target) return
const wz = event.position[2] lastTarget = target
const sx = Math.round(wx * 20) / 20 const sx = Math.round(target.localX * 20) / 20
const sz = Math.round(wz * 20) / 20 const sz = Math.round(target.localZ * 20) / 20
if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) { if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
lastSnap = [sx, sz] lastSnap = [sx, sz]
} }
const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz) setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0))
if (!hit) return setPreviewPos(
roofSegmentLocalToBuildingLocal(target.segment.id, [
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) target.localX,
setPreviewPos(worldToBuildingLocal(wx, wy, wz)) target.localY,
target.localZ,
]),
)
event.stopPropagation() event.stopPropagation()
} }
const onRoofClick = (event: RoofEvent) => { const onRoofClick = (event: RoofEvent) => {
const hit = resolveRoofSegmentHit( const target = lastTarget ?? resolveTarget(event)
event.node as RoofNode, if (!target) return
event.position[0], const targetSegmentId = target.segment.id as AnyNodeId
event.position[1],
event.position[2],
)
if (!hit) return
const targetSegmentId = hit.segment.id as AnyNodeId
const st = useScene.getState() const st = useScene.getState()
const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined
@@ -114,7 +123,7 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
st.updateNode(node.id as AnyNodeId, { st.updateNode(node.id as AnyNodeId, {
roofSegmentId: targetSegmentId, roofSegmentId: targetSegmentId,
parentId: targetSegmentId, parentId: targetSegmentId,
position: [hit.localX, hit.localY, hit.localZ], position: [target.localX, target.localY, target.localZ],
rotation: original.rotation, rotation: original.rotation,
visible: true, visible: true,
metadata: {}, metadata: {},
+44 -25
View File
@@ -41,6 +41,7 @@ export const MoveRoofTool: React.FC<{
}, []) }, [])
const previousGridPosRef = useRef<[number, number] | null>(null) const previousGridPosRef = useRef<[number, number] | null>(null)
const dragAnchorRef = useRef<[number, number] | null>(null)
const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => { const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => {
const obj = sceneRegistry.nodes.get(movingNode.id) const obj = sceneRegistry.nodes.get(movingNode.id)
@@ -78,6 +79,8 @@ export const MoveRoofTool: React.FC<{
useEffect(() => { useEffect(() => {
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
dragAnchorRef.current = null
previousGridPosRef.current = null
const meta = const meta =
typeof movingNode.metadata === 'object' && movingNode.metadata !== null typeof movingNode.metadata === 'object' && movingNode.metadata !== null
@@ -255,6 +258,24 @@ export const MoveRoofTool: React.FC<{
return [buildingLocalX, buildingLocalZ] 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 onGridMove = (event: GridEvent) => {
const y = event.position[1] const y = event.position[1]
@@ -263,29 +284,40 @@ export const MoveRoofTool: React.FC<{
walls: levelWalls, walls: levelWalls,
fences: levelFences, fences: levelFences,
}) })
// Layer alignment snap on top (top-level stair/roof). Recompute the const [rawGridX, , rawGridZ] = localToWorldPoint(snappedLocal, y)
// world point from the aligned building-local point so it stays correct const [rawLocalX, rawLocalZ] = computeLocal(
// under building rotation. rawGridX,
const [lx, lz] = alignLocalPoint( rawGridZ,
y,
snappedLocal[0], snappedLocal[0],
snappedLocal[1], 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 ( if (
previousGridPosRef.current && previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1]) (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
) { ) {
triggerSFX('sfx:grid-snap') 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] lastLocalPosition = [localX, movingNode.position[1], localZ]
const previewPosition = getPreviewPosition(lastLocalPosition) 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 // Directly update the Three.js mesh — no store update during drag
const mesh = sceneRegistry.nodes.get(movingNode.id) 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; // Floor-placed parents (stairs) stay in their committed local frame;
// the lifted Y remains presentation-only in the 3D view. // the lifted Y remains presentation-only in the 3D view.
useLiveTransforms.getState().set(movingNode.id, { useLiveTransforms.getState().set(movingNode.id, {
position: isFloorPlaced ? lastLocalPosition : [gridX, y, gridZ], position: lastLocalPosition,
rotation: pendingRotation, rotation: pendingRotation,
}) })
} }
const onGridClick = (event: GridEvent) => { const onGridClick = (event: GridEvent) => {
const y = event.position[1] const [localX, , localZ] = lastLocalPosition
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)
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
wasCommitted = true wasCommitted = true
@@ -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,
}
},
}
}
+23 -56
View File
@@ -11,35 +11,16 @@ import {
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import * as THREE from 'three' 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 { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
import SkylightPreview from './preview' import SkylightPreview from './preview'
function resolveSegmentFromWorldPoint(
roof: RoofNode,
worldX: number,
worldY: number,
worldZ: number,
state: ReturnType<typeof useScene.getState>,
): { 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 }) { export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
const exitMoveMode = useCallback(() => { const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null) useEditor.getState().setMovingNode(null)
@@ -81,19 +62,10 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
const skylightObj = sceneRegistry.nodes.get(node.id) const skylightObj = sceneRegistry.nodes.get(node.id)
if (skylightObj) skylightObj.visible = false 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 lastSnapX = 0
let lastSnapZ = 0 let lastSnapZ = 0
let lastTarget: RelativeRoofDragTarget | null = null
const roofDrag = createRelativeRoofDrag(original)
// Resolve which segment the cursor is over, then derive the same // Resolve which segment the cursor is over, then derive the same
// preview transform stack the placement tool uses (`skylight/tool.tsx`): // 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. // same via its `if (!hit) return` guard.
const updateFromHit = (event: RoofEvent) => { const updateFromHit = (event: RoofEvent) => {
const roof = event.node as RoofNode const roof = event.node as RoofNode
const hit = resolveRoofSegmentHit( const target = roofDrag.resolve(event)
roof, if (!target) {
event.position[0],
event.position[1],
event.position[2],
)
if (!hit) {
setHasHit(false) setHasHit(false)
return 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())) setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
setPreviewYaw((roof.rotation ?? 0) + (hit.segment.rotation ?? 0)) setPreviewYaw((roof.rotation ?? 0) + (target.segment.rotation ?? 0))
setPreviewPos(worldToBuildingLocal(event.position[0], event.position[1], event.position[2])) setPreviewPos(
roofSegmentLocalToBuildingLocal(target.segment.id, [
target.localX,
target.localY,
target.localZ,
]),
)
setHasHit(true) setHasHit(true)
return true return true
} }
@@ -139,19 +113,12 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
} }
const onRoofClick = (event: RoofEvent) => { const onRoofClick = (event: RoofEvent) => {
const roof = event.node as RoofNode
const st = useScene.getState() const st = useScene.getState()
const hit = resolveSegmentFromWorldPoint( const target = lastTarget ?? roofDrag.resolve(event)
roof, if (!target) return
event.position[0],
event.position[1],
event.position[2],
st,
)
if (!hit) return
const targetSegmentId = hit.segment.id as AnyNodeId const targetSegmentId = target.segment.id as AnyNodeId
const finalRotation = original.rotation const finalRotation = original.rotation
st.updateNode(node.id as AnyNodeId, { st.updateNode(node.id as AnyNodeId, {
@@ -166,7 +133,7 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
st.updateNode(node.id as AnyNodeId, { st.updateNode(node.id as AnyNodeId, {
roofSegmentId: targetSegmentId, roofSegmentId: targetSegmentId,
parentId: targetSegmentId, parentId: targetSegmentId,
position: [hit.localX, hit.localY, hit.localZ], position: [target.localX, target.localY, target.localZ],
rotation: finalRotation, rotation: finalRotation,
visible: true, visible: true,
metadata: {}, metadata: {},
+26 -36
View File
@@ -4,17 +4,19 @@ import {
type AnyNodeId, type AnyNodeId,
emitter, emitter,
type RoofEvent, type RoofEvent,
type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
type SolarPanelNode, type SolarPanelNode,
sceneRegistry, sceneRegistry,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { EDITOR_LAYER, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three' 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 { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
// MeshBasicMaterial: avoids the WebGPU "Color target has no corresponding // 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) const panelObj = sceneRegistry.nodes.get(node.id)
if (panelObj) panelObj.visible = false 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 lastSnapX = 0
let lastSnapZ = 0 let lastSnapZ = 0
let lastTarget: RelativeRoofDragTarget | null = null
const roofDrag = createRelativeRoofDrag(original)
const updateGhost = (event: RoofEvent) => { const updateGhost = (event: RoofEvent) => {
const wx = event.position[0] const target = roofDrag.resolve(event)
const wy = event.position[1] if (!target) return
const wz = event.position[2] lastTarget = target
const sx = Math.round(wx * 20) / 20 const sx = Math.round(target.localX * 20) / 20
const sz = Math.round(wz * 20) / 20 const sz = Math.round(target.localZ * 20) / 20
if (sx !== lastSnapX || sz !== lastSnapZ) { if (sx !== lastSnapX || sz !== lastSnapZ) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
lastSnapX = sx lastSnapX = sx
@@ -119,35 +112,32 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
// because analytical normals are computed in segment-local space // because analytical normals are computed in segment-local space
// and the yaw is applied explicitly, avoiding any world-vs-local // and the yaw is applied explicitly, avoiding any world-vs-local
// normal mismatch. // normal mismatch.
const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz) const segLocalNormal = getAnalyticalNormal(target.localX, target.localZ, target.segment)
if (!hit) return
const segLocalNormal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment)
setPreviewSurfaceQuat(surfaceQuatFromNormal(segLocalNormal, new THREE.Quaternion())) setPreviewSurfaceQuat(surfaceQuatFromNormal(segLocalNormal, new THREE.Quaternion()))
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0))
setPreviewPos(worldToBuildingLocal(wx, wy, wz)) setPreviewPos(
roofSegmentLocalToBuildingLocal(target.segment.id, [
target.localX,
target.localY,
target.localZ,
]),
)
setHasHit(true) setHasHit(true)
event.stopPropagation() event.stopPropagation()
} }
const onRoofClick = (event: RoofEvent) => { const onRoofClick = (event: RoofEvent) => {
const roof = event.node as RoofNode
const st = useScene.getState() const st = useScene.getState()
const hit = resolveRoofSegmentHit( const target = lastTarget ?? roofDrag.resolve(event)
roof, if (!target) return
event.position[0],
event.position[1],
event.position[2],
)
if (!hit) 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 // Compute segment-local normal for the committed node so the
// renderer's surfaceQuat + outer segment.rotation compose to // renderer's surfaceQuat + outer segment.rotation compose to
// the same world orientation the ghost showed. // 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, { st.updateNode(node.id as AnyNodeId, {
position: original.position, position: original.position,
@@ -161,7 +151,7 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
st.updateNode(node.id as AnyNodeId, { st.updateNode(node.id as AnyNodeId, {
roofSegmentId: targetSegmentId, roofSegmentId: targetSegmentId,
parentId: targetSegmentId, parentId: targetSegmentId,
position: [hit.localX, hit.localY, hit.localZ], position: [target.localX, target.localY, target.localZ],
rotation: original.rotation, rotation: original.rotation,
// Segment-local normal — must stay consistent with getAnalyticalNormal // Segment-local normal — must stay consistent with getAnalyticalNormal
// semantics so the renderer's surfaceQuat is in the correct frame. // semantics so the renderer's surfaceQuat is in the correct frame.
+25 -32
View File
@@ -4,17 +4,19 @@ import {
type AnyNodeId, type AnyNodeId,
emitter, emitter,
type RoofEvent, type RoofEvent,
type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
sceneRegistry, sceneRegistry,
type TurbineVentNode, type TurbineVentNode,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import * as THREE from 'three' 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 { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
import TurbineVentPreview from './preview' import TurbineVentPreview from './preview'
@@ -54,48 +56,39 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode })
const ventObj = sceneRegistry.nodes.get(node.id) const ventObj = sceneRegistry.nodes.get(node.id)
if (ventObj) ventObj.visible = false 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 lastSnap: [number, number] | null = null
let lastTarget: RelativeRoofDragTarget | null = null
const roofDrag = createRelativeRoofDrag(original)
const updatePreview = (event: RoofEvent) => { const updatePreview = (event: RoofEvent) => {
const wx = event.position[0] const target = roofDrag.resolve(event)
const wy = event.position[1] if (!target) return
const wz = event.position[2] lastTarget = target
const sx = Math.round(wx * 20) / 20 const sx = Math.round(target.localX * 20) / 20
const sz = Math.round(wz * 20) / 20 const sz = Math.round(target.localZ * 20) / 20
if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) { if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
lastSnap = [sx, sz] lastSnap = [sx, sz]
} }
const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz) const normal = getAnalyticalNormal(target.localX, target.localZ, target.segment)
if (!hit) return
const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment)
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0))
setPreviewPos(worldToBuildingLocal(wx, wy, wz)) setPreviewPos(
roofSegmentLocalToBuildingLocal(target.segment.id, [
target.localX,
target.localY,
target.localZ,
]),
)
event.stopPropagation() event.stopPropagation()
} }
const onRoofClick = (event: RoofEvent) => { const onRoofClick = (event: RoofEvent) => {
const hit = resolveRoofSegmentHit( const target = lastTarget ?? roofDrag.resolve(event)
event.node as RoofNode, if (!target) return
event.position[0], const targetSegmentId = target.segment.id as AnyNodeId
event.position[1],
event.position[2],
)
if (!hit) return
const targetSegmentId = hit.segment.id as AnyNodeId
const st = useScene.getState() const st = useScene.getState()
const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined
@@ -119,7 +112,7 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode })
st.updateNode(node.id as AnyNodeId, { st.updateNode(node.id as AnyNodeId, {
roofSegmentId: targetSegmentId, roofSegmentId: targetSegmentId,
parentId: targetSegmentId, parentId: targetSegmentId,
position: [hit.localX, hit.localY, hit.localZ], position: [target.localX, target.localY, target.localZ],
rotation: original.rotation, rotation: original.rotation,
visible: true, visible: true,
metadata: {}, metadata: {},
+107 -136
View File
@@ -86,6 +86,24 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
} }
let currentWallId: string | null = movingWindowNode.parentId 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) => { const markWallDirty = (wallId: string | null) => {
if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId) 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) edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
} }
const onWallEnter = (event: WallEvent) => { const resolveMoveTarget = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) { if (isCurvedWall(event.node)) {
hideCursor() hideCursor()
@@ -153,40 +171,35 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const itemRotation = calculateItemRotation(event.normal) const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) 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({ const localX = resolveWallSlideAlignment({
wallNode: event.node, wallNode: event.node,
rawLocalX: event.localPosition[0], rawLocalX: targetLocalX,
width: movingWindowNode.width, width: movingWindowNode.width,
candidates: alignmentCandidates, candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true, bypass: event.nativeEvent?.altKey === true,
}) })
const localY = snapToHalf(event.localPosition[1])
const { clampedX, clampedY } = clampToWall( const { clampedX, clampedY } = clampToWall(
event.node, event.node,
localX, localX,
localY, targetLocalY,
movingWindowNode.width, movingWindowNode.width,
movingWindowNode.height, 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( const valid = !hasWallChildOverlap(
event.node.id, event.node.id,
clampedX, clampedX,
@@ -196,17 +209,62 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
movingWindowNode.id, movingWindowNode.id,
) )
return {
wallNode: event.node,
wallId: event.node.id,
side,
itemRotation,
cursorRotation,
clampedX,
clampedY,
valid,
event,
}
}
const applyPreview = (target: NonNullable<typeof lastTarget>) => {
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( updateCursor(
wallLocalToWorld( wallLocalToWorld(
event.node, target.wallNode,
clampedX, target.clampedX,
clampedY, target.clampedY,
getLevelYOffset(), getLevelYOffset(),
getSlabElevation(event), getSlabElevation(target.event),
), ),
cursorRotation, target.cursorRotation,
valid, target.valid,
) )
}
const onWallEnter = (event: WallEvent) => {
const target = resolveMoveTarget(event)
if (!target) return
lastTarget = target
applyPreview(target)
event.stopPropagation() event.stopPropagation()
} }
@@ -219,73 +277,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
// Only interact with walls on the current level // Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal) const target = resolveMoveTarget(event)
const itemRotation = calculateItemRotation(event.normal) if (!target) return
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) lastTarget = target
applyPreview(target)
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,
)
event.stopPropagation() event.stopPropagation()
} }
@@ -295,34 +290,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
// Only interact with walls on the current level // Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal) const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event)
const itemRotation = calculateItemRotation(event.normal) if (!target?.valid) return
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
let placedId: string let placedId: string
@@ -341,13 +310,13 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const node = WindowNode.parse({ const node = WindowNode.parse({
...cloned, ...cloned,
position: [clampedX, clampedY, 0], position: [target.clampedX, target.clampedY, 0],
rotation: [0, itemRotation, 0], rotation: [0, target.itemRotation, 0],
side, side: target.side,
wallId: event.node.id, wallId: target.wallId,
parentId: event.node.id, parentId: target.wallId,
}) })
useScene.getState().createNode(node, event.node.id as AnyNodeId) useScene.getState().createNode(node, target.wallId as AnyNodeId)
placedId = node.id placedId = node.id
} else { } else {
// Move mode: restore original (clean baseline) + resume + updateNode // 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.temporal.getState().resume()
useScene.getState().updateNode(movingWindowNode.id, { useScene.getState().updateNode(movingWindowNode.id, {
position: [clampedX, clampedY, 0], position: [target.clampedX, target.clampedY, 0],
rotation: [0, itemRotation, 0], rotation: [0, target.itemRotation, 0],
side, side: target.side,
parentId: event.node.id, parentId: target.wallId,
wallId: event.node.id, wallId: target.wallId,
metadata: {}, metadata: {},
}) })
if (original.parentId && original.parentId !== event.node.id) { if (original.parentId && original.parentId !== target.wallId) {
markWallDirty(original.parentId) markWallDirty(original.parentId)
} }
placedId = movingWindowNode.id placedId = movingWindowNode.id
} }
markWallDirty(event.node.id) markWallDirty(target.wallId)
useLiveTransforms.getState().clear(movingWindowNode.id) useLiveTransforms.getState().clear(movingWindowNode.id)
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
@@ -391,6 +360,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const onWallLeave = () => { const onWallLeave = () => {
hideCursor() hideCursor()
useLiveTransforms.getState().clear(movingWindowNode.id) useLiveTransforms.getState().clear(movingWindowNode.id)
dragAnchor = null
lastTarget = null
if (isNew) return // No original to restore for duplicates if (isNew) return // No original to restore for duplicates
// Move mode: restore to original position while off-wall // Move mode: restore to original position while off-wall
if (currentWallId && currentWallId !== original.parentId) { if (currentWallId && currentWallId !== original.parentId) {