@pascal-app/editor 1.0.0-beta.4 → 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 (283) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +15 -8
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
  6. package/src/components/editor/bake-exporter.tsx +1 -0
  7. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  8. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  9. package/src/components/editor/capture-camera-rig.tsx +59 -0
  10. package/src/components/editor/custom-camera-controls.tsx +20 -11
  11. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  12. package/src/components/editor/editor-layout-v2.tsx +17 -5
  13. package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
  14. package/src/components/editor/export-manager.tsx +175 -33
  15. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  16. package/src/components/editor/first-person-controls.tsx +182 -57
  17. package/src/components/editor/floating-action-menu.tsx +55 -55
  18. package/src/components/editor/floorplan-background-selection.test.ts +31 -4
  19. package/src/components/editor/floorplan-background-selection.ts +27 -7
  20. package/src/components/editor/floorplan-panel.tsx +81 -57
  21. package/src/components/editor/grid.tsx +12 -7
  22. package/src/components/editor/group-actions.ts +34 -50
  23. package/src/components/editor/group-floating-action-menu.tsx +30 -15
  24. package/src/components/editor/group-move-3d.ts +51 -21
  25. package/src/components/editor/group-transform-shared.test.ts +78 -0
  26. package/src/components/editor/group-transform-shared.ts +74 -7
  27. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  28. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  29. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  30. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  31. package/src/components/editor/handles/resize-snap.ts +10 -1
  32. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  33. package/src/components/editor/index.tsx +252 -46
  34. package/src/components/editor/measurement-pill.tsx +12 -12
  35. package/src/components/editor/node-action-menu.tsx +42 -1
  36. package/src/components/editor/node-arrow-handles.tsx +47 -58
  37. package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
  38. package/src/components/editor/selection-manager.tsx +132 -47
  39. package/src/components/editor/site-edge-labels.tsx +2 -1
  40. package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
  41. package/src/components/editor/snapshot-capture.test.ts +422 -0
  42. package/src/components/editor/snapshot-capture.ts +145 -0
  43. package/src/components/editor/thumbnail-generator.tsx +295 -232
  44. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  45. package/src/components/editor/wall-measurement-label.tsx +3 -2
  46. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  47. package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
  48. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  49. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  50. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  51. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  52. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  53. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  54. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +204 -114
  57. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  59. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  60. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  61. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  62. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  63. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  64. package/src/components/systems/selection-affordance-services.ts +19 -0
  65. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  66. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  67. package/src/components/tools/fence/fence-drafting.ts +6 -25
  68. package/src/components/tools/item/draft-creation.test.ts +12 -0
  69. package/src/components/tools/item/draft-creation.ts +10 -0
  70. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  71. package/src/components/tools/item/face-host-commit.ts +56 -0
  72. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  73. package/src/components/tools/item/face-host-preview.ts +82 -0
  74. package/src/components/tools/item/move-tool.tsx +4 -3
  75. package/src/components/tools/item/placement-math.test.ts +11 -1
  76. package/src/components/tools/item/placement-math.ts +0 -20
  77. package/src/components/tools/item/placement-strategies.test.ts +494 -0
  78. package/src/components/tools/item/placement-strategies.ts +192 -24
  79. package/src/components/tools/item/placement-surface.test.ts +61 -0
  80. package/src/components/tools/item/placement-surface.ts +24 -0
  81. package/src/components/tools/item/placement-types.ts +6 -0
  82. package/src/components/tools/item/test-face-host.ts +121 -0
  83. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  84. package/src/components/tools/item/use-draft-node.ts +26 -4
  85. package/src/components/tools/item/use-placement-coordinator.tsx +447 -180
  86. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  87. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  88. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  89. package/src/components/tools/registry-tool-context.tsx +30 -0
  90. package/src/components/tools/select/box-select-tool.tsx +3 -1
  91. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  92. package/src/components/tools/select/marquee-footprint.ts +29 -0
  93. package/src/components/tools/shared/placement-box.tsx +63 -8
  94. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  95. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  96. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  97. package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
  98. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  99. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  100. package/src/components/tools/stair/stair-tool.tsx +127 -74
  101. package/src/components/tools/tool-manager.tsx +25 -8
  102. package/src/components/tools/wall/wall-drafting.test.ts +527 -4
  103. package/src/components/tools/wall/wall-drafting.ts +55 -462
  104. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  105. package/src/components/ui/action-menu/index.tsx +11 -1
  106. package/src/components/ui/command-palette/editor-commands.tsx +12 -13
  107. package/src/components/ui/command-palette/index.tsx +7 -2
  108. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  109. package/src/components/ui/controls/material-picker.tsx +4 -4
  110. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  111. package/src/components/ui/controls/segmented-control.tsx +8 -1
  112. package/src/components/ui/controls/slider-control.tsx +19 -3
  113. package/src/components/ui/controls/toggle-control.tsx +18 -5
  114. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  115. package/src/components/ui/floating-level-selector.tsx +23 -6
  116. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  117. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  118. package/src/components/ui/helpers/item-helper.tsx +1 -1
  119. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  120. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  121. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  122. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  123. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  124. package/src/components/ui/panels/multi-field-value.ts +163 -0
  125. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  126. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  127. package/src/components/ui/panels/multi-selection-panel.tsx +78 -26
  128. package/src/components/ui/panels/node-display.ts +4 -0
  129. package/src/components/ui/panels/panel-manager.tsx +77 -3
  130. package/src/components/ui/panels/panel-wrapper.tsx +228 -12
  131. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  132. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  133. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  134. package/src/components/ui/panels/reference-panel.tsx +31 -1
  135. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  136. package/src/components/ui/scene-loader.tsx +35 -0
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
  138. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  139. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -23
  140. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  142. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  143. package/src/components/ui/sidebar/panels/site-panel/index.tsx +128 -46
  144. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  145. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  146. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  147. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +22 -7
  148. package/src/components/ui/sidebar/tab-bar.tsx +9 -1
  149. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  150. package/src/components/viewer/floorplan-compass-button.tsx +50 -0
  151. package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
  152. package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
  153. package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
  154. package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
  155. package/src/components/viewer/floorplan-preview.tsx +1069 -0
  156. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
  157. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  158. package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
  159. package/src/components/viewer/viewer-stage-modes.ts +49 -0
  160. package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
  161. package/src/components/viewer/viewer-stage.test.tsx +47 -0
  162. package/src/components/viewer/viewer-stage.tsx +221 -0
  163. package/src/components/viewer-overlay.tsx +13 -30
  164. package/src/components/viewer-zone-system.tsx +6 -2
  165. package/src/components/walkthrough-hud.tsx +19 -10
  166. package/src/hooks/use-auto-save.test.ts +122 -1
  167. package/src/hooks/use-auto-save.ts +119 -13
  168. package/src/hooks/use-ceiling-events.test.ts +122 -0
  169. package/src/hooks/use-drag-action.ts +17 -3
  170. package/src/hooks/use-grid-events.test.ts +240 -0
  171. package/src/hooks/use-grid-events.ts +190 -11
  172. package/src/hooks/use-keyboard.test.ts +244 -0
  173. package/src/hooks/use-keyboard.ts +164 -63
  174. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  175. package/src/hooks/use-save-shortcut.ts +33 -0
  176. package/src/index.tsx +109 -4
  177. package/src/lib/active-placement-surface.test.ts +16 -0
  178. package/src/lib/active-placement-surface.ts +14 -0
  179. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  180. package/src/lib/ceiling-surface-raycast.ts +12 -0
  181. package/src/lib/contextual-help-extension.ts +17 -0
  182. package/src/lib/contextual-help.test.ts +8 -0
  183. package/src/lib/contextual-help.ts +8 -0
  184. package/src/lib/continuation.test.ts +11 -0
  185. package/src/lib/continuation.ts +8 -1
  186. package/src/lib/direct-manipulation.test.ts +132 -0
  187. package/src/lib/direct-manipulation.ts +38 -1
  188. package/src/lib/editor-api.ts +6 -3
  189. package/src/lib/elevation-guides.test.ts +28 -1
  190. package/src/lib/elevation-guides.ts +22 -7
  191. package/src/lib/floating-menu-scale.test.ts +29 -0
  192. package/src/lib/floating-menu-scale.ts +14 -0
  193. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  194. package/src/lib/floorplan/apply-alignment.ts +10 -1
  195. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  196. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  197. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  198. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  199. package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
  200. package/src/lib/floorplan/floorplan-readonly.ts +85 -0
  201. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  202. package/src/lib/floorplan-grid-event-point.ts +13 -0
  203. package/src/lib/glb-export.test.ts +1169 -21
  204. package/src/lib/glb-export.ts +629 -101
  205. package/src/lib/grid-event-presentation.ts +16 -0
  206. package/src/lib/history.test.ts +523 -77
  207. package/src/lib/history.ts +133 -14
  208. package/src/lib/host-tree-children.test.ts +27 -0
  209. package/src/lib/host-tree-children.ts +79 -0
  210. package/src/lib/inspector-card-mode.test.ts +92 -0
  211. package/src/lib/inspector-card-mode.ts +60 -0
  212. package/src/lib/interaction/hot-set.test.ts +10 -1
  213. package/src/lib/interaction/hot-set.ts +3 -1
  214. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  215. package/src/lib/interaction/overlay-policy.ts +16 -0
  216. package/src/lib/interaction/registered-drafting.ts +36 -0
  217. package/src/lib/interaction/scope.ts +31 -0
  218. package/src/lib/level-print-export.test.ts +484 -0
  219. package/src/lib/level-print-export.ts +526 -0
  220. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  221. package/src/lib/local-project-presentation-persistence.ts +335 -0
  222. package/src/lib/material-paint.ts +2 -0
  223. package/src/lib/measurement-parser.ts +3 -0
  224. package/src/lib/measurements.test.ts +4 -0
  225. package/src/lib/model-export.ts +29 -0
  226. package/src/lib/paint-preview-owner.ts +71 -0
  227. package/src/lib/paint-scope.test.ts +58 -2
  228. package/src/lib/paint-scope.ts +8 -2
  229. package/src/lib/planar-cursor-placement.test.ts +178 -1
  230. package/src/lib/planar-cursor-placement.ts +61 -5
  231. package/src/lib/plugin-panels.test.ts +92 -1
  232. package/src/lib/plugin-panels.ts +48 -0
  233. package/src/lib/portable-export.test.ts +366 -0
  234. package/src/lib/portable-export.ts +969 -0
  235. package/src/lib/print-3mf.test.ts +86 -0
  236. package/src/lib/print-3mf.ts +161 -0
  237. package/src/lib/print-content-scope.test.ts +107 -0
  238. package/src/lib/print-content-scope.ts +90 -0
  239. package/src/lib/print-export.test.ts +294 -0
  240. package/src/lib/print-export.ts +735 -0
  241. package/src/lib/print-feature-thickness.test.ts +147 -0
  242. package/src/lib/print-feature-thickness.ts +180 -0
  243. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  244. package/src/lib/print-golden-house.test.ts +249 -0
  245. package/src/lib/print-roof-solids.test.ts +133 -0
  246. package/src/lib/print-roof-solids.ts +630 -0
  247. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  248. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  249. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  250. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  251. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  252. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  253. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  254. package/src/lib/print-shell-compiler.ts +273 -0
  255. package/src/lib/print-wall-solids.test.ts +194 -0
  256. package/src/lib/print-wall-solids.ts +328 -0
  257. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  258. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  259. package/src/lib/scene.ts +22 -8
  260. package/src/lib/selection-routing.test.ts +125 -6
  261. package/src/lib/selection-routing.ts +34 -1
  262. package/src/lib/session-groups.test.ts +88 -0
  263. package/src/lib/session-groups.ts +201 -0
  264. package/src/lib/sfx-bus.ts +4 -0
  265. package/src/lib/sfx-player.ts +8 -0
  266. package/src/lib/snapping-mode.test.ts +32 -4
  267. package/src/lib/snapping-mode.ts +11 -7
  268. package/src/lib/structured-clone-fallback.test.ts +45 -0
  269. package/src/lib/structured-clone-fallback.ts +24 -0
  270. package/src/lib/usdz-export.ts +38 -0
  271. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  272. package/src/store/live-draft-preview-stores.test.ts +3 -0
  273. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  274. package/src/store/tool-mode.test.ts +87 -0
  275. package/src/store/use-camera-hint-focus.ts +48 -0
  276. package/src/store/use-editor.tsx +299 -188
  277. package/src/store/use-floorplan-draft-preview.ts +9 -0
  278. package/src/store/use-interaction-scope.test.ts +23 -1
  279. package/src/store/use-interaction-scope.ts +63 -2
  280. package/src/store/use-placement-preview.ts +56 -3
  281. package/src/store/use-session-groups.ts +103 -0
  282. package/src/components/tools/roof/roof-tool.tsx +0 -656
  283. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -0,0 +1,16 @@
