feat: add fence move handles and duplication functionality to the floorplan editor
This commit is contained in:
@@ -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,30 +95,21 @@ 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
|
||||||
|
? []
|
||||||
|
: getLinkedFenceSnapshots({
|
||||||
fenceId: node.id,
|
fenceId: node.id,
|
||||||
fenceParentId: node.parentId ?? null,
|
fenceParentId: node.parentId ?? null,
|
||||||
originalStart: node.start,
|
originalStart: node.start,
|
||||||
@@ -130,7 +117,10 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
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 } },
|
|
||||||
...linkedUpdates.map((u) => ({
|
|
||||||
id: u.id as AnyNodeId,
|
|
||||||
data: { start: u.start, end: u.end },
|
|
||||||
})),
|
|
||||||
])
|
])
|
||||||
useScene.getState().markDirty(fenceId as AnyNodeId)
|
useScene.temporal.getState().pause()
|
||||||
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user