Fix realtime undo updates for wall and fence moves

This commit is contained in:
sudhir
2026-04-21 11:12:04 +05:30
parent 86a4a82a92
commit ffa78f1344
11 changed files with 127 additions and 24 deletions
+6
View File
@@ -54,6 +54,12 @@ export {
type ItemInteractiveState, type ItemInteractiveState,
useInteractive, useInteractive,
} from './store/use-interactive' } from './store/use-interactive'
export {
getSceneHistoryPauseDepth,
pauseSceneHistory,
resetSceneHistoryPauseDepth,
resumeSceneHistory,
} from './store/history-control'
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms' export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
export { FenceSystem } from './systems/fence/fence-system' export { FenceSystem } from './systems/fence/fence-system'
export { clearSceneHistory, default as useScene } from './store/use-scene' export { clearSceneHistory, default as useScene } from './store/use-scene'
+8 -2
View File
@@ -4,6 +4,11 @@ import {
isCurvedWall, isCurvedWall,
} from '../systems/wall/wall-curve' } from '../systems/wall/wall-curve'
import { CeilingNode, SlabNode, type CeilingNode as CeilingNodeType, type SlabNode as SlabNodeType, type WallNode } from '../schema' import { CeilingNode, SlabNode, type CeilingNode as CeilingNodeType, type SlabNode as SlabNodeType, type WallNode } from '../schema'
import {
getSceneHistoryPauseDepth,
pauseSceneHistory,
resumeSceneHistory,
} from '../store/history-control'
import { simplifyClosedPolygon } from './polygon-geometry' import { simplifyClosedPolygon } from './polygon-geometry'
type Point2D = { x: number; y: number } type Point2D = { x: number; y: number }
@@ -855,6 +860,7 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () =>
const unsubscribe = sceneStore.subscribe((state: any) => { const unsubscribe = sceneStore.subscribe((state: any) => {
if (isProcessing) return if (isProcessing) return
if (getSceneHistoryPauseDepth() > 0) return
const nodes = state.nodes const nodes = state.nodes
const wallsByLevel = new Map<string, WallNode[]>() const wallsByLevel = new Map<string, WallNode[]>()
@@ -889,11 +895,11 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () =>
} }
isProcessing = true isProcessing = true
sceneStore.temporal.getState().pause() pauseSceneHistory(sceneStore)
try { try {
runSpaceDetection([...levelsToUpdate], sceneStore, editorStore, nodes) runSpaceDetection([...levelsToUpdate], sceneStore, editorStore, nodes)
} finally { } finally {
sceneStore.temporal.getState().resume() resumeSceneHistory(sceneStore)
previousSnapshots.clear() previousSnapshots.clear()
for (const [levelId, snapshot] of currentSnapshots.entries()) { for (const [levelId, snapshot] of currentSnapshots.entries()) {
previousSnapshots.set(levelId, snapshot) previousSnapshots.set(levelId, snapshot)
@@ -0,0 +1,36 @@
let sceneHistoryPauseDepth = 0
type TemporalStoreLike = {
temporal: {
getState(): {
pause(): void
resume(): void
}
}
}
export function pauseSceneHistory(sceneStore: TemporalStoreLike): void {
if (sceneHistoryPauseDepth === 0) {
sceneStore.temporal.getState().pause()
}
sceneHistoryPauseDepth += 1
}
export function resumeSceneHistory(sceneStore: TemporalStoreLike): void {
if (sceneHistoryPauseDepth === 0) {
return
}
sceneHistoryPauseDepth -= 1
if (sceneHistoryPauseDepth === 0) {
sceneStore.temporal.getState().resume()
}
}
export function getSceneHistoryPauseDepth(): number {
return sceneHistoryPauseDepth
}
export function resetSceneHistoryPauseDepth(): void {
sceneHistoryPauseDepth = 0
}
+5 -2
View File
@@ -11,6 +11,7 @@ import { SiteNode } from '../schema/nodes/site'
import { StairNode as StairNodeSchema } from '../schema/nodes/stair' import { StairNode as StairNodeSchema } from '../schema/nodes/stair'
import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment' import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment'
import type { AnyNode, AnyNodeId } from '../schema/types' import type { AnyNode, AnyNodeId } from '../schema/types'
import { resetSceneHistoryPauseDepth } from './history-control'
import * as nodeActions from './actions/node-actions' import * as nodeActions from './actions/node-actions'
function getFiniteNumber(value: unknown, fallback: number) { function getFiniteNumber(value: unknown, fallback: number) {
@@ -628,6 +629,7 @@ let prevNodesSnapshot: Record<AnyNodeId, AnyNode> | null = null
export function clearSceneHistory() { export function clearSceneHistory() {
useScene.temporal.getState().clear() useScene.temporal.getState().clear()
resetSceneHistoryPauseDepth()
prevPastLength = 0 prevPastLength = 0
prevFutureLength = 0 prevFutureLength = 0
prevNodesSnapshot = null prevNodesSnapshot = null
@@ -647,8 +649,9 @@ useScene.temporal.subscribe((state) => {
// Capture the previous snapshot before RAF fires // Capture the previous snapshot before RAF fires
const snapshotBefore = prevNodesSnapshot const snapshotBefore = prevNodesSnapshot
// Use RAF to ensure all middleware and store updates are complete // Defer to a microtask so the scene store has settled before we diff,
requestAnimationFrame(() => { // but still mark walls/items dirty before the next paint.
queueMicrotask(() => {
const currentNodes = useScene.getState().nodes const currentNodes = useScene.getState().nodes
const { markDirty } = useScene.getState() const { markDirty } = useScene.getState()
@@ -159,6 +159,12 @@ type FloorplanViewport = {
width: number width: number
} }
function floorplanViewportEquals(a: FloorplanViewport | null, b: FloorplanViewport | null) {
if (a === b) return true
if (!(a && b)) return false
return a.centerX === b.centerX && a.centerY === b.centerY && a.width === b.width
}
type SvgPoint = { type SvgPoint = {
x: number x: number
y: number y: number
@@ -6155,12 +6161,12 @@ export function FloorplanPanel() {
if (levelChanged) { if (levelChanged) {
previousLevelIdRef.current = levelId ?? null previousLevelIdRef.current = levelId ?? null
hasUserAdjustedViewportRef.current = false hasUserAdjustedViewportRef.current = false
setViewport(fittedViewport) setViewport((current) => (floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport))
return return
} }
if (!hasUserAdjustedViewportRef.current) { if (!hasUserAdjustedViewportRef.current) {
setViewport(fittedViewport) setViewport((current) => (floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport))
} }
}, [fittedViewport, levelId]) }, [fittedViewport, levelId])
@@ -167,6 +167,14 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
const preview = previewRef.current ?? { start: originalStart, end: originalEnd } const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
wasCommitted = true wasCommitted = true
// Restore original baseline while paused so the next resume+update
// registers as a single tracked change (undo reverts to original).
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
applyNodePreview([ applyNodePreview([
{ id: nodeId, start: preview.start, end: preview.end }, { id: nodeId, start: preview.start, end: preview.end },
@@ -1,6 +1,14 @@
'use client' 'use client'
import { type AnyNodeId, emitter, type GridEvent, useScene, type WallNode } from '@pascal-app/core' import {
type AnyNodeId,
emitter,
type GridEvent,
pauseSceneHistory,
resumeSceneHistory,
useScene,
type WallNode,
} from '@pascal-app/core'
import { Html } from '@react-three/drei' import { Html } from '@react-three/drei'
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'
@@ -127,7 +135,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
node?.type === 'wall' && (node.parentId ?? null) === (target.wall.parentId ?? null), node?.type === 'wall' && (node.parentId ?? null) === (target.wall.parentId ?? null),
) )
useScene.temporal.getState().pause() pauseSceneHistory(useScene)
let wasCommitted = false let wasCommitted = false
const applyNodePreview = ( const applyNodePreview = (
@@ -209,7 +217,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
...linkedOriginalsRef.current, ...linkedOriginalsRef.current,
]) ])
useScene.temporal.getState().resume() resumeSceneHistory(useScene)
applyNodePreview([ applyNodePreview([
{ id: nodeId, start: preview.start, end: preview.end }, { id: nodeId, start: preview.start, end: preview.end },
...(altPressedRef.current ...(altPressedRef.current
@@ -222,7 +230,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
preview.end, preview.end,
)), )),
]) ])
useScene.temporal.getState().pause() pauseSceneHistory(useScene)
sfxEmitter.emit('sfx:item-place') sfxEmitter.emit('sfx:item-place')
} }
@@ -234,7 +242,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
const onCancel = () => { const onCancel = () => {
restoreOriginal() restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] }) useViewer.getState().setSelection({ selectedIds: [nodeId] })
useScene.temporal.getState().resume() resumeSceneHistory(useScene)
markToolCancelConsumed() markToolCancelConsumed()
exitMoveMode() exitMoveMode()
} }
@@ -279,7 +287,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
if (!wasCommitted) { if (!wasCommitted) {
restoreOriginal() restoreOriginal()
} }
useScene.temporal.getState().resume() resumeSceneHistory(useScene)
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)
@@ -1,6 +1,14 @@
'use client' 'use client'
import { type AnyNodeId, emitter, type GridEvent, useScene, type WallNode } from '@pascal-app/core' import {
type AnyNodeId,
emitter,
type GridEvent,
pauseSceneHistory,
resumeSceneHistory,
useScene,
type WallNode,
} from '@pascal-app/core'
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 { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
@@ -24,9 +32,9 @@ function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata'
return meta return meta
} }
const nextMeta = { ...(meta as Record<string, unknown>) } const nextMeta = { ...(meta as Record<string, unknown>) } as Record<string, unknown>
delete nextMeta.isNew delete nextMeta.isNew
return nextMeta return nextMeta as WallNode['metadata']
} }
type LinkedWallSnapshot = { type LinkedWallSnapshot = {
@@ -146,7 +154,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const originalCenter = originalCenterRef.current const originalCenter = originalCenterRef.current
const originalHalfVector = originalHalfVectorRef.current const originalHalfVector = originalHalfVectorRef.current
useScene.temporal.getState().pause() pauseSceneHistory(useScene)
let wasCommitted = false let wasCommitted = false
const applyNodePreview = ( const applyNodePreview = (
@@ -237,7 +245,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
...linkedOriginalsRef.current, ...linkedOriginalsRef.current,
]) ])
useScene.temporal.getState().resume() resumeSceneHistory(useScene)
const commitUpdates = [ const commitUpdates = [
{ {
@@ -266,7 +274,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
useScene.getState().markDirty(id) useScene.getState().markDirty(id)
} }
useScene.temporal.getState().pause() pauseSceneHistory(useScene)
sfxEmitter.emit('sfx:item-place') sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [nodeId] }) useViewer.getState().setSelection({ selectedIds: [nodeId] })
@@ -315,7 +323,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const onCancel = () => { const onCancel = () => {
restoreOriginal() restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] }) useViewer.getState().setSelection({ selectedIds: [nodeId] })
useScene.temporal.getState().resume() resumeSceneHistory(useScene)
markToolCancelConsumed() markToolCancelConsumed()
exitMoveMode() exitMoveMode()
} }
@@ -331,7 +339,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
restoreOriginal() restoreOriginal()
} }
shiftPressedRef.current = false shiftPressedRef.current = false
useScene.temporal.getState().resume() resumeSceneHistory(useScene)
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)
@@ -35,6 +35,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { useEffect } from 'react' import { useEffect } from 'react'
import { deleteLevelWithFallbackSelection } from '../../../lib/level-selection' import { deleteLevelWithFallbackSelection } from '../../../lib/level-selection'
import { runRedo, runUndo } from '../../../lib/history'
import { useCommandRegistry } from '../../../store/use-command-registry' import { useCommandRegistry } from '../../../store/use-command-registry'
import type { StructureTool } from '../../../store/use-editor' import type { StructureTool } from '../../../store/use-editor'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
@@ -313,7 +314,7 @@ export function EditorCommands() {
group: 'History', group: 'History',
icon: <Undo2 className="h-4 w-4" />, icon: <Undo2 className="h-4 w-4" />,
keywords: ['undo', 'revert', 'back'], keywords: ['undo', 'revert', 'back'],
execute: () => run(() => useScene.temporal.getState().undo()), execute: () => run(() => runUndo()),
}, },
{ {
id: 'editor.history.redo', id: 'editor.history.redo',
@@ -321,7 +322,7 @@ export function EditorCommands() {
group: 'History', group: 'History',
icon: <Redo2 className="h-4 w-4" />, icon: <Redo2 className="h-4 w-4" />,
keywords: ['redo', 'forward', 'repeat'], keywords: ['redo', 'forward', 'repeat'],
execute: () => run(() => useScene.temporal.getState().redo()), execute: () => run(() => runRedo()),
}, },
// ── Export & Share ─────────────────────────────────────────────────── // ── Export & Share ───────────────────────────────────────────────────
+3 -2
View File
@@ -1,6 +1,7 @@
import { type AnyNodeId, emitter, useScene } from '@pascal-app/core' import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react' import { useEffect } from 'react'
import { runRedo, runUndo } from '../lib/history'
import { sfxEmitter } from '../lib/sfx-bus' import { sfxEmitter } from '../lib/sfx-bus'
import useEditor from '../store/use-editor' import useEditor from '../store/use-editor'
@@ -91,11 +92,11 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
if (isVersionPreviewMode) return if (isVersionPreviewMode) return
e.preventDefault() e.preventDefault()
useScene.temporal.getState().undo() runUndo()
} else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
if (isVersionPreviewMode) return if (isVersionPreviewMode) return
e.preventDefault() e.preventDefault()
useScene.temporal.getState().redo() runRedo()
} else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) {
e.preventDefault() e.preventDefault()
const { buildingId, levelId } = useViewer.getState().selection const { buildingId, levelId } = useViewer.getState().selection
+20
View File
@@ -0,0 +1,20 @@
import { useLiveTransforms, useScene } from '@pascal-app/core'
function refreshSceneAfterHistoryJump() {
useLiveTransforms.getState().clearAll()
const state = useScene.getState()
for (const node of Object.values(state.nodes)) {
state.markDirty(node.id)
}
}
export function runUndo() {
useScene.temporal.getState().undo()
refreshSceneAfterHistoryJump()
}
export function runRedo() {
useScene.temporal.getState().redo()
refreshSceneAfterHistoryJump()
}