@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
@@ -1,14 +1,22 @@
1
- import { beforeEach, describe, expect, test } from 'bun:test'
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
2
  import {
3
3
  type AnyNode,
4
4
  type AnyNodeId,
5
5
  applyHeightPatch,
6
+ BlockNode,
7
+ CeilingNode,
6
8
  createTerrainField,
7
9
  DoorNode as DoorSchema,
8
10
  encodeTerrainField,
9
11
  flattenPatch,
10
12
  GROUND_SUPPORT_ID,
13
+ getFloorPlacedElevation,
14
+ initSpaceDetectionSync,
15
+ nodeRegistry,
16
+ registerNode,
17
+ resolveTerrainWallConstructionOptions,
11
18
  runAsSingleSceneHistoryStep,
19
+ SlabNode,
12
20
  spatialGridManager,
13
21
  useScene,
14
22
  type WallNode,
@@ -20,7 +28,6 @@ import useInteractionScope from '../../../store/use-interaction-scope'
20
28
  import {
21
29
  createWallOnCurrentLevel,
22
30
  resolveEndpointWallSplit,
23
- resolveTerrainWallConstructionOptions,
24
31
  snapWallDraftPointDetailed,
25
32
  } from './wall-drafting'
26
33
  import type { WallPlanPoint } from './wall-snap-geometry'
@@ -56,7 +63,7 @@ function seedLevel(walls: WallNode[], extraNodes: AnyNode[] = []) {
56
63
  parentId: null,
57
64
  visible: true,
58
65
  metadata: {},
59
- children: walls.map((wall) => wall.id),
66
+ children: [...walls.map((wall) => wall.id), ...extraNodes.map((node) => node.id)],
60
67
  level: 0,
61
68
  } as AnyNode,
62
69
  ],
@@ -69,6 +76,66 @@ function seedLevel(walls: WallNode[], extraNodes: AnyNode[] = []) {
69
76
  } as never)
70
77
  }
71
78
 
