feat: add fence move handles and duplication functionality to the floorplan editor

This commit is contained in:
sudhir
2026-05-20 00:45:25 +00:00
committed by open-pascal
parent 5f17172638
commit 7cc11f0fd6
+98 -91
View File
@@ -2,13 +2,14 @@
import { import {
type AnyNodeId, type AnyNodeId,
constrainWallMoveDeltaToAxis,
emitter, emitter,
type FenceNode, type FenceNode,
getPerpendicularWallMoveAxis,
type GridEvent, type GridEvent,
type LevelNode, type LevelNode,
sceneRegistry,
useLiveTransforms,
useScene, useScene,
type WallMoveAxis,
type WallNode, type WallNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
@@ -20,22 +21,17 @@ import {
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import type * as THREE from 'three'
/** /**
* Phase 5 Stage D — fence whole-move tool. * Phase 5 Stage D — fence whole-move tool.
* *
* Live-drag pattern: translate the fence MESH (and any linked fences * Live-drag pattern: translate the fence via direct scene updates while
* sharing an endpoint) via `sceneRegistry.nodes.get(id).position` + * temporal history is paused. On commit we restore the original, resume
* `useLiveTransforms`. No `scene.update` during the drag — fence * history, apply the final position (single undo step), then re-pause.
* geometry isn't rebuilt per tick. On commit we write the translated * `constrainWallMoveDeltaToAxis` keeps moves axis-aligned.
* start/end (plus the linked-fence cascade) to the scene once.
* `GeometrySystem` resets `mesh.position` on rebuild, so the visual
* transitions smoothly with no teleport.
* *
* History stays UNPAUSED during the drag (we're only mutating Three.js * Wired via `def.affordanceTools.move`. The editor's `MoveTool`
* mesh transforms). The single `scene.update` on commit is the single * dispatcher picks this up before its legacy chain.
* undo step.
*/ */
function samePoint(a: [number, number], b: [number, number]) { function samePoint(a: [number, number], b: [number, number]) {
return a[0] === b[0] && a[1] === b[1] return a[0] === b[0] && a[1] === b[1]
@@ -99,38 +95,32 @@ function getLinkedFenceUpdates(
})) }))
} }
function setMeshOffset(fenceId: FenceNode['id'], deltaX: number, deltaZ: number): void {
const mesh = sceneRegistry.nodes.get(fenceId) as THREE.Object3D | undefined
if (mesh) mesh.position.set(deltaX, 0, deltaZ)
}
function setFenceLiveTransform(fenceId: FenceNode['id'], deltaX: number, deltaZ: number): void {
// useLiveTransforms holds the SAME delta the direct mesh.position
// mutation uses — ParametricNodeRenderer reads it and reconciles
// `<group position={liveTransform.position}>` via React. Mismatched
// values here cause the two systems to fight per frame (jitter
// during drag).
useLiveTransforms.getState().set(fenceId, {
position: [deltaX, 0, deltaZ],
rotation: 0,
})
}
export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
const activatedAtRef = useRef<number>(Date.now()) const activatedAtRef = useRef<number>(Date.now())
const previousGridPosRef = useRef<[number, number] | null>(null) const previousGridPosRef = useRef<[number, number] | null>(null)
const originalStartRef = useRef<[number, number]>([...node.start] as [number, number]) const originalStartRef = useRef<[number, number]>([...node.start] as [number, number])
const originalEndRef = useRef<[number, number]>([...node.end] as [number, number]) const originalEndRef = useRef<[number, number]>([...node.end] as [number, number])
const meta =
typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata)
? (node.metadata as Record<string, unknown>)
: {}
const isNew = !!meta.isNew
const linkedOriginalsRef = useRef( const linkedOriginalsRef = useRef(
getLinkedFenceSnapshots({ isNew
fenceId: node.id, ? []
fenceParentId: node.parentId ?? null, : getLinkedFenceSnapshots({
originalStart: node.start, fenceId: node.id,
originalEnd: node.end, fenceParentId: node.parentId ?? null,
}), originalStart: node.start,
originalEnd: node.end,
}),
) )
const dragAnchorRef = useRef<[number, number] | null>(null) const dragAnchorRef = useRef<[number, number] | null>(null)
const deltaRef = useRef<[number, number]>([0, 0]) const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null)
const moveAxisRef = useRef<WallMoveAxis | null>(
getPerpendicularWallMoveAxis(node.start, node.end),
)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => { const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const centerX = (node.start[0] + node.end[0]) / 2 const centerX = (node.start[0] + node.end[0]) / 2
@@ -146,7 +136,6 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
const fenceId = node.id const fenceId = node.id
const originalStart = originalStartRef.current const originalStart = originalStartRef.current
const originalEnd = originalEndRef.current const originalEnd = originalEndRef.current
const linkedOriginals = linkedOriginalsRef.current
const levelNode = const levelNode =
node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level' node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level'
@@ -160,29 +149,47 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
.map((childId) => useScene.getState().nodes[childId as AnyNodeId]) .map((childId) => useScene.getState().nodes[childId as AnyNodeId])
.filter((child): child is FenceNode => child?.type === 'fence') .filter((child): child is FenceNode => child?.type === 'fence')
useScene.temporal.getState().pause()
let wasCommitted = false let wasCommitted = false
const applyPreview = (deltaX: number, deltaZ: number) => { const applyNodePreview = (
deltaRef.current = [deltaX, deltaZ] updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>,
setMeshOffset(fenceId, deltaX, deltaZ) ) => {
setFenceLiveTransform(fenceId, deltaX, deltaZ) useScene.getState().updateNodes(
for (const linked of linkedOriginals) { updates.map((entry) => ({
setMeshOffset(linked.id, deltaX, deltaZ) id: entry.id as AnyNodeId,
setFenceLiveTransform(linked.id, deltaX, deltaZ) data: { start: entry.start, end: entry.end },
})),
)
for (const entry of updates) {
useScene.getState().markDirty(entry.id as AnyNodeId)
} }
// Cursor at translated polygon center.
const centerX = (originalStart[0] + originalEnd[0]) / 2
const centerZ = (originalStart[1] + originalEnd[1]) / 2
setCursorLocalPos([centerX + deltaX, 0, centerZ + deltaZ])
} }
const clearPreview = () => { const restoreOriginal = () => {
setMeshOffset(fenceId, 0, 0) applyNodePreview([
useLiveTransforms.getState().clear(fenceId) { id: fenceId, start: originalStart, end: originalEnd },
for (const linked of linkedOriginals) { ...linkedOriginalsRef.current,
setMeshOffset(linked.id, 0, 0) ])
useLiveTransforms.getState().clear(linked.id) }
}
const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => {
previewRef.current = { start: nextStart, end: nextEnd }
const centerX = (nextStart[0] + nextEnd[0]) / 2
const centerZ = (nextStart[1] + nextEnd[1]) / 2
setCursorLocalPos([centerX, 0, centerZ])
const previewUpdates = [
{ id: fenceId, start: nextStart, end: nextEnd },
...getLinkedFenceUpdates(
linkedOriginalsRef.current,
originalStart,
originalEnd,
nextStart,
nextEnd,
),
]
applyNodePreview(previewUpdates)
} }
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
@@ -204,7 +211,16 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
const anchor = dragAnchorRef.current ?? [localX, localZ] const anchor = dragAnchorRef.current ?? [localX, localZ]
dragAnchorRef.current = anchor dragAnchorRef.current = anchor
applyPreview(localX - anchor[0], localZ - anchor[1]) const [deltaX, deltaZ] = constrainWallMoveDeltaToAxis(
localX - anchor[0],
localZ - anchor[1],
moveAxisRef.current,
)
const nextStart: [number, number] = [originalStart[0] + deltaX, originalStart[1] + deltaZ]
const nextEnd: [number, number] = [originalEnd[0] + deltaX, originalEnd[1] + deltaZ]
applyPreview(nextStart, nextEnd)
} }
const onGridClick = (event: GridEvent) => { const onGridClick = (event: GridEvent) => {
@@ -213,37 +229,31 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
return return
} }
const [deltaX, deltaZ] = deltaRef.current
wasCommitted = true wasCommitted = true
if (deltaX !== 0 || deltaZ !== 0) { const preview = previewRef.current
const nextStart: [number, number] = [originalStart[0] + deltaX, originalStart[1] + deltaZ] if (!preview) {
const nextEnd: [number, number] = [originalEnd[0] + deltaX, originalEnd[1] + deltaZ] exitMoveMode()
const linkedUpdates = getLinkedFenceUpdates( event.nativeEvent?.stopPropagation?.()
linkedOriginals, return
}
// Restore original baseline while paused so the next resume+update
// registers as a single tracked change (undo reverts to original).
restoreOriginal()
useScene.temporal.getState().resume()
applyNodePreview([
{ id: fenceId, start: preview.start, end: preview.end },
...getLinkedFenceUpdates(
linkedOriginalsRef.current,
originalStart, originalStart,
originalEnd, originalEnd,
nextStart, preview.start,
nextEnd, preview.end,
) ),
useScene.getState().updateNodes([ ])
{ id: fenceId as AnyNodeId, data: { start: nextStart, end: nextEnd } }, useScene.temporal.getState().pause()
...linkedUpdates.map((u) => ({
id: u.id as AnyNodeId,
data: { start: u.start, end: u.end },
})),
])
useScene.getState().markDirty(fenceId as AnyNodeId)
for (const linked of linkedOriginals) {
useScene.getState().markDirty(linked.id as AnyNodeId)
}
}
// Clear useLiveTransforms but leave mesh.position — GeometrySystem
// resets it on the rebuild next frame.
useLiveTransforms.getState().clear(fenceId)
for (const linked of linkedOriginals) {
useLiveTransforms.getState().clear(linked.id)
}
triggerSFX('sfx:item-place') triggerSFX('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [fenceId] }) useViewer.getState().setSelection({ selectedIds: [fenceId] })
@@ -252,8 +262,9 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
} }
const onCancel = () => { const onCancel = () => {
clearPreview() restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [fenceId] }) useViewer.getState().setSelection({ selectedIds: [fenceId] })
useScene.temporal.getState().resume()
markToolCancelConsumed() markToolCancelConsumed()
exitMoveMode() exitMoveMode()
} }
@@ -264,13 +275,9 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
return () => { return () => {
if (!wasCommitted) { if (!wasCommitted) {
clearPreview() restoreOriginal()
} else {
useLiveTransforms.getState().clear(fenceId)
for (const linked of linkedOriginals) {
useLiveTransforms.getState().clear(linked.id)
}
} }
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)