@pascal-app/editor 1.0.0-beta.5 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +13 -7
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +1 -0
  6. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  7. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  8. package/src/components/editor/capture-camera-rig.tsx +59 -0
  9. package/src/components/editor/custom-camera-controls.tsx +20 -11
  10. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  11. package/src/components/editor/editor-layout-v2.tsx +9 -2
  12. package/src/components/editor/export-manager.tsx +175 -33
  13. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  14. package/src/components/editor/first-person-controls.tsx +180 -12
  15. package/src/components/editor/floating-action-menu.tsx +42 -51
  16. package/src/components/editor/floorplan-panel.tsx +76 -12
  17. package/src/components/editor/grid.tsx +12 -7
  18. package/src/components/editor/group-actions.ts +34 -50
  19. package/src/components/editor/group-move-3d.ts +51 -21
  20. package/src/components/editor/group-transform-shared.test.ts +78 -0
  21. package/src/components/editor/group-transform-shared.ts +74 -7
  22. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  23. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  24. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  25. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  26. package/src/components/editor/handles/resize-snap.ts +10 -1
  27. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  28. package/src/components/editor/index.tsx +140 -21
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +47 -58
  31. package/src/components/editor/selection-manager.tsx +104 -42
  32. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  33. package/src/components/editor/snapshot-capture.test.ts +422 -0
  34. package/src/components/editor/snapshot-capture.ts +145 -0
  35. package/src/components/editor/thumbnail-generator.tsx +284 -237
  36. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  37. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  38. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  39. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  41. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  42. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  43. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  44. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  45. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  46. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +180 -32
  47. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  48. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  49. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  51. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  52. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  53. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  54. package/src/components/systems/selection-affordance-services.ts +19 -0
  55. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  56. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  57. package/src/components/tools/fence/fence-drafting.ts +6 -25
  58. package/src/components/tools/item/draft-creation.test.ts +12 -0
  59. package/src/components/tools/item/draft-creation.ts +10 -0
  60. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  61. package/src/components/tools/item/face-host-commit.ts +56 -0
  62. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  63. package/src/components/tools/item/face-host-preview.ts +82 -0
  64. package/src/components/tools/item/move-tool.tsx +4 -3
  65. package/src/components/tools/item/placement-math.test.ts +11 -1
  66. package/src/components/tools/item/placement-math.ts +0 -20
  67. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  68. package/src/components/tools/item/placement-strategies.ts +135 -2
  69. package/src/components/tools/item/placement-surface.test.ts +61 -0
  70. package/src/components/tools/item/placement-surface.ts +24 -0
  71. package/src/components/tools/item/placement-types.ts +6 -0
  72. package/src/components/tools/item/test-face-host.ts +121 -0
  73. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  74. package/src/components/tools/item/use-draft-node.ts +26 -4
  75. package/src/components/tools/item/use-placement-coordinator.tsx +428 -153
  76. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  77. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  78. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  79. package/src/components/tools/registry-tool-context.tsx +30 -0
  80. package/src/components/tools/select/box-select-tool.tsx +3 -1
  81. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  82. package/src/components/tools/select/marquee-footprint.ts +29 -0
  83. package/src/components/tools/shared/placement-box.tsx +57 -7
  84. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  85. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  86. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  89. package/src/components/tools/stair/stair-tool.tsx +127 -74
  90. package/src/components/tools/tool-manager.tsx +25 -8
  91. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  92. package/src/components/tools/wall/wall-drafting.ts +23 -114
  93. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  94. package/src/components/ui/action-menu/index.tsx +11 -1
  95. package/src/components/ui/command-palette/editor-commands.tsx +9 -12
  96. package/src/components/ui/command-palette/index.tsx +7 -2
  97. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  98. package/src/components/ui/controls/material-picker.tsx +4 -4
  99. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  100. package/src/components/ui/controls/segmented-control.tsx +8 -1
  101. package/src/components/ui/controls/slider-control.tsx +19 -3
  102. package/src/components/ui/controls/toggle-control.tsx +18 -5
  103. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  104. package/src/components/ui/floating-level-selector.tsx +22 -5
  105. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  106. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  107. package/src/components/ui/helpers/item-helper.tsx +1 -1
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  109. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  110. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  111. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  112. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  113. package/src/components/ui/panels/multi-field-value.ts +163 -0
  114. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  115. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  116. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  117. package/src/components/ui/panels/node-display.ts +4 -0
  118. package/src/components/ui/panels/panel-manager.tsx +77 -3
  119. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  120. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  121. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  122. package/src/components/ui/panels/reference-panel.tsx +31 -1
  123. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  124. package/src/components/ui/scene-loader.tsx +35 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  127. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  128. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  129. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  130. package/src/components/ui/sidebar/panels/site-panel/index.tsx +116 -46
  131. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  132. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  134. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +14 -6
  135. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  136. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  137. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  138. package/src/components/viewer/viewer-stage.tsx +4 -1
  139. package/src/components/viewer-overlay.tsx +1 -22
  140. package/src/components/viewer-zone-system.tsx +6 -2
  141. package/src/components/walkthrough-hud.tsx +19 -10
  142. package/src/hooks/use-auto-save.test.ts +73 -1
  143. package/src/hooks/use-auto-save.ts +76 -5
  144. package/src/hooks/use-ceiling-events.test.ts +122 -0
  145. package/src/hooks/use-drag-action.ts +17 -3
  146. package/src/hooks/use-grid-events.test.ts +240 -0
  147. package/src/hooks/use-grid-events.ts +190 -11
  148. package/src/hooks/use-keyboard.test.ts +244 -0
  149. package/src/hooks/use-keyboard.ts +129 -62
  150. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  151. package/src/hooks/use-save-shortcut.ts +33 -0
  152. package/src/index.tsx +82 -4
  153. package/src/lib/active-placement-surface.test.ts +16 -0
  154. package/src/lib/active-placement-surface.ts +14 -0
  155. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  156. package/src/lib/ceiling-surface-raycast.ts +12 -0
  157. package/src/lib/contextual-help-extension.ts +17 -0
  158. package/src/lib/continuation.test.ts +11 -0
  159. package/src/lib/continuation.ts +8 -1
  160. package/src/lib/direct-manipulation.test.ts +132 -0
  161. package/src/lib/direct-manipulation.ts +38 -1
  162. package/src/lib/editor-api.ts +6 -3
  163. package/src/lib/elevation-guides.test.ts +28 -1
  164. package/src/lib/elevation-guides.ts +22 -7
  165. package/src/lib/floating-menu-scale.test.ts +29 -0
  166. package/src/lib/floating-menu-scale.ts +14 -0
  167. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  168. package/src/lib/floorplan/apply-alignment.ts +10 -1
  169. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  170. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  171. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  172. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  173. package/src/lib/floorplan/floorplan-readonly.ts +1 -0
  174. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  175. package/src/lib/floorplan-grid-event-point.ts +13 -0
  176. package/src/lib/glb-export.test.ts +1169 -21
  177. package/src/lib/glb-export.ts +629 -101
  178. package/src/lib/grid-event-presentation.ts +16 -0
  179. package/src/lib/history.test.ts +528 -147
  180. package/src/lib/history.ts +81 -7
  181. package/src/lib/host-tree-children.test.ts +27 -0
  182. package/src/lib/host-tree-children.ts +79 -0
  183. package/src/lib/interaction/hot-set.test.ts +10 -1
  184. package/src/lib/interaction/hot-set.ts +3 -1
  185. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  186. package/src/lib/interaction/overlay-policy.ts +16 -0
  187. package/src/lib/interaction/registered-drafting.ts +36 -0
  188. package/src/lib/interaction/scope.ts +31 -0
  189. package/src/lib/level-print-export.test.ts +484 -0
  190. package/src/lib/level-print-export.ts +526 -0
  191. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  192. package/src/lib/local-project-presentation-persistence.ts +335 -0
  193. package/src/lib/material-paint.ts +2 -0
  194. package/src/lib/model-export.ts +29 -0
  195. package/src/lib/paint-preview-owner.ts +71 -0
  196. package/src/lib/planar-cursor-placement.test.ts +178 -1
  197. package/src/lib/planar-cursor-placement.ts +61 -5
  198. package/src/lib/plugin-panels.test.ts +92 -1
  199. package/src/lib/plugin-panels.ts +48 -0
  200. package/src/lib/portable-export.test.ts +366 -0
  201. package/src/lib/portable-export.ts +969 -0
  202. package/src/lib/print-3mf.test.ts +86 -0
  203. package/src/lib/print-3mf.ts +161 -0
  204. package/src/lib/print-content-scope.test.ts +107 -0
  205. package/src/lib/print-content-scope.ts +90 -0
  206. package/src/lib/print-export.test.ts +294 -0
  207. package/src/lib/print-export.ts +735 -0
  208. package/src/lib/print-feature-thickness.test.ts +147 -0
  209. package/src/lib/print-feature-thickness.ts +180 -0
  210. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  211. package/src/lib/print-golden-house.test.ts +249 -0
  212. package/src/lib/print-roof-solids.test.ts +133 -0
  213. package/src/lib/print-roof-solids.ts +630 -0
  214. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  215. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  216. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  217. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  218. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  219. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  220. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  221. package/src/lib/print-shell-compiler.ts +273 -0
  222. package/src/lib/print-wall-solids.test.ts +194 -0
  223. package/src/lib/print-wall-solids.ts +328 -0
  224. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  225. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  226. package/src/lib/scene.ts +22 -8
  227. package/src/lib/selection-routing.test.ts +105 -1
  228. package/src/lib/selection-routing.ts +19 -0
  229. package/src/lib/sfx-bus.ts +4 -0
  230. package/src/lib/sfx-player.ts +8 -0
  231. package/src/lib/snapping-mode.test.ts +32 -4
  232. package/src/lib/snapping-mode.ts +11 -7
  233. package/src/lib/usdz-export.ts +38 -0
  234. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  235. package/src/store/live-draft-preview-stores.test.ts +3 -0
  236. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  237. package/src/store/tool-mode.test.ts +87 -0
  238. package/src/store/use-camera-hint-focus.ts +48 -0
  239. package/src/store/use-editor.tsx +299 -188
  240. package/src/store/use-floorplan-draft-preview.ts +9 -0
  241. package/src/store/use-interaction-scope.test.ts +23 -1
  242. package/src/store/use-interaction-scope.ts +63 -2
  243. package/src/store/use-placement-preview.ts +56 -3
  244. package/src/components/tools/roof/roof-tool.tsx +0 -656
  245. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -1,161 +1,542 @@