79
+ // Seeds a site/building/level chain whose terrain field carries a sculpted
80
+ // patch raised to `liftTo` in the far corner — enough for ground drafts to be
81
+ // terrain chains, while the walls under test stand where the ground is 0.
82
+ function seedTerrainLevel(walls: WallNode[], liftTo: number) {
83
+ const field = createTerrainField({ cols: 9, rows: 9, spacing: 1, origin: [-4, -4] })
84
+ const patch = flattenPatch(field, { maxX: -2, maxZ: -2, minX: -4, minZ: -4 }, liftTo)
85
+ if (!patch) throw new Error('Expected terrain patch')
86
+ const terrain = applyHeightPatch(field, patch)
87
+ useScene.setState({
88
+ nodes: Object.fromEntries([
89
+ [
90
+ 'site_test',
91
+ {
92
+ id: 'site_test',
93
+ type: 'site',
94
+ object: 'node',
95
+ parentId: null,
96
+ visible: true,
97
+ metadata: {},
98
+ children: ['building_test'],
99
+ terrain: encodeTerrainField(terrain),
100
+ } as unknown as AnyNode,
101
+ ],
102
+ [
103
+ 'building_test',
104
+ {
105
+ id: 'building_test',
106
+ type: 'building',
107
+ object: 'node',
108
+ parentId: 'site_test',
109
+ visible: true,
110
+ metadata: {},
111
+ children: [LEVEL_ID],
112
+ position: [0, 0, 0],
113
+ rotation: [0, 0, 0],
114
+ } as AnyNode,
115
+ ],
116
+ [
117
+ LEVEL_ID,
118
+ {
119
+ id: LEVEL_ID,
120
+ type: 'level',
121
+ object: 'node',
122
+ parentId: 'building_test',
123
+ visible: true,
124
+ metadata: {},
125
+ children: walls.map((wall) => wall.id),
126
+ level: 0,
127
+ baseElevation: 0,
128
+ height: 2.5,
129
+ } as AnyNode,
130
+ ],
131
+ ...walls.map((wall) => [wall.id, wall] as const),
132
+ ]),
133
+ rootNodeIds: ['site_test' as AnyNodeId],
134
+ dirtyNodes: new Set(),
135
+ collections: {},
136
+ } as never)
137
+ }
138
+
72
139
  function levelWalls(): WallNode[] {
73
140
  return Object.values(useScene.getState().nodes).filter(
74
141
  (node): node is WallNode => node?.type === 'wall',
@@ -76,6 +143,15 @@ function levelWalls(): WallNode[] {
76
143
  }
77
144
 
78
145
  describe('createWallOnCurrentLevel', () => {
146
+ // Set by tests that mutate the process-wide node registry; restored here
147
+ // so the mutation can't leak into later test files (order-dependent flakes).
148
+ let restoreRegistry: (() => void) | undefined
149
+
150
+ afterEach(() => {
151
+ restoreRegistry?.()
152
+ restoreRegistry = undefined
153
+ })
154
+
79
155
  beforeEach(() => {
80
156
  useViewer.setState({
81
157
  selection: {
@@ -109,7 +185,9 @@ describe('createWallOnCurrentLevel', () => {
109
185
  expect(levelWalls()).toHaveLength(2)
110
186
  })
111
187
 
112
- test('committed wall preserves the ghost construction elevation on ground', () => {
188
+ test('committed wall preserves the ghost construction elevation on terrain', () => {
189
+ seedTerrainLevel([makeWall([0, 0], [4, 0], 'wall_a')], 1.75)
190
+
113
191
  const created = createWallOnCurrentLevel([2, 2], [3, 2], {
114
192
  supportCap: 1.75,
115
193
  preferredSupportSlabId: GROUND_SUPPORT_ID,
@@ -133,6 +211,374 @@ describe('createWallOnCurrentLevel', () => {
133
211
  expect(support.elevation).toBe(1.75)
134
212
  })
135
213
 
214
+ test('never exposes a raised wall at floor elevation during commit', () => {
215
+ const observed: WallNode[] = []
216
+ const unsubscribe = useScene.subscribe((state) => {
217
+ const wall = Object.values(state.nodes).find(
218
+ (node): node is WallNode => node?.type === 'wall' && node.id !== 'wall_a',
219
+ )
220
+ if (wall) observed.push(wall)
221
+ })
222
+
223
+ const created = createWallOnCurrentLevel([2, 2], [3, 2], {
224
+ supportCap: 1.75,
225
+ preferredSupportSlabId: GROUND_SUPPORT_ID,
226
+ constructionElevation: 1.75,
227
+ constructionHeight: 2.5,
228
+ flatConstructionBase: true,
229
+ })
230
+ unsubscribe()
231
+
232
+ expect(created).not.toBeNull()
233
+ expect(observed.length).toBeGreaterThan(0)
234
+ expect(observed.every((wall) => wall.supportSlabId === GROUND_SUPPORT_ID)).toBe(true)
235
+ expect(observed.every((wall) => wall.supportOffset === 1.75)).toBe(true)
236
+ })
237
+
238
+ test('keeps a node-top room plane flat across changing terrain', () => {
239
+ const field = createTerrainField({ cols: 5, rows: 5, spacing: 1, origin: [0, 0] })
240
+ const patch = flattenPatch(field, { minX: 3.5, minZ: 0, maxX: 4, maxZ: 4 }, 1)
241
+ if (!patch) throw new Error('Expected terrain patch')
242
+ const terrain = applyHeightPatch(field, patch)
243
+ const site = {
244
+ id: 'site_test',
245
+ type: 'site',
246
+ object: 'node',
247
+ parentId: null,
248
+ visible: true,
249
+ metadata: {},
250
+ children: ['building_test'],
251
+ terrain: encodeTerrainField(terrain),
252
+ } as unknown as AnyNode
253
+ const building = {
254
+ id: 'building_test',
255
+ type: 'building',
256
+ object: 'node',
257
+ parentId: site.id,
258
+ visible: true,
259
+ metadata: {},
260
+ children: [LEVEL_ID],
261
+ position: [0, 0, 0],
262
+ rotation: [0, 0, 0],
263
+ } as AnyNode
264
+ const level = {
265
+ id: LEVEL_ID,
266
+ type: 'level',
267
+ object: 'node',
268
+ parentId: building.id,
269
+ visible: true,
270
+ metadata: {},
271
+ children: [],
272
+ level: 0,
273
+ baseElevation: 0,
274
+ height: 3,
275
+ } as AnyNode
276
+ useScene.setState({
277
+ nodes: Object.fromEntries([site, building, level].map((node) => [node.id, node])),
278
+ rootNodeIds: [site.id],
279
+ dirtyNodes: new Set(),
280
+ } as never)
281
+
282
+ const lowTerrainWall = createWallOnCurrentLevel([0, 0], [1, 0], {
283
+ constructionElevation: 3,
284
+ constructionHeight: 2.5,
285
+ flatConstructionBase: true,
286
+ supportCap: 3,
287
+ })
288
+ const highTerrainWall = createWallOnCurrentLevel([4, 0], [4, 1], {
289
+ constructionElevation: 3,
290
+ constructionHeight: 2.5,
291
+ flatConstructionBase: true,
292
+ supportCap: 3,
293
+ })
294
+
295
+ expect(lowTerrainWall?.supportSlabId).toBe(GROUND_SUPPORT_ID)
296
+ expect(highTerrainWall?.supportSlabId).toBe(GROUND_SUPPORT_ID)
297
+ expect(lowTerrainWall?.supportOffset).toBeCloseTo(3)
298
+ expect(highTerrainWall?.supportOffset).toBeCloseTo(2)
299
+ expect(
300
+ spatialGridManager.getSlabSupportForWall(
301
+ LEVEL_ID,
302
+ lowTerrainWall!.start,
303
+ lowTerrainWall!.end,
304
+ lowTerrainWall!.curveOffset,
305
+ lowTerrainWall!.thickness,
306
+ lowTerrainWall!.supportSlabId,
307
+ undefined,
308
+ lowTerrainWall!.supportOffset,
309
+ ).elevation,
310
+ ).toBeCloseTo(3)
311
+ expect(
312
+ spatialGridManager.getSlabSupportForWall(
313
+ LEVEL_ID,
314
+ highTerrainWall!.start,
315
+ highTerrainWall!.end,
316
+ highTerrainWall!.curveOffset,
317
+ highTerrainWall!.thickness,
318
+ highTerrainWall!.supportSlabId,
319
+ undefined,
320
+ highTerrainWall!.supportOffset,
321
+ ).elevation,
322
+ ).toBeCloseTo(3)
323
+ })
324
+
325
+ test('pins an existing construction source before a generated room slab can lift it', () => {
326
+ // The reset + throwaway `block` registration is scoped to this test —
327
+ // the registry is a process-wide singleton, so leaking it would leave
328
+ // later test FILES with a stripped registry (order-dependent flakes).
329
+ restoreRegistry = nodeRegistry._snapshot()
330
+ nodeRegistry._reset()
331
+ spatialGridManager.clear()
332
+ registerNode({
333
+ kind: 'block',
334
+ schemaVersion: 2,
335
+ schema: BlockNode,
336
+ category: 'structure',
337
+ defaults: () => BlockNode.parse({ name: 'Block' }),
338
+ capabilities: {
339
+ floorPlaced: {
340
+ footprint: () => ({ dimensions: [4, 2.4, 4], rotation: [0, 0, 0] }),
341
+ },
342
+ },
343
+ } as never)
344
+
345
+ const slope = BlockNode.parse({
346
+ name: 'Existing slope',
347
+ parentId: LEVEL_ID,
348
+ position: [0, 0, 0],
349
+ })
350
+ seedLevel([makeWall([0, 0], [4, 0], 'wall_a')], [slope as AnyNode])
351
+
352
+ createWallOnCurrentLevel([0, 1], [1, 1], {
353
+ constructionElevation: 2.4,
354
+ constructionHeight: 2.5,
355
+ constructionSourceNodeId: slope.id,
356
+ flatConstructionBase: true,
357
+ supportCap: 2.4,
358
+ })
359
+
360
+ const pinnedSlope = useScene.getState().nodes[slope.id] as BlockNode
361
+ expect(pinnedSlope.supportSlabId).toBe(GROUND_SUPPORT_ID)
362
+
363
+ const generatedSlab = SlabNode.parse({
364
+ polygon: [
365
+ [-2, -2],
366
+ [2, -2],
367
+ [2, 2],
368
+ [-2, 2],
369
+ ],
370
+ elevation: 2.45,
371
+ autoFromWalls: true,
372
+ parentId: LEVEL_ID,
373
+ })
374
+ spatialGridManager.handleNodeCreated(generatedSlab as AnyNode, LEVEL_ID)
375
+
376
+ expect(
377
+ getFloorPlacedElevation({
378
+ node: pinnedSlope,
379
+ nodes: {
380
+ ...useScene.getState().nodes,
381
+ [generatedSlab.id]: generatedSlab as AnyNode,
382
+ },
383
+ position: pinnedSlope.position,
384
+ rotation: [0, pinnedSlope.rotation, 0],
385
+ }),
386
+ ).toBe(0)
387
+ })
388
+
389
+ test('a flat-ground draft (no sculpted terrain) commits plane-bound', () => {
390
+ // Pointing at bare ground freezes a GROUND construction plane at 0. With
391
+ // no terrain field in the scene that plane is just the backdrop — none of
392
+ // the draft options may reach the committed node: no stamped height, no
393
+ // persisted ground host (a slab drawn later must lift the wall), no
394
+ // election cap.
395
+ const created = createWallOnCurrentLevel([2, 2], [3, 2], {
396
+ supportCap: 0,
397
+ preferredSupportSlabId: GROUND_SUPPORT_ID,
398
+ constructionElevation: 0,
399
+ constructionHeight: 2.5,
400
+ })
401
+
402
+ expect(created).not.toBeNull()
403
+ expect(created?.height).toBeUndefined()
404
+ expect(created?.supportOffset).toBeUndefined()
405
+ expect(created?.supportSlabId).toBeUndefined()
406
+ })
407
+
408
+ test('a wall started on a slab stays plane-bound (no stamped height or offset)', () => {
409
+ const slab = SlabNode.parse({
410
+ id: 'slab_floor',
411
+ parentId: LEVEL_ID,
412
+ polygon: [
413
+ [-1, -1],
414
+ [5, -1],
415
+ [5, 5],
416
+ [-1, 5],
417
+ ],
418
+ elevation: 0.05,
419
+ thickness: 0.05,
420
+ })
421
+ seedLevel([makeWall([0, 0], [4, 0], 'wall_a')], [slab])
422
+
423
+ // Mirrors the 3D tool's first click on the slab top: frozen plane at the
424
+ // slab elevation, ghost drawn at the level height. None of it may reach
425
+ // the committed node — plane-bound is the default.
426
+ const created = createWallOnCurrentLevel([2, 2], [3, 2], {
427
+ supportCap: 0.05,
428
+ preferredSupportSlabId: slab.id,
429
+ constructionElevation: 0.05,
430
+ constructionHeight: 2.55,
431
+ })
432
+
433
+ expect(created).not.toBeNull()
434
+ expect(created?.height).toBeUndefined()
435
+ expect(created?.supportOffset).toBeUndefined()
436
+ expect(created?.supportSlabId).not.toBe(GROUND_SUPPORT_ID)
437
+ })
438
+
439
+ test('a fresh-scene wall started on a slab node-top elects that slab plane-bound', () => {
440
+ const slab = SlabNode.parse({
441
+ id: 'slab_fresh_floor',
442
+ parentId: LEVEL_ID,
443
+ polygon: [
444
+ [-1, -1],
445
+ [5, -1],
446
+ [5, 5],
447
+ [-1, 5],
448
+ ],
449
+ elevation: 0.05,
450
+ thickness: 0.05,
451
+ })
452
+ seedLevel([], [slab])
453
+ spatialGridManager.clear()
454
+ spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID)
455
+
456
+ const created = createWallOnCurrentLevel([2, 2], [3, 2], {
457
+ supportCap: 0.05,
458
+ preferredSupportSlabId: null,
459
+ constructionElevation: 0.05,
460
+ constructionHeight: 2.5,
461
+ constructionSourceNodeId: slab.id,
462
+ flatConstructionBase: true,
463
+ })
464
+
465
+ expect(created).not.toBeNull()
466
+ expect(created?.supportSlabId).toBeUndefined()
467
+ expect(created?.height).toBeUndefined()
468
+ expect(created?.supportOffset).toBeUndefined()
469
+ const support = spatialGridManager.getSlabSupportForWall(
470
+ LEVEL_ID,
471
+ created!.start,
472
+ created!.end,
473
+ created!.curveOffset,
474
+ created!.thickness,
475
+ created!.supportSlabId,
476
+ )
477
+ expect(support.electedSlabId).toBe(slab.id)
478
+ expect(support.elevation).toBeCloseTo(0.05)
479
+ })
480
+
481
+ test('a direct slab source does not pin a cross-slab wall away from the higher majority support', () => {
482
+ const sourceSlab = SlabNode.parse({
483
+ id: 'slab_source_low',
484
+ parentId: LEVEL_ID,
485
+ polygon: [
486
+ [-0.1, -1],
487
+ [0.1, -1],
488
+ [0.1, 1],
489
+ [-0.1, 1],
490
+ ],
491
+ elevation: 0.1,
492
+ thickness: 0.05,
493
+ })
494
+ const majoritySlab = SlabNode.parse({
495
+ id: 'slab_majority_high',
496
+ parentId: LEVEL_ID,
497
+ polygon: [
498
+ [0.1, -1],
499
+ [4.1, -1],
500
+ [4.1, 1],
501
+ [0.1, 1],
502
+ ],
503
+ elevation: 0.6,
504
+ thickness: 0.1,
505
+ })
506
+ seedLevel([], [sourceSlab, majoritySlab])
507
+ spatialGridManager.clear()
508
+ spatialGridManager.handleNodeCreated(sourceSlab as AnyNode, LEVEL_ID)
509
+ spatialGridManager.handleNodeCreated(majoritySlab as AnyNode, LEVEL_ID)
510
+
511
+ const created = createWallOnCurrentLevel([0, 0], [4, 0], {
512
+ supportCap: 0.6,
513
+ preferredSupportSlabId: null,
514
+ constructionElevation: 0.1,
515
+ constructionHeight: 2.5,
516
+ constructionSourceNodeId: sourceSlab.id,
517
+ flatConstructionBase: true,
518
+ })
519
+
520
+ expect(created?.supportSlabId).toBe(majoritySlab.id)
521
+ expect(created?.height).toBeUndefined()
522
+ expect(created?.supportOffset).toBeUndefined()
523
+ })
524
+
525
+ test('a grazing direct slab source does not override the commit elevation cap', () => {
526
+ const sourceSlab = SlabNode.parse({
527
+ id: 'slab_source_high',
528
+ parentId: LEVEL_ID,
529
+ polygon: [
530
+ [-0.1, -1],
531
+ [0.1, -1],
532
+ [0.1, 1],
533
+ [-0.1, 1],
534
+ ],
535
+ elevation: 0.6,
536
+ thickness: 0.1,
537
+ })
538
+ const cappedSlab = SlabNode.parse({
539
+ id: 'slab_capped_low',
540
+ parentId: LEVEL_ID,
541
+ polygon: [
542
+ [-0.1, -1],
543
+ [4.1, -1],
544
+ [4.1, 1],
545
+ [-0.1, 1],
546
+ ],
547
+ elevation: 0.1,
548
+ thickness: 0.05,
549
+ })
550
+ seedLevel([], [sourceSlab, cappedSlab])
551
+ spatialGridManager.clear()
552
+ spatialGridManager.handleNodeCreated(sourceSlab as AnyNode, LEVEL_ID)
553
+ spatialGridManager.handleNodeCreated(cappedSlab as AnyNode, LEVEL_ID)
554
+
555
+ const created = createWallOnCurrentLevel([0, 0], [4, 0], {
556
+ supportCap: 0.1,
557
+ preferredSupportSlabId: null,
558
+ constructionElevation: 0.6,
559
+ constructionHeight: 2.5,
560
+ constructionSourceNodeId: sourceSlab.id,
561
+ flatConstructionBase: true,
562
+ })
563
+
564
+ expect(created?.supportSlabId).toBe(cappedSlab.id)
565
+ expect(created?.height).toBeUndefined()
566
+ expect(created?.supportOffset).toBeUndefined()
567
+ })
568
+
569
+ test('a non-ground draft never freezes the ghost height, even at a raised plane', () => {
570
+ const created = createWallOnCurrentLevel([2, 2], [3, 2], {
571
+ supportCap: 1.2,
572
+ preferredSupportSlabId: null,
573
+ constructionElevation: 1.2,
574
+ constructionHeight: 2.5,
575
+ })
576
+
577
+ expect(created).not.toBeNull()
578
+ expect(created?.height).toBeUndefined()
579
+ expect(created?.supportOffset).toBeUndefined()
580
+ })
581
+
136
582
  test('2D terrain construction options freeze the first-point elevation and wall height', () => {
137
583
  const field = createTerrainField({ cols: 5, rows: 5, spacing: 1, origin: [-2, -2] })
138
584
  const patch = flattenPatch(field, { minX: -2, minZ: -2, maxX: 2, maxZ: 2 }, 1.5)
@@ -168,6 +614,7 @@ describe('createWallOnCurrentLevel', () => {
168
614
  metadata: {},
169
615
  children: [],
170
616
  level: 0,
617
+ baseElevation: 0,
171
618
  height: 3,
172
619
  } as AnyNode
173
620
  const nodes = Object.fromEntries([site, building, level].map((node) => [node.id, node]))
@@ -261,6 +708,82 @@ describe('createWallOnCurrentLevel', () => {
261
708
  expect(created).not.toBeNull()
262
709
  expect(useScene.temporal.getState().pastStates.length - before).toBe(1)
263
710
  })
711
+
712
+ test('a crossing splits both the host and the inserted wall in one undo step', () => {
713
+ const before = useScene.temporal.getState().pastStates.length
714
+
715
+ const created = createWallOnCurrentLevel([2, -2], [2, 2])
716
+
717
+ expect(created?.start).toEqual([2, 0])
718
+ expect(created?.end).toEqual([2, 2])
719
+ expect(useScene.getState().nodes['wall_a' as AnyNodeId]).toBeUndefined()
720
+ expect(levelWalls()).toHaveLength(4)
721
+ expect(useScene.temporal.getState().pastStates.length - before).toBe(1)
722
+ })
723
+
724
+ test('a room divider splits customized auto slabs and ceilings', () => {
725
+ const walls = [
726
+ makeWall([0, 0], [8, 0], 'wall_bottom'),
727
+ makeWall([8, 0], [8, 6], 'wall_right'),
728
+ makeWall([8, 6], [0, 6], 'wall_top'),
729
+ makeWall([0, 6], [0, 0], 'wall_left'),
730
+ ]
731
+ const slab = SlabNode.parse({
732
+ id: 'slab_auto',
733
+ parentId: LEVEL_ID,
734
+ polygon: [
735
+ [0, 0],
736
+ [8, 0],
737
+ [8, 6],
738
+ [0, 6],
739
+ ],
740
+ elevation: 0.18,
741
+ thickness: 0.32,
742
+ autoFromWalls: true,
743
+ })
744
+ const ceiling = CeilingNode.parse({
745
+ id: 'ceiling_auto',
746
+ parentId: LEVEL_ID,
747
+ polygon: slab.polygon,
748
+ autoFromWalls: true,
749
+ })
750
+ seedLevel(walls, [slab, ceiling])
751
+ const stopDetection = initSpaceDetectionSync(useScene, useEditor)
752
+
753
+ try {
754
+ expect(createWallOnCurrentLevel([4, 0], [4, 6])).not.toBeNull()
755
+
756
+ const nodes = Object.values(useScene.getState().nodes)
757
+ const slabs = nodes.filter((node) => node.type === 'slab')
758
+ const ceilings = nodes.filter((node) => node.type === 'ceiling')
759
+ expect(slabs).toHaveLength(2)
760
+ expect(ceilings).toHaveLength(2)
761
+ expect(
762
+ slabs.every(
763
+ (node) => node.autoFromWalls && node.elevation === 0.18 && node.thickness === 0.32,
764
+ ),
765
+ ).toBe(true)
766
+ expect(ceilings.every((node) => node.autoFromWalls)).toBe(true)
767
+ } finally {
768
+ stopDetection()
769
+ }
770
+ })
771
+
772
+ test('close crossings reject the whole insertion without mutating the scene', () => {
773
+ seedLevel([
774
+ makeWall([2, -2], [2, 2], 'wall_close_a'),
775
+ makeWall([2.0055, -2], [2.0055, 2], 'wall_close_b'),
776
+ ])
777
+ useScene.temporal.getState().clear()
778
+ const beforeNodes = useScene.getState().nodes
779
+
780
+ const created = createWallOnCurrentLevel([0, 0], [4, 0])
781
+
782
+ expect(created).toBeNull()
783
+ expect(useScene.getState().nodes).toBe(beforeNodes)
784
+ expect(levelWalls()).toHaveLength(2)
785
+ expect(useScene.temporal.getState().pastStates).toHaveLength(0)
786
+ })
264
787
  })
265
788
 
266
789
  describe('resolveEndpointWallSplit', () => {