1
+ import type { GridEvent } from '@pascal-app/core'
2
+
3
+ export type GridEventScreenProjection = {
4
+ pointer: [number, number]
5
+ localToScreen: [number, number, number, number, number, number]
6
+ }
7
+
8
+ export type EditorGridEvent = GridEvent & {
9
+ screenProjection?: GridEventScreenProjection
10
+ }
11
+
12
+ export function getGridEventScreenProjection(
13
+ event: GridEvent,
14
+ ): GridEventScreenProjection | undefined {
15
+ return (event as EditorGridEvent).screenProjection
16
+ }
@@ -1,96 +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 { installHistoryCommandDelegate, runRedo, runUndo } from './history'
11
-
12
- type RafFn = (cb: (time: number) => void) => number
13
- ;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (cb) => {
14
- cb(0)
15
- 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
+ }
16
78
  }
17
- ;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??=
18
- () => {}
19
79
 
20
- const BUILDING_ID = 'building_history_controller' as AnyNodeId
21
- const LEVEL_ID = 'level_history_controller' as AnyNodeId
22
- 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
+ })
23
95
 
24
- function levelNumber(): number {
25
- return (useScene.getState().nodes[LEVEL_ID] as { level: number }).level
26
- }
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
+ })
27
111
 
28
- describe('editor history controller', () => {
29
- beforeEach(() => {
30
- disposeController()
31
- disposeController = () => {}
32
- const level = LevelNode.parse({
33
- id: LEVEL_ID,
34
- parentId: BUILDING_ID,
35
- children: [],
36
- level: 0,
37
- })
38
- const building = BuildingNode.parse({
39
- id: BUILDING_ID,
40
- parentId: null,
41
- children: [LEVEL_ID],
42
- })
43
- useScene.setState({
44
- nodes: { [BUILDING_ID]: building, [LEVEL_ID]: level },
45
- rootNodeIds: [BUILDING_ID],
46
- dirtyNodes: new Set<AnyNodeId>(),
47
- collections: {},
48
- materials: {},
49
- readOnly: false,
50
- } as never)
51
- clearSceneHistory()
52
- useScene.getState().updateNode(LEVEL_ID, { level: 1 } as Partial<AnyNode>)
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
+ `)
53
155
  })
54
156
 
55
- afterEach(() => {
56
- disposeController()
57
- disposeController = () => {}
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
+ `)
58
165
  })
59
166
 
60
- test('delegates undo and redo while a host delegate is installed', () => {
61
- const undo = mock(() => {})
62
- const redo = mock(() => {})
63
- disposeController = installHistoryCommandDelegate({ undo, redo })
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
+ })
64
181
 
65
- runUndo()
66
- runRedo()
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
+ })
67
191
 
