@pascal-app/editor 0.8.0 → 0.9.2

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 (350) hide show
  1. package/package.json +15 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +168 -0
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +795 -28
  5. package/src/components/editor/editor-layout-mobile.tsx +8 -3
  6. package/src/components/editor/editor-layout-v2.tsx +76 -51
  7. package/src/components/editor/export-manager.tsx +69 -32
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +182 -0
  10. package/src/components/editor/first-person/build-collider-world.ts +228 -83
  11. package/src/components/editor/first-person-controls.tsx +943 -27
  12. package/src/components/editor/floating-action-menu.tsx +676 -208
  13. package/src/components/editor/floating-building-action-menu.tsx +2 -2
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +6916 -12137
  17. package/src/components/editor/grid.tsx +197 -36
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +420 -0
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +396 -0
  24. package/src/components/editor/group-transform-shared.ts +489 -0
  25. package/src/components/editor/handles/handle-arrow.tsx +581 -0
  26. package/src/components/editor/handles/use-handle-drag.ts +246 -0
  27. package/src/components/editor/index.tsx +412 -162
  28. package/src/components/editor/measurement-pill.tsx +115 -0
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +1646 -0
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +1106 -483
  36. package/src/components/editor/site-edge-labels.tsx +45 -21
  37. package/src/components/editor/slab-hole-highlights.tsx +474 -0
  38. package/src/components/editor/snapshot-capture-overlay.tsx +369 -155
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +140 -41
  41. package/src/components/editor/use-floorplan-background-placement.ts +190 -35
  42. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  43. package/src/components/editor/use-floorplan-scene-data.ts +19 -7
  44. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  45. package/src/components/editor/wall-measurement-label.tsx +14 -19
  46. package/src/components/editor/wall-move-side-handles.tsx +870 -0
  47. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  48. package/src/components/editor/wall-snap-beacon-layer.tsx +304 -0
  49. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +15 -6
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  52. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  53. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  54. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  56. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +392 -0
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +857 -0
  59. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  60. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +77 -0
  61. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  62. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  63. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  64. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  65. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  66. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +62 -0
  67. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  68. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +3325 -0
  69. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  70. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  71. package/src/components/editor-2d/svg-paths.ts +10 -2
  72. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +439 -57
  73. package/src/components/systems/ceiling/ceiling-system.tsx +109 -12
  74. package/src/components/systems/roof/roof-edit-system.tsx +1851 -22
  75. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  76. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  77. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  78. package/src/components/systems/zone/zone-system.tsx +42 -13
  79. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  80. package/src/components/tools/elevator/elevator-tool.tsx +375 -0
  81. package/src/components/tools/elevator/move-elevator-tool.tsx +263 -0
  82. package/src/components/tools/fence/fence-drafting.ts +141 -27
  83. package/src/components/tools/item/move-tool.tsx +45 -105
  84. package/src/components/tools/item/placement-math.test.ts +22 -0
  85. package/src/components/tools/item/placement-math.ts +59 -7
  86. package/src/components/tools/item/placement-strategies.ts +432 -16
  87. package/src/components/tools/item/placement-types.ts +22 -1
  88. package/src/components/tools/item/use-draft-node.ts +51 -2
  89. package/src/components/tools/item/use-placement-coordinator.tsx +1399 -256
  90. package/src/components/tools/registry/move-registry-node-tool.tsx +1064 -0
  91. package/src/components/tools/roof/roof-tool.tsx +350 -28
  92. package/src/components/tools/select/box-select-state.ts +88 -0
  93. package/src/components/tools/select/box-select-tool.tsx +442 -520
  94. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  95. package/src/components/tools/select/marquee-geometry.ts +155 -0
  96. package/src/components/tools/select/plane-box-select-tool.tsx +570 -0
  97. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  98. package/src/components/tools/select/select-candidates.test.ts +109 -0
  99. package/src/components/tools/select/select-candidates.ts +85 -0
  100. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  101. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  102. package/src/components/tools/shared/drag-bounding-box.tsx +195 -0
  103. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  104. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  105. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  106. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  107. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  108. package/src/components/tools/shared/placement-box.tsx +305 -0
  109. package/src/components/tools/shared/polygon-editor.tsx +866 -175
  110. package/src/components/tools/shared/segment-angle.ts +56 -0
  111. package/src/components/tools/site/site-boundary-editor.tsx +520 -16
  112. package/src/components/tools/site/site-flag-model.ts +18 -0
  113. package/src/components/tools/stair/stair-defaults.ts +2 -1
  114. package/src/components/tools/stair/stair-tool.tsx +342 -37
  115. package/src/components/tools/tool-manager.tsx +239 -62
  116. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  117. package/src/components/tools/wall/wall-drafting.ts +248 -171
  118. package/src/components/tools/wall/wall-snap-geometry.test.ts +123 -0
  119. package/src/components/tools/wall/wall-snap-geometry.ts +331 -0
  120. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  121. package/src/components/tools/zone/zone-tool.tsx +86 -82
  122. package/src/components/ui/action-menu/action-button.tsx +21 -1
  123. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  124. package/src/components/ui/action-menu/control-modes.tsx +40 -170
  125. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  126. package/src/components/ui/action-menu/index.tsx +3 -117
  127. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  128. package/src/components/ui/action-menu/structure-tools.tsx +27 -93
  129. package/src/components/ui/action-menu/view-toggles.tsx +279 -90
  130. package/src/components/ui/command-palette/editor-commands.tsx +20 -15
  131. package/src/components/ui/command-palette/index.tsx +5 -3
  132. package/src/components/ui/controls/material-paint-panel.tsx +152 -0
  133. package/src/components/ui/controls/material-picker.tsx +87 -177
  134. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  135. package/src/components/ui/controls/metric-control.tsx +142 -46
  136. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  137. package/src/components/ui/controls/slider-control.tsx +88 -38
  138. package/src/components/ui/floating-level-selector.tsx +47 -10
  139. package/src/components/ui/helpers/building-helper.tsx +10 -23
  140. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  141. package/src/components/ui/helpers/helper-manager.tsx +235 -23
  142. package/src/components/ui/helpers/item-helper.tsx +28 -32
  143. package/src/components/ui/helpers/registered-tool-helper.tsx +59 -0
  144. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  145. package/src/components/ui/icon-ref.tsx +47 -0
  146. package/src/components/ui/item-catalog/catalog-items.tsx +40 -1
  147. package/src/components/ui/item-catalog/item-catalog.tsx +16 -36
  148. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  149. package/src/components/ui/panels/node-display.ts +17 -16
  150. package/src/components/ui/panels/panel-manager.tsx +55 -58
  151. package/src/components/ui/panels/panel-wrapper.tsx +238 -12
  152. package/src/components/ui/panels/parametric-inspector.tsx +482 -0
  153. package/src/components/ui/panels/reference-panel.tsx +54 -19
  154. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  155. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  156. package/src/components/ui/primitives/sidebar.tsx +1 -0
  157. package/src/components/ui/scene-loader.tsx +1 -3
  158. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  159. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  160. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  161. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  162. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  163. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +122 -41
  164. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +66 -7
  165. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +267 -0
  166. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  167. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +9 -5
  168. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +86 -0
  169. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  170. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  171. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +92 -0
  172. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  173. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +3 -3
  174. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +86 -0
  175. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -21
  176. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  177. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  178. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +140 -0
  179. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +48 -8
  180. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +132 -0
  181. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -5
  182. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +86 -0
  183. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  184. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  185. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +112 -39
  186. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  187. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  188. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  189. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  190. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  191. package/src/components/ui/sidebar/tab-bar.tsx +75 -1
  192. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  193. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  194. package/src/components/ui/snap-target-badge.tsx +88 -0
  195. package/src/components/viewer-overlay.tsx +253 -61
  196. package/src/components/viewer-zone-system.tsx +7 -2
  197. package/src/hooks/use-auto-save.test.ts +14 -0
  198. package/src/hooks/use-auto-save.ts +64 -10
  199. package/src/hooks/use-ceiling-events.ts +176 -0
  200. package/src/hooks/use-drag-action.ts +127 -0
  201. package/src/hooks/use-keyboard.ts +493 -95
  202. package/src/hooks/use-selection.ts +64 -0
  203. package/src/index.tsx +451 -6
  204. package/src/lib/active-placement-surface.ts +35 -0
  205. package/src/lib/ceiling-plan-snap.ts +24 -0
  206. package/src/lib/constants.ts +6 -2
  207. package/src/lib/contextual-help.test.ts +112 -0
  208. package/src/lib/contextual-help.ts +144 -0
  209. package/src/lib/continuation.ts +64 -0
  210. package/src/lib/direct-manipulation.test.ts +192 -0
  211. package/src/lib/direct-manipulation.ts +109 -0
  212. package/src/lib/editor-api.ts +50 -0
  213. package/src/lib/elevator-support.ts +96 -0
  214. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  215. package/src/lib/floorplan/apply-alignment.ts +117 -0
  216. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  217. package/src/lib/floorplan/geometry.ts +53 -0
  218. package/src/lib/floorplan/index.ts +10 -0
  219. package/src/lib/floorplan/items.ts +5 -2
  220. package/src/lib/floorplan/plan-coords.ts +21 -0
  221. package/src/lib/floorplan/selection-tool.ts +40 -0
  222. package/src/lib/floorplan/stairs.ts +12 -1
  223. package/src/lib/fresh-planar-placement.test.ts +339 -0
  224. package/src/lib/fresh-planar-placement.ts +130 -0
  225. package/src/lib/glb-export.test.ts +441 -0
  226. package/src/lib/glb-export.ts +874 -0
  227. package/src/lib/history.ts +11 -1
  228. package/src/lib/interaction/hot-set.test.ts +133 -0
  229. package/src/lib/interaction/hot-set.ts +67 -0
  230. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  231. package/src/lib/interaction/overlay-policy.ts +59 -0
  232. package/src/lib/interaction/scope.ts +211 -0
  233. package/src/lib/level-duplication.ts +54 -42
  234. package/src/lib/level-selection.ts +2 -2
  235. package/src/lib/material-paint.ts +227 -48
  236. package/src/lib/measurement-kind.test.ts +30 -0
  237. package/src/lib/measurement-kind.ts +19 -0
  238. package/src/lib/measurement-label.test.ts +47 -0
  239. package/src/lib/measurement-label.ts +68 -0
  240. package/src/lib/measurement-parser.ts +116 -0
  241. package/src/lib/measurements.test.ts +183 -0
  242. package/src/lib/measurements.ts +199 -0
  243. package/src/lib/paint-scope.test.ts +454 -0
  244. package/src/lib/paint-scope.ts +461 -0
  245. package/src/lib/placement-drag-release.ts +23 -0
  246. package/src/lib/placement-metadata.ts +29 -0
  247. package/src/lib/planar-cursor-placement.test.ts +100 -0
  248. package/src/lib/planar-cursor-placement.ts +42 -0
  249. package/src/lib/plugin-panels.test.ts +19 -0
  250. package/src/lib/plugin-panels.ts +91 -0
  251. package/src/lib/quick-measurement.test.ts +77 -0
  252. package/src/lib/quick-measurement.ts +109 -0
  253. package/src/lib/roof-duplication.ts +7 -7
  254. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  255. package/src/lib/roof-wall-hit.ts +164 -0
  256. package/src/lib/scene-clipboard.test.ts +196 -0
  257. package/src/lib/scene-clipboard.ts +332 -0
  258. package/src/lib/scene.ts +40 -8
  259. package/src/lib/selection-routing.test.ts +298 -0
  260. package/src/lib/selection-routing.ts +175 -0
  261. package/src/lib/sfx/index.ts +1 -0
  262. package/src/lib/sfx/movement-tick.test.ts +65 -0
  263. package/src/lib/sfx/movement-tick.ts +18 -0
  264. package/src/lib/sfx-bus.ts +69 -10
  265. package/src/lib/sfx-player.test.ts +124 -0
  266. package/src/lib/sfx-player.ts +148 -55
  267. package/src/lib/site-boundary.ts +1 -0
  268. package/src/lib/slab-plan-snap.test.ts +93 -0
  269. package/src/lib/slab-plan-snap.ts +108 -0
  270. package/src/lib/snapping-mode.test.ts +138 -0
  271. package/src/lib/snapping-mode.ts +179 -0
  272. package/src/lib/stair-duplication.ts +4 -4
  273. package/src/lib/stair-levels.test.ts +146 -0
  274. package/src/lib/stair-levels.ts +177 -0
  275. package/src/lib/surface-plan-snap.test.ts +71 -0
  276. package/src/lib/surface-plan-snap.ts +256 -0
  277. package/src/lib/use-linear-display.ts +40 -0
  278. package/src/lib/world-grid-snap.ts +231 -0
  279. package/src/lib/zone-content.ts +130 -0
  280. package/src/store/use-alignment-guides.ts +21 -0
  281. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  282. package/src/store/use-editor.tsx +767 -114
  283. package/src/store/use-facing-pose.ts +44 -0
  284. package/src/store/use-fence-curve-draft.ts +21 -0
  285. package/src/store/use-floorplan-draft-preview.ts +101 -0
  286. package/src/store/use-floorplan-marquee.ts +50 -0
  287. package/src/store/use-interaction-scope.test.ts +186 -0
  288. package/src/store/use-interaction-scope.ts +132 -0
  289. package/src/store/use-measurement-draft.test.ts +530 -0
  290. package/src/store/use-measurement-draft.ts +543 -0
  291. package/src/store/use-opening-guides.ts +41 -0
  292. package/src/store/use-placement-preview.ts +39 -0
  293. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  294. package/src/store/use-quick-measurement-hud.ts +77 -0
  295. package/src/store/use-segment-draft-chain.ts +28 -0
  296. package/src/store/use-stair-build-preview.ts +43 -0
  297. package/src/store/use-wall-move-ghosts.ts +36 -0
  298. package/src/store/use-wall-snap-indicator.ts +34 -0
  299. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  300. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  301. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  302. package/src/components/tools/building/move-building-tool.tsx +0 -157
  303. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  304. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  305. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  306. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  307. package/src/components/tools/column/column-tool.tsx +0 -97
  308. package/src/components/tools/column/move-column-tool.tsx +0 -105
  309. package/src/components/tools/door/door-math.ts +0 -110
  310. package/src/components/tools/door/door-tool.tsx +0 -324
  311. package/src/components/tools/door/move-door-tool.tsx +0 -412
  312. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  313. package/src/components/tools/fence/fence-tool.tsx +0 -346
  314. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  315. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  316. package/src/components/tools/item/item-tool.tsx +0 -26
  317. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  318. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  319. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  320. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  321. package/src/components/tools/slab/slab-tool.tsx +0 -322
  322. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  323. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  324. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  325. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  326. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  327. package/src/components/tools/wall/wall-tool.tsx +0 -332
  328. package/src/components/tools/window/move-window-tool.tsx +0 -447
  329. package/src/components/tools/window/window-math.ts +0 -117
  330. package/src/components/tools/window/window-tool.tsx +0 -332
  331. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  332. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  333. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  334. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  335. package/src/components/ui/panels/column-panel.tsx +0 -759
  336. package/src/components/ui/panels/door-panel.tsx +0 -1299
  337. package/src/components/ui/panels/fence-panel.tsx +0 -222
  338. package/src/components/ui/panels/item-panel.tsx +0 -306
  339. package/src/components/ui/panels/paint-panel.tsx +0 -163
  340. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  341. package/src/components/ui/panels/roof-panel.tsx +0 -280
  342. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  343. package/src/components/ui/panels/slab-panel.tsx +0 -250
  344. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  345. package/src/components/ui/panels/stair-panel.tsx +0 -577
  346. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  347. package/src/components/ui/panels/wall-panel.tsx +0 -273
  348. package/src/components/ui/panels/window-panel.tsx +0 -968
  349. package/src/contexts/presets-context.tsx +0 -121
  350. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -0,0 +1,138 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import {
3
+ cycleSnappingModeIn,
4
+ DEFAULT_SNAPPING_MODE,
5
+ defaultSnappingModeFor,
6
+ nextSnappingMode,
7
+ resolveSnapFlags,
8
+ SNAPPING_MODES,
9
+ snapContextOf,
10
+ snappingModesFor,
11
+ } from './snapping-mode'
12
+
13
+ describe('resolveSnapFlags', () => {
14
+ it('default mode is grid', () => {
15
+ expect(DEFAULT_SNAPPING_MODE).toBe('grid')
16
+ })
17
+
18
+ it("modes are exclusive: 'grid' snaps to the lattice only", () => {
19
+ expect(resolveSnapFlags('grid')).toEqual({ grid: true, magnetic: false, angles: false })
20
+ })
21
+
22
+ it("'off' disables grid, magnetic, and angles", () => {
23
+ expect(resolveSnapFlags('off')).toEqual({ grid: false, magnetic: false, angles: false })
24
+ })
25
+
26
+ it("'lines' keeps magnetic but drops the grid lattice and angle lock", () => {
27
+ expect(resolveSnapFlags('lines')).toEqual({ grid: false, magnetic: true, angles: false })
28
+ })
29
+
30
+ it("'angles' keeps the angle lock but drops grid and magnetic", () => {
31
+ expect(resolveSnapFlags('angles')).toEqual({ grid: false, magnetic: false, angles: true })
32
+ })
33
+
34
+ it("'lines' and 'angles' are distinct", () => {
35
+ expect(resolveSnapFlags('lines')).not.toEqual(resolveSnapFlags('angles'))
36
+ })
37
+
38
+ it('cycles through every mode and wraps', () => {
39
+ const seen = [DEFAULT_SNAPPING_MODE]
40
+ let mode = DEFAULT_SNAPPING_MODE
41
+ for (let i = 0; i < SNAPPING_MODES.length - 1; i += 1) {
42
+ mode = nextSnappingMode(mode)
43
+ seen.push(mode)
44
+ }
45
+ expect(seen).toEqual(SNAPPING_MODES)
46
+ expect(nextSnappingMode(mode)).toBe(DEFAULT_SNAPPING_MODE)
47
+ })
48
+ })
49
+
50
+ describe('per-context snapping', () => {
51
+ it('items default to grid with no angle lock', () => {
52
+ expect(defaultSnappingModeFor('item')).toBe('grid')
53
+ expect(snappingModesFor('item')).toEqual(['lines', 'grid', 'off'])
54
+ expect(snappingModesFor('item')).not.toContain('angles')
55
+ })
56
+
57
+ it('walls default to grid and expose the angle lock; polygons do NOT', () => {
58
+ expect(defaultSnappingModeFor('wall')).toBe('grid')
59
+ expect(defaultSnappingModeFor('polygon')).toBe('grid')
60
+ expect(snappingModesFor('wall')).toContain('angles')
61
+ // Angle lock is wall/fence-only — slabs, curves and translates never get it.
62
+ expect(snappingModesFor('polygon')).not.toContain('angles')
63
+ expect(snappingModesFor('polygon')).toEqual(['grid', 'lines', 'off'])
64
+ })
65
+
66
+ it('cycles within the context set and clamps a foreign value', () => {
67
+ expect(cycleSnappingModeIn('item', 'lines')).toBe('grid')
68
+ expect(cycleSnappingModeIn('item', 'off')).toBe('lines')
69
+ // 'angles' isn't an item mode → restart at the first entry
70
+ expect(cycleSnappingModeIn('item', 'angles')).toBe('lines')
71
+ })
72
+ })
73
+
74
+ describe('snapContextOf (profile-driven, node-declared)', () => {
75
+ // Stands in for the registry's declared `def.snapProfile` (the only per-kind
76
+ // data) — the resolver itself has no kind switch.
77
+ const declared: Record<string, 'item' | 'structural'> = {
78
+ wall: 'structural',
79
+ fence: 'structural',
80
+ item: 'item',
81
+ slab: 'structural',
82
+ ceiling: 'structural',
83
+ roof: 'structural',
84
+ zone: 'structural',
85
+ }
86
+ const profileOf = (t: string) => declared[t]
87
+ const ctx = (
88
+ scope: { kind: string; nodeType?: string; reshape?: string; tool?: string },
89
+ mode = 'select',
90
+ tool: string | null = null,
91
+ ) => snapContextOf({ scope, mode, tool, profileOf })
92
+
93
+ it('translating a whole structural node has no angle (polygon, not wall)', () => {
94
+ expect(ctx({ kind: 'moving', nodeType: 'wall' })).toBe('polygon')
95
+ expect(ctx({ kind: 'moving', nodeType: 'slab' })).toBe('polygon')
96
+ expect(ctx({ kind: 'placing', nodeType: 'item' }, 'build', 'item')).toBe('item')
97
+ })
98
+
99
+ it('endpoint reshape is angle-bearing (wall); curve + polygon vertex edits are not', () => {
100
+ expect(ctx({ kind: 'reshaping', reshape: 'endpoint' })).toBe('wall')
101
+ expect(ctx({ kind: 'reshaping', reshape: 'curve' })).toBe('polygon')
102
+ expect(ctx({ kind: 'reshaping', reshape: 'boundary' })).toBe('polygon')
103
+ expect(ctx({ kind: 'reshaping', reshape: 'hole' })).toBe('polygon')
104
+ })
105
+
106
+ it('drafting a structural kind (wall OR slab) is angle-bearing (wall)', () => {
107
+ expect(ctx({ kind: 'idle' }, 'build', 'wall')).toBe('wall')
108
+ expect(ctx({ kind: 'idle' }, 'build', 'slab')).toBe('wall')
109
+ expect(ctx({ kind: 'idle' }, 'build', 'item')).toBe('item')
110
+ expect(ctx({ kind: 'idle' }, 'select', null)).toBeNull()
111
+ })
112
+
113
+ it('an undeclared kind (no snapProfile) gets no snap context', () => {
114
+ expect(ctx({ kind: 'moving', nodeType: 'door' })).toBeNull()
115
+ expect(ctx({ kind: 'idle' }, 'build', 'shelf')).toBeNull()
116
+ })
117
+
118
+ it('drafting a non-directional structural kind is angle-less (polygon, not wall)', () => {
119
+ // Roof / stair / elevator are placed as footprints, not directional draws →
120
+ // declared `snapDraftDirectional: false`, so their draft context drops the
121
+ // angle-lock mode. Directional structural kinds (no flag) stay `wall`.
122
+ const draftDirectionalOf = (t: string) => t !== 'roof'
123
+ const draftCtx = (tool: string) =>
124
+ snapContextOf({ scope: { kind: 'idle' }, mode: 'build', tool, profileOf, draftDirectionalOf })
125
+ expect(draftCtx('roof')).toBe('polygon')
126
+ expect(draftCtx('wall')).toBe('wall')
127
+ // Also via the explicit `drafting` scope path.
128
+ expect(
129
+ snapContextOf({
130
+ scope: { kind: 'drafting', tool: 'roof' },
131
+ mode: 'build',
132
+ tool: 'roof',
133
+ profileOf,
134
+ draftDirectionalOf,
135
+ }),
136
+ ).toBe('polygon')
137
+ })
138
+ })
@@ -0,0 +1,179 @@
1
+ import type { SnapProfile } from '@pascal-app/core'
2
+ import { GROUP_MOVE_DRAG_LABEL } from './contextual-help'
3
+
4
+ /**
5
+ * Snapping mode is a single global, user-cyclable control that maps onto the
6
+ * two pre-existing snap knobs (`gridSnapStep` grid snap + `magneticSnap`).
7
+ * The default `'grid'` resolves to the exact pair the editor shipped with
8
+ * before this control existed (grid on, magnetic on), so the default path is
9
+ * behaviourally unchanged — only when a user opts into `'lines'` or `'off'`
10
+ * does any snap math get suppressed.
11
+ */
12
+ export type SnappingMode = 'grid' | 'lines' | 'angles' | 'off'
13
+
14
+ export const SNAPPING_MODES: SnappingMode[] = ['grid', 'lines', 'angles', 'off']
15
+
16
+ export const DEFAULT_SNAPPING_MODE: SnappingMode = 'grid'
17
+
18
+ export type SnapFlags = {
19
+ grid: boolean
20
+ magnetic: boolean
21
+ angles: boolean
22
+ }
23
+
24
+ /**
25
+ * Pure mapping from the mode enum onto the individual snap knobs. Modes are
26
+ * EXCLUSIVE — each does exactly what its chip label says, one guide at a time,
27
+ * so the HUD is honest:
28
+ *
29
+ * - `grid` → grid lattice only.
30
+ * - `lines` → magnetic only: alignment axes + wall corner-join (connectivity
31
+ * is part of the "lines" magnetic snap, not a separate always-on behaviour).
32
+ * - `angles` → angle lock only (15°/45° rays).
33
+ * - `off` → nothing snaps (raw cursor).
34
+ */
35
+ export function resolveSnapFlags(mode: SnappingMode): SnapFlags {
36
+ switch (mode) {
37
+ case 'grid':
38
+ return { grid: true, magnetic: false, angles: false }
39
+ case 'lines':
40
+ return { grid: false, magnetic: true, angles: false }
41
+ case 'angles':
42
+ return { grid: false, magnetic: false, angles: true }
43
+ case 'off':
44
+ return { grid: false, magnetic: false, angles: false }
45
+ }
46
+ }
47
+
48
+ const SNAPPING_MODE_LABELS: Record<SnappingMode, string> = {
49
+ grid: 'Grid',
50
+ lines: 'Lines',
51
+ angles: 'Angles',
52
+ off: 'Off',
53
+ }
54
+
55
+ export function getSnappingModeLabel(mode: SnappingMode): string {
56
+ return SNAPPING_MODE_LABELS[mode]
57
+ }
58
+
59
+ export function nextSnappingMode(mode: SnappingMode): SnappingMode {
60
+ const index = SNAPPING_MODES.indexOf(mode)
61
+ return SNAPPING_MODES[(index + 1) % SNAPPING_MODES.length] ?? DEFAULT_SNAPPING_MODE
62
+ }
63
+
64
+ // ── Per-context snapping ──────────────────────────────────────────────────────
65
+ //
66
+ // Snapping is no longer one global value: each *activity* has its own mode set
67
+ // and default, because they want different behaviour (drawing a wall wants a
68
+ // grid + angle lock; nudging an item wants free movement that only catches on
69
+ // alignment lines). The mode is remembered per context and shown live, so it's
70
+ // never a silent surprise — it just matches what you're doing.
71
+
72
+ export type SnapContext = 'wall' | 'item' | 'polygon'
73
+
74
+ // The cyclable mode-set for a context (distinct from the node's `SnapProfile`).
75
+ type SnapModeSet = { modes: SnappingMode[]; default: SnappingMode }
76
+
77
+ // `modes[0]` is the cycle's first entry; `default` is what a context starts at.
78
+ // The 'wall' set is the ONLY one with an angle lock — it applies solely when
79
+ // you're setting a segment's DIRECTION (wall/fence drafting + endpoint drag).
80
+ // Translating a whole wall, curving it, or drawing/moving a slab can't change an
81
+ // angle, so those use the no-angle 'polygon' set.
82
+ const SNAP_PROFILES: Record<SnapContext, SnapModeSet> = {
83
+ // Wall / fence drafting + endpoint reshape: direction matters → angle lock.
84
+ wall: { modes: ['grid', 'lines', 'angles', 'off'], default: 'grid' },
85
+ // Item placement / move: grid by default; lines = magnetic alignment only (no
86
+ // grid lattice), no angle lock (meaningless for a footprint).
87
+ item: { modes: ['lines', 'grid', 'off'], default: 'grid' },
88
+ // Structural / surface, no direction to set: slab / ceiling / roof draft+move,
89
+ // whole wall/fence translate, curve reshape, polygon boundary edit. Grid by
90
+ // default, NO angle lock.
91
+ polygon: { modes: ['grid', 'lines', 'off'], default: 'grid' },
92
+ }
93
+
94
+ export const SNAP_CONTEXTS: SnapContext[] = ['wall', 'item', 'polygon']
95
+
96
+ export function snappingModesFor(context: SnapContext): SnappingMode[] {
97
+ return SNAP_PROFILES[context].modes
98
+ }
99
+
100
+ export function defaultSnappingModeFor(context: SnapContext): SnappingMode {
101
+ return SNAP_PROFILES[context].default
102
+ }
103
+
104
+ // Cycle within the context's own set (clamps a foreign value to the first entry).
105
+ export function cycleSnappingModeIn(context: SnapContext, mode: SnappingMode): SnappingMode {
106
+ const modes = SNAP_PROFILES[context].modes
107
+ const index = modes.indexOf(mode)
108
+ return modes[(index + 1) % modes.length] ?? modes[0] ?? DEFAULT_SNAPPING_MODE
109
+ }
110
+
111
+ // The kind's declared `snapProfile` (from the registry) → the active mode-set
112
+ // context. The only behaviour difference is the angle lock, which a `structural`
113
+ // kind gets while SETTING DIRECTION (drafting a run/polygon, dragging an endpoint
114
+ // or a polygon vertex) — never while translating or curving. A node with no
115
+ // declared profile has no snapping UI (chip) yet — returns null.
116
+ function contextForProfile(
117
+ profile: SnapProfile | undefined,
118
+ directionSetting: boolean,
119
+ ): SnapContext | null {
120
+ if (profile === 'item') return 'item'
121
+ if (profile === 'structural') return directionSetting ? 'wall' : 'polygon'
122
+ return null
123
+ }
124
+
125
+ /**
126
+ * The active snapping context, derived from what the user is doing — fully
127
+ * node-declared: the kind's `snapProfile` (looked up via the injected
128
+ * `profileOf`) supplies the data, and this maps (profile × action) to the
129
+ * mode-set. No per-kind switch lives here. `profileOf` is injected so this stays
130
+ * pure + testable and `snapping-mode` need not import the registry.
131
+ *
132
+ * Prefers the authoritative interaction scope; falls back to the build tool
133
+ * because the `drafting` scope isn't wired yet (wall/slab draw runs idle).
134
+ * Returns null when nothing snappable is active → no chip, safe-default snap.
135
+ */
136
+ export function snapContextOf(args: {
137
+ scope: {
138
+ kind: string
139
+ nodeType?: string
140
+ reshape?: string
141
+ nodeId?: string
142
+ tool?: string
143
+ handle?: string
144
+ }
145
+ mode: string
146
+ tool: string | null
147
+ profileOf: (typeOrTool: string) => SnapProfile | undefined
148
+ // Whether drafting a kind sets a direction (angle-lock meaningful). Injected
149
+ // like `profileOf` so `snapping-mode` need not import the registry; defaults
150
+ // to `true` (the structural draw default) when not supplied.
151
+ draftDirectionalOf?: (typeOrTool: string) => boolean
152
+ }): SnapContext | null {
153
+ const { scope, mode, tool, profileOf, draftDirectionalOf } = args
154
+ // The group-move gizmo translates the whole selection — same no-angle
155
+ // treatment as a single-node move, so Shift cycles the 'item' modes and the
156
+ // HUD shows the item snapping chips for the drag.
157
+ if (scope.kind === 'handle-drag' && scope.handle === GROUP_MOVE_DRAG_LABEL) {
158
+ return 'item'
159
+ }
160
+ switch (scope.kind) {
161
+ case 'placing':
162
+ case 'moving':
163
+ // A whole-node translate never sets direction → no angle.
164
+ return scope.nodeType ? contextForProfile(profileOf(scope.nodeType), false) : null
165
+ case 'reshaping':
166
+ // Dragging a wall ENDPOINT sets the segment's direction → angle-bearing
167
+ // 'wall'. Curving, and polygon vertex/edge edits (boundary / hole), don't
168
+ // — they use the no-angle 'polygon' set (grid / lines / off).
169
+ return scope.reshape === 'endpoint' ? 'wall' : 'polygon'
170
+ case 'drafting':
171
+ return scope.tool
172
+ ? contextForProfile(profileOf(scope.tool), draftDirectionalOf?.(scope.tool) ?? true)
173
+ : null
174
+ default:
175
+ return mode === 'build' && tool
176
+ ? contextForProfile(profileOf(tool), draftDirectionalOf?.(tool) ?? true)
177
+ : null
178
+ }
179
+ }
@@ -37,7 +37,7 @@ function stripDuplicateFlags(metadata: unknown) {
37
37
 
38
38
  function moveStairWhenRegistered(stairId: StairNode['id'], attempt = 0) {
39
39
  const latestStair = useScene.getState().nodes[stairId as AnyNodeId]
40
- if (!latestStair || latestStair.type !== 'stair') {
40
+ if (latestStair?.type !== 'stair') {
41
41
  return
42
42
  }
43
43
 
@@ -64,7 +64,7 @@ export function duplicateStairSubtree(
64
64
  const scene = useScene.getState()
65
65
  const sourceStair = scene.nodes[sourceStairId]
66
66
 
67
- if (!sourceStair || sourceStair.type !== 'stair') {
67
+ if (sourceStair?.type !== 'stair') {
68
68
  throw new Error(`Node "${sourceStairId}" is not a stair`)
69
69
  }
70
70
 
@@ -89,7 +89,7 @@ export function duplicateStairSubtree(
89
89
  const segmentClones: StairSegmentNode[] = []
90
90
  for (const childId of sourceStair.children ?? []) {
91
91
  const childNode = scene.nodes[childId as AnyNodeId]
92
- if (!childNode || childNode.type !== 'stair-segment') {
92
+ if (childNode?.type !== 'stair-segment') {
93
93
  continue
94
94
  }
95
95
 
@@ -108,7 +108,7 @@ export function duplicateStairSubtree(
108
108
  ])
109
109
 
110
110
  const createdStair = useScene.getState().nodes[stairClone.id as AnyNodeId]
111
- if (!createdStair || createdStair.type !== 'stair') {
111
+ if (createdStair?.type !== 'stair') {
112
112
  throw new Error(`Duplicated stair "${stairClone.id}" was not created`)
113
113
  }
114
114
 
@@ -0,0 +1,146 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeId,
5
+ BuildingNode,
6
+ LevelNode,
7
+ StairNode,
8
+ } from '@pascal-app/core/schema'
9
+ import {
10
+ getBuildingLevelsForLevel,
11
+ getStairLevelOptions,
12
+ resolveStairDestinationLevel,
13
+ resolveStairFromLevelId,
14
+ resolveStairPlacementLevelId,
15
+ resolveStairToLevelId,
16
+ } from './stair-levels'
17
+
18
+ describe('stair level helpers', () => {
19
+ test('creates a missing upper level in the same building', () => {
20
+ const ground = LevelNode.parse({ level: 0, children: [] })
21
+ const building = BuildingNode.parse({ children: [ground.id] })
22
+ const nodes = {
23
+ [building.id]: building,
24
+ [ground.id]: ground,
25
+ } as Record<AnyNodeId, AnyNode>
26
+
27
+ const plan = resolveStairDestinationLevel({
28
+ createMissing: true,
29
+ fromLevelId: ground.id,
30
+ nodes,
31
+ })
32
+
33
+ expect(plan?.buildingId).toBe(building.id)
34
+ expect(plan?.fromLevel.id).toBe(ground.id)
35
+ expect(plan?.toLevel.level).toBe(1)
36
+ expect(plan?.toLevel.id).toBe(plan?.createdLevel?.id)
37
+ expect(plan?.createdLevel?.parentId).toBe(building.id)
38
+ })
39
+
40
+ test('uses the nearest higher sibling level instead of creating one', () => {
41
+ const building = BuildingNode.parse({})
42
+ const ground = LevelNode.parse({ level: 0, parentId: building.id })
43
+ const second = LevelNode.parse({ level: 1, parentId: building.id })
44
+ const third = LevelNode.parse({ level: 2, parentId: building.id })
45
+ const nodes = {
46
+ [building.id]: { ...building, children: [ground.id, third.id, second.id] },
47
+ [ground.id]: ground,
48
+ [second.id]: second,
49
+ [third.id]: third,
50
+ } as Record<AnyNodeId, AnyNode>
51
+
52
+ const plan = resolveStairDestinationLevel({
53
+ createMissing: true,
54
+ fromLevelId: ground.id,
55
+ nodes,
56
+ })
57
+
58
+ expect(plan?.createdLevel).toBeNull()
59
+ expect(plan?.toLevel.id).toBe(second.id)
60
+ })
61
+
62
+ test('ignores levels from other buildings', () => {
63
+ const buildingA = BuildingNode.parse({})
64
+ const buildingB = BuildingNode.parse({})
65
+ const groundA = LevelNode.parse({ level: 0, parentId: buildingA.id })
66
+ const upperA = LevelNode.parse({ level: 1, parentId: buildingA.id })
67
+ const upperB = LevelNode.parse({ level: 1, parentId: buildingB.id })
68
+ const nodes = {
69
+ [buildingA.id]: { ...buildingA, children: [groundA.id, upperA.id] },
70
+ [buildingB.id]: { ...buildingB, children: [upperB.id] },
71
+ [groundA.id]: groundA,
72
+ [upperA.id]: upperA,
73
+ [upperB.id]: upperB,
74
+ } as Record<AnyNodeId, AnyNode>
75
+
76
+ expect(getBuildingLevelsForLevel(nodes, groundA.id).map((level) => level.id)).toEqual([
77
+ groundA.id,
78
+ upperA.id,
79
+ ])
80
+ expect(
81
+ resolveStairDestinationLevel({ createMissing: true, fromLevelId: groundA.id, nodes })?.toLevel
82
+ .id,
83
+ ).toBe(upperA.id)
84
+ })
85
+
86
+ test('includes source and parent-linked sibling levels when building children are stale', () => {
87
+ const building = BuildingNode.parse({ children: [] })
88
+ const ground = LevelNode.parse({ level: 0, parentId: building.id })
89
+ const upper = LevelNode.parse({ level: 1, parentId: building.id })
90
+ const nodes = {
91
+ [building.id]: building,
92
+ [ground.id]: ground,
93
+ [upper.id]: upper,
94
+ } as Record<AnyNodeId, AnyNode>
95
+
96
+ const levels = getBuildingLevelsForLevel(nodes, ground.id)
97
+ const plan = resolveStairDestinationLevel({
98
+ createMissing: true,
99
+ fromLevelId: ground.id,
100
+ nodes,
101
+ })
102
+
103
+ expect(levels.map((level) => level.id)).toEqual([ground.id, upper.id])
104
+ expect(plan?.createdLevel).toBeNull()
105
+ expect(plan?.toLevel.id).toBe(upper.id)
106
+ })
107
+
108
+ test('falls back from stale placement level ids to a valid level in the selected building', () => {
109
+ const buildingA = BuildingNode.parse({})
110
+ const groundA = LevelNode.parse({ level: 0, parentId: buildingA.id })
111
+ const buildingB = BuildingNode.parse({})
112
+ const groundB = LevelNode.parse({ level: 0, parentId: buildingB.id })
113
+ const nodes = {
114
+ [buildingA.id]: { ...buildingA, children: [groundA.id] },
115
+ [groundA.id]: groundA,
116
+ [buildingB.id]: { ...buildingB, children: [groundB.id] },
117
+ [groundB.id]: groundB,
118
+ } as Record<AnyNodeId, AnyNode>
119
+
120
+ expect(resolveStairPlacementLevelId(nodes, 'level_missing', buildingB.id)).toBe(groundB.id)
121
+ })
122
+
123
+ test('repairs panel level ids for stairs with stale from-level data', () => {
124
+ const building = BuildingNode.parse({})
125
+ const ground = LevelNode.parse({ level: 0, parentId: building.id })
126
+ const upper = LevelNode.parse({ level: 1, parentId: building.id })
127
+ const stair = StairNode.parse({
128
+ parentId: ground.id,
129
+ fromLevelId: 'default',
130
+ toLevelId: upper.id,
131
+ })
132
+ const nodes = {
133
+ [building.id]: { ...building, children: [ground.id, upper.id] },
134
+ [ground.id]: ground,
135
+ [upper.id]: upper,
136
+ [stair.id]: stair,
137
+ } as Record<AnyNodeId, AnyNode>
138
+
139
+ const levels = getStairLevelOptions(nodes, stair)
140
+ const fromLevelId = resolveStairFromLevelId(nodes, stair, levels)
141
+
142
+ expect(levels.map((level) => level.id)).toEqual([ground.id, upper.id])
143
+ expect(fromLevelId).toBe(ground.id)
144
+ expect(resolveStairToLevelId(nodes, stair, fromLevelId, levels)).toBe(upper.id)
145
+ })
146
+ })
@@ -0,0 +1,177 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ LevelNode,
5
+ type LevelNode as LevelNodeType,
6
+ resolveBuildingForLevel,
7
+ type StairNode,
8
+ } from '@pascal-app/core'
9
+
10
+ function sortLevelsByHeight(levels: LevelNodeType[]) {
11
+ return [...levels].sort((left, right) => left.level - right.level)
12
+ }
13
+
14
+ function isLevelNode(node: AnyNode | undefined): node is LevelNodeType {
15
+ return node?.type === 'level'
16
+ }
17
+
18
+ function getAllSceneLevels(nodes: Record<string, AnyNode>) {
19
+ return sortLevelsByHeight(
20
+ Object.values(nodes).filter((entry): entry is LevelNodeType => entry?.type === 'level'),
21
+ )
22
+ }
23
+
24
+ function getBuildingLevels(
25
+ nodes: Record<string, AnyNode>,
26
+ buildingId: AnyNodeId | string | null | undefined,
27
+ source?: LevelNodeType,
28
+ ) {
29
+ if (!buildingId) return source ? [source] : []
30
+ const building = nodes[buildingId as AnyNodeId]
31
+ if (building?.type !== 'building') return source ? [source] : []
32
+
33
+ const levels = new Map<string, LevelNodeType>()
34
+ if (source) levels.set(source.id, source)
35
+
36
+ for (const childId of building.children ?? []) {
37
+ const child = nodes[childId as AnyNodeId]
38
+ if (isLevelNode(child)) levels.set(child.id, child)
39
+ }
40
+
41
+ for (const candidate of Object.values(nodes)) {
42
+ if (isLevelNode(candidate) && candidate.parentId === building.id) {
43
+ levels.set(candidate.id, candidate)
44
+ }
45
+ }
46
+
47
+ return sortLevelsByHeight(Array.from(levels.values()))
48
+ }
49
+
50
+ export function getBuildingLevelsForLevel(
51
+ nodes: Record<string, AnyNode>,
52
+ levelId: AnyNodeId | string | null | undefined,
53
+ ) {
54
+ if (!levelId) return []
55
+ const source = nodes[levelId as AnyNodeId]
56
+ if (!isLevelNode(source)) return []
57
+
58
+ const buildingId = resolveBuildingForLevel(
59
+ source.id as AnyNodeId,
60
+ nodes as Record<AnyNodeId, AnyNode>,
61
+ )
62
+ return getBuildingLevels(nodes, buildingId, source)
63
+ }
64
+
65
+ export function getStairLevelOptions(nodes: Record<string, AnyNode>, stair: StairNode) {
66
+ for (const candidateId of [stair.fromLevelId, stair.parentId, stair.toLevelId]) {
67
+ if (isLevelNode(nodes[candidateId as AnyNodeId])) {
68
+ return getBuildingLevelsForLevel(nodes, candidateId)
69
+ }
70
+ }
71
+
72
+ return getAllSceneLevels(nodes)
73
+ }
74
+
75
+ export function resolveStairPlacementLevelId(
76
+ nodes: Record<string, AnyNode>,
77
+ preferredLevelId: AnyNodeId | string | null | undefined,
78
+ preferredBuildingId?: AnyNodeId | string | null,
79
+ ) {
80
+ if (isLevelNode(nodes[preferredLevelId as AnyNodeId])) {
81
+ return preferredLevelId as LevelNodeType['id']
82
+ }
83
+
84
+ const buildingLevels = getBuildingLevels(nodes, preferredBuildingId)
85
+ return buildingLevels[0]?.id ?? getAllSceneLevels(nodes)[0]?.id ?? null
86
+ }
87
+
88
+ export function resolveStairFromLevelId(
89
+ nodes: Record<string, AnyNode>,
90
+ stair: StairNode,
91
+ levels = getStairLevelOptions(nodes, stair),
92
+ ) {
93
+ const optionIds = new Set<string>(levels.map((level) => level.id))
94
+ if (stair.fromLevelId && optionIds.has(stair.fromLevelId)) return stair.fromLevelId
95
+ if (stair.parentId && optionIds.has(stair.parentId)) return stair.parentId
96
+
97
+ const toLevel = stair.toLevelId ? nodes[stair.toLevelId as AnyNodeId] : undefined
98
+ if (isLevelNode(toLevel)) {
99
+ const lowerLevel = [...levels].reverse().find((level) => level.level < toLevel.level)
100
+ if (lowerLevel) return lowerLevel.id
101
+ }
102
+
103
+ return levels[0]?.id ?? null
104
+ }
105
+
106
+ export function resolveStairToLevelId(
107
+ nodes: Record<string, AnyNode>,
108
+ stair: StairNode,
109
+ fromLevelId: AnyNodeId | string | null | undefined,
110
+ levels = getStairLevelOptions(nodes, stair),
111
+ ) {
112
+ const optionIds = new Set<string>(levels.map((level) => level.id))
113
+ if (stair.toLevelId && stair.toLevelId !== fromLevelId && optionIds.has(stair.toLevelId)) {
114
+ return stair.toLevelId
115
+ }
116
+
117
+ const fromLevel = fromLevelId ? nodes[fromLevelId as AnyNodeId] : undefined
118
+ if (isLevelNode(fromLevel)) {
119
+ return levels.find((level) => level.level > fromLevel.level)?.id ?? fromLevel.id
120
+ }
121
+
122
+ return levels[0]?.id ?? null
123
+ }
124
+
125
+ export function resolveStairDestinationLevel({
126
+ createMissing,
127
+ fromLevelId,
128
+ nodes,
129
+ }: {
130
+ createMissing?: boolean
131
+ fromLevelId: AnyNodeId | string | null | undefined
132
+ nodes: Record<string, AnyNode>
133
+ }) {
134
+ if (!fromLevelId) return null
135
+ const fromLevel = nodes[fromLevelId as AnyNodeId]
136
+ if (!isLevelNode(fromLevel)) return null
137
+
138
+ const buildingId = resolveBuildingForLevel(
139
+ fromLevel.id as AnyNodeId,
140
+ nodes as Record<AnyNodeId, AnyNode>,
141
+ )
142
+ const levels = getBuildingLevelsForLevel(nodes, fromLevel.id)
143
+ const nextExistingLevel = levels.find((level) => level.level > fromLevel.level) ?? null
144
+ if (nextExistingLevel) {
145
+ return {
146
+ buildingId,
147
+ createdLevel: null,
148
+ fromLevel,
149
+ levels,
150
+ toLevel: nextExistingLevel,
151
+ }
152
+ }
153
+
154
+ if (createMissing && buildingId) {
155
+ const createdLevel = LevelNode.parse({
156
+ children: [],
157
+ level: fromLevel.level + 1,
158
+ parentId: buildingId,
159
+ })
160
+
161
+ return {
162
+ buildingId,
163
+ createdLevel,
164
+ fromLevel,
165
+ levels: sortLevelsByHeight([...levels, createdLevel]),
166
+ toLevel: createdLevel,
167
+ }
168
+ }
169
+
170
+ return {
171
+ buildingId,
172
+ createdLevel: null,
173
+ fromLevel,
174
+ levels,
175
+ toLevel: fromLevel,
176
+ }
177
+ }