1
- import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
2
- import {
3
- type AnyNode,
4
- type AnyNodeId,
5
- BuildingNode,
6
- clearSceneHistory,
7
- LevelNode,
8
- useScene,
9
- } from '@pascal-app/core'
10
- import {
11
- getHistoryCommandState,
12
- installHistoryCommandDelegate,
13
- runRedo,
14
- runUndo,
15
- subscribeHistoryCommandState,
16
- } from './history'
17
-
18
- type RafFn = (cb: (time: number) => void) => number
19
- ;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (cb) => {
20
- cb(0)
21
- return 0
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { join, resolve } from 'node:path'
4
+
5
+ function runSourceHistoryTest(body: string) {
6
+ const cache = join(import.meta.dir, '.turbo')
7
+ mkdirSync(cache, { recursive: true })
8
+ const directory = mkdtempSync(join(cache, 'history-'))
9
+ const probe = join(directory, 'probe.ts')
10
+ try {
11
+ writeFileSync(
12
+ probe,
13
+ `
14
+ import assert from 'node:assert/strict'
15
+ import { mock } from 'bun:test'
16
+ import { fileURLToPath, pathToFileURL } from 'node:url'
17
+ const editorConsumer = ${JSON.stringify(resolve(import.meta.dir, 'history.ts'))}
18
+ const coreConsumer = ${JSON.stringify(resolve(import.meta.dir, '../../../core/src/index.ts'))}
19
+ const viewerConsumer = ${JSON.stringify(resolve(import.meta.dir, '../../../viewer/src/systems/wall/wall-system.tsx'))}
20
+ const nodesConsumer = ${JSON.stringify(resolve(import.meta.dir, '../../../nodes/src/shared/node-batch/system.tsx'))}
21
+ const peerConsumers = [editorConsumer, coreConsumer, viewerConsumer, nodesConsumer]
22
+ // Resolve only from declared consumers; isolated installs cannot see sibling dependencies.
23
+ const sharedConsumers = [
24
+ ['@pascal-app/core', [editorConsumer, viewerConsumer, nodesConsumer]],
25
+ ['@pascal-app/viewer', [editorConsumer, nodesConsumer]],
26
+ ['react', peerConsumers],
27
+ ['three', peerConsumers],
28
+ ['@react-three/fiber', peerConsumers],
29
+ ]
30
+ const sharedPaths = new Map(
31
+ sharedConsumers.map(([specifier, consumers]) => [
32
+ specifier,
33
+ [...new Set(consumers.map(consumer => fileURLToPath(import.meta.resolve(specifier, pathToFileURL(consumer).href))))],
34
+ ]),
35
+ )
36
+ function mockShared(specifier, factory) {
37
+ for (const path of sharedPaths.get(specifier)) mock.module(path, factory)
38
+ }
39
+ async function importShared(specifier) {
40
+ const module = await import(sharedPaths.get(specifier)[0])
41
+ mockShared(specifier, () => module)
42
+ return module
43
+ }
44
+ await importShared('react')
45
+ await importShared('three')
46
+ await importShared('@react-three/fiber')
47
+ globalThis.requestAnimationFrame = callback => { callback(0); return 0 }
48
+ globalThis.cancelAnimationFrame = () => {}
49
+ const core = await import(${JSON.stringify(resolve(import.meta.dir, '../../..', 'core/src/index.ts'))})
50
+ mockShared('@pascal-app/core', () => core)
51
+ await importShared('@pascal-app/viewer')
52
+ const { useScene: scene, clearSceneHistory, useLiveTransforms: transforms, useLiveNodeOverrides: overrides } = core
53
+ const { runUndo, runRedo, installHistoryCommandDelegate, getHistoryCommandState, shouldCancelDraftOnHistoryJump, subscribeHistoryCommandState } = await import(${JSON.stringify(resolve(import.meta.dir, 'history.ts'))})
54
+ const { default: useInteractionScope } = await import(${JSON.stringify(resolve(import.meta.dir, '../store/use-interaction-scope.ts'))})
55
+ const level = core.LevelNode.parse({ id: 'level_history_source', children: ['wall_history_source', 'wall_remote_source', 'slab_history_source'] })
56
+ const wall = core.WallNode.parse({ id: 'wall_history_source', parentId: level.id, start: [0,0], end: [4,0] })
57
+ const remote = core.WallNode.parse({ id: 'wall_remote_source', parentId: level.id, start: [20,0], end: [24,0] })
58
+ const opening = core.DoorNode.parse({ id: 'door_history_source', parentId: wall.id, wallId: wall.id })
59
+ const slab = core.SlabNode.parse({ id: 'slab_history_source', parentId: level.id, polygon: [[0,0],[4,0],[4,4],[0,4]] })
60
+ const baseline = Object.fromEntries([level, { ...wall, children: [opening.id] }, remote, opening, slab].map(node => [node.id, node]))
61
+ scene.setState({ nodes: baseline, dirtyNodes: new Set(), readOnly: false, materials: {}, collections: {}, rootNodeIds: [level.id] })
62
+ clearSceneHistory()
63
+ const flush = async () => { await Promise.resolve(); await Promise.resolve() }
64
+ const clean = () => scene.getState().dirtyNodes.clear()
65
+ const dirty = id => scene.getState().dirtyNodes.has(id)
66
+ const edit = (id, patch) => scene.getState().updateNode(id, patch)
67
+ ${body}
68
+ `,
69
+ )
70
+ const result = Bun.spawnSync([process.execPath, probe], { stdout: 'pipe', stderr: 'pipe' })
71
+ expect({ code: result.exitCode, stderr: result.stderr.toString() }).toEqual({
72
+ code: 0,
73
+ stderr: '',
74
+ })
75
+ } finally {
76
+ rmSync(directory, { recursive: true, force: true })
77
+ }
22
78
  }
23
- ;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??=
24
- () => {}
25
79
 
26
- const BUILDING_ID = 'building_history_controller' as AnyNodeId
27
- const LEVEL_ID = 'level_history_controller' as AnyNodeId
28
- let disposeController = () => {}
80
+ describe('standalone history source invalidation', () => {
81
+ test('actual undo/redo keeps unchanged nodes clean and restores exact wall data on repeated jumps', () => {
82
+ runSourceHistoryTest(`
83
+ edit(wall.id, { start: [0,2], end: [4,2] })
84
+ const moved = scene.getState().nodes
85
+ for (let i = 0; i < 3; i++) {
86
+ clean(); runUndo(); await flush()
87
+ assert.equal(scene.getState().nodes[wall.id], baseline[wall.id])
88
+ assert(dirty(wall.id)); assert(!dirty(remote.id)); assert(!dirty(slab.id))
89
+ clean(); runRedo(); await flush()
90
+ assert.equal(scene.getState().nodes[wall.id], moved[wall.id])
91
+ assert(dirty(wall.id)); assert(!dirty(remote.id)); assert(!dirty(slab.id))
92
+ }
93
+ `)
94
+ })
29
95
 
30
- function levelNumber(): number {
31
- return (useScene.getState().nodes[LEVEL_ID] as { level: number }).level
32
- }
96
+ test('unchanged transform/override targets and hosted parents restore after clear, including stair holes', () => {
97
+ runSourceHistoryTest(`
98
+ edit(level.id, { name: 'Changed' })
99
+ transforms.getState().set(remote.id, { position: [1,0,0], rotation: 0 })
100
+ overrides.getState().set(opening.id, { width: 2 })
101
+ const controller = core.createSurfaceOpeningPreviewController()
102
+ controller.apply([{ id: slab.id, data: { holes: [[[1,1],[2,1],[2,2]]] } }])
103
+ clean(); runUndo(); await flush()
104
+ for (const id of [remote.id, opening.id, wall.id, slab.id]) assert(dirty(id), id)
105
+ assert.equal(scene.getState().nodes[remote.id], baseline[remote.id])
106
+ assert.equal(transforms.getState().transforms.size, 0)
107
+ assert.equal(overrides.getState().overrides.size, 0)
108
+ controller.clear()
109
+ `)
110
+ })
33
111
 
34
- describe('editor history controller', () => {
35
- beforeEach(() => {
36
- disposeController()
37
- disposeController = () => {}
38
- const level = LevelNode.parse({
39
- id: LEVEL_ID,
40
- parentId: BUILDING_ID,
41
- children: [],
42
- level: 0,
43
- })
44
- const building = BuildingNode.parse({
45
- id: BUILDING_ID,
46
- parentId: null,
47
- children: [LEVEL_ID],
48
- })
49
- useScene.setState({
50
- nodes: { [BUILDING_ID]: building, [LEVEL_ID]: level },
51
- rootNodeIds: [BUILDING_ID],
52
- dirtyNodes: new Set<AnyNodeId>(),
53
- collections: {},
54
- materials: {},
55
- readOnly: false,
56
- } as never)
57
- clearSceneHistory()
58
- useScene.getState().updateNode(LEVEL_ID, { level: 1 } as Partial<AnyNode>)
59
- })
60
-
61
- afterEach(() => {
62
- disposeController()
63
- disposeController = () => {}
64
- })
65
-
66
- test('delegates undo and redo while a host delegate is installed', () => {
67
- const undo = mock(() => ({ kind: 'applied', persistence: 'queued' }) as const)
68
- const redo = mock(() => ({ kind: 'empty' }) as const)
69
- disposeController = installHistoryCommandDelegate({
70
- getState: () => ({
71
- canRedo: false,
72
- canUndo: true,
73
- mode: 'collaborative',
74
- status: 'syncing',
75
- }),
76
- redo,
77
- subscribe: () => () => {},
78
- undo,
79
- })
112
+ test.each([
113
+ true,
114
+ false,
115
+ ])('discarded preview neighbours rebuild after undo/redo (joined: %s)', (joined) => {
116
+ runSourceHistoryTest(`
117
+ const react = await importShared('react')
118
+ mockShared('react', () => ({ ...react, useEffect: () => {} }))
119
+ const frames = []
120
+ const fiber = await importShared('@react-three/fiber')
121
+ mockShared('@react-three/fiber', () => ({ ...fiber, useFrame: frame => frames.push(frame) }))
122
+ const selector = store => Object.assign(fn => fn(store.getState()), store)
123
+ mockShared('@pascal-app/core', () => ({ ...core, useScene: selector(scene), useLiveNodeOverrides: selector(overrides) }))
124
+ const { Mesh } = await importShared('three')
125
+ const { WallSystem, getPendingWallRebuildCount } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../viewer/src/systems/wall/wall-system.tsx'))})
126
+ const neighbor = { ...remote, start: [8,0], end: [8,4] }
127
+ scene.setState({ nodes: { ...baseline, [level.id]: { ...level, children: [wall.id, neighbor.id] }, [neighbor.id]: neighbor } })
128
+ clearSceneHistory()
129
+ const a = new Mesh(), b = new Mesh()
130
+ core.sceneRegistry.nodes.set(wall.id, a)
131
+ core.sceneRegistry.nodes.set(neighbor.id, b)
132
+ let now = 0
133
+ performance.now = () => now
134
+ WallSystem()
135
+ const frame = () => { now += 100; frames[0]() }
136
+ scene.getState().markDirty(wall.id); scene.getState().markDirty(neighbor.id)
137
+ frame(); frame(); clean()
138
+ const canonical = Array.from(b.geometry.getAttribute('position').array)
139
+ edit(level.id, { name: 'Unrelated edit' })
140
+ for (const jump of [runUndo, runRedo]) {
141
+ overrides.getState().set(wall.id, { start: [4,0], end: [${joined ? 8 : 6},0] })
142
+ scene.getState().markDirty(wall.id)
143
+ frame(); frame()
144
+ assert.equal(getPendingWallRebuildCount(), 0)
145
+ const preview = Array.from(b.geometry.getAttribute('position').array)
146
+ ${joined ? 'assert.notDeepEqual(preview, canonical)' : 'assert.deepEqual(preview, canonical)'}
147
+ clean(); jump(); await flush()
148
+ assert(dirty(wall.id))
149
+ assert.equal(dirty(neighbor.id), ${joined})
150
+ assert.equal(overrides.getState().overrides.size, 0)
151
+ frame(); frame()
152
+ assert.deepEqual(Array.from(b.geometry.getAttribute('position').array), canonical)
153
+ }
154
+ `)
155
+ })
80
156
 
81
- expect(runUndo()).toEqual({ kind: 'applied', persistence: 'queued' })
82
- expect(runRedo()).toEqual({ kind: 'empty' })
83
- expect(getHistoryCommandState()).toEqual({
84
- canRedo: false,
85
- canUndo: true,
86
- mode: 'collaborative',
87
- status: 'syncing',
88
- })
157
+ test('opening reparent dirties old and new walls on undo and redo', () => {
158
+ runSourceHistoryTest(`
159
+ edit(opening.id, { parentId: remote.id, wallId: remote.id })
160
+ for (const jump of [runUndo, runRedo]) {
161
+ clean(); jump(); await flush()
162
+ for (const id of [wall.id, remote.id, opening.id]) assert(dirty(id), id)
163
+ }
164
+ `)
165
+ })
89
166
 
90
- expect(undo).toHaveBeenCalledTimes(1)
91
- expect(redo).toHaveBeenCalledTimes(1)
92
- expect(levelNumber()).toBe(1)
93
- expect(useScene.temporal.getState().pastStates).toHaveLength(1)
94
- })
95
-
96
- test('falls back to standalone Zundo undo and redo when no controller is installed', () => {
97
- expect(runUndo()).toEqual({ kind: 'applied', persistence: 'local' })
98
- expect(levelNumber()).toBe(0)
99
- expect(useScene.temporal.getState().futureStates).toHaveLength(1)
100
-
101
- expect(runRedo()).toEqual({ kind: 'applied', persistence: 'local' })
102
- expect(levelNumber()).toBe(1)
103
- expect(useScene.temporal.getState().pastStates).toHaveLength(1)
104
- })
105
-
106
- test('an older cleanup cannot uninstall a newer controller', () => {
107
- const firstUndo = mock(() => {})
108
- const delegate = (undo: () => void) => ({
109
- getState: () => ({
110
- canRedo: false,
111
- canUndo: true,
112
- mode: 'collaborative' as const,
113
- status: 'ready' as const,
114
- }),
115
- redo: () => ({ kind: 'empty' as const }),
116
- subscribe: () => () => {},
117
- undo: () => {
118
- undo()
119
- return { kind: 'applied' as const, persistence: 'queued' as const }
120
- },
121
- })
122
- const stopFirst = installHistoryCommandDelegate(delegate(firstUndo))
123
- const secondUndo = mock(() => {})
124
- disposeController = installHistoryCommandDelegate(delegate(secondUndo))
167
+ test('delete/restore of a subtree leaves no deleted dirty ids or live entries', () => {
168
+ runSourceHistoryTest(`
169
+ scene.getState().deleteNode(wall.id)
170
+ clean(); runUndo(); await flush()
171
+ assert.equal(scene.getState().nodes[opening.id], baseline[opening.id])
172
+ assert(dirty(wall.id)); assert(dirty(opening.id))
173
+ transforms.getState().set(opening.id, { position: [1,0,0], rotation: 0 })
174
+ scene.getState().markDirty(opening.id)
175
+ runRedo(); await flush()
176
+ assert(!scene.getState().nodes[wall.id]); assert(!scene.getState().nodes[opening.id])
177
+ assert(!dirty(wall.id)); assert(!dirty(opening.id))
178
+ assert.equal(transforms.getState().transforms.size, 0)
179
+ `)
180
+ })
125
181
 
126
- stopFirst()
127
- runUndo()
182
+ test('synchronous jumps preserve intermediate wall layouts until their microtasks flush', () => {
183
+ runSourceHistoryTest(`
184
+ edit(wall.id, { start: [16,0], end: [20,0] })
185
+ runUndo(); await flush(); clean()
186
+ runRedo(); runUndo(); await flush()
187
+ assert(dirty(remote.id))
188
+ assert.equal(scene.getState().nodes[wall.id], baseline[wall.id])
189
+ `)
190
+ })
128
191
 
129
- expect(firstUndo).toHaveBeenCalledTimes(0)
130
- expect(secondUndo).toHaveBeenCalledTimes(1)
192
+ test('empty commands and collaborative delegates retain ownership of live previews and dirtiness', () => {
193
+ runSourceHistoryTest(`
194
+ transforms.getState().set(remote.id, { position: [1,0,0], rotation: 0 })
195
+ overrides.getState().set(opening.id, { width: 2 })
196
+ clean()
197
+ assert.equal(runUndo().kind, 'empty'); assert.equal(runRedo().kind, 'empty')
198
+ edit(wall.id, { thickness: 0.4 }); clean()
199
+ const changed = scene.getState().nodes
200
+ let undo = 0, redo = 0
201
+ const stop = installHistoryCommandDelegate({
202
+ getState: () => ({ canUndo: true, canRedo: true, mode: 'collaborative', status: 'ready' }),
203
+ subscribe: () => () => {},
204
+ undo: () => { undo++; return { kind: 'applied', persistence: 'queued' } },
205
+ redo: () => { redo++; return { kind: 'empty' } },
206
+ })
207
+ runUndo(); runRedo(); await flush(); stop()
208
+ assert.equal(undo, 1); assert.equal(redo, 1)
209
+ assert.equal(scene.getState().nodes, changed)
210
+ assert.equal(scene.getState().dirtyNodes.size, 0)
211
+ assert.equal(transforms.getState().transforms.size, 1)
212
+ assert.equal(overrides.getState().overrides.size, 1)
213
+ `)
214
+ })
215
+ test('one-wall undo releases only its openings and neighbour openings from the real batch store', () => {
216
+ runSourceHistoryTest(`
217
+ const { Group, Mesh, MeshBasicMaterial, BoxGeometry } = await importShared('three')
218
+ const viewer = await importShared('@pascal-app/viewer')
219
+ const { captureChangedNodes, runBatchFrame, resetNodeBatchState } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../nodes/src/shared/node-batch/system.tsx'))})
220
+ const root = new Group()
221
+ core.sceneRegistry.nodes.set(level.id, root)
222
+ core.sceneRegistry.byType.level.add(level.id)
223
+ const material = new MeshBasicMaterial()
224
+ const meshes = []
225
+ const walls = [wall, { ...wall, id: 'wall_neighbor', start: [4,0], end: [4,4] }, remote, { ...remote, id: 'wall_far', start: [30,0], end: [34,0] }]
226
+ const nodes = { [level.id]: level }
227
+ walls.forEach((host, i) => {
228
+ const door = core.DoorNode.parse({ id: 'door_batch_' + i, parentId: host.id })
229
+ nodes[host.id] = { ...host, children: [door.id] }
230
+ nodes[door.id] = door
231
+ const mesh = new Mesh(new BoxGeometry(), material)
232
+ meshes.push(mesh); root.add(mesh)
233
+ core.sceneRegistry.nodes.set(door.id, mesh)
234
+ core.sceneRegistry.byType.door.add(door.id)
235
+ })
236
+ scene.setState({ nodes }); clearSceneHistory()
237
+ edit(wall.id, { start: [0,2], end: [4,2] }); clean()
238
+ viewer.useViewer.setState({ externalSelectedIds: [], previewSelectedIds: [], hoveredId: null, selection: { ...viewer.useViewer.getState().selection, selectedIds: [], levelId: null } })
239
+ let now = 0
240
+ performance.now = () => now
241
+ const wake = { current: null }
242
+ const frame = () => runBatchFrame(() => {}, wake)
243
+ frame(); now += 181; frame()
244
+ assert(meshes.every(mesh => !mesh.layers.isEnabled(viewer.SCENE_LAYER)))
245
+ const batch = root.children.find(child => child.name === 'item-batch')
246
+ assert.equal(batch.instanceCount, 4)
247
+ runUndo(); await flush()
248
+ captureChangedNodes(); clean(); frame()
249
+ assert.deepEqual(meshes.map(mesh => mesh.layers.isEnabled(viewer.SCENE_LAYER)), [true, true, false, false])
250
+ assert.equal(batch.instanceCount, 2)
251
+ now += 181; frame()
252
+ assert.equal(batch.instanceCount, 4)
253
+ assert(meshes.every(mesh => !mesh.layers.isEnabled(viewer.SCENE_LAYER)))
254
+ resetNodeBatchState(); if (wake.current) clearTimeout(wake.current)
255
+ `)
131
256
  })
132
257
 
133
- test('publishes delegate state changes and restores standalone availability on teardown', () => {
134
- const listeners = new Set<() => void>()
135
- const observed: string[] = []
136
- const unsubscribe = subscribeHistoryCommandState(() => {
137
- observed.push(getHistoryCommandState().mode)
138
- })
139
- disposeController = installHistoryCommandDelegate({
140
- getState: () => ({
141
- canRedo: false,
142
- canUndo: true,
143
- mode: 'collaborative',
144
- status: 'offline',
145
- }),
146
- redo: () => ({ kind: 'empty' }),
147
- subscribe: (listener) => {
148
- listeners.add(listener)
149
- return () => listeners.delete(listener)
150
- },
151
- undo: () => ({ kind: 'applied', persistence: 'queued' }),
152
- })
258
+ test('endpoint undo/redo releases exactly both endpoint neighbours and their hosted children', () => {
259
+ runSourceHistoryTest(`
260
+ const { Group, Mesh, MeshBasicMaterial, BoxGeometry } = await importShared('three')
261
+ const viewer = await importShared('@pascal-app/viewer')
262
+ const { captureChangedNodes, runBatchFrame, resetNodeBatchState } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../nodes/src/shared/node-batch/system.tsx'))})
263
+ const root = new Group()
264
+ core.sceneRegistry.nodes.set(level.id, root)
265
+ core.sceneRegistry.byType.level.add(level.id)
266
+ const material = new MeshBasicMaterial()
267
+ const meshes = []
268
+ 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] }]
269
+ const nodes = { [level.id]: level, [slab.id]: slab }
270
+ walls.forEach((host, i) => {
271
+ const door = core.DoorNode.parse({ id: 'door_batch_' + i, parentId: host.id })
272
+ nodes[host.id] = { ...host, children: [door.id] }
273
+ nodes[door.id] = door
274
+ const mesh = new Mesh(new BoxGeometry(), material)
275
+ meshes.push(mesh); root.add(mesh)
276
+ core.sceneRegistry.nodes.set(door.id, mesh)
277
+ core.sceneRegistry.byType.door.add(door.id)
278
+ })
279
+ scene.setState({ nodes }); clearSceneHistory()
280
+ const stopSpatial = core.initSpatialGridSync()
281
+ edit(wall.id, { end: [6,1] }); clean()
282
+ viewer.useViewer.setState({ externalSelectedIds: [], previewSelectedIds: [], hoveredId: null, selection: { ...viewer.useViewer.getState().selection, selectedIds: [], levelId: null } })
283
+ let now = 0
284
+ performance.now = () => now
285
+ const wake = { current: null }
286
+ const frame = () => runBatchFrame(() => {}, wake)
287
+ frame(); now += 181; frame()
288
+ assert(meshes.every(mesh => !mesh.layers.isEnabled(viewer.SCENE_LAYER)))
289
+ const batch = root.children.find(child => child.name === 'item-batch')
290
+ assert.equal(batch.instanceCount, 7)
291
+ for (const jump of [runUndo, runRedo]) {
292
+ clean(); jump(); await flush()
293
+ assert.deepEqual([...scene.getState().dirtyNodes].sort(), [level.id, ...walls.slice(0,4).map(node => node.id)].sort())
294
+ captureChangedNodes(); clean(); frame()
295
+ assert.deepEqual(meshes.map(mesh => mesh.layers.isEnabled(viewer.SCENE_LAYER)), [true, true, true, true, false, false, false])
296
+ assert.equal(batch.instanceCount, 3)
297
+ now += 181; frame()
298
+ assert.equal(batch.instanceCount, 7)
299
+ assert(meshes.every(mesh => !mesh.layers.isEnabled(viewer.SCENE_LAYER)))
300
+ }
301
+ stopSpatial(); core.spatialGridManager.clear(); resetNodeBatchState(); if (wake.current) clearTimeout(wake.current)
302
+ `)
303
+ })
304
+
305
+ test('all four item supports transfer through undo and redo without touching unrelated hosts', () => {
306
+ runSourceHistoryTest(`
307
+ const ceiling = core.CeilingNode.parse({ id: 'ceiling_transfer', parentId: level.id, polygon: slab.polygon })
308
+ const deck = { ...slab, elevation: 1 }
309
+ const asset = { id: 'transfer', name: 'transfer', category: 'test', thumbnail: '', src: '/test.glb' }
310
+ const supports = [
311
+ { parentId: level.id, supportSlabId: core.GROUND_SUPPORT_ID, asset },
312
+ { parentId: wall.id, supportSlabId: undefined, asset: { ...asset, attachTo: 'wall-side' } },
313
+ { parentId: ceiling.id, supportSlabId: undefined, asset: { ...asset, attachTo: 'ceiling' } },
314
+ { parentId: level.id, supportSlabId: deck.id, asset },
315
+ ]
316
+ for (let from = 0; from < supports.length; from++) {
317
+ for (let to = from + 1; to < supports.length; to++) {
318
+ const item = core.ItemNode.parse({ id: 'item_transfer', ...supports[from] })
319
+ scene.setState({ nodes: { ...baseline, [ceiling.id]: ceiling, [deck.id]: deck, [item.id]: item } })
320
+ clearSceneHistory()
321
+ edit(item.id, { ...supports[to], position: [2,0,2] })
322
+ const moved = scene.getState().nodes[item.id]
323
+ for (const [jump, expected] of [[runUndo, item], [runRedo, moved]]) {
324
+ clean(); jump(); await flush()
325
+ assert.equal(scene.getState().nodes[item.id], expected)
326
+ assert.deepEqual([...scene.getState().dirtyNodes].sort(), [...new Set([item.id, level.id, supports[from].parentId, supports[to].parentId])].sort())
327
+ assert(!dirty(remote.id)); assert(!dirty(deck.id))
328
+ }
329
+ }
330
+ }
331
+ `)
332
+ })
333
+
334
+ test('undo and redo re-mark hosted opening proxies and wall-side offsets for real frame rebuilds', () => {
335
+ runSourceHistoryTest(`
336
+ const react = await importShared('react')
337
+ mockShared('react', () => ({ ...react, useEffect: () => {}, useRef: current => ({ current }) }))
338
+ const frames = []
339
+ const fiber = await importShared('@react-three/fiber')
340
+ mockShared('@react-three/fiber', () => ({ ...fiber, useFrame: frame => frames.push(frame) }))
341
+ const selector = store => Object.assign(fn => fn(store.getState()), store)
342
+ mockShared('@pascal-app/core', () => ({ ...core, useScene: selector(scene), useLiveNodeOverrides: selector(overrides) }))
343
+ const viewer = await importShared('@pascal-app/viewer')
344
+ mock.module(${JSON.stringify(resolve(import.meta.dir, '../../../viewer/src/store/use-viewer.ts'))}, () => ({ default: selector(viewer.useViewer) }))
345
+ const { Mesh } = await importShared('three')
346
+ const { DoorSystem } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../viewer/src/systems/door/door-system.tsx'))})
347
+ const { WindowSystem } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../viewer/src/systems/window/window-system.tsx'))})
348
+ const { ItemSystem } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../viewer/src/systems/item/item-system.tsx'))})
349
+ const window = core.WindowNode.parse({ id: 'window_thickness', parentId: wall.id })
350
+ 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' } })
351
+ const children = [opening, window, item]
352
+ scene.setState({ nodes: { ...baseline, [wall.id]: { ...wall, children: children.map(node => node.id) }, [window.id]: window, [item.id]: item } })
353
+ const meshes = children.map(node => { const mesh = new Mesh(); mesh.userData.itemModelSettled = true; core.sceneRegistry.nodes.set(node.id, mesh); return mesh })
354
+ clearSceneHistory()
355
+ DoorSystem(); WindowSystem(); ItemSystem()
356
+ const frame = () => frames.forEach(frame => frame())
357
+ const depths = () => meshes.slice(0, 2).map(mesh => mesh.getObjectByName('cutout').geometry.parameters.depth)
358
+ const check = thickness => {
359
+ assert.deepEqual(depths(), [thickness + 0.08, thickness + 0.08])
360
+ assert.equal(meshes[2].position.z, thickness / 2)
361
+ }
362
+ children.forEach(node => scene.getState().markDirty(node.id)); frame(); check(core.getWallThickness(wall))
363
+ edit(wall.id, { thickness: 0.6 })
364
+ children.forEach(node => scene.getState().markDirty(node.id)); frame(); check(0.6)
365
+ for (const [jump, thickness] of [[runUndo, core.getWallThickness(wall)], [runRedo, 0.6]]) {
366
+ clean(); jump(); await flush()
367
+ children.forEach(node => assert(dirty(node.id), node.id))
368
+ assert(!dirty(remote.id)); assert(!dirty(slab.id))
369
+ frame(); check(thickness)
370
+ children.forEach(node => assert(!dirty(node.id), node.id))
371
+ }
372
+ `)
373
+ })
374
+
375
+ test('undo removes a reconciliation-created side-effect wall and its auto surfaces in one step', () => {
376
+ runSourceHistoryTest(`
377
+ const upper = core.LevelNode.parse({ id: 'level_unrelated_side_effect', level: 1 })
378
+ const walls = [wall, { ...wall, id: 'wall_east', start: [4,0], end: [4,4] }, { ...wall, id: 'wall_north', start: [4,4], end: [0,4] }]
379
+ const closing = core.WallNode.parse({ id: 'wall_closing', parentId: level.id, start: [0,4], end: [0,0] })
380
+ const sideEffect = core.WallNode.parse({ id: 'wall_derived', parentId: level.id, start: [4,4], end: [6,4] })
381
+ scene.setState({ nodes: Object.fromEntries([{ ...level, children: walls.map(node => node.id) }, upper, { ...remote, parentId: upper.id }, ...walls].map(node => [node.id, node])) })
382
+ const editor = { spaces: {}, setSpaces: spaces => { editor.spaces = spaces } }
383
+ let created = false
384
+ const stop = core.initSpaceDetectionSync(scene, { getState: () => editor }, {
385
+ onTopologyReconcile: () => {
386
+ if (created) return
387
+ created = true
388
+ scene.getState().createNode(sideEffect, level.id)
389
+ },
390
+ })
391
+ clearSceneHistory()
392
+ scene.getState().createNode(closing, level.id)
393
+ await flush()
394
+ assert(created); assert(scene.getState().nodes[sideEffect.id])
395
+ const surfaces = Object.values(scene.getState().nodes).filter(node => node.type === 'slab' || node.type === 'ceiling')
396
+ assert(surfaces.length > 0)
397
+ assert.equal(scene.temporal.getState().pastStates.length, 1)
398
+ clean(); runUndo(); await flush()
399
+ for (const node of [closing, sideEffect, ...surfaces]) {
400
+ assert(!scene.getState().nodes[node.id], node.id)
401
+ assert(!dirty(node.id), node.id)
402
+ }
403
+ assert(dirty('wall_north')); assert(!dirty(remote.id))
404
+ stop()
405
+ `)
406
+ })
407
+
408
+ test('mounted slab and space subscriptions run on temporal writes without swallowing the wall diff', () => {
409
+ runSourceHistoryTest(`
410
+ const react = await importShared('react')
411
+ const effects = []
412
+ mockShared('react', () => ({ ...react, useEffect: effect => effects.push(effect) }))
413
+ const { default: SlabSystems } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../nodes/src/slab/system.tsx'))})
414
+ SlabSystems()
415
+ const stopSlabs = effects[0]()
416
+ let publications = 0
417
+ const editor = { spaces: {}, setSpaces: spaces => { editor.spaces = spaces; publications++ } }
418
+ const stopSpaces = core.initSpaceDetectionSync(scene, { getState: () => editor })
419
+ edit(wall.id, { start: [0,2], end: [4,2] })
420
+ clean(); const previousPublications = publications
421
+ runUndo()
422
+ assert(dirty(slab.id))
423
+ assert(publications > previousPublications)
424
+ await flush()
425
+ assert(dirty(wall.id))
426
+ assert(!dirty(remote.id))
427
+ stopSpaces(); stopSlabs()
428
+ `)
429
+ })
153
430
 
154
- for (const listener of listeners) listener()
155
- disposeController()
156
- disposeController = () => {}
157
- unsubscribe()
431
+ test('stair preview cleanup captures holes republished during the first clear', () => {
432
+ runSourceHistoryTest(`
433
+ edit(level.id, { name: 'Changed' })
434
+ transforms.getState().set(wall.id, { position: [1,0,0], rotation: 0 })
435
+ let published = false
436
+ const stop = overrides.subscribe(state => {
437
+ if (published || state.overrides.size || !transforms.getState().transforms.size) return
438
+ published = true
439
+ overrides.getState().set(slab.id, { holes: [[[1,1],[2,1],[2,2]]] })
440
+ })
441
+ clean(); runUndo(); await flush(); stop()
442
+ assert(published)
443
+ assert.equal(overrides.getState().overrides.size, 0)
444
+ assert(dirty(slab.id))
445
+ `)
446
+ })
447
+ test('the wall geometry harness restores positions, normals, UVs and opening cutouts after undo', () => {
448
+ runSourceHistoryTest(`
449
+ const { Mesh } = await importShared('three')
450
+ const { generateExtrudedWall } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../viewer/src/systems/wall/wall-system.tsx'))})
451
+ core.sceneRegistry.nodes.set(wall.id, new Mesh())
452
+ const geometry = () => {
453
+ const nodes = scene.getState().nodes
454
+ const currentWall = nodes[wall.id]
455
+ const children = currentWall.children.map(id => nodes[id]).filter(Boolean)
456
+ const mesh = generateExtrudedWall(currentWall, children, core.calculateLevelMiters([currentWall]))
457
+ const result = Object.fromEntries(['position', 'normal', 'uv'].map(name => [name, Array.from(mesh.getAttribute(name).array)]))
458
+ mesh.dispose(); return result
459
+ }
460
+ const canonical = geometry()
461
+ for (const [id, patch] of [
462
+ [wall.id, { thickness: 0.4 }], [wall.id, { end: [7,2] }],
463
+ [wall.id, { curveOffset: 0.7 }], [opening.id, { position: [2,1,0], width: 1.5 }],
464
+ ]) {
465
+ edit(id, patch)
466
+ assert.notDeepEqual(geometry(), canonical)
467
+ clean(); runUndo(); await flush()
468
+ assert(dirty(wall.id))
469
+ assert.deepEqual(geometry(), canonical)
470
+ }
471
+ `)
472
+ })
473
+
474
+ test('the mounted stair subscription restores derived flight heights after a temporal level write', () => {
475
+ runSourceHistoryTest(`
476
+ const react = await importShared('react')
477
+ const effects = []
478
+ mockShared('react', () => ({ ...react, useEffect: effect => effects.push(effect), useRef: current => ({ current }) }))
479
+ const segment = core.StairSegmentNode.parse({ id: 'sseg_history', parentId: 'stair_history', height: 2.5 })
480
+ const stair = core.StairNode.parse({ id: 'stair_history', parentId: level.id, children: [segment.id] })
481
+ scene.setState({ nodes: { ...baseline, [level.id]: { ...level, height: 2.5, children: [stair.id] }, [stair.id]: stair, [segment.id]: segment } })
482
+ const { StairOpeningSystem } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../core/src/systems/stair/stair-opening-system.tsx'))})
483
+ StairOpeningSystem()
484
+ const stop = effects[0]()
485
+ await flush(); clearSceneHistory()
486
+ edit(level.id, { height: 4 }); await flush()
487
+ assert.equal(scene.getState().nodes[segment.id].height, 4)
488
+ clean(); runUndo(); await flush()
489
+ assert.equal(scene.getState().nodes[segment.id].height, 2.5)
490
+ assert(dirty(segment.id)); assert(!dirty(remote.id))
491
+ stop()
492
+ `)
493
+ })
494
+ })
495
+
496
+ describe('editor history controller', () => {
497
+ test('draft cancellation follows the registered kind', () => {
498
+ runSourceHistoryTest(`
499
+ let cancelled = 0
500
+ core.emitter.on('tool:cancel', () => cancelled++)
501
+ useInteractionScope.getState().begin({ kind: 'drafting', tool: 'plain-draft' })
502
+ assert.equal(shouldCancelDraftOnHistoryJump(), false)
503
+ core.nodeRegistry._register({ kind: 'registered-draft', schemaVersion: 1, drafting: { cancelOnHistoryJump: true } })
504
+ useInteractionScope.getState().begin({ kind: 'drafting', tool: 'registered-draft' })
505
+ assert.equal(shouldCancelDraftOnHistoryJump(), true)
506
+ edit(level.id, { level: 1 }); runUndo()
507
+ assert.equal(cancelled, 1)
508
+ `)
509
+ })
158
510
 
159
- expect(observed).toEqual(['collaborative', 'collaborative', 'standalone'])
511
+ test('delegates publish availability and an older cleanup cannot uninstall the current delegate', () => {
512
+ runSourceHistoryTest(`
513
+ edit(level.id, { level: 1 })
514
+ const observed = []
515
+ const unsubscribe = subscribeHistoryCommandState(() => observed.push(getHistoryCommandState().mode))
516
+ const listeners = new Set()
517
+ let firstCalls = 0, secondCalls = 0
518
+ const delegate = undo => ({
519
+ getState: () => ({ canRedo: false, canUndo: true, mode: 'collaborative', status: 'syncing' }),
520
+ redo: () => ({ kind: 'empty' }),
521
+ subscribe: listener => { listeners.add(listener); return () => listeners.delete(listener) },
522
+ undo,
523
+ })
524
+ const stopFirst = installHistoryCommandDelegate(delegate(() => { firstCalls++; return { kind: 'empty' } }))
525
+ const stopSecond = installHistoryCommandDelegate(delegate(() => { secondCalls++; return { kind: 'applied', persistence: 'queued' } }))
526
+ stopFirst()
527
+ assert.deepEqual(runUndo(), { kind: 'applied', persistence: 'queued' })
528
+ assert.deepEqual(runRedo(), { kind: 'empty' })
529
+ assert.equal(firstCalls, 0); assert.equal(secondCalls, 1)
530
+ assert.equal(scene.getState().nodes[level.id].level, 1)
531
+ assert.equal(scene.temporal.getState().pastStates.length, 1)
532
+ assert.deepEqual(getHistoryCommandState(), { canRedo: false, canUndo: true, mode: 'collaborative', status: 'syncing' })
533
+ for (const listener of listeners) listener()
534
+ stopSecond(); unsubscribe()
535
+ assert.deepEqual(observed, ['collaborative', 'collaborative', 'collaborative', 'standalone'])
536
+ assert.deepEqual(runUndo(), { kind: 'applied', persistence: 'local' })
537
+ assert.equal(scene.getState().nodes[level.id].level, 0)
538
+ assert.deepEqual(runRedo(), { kind: 'applied', persistence: 'local' })
539
+ assert.equal(scene.getState().nodes[level.id].level, 1)
540
+ `)
160
541
  })
161
542
  })