68
- expect(undo).toHaveBeenCalledTimes(1)
69
- expect(redo).toHaveBeenCalledTimes(1)
70
- expect(levelNumber()).toBe(1)
71
- expect(useScene.temporal.getState().pastStates).toHaveLength(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
+ `)
72
256
  })
73
257
 
74
- test('falls back to standalone Zundo undo and redo when no controller is installed', () => {
75
- runUndo()
76
- expect(levelNumber()).toBe(0)
77
- expect(useScene.temporal.getState().futureStates).toHaveLength(1)
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
+ })
78
304
 
79
- runRedo()
80
- expect(levelNumber()).toBe(1)
81
- expect(useScene.temporal.getState().pastStates).toHaveLength(1)
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
+ `)
82
332
  })
83
333
 
84
- test('an older cleanup cannot uninstall a newer controller', () => {
85
- const firstUndo = mock(() => {})
86
- const stopFirst = installHistoryCommandDelegate({ undo: firstUndo, redo: () => {} })
87
- const secondUndo = mock(() => {})
88
- disposeController = installHistoryCommandDelegate({ undo: secondUndo, redo: () => {} })
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
+ })
89
374
 
90
- stopFirst()
91
- runUndo()
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
+ })
430
+
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
+ })
92
510
 
93
- expect(firstUndo).toHaveBeenCalledTimes(0)
94
- expect(secondUndo).toHaveBeenCalledTimes(1)
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
+ `)
95
541
  })
96
542
  })