diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts index d96ee2f36e..f84ed61625 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts @@ -4,7 +4,7 @@ import { encodeTerrainField } from '../../lib/terrain-codec' import { applyHeightPatch, createTerrainField, flattenPatch } from '../../lib/terrain-field' import { nodeRegistry, registerNode } from '../../registry' import type { AnyNodeDefinition } from '../../registry/types' -import type { AnyNode, AnyNodeId } from '../../schema' +import { type AnyNode, type AnyNodeId, ItemNode, LevelNode, SlabNode, WallNode } from '../../schema' import useLiveTerrain from '../../store/use-live-terrain' import useScene, { clearSceneHistory } from '../../store/use-scene' import { spatialGridManager } from './spatial-grid-manager' @@ -565,3 +565,221 @@ describe('spatial-grid sync dirty rules (terrain support)', () => { expect(marked).toEqual(['wall_ground', 'wall_fill', 'slab_fill', 'column_a']) }) }) + +describe('temporal writes update slab support dependencies', () => { + let stop = () => {} + let restore = () => {} + beforeEach(() => { + restore = nodeRegistry._snapshot() + nodeRegistry._register({ + kind: 'item', + schemaVersion: 1, + schema: ItemNode, + capabilities: { + floorPlaced: { footprint: () => ({ dimensions: [0.2, 1, 0.2], rotation: [0, 0, 0] }) }, + }, + } as never) + spatialGridManager.clear() + }) + afterEach(() => { + stop() + restore() + spatialGridManager.clear() + clearSceneHistory() + }) + + test('undo/redo of wall thickness re-elevates an unchanged item on the former rendered slab band', async () => { + const level = LevelNode.parse({ id: 'level_band_history' }) + const wall = WallNode.parse({ + id: 'wall_band_history', + parentId: level.id, + start: [4, 0], + end: [4, 4], + thickness: 0.8, + }) + const slab = SlabNode.parse({ + parentId: level.id, + polygon: SQUARE, + elevation: 0.4, + thickness: 0.4, + }) + const item = ItemNode.parse({ + parentId: level.id, + position: [4.45, 0, 2], + asset: { + id: 'test', + name: 'test', + category: 'test', + thumbnail: '', + src: '/test.glb', + dimensions: [0.2, 1, 0.2], + }, + }) + const remote = { ...item, id: 'item_remote_band', position: [20, 0, 20] } as AnyNode + const interior = { ...item, id: 'item_interior_band', position: [2, 0, 2] } as AnyNode + const interiorWall = WallNode.parse({ + id: 'wall_interior_band', + parentId: level.id, + start: [1, 1], + end: [2, 1], + }) + const upper = LevelNode.parse({ id: 'level_other_band', level: 1 }) + const upperItem = { ...item, id: 'item_upper_band', parentId: upper.id } as AnyNode + const upperWall = { ...wall, id: 'wall_upper_band', parentId: upper.id } as AnyNode + const upperSlab = { ...slab, id: 'slab_upper_band', parentId: upper.id } as AnyNode + useScene.setState({ + nodes: nodesFor( + level, + wall, + slab, + item, + remote, + interior, + interiorWall, + upper, + upperItem, + upperWall, + upperSlab, + ), + dirtyNodes: new Set(), + readOnly: false, + }) + clearSceneHistory() + stop = initSpatialGridSync() + const elevation = () => + spatialGridManager.getSlabSupportForItem(level.id, item.position, [0.2, 1, 0.2], [0, 0, 0]) + .elevation + expect(elevation()).toBeCloseTo(0.4) + useScene.setState({ + nodes: { ...useScene.getState().nodes, [wall.id]: { ...wall, thickness: 0.1 } }, + }) + expect(elevation()).toBe(0) + expect(useScene.getState().dirtyNodes.has(item.id)).toBe(true) + for (const [jump, expected] of [ + [useScene.temporal.getState().undo, 0.4], + [useScene.temporal.getState().redo, 0], + ] as const) { + useScene.getState().dirtyNodes.clear() + jump() + // The support subscription runs on the write, before the temporal microtask. + expect(useScene.getState().dirtyNodes.has(item.id)).toBe(true) + expect(elevation()).toBeCloseTo(expected) + await Promise.resolve() + expect(useScene.getState().nodes[item.id]).toBe(item) + expect(useScene.getState().nodes[slab.id]).toBe(slab) + for (const unaffected of [ + remote, + interior, + interiorWall, + upper, + upperItem, + upperWall, + upperSlab, + ]) { + expect(useScene.getState().dirtyNodes.has(unaffected.id)).toBe(false) + } + } + }) + + test('slab reparent and undo mark covering dependents below both parent levels', async () => { + const levels = [0, 1, 2, 3].map((ordinal) => + makeLevel(`level_${ordinal}`, ordinal, 2.5, [ + `wall_covering_${ordinal}`, + `ceiling_covering_${ordinal}`, + ...(ordinal === 2 ? ['slab_reparent'] : []), + ]), + ) + const consumers = levels.flatMap((level, ordinal) => [ + { + ...makeChild(`wall_covering_${ordinal}`, 'wall', level.id), + start: [20, 0], + end: [24, 0], + } as AnyNode, + makeChild(`ceiling_covering_${ordinal}`, 'ceiling', level.id), + ]) + const slab = makeSlab('slab_reparent', 'level_2') + useScene.setState({ + nodes: nodesFor(...levels, ...consumers, slab), + rootNodeIds: levels.map((level) => level.id), + installedPlugins: [], + dirtyNodes: new Set(), + readOnly: false, + }) + clearSceneHistory() + stop = initSpatialGridSync() + const coveringDirtyIds = () => dirtyIds().filter((id) => id.includes('_covering_')) + const expected = [ + 'ceiling_covering_1', + 'ceiling_covering_2', + 'wall_covering_1', + 'wall_covering_2', + ] + useScene.getState().dirtyNodes.clear() + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + [slab.id]: { ...slab, parentId: 'level_3' } as AnyNode, + level_2: { ...levels[2]!, children: ['wall_covering_2', 'ceiling_covering_2'] } as AnyNode, + level_3: { + ...levels[3]!, + children: ['wall_covering_3', 'ceiling_covering_3', slab.id], + } as AnyNode, + }, + }) + await Promise.resolve() + expect(coveringDirtyIds()).toEqual(expected) + + useScene.getState().dirtyNodes.clear() + useScene.temporal.getState().undo() + await Promise.resolve() + expect(useScene.getState().nodes[slab.id]?.parentId).toBe('level_2') + expect(coveringDirtyIds()).toEqual(expected) + }) + + test('slab elevation and level height subscribers fire during real temporal restoration', async () => { + const level = LevelNode.parse({ + id: 'level_vertical_history', + children: ['wall_vertical_history'], + }) + const wall = WallNode.parse({ + id: 'wall_vertical_history', + parentId: level.id, + start: [0, 0], + end: [4, 0], + }) + const slab = SlabNode.parse({ + parentId: level.id, + polygon: SQUARE, + elevation: 1, + thickness: 0.1, + }) + const item = ItemNode.parse({ + parentId: level.id, + position: [2, 0, 2], + asset: { id: 'test', name: 'test', category: 'test', thumbnail: '', src: '/test.glb' }, + }) + useScene.setState({ + nodes: nodesFor(level, wall, slab, item), + dirtyNodes: new Set(), + readOnly: false, + }) + clearSceneHistory() + stop = initSpatialGridSync() + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + [slab.id]: { ...slab, elevation: 2 }, + [level.id]: { ...level, height: 4 }, + }, + }) + useScene.getState().dirtyNodes.clear() + useScene.temporal.getState().undo() + expect(useScene.getState().dirtyNodes.has(item.id)).toBe(true) + expect(useScene.getState().dirtyNodes.has(wall.id)).toBe(true) + expect( + spatialGridManager.getSlabSupportForItem(level.id, item.position, [0.2, 1, 0.2], [0, 0, 0]) + .elevation, + ).toBe(1) + await Promise.resolve() + }) +}) diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts index d05d0d68e4..01869c84c1 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts @@ -1,3 +1,4 @@ +import { subtractPolygonsFromPolygon } from '../../lib/polygon-union' import { getRenderableSlabPolygon } from '../../lib/slab-polygon' import { isLevelAtSiteDatum, isLevelBaseConsumer } from '../../lib/terrain-support' import { nodeRegistry } from '../../registry' @@ -111,6 +112,38 @@ export function initSpatialGridSync(): () => void { // Subscribe to all changes const unsubscribeScene = store.subscribe((state, prevState) => { + if (state.nodes === prevState.nodes) return + const changedSlabContextLevels = new Set() + const checkSlabContext = (id: string) => { + const previous = prevState.nodes[id as AnyNodeId] + const next = state.nodes[id as AnyNodeId] + if (previous === next) return + const wallChanged = + (previous?.type === 'wall' || next?.type === 'wall') && + (previous?.type !== 'wall' || + next?.type !== 'wall' || + previous.parentId !== next.parentId || + previous.start !== next.start || + previous.end !== next.end || + previous.thickness !== next.thickness || + previous.curveOffset !== next.curveOffset) + const slabChanged = + (previous?.type === 'slab' || next?.type === 'slab') && + (previous?.type !== 'slab' || + next?.type !== 'slab' || + previous.parentId !== next.parentId || + previous.polygon !== next.polygon || + previous.elevation !== next.elevation) + if (!(wallChanged || slabChanged)) return + if (previous) changedSlabContextLevels.add(resolveLevelId(previous, prevState.nodes)) + if (next) changedSlabContextLevels.add(resolveLevelId(next, state.nodes)) + } + + for (const id in prevState.nodes) checkSlabContext(id) + for (const id in state.nodes) { + if (!prevState.nodes[id as AnyNodeId]) checkSlabContext(id) + } + // Detect added nodes for (const [id, node] of Object.entries(state.nodes)) { if (!prevState.nodes[id as AnyNode['id']]) { @@ -140,7 +173,7 @@ export function initSpatialGridSync(): () => void { // When a slab is removed, mark items/walls that were on it dirty (using current state) if (node.type === 'slab') { - markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty) + markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty, prevState.nodes) markCoveringDependentsBelow(levelId, state.nodes, markDirty) } @@ -183,7 +216,13 @@ export function initSpatialGridSync(): () => void { const levelId = resolveLevelId(node, state.nodes) spatialGridManager.handleNodeUpdated(node, levelId) } - markSlabChangeDependents(prev as SlabNode, node as SlabNode, state.nodes, markDirty) + markSlabChangeDependents( + prev as SlabNode, + node as SlabNode, + state.nodes, + markDirty, + prevState.nodes, + ) } else if (node.type === 'level' && prev.type === 'level') { if (node.height !== prev.height) { markLevelHeightDependents(node as LevelNode, state.nodes, markDirty) @@ -210,6 +249,47 @@ export function initSpatialGridSync(): () => void { } } } + + // Unchanged slabs can lose an adopted wall band or a sibling seam. Their + // stored polygons cannot identify objects standing on the former boundary. + if (changedSlabContextLevels.size === 0) return + const beforeContext = slabBoundaryContext(prevState.nodes, changedSlabContextLevels) + const afterContext = slabBoundaryContext(state.nodes, changedSlabContextLevels) + for (const context of afterContext.values()) { + for (const slab of context.slabs) { + const previous = prevState.nodes[slab.id] + if (previous?.type !== 'slab') continue + if ( + slab.parentId !== previous.parentId || + slab.polygon !== previous.polygon || + slab.elevation !== previous.elevation || + slab.holes !== previous.holes + ) + continue + const previousContext = beforeContext.get(resolveLevelId(previous, prevState.nodes))! + const beforePolygon = cachedSlabPolygon(previous, previousContext) + const afterPolygon = cachedSlabPolygon(slab, context) + if ( + beforePolygon.length === afterPolygon.length && + beforePolygon.every((point, i) => arraysEqual(point, afterPolygon[i]!)) + ) + continue + // Support only changed in the gained/lost bands, not across the slab interior. + const changedBands = [ + ...subtractPolygonsFromPolygon(beforePolygon, [afterPolygon]), + ...subtractPolygonsFromPolygon(afterPolygon, [beforePolygon]), + ] + for (const polygon of changedBands) { + markNodesOverlappingPolygon( + resolveLevelId(slab, state.nodes), + polygon, + state.nodes, + markDirty, + context.consumers, + ) + } + } + } }) // Live terrain is deliberately not written into `useScene` per dab: doing so @@ -282,14 +362,16 @@ export function markSlabChangeDependents( next: SlabNode, nodes: Record, markDirty: (id: AnyNodeId) => void, + previousNodes = nodes, ) { const supportChanged = + next.parentId !== previous.parentId || next.polygon !== previous.polygon || next.elevation !== previous.elevation || next.holes !== previous.holes if (supportChanged) { - markNodesOverlappingSlab(previous, nodes, markDirty) + markNodesOverlappingSlab(previous, nodes, markDirty, previousNodes) markNodesOverlappingSlab(next, nodes, markDirty) } if (next.elevation !== previous.elevation) { @@ -300,7 +382,14 @@ export function markSlabChangeDependents( next.thickness !== previous.thickness || next.recessed !== previous.recessed ) { - markCoveringDependentsBelow(resolveLevelId(next, nodes), nodes, markDirty) + const nextLevelId = resolveLevelId(next, nodes) + markCoveringDependentsBelow(nextLevelId, nodes, markDirty) + if (next.parentId !== previous.parentId) { + const previousLevelId = resolveLevelId(previous, previousNodes) + if (previousLevelId !== nextLevelId) { + markCoveringDependentsBelow(previousLevelId, nodes, markDirty) + } + } } } @@ -396,22 +485,8 @@ export function markCoveringDependentsBelow( } } -/** - * Mark all floor items and walls that may be affected by a slab change as dirty. - */ -function markNodesOverlappingSlab( - slab: SlabNode, - nodes: Record, - markDirty: (id: AnyNodeId) => void, -) { - if (slab.polygon.length < 3) return +function renderableSlabPolygon(slab: SlabNode, nodes: Record) { const slabLevelId = resolveLevelId(slab, nodes) - - // Walls AND floor-placed nodes follow the slab's RENDERED footprint - // (band-adopted edges reach the wall's outer face), so the dirty gate - // must test the same polygon the support queries re-evaluate — a stored - // polygon that stops short of the wall body would otherwise never - // re-elevate nodes sitting over the adopted band. const levelWalls: WallNode[] = [] const siblingSlabs: SlabNode[] = [] for (const node of Object.values(nodes)) { @@ -425,9 +500,33 @@ function markNodesOverlappingSlab( siblingSlabs.push(node as SlabNode) } } - const renderedPolygon = getRenderableSlabPolygon(slab, { walls: levelWalls, siblingSlabs }) + return getRenderableSlabPolygon(slab, { walls: levelWalls, siblingSlabs }) +} - for (const node of Object.values(nodes)) { +/** + * Mark all floor items and walls that may be affected by a slab change as dirty. + */ +function markNodesOverlappingSlab( + slab: SlabNode, + nodes: Record, + markDirty: (id: AnyNodeId) => void, + contextNodes = nodes, +) { + if (slab.polygon.length < 3) return + const slabLevelId = resolveLevelId(slab, contextNodes) + const renderedPolygon = renderableSlabPolygon(slab, contextNodes) + + markNodesOverlappingPolygon(slabLevelId, renderedPolygon, nodes, markDirty) +} + +function markNodesOverlappingPolygon( + slabLevelId: string, + renderedPolygon: [number, number][], + nodes: Record, + markDirty: (id: AnyNodeId) => void, + candidates: Iterable = Object.values(nodes), +) { + for (const node of candidates) { if (node.type === 'wall') { const wall = node as WallNode if (resolveLevelId(node, nodes) !== slabLevelId) continue @@ -477,3 +576,42 @@ function markNodesOverlappingSlab( } } } + +type SlabBoundaryContext = { + walls: WallNode[] + slabs: SlabNode[] + consumers: AnyNode[] + polygons: Map +} + +function slabBoundaryContext(nodes: Record, levels: Set) { + const contexts = new Map() + for (const id in nodes) { + const node = nodes[id]! + const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced + if (node.type !== 'wall' && node.type !== 'slab' && !floorPlaced) continue + const levelId = resolveLevelId(node, nodes) + if (!levels.has(levelId)) continue + let context = contexts.get(levelId) + if (!context) { + context = { walls: [], slabs: [], consumers: [], polygons: new Map() } + contexts.set(levelId, context) + } + if (node.type === 'wall') context.walls.push(node) + if (node.type === 'slab') context.slabs.push(node) + if (node.type === 'wall' || floorPlaced) context.consumers.push(node) + } + return contexts +} + +function cachedSlabPolygon(slab: SlabNode, context: SlabBoundaryContext) { + let polygon = context.polygons.get(slab.id) + if (!polygon) { + polygon = getRenderableSlabPolygon(slab, { + walls: context.walls, + siblingSlabs: context.slabs.filter((sibling) => sibling.id !== slab.id), + }) + context.polygons.set(slab.id, polygon) + } + return polygon +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1d250404a9..83ee9bc6bf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -311,6 +311,7 @@ export { type SceneSnapshot, subscribeSceneCommits, } from './store/history-control' +export { getHistoryDirtyNodeIds } from './store/history-invalidation' export { type ControlValue, type DoorAnimationState, diff --git a/packages/core/src/store/history-invalidation.test.ts b/packages/core/src/store/history-invalidation.test.ts new file mode 100644 index 0000000000..101cc04795 --- /dev/null +++ b/packages/core/src/store/history-invalidation.test.ts @@ -0,0 +1,286 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { nodeRegistry } from '../registry' +import { + type AnyNode, + CeilingNode, + DoorNode, + ItemNode, + LevelNode, + SlabNode, + WallNode, + WindowNode, +} from '../schema' +import { getHistoryDirtyNodeIds } from './history-invalidation' +import useScene, { clearSceneHistory } from './use-scene' + +const level = LevelNode.parse({ id: 'level_history' }) +const wall = WallNode.parse({ id: 'wall_changed', parentId: level.id, start: [0, 0], end: [4, 0] }) +const remote = WallNode.parse({ + id: 'wall_remote', + parentId: level.id, + start: [20, 0], + end: [24, 0], +}) +const nodes = (...entries: AnyNode[]) => Object.fromEntries(entries.map((node) => [node.id, node])) +const ids = (before: Record, after: Record) => + [...getHistoryDirtyNodeIds(before, after)].sort() +const asset = { id: 'test', name: 'test', category: 'test', thumbnail: '', src: '/test.glb' } + +describe('history dependency closure', () => { + test.each([ + 'corner', + 'tee', + 'reverse tee', + 'curve', + ])('wall body move disconnects and restores a %s in both directions, only on its level', (junction) => { + const changed = junction === 'curve' ? { ...wall, curveOffset: 1 } : wall + const adjacent = WallNode.parse({ + id: 'wall_adjacent', + parentId: level.id, + start: + junction === 'corner' + ? [4, 0] + : junction === 'tee' + ? [2, 0] + : junction === 'curve' + ? [4, 0] + : [4, -2], + end: junction === 'reverse tee' ? [4, 2] : [2, -3], + }) + const otherLevel = { ...adjacent, id: 'wall_other_level', parentId: 'level_other' } as WallNode + const before = nodes(level, changed, adjacent, remote, otherLevel) + const after = { + ...before, + [changed.id]: { ...changed, start: [8, 8], end: [12, 8] } as WallNode, + } + expect(ids(before, after)).toEqual([level.id, adjacent.id, changed.id].sort()) + expect(ids(after, before)).toEqual(ids(before, after)) + }) + + test('endpoint move includes former and new neighbours without following a junction transitively', () => { + const old = WallNode.parse({ id: 'wall_old', parentId: level.id, start: [4, 0], end: [4, 4] }) + const next = WallNode.parse({ id: 'wall_next', parentId: level.id, start: [6, 0], end: [6, 4] }) + const beyond = WallNode.parse({ + id: 'wall_beyond', + parentId: level.id, + start: [6, 4], + end: [8, 4], + }) + const before = nodes(level, wall, old, next, beyond, remote) + const after = { ...before, [wall.id]: { ...wall, end: [6, 0] } as WallNode } + expect(ids(before, after)).toEqual([level.id, wall.id, old.id, next.id].sort()) + }) + + test.each([ + { thickness: 0.4 }, + { height: 4 }, + { curveOffset: 0.8 }, + ])('host shape change %j includes opening proxies and wall-side items', (patch) => { + const door = DoorNode.parse({ parentId: wall.id }) + const window = WindowNode.parse({ parentId: wall.id }) + const item = ItemNode.parse({ parentId: wall.id, asset: { ...asset, attachTo: 'wall-side' } }) + const other = DoorNode.parse({ parentId: remote.id }) + const before = nodes(level, wall, remote, door, window, item, other) + const after = { ...before, [wall.id]: { ...wall, ...patch } } + expect(ids(before, after)).toEqual([level.id, wall.id, door.id, window.id, item.id].sort()) + expect(ids(after, before)).toEqual(ids(before, after)) + }) + + test.each([ + DoorNode, + WindowNode, + ])('opening add/remove/move/resize/reparent marks surviving hosts', (schema) => { + const opening = schema.parse({ parentId: wall.id }) + const before = nodes(level, wall, remote, opening) + for (const patch of [{ position: [1, 1, 0] }, { width: 2 }, { parentId: remote.id }]) { + const after = { ...before, [opening.id]: { ...opening, ...patch } as AnyNode } + expect(ids(before, after)).toEqual( + [opening.id, wall.id, ...('parentId' in patch ? [remote.id] : [])].sort(), + ) + } + const absent = nodes(level, wall, remote) + expect(ids(absent, before)).toEqual([opening.id, wall.id].sort()) + expect(ids(before, absent)).toEqual([wall.id]) + }) + + test.each([ + 'floor', + 'wall host', + 'elevated slab', + ])('item move on %s marks the item and parent, preserving its asset', (support) => { + const item = ItemNode.parse({ + parentId: support === 'wall host' ? wall.id : level.id, + supportSlabId: support === 'elevated slab' ? 'slab_deck' : undefined, + asset, + }) + const before = nodes(level, wall, remote, item) + const moved = { ...item, position: [3, 0, 2], rotation: [0, 1, 0] } as AnyNode + const after = { ...before, [item.id]: moved } + expect(ids(before, after)).toEqual([item.id, item.parentId!].sort()) + expect((moved as ItemNode).asset).toBe(item.asset) + }) + + test.each([ + SlabNode, + CeilingNode, + ])('surface polygon/holes/elevation marks the surface; subscribers own support dependencies', (schema) => { + const surface = schema.parse({ + parentId: level.id, + polygon: [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ], + }) + const before = nodes(level, wall, remote, surface) + for (const patch of [ + { + polygon: [ + [0, 0], + [8, 0], + [8, 4], + [0, 4], + ], + }, + schema === SlabNode ? { elevation: 1 } : { height: 4 }, + { + holes: [ + [ + [1, 1], + [2, 1], + [2, 2], + ], + ], + }, + ]) { + expect(ids(before, { ...before, [surface.id]: { ...surface, ...patch } as AnyNode })).toEqual( + [surface.id, level.id].sort(), + ) + } + }) + + test('level height leaves descendant invalidation to the spatial subscription', () => { + const before = nodes(level, wall, remote) + expect(ids(before, { ...before, [level.id]: { ...level, height: 4 } })).toEqual([level.id]) + }) + + test('subtree delete/restore filters missing ids and preserves the deletion sibling rule', () => { + const door = DoorNode.parse({ parentId: wall.id }) + const full = nodes( + { ...level, children: [wall.id, remote.id] }, + { ...wall, children: [door.id] }, + remote, + door, + ) + const removed = nodes({ ...level, children: [remote.id] }, remote) + expect(ids(full, removed)).toEqual([level.id, remote.id].sort()) + expect(ids(removed, full)).toEqual([level.id, wall.id, door.id].sort()) + expect(ids(full, full)).toEqual([]) + }) + test('wall reparent includes neighbours on the old and new levels', () => { + const upper = LevelNode.parse({ id: 'level_upper_history' }) + const old = WallNode.parse({ + id: 'wall_old_level', + parentId: level.id, + start: [4, 0], + end: [4, 4], + }) + const next = { ...old, id: 'wall_new_level', parentId: upper.id } as WallNode + const before = nodes(level, upper, wall, old, next, remote) + const after = { ...before, [wall.id]: { ...wall, parentId: upper.id } } + expect(ids(before, after)).toEqual([level.id, upper.id, wall.id, old.id, next.id].sort()) + }) + + test('thickness rebuilds a joined corner and T junction without dirtying a disconnected wall', () => { + const corner = WallNode.parse({ + id: 'wall_corner', + parentId: level.id, + start: [4, 0], + end: [4, 4], + }) + const tee = WallNode.parse({ id: 'wall_tee', parentId: level.id, start: [2, 0], end: [2, -4] }) + const before = nodes(level, wall, corner, tee, remote) + expect(ids(before, { ...before, [wall.id]: { ...wall, thickness: 0.5 } })).toEqual( + [level.id, wall.id, corner.id, tee.id].sort(), + ) + }) + + test('same-count subtree replacement restores original identities and excludes deleted ids', () => { + const door = DoorNode.parse({ parentId: wall.id }) + const replacement = DoorNode.parse({ parentId: remote.id }) + const before = nodes(level, { ...wall, children: [door.id] }, remote, door) + const after = nodes( + level, + { ...wall, children: [] }, + { ...remote, children: [replacement.id] }, + replacement, + ) + const dirty = getHistoryDirtyNodeIds(after, before) + expect(dirty.has(door.id)).toBe(true) + expect(dirty.has(replacement.id)).toBe(false) + expect(dirty.has(wall.id)).toBe(true) + expect(dirty.has(remote.id)).toBe(true) + }) +}) + +describe('consecutive temporal wall moves', () => { + let restore = () => {} + + beforeEach(() => { + restore = nodeRegistry._snapshot() + nodeRegistry._reset() + clearSceneHistory() + }) + + afterEach(() => { + clearSceneHistory() + restore() + }) + + test('three undos and redos mark exactly the neighbours in each pre/post-jump layout', async () => { + const neighbours = [0, 10, 20, 30].map((x, index) => + WallNode.parse({ + id: `wall_neighbour_${index}`, + parentId: level.id, + start: [x + 4, 0], + end: [x + 4, 4], + }), + ) + const layouts = [0, 10, 20, 30].map( + (x) => + ({ + ...wall, + start: [x, 0], + end: [x + 4, 0], + }) as WallNode, + ) + const unrelated = { ...remote, start: [100, 0], end: [104, 0] } as WallNode + useScene.setState({ + nodes: nodes(level, layouts[0]!, ...neighbours, unrelated), + rootNodeIds: [level.id], + collections: {}, + installedPlugins: [], + dirtyNodes: new Set(), + readOnly: false, + }) + clearSceneHistory() + for (const moved of layouts.slice(1)) { + useScene.setState({ nodes: { ...useScene.getState().nodes, [wall.id]: moved } }) + } + expect(useScene.temporal.getState().pastStates).toHaveLength(3) + + for (const direction of ['undo', 'redo'] as const) { + for (const beforeIndex of direction === 'undo' ? [3, 2, 1] : [0, 1, 2]) { + const afterIndex = beforeIndex + (direction === 'undo' ? -1 : 1) + useScene.getState().dirtyNodes.clear() + useScene.temporal.getState()[direction]() + await Promise.resolve() + expect(useScene.getState().nodes[wall.id]).toBe(layouts[afterIndex]) + expect([...useScene.getState().dirtyNodes].sort()).toEqual( + [level.id, wall.id, neighbours[beforeIndex]!.id, neighbours[afterIndex]!.id].sort(), + ) + } + } + }) +}) diff --git a/packages/core/src/store/history-invalidation.ts b/packages/core/src/store/history-invalidation.ts new file mode 100644 index 0000000000..5e20d6051e --- /dev/null +++ b/packages/core/src/store/history-invalidation.ts @@ -0,0 +1,80 @@ +import type { AnyNode, AnyNodeId, WallNode } from '../schema' +import { getAdjacentWallIds } from '../systems/wall/wall-mitering' + +export function getHistoryDirtyNodeIds( + before: Record, + after: Record, +): Set { + const dirty = new Set() + const changedWalls = new Set() + const changedHosts = new Set() + const add = (id: string | null | undefined) => { + if (id && after[id]) dirty.add(id as AnyNodeId) + } + + const visitChange = (id: string) => { + const previous = before[id] + const next = after[id] + if (previous === next) return + add(id) + add(previous?.parentId) + add(next?.parentId) + + if (previous?.type === 'wall' || next?.type === 'wall') { + changedWalls.add(id) + if ( + previous?.type !== 'wall' || + next?.type !== 'wall' || + previous.thickness !== next.thickness || + previous.height !== next.height || + previous.curveOffset !== next.curveOffset + ) { + changedHosts.add(id) + } + } + + if (previous && !next && previous.parentId) { + const parent = after[previous.parentId] + // Preserve deletion's sibling refresh for merged geometry consumers. + if (parent && 'children' in parent && Array.isArray(parent.children)) { + for (const childId of parent.children) add(childId) + } + } + } + + for (const id in before) visitChange(id) + for (const id in after) { + if (!before[id]) visitChange(id) + } + + if (changedWalls.size === 0) return dirty + + for (const nodes of [before, after]) { + const wallsByLevel = new Map() + for (const id of changedWalls) { + const wall = nodes[id] + if (wall?.type === 'wall' && !wallsByLevel.has(wall.parentId)) { + wallsByLevel.set(wall.parentId, []) + } + } + for (const id in nodes) { + const node = nodes[id]! + if (node.type === 'wall') { + wallsByLevel.get(node.parentId)?.push(node) + } + if ( + node.parentId && + changedHosts.has(node.parentId) && + (node.type === 'door' || node.type === 'window' || node.type === 'item') + ) { + add(node.id) + } + } + // Former junctions must rebuild too; the viewer only sees the restored layout. + for (const walls of wallsByLevel.values()) { + const changed = new Set(walls.filter((wall) => changedWalls.has(wall.id)).map((w) => w.id)) + for (const id of getAdjacentWallIds(walls, changed)) add(id) + } + } + return dirty +} diff --git a/packages/core/src/store/use-scene-dirty-tracking.test.ts b/packages/core/src/store/use-scene-dirty-tracking.test.ts index e26444711c..caf7f15b7d 100644 --- a/packages/core/src/store/use-scene-dirty-tracking.test.ts +++ b/packages/core/src/store/use-scene-dirty-tracking.test.ts @@ -39,10 +39,11 @@ describe('dirty tracking', () => { beforeEach(() => { if (!nodeRegistry.has(untrackedDef.kind)) nodeRegistry._register(untrackedDef) if (!nodeRegistry.has(trackedDef.kind)) nodeRegistry._register(trackedDef) - // Clear rather than replace the dirty set: the store's own instance is the - // guarded one, and the raw-add tests below exercise that guard. - useScene.getState().dirtyNodes.clear() + // Other source tests can replace the set; raw-add tests need the store's guard. + const dirtyNodes = useScene.getInitialState().dirtyNodes + dirtyNodes.clear() useScene.setState({ + dirtyNodes, nodes: { [UNTRACKED]: makeNode(UNTRACKED, 'test-untracked'), [TRACKED]: makeNode(TRACKED, 'test-tracked'), diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 1f92432ade..052fea9061 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -52,6 +52,7 @@ import { type SceneCommitOrigin, type SceneSnapshot, } from './history-control' +import { getHistoryDirtyNodeIds } from './history-invalidation' import useLiveNodeOverrides from './use-live-node-overrides' import useLiveTransforms from './use-live-transforms' @@ -2138,10 +2139,9 @@ export function applySceneSnapshot( return true } -// Track previous temporal state lengths and node snapshot for diffing +// Track previous temporal state lengths for identifying history jumps let prevPastLength = 0 let prevFutureLength = 0 -let prevNodesSnapshot: Record | null = null export function clearSceneHistory() { resetSceneHistoryPauseDepth() @@ -2154,11 +2154,17 @@ export function clearSceneHistory() { useScene.temporal.getState().clear() prevPastLength = 0 prevFutureLength = 0 - prevNodesSnapshot = null } // Subscribe to the temporal store (Undo/Redo events) -useScene.temporal.subscribe((state) => { +useScene.temporal.subscribe((state, previousState) => { + // Zundo mutates its source stack before writing the scene. Reconciliation's + // pause/resume notifications must not advance our pre-jump stack lengths. + if ( + state.pastStates === previousState.pastStates && + state.futureStates === previousState.futureStates + ) + return const currentPastLength = state.pastStates.length const currentFutureLength = state.futureStates.length @@ -2168,8 +2174,13 @@ useScene.temporal.subscribe((state) => { const didRedo = currentPastLength > prevPastLength && currentFutureLength < prevFutureLength if (didUndo || didRedo) { - // Capture the previous snapshot before RAF fires - const snapshotBefore = prevNodesSnapshot + // Capture both layouts before another synchronous jump can replace them. + // The state pushed onto the opposite stack includes history-paused derived + // writes (such as stair rise), unlike a snapshot saved at the last edit. + const snapshotBefore = didUndo + ? state.futureStates[prevFutureLength]?.nodes + : state.pastStates[prevPastLength]?.nodes + const snapshotAfter = useScene.getState().nodes // Defer to a microtask so the scene store has settled before we diff, // but still mark walls/items dirty before the next paint. @@ -2178,31 +2189,7 @@ useScene.temporal.subscribe((state) => { const { markDirty } = useScene.getState() if (snapshotBefore) { - // Diff: only mark nodes that actually changed - for (const [id, node] of Object.entries(currentNodes) as [AnyNodeId, AnyNode][]) { - if (snapshotBefore[id] !== node) { - markDirty(id) - // Also mark parent so merged geometries update - if (node.parentId) markDirty(node.parentId as AnyNodeId) - } - } - // Nodes that were deleted (exist in prev but not current) - for (const [id, node] of Object.entries(snapshotBefore) as [AnyNodeId, AnyNode][]) { - if (!currentNodes[id]) { - const parentId = node.parentId as AnyNodeId | undefined - if (parentId) { - markDirty(parentId) - // Mark sibling nodes dirty so they can update their geometry - // (e.g. adjacent walls need to recalculate miter/junction geometry) - const parent = currentNodes[parentId] - if (parent && 'children' in parent && Array.isArray(parent.children)) { - for (const childId of parent.children) { - markDirty(childId as AnyNodeId) - } - } - } - } - } + for (const id of getHistoryDirtyNodeIds(snapshotBefore, snapshotAfter)) markDirty(id) } else { // No snapshot to diff against — fall back to marking all for (const node of Object.values(currentNodes)) { @@ -2220,8 +2207,7 @@ useScene.temporal.subscribe((state) => { }) } - // Update tracked lengths and snapshot + // Update tracked lengths prevPastLength = currentPastLength prevFutureLength = currentFutureLength - prevNodesSnapshot = useScene.getState().nodes }) diff --git a/packages/editor/src/lib/history.test.ts b/packages/editor/src/lib/history.test.ts index f356459afb..65768e5141 100644 --- a/packages/editor/src/lib/history.test.ts +++ b/packages/editor/src/lib/history.test.ts @@ -1,194 +1,535 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' -import { - type AnyNode, - type AnyNodeId, - BuildingNode, - clearSceneHistory, - emitter, - LevelNode, - nodeRegistry, - useScene, -} from '@pascal-app/core' -import useInteractionScope from '../store/use-interaction-scope' -import { - getHistoryCommandState, - installHistoryCommandDelegate, - runRedo, - runUndo, - shouldCancelDraftOnHistoryJump, - subscribeHistoryCommandState, -} from './history' - -type RafFn = (cb: (time: number) => void) => number -;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (cb) => { - cb(0) - return 0 +import { describe, expect, test } from 'bun:test' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' + +function runSourceHistoryTest(body: string) { + const cache = join(import.meta.dir, '.turbo') + mkdirSync(cache, { recursive: true }) + const directory = mkdtempSync(join(cache, 'history-')) + const probe = join(directory, 'probe.ts') + try { + writeFileSync( + probe, + ` + import assert from 'node:assert/strict' + import { mock } from 'bun:test' + import { fileURLToPath, pathToFileURL } from 'node:url' + const consumers = [ + ${JSON.stringify(resolve(import.meta.dir, 'history.ts'))}, + ${JSON.stringify(resolve(import.meta.dir, '../../../core/src/index.ts'))}, + ${JSON.stringify(resolve(import.meta.dir, '../../../viewer/src/systems/wall/wall-system.tsx'))}, + ${JSON.stringify(resolve(import.meta.dir, '../../../nodes/src/shared/node-batch/system.tsx'))}, + ] + const sharedPaths = new Map( + ['@pascal-app/core', '@pascal-app/viewer', 'react', 'three', '@react-three/fiber'].map(specifier => [ + specifier, + [...new Set(consumers.map(consumer => fileURLToPath(import.meta.resolve(specifier, pathToFileURL(consumer).href))))], + ]), + ) + function mockShared(specifier, factory) { + for (const path of sharedPaths.get(specifier)) mock.module(path, factory) + } + async function importShared(specifier) { + const module = await import(sharedPaths.get(specifier)[0]) + mockShared(specifier, () => module) + return module + } + await importShared('react') + await importShared('three') + await importShared('@react-three/fiber') + globalThis.requestAnimationFrame = callback => { callback(0); return 0 } + globalThis.cancelAnimationFrame = () => {} + const core = await import(${JSON.stringify(resolve(import.meta.dir, '../../..', 'core/src/index.ts'))}) + mockShared('@pascal-app/core', () => core) + await importShared('@pascal-app/viewer') + const { useScene: scene, clearSceneHistory, useLiveTransforms: transforms, useLiveNodeOverrides: overrides } = core + const { runUndo, runRedo, installHistoryCommandDelegate, getHistoryCommandState, shouldCancelDraftOnHistoryJump, subscribeHistoryCommandState } = await import(${JSON.stringify(resolve(import.meta.dir, 'history.ts'))}) + const { default: useInteractionScope } = await import(${JSON.stringify(resolve(import.meta.dir, '../store/use-interaction-scope.ts'))}) + const level = core.LevelNode.parse({ id: 'level_history_source' }) + const wall = core.WallNode.parse({ id: 'wall_history_source', parentId: level.id, start: [0,0], end: [4,0] }) + const remote = core.WallNode.parse({ id: 'wall_remote_source', parentId: level.id, start: [20,0], end: [24,0] }) + const opening = core.DoorNode.parse({ id: 'door_history_source', parentId: wall.id, wallId: wall.id }) + const slab = core.SlabNode.parse({ id: 'slab_history_source', parentId: level.id, polygon: [[0,0],[4,0],[4,4],[0,4]] }) + const baseline = Object.fromEntries([level, { ...wall, children: [opening.id] }, remote, opening, slab].map(node => [node.id, node])) + scene.setState({ nodes: baseline, dirtyNodes: new Set(), readOnly: false, materials: {}, collections: {}, rootNodeIds: [level.id] }) + clearSceneHistory() + const flush = async () => { await Promise.resolve(); await Promise.resolve() } + const clean = () => scene.getState().dirtyNodes.clear() + const dirty = id => scene.getState().dirtyNodes.has(id) + const edit = (id, patch) => scene.getState().updateNode(id, patch) + ${body} + `, + ) + const result = Bun.spawnSync([process.execPath, probe], { stdout: 'pipe', stderr: 'pipe' }) + expect({ code: result.exitCode, stderr: result.stderr.toString() }).toEqual({ + code: 0, + stderr: '', + }) + } finally { + rmSync(directory, { recursive: true, force: true }) + } } -;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= - () => {} -const BUILDING_ID = 'building_history_controller' as AnyNodeId -const LEVEL_ID = 'level_history_controller' as AnyNodeId -let disposeController = () => {} -let restoreRegistry = () => {} +describe('standalone history source invalidation', () => { + test('actual undo/redo keeps unchanged nodes clean and restores exact wall data on repeated jumps', () => { + runSourceHistoryTest(` + edit(wall.id, { start: [0,2], end: [4,2] }) + const moved = scene.getState().nodes + for (let i = 0; i < 3; i++) { + clean(); runUndo(); await flush() + assert.equal(scene.getState().nodes[wall.id], baseline[wall.id]) + assert(dirty(wall.id)); assert(!dirty(remote.id)); assert(!dirty(slab.id)) + clean(); runRedo(); await flush() + assert.equal(scene.getState().nodes[wall.id], moved[wall.id]) + assert(dirty(wall.id)); assert(!dirty(remote.id)); assert(!dirty(slab.id)) + } + `) + }) -function levelNumber(): number { - return (useScene.getState().nodes[LEVEL_ID] as { level: number }).level -} + test('unchanged transform/override targets and hosted parents restore after clear, including stair holes', () => { + runSourceHistoryTest(` + edit(level.id, { name: 'Changed' }) + transforms.getState().set(remote.id, { position: [1,0,0], rotation: 0 }) + overrides.getState().set(opening.id, { width: 2 }) + const controller = core.createSurfaceOpeningPreviewController() + controller.apply([{ id: slab.id, data: { holes: [[[1,1],[2,1],[2,2]]] } }]) + clean(); runUndo(); await flush() + for (const id of [remote.id, opening.id, wall.id, slab.id]) assert(dirty(id), id) + assert.equal(scene.getState().nodes[remote.id], baseline[remote.id]) + assert.equal(transforms.getState().transforms.size, 0) + assert.equal(overrides.getState().overrides.size, 0) + controller.clear() + `) + }) -describe('editor history controller', () => { - beforeEach(() => { - disposeController() - disposeController = () => {} - restoreRegistry() - restoreRegistry = nodeRegistry._snapshot() - useInteractionScope.getState().end() - const level = LevelNode.parse({ - id: LEVEL_ID, - parentId: BUILDING_ID, - children: [], - level: 0, - }) - const building = BuildingNode.parse({ - id: BUILDING_ID, - parentId: null, - children: [LEVEL_ID], - }) - useScene.setState({ - nodes: { [BUILDING_ID]: building, [LEVEL_ID]: level }, - rootNodeIds: [BUILDING_ID], - dirtyNodes: new Set(), - collections: {}, - materials: {}, - readOnly: false, - } as never) - clearSceneHistory() - useScene.getState().updateNode(LEVEL_ID, { level: 1 } as Partial) - }) - - afterEach(() => { - disposeController() - disposeController = () => {} - restoreRegistry() - restoreRegistry = () => {} - useInteractionScope.getState().end() - }) - - test('cancels history jumps only when the drafted kind opts in', () => { - const onCancel = mock(() => {}) - emitter.on('tool:cancel', onCancel) - try { - useInteractionScope.getState().begin({ kind: 'drafting', tool: 'plain-draft' }) - expect(shouldCancelDraftOnHistoryJump()).toBe(false) + test.each([ + true, + false, + ])('discarded preview neighbours rebuild after undo/redo (joined: %s)', (joined) => { + runSourceHistoryTest(` + const react = await importShared('react') + mockShared('react', () => ({ ...react, useEffect: () => {} })) + const frames = [] + const fiber = await importShared('@react-three/fiber') + mockShared('@react-three/fiber', () => ({ ...fiber, useFrame: frame => frames.push(frame) })) + const selector = store => Object.assign(fn => fn(store.getState()), store) + mockShared('@pascal-app/core', () => ({ ...core, useScene: selector(scene), useLiveNodeOverrides: selector(overrides) })) + const { Mesh } = await importShared('three') + const { WallSystem, getPendingWallRebuildCount } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../viewer/src/systems/wall/wall-system.tsx'))}) + const neighbor = { ...remote, start: [8,0], end: [8,4] } + scene.setState({ nodes: { ...baseline, [level.id]: { ...level, children: [wall.id, neighbor.id] }, [neighbor.id]: neighbor } }) + clearSceneHistory() + const a = new Mesh(), b = new Mesh() + core.sceneRegistry.nodes.set(wall.id, a) + core.sceneRegistry.nodes.set(neighbor.id, b) + let now = 0 + performance.now = () => now + WallSystem() + const frame = () => { now += 100; frames[0]() } + scene.getState().markDirty(wall.id); scene.getState().markDirty(neighbor.id) + frame(); frame(); clean() + const canonical = Array.from(b.geometry.getAttribute('position').array) + edit(level.id, { name: 'Unrelated edit' }) + for (const jump of [runUndo, runRedo]) { + overrides.getState().set(wall.id, { start: [4,0], end: [${joined ? 8 : 6},0] }) + scene.getState().markDirty(wall.id) + frame(); frame() + assert.equal(getPendingWallRebuildCount(), 0) + const preview = Array.from(b.geometry.getAttribute('position').array) + ${joined ? 'assert.notDeepEqual(preview, canonical)' : 'assert.deepEqual(preview, canonical)'} + clean(); jump(); await flush() + assert(dirty(wall.id)) + assert.equal(dirty(neighbor.id), ${joined}) + assert.equal(overrides.getState().overrides.size, 0) + frame(); frame() + assert.deepEqual(Array.from(b.geometry.getAttribute('position').array), canonical) + } + `) + }) - nodeRegistry._register({ - kind: 'registered-draft', - schemaVersion: 1, - drafting: { cancelOnHistoryJump: true }, - } as never) - useInteractionScope.getState().begin({ kind: 'drafting', tool: 'registered-draft' }) - expect(shouldCancelDraftOnHistoryJump()).toBe(true) + test('opening reparent dirties old and new walls on undo and redo', () => { + runSourceHistoryTest(` + edit(opening.id, { parentId: remote.id, wallId: remote.id }) + for (const jump of [runUndo, runRedo]) { + clean(); jump(); await flush() + for (const id of [wall.id, remote.id, opening.id]) assert(dirty(id), id) + } + `) + }) - runUndo() - expect(onCancel).toHaveBeenCalledTimes(1) - } finally { - emitter.off('tool:cancel', onCancel) - } - }) - - test('delegates undo and redo while a host delegate is installed', () => { - const undo = mock(() => ({ kind: 'applied', persistence: 'queued' }) as const) - const redo = mock(() => ({ kind: 'empty' }) as const) - disposeController = installHistoryCommandDelegate({ - getState: () => ({ - canRedo: false, - canUndo: true, - mode: 'collaborative', - status: 'syncing', - }), - redo, - subscribe: () => () => {}, - undo, - }) + test('delete/restore of a subtree leaves no deleted dirty ids or live entries', () => { + runSourceHistoryTest(` + scene.getState().deleteNode(wall.id) + clean(); runUndo(); await flush() + assert.equal(scene.getState().nodes[opening.id], baseline[opening.id]) + assert(dirty(wall.id)); assert(dirty(opening.id)) + transforms.getState().set(opening.id, { position: [1,0,0], rotation: 0 }) + scene.getState().markDirty(opening.id) + runRedo(); await flush() + assert(!scene.getState().nodes[wall.id]); assert(!scene.getState().nodes[opening.id]) + assert(!dirty(wall.id)); assert(!dirty(opening.id)) + assert.equal(transforms.getState().transforms.size, 0) + `) + }) - expect(runUndo()).toEqual({ kind: 'applied', persistence: 'queued' }) - expect(runRedo()).toEqual({ kind: 'empty' }) - expect(getHistoryCommandState()).toEqual({ - canRedo: false, - canUndo: true, - mode: 'collaborative', - status: 'syncing', - }) + test('synchronous jumps preserve intermediate wall layouts until their microtasks flush', () => { + runSourceHistoryTest(` + edit(wall.id, { start: [16,0], end: [20,0] }) + runUndo(); await flush(); clean() + runRedo(); runUndo(); await flush() + assert(dirty(remote.id)) + assert.equal(scene.getState().nodes[wall.id], baseline[wall.id]) + `) + }) - expect(undo).toHaveBeenCalledTimes(1) - expect(redo).toHaveBeenCalledTimes(1) - expect(levelNumber()).toBe(1) - expect(useScene.temporal.getState().pastStates).toHaveLength(1) - }) - - test('falls back to standalone Zundo undo and redo when no controller is installed', () => { - expect(runUndo()).toEqual({ kind: 'applied', persistence: 'local' }) - expect(levelNumber()).toBe(0) - expect(useScene.temporal.getState().futureStates).toHaveLength(1) - - expect(runRedo()).toEqual({ kind: 'applied', persistence: 'local' }) - expect(levelNumber()).toBe(1) - expect(useScene.temporal.getState().pastStates).toHaveLength(1) - }) - - test('an older cleanup cannot uninstall a newer controller', () => { - const firstUndo = mock(() => {}) - const delegate = (undo: () => void) => ({ - getState: () => ({ - canRedo: false, - canUndo: true, - mode: 'collaborative' as const, - status: 'ready' as const, - }), - redo: () => ({ kind: 'empty' as const }), - subscribe: () => () => {}, - undo: () => { - undo() - return { kind: 'applied' as const, persistence: 'queued' as const } - }, - }) - const stopFirst = installHistoryCommandDelegate(delegate(firstUndo)) - const secondUndo = mock(() => {}) - disposeController = installHistoryCommandDelegate(delegate(secondUndo)) + test('empty commands and collaborative delegates retain ownership of live previews and dirtiness', () => { + runSourceHistoryTest(` + transforms.getState().set(remote.id, { position: [1,0,0], rotation: 0 }) + overrides.getState().set(opening.id, { width: 2 }) + clean() + assert.equal(runUndo().kind, 'empty'); assert.equal(runRedo().kind, 'empty') + edit(wall.id, { thickness: 0.4 }); clean() + const changed = scene.getState().nodes + let undo = 0, redo = 0 + const stop = installHistoryCommandDelegate({ + getState: () => ({ canUndo: true, canRedo: true, mode: 'collaborative', status: 'ready' }), + subscribe: () => () => {}, + undo: () => { undo++; return { kind: 'applied', persistence: 'queued' } }, + redo: () => { redo++; return { kind: 'empty' } }, + }) + runUndo(); runRedo(); await flush(); stop() + assert.equal(undo, 1); assert.equal(redo, 1) + assert.equal(scene.getState().nodes, changed) + assert.equal(scene.getState().dirtyNodes.size, 0) + assert.equal(transforms.getState().transforms.size, 1) + assert.equal(overrides.getState().overrides.size, 1) + `) + }) + test('one-wall undo releases only its openings and neighbour openings from the real batch store', () => { + runSourceHistoryTest(` + const { Group, Mesh, MeshBasicMaterial, BoxGeometry } = await importShared('three') + const viewer = await importShared('@pascal-app/viewer') + const { captureChangedNodes, runBatchFrame, resetNodeBatchState } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../nodes/src/shared/node-batch/system.tsx'))}) + const root = new Group() + core.sceneRegistry.nodes.set(level.id, root) + core.sceneRegistry.byType.level.add(level.id) + const material = new MeshBasicMaterial() + const meshes = [] + const walls = [wall, { ...wall, id: 'wall_neighbor', start: [4,0], end: [4,4] }, remote, { ...remote, id: 'wall_far', start: [30,0], end: [34,0] }] + const nodes = { [level.id]: level } + walls.forEach((host, i) => { + const door = core.DoorNode.parse({ id: 'door_batch_' + i, parentId: host.id }) + nodes[host.id] = { ...host, children: [door.id] } + nodes[door.id] = door + const mesh = new Mesh(new BoxGeometry(), material) + meshes.push(mesh); root.add(mesh) + core.sceneRegistry.nodes.set(door.id, mesh) + core.sceneRegistry.byType.door.add(door.id) + }) + scene.setState({ nodes }); clearSceneHistory() + edit(wall.id, { start: [0,2], end: [4,2] }); clean() + viewer.useViewer.setState({ externalSelectedIds: [], previewSelectedIds: [], hoveredId: null, selection: { ...viewer.useViewer.getState().selection, selectedIds: [], levelId: null } }) + let now = 0 + performance.now = () => now + const wake = { current: null } + const frame = () => runBatchFrame(() => {}, wake) + frame(); now += 181; frame() + assert(meshes.every(mesh => !mesh.layers.isEnabled(viewer.SCENE_LAYER))) + const batch = root.children.find(child => child.name === 'item-batch') + assert.equal(batch.instanceCount, 4) + runUndo(); await flush() + captureChangedNodes(); clean(); frame() + assert.deepEqual(meshes.map(mesh => mesh.layers.isEnabled(viewer.SCENE_LAYER)), [true, true, false, false]) + assert.equal(batch.instanceCount, 2) + now += 181; frame() + assert.equal(batch.instanceCount, 4) + assert(meshes.every(mesh => !mesh.layers.isEnabled(viewer.SCENE_LAYER))) + resetNodeBatchState(); if (wake.current) clearTimeout(wake.current) + `) + }) - stopFirst() - runUndo() + test('endpoint undo/redo releases exactly both endpoint neighbours and their hosted children', () => { + runSourceHistoryTest(` + const { Group, Mesh, MeshBasicMaterial, BoxGeometry } = await importShared('three') + const viewer = await importShared('@pascal-app/viewer') + const { captureChangedNodes, runBatchFrame, resetNodeBatchState } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../nodes/src/shared/node-batch/system.tsx'))}) + const root = new Group() + core.sceneRegistry.nodes.set(level.id, root) + core.sceneRegistry.byType.level.add(level.id) + const material = new MeshBasicMaterial() + const meshes = [] + const walls = [wall, { ...wall, id: 'wall_start', start: [0,0], end: [0,4] }, { ...wall, id: 'wall_old', start: [4,0], end: [4,4] }, { ...wall, id: 'wall_new', start: [6,1], end: [6,4] }, { ...wall, id: 'wall_beyond', start: [6,4], end: [8,4] }, remote, { ...wall, id: 'wall_interior', start: [1,1], end: [2,1] }] + const nodes = { [level.id]: level, [slab.id]: slab } + walls.forEach((host, i) => { + const door = core.DoorNode.parse({ id: 'door_batch_' + i, parentId: host.id }) + nodes[host.id] = { ...host, children: [door.id] } + nodes[door.id] = door + const mesh = new Mesh(new BoxGeometry(), material) + meshes.push(mesh); root.add(mesh) + core.sceneRegistry.nodes.set(door.id, mesh) + core.sceneRegistry.byType.door.add(door.id) + }) + scene.setState({ nodes }); clearSceneHistory() + const stopSpatial = core.initSpatialGridSync() + edit(wall.id, { end: [6,1] }); clean() + viewer.useViewer.setState({ externalSelectedIds: [], previewSelectedIds: [], hoveredId: null, selection: { ...viewer.useViewer.getState().selection, selectedIds: [], levelId: null } }) + let now = 0 + performance.now = () => now + const wake = { current: null } + const frame = () => runBatchFrame(() => {}, wake) + frame(); now += 181; frame() + assert(meshes.every(mesh => !mesh.layers.isEnabled(viewer.SCENE_LAYER))) + const batch = root.children.find(child => child.name === 'item-batch') + assert.equal(batch.instanceCount, 7) + for (const jump of [runUndo, runRedo]) { + clean(); jump(); await flush() + assert.deepEqual([...scene.getState().dirtyNodes].sort(), [level.id, ...walls.slice(0,4).map(node => node.id)].sort()) + captureChangedNodes(); clean(); frame() + assert.deepEqual(meshes.map(mesh => mesh.layers.isEnabled(viewer.SCENE_LAYER)), [true, true, true, true, false, false, false]) + assert.equal(batch.instanceCount, 3) + now += 181; frame() + assert.equal(batch.instanceCount, 7) + assert(meshes.every(mesh => !mesh.layers.isEnabled(viewer.SCENE_LAYER))) + } + stopSpatial(); core.spatialGridManager.clear(); resetNodeBatchState(); if (wake.current) clearTimeout(wake.current) + `) + }) - expect(firstUndo).toHaveBeenCalledTimes(0) - expect(secondUndo).toHaveBeenCalledTimes(1) + test('all four item supports transfer through undo and redo without touching unrelated hosts', () => { + runSourceHistoryTest(` + const ceiling = core.CeilingNode.parse({ id: 'ceiling_transfer', parentId: level.id, polygon: slab.polygon }) + const deck = { ...slab, elevation: 1 } + const asset = { id: 'transfer', name: 'transfer', category: 'test', thumbnail: '', src: '/test.glb' } + const supports = [ + { parentId: level.id, supportSlabId: core.GROUND_SUPPORT_ID, asset }, + { parentId: wall.id, supportSlabId: undefined, asset: { ...asset, attachTo: 'wall-side' } }, + { parentId: ceiling.id, supportSlabId: undefined, asset: { ...asset, attachTo: 'ceiling' } }, + { parentId: level.id, supportSlabId: deck.id, asset }, + ] + for (let from = 0; from < supports.length; from++) { + for (let to = from + 1; to < supports.length; to++) { + const item = core.ItemNode.parse({ id: 'item_transfer', ...supports[from] }) + scene.setState({ nodes: { ...baseline, [ceiling.id]: ceiling, [deck.id]: deck, [item.id]: item } }) + clearSceneHistory() + edit(item.id, { ...supports[to], position: [2,0,2] }) + const moved = scene.getState().nodes[item.id] + for (const [jump, expected] of [[runUndo, item], [runRedo, moved]]) { + clean(); jump(); await flush() + assert.equal(scene.getState().nodes[item.id], expected) + assert.deepEqual([...scene.getState().dirtyNodes].sort(), [...new Set([item.id, level.id, supports[from].parentId, supports[to].parentId])].sort()) + assert(!dirty(remote.id)); assert(!dirty(deck.id)) + } + } + } + `) }) - test('publishes delegate state changes and restores standalone availability on teardown', () => { - const listeners = new Set<() => void>() - const observed: string[] = [] - const unsubscribe = subscribeHistoryCommandState(() => { - observed.push(getHistoryCommandState().mode) - }) - disposeController = installHistoryCommandDelegate({ - getState: () => ({ - canRedo: false, - canUndo: true, - mode: 'collaborative', - status: 'offline', - }), - redo: () => ({ kind: 'empty' }), - subscribe: (listener) => { - listeners.add(listener) - return () => listeners.delete(listener) - }, - undo: () => ({ kind: 'applied', persistence: 'queued' }), - }) + test('undo and redo re-mark hosted opening proxies and wall-side offsets for real frame rebuilds', () => { + runSourceHistoryTest(` + const react = await importShared('react') + mockShared('react', () => ({ ...react, useEffect: () => {}, useRef: current => ({ current }) })) + const frames = [] + const fiber = await importShared('@react-three/fiber') + mockShared('@react-three/fiber', () => ({ ...fiber, useFrame: frame => frames.push(frame) })) + const selector = store => Object.assign(fn => fn(store.getState()), store) + mockShared('@pascal-app/core', () => ({ ...core, useScene: selector(scene), useLiveNodeOverrides: selector(overrides) })) + const viewer = await importShared('@pascal-app/viewer') + mock.module(${JSON.stringify(resolve(import.meta.dir, '../../../viewer/src/store/use-viewer.ts'))}, () => ({ default: selector(viewer.useViewer) })) + const { Mesh } = await importShared('three') + const { DoorSystem } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../viewer/src/systems/door/door-system.tsx'))}) + const { WindowSystem } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../viewer/src/systems/window/window-system.tsx'))}) + const { ItemSystem } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../viewer/src/systems/item/item-system.tsx'))}) + const window = core.WindowNode.parse({ id: 'window_thickness', parentId: wall.id }) + const item = core.ItemNode.parse({ id: 'item_thickness', parentId: wall.id, side: 'front', asset: { id: 'test', name: 'test', category: 'test', thumbnail: '', src: '/test.glb', attachTo: 'wall-side' } }) + const children = [opening, window, item] + scene.setState({ nodes: { ...baseline, [wall.id]: { ...wall, children: children.map(node => node.id) }, [window.id]: window, [item.id]: item } }) + const meshes = children.map(node => { const mesh = new Mesh(); mesh.userData.itemModelSettled = true; core.sceneRegistry.nodes.set(node.id, mesh); return mesh }) + clearSceneHistory() + DoorSystem(); WindowSystem(); ItemSystem() + const frame = () => frames.forEach(frame => frame()) + const depths = () => meshes.slice(0, 2).map(mesh => mesh.getObjectByName('cutout').geometry.parameters.depth) + const check = thickness => { + assert.deepEqual(depths(), [thickness + 0.08, thickness + 0.08]) + assert.equal(meshes[2].position.z, thickness / 2) + } + children.forEach(node => scene.getState().markDirty(node.id)); frame(); check(core.getWallThickness(wall)) + edit(wall.id, { thickness: 0.6 }) + children.forEach(node => scene.getState().markDirty(node.id)); frame(); check(0.6) + for (const [jump, thickness] of [[runUndo, core.getWallThickness(wall)], [runRedo, 0.6]]) { + clean(); jump(); await flush() + children.forEach(node => assert(dirty(node.id), node.id)) + assert(!dirty(remote.id)); assert(!dirty(slab.id)) + frame(); check(thickness) + children.forEach(node => assert(!dirty(node.id), node.id)) + } + `) + }) + + test('undo removes a reconciliation-created side-effect wall and its auto surfaces in one step', () => { + runSourceHistoryTest(` + const upper = core.LevelNode.parse({ id: 'level_unrelated_side_effect', level: 1 }) + const walls = [wall, { ...wall, id: 'wall_east', start: [4,0], end: [4,4] }, { ...wall, id: 'wall_north', start: [4,4], end: [0,4] }] + const closing = core.WallNode.parse({ id: 'wall_closing', parentId: level.id, start: [0,4], end: [0,0] }) + const sideEffect = core.WallNode.parse({ id: 'wall_derived', parentId: level.id, start: [4,4], end: [6,4] }) + scene.setState({ nodes: Object.fromEntries([{ ...level, children: walls.map(node => node.id) }, upper, { ...remote, parentId: upper.id }, ...walls].map(node => [node.id, node])) }) + const editor = { spaces: {}, setSpaces: spaces => { editor.spaces = spaces } } + let created = false + const stop = core.initSpaceDetectionSync(scene, { getState: () => editor }, { + onTopologyReconcile: () => { + if (created) return + created = true + scene.getState().createNode(sideEffect, level.id) + }, + }) + clearSceneHistory() + scene.getState().createNode(closing, level.id) + await flush() + assert(created); assert(scene.getState().nodes[sideEffect.id]) + const surfaces = Object.values(scene.getState().nodes).filter(node => node.type === 'slab' || node.type === 'ceiling') + assert(surfaces.length > 0) + assert.equal(scene.temporal.getState().pastStates.length, 1) + clean(); runUndo(); await flush() + for (const node of [closing, sideEffect, ...surfaces]) { + assert(!scene.getState().nodes[node.id], node.id) + assert(!dirty(node.id), node.id) + } + assert(dirty('wall_north')); assert(!dirty(remote.id)) + stop() + `) + }) - for (const listener of listeners) listener() - disposeController() - disposeController = () => {} - unsubscribe() + test('mounted slab and space subscriptions run on temporal writes without swallowing the wall diff', () => { + runSourceHistoryTest(` + const react = await importShared('react') + const effects = [] + mockShared('react', () => ({ ...react, useEffect: effect => effects.push(effect) })) + const { default: SlabSystems } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../nodes/src/slab/system.tsx'))}) + SlabSystems() + const stopSlabs = effects[0]() + let publications = 0 + const editor = { spaces: {}, setSpaces: spaces => { editor.spaces = spaces; publications++ } } + const stopSpaces = core.initSpaceDetectionSync(scene, { getState: () => editor }) + edit(wall.id, { start: [0,2], end: [4,2] }) + clean(); const previousPublications = publications + runUndo() + assert(dirty(slab.id)) + assert(publications > previousPublications) + await flush() + assert(dirty(wall.id)) + assert(!dirty(remote.id)) + stopSpaces(); stopSlabs() + `) + }) + + test('stair preview cleanup captures holes republished during the first clear', () => { + runSourceHistoryTest(` + edit(level.id, { name: 'Changed' }) + transforms.getState().set(wall.id, { position: [1,0,0], rotation: 0 }) + let published = false + const stop = overrides.subscribe(state => { + if (published || state.overrides.size || !transforms.getState().transforms.size) return + published = true + overrides.getState().set(slab.id, { holes: [[[1,1],[2,1],[2,2]]] }) + }) + clean(); runUndo(); await flush(); stop() + assert(published) + assert.equal(overrides.getState().overrides.size, 0) + assert(dirty(slab.id)) + `) + }) + test('the wall geometry harness restores positions, normals, UVs and opening cutouts after undo', () => { + runSourceHistoryTest(` + const { Mesh } = await importShared('three') + const { generateExtrudedWall } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../viewer/src/systems/wall/wall-system.tsx'))}) + core.sceneRegistry.nodes.set(wall.id, new Mesh()) + const geometry = () => { + const nodes = scene.getState().nodes + const currentWall = nodes[wall.id] + const children = currentWall.children.map(id => nodes[id]).filter(Boolean) + const mesh = generateExtrudedWall(currentWall, children, core.calculateLevelMiters([currentWall])) + const result = Object.fromEntries(['position', 'normal', 'uv'].map(name => [name, Array.from(mesh.getAttribute(name).array)])) + mesh.dispose(); return result + } + const canonical = geometry() + for (const [id, patch] of [ + [wall.id, { thickness: 0.4 }], [wall.id, { end: [7,2] }], + [wall.id, { curveOffset: 0.7 }], [opening.id, { position: [2,1,0], width: 1.5 }], + ]) { + edit(id, patch) + assert.notDeepEqual(geometry(), canonical) + clean(); runUndo(); await flush() + assert(dirty(wall.id)) + assert.deepEqual(geometry(), canonical) + } + `) + }) + + test('the mounted stair subscription restores derived flight heights after a temporal level write', () => { + runSourceHistoryTest(` + const react = await importShared('react') + const effects = [] + mockShared('react', () => ({ ...react, useEffect: effect => effects.push(effect), useRef: current => ({ current }) })) + const segment = core.StairSegmentNode.parse({ id: 'sseg_history', parentId: 'stair_history', height: 2.5 }) + const stair = core.StairNode.parse({ id: 'stair_history', parentId: level.id, children: [segment.id] }) + scene.setState({ nodes: { ...baseline, [level.id]: { ...level, height: 2.5, children: [stair.id] }, [stair.id]: stair, [segment.id]: segment } }) + const { StairOpeningSystem } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../core/src/systems/stair/stair-opening-system.tsx'))}) + StairOpeningSystem() + const stop = effects[0]() + await flush(); clearSceneHistory() + edit(level.id, { height: 4 }); await flush() + assert.equal(scene.getState().nodes[segment.id].height, 4) + clean(); runUndo(); await flush() + assert.equal(scene.getState().nodes[segment.id].height, 2.5) + assert(dirty(segment.id)); assert(!dirty(remote.id)) + stop() + `) + }) +}) + +describe('editor history controller', () => { + test('draft cancellation follows the registered kind', () => { + runSourceHistoryTest(` + let cancelled = 0 + core.emitter.on('tool:cancel', () => cancelled++) + useInteractionScope.getState().begin({ kind: 'drafting', tool: 'plain-draft' }) + assert.equal(shouldCancelDraftOnHistoryJump(), false) + core.nodeRegistry._register({ kind: 'registered-draft', schemaVersion: 1, drafting: { cancelOnHistoryJump: true } }) + useInteractionScope.getState().begin({ kind: 'drafting', tool: 'registered-draft' }) + assert.equal(shouldCancelDraftOnHistoryJump(), true) + edit(level.id, { level: 1 }); runUndo() + assert.equal(cancelled, 1) + `) + }) - expect(observed).toEqual(['collaborative', 'collaborative', 'standalone']) + test('delegates publish availability and an older cleanup cannot uninstall the current delegate', () => { + runSourceHistoryTest(` + edit(level.id, { level: 1 }) + const observed = [] + const unsubscribe = subscribeHistoryCommandState(() => observed.push(getHistoryCommandState().mode)) + const listeners = new Set() + let firstCalls = 0, secondCalls = 0 + const delegate = undo => ({ + getState: () => ({ canRedo: false, canUndo: true, mode: 'collaborative', status: 'syncing' }), + redo: () => ({ kind: 'empty' }), + subscribe: listener => { listeners.add(listener); return () => listeners.delete(listener) }, + undo, + }) + const stopFirst = installHistoryCommandDelegate(delegate(() => { firstCalls++; return { kind: 'empty' } })) + const stopSecond = installHistoryCommandDelegate(delegate(() => { secondCalls++; return { kind: 'applied', persistence: 'queued' } })) + stopFirst() + assert.deepEqual(runUndo(), { kind: 'applied', persistence: 'queued' }) + assert.deepEqual(runRedo(), { kind: 'empty' }) + assert.equal(firstCalls, 0); assert.equal(secondCalls, 1) + assert.equal(scene.getState().nodes[level.id].level, 1) + assert.equal(scene.temporal.getState().pastStates.length, 1) + assert.deepEqual(getHistoryCommandState(), { canRedo: false, canUndo: true, mode: 'collaborative', status: 'syncing' }) + for (const listener of listeners) listener() + stopSecond(); unsubscribe() + assert.deepEqual(observed, ['collaborative', 'collaborative', 'collaborative', 'standalone']) + assert.deepEqual(runUndo(), { kind: 'applied', persistence: 'local' }) + assert.equal(scene.getState().nodes[level.id].level, 0) + assert.deepEqual(runRedo(), { kind: 'applied', persistence: 'local' }) + assert.equal(scene.getState().nodes[level.id].level, 1) + `) }) }) diff --git a/packages/editor/src/lib/history.ts b/packages/editor/src/lib/history.ts index 846d0f8410..d14108888d 100644 --- a/packages/editor/src/lib/history.ts +++ b/packages/editor/src/lib/history.ts @@ -1,4 +1,12 @@ -import { emitter, useLiveNodeOverrides, useLiveTransforms, useScene } from '@pascal-app/core' +import { + type AnyNode, + type AnyNodeId, + emitter, + getHistoryDirtyNodeIds, + useLiveNodeOverrides, + useLiveTransforms, + useScene, +} from '@pascal-app/core' import { markPerfAction } from '@pascal-app/viewer' import useInteractionScope from '../store/use-interaction-scope' import { registeredDraftingConfig } from './interaction/registered-drafting' @@ -64,13 +72,55 @@ function notifyHistoryCommandListeners() { for (const listener of [...historyCommandListeners]) listener() } -function refreshSceneAfterHistoryJump() { +function capturePreviewLayout() { + const overrides = useLiveNodeOverrides.getState().overrides + if (overrides.size === 0) return null + const nodes = { ...useScene.getState().nodes } + for (const [id, values] of overrides) { + const node = nodes[id as AnyNodeId] + if (node) nodes[node.id] = { ...node, ...values } as AnyNode + } + return nodes +} + +function refreshSceneAfterHistoryJump(previewLayout: Record | null) { + const target = useScene.getState().nodes + const previewDirty = previewLayout + ? getHistoryDirtyNodeIds(previewLayout, target) + : new Set() + const previewIds = new Set([ + ...useLiveTransforms.getState().transforms.keys(), + ...useLiveNodeOverrides.getState().overrides.keys(), + ]) + const currentPreviewLayout = capturePreviewLayout() + if (currentPreviewLayout) { + for (const id of getHistoryDirtyNodeIds(currentPreviewLayout, target)) previewDirty.add(id) + } useLiveNodeOverrides.getState().clearAll() useLiveTransforms.getState().clearAll() + // Clearing overrides can republish stair holes while a live transform still + // exists. Capture that final publication before clearing it too. + const remainingOverrides = useLiveNodeOverrides.getState().overrides + if (remainingOverrides.size > 0) { + for (const id of remainingOverrides.keys()) previewIds.add(id) + const remainingLayout = capturePreviewLayout() + if (remainingLayout) { + for (const id of getHistoryDirtyNodeIds(remainingLayout, target)) previewDirty.add(id) + } + useLiveNodeOverrides.getState().clearAll() + } const state = useScene.getState() - for (const node of Object.values(state.nodes)) { + for (const id of previewDirty) { + if (state.nodes[id]) state.markDirty(id) + } + for (const id of previewIds) { + const node = state.nodes[id as AnyNodeId] + if (!node) continue state.markDirty(node.id) + if (node.parentId && state.nodes[node.parentId as AnyNodeId]) { + state.markDirty(node.parentId as AnyNodeId) + } } } @@ -90,8 +140,9 @@ export function runUndo(): HistoryCommandResult { } if (useScene.temporal.getState().pastStates.length === 0) return { kind: 'empty' } markPerfAction('undo') + const previewLayout = capturePreviewLayout() useScene.temporal.getState().undo() - refreshSceneAfterHistoryJump() + refreshSceneAfterHistoryJump(previewLayout) return { kind: 'applied', persistence: 'local' } } @@ -104,8 +155,9 @@ export function runRedo(): HistoryCommandResult { } if (useScene.temporal.getState().futureStates.length === 0) return { kind: 'empty' } markPerfAction('redo') + const previewLayout = capturePreviewLayout() useScene.temporal.getState().redo() - refreshSceneAfterHistoryJump() + refreshSceneAfterHistoryJump(previewLayout) return { kind: 'applied', persistence: 'local' } } diff --git a/packages/editor/src/store/tool-mode.test.ts b/packages/editor/src/store/tool-mode.test.ts index abe47f87be..e21dc120ef 100644 --- a/packages/editor/src/store/tool-mode.test.ts +++ b/packages/editor/src/store/tool-mode.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import useEditor, { normalizePersistedEditorUiState } from './use-editor' function resetToolMode() { @@ -8,6 +8,7 @@ function resetToolMode() { useEditor.getState().setActivePaintMaterial(null) } +beforeEach(resetToolMode) afterEach(resetToolMode) describe('ToolMode transition', () => { diff --git a/packages/editor/src/store/use-interaction-scope.test.ts b/packages/editor/src/store/use-interaction-scope.test.ts index 073aa2699e..62686cd617 100644 --- a/packages/editor/src/store/use-interaction-scope.test.ts +++ b/packages/editor/src/store/use-interaction-scope.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import type { AnyNode } from '@pascal-app/core' import { type ActiveInteractionScope, @@ -21,6 +21,7 @@ const mockNode = (id: string, type: string): AnyNode => ({ id, type }) as unknow function reset() { useInteractionScope.getState().end() } +beforeEach(reset) afterEach(reset) describe('use-interaction-scope state machine', () => { diff --git a/wiki/architecture/systems.md b/wiki/architecture/systems.md index 155994669e..9e9483a510 100644 --- a/wiki/architecture/systems.md +++ b/wiki/architecture/systems.md @@ -105,6 +105,39 @@ Any optimization that scopes reconciliation to a subset of nodes or rooms must b equivalence with a full level scan. Representative create, update, delete, cascade, split, merge, and corridor-enclosure edits must produce the same spaces and surfaces as full reconciliation. +## Undo and redo invalidation + +Standalone history jumps clear live transforms and node overrides, including surface-hole +previews. Before a jump, the editor captures the effective layout by merging live overrides +onto committed nodes. Before clearing previews it runs the same pure dependency closure used +for committed history snapshots, with that effective layout as `before` and the committed +target as `after`: wall neighbours in either layout and hosted children on host dimension +changes must rebuild even when only the discarded preview connected them. Overrides published +during restoration/cleanup also contribute their closure before being cleared. Surviving live +transform targets and their parents receive restoration marks too. Empty commands preserve +previews, and collaborative delegates own their own refresh. + +Core diffs the before/after node snapshots in a microtask before paint. It marks changed nodes, +old and new parents, wall neighbours in both layouts (scoped to the wall's level), and hosted +doors/windows/items when wall thickness, height or curvature changes. Deletion retains its +conservative surviving-sibling refresh and removes marks for missing IDs. Both layouts are +captured per jump; reconciliation's history pause/resume notifications cannot replace them. +The cold-start fallback without a previous snapshot remains conservative. + +Temporal restoration writes to the scene store, so existing subscriptions still own spatial +index updates, slab context tracking, space detection, stair rise/openings, elevator openings, +and level-height dependents. Spatial sync also checks before/after rendered slab boundaries: +wall bands and sibling seams can change support even when the slab's stored polygon is unchanged. +Support invalidation tests the gained/lost rendered bands in both layouts, so objects on a +former boundary re-elevate while consumers in the unchanged interior stay clean. Each pass +groups affected-level walls, slabs and consumers once and caches each slab's rendered polygon +once per layout. Discovering changes still scans the snapshots; it does not scan the scene +again for each candidate slab. + +There is no routine whole-scene history refresh or batch reset. The existing priority-1 batch +snapshot releases affected sources (including dirty walls' openings); untouched members stay +batched, and affected members rejoin through the normal settle window. + ## Adding a New System 1. Decide the scope: