From bc63f7e53db619382b318ec73ba698c295692f7a Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 9 Sep 2026 14:25:31 -0400 Subject: [PATCH 01/11] Scope undo invalidation to changed geometry and cleared previews --- .../spatial-grid/spatial-grid-sync.test.ts | 132 ++++++++- .../hooks/spatial-grid/spatial-grid-sync.ts | 98 +++++-- .../src/store/history-invalidation.test.ts | 223 +++++++++++++++ .../core/src/store/history-invalidation.ts | 70 +++++ packages/core/src/store/use-scene.ts | 52 ++-- packages/editor/src/lib/history.test.ts | 265 ++++++++++++++++++ packages/editor/src/lib/history.ts | 26 +- wiki/architecture/systems.md | 23 ++ 8 files changed, 834 insertions(+), 55 deletions(-) create mode 100644 packages/core/src/store/history-invalidation.test.ts create mode 100644 packages/core/src/store/history-invalidation.ts 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..0241aa3779 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,133 @@ 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 + useScene.setState({ + nodes: nodesFor(level, wall, slab, item, remote), + 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) + expect(useScene.getState().dirtyNodes.has(remote.id)).toBe(false) + } + }) + + 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..9b8d35329a 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts @@ -111,6 +111,33 @@ export function initSpatialGridSync(): () => void { // Subscribe to all changes const unsubscribeScene = store.subscribe((state, prevState) => { + if (state.nodes === prevState.nodes) return + const changedSlabContextLevels = new Set() + for (const id of new Set([...Object.keys(prevState.nodes), ...Object.keys(state.nodes)])) { + const previous = prevState.nodes[id as AnyNodeId] + const next = state.nodes[id as AnyNodeId] + if (previous === next) continue + 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)) continue + if (previous) changedSlabContextLevels.add(resolveLevelId(previous, prevState.nodes)) + if (next) changedSlabContextLevels.add(resolveLevelId(next, state.nodes)) + } + // Detect added nodes for (const [id, node] of Object.entries(state.nodes)) { if (!prevState.nodes[id as AnyNode['id']]) { @@ -140,7 +167,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 +210,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 +243,31 @@ 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. + for (const slab of Object.values(state.nodes)) { + if (slab.type !== 'slab') continue + 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 + if (!changedSlabContextLevels.has(resolveLevelId(slab, state.nodes))) continue + const beforePolygon = renderableSlabPolygon(previous, prevState.nodes) + const afterPolygon = renderableSlabPolygon(slab, state.nodes) + if ( + beforePolygon.length === afterPolygon.length && + beforePolygon.every((point, i) => arraysEqual(point, afterPolygon[i]!)) + ) + continue + markNodesOverlappingSlab(previous, state.nodes, markDirty, prevState.nodes) + markNodesOverlappingSlab(slab, state.nodes, markDirty) + } }) // Live terrain is deliberately not written into `useScene` per dab: doing so @@ -282,14 +340,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) { @@ -396,22 +456,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,7 +471,21 @@ function markNodesOverlappingSlab( siblingSlabs.push(node as SlabNode) } } - const renderedPolygon = getRenderableSlabPolygon(slab, { walls: levelWalls, siblingSlabs }) + return getRenderableSlabPolygon(slab, { walls: levelWalls, siblingSlabs }) +} + +/** + * 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) for (const node of Object.values(nodes)) { if (node.type === 'wall') { 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..576b7669e0 --- /dev/null +++ b/packages/core/src/store/history-invalidation.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + CeilingNode, + DoorNode, + ItemNode, + LevelNode, + SlabNode, + WallNode, + WindowNode, +} from '../schema' +import { getHistoryDirtyNodeIds } from './history-invalidation' + +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) + }) +}) diff --git a/packages/core/src/store/history-invalidation.ts b/packages/core/src/store/history-invalidation.ts new file mode 100644 index 0000000000..f70a98e9d1 --- /dev/null +++ b/packages/core/src/store/history-invalidation.ts @@ -0,0 +1,70 @@ +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) + } + + for (const id of new Set([...Object.keys(before), ...Object.keys(after)])) { + const previous = before[id] + const next = after[id] + if (previous === next) continue + 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) + } + } + } + + if (changedWalls.size === 0) return dirty + + for (const nodes of [before, after]) { + const wallsByLevel = new Map() + for (const node of Object.values(nodes)) { + if (node.type === 'wall') { + const walls = wallsByLevel.get(node.parentId) ?? [] + walls.push(node) + wallsByLevel.set(node.parentId, walls) + } + 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.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..b9f5b2dbab 100644 --- a/packages/editor/src/lib/history.test.ts +++ b/packages/editor/src/lib/history.test.ts @@ -1,4 +1,6 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' import { type AnyNode, type AnyNodeId, @@ -192,3 +194,266 @@ describe('editor history controller', () => { expect(observed).toEqual(['collaborative', 'collaborative', 'standalone']) }) }) + +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' + globalThis.requestAnimationFrame = callback => { callback(0); return 0 } + globalThis.cancelAnimationFrame = () => {} + const core = await import(${JSON.stringify(resolve(import.meta.dir, '../../..', 'core/src/index.ts'))}) + mock.module('@pascal-app/core', () => core) + const { useScene: scene, clearSceneHistory, useLiveTransforms: transforms, useLiveNodeOverrides: overrides } = core + const { runUndo, runRedo, installHistoryCommandDelegate } = await import(${JSON.stringify(resolve(import.meta.dir, 'history.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 }) + } +} + +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)) + } + `) + }) + + 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() + `) + }) + + 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) + } + `) + }) + + 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) + `) + }) + + 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]) + `) + }) + + 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 import('three') + const viewer = await import('@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) + `) + }) + + test('mounted slab and space subscriptions run on temporal writes without swallowing the wall diff', () => { + runSourceHistoryTest(` + const react = await import('react') + const effects = [] + mock.module('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 import('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 import('react') + const effects = [] + mock.module('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() + `) + }) +}) diff --git a/packages/editor/src/lib/history.ts b/packages/editor/src/lib/history.ts index 846d0f8410..58b682986d 100644 --- a/packages/editor/src/lib/history.ts +++ b/packages/editor/src/lib/history.ts @@ -1,4 +1,10 @@ -import { emitter, useLiveNodeOverrides, useLiveTransforms, useScene } from '@pascal-app/core' +import { + type AnyNodeId, + emitter, + 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' @@ -65,12 +71,28 @@ function notifyHistoryCommandListeners() { } function refreshSceneAfterHistoryJump() { + const previewIds = new Set([ + ...useLiveTransforms.getState().transforms.keys(), + ...useLiveNodeOverrides.getState().overrides.keys(), + ]) 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) + useLiveNodeOverrides.getState().clearAll() + } const state = useScene.getState() - for (const node of Object.values(state.nodes)) { + 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) + } } } diff --git a/wiki/architecture/systems.md b/wiki/architecture/systems.md index 155994669e..267b5ab73a 100644 --- a/wiki/architecture/systems.md +++ b/wiki/architecture/systems.md @@ -105,6 +105,29 @@ 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. Only surviving preview targets and their parents receive restoration marks from the +editor. 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 uses both layouts so objects on a former boundary re-elevate. + +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: From 116ade6d789d001e06b19ca450c636983437f1c6 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 9 Sep 2026 14:26:08 -0400 Subject: [PATCH 02/11] Reset editor state before randomized store tests --- packages/editor/src/store/tool-mode.test.ts | 3 ++- packages/editor/src/store/use-interaction-scope.test.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) 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', () => { From 2eb0a81520d567411c64bdb6875d76fc03f496b1 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 9 Sep 2026 14:55:32 -0400 Subject: [PATCH 03/11] Resolve history probe mocks from each consuming package --- packages/editor/src/lib/history.test.ts | 41 ++++++++++++++++++++----- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/editor/src/lib/history.test.ts b/packages/editor/src/lib/history.test.ts index b9f5b2dbab..816608ee3b 100644 --- a/packages/editor/src/lib/history.test.ts +++ b/packages/editor/src/lib/history.test.ts @@ -206,10 +206,35 @@ function runSourceHistoryTest(body: string) { ` 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'))}) - mock.module('@pascal-app/core', () => core) + mockShared('@pascal-app/core', () => core) + await importShared('@pascal-app/viewer') const { useScene: scene, clearSceneHistory, useLiveTransforms: transforms, useLiveNodeOverrides: overrides } = core const { runUndo, runRedo, installHistoryCommandDelegate } = await import(${JSON.stringify(resolve(import.meta.dir, 'history.ts'))}) const level = core.LevelNode.parse({ id: 'level_history_source' }) @@ -329,8 +354,8 @@ describe('standalone history source invalidation', () => { }) test('one-wall undo releases only its openings and neighbour openings from the real batch store', () => { runSourceHistoryTest(` - const { Group, Mesh, MeshBasicMaterial, BoxGeometry } = await import('three') - const viewer = await import('@pascal-app/viewer') + 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) @@ -372,9 +397,9 @@ describe('standalone history source invalidation', () => { test('mounted slab and space subscriptions run on temporal writes without swallowing the wall diff', () => { runSourceHistoryTest(` - const react = await import('react') + const react = await importShared('react') const effects = [] - mock.module('react', () => ({ ...react, useEffect: effect => effects.push(effect) })) + 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]() @@ -411,7 +436,7 @@ describe('standalone history source invalidation', () => { }) test('the wall geometry harness restores positions, normals, UVs and opening cutouts after undo', () => { runSourceHistoryTest(` - const { Mesh } = await import('three') + 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 = () => { @@ -438,9 +463,9 @@ describe('standalone history source invalidation', () => { test('the mounted stair subscription restores derived flight heights after a temporal level write', () => { runSourceHistoryTest(` - const react = await import('react') + const react = await importShared('react') const effects = [] - mock.module('react', () => ({ ...react, useEffect: effect => effects.push(effect), useRef: current => ({ current }) })) + 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 } }) From 2010986bfe45dbb08b4d13a24e5401894684bacc Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 9 Sep 2026 14:57:50 -0400 Subject: [PATCH 04/11] Restore discarded preview dependency closures on undo and redo --- packages/core/src/index.ts | 1 + packages/editor/src/lib/history.test.ts | 291 ++++++++---------------- packages/editor/src/lib/history.ts | 36 ++- wiki/architecture/systems.md | 10 +- 4 files changed, 138 insertions(+), 200 deletions(-) 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/editor/src/lib/history.test.ts b/packages/editor/src/lib/history.test.ts index 816608ee3b..cd5d28971a 100644 --- a/packages/editor/src/lib/history.test.ts +++ b/packages/editor/src/lib/history.test.ts @@ -1,199 +1,6 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { describe, expect, test } from 'bun:test' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' -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 -} -;(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 = () => {} - -function levelNumber(): number { - return (useScene.getState().nodes[LEVEL_ID] as { level: number }).level -} - -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) - - nodeRegistry._register({ - kind: 'registered-draft', - schemaVersion: 1, - drafting: { cancelOnHistoryJump: true }, - } as never) - useInteractionScope.getState().begin({ kind: 'drafting', tool: 'registered-draft' }) - expect(shouldCancelDraftOnHistoryJump()).toBe(true) - - 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, - }) - - expect(runUndo()).toEqual({ kind: 'applied', persistence: 'queued' }) - expect(runRedo()).toEqual({ kind: 'empty' }) - expect(getHistoryCommandState()).toEqual({ - canRedo: false, - canUndo: true, - mode: 'collaborative', - status: 'syncing', - }) - - 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)) - - stopFirst() - runUndo() - - expect(firstUndo).toHaveBeenCalledTimes(0) - expect(secondUndo).toHaveBeenCalledTimes(1) - }) - - 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' }), - }) - - for (const listener of listeners) listener() - disposeController() - disposeController = () => {} - unsubscribe() - - expect(observed).toEqual(['collaborative', 'collaborative', 'standalone']) - }) -}) function runSourceHistoryTest(body: string) { const cache = join(import.meta.dir, '.turbo') @@ -236,7 +43,8 @@ function runSourceHistoryTest(body: string) { mockShared('@pascal-app/core', () => core) await importShared('@pascal-app/viewer') const { useScene: scene, clearSceneHistory, useLiveTransforms: transforms, useLiveNodeOverrides: overrides } = core - const { runUndo, runRedo, installHistoryCommandDelegate } = await import(${JSON.stringify(resolve(import.meta.dir, 'history.ts'))}) + 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] }) @@ -294,6 +102,51 @@ describe('standalone history source invalidation', () => { `) }) + 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) + } + `) + }) + test('opening reparent dirties old and new walls on undo and redo', () => { runSourceHistoryTest(` edit(opening.id, { parentId: remote.id, wallId: remote.id }) @@ -482,3 +335,51 @@ describe('standalone history source invalidation', () => { `) }) }) + +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) + `) + }) + + 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 58b682986d..d14108888d 100644 --- a/packages/editor/src/lib/history.ts +++ b/packages/editor/src/lib/history.ts @@ -1,6 +1,8 @@ import { + type AnyNode, type AnyNodeId, emitter, + getHistoryDirtyNodeIds, useLiveNodeOverrides, useLiveTransforms, useScene, @@ -70,11 +72,30 @@ 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 @@ -82,10 +103,17 @@ function refreshSceneAfterHistoryJump() { 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 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 @@ -112,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' } } @@ -126,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/wiki/architecture/systems.md b/wiki/architecture/systems.md index 267b5ab73a..ca060e345a 100644 --- a/wiki/architecture/systems.md +++ b/wiki/architecture/systems.md @@ -108,8 +108,14 @@ and corridor-enclosure edits must produce the same spaces and surfaces as full r ## Undo and redo invalidation Standalone history jumps clear live transforms and node overrides, including surface-hole -previews. Only surviving preview targets and their parents receive restoration marks from the -editor. Empty commands preserve previews, and collaborative delegates own their own refresh. +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 From e7b215ba594f87a878650e32129cb2c84478757d Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 9 Sep 2026 15:02:32 -0400 Subject: [PATCH 05/11] Limit rendered slab invalidation to changed boundary bands --- .../spatial-grid/spatial-grid-sync.test.ts | 37 +++++- .../hooks/spatial-grid/spatial-grid-sync.ts | 121 ++++++++++++++---- .../core/src/store/history-invalidation.ts | 22 +++- wiki/architecture/systems.md | 6 +- 4 files changed, 152 insertions(+), 34 deletions(-) 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 0241aa3779..e25865cadd 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 @@ -616,8 +616,31 @@ describe('temporal writes update slab support dependencies', () => { }, }) 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), + nodes: nodesFor( + level, + wall, + slab, + item, + remote, + interior, + interiorWall, + upper, + upperItem, + upperWall, + upperSlab, + ), dirtyNodes: new Set(), readOnly: false, }) @@ -644,7 +667,17 @@ describe('temporal writes update slab support dependencies', () => { await Promise.resolve() expect(useScene.getState().nodes[item.id]).toBe(item) expect(useScene.getState().nodes[slab.id]).toBe(slab) - expect(useScene.getState().dirtyNodes.has(remote.id)).toBe(false) + for (const unaffected of [ + remote, + interior, + interiorWall, + upper, + upperItem, + upperWall, + upperSlab, + ]) { + expect(useScene.getState().dirtyNodes.has(unaffected.id)).toBe(false) + } } }) 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 9b8d35329a..edeeaf224c 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' @@ -113,10 +114,10 @@ export function initSpatialGridSync(): () => void { const unsubscribeScene = store.subscribe((state, prevState) => { if (state.nodes === prevState.nodes) return const changedSlabContextLevels = new Set() - for (const id of new Set([...Object.keys(prevState.nodes), ...Object.keys(state.nodes)])) { + const checkSlabContext = (id: string) => { const previous = prevState.nodes[id as AnyNodeId] const next = state.nodes[id as AnyNodeId] - if (previous === next) continue + if (previous === next) return const wallChanged = (previous?.type === 'wall' || next?.type === 'wall') && (previous?.type !== 'wall' || @@ -133,11 +134,16 @@ export function initSpatialGridSync(): () => void { previous.parentId !== next.parentId || previous.polygon !== next.polygon || previous.elevation !== next.elevation) - if (!(wallChanged || slabChanged)) continue + 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']]) { @@ -246,27 +252,43 @@ 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. - for (const slab of Object.values(state.nodes)) { - if (slab.type !== 'slab') continue - 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 - if (!changedSlabContextLevels.has(resolveLevelId(slab, state.nodes))) continue - const beforePolygon = renderableSlabPolygon(previous, prevState.nodes) - const afterPolygon = renderableSlabPolygon(slab, state.nodes) - if ( - beforePolygon.length === afterPolygon.length && - beforePolygon.every((point, i) => arraysEqual(point, afterPolygon[i]!)) - ) - continue - markNodesOverlappingSlab(previous, state.nodes, markDirty, prevState.nodes) - markNodesOverlappingSlab(slab, state.nodes, markDirty) + 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, + ) + } + } } }) @@ -487,7 +509,17 @@ function markNodesOverlappingSlab( const slabLevelId = resolveLevelId(slab, contextNodes) const renderedPolygon = renderableSlabPolygon(slab, contextNodes) - for (const node of Object.values(nodes)) { + 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 @@ -537,3 +569,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/store/history-invalidation.ts b/packages/core/src/store/history-invalidation.ts index f70a98e9d1..5e20d6051e 100644 --- a/packages/core/src/store/history-invalidation.ts +++ b/packages/core/src/store/history-invalidation.ts @@ -12,10 +12,10 @@ export function getHistoryDirtyNodeIds( if (id && after[id]) dirty.add(id as AnyNodeId) } - for (const id of new Set([...Object.keys(before), ...Object.keys(after)])) { + const visitChange = (id: string) => { const previous = before[id] const next = after[id] - if (previous === next) continue + if (previous === next) return add(id) add(previous?.parentId) add(next?.parentId) @@ -42,15 +42,25 @@ export function getHistoryDirtyNodeIds( } } + 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 node of Object.values(nodes)) { + 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') { - const walls = wallsByLevel.get(node.parentId) ?? [] - walls.push(node) - wallsByLevel.set(node.parentId, walls) + wallsByLevel.get(node.parentId)?.push(node) } if ( node.parentId && diff --git a/wiki/architecture/systems.md b/wiki/architecture/systems.md index ca060e345a..9e9483a510 100644 --- a/wiki/architecture/systems.md +++ b/wiki/architecture/systems.md @@ -128,7 +128,11 @@ Temporal restoration writes to the scene store, so existing subscriptions still 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 uses both layouts so objects on a former boundary re-elevate. +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 From 44fa3df918283a2a6c738ec8267b732f275b6eeb Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 9 Sep 2026 15:02:32 -0400 Subject: [PATCH 06/11] Cover history support transfers and scoped endpoint rebuilds --- packages/editor/src/lib/history.test.ts | 149 ++++++++++++++++++++++++ 1 file changed, 149 insertions(+) diff --git a/packages/editor/src/lib/history.test.ts b/packages/editor/src/lib/history.test.ts index cd5d28971a..8aa4c77b1a 100644 --- a/packages/editor/src/lib/history.test.ts +++ b/packages/editor/src/lib/history.test.ts @@ -248,6 +248,155 @@ describe('standalone history source invalidation', () => { `) }) + 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,0], end: [6,4] }, { ...wall, id: 'wall_beyond', start: [6,4], end: [8,4] }, remote] + 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, { end: [6,0] }); 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, 6) + 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]) + assert.equal(batch.instanceCount, 2) + now += 181; frame() + assert.equal(batch.instanceCount, 6) + assert(meshes.every(mesh => !mesh.layers.isEnabled(viewer.SCENE_LAYER))) + } + resetNodeBatchState(); if (wake.current) clearTimeout(wake.current) + `) + }) + + 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('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() + `) + }) + test('mounted slab and space subscriptions run on temporal writes without swallowing the wall diff', () => { runSourceHistoryTest(` const react = await importShared('react') From db466c05aa9c007c776208a00d48e6f6fec18c8e Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 9 Sep 2026 15:06:54 -0400 Subject: [PATCH 07/11] Pin endpoint history closure with spatial sync mounted --- packages/editor/src/lib/history.test.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/editor/src/lib/history.test.ts b/packages/editor/src/lib/history.test.ts index 8aa4c77b1a..65768e5141 100644 --- a/packages/editor/src/lib/history.test.ts +++ b/packages/editor/src/lib/history.test.ts @@ -258,8 +258,8 @@ describe('standalone history source invalidation', () => { 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,0], end: [6,4] }, { ...wall, id: 'wall_beyond', start: [6,4], end: [8,4] }, remote] - const nodes = { [level.id]: level } + 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] } @@ -270,7 +270,8 @@ describe('standalone history source invalidation', () => { core.sceneRegistry.byType.door.add(door.id) }) scene.setState({ nodes }); clearSceneHistory() - edit(wall.id, { end: [6,0] }); clean() + 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 @@ -279,18 +280,18 @@ describe('standalone history source invalidation', () => { 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, 6) + 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]) - assert.equal(batch.instanceCount, 2) + 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, 6) + assert.equal(batch.instanceCount, 7) assert(meshes.every(mesh => !mesh.layers.isEnabled(viewer.SCENE_LAYER))) } - resetNodeBatchState(); if (wake.current) clearTimeout(wake.current) + stopSpatial(); core.spatialGridManager.clear(); resetNodeBatchState(); if (wake.current) clearTimeout(wake.current) `) }) From 35b29f642197b23a22990f6b082c8b7dd4ecb456 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 9 Sep 2026 15:07:32 -0400 Subject: [PATCH 08/11] Run package tests against core source without rebuilding dist --- bunfig.toml | 2 ++ .../src/store/use-scene-dirty-tracking.test.ts | 7 ++++--- .../shared/node-batch/source-systems.test.ts | 3 ++- scripts/test-preload.ts | 18 ++++++++++++++++++ 4 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 bunfig.toml create mode 100644 scripts/test-preload.ts diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000000..10e82fcb12 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./scripts/test-preload.ts"] 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/nodes/src/shared/node-batch/source-systems.test.ts b/packages/nodes/src/shared/node-batch/source-systems.test.ts index c6d7d69ac8..05ff02cc7e 100644 --- a/packages/nodes/src/shared/node-batch/source-systems.test.ts +++ b/packages/nodes/src/shared/node-batch/source-systems.test.ts @@ -25,6 +25,7 @@ function runSourceTest(body: string) { // Share the viewer's instance across every resolved path before loading consumers. const consumers = [ ${sourcePath('packages/viewer/src/lib/materials.ts')}, + ${sourcePath('packages/core/src/index.ts')}, ${sourcePath('packages/nodes/src/shared/node-batch/system.tsx')}, ${sourcePath('packages/editor/src/components/editor/selection-manager.tsx')}, ] @@ -38,7 +39,7 @@ function runSourceTest(body: string) { for (const path of sharedPaths.get(specifier)) mock.module(path, factory) } async function importShared(specifier) { - const module = await import(sharedPaths.get(specifier)[0]) + const module = await import(specifier === '@pascal-app/core' ? ${sourcePath('packages/core/src/index.ts')} : sharedPaths.get(specifier)[0]) mockShared(specifier, () => module) return module } diff --git a/scripts/test-preload.ts b/scripts/test-preload.ts new file mode 100644 index 0000000000..4dfd6f6c69 --- /dev/null +++ b/scripts/test-preload.ts @@ -0,0 +1,18 @@ +import { mock } from 'bun:test' +import { resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import * as core from '../packages/core/src/index' + +// Source consumers need the current core API even when package dists are stale. +const root = resolve(import.meta.dir, '..') +const paths = new Set( + ['core', 'viewer', 'nodes', 'editor'].map((name) => + fileURLToPath( + import.meta.resolve( + '@pascal-app/core', + pathToFileURL(resolve(root, `packages/${name}/src/index.ts`)).href, + ), + ), + ), +) +for (const path of paths) mock.module(path, () => core) From 14218f4aa23c4e4a27cb87bcdea21153f3a779ef Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 9 Sep 2026 15:30:44 -0400 Subject: [PATCH 09/11] test: drop the repo-wide core source preload --- bunfig.toml | 2 -- .../shared/node-batch/source-systems.test.ts | 3 +-- scripts/test-preload.ts | 18 ------------------ 3 files changed, 1 insertion(+), 22 deletions(-) delete mode 100644 bunfig.toml delete mode 100644 scripts/test-preload.ts diff --git a/bunfig.toml b/bunfig.toml deleted file mode 100644 index 10e82fcb12..0000000000 --- a/bunfig.toml +++ /dev/null @@ -1,2 +0,0 @@ -[test] -preload = ["./scripts/test-preload.ts"] diff --git a/packages/nodes/src/shared/node-batch/source-systems.test.ts b/packages/nodes/src/shared/node-batch/source-systems.test.ts index 05ff02cc7e..c6d7d69ac8 100644 --- a/packages/nodes/src/shared/node-batch/source-systems.test.ts +++ b/packages/nodes/src/shared/node-batch/source-systems.test.ts @@ -25,7 +25,6 @@ function runSourceTest(body: string) { // Share the viewer's instance across every resolved path before loading consumers. const consumers = [ ${sourcePath('packages/viewer/src/lib/materials.ts')}, - ${sourcePath('packages/core/src/index.ts')}, ${sourcePath('packages/nodes/src/shared/node-batch/system.tsx')}, ${sourcePath('packages/editor/src/components/editor/selection-manager.tsx')}, ] @@ -39,7 +38,7 @@ function runSourceTest(body: string) { for (const path of sharedPaths.get(specifier)) mock.module(path, factory) } async function importShared(specifier) { - const module = await import(specifier === '@pascal-app/core' ? ${sourcePath('packages/core/src/index.ts')} : sharedPaths.get(specifier)[0]) + const module = await import(sharedPaths.get(specifier)[0]) mockShared(specifier, () => module) return module } diff --git a/scripts/test-preload.ts b/scripts/test-preload.ts deleted file mode 100644 index 4dfd6f6c69..0000000000 --- a/scripts/test-preload.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { mock } from 'bun:test' -import { resolve } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' -import * as core from '../packages/core/src/index' - -// Source consumers need the current core API even when package dists are stale. -const root = resolve(import.meta.dir, '..') -const paths = new Set( - ['core', 'viewer', 'nodes', 'editor'].map((name) => - fileURLToPath( - import.meta.resolve( - '@pascal-app/core', - pathToFileURL(resolve(root, `packages/${name}/src/index.ts`)).href, - ), - ), - ), -) -for (const path of paths) mock.module(path, () => core) From aa9c58ad31ddc5449873397ce46b880a72f7eb1a Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 9 Sep 2026 15:45:48 -0400 Subject: [PATCH 10/11] test: verify consecutive undo and redo invalidation Zundo 2.3.0 appends the just-left snapshot to both destination stacks, so the existing pre-jump length indices are correct. Cover three adjacency-changing moves and each undo/redo with cleared marks and flushed microtasks. --- .../src/store/history-invalidation.test.ts | 65 ++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/packages/core/src/store/history-invalidation.test.ts b/packages/core/src/store/history-invalidation.test.ts index 576b7669e0..101cc04795 100644 --- a/packages/core/src/store/history-invalidation.test.ts +++ b/packages/core/src/store/history-invalidation.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { nodeRegistry } from '../registry' import { type AnyNode, CeilingNode, @@ -10,6 +11,7 @@ import { 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] }) @@ -221,3 +223,64 @@ describe('history dependency closure', () => { 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(), + ) + } + } + }) +}) From 5aa651594b9767ae7b706b800e6e16c2c1be84db Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 9 Sep 2026 15:45:57 -0400 Subject: [PATCH 11/11] fix: invalidate old slab covering dependents on reparent Refresh covering dependents below both parent levels, deduplicating equal resolved levels. Cover reparent from level 2 to level 3 and undo with exact wall/ceiling sets and unrelated levels left clean. --- .../spatial-grid/spatial-grid-sync.test.ts | 55 +++++++++++++++++++ .../hooks/spatial-grid/spatial-grid-sync.ts | 9 ++- 2 files changed, 63 insertions(+), 1 deletion(-) 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 e25865cadd..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 @@ -681,6 +681,61 @@ describe('temporal writes update slab support dependencies', () => { } }) + 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', 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 edeeaf224c..01869c84c1 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts @@ -382,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) + } + } } }