@pascal-app/editor 1.0.1 → 1.0.4

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 (498) hide show
  1. package/package.json +16 -9
  2. package/src/components/editor/action-menu-button.tsx +83 -0
  3. package/src/components/editor/bake-thumbnail.tsx +315 -4
  4. package/src/components/editor/capture-level-picker.tsx +72 -0
  5. package/src/components/editor/custom-camera-controls.tsx +221 -128
  6. package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
  7. package/src/components/editor/duplicate-node.ts +244 -0
  8. package/src/components/editor/editor-layout-mobile.tsx +37 -25
  9. package/src/components/editor/editor-layout-v2.tsx +131 -58
  10. package/src/components/editor/export-manager.tsx +34 -8
  11. package/src/components/editor/first-person/build-collider-world.test.ts +51 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +6 -2
  13. package/src/components/editor/first-person/mechanism-targets.test.ts +75 -0
  14. package/src/components/editor/first-person/mechanism-targets.ts +36 -0
  15. package/src/components/editor/first-person-controls.tsx +205 -2
  16. package/src/components/editor/floating-action-menu.tsx +63 -207
  17. package/src/components/editor/floor-aware-camera-controls.test.ts +442 -0
  18. package/src/components/editor/floor-aware-camera-controls.ts +324 -0
  19. package/src/components/editor/floor-openings-overlay.tsx +243 -0
  20. package/src/components/editor/floor-region-controls.tsx +846 -0
  21. package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
  22. package/src/components/editor/floor-surface.test.ts +165 -0
  23. package/src/components/editor/floor-surface.ts +182 -0
  24. package/src/components/editor/floorplan-background-selection.test.ts +11 -2
  25. package/src/components/editor/floorplan-background-selection.ts +1 -3
  26. package/src/components/editor/floorplan-panel.tsx +536 -487
  27. package/src/components/editor/footprint-height-handle.tsx +204 -0
  28. package/src/components/editor/grid.tsx +31 -7
  29. package/src/components/editor/group-actions.ts +59 -11
  30. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  31. package/src/components/editor/group-move-3d.ts +4 -6
  32. package/src/components/editor/group-rotate-handle.tsx +81 -35
  33. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  34. package/src/components/editor/group-transform-shared.test.ts +126 -1
  35. package/src/components/editor/group-transform-shared.ts +79 -73
  36. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  37. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  38. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  39. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  40. package/src/components/editor/handles/use-handle-drag.ts +272 -131
  41. package/src/components/editor/index.tsx +316 -176
  42. package/src/components/editor/join-walls-pill.tsx +44 -0
  43. package/src/components/editor/node-action-menu.tsx +70 -70
  44. package/src/components/editor/node-arrow-handles.tsx +167 -14
  45. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  46. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  47. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  48. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  49. package/src/components/editor/registry-action-contributions.tsx +132 -0
  50. package/src/components/editor/room-controls.tsx +847 -0
  51. package/src/components/editor/room-floor-highlight.tsx +128 -0
  52. package/src/components/editor/room-handles-2d.tsx +354 -0
  53. package/src/components/editor/room-handles.tsx +675 -0
  54. package/src/components/editor/room-highlight.tsx +225 -0
  55. package/src/components/editor/room-labels.test.ts +357 -0
  56. package/src/components/editor/room-labels.tsx +990 -0
  57. package/src/components/editor/room-transform-ghost.tsx +256 -0
  58. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  59. package/src/components/editor/selection-manager.tsx +451 -317
  60. package/src/components/editor/slab-hole-highlights.tsx +3 -0
  61. package/src/components/editor/snapshot-capture-overlay.tsx +4 -0
  62. package/src/components/editor/snapshot-capture.test.ts +52 -4
  63. package/src/components/editor/snapshot-capture.ts +30 -5
  64. package/src/components/editor/thumbnail-generator.test.ts +308 -0
  65. package/src/components/editor/thumbnail-generator.tsx +1035 -229
  66. package/src/components/editor/use-floorplan-background-placement.ts +31 -11
  67. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  68. package/src/components/editor/views/host-view.tsx +61 -0
  69. package/src/components/editor/views/plugins-view.tsx +670 -0
  70. package/src/components/editor/views/scene-region.tsx +59 -0
  71. package/src/components/editor/views/use-editor-views.ts +57 -0
  72. package/src/components/editor/views/view-bar.tsx +220 -0
  73. package/src/components/editor/wall-justification.test.ts +140 -0
  74. package/src/components/editor/wall-move-side-handles.tsx +114 -163
  75. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  76. package/src/components/editor/wall-push-arrow.tsx +92 -0
  77. package/src/components/editor/wall-region-handles.tsx +509 -0
  78. package/src/components/editor/wall-snap-beacon-layer.tsx +3 -0
  79. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  80. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  81. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  82. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  83. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  84. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  85. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +242 -126
  86. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  88. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  89. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +166 -0
  90. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  91. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +87 -8
  92. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +355 -245
  93. package/src/components/editor-2d/svg-paths.ts +12 -15
  94. package/src/components/procedural-items/inspector.tsx +40 -15
  95. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  96. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  97. package/src/components/systems/zone/zone-system.test.ts +47 -0
  98. package/src/components/systems/zone/zone-system.tsx +31 -66
  99. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  100. package/src/components/tools/item/item-surface-placement.test.ts +3 -9
  101. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
  102. package/src/components/tools/item/placement-history.test.tsx +474 -0
  103. package/src/components/tools/item/placement-strategies.ts +63 -47
  104. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
  105. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  106. package/src/components/tools/item/use-draft-node.ts +207 -97
  107. package/src/components/tools/item/use-placement-coordinator.tsx +251 -111
  108. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  109. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  110. package/src/components/tools/registry/item-surface-move.test.ts +10 -2
  111. package/src/components/tools/registry/item-surface-move.ts +46 -24
  112. package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +26 -10
  114. package/src/components/tools/select/box-select-tool.tsx +2 -12
  115. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  116. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  117. package/src/components/tools/select/select-candidates.test.ts +37 -4
  118. package/src/components/tools/select/select-candidates.ts +15 -11
  119. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  120. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  121. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  122. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  123. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  124. package/src/components/tools/shared/pointer-support-cap.ts +17 -5
  125. package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
  126. package/src/components/tools/shared/surface-rejection.tsx +14 -0
  127. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  128. package/src/components/tools/stair/stair-defaults.ts +0 -3
  129. package/src/components/tools/stair/stair-tool.tsx +90 -42
  130. package/src/components/tools/tool-manager.tsx +82 -23
  131. package/src/components/tools/wall/wall-drafting.test.ts +19 -4
  132. package/src/components/tools/zone/zone-tool.tsx +4 -12
  133. package/src/components/ui/action-menu/action-button.tsx +3 -0
  134. package/src/components/ui/action-menu/camera-actions.tsx +2 -1
  135. package/src/components/ui/action-menu/control-modes.tsx +2 -35
  136. package/src/components/ui/action-menu/measurement-control.tsx +3 -10
  137. package/src/components/ui/build-panel/build-panel.tsx +235 -0
  138. package/src/components/ui/command-palette/editor-commands.tsx +4 -30
  139. package/src/components/ui/command-palette/index.tsx +5 -24
  140. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  141. package/src/components/ui/controls/material-paint-panel.tsx +101 -91
  142. package/src/components/ui/controls/material-picker.tsx +8 -67
  143. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  144. package/src/components/ui/controls/select-control.tsx +65 -0
  145. package/src/components/ui/controls/slider-control.tsx +76 -31
  146. package/src/components/ui/floating-level-selector.tsx +9 -14
  147. package/src/components/ui/helpers/building-helper.tsx +4 -1
  148. package/src/components/ui/helpers/contextual-helper-panel.tsx +154 -70
  149. package/src/components/ui/helpers/helper-manager.test.tsx +69 -0
  150. package/src/components/ui/helpers/helper-manager.tsx +348 -19
  151. package/src/components/ui/helpers/item-helper.tsx +8 -0
  152. package/src/components/ui/helpers/registered-tool-helper.tsx +10 -33
  153. package/src/components/ui/icon-ref.tsx +45 -1
  154. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  155. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  156. package/src/components/ui/node-icon-image.tsx +16 -0
  157. package/src/components/ui/panels/collections/collections-popover.tsx +20 -14
  158. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  159. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  160. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  161. package/src/components/ui/panels/mobile-selection-bar.tsx +2 -2
  162. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  163. package/src/components/ui/panels/multi-field-value.ts +6 -3
  164. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  165. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  166. package/src/components/ui/panels/multi-selection-panel.tsx +43 -4
  167. package/src/components/ui/panels/node-display.ts +8 -3
  168. package/src/components/ui/panels/panel-manager.tsx +103 -68
  169. package/src/components/ui/panels/panel-wrapper.tsx +78 -36
  170. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  171. package/src/components/ui/panels/parametric-inspector.tsx +7 -19
  172. package/src/components/ui/panels/reference-panel.tsx +2 -3
  173. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  174. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  175. package/src/components/ui/panels/room-name-options.ts +47 -0
  176. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  177. package/src/components/ui/panels/room-panel.tsx +725 -0
  178. package/src/components/ui/panels/shape-choice.tsx +58 -0
  179. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  180. package/src/components/ui/primitives/color-dot.tsx +36 -24
  181. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  182. package/src/components/ui/primitives/sidebar.tsx +2 -0
  183. package/src/components/ui/right-stack.test.ts +29 -0
  184. package/src/components/ui/right-stack.tsx +145 -0
  185. package/src/components/ui/sidebar/icon-rail.tsx +5 -0
  186. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  187. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +615 -372
  188. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +7 -7
  189. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  190. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  191. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  192. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  193. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  194. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  195. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  196. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  197. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  198. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  199. package/src/components/ui/sidebar/panels/site-panel/index.tsx +176 -211
  200. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +0 -27
  211. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  215. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +0 -2
  216. package/src/components/ui/sidebar/tab-bar.tsx +10 -0
  217. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  218. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  219. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  220. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  221. package/src/components/viewer/floorplan-preview.tsx +6 -18
  222. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  223. package/src/components/viewer/viewer-rooms.tsx +175 -0
  224. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  225. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  226. package/src/components/viewer/viewer-stage.tsx +2 -2
  227. package/src/components/viewer-zone-system.tsx +9 -9
  228. package/src/hooks/use-auto-save.integration.test.tsx +311 -0
  229. package/src/hooks/use-auto-save.ts +55 -14
  230. package/src/hooks/use-ceiling-events.test.ts +1 -0
  231. package/src/hooks/use-ceiling-events.ts +2 -0
  232. package/src/hooks/use-grid-events.test.ts +2 -0
  233. package/src/hooks/use-grid-events.ts +16 -11
  234. package/src/hooks/use-keyboard.test.ts +182 -0
  235. package/src/hooks/use-keyboard.ts +122 -91
  236. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  237. package/src/hooks/use-open-wall-ends.ts +238 -0
  238. package/src/hooks/use-placement-notice.test.ts +48 -0
  239. package/src/hooks/use-placement-notice.ts +42 -0
  240. package/src/hooks/use-selected-room.ts +143 -0
  241. package/src/index.tsx +306 -39
  242. package/src/lib/catalog-panel-model.ts +39 -0
  243. package/src/lib/catalog-selection.test.ts +27 -0
  244. package/src/lib/catalog-selection.ts +8 -0
  245. package/src/lib/ceiling-edit-session.test.ts +321 -0
  246. package/src/lib/ceiling-edit-session.ts +206 -0
  247. package/src/lib/ceiling-region-session.test.ts +179 -0
  248. package/src/lib/contextual-help.test.ts +27 -1
  249. package/src/lib/contextual-help.ts +16 -9
  250. package/src/lib/continuation.test.ts +19 -1
  251. package/src/lib/continuation.ts +25 -4
  252. package/src/lib/door-interaction.ts +14 -0
  253. package/src/lib/editor-level-display.test.ts +96 -0
  254. package/src/lib/editor-level-display.ts +48 -0
  255. package/src/lib/editor-views.ts +144 -0
  256. package/src/lib/export-geometry-wait.test.ts +90 -0
  257. package/src/lib/floor-edit-session.ts +153 -0
  258. package/src/lib/floor-footprints.test.ts +563 -0
  259. package/src/lib/floor-footprints.ts +395 -0
  260. package/src/lib/floor-foundation-support.test.ts +283 -0
  261. package/src/lib/floor-opening-draft.ts +218 -0
  262. package/src/lib/floor-openings.test.ts +216 -0
  263. package/src/lib/floor-region-geometry.ts +191 -0
  264. package/src/lib/floor-region-session.test.ts +485 -0
  265. package/src/lib/floor-region-session.ts +589 -0
  266. package/src/lib/floor-region-snap.ts +101 -0
  267. package/src/lib/floor-step-hover.test.ts +32 -0
  268. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  269. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  270. package/src/lib/floorplan/dimension-drive.ts +479 -0
  271. package/src/lib/floorplan/floorplan-export.test.ts +15 -9
  272. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  273. package/src/lib/floorplan/floorplan-extension.ts +48 -0
  274. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  275. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  276. package/src/lib/floorplan/floorplan-pdfkit-document.ts +339 -18
  277. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +138 -8
  278. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +56 -25
  279. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  280. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  281. package/src/lib/floorplan/geometry.ts +13 -6
  282. package/src/lib/floorplan/index.ts +0 -9
  283. package/src/lib/floorplan/items.ts +5 -2
  284. package/src/lib/floorplan/marks.test.ts +213 -0
  285. package/src/lib/floorplan/marks.ts +238 -0
  286. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  287. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  288. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  289. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  290. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  291. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  292. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  293. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  294. package/src/lib/floorplan/schedules.test.ts +189 -0
  295. package/src/lib/floorplan/schedules.ts +339 -0
  296. package/src/lib/floorplan/sheet-export.test.ts +100 -0
  297. package/src/lib/floorplan/sheet-export.ts +318 -0
  298. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +779 -0
  299. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  300. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  301. package/src/lib/floorplan/site-plan/coverage.ts +232 -0
  302. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  303. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  304. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  305. package/src/lib/floorplan/site-plan/front-edge.ts +156 -0
  306. package/src/lib/floorplan/site-plan/geometry.test.ts +275 -0
  307. package/src/lib/floorplan/site-plan/geometry.ts +557 -0
  308. package/src/lib/floorplan/site-plan/index.ts +71 -0
  309. package/src/lib/floorplan/site-plan/site-annotations.test.ts +366 -0
  310. package/src/lib/floorplan/site-plan/site-annotations.ts +1083 -0
  311. package/src/lib/floorplan/site-plan/site-parts.ts +379 -0
  312. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  313. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  314. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  315. package/src/lib/floorplan/site-plan/yards.test.ts +69 -0
  316. package/src/lib/floorplan/types.ts +1 -27
  317. package/src/lib/fresh-planar-placement.test.ts +36 -0
  318. package/src/lib/fresh-planar-placement.ts +191 -36
  319. package/src/lib/geometry-script/author.ts +152 -0
  320. package/src/lib/geometry-script/client.ts +152 -0
  321. package/src/lib/geometry-script/protocol.ts +12 -0
  322. package/src/lib/gesture-lifecycle.test.ts +595 -0
  323. package/src/lib/gesture-lifecycle.ts +290 -0
  324. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  325. package/src/lib/glb-export.test.ts +544 -16
  326. package/src/lib/glb-export.ts +256 -125
  327. package/src/lib/grid-frame.test.ts +19 -0
  328. package/src/lib/grid-frame.ts +18 -0
  329. package/src/lib/history.test.ts +6 -0
  330. package/src/lib/history.ts +7 -0
  331. package/src/lib/hud-title.ts +109 -0
  332. package/src/lib/hydration-gated-storage.ts +49 -0
  333. package/src/lib/ifc-export.test.ts +174 -0
  334. package/src/lib/ifc-export.ts +201 -0
  335. package/src/lib/ifc-parts.ts +15 -0
  336. package/src/lib/inspector-expanded.test.ts +44 -0
  337. package/src/lib/inspector-expanded.ts +36 -0
  338. package/src/lib/interaction/scope.ts +27 -1
  339. package/src/lib/keyboard-pan.test.ts +184 -0
  340. package/src/lib/keyboard-pan.ts +238 -0
  341. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  342. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  343. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  344. package/src/lib/lot/clean-ring.test.ts +186 -0
  345. package/src/lib/lot/clean-ring.ts +217 -0
  346. package/src/lib/lot/dossier.test.ts +202 -0
  347. package/src/lib/lot/dossier.ts +498 -0
  348. package/src/lib/lot/drop-in.test.ts +326 -0
  349. package/src/lib/lot/drop-in.ts +407 -0
  350. package/src/lib/lot/index.ts +43 -0
  351. package/src/lib/lot/lot-patch.test.ts +340 -0
  352. package/src/lib/lot/lot-patch.ts +394 -0
  353. package/src/lib/lot/parcel-provider.ts +43 -0
  354. package/src/lib/lot/terrain.test.ts +161 -0
  355. package/src/lib/lot/terrain.ts +266 -0
  356. package/src/lib/material-catalog-model.ts +74 -0
  357. package/src/lib/material-paint-panel-model.ts +80 -0
  358. package/src/lib/material-paint.ts +4 -0
  359. package/src/lib/mezzanine-actions.test.ts +342 -0
  360. package/src/lib/mezzanine-draft.test.ts +501 -0
  361. package/src/lib/mezzanine-draft.ts +278 -0
  362. package/src/lib/mezzanine-messages.ts +26 -0
  363. package/src/lib/mezzanine-selection.test.ts +207 -0
  364. package/src/lib/mezzanine-stairs.ts +46 -0
  365. package/src/lib/model-export.ts +3 -1
  366. package/src/lib/node-action-movement.ts +17 -6
  367. package/src/lib/node-display-label.ts +37 -0
  368. package/src/lib/orbit-floor-clearance.test.ts +165 -0
  369. package/src/lib/orbit-floor-clearance.ts +106 -0
  370. package/src/lib/overlay-transform.test.ts +28 -0
  371. package/src/lib/overlay-transform.ts +12 -0
  372. package/src/lib/paint-commit-route.test.ts +37 -0
  373. package/src/lib/paint-commit-route.ts +17 -0
  374. package/src/lib/paint-eyedropper.test.tsx +165 -0
  375. package/src/lib/paint-eyedropper.ts +99 -0
  376. package/src/lib/paint-pass-through.test.ts +111 -0
  377. package/src/lib/paint-pass-through.ts +76 -0
  378. package/src/lib/paint-region-hover.ts +25 -0
  379. package/src/lib/paint-region-mode.ts +154 -0
  380. package/src/lib/paint-regions.test.ts +166 -0
  381. package/src/lib/paint-regions.ts +192 -0
  382. package/src/lib/paint-scope.test.ts +317 -35
  383. package/src/lib/paint-scope.ts +266 -25
  384. package/src/lib/panel-rows.ts +96 -0
  385. package/src/lib/panel-tool-options.test.ts +34 -0
  386. package/src/lib/panel-tool-options.ts +46 -0
  387. package/src/lib/parametric-node-update.ts +37 -0
  388. package/src/lib/plate-paint-affected.test.ts +300 -0
  389. package/src/lib/plate-paint-affected.ts +177 -0
  390. package/src/lib/plugin-chat.ts +50 -0
  391. package/src/lib/plugin-panels.test.ts +25 -0
  392. package/src/lib/plugin-panels.ts +94 -0
  393. package/src/lib/print-shell-compiler.ts +1 -0
  394. package/src/lib/registered-draft-snap.ts +12 -0
  395. package/src/lib/roof-duplication.ts +10 -2
  396. package/src/lib/room-assembly-overlay.test.ts +275 -0
  397. package/src/lib/room-assembly-overlay.ts +266 -0
  398. package/src/lib/room-built-on.test.ts +379 -0
  399. package/src/lib/room-built-on.ts +138 -0
  400. package/src/lib/room-construction-commands.ts +712 -0
  401. package/src/lib/room-construction.test.ts +746 -0
  402. package/src/lib/room-construction.ts +385 -0
  403. package/src/lib/room-controls-phase6.test.ts +630 -0
  404. package/src/lib/room-divide-session.ts +523 -0
  405. package/src/lib/room-handle-drag.ts +1068 -0
  406. package/src/lib/room-name-catalog.test.ts +90 -0
  407. package/src/lib/room-name-catalog.ts +168 -0
  408. package/src/lib/room-push-dimensions.test.ts +311 -0
  409. package/src/lib/room-push-dimensions.ts +251 -0
  410. package/src/lib/room-selection-commands.ts +62 -0
  411. package/src/lib/room-selection.test.ts +973 -0
  412. package/src/lib/room-selection.ts +417 -0
  413. package/src/lib/room-structure-commands.ts +94 -0
  414. package/src/lib/room-structure.test.ts +921 -0
  415. package/src/lib/room-transform-session.ts +570 -0
  416. package/src/lib/room-zone-routing.test.ts +294 -0
  417. package/src/lib/room-zone-routing.ts +197 -0
  418. package/src/lib/scene-clipboard.test.ts +364 -0
  419. package/src/lib/scene-clipboard.ts +229 -48
  420. package/src/lib/scene-material-refs.test.ts +79 -0
  421. package/src/lib/scene-material-refs.ts +79 -0
  422. package/src/lib/scene.test.ts +87 -0
  423. package/src/lib/scene.ts +39 -45
  424. package/src/lib/selection-routing.test.ts +129 -45
  425. package/src/lib/selection-routing.ts +53 -74
  426. package/src/lib/session-writes.test.ts +120 -0
  427. package/src/lib/session-writes.ts +205 -0
  428. package/src/lib/sidebar-panel.ts +62 -0
  429. package/src/lib/site-boundary.test.ts +4 -6
  430. package/src/lib/site-boundary.ts +1 -1
  431. package/src/lib/snapping-mode.test.ts +17 -2
  432. package/src/lib/snapping-mode.ts +19 -7
  433. package/src/lib/spatial-drag-plane.test.ts +53 -0
  434. package/src/lib/spatial-drag-plane.ts +19 -0
  435. package/src/lib/spatial-pointer-input.test.ts +72 -0
  436. package/src/lib/spatial-pointer-input.ts +62 -0
  437. package/src/lib/spatial-snap.test.ts +47 -0
  438. package/src/lib/terrace-draft.test.ts +145 -0
  439. package/src/lib/terrace-draft.ts +213 -0
  440. package/src/lib/terrain-panel-model.ts +117 -0
  441. package/src/lib/tool-cursor-icon.test.ts +16 -0
  442. package/src/lib/tool-cursor-icon.ts +11 -0
  443. package/src/lib/typing-target.ts +9 -0
  444. package/src/lib/units.ts +77 -39
  445. package/src/lib/vector-edges.test.ts +58 -0
  446. package/src/lib/vector-edges.ts +400 -0
  447. package/src/lib/viewer-selection.test.ts +577 -0
  448. package/src/lib/viewer-selection.ts +569 -0
  449. package/src/lib/wall-draw-variant.test.ts +71 -0
  450. package/src/lib/wall-draw-variant.ts +91 -0
  451. package/src/lib/wall-paint-region-session.ts +246 -0
  452. package/src/lib/wall-paint-region.test.ts +373 -0
  453. package/src/lib/wall-polygon-draft.test.ts +262 -0
  454. package/src/lib/wall-polygon-draft.ts +286 -0
  455. package/src/lib/wall-push.test.ts +453 -0
  456. package/src/lib/wall-region-face.ts +352 -0
  457. package/src/lib/wall-region-handles.test.ts +304 -0
  458. package/src/lib/wall-region-handles.ts +368 -0
  459. package/src/lib/wall-region-list.test.ts +119 -0
  460. package/src/lib/wall-region-list.ts +136 -0
  461. package/src/lib/wall-region-snap.ts +110 -0
  462. package/src/lib/window-interaction.ts +14 -0
  463. package/src/lib/world-grid-snap.test.ts +48 -0
  464. package/src/lib/world-grid-snap.ts +9 -5
  465. package/src/lib/zone-content.ts +4 -1
  466. package/src/lib/zone-name.test.ts +17 -0
  467. package/src/lib/zone-name.ts +17 -0
  468. package/src/store/opens-in-select-mode.test.ts +223 -0
  469. package/src/store/paint-mode.test.ts +188 -0
  470. package/src/store/rotation-snapping.test.ts +53 -0
  471. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  472. package/src/store/tool-mode.test.ts +137 -6
  473. package/src/store/use-delete-confirmation.ts +28 -1
  474. package/src/store/use-drawing-view.test.ts +24 -3
  475. package/src/store/use-drawing-view.ts +29 -1
  476. package/src/store/use-editor.tsx +442 -323
  477. package/src/store/use-floorplan-draft-preview.ts +23 -1
  478. package/src/store/use-floorplan-marquee.ts +12 -2
  479. package/src/store/use-interaction-scope.ts +155 -6
  480. package/src/store/use-quick-measurement-hud.ts +3 -3
  481. package/src/store/use-stair-build-preview.ts +13 -5
  482. package/src/store/use-wall-move-ghosts.ts +4 -2
  483. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  484. package/src/store/use-wall-region-handles-selection.ts +139 -0
  485. package/src/store/view-layout.test.ts +175 -0
  486. package/src/store/view-layout.ts +196 -0
  487. package/src/test-utils/immediate-animation-frames.ts +16 -0
  488. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  489. package/src/test-utils/selection-harness.ts +82 -0
  490. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  491. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  492. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  493. package/src/components/tools/fence/fence-drafting.ts +0 -266
  494. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  495. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  496. package/src/lib/floorplan/stairs.ts +0 -489
  497. package/src/lib/level-duplication.test.ts +0 -144
  498. package/src/lib/level-duplication.ts +0 -195
@@ -0,0 +1,973 @@
1
+ import { afterEach, describe, expect, jest, spyOn, test } from 'bun:test'
2
+ import { readFileSync } from 'node:fs'
3
+ import {
4
+ type AnyNode,
5
+ BuildingNode,
6
+ CeilingNode,
7
+ createRoomTopologyIndex,
8
+ emitter,
9
+ getWallCurveFrameAt,
10
+ ItemNode,
11
+ LevelNode,
12
+ type NodeEvent,
13
+ reconcileLevelStructure,
14
+ SlabNode,
15
+ SpawnNode,
16
+ StairNode,
17
+ sceneRegistry,
18
+ useScene,
19
+ WallNode,
20
+ ZoneNode,
21
+ } from '@pascal-app/core'
22
+ import { migrateCeilingRoomLinks, migrateRoomZones } from '@pascal-app/core/scene-migrations'
23
+ import { useViewer } from '@pascal-app/viewer'
24
+ import { _roots, act } from '@react-three/fiber'
25
+ import { createElement } from 'react'
26
+ import { Group, type LineSegments } from 'three'
27
+ import { SelectionManager } from '../components/editor/selection-manager'
28
+ import { cancelActiveTool, runHistoryShortcut } from '../hooks/use-keyboard'
29
+ import { resolvePlanRoomHit, roomPickingEnabled, useSelectedRoom } from '../hooks/use-selected-room'
30
+ import useEditor from '../store/use-editor'
31
+ import useInteractionScope from '../store/use-interaction-scope'
32
+ import useSessionGroups from '../store/use-session-groups'
33
+ import { withSelectionHarness } from '../test-utils/selection-harness'
34
+ import { startRoomDivide } from './room-divide-session'
35
+ import { runRoomHandleDrag } from './room-handle-drag'
36
+ import {
37
+ type RoomKey,
38
+ RoomSelectionIndex,
39
+ resolveRoomHit,
40
+ shouldSelectRoom,
41
+ } from './room-selection'
42
+ import { selectRoom, selectRoomFromHit, shouldInterceptRoom } from './room-selection-commands'
43
+
44
+ const levelId = 'level_room_selection'
45
+ const buildingId = 'building_room_selection'
46
+ const plain = { alt: false, shift: false, ctrl: false, meta: false }
47
+ function wall(id: string, start: [number, number], end: [number, number], extra = {}) {
48
+ return WallNode.parse({
49
+ id: `wall_${id}`,
50
+ parentId: levelId,
51
+ start,
52
+ end,
53
+ thickness: 0.2,
54
+ ...extra,
55
+ })
56
+ }
57
+ function fixture(
58
+ extra: AnyNode[] = [],
59
+ walls = [
60
+ wall('south', [0, 0], [8, 0]),
61
+ wall('east', [8, 0], [8, 4]),
62
+ wall('north', [8, 4], [0, 4]),
63
+ wall('west', [0, 4], [0, 0]),
64
+ wall('shared', [4, 0], [4, 4]),
65
+ ],
66
+ ) {
67
+ const rawNodes = Object.fromEntries(
68
+ [
69
+ BuildingNode.parse({ id: buildingId, children: [levelId] }),
70
+ LevelNode.parse({
71
+ id: levelId,
72
+ parentId: buildingId,
73
+ children: [...walls, ...extra].map((node) => node.id),
74
+ }),
75
+ ...walls,
76
+ ...extra,
77
+ ].map((node) => [node.id, node]),
78
+ )
79
+ const nodes = migrateCeilingRoomLinks(migrateRoomZones(rawNodes).nodes).nodes as Record<
80
+ string,
81
+ AnyNode
82
+ >
83
+ const index = createRoomTopologyIndex()
84
+ index.rebuild(nodes)
85
+ return { nodes, index, shared: walls[4]!, south: walls[0]! }
86
+ }
87
+
88
+ afterEach(() => {
89
+ jest.useRealTimers()
90
+ useEditor.getState().clearRoom()
91
+ useInteractionScope.getState().end()
92
+ })
93
+
94
+ describe('room hit resolution', () => {
95
+ test('shared wall faces and 2D cursor sides resolve different rooms', () => {
96
+ const { index, shared } = fixture()
97
+ const left = index.roomAtPoint(levelId, [2, 2])!
98
+ const right = index.roomAtPoint(levelId, [6, 2])!
99
+ expect(left.id).not.toBe(right.id)
100
+ for (const view of ['2d', '3d'] as const) {
101
+ expect(resolveRoomHit(index, levelId, shared, [3.9, 2], view)?.id).toBe(left.id)
102
+ expect(resolveRoomHit(index, levelId, shared, [4.1, 2], view)?.id).toBe(right.id)
103
+ }
104
+ })
105
+ test('slabs, ceilings and floor points resolve rooms', () => {
106
+ const { index, south } = fixture()
107
+ const polygon: [number, number][] = [
108
+ [0, 0],
109
+ [4, 0],
110
+ [4, 4],
111
+ [0, 4],
112
+ ]
113
+ const slab = SlabNode.parse({ polygon, parentId: levelId })
114
+ const ceiling = CeilingNode.parse({ polygon, parentId: levelId })
115
+ for (const node of [null, slab, ceiling]) {
116
+ expect(resolveRoomHit(index, levelId, node, [2, 2])?.id).toBe(
117
+ index.roomAtPoint(levelId, [2, 2])!.id,
118
+ )
119
+ expect(resolveRoomHit(index, levelId, node, [12, 2])).toBeNull()
120
+ }
121
+ })
122
+ test('a wall bounding one room resolves that room from either face', () => {
123
+ const { index, nodes } = fixture()
124
+ const cache = new RoomSelectionIndex(levelId)
125
+ cache.update(nodes)
126
+ const west = nodes.wall_west as WallNode
127
+ const east = nodes.wall_east as WallNode
128
+ const left = index.roomAtPoint(levelId, [2, 2])!
129
+ const right = index.roomAtPoint(levelId, [6, 2])!
130
+ for (const view of ['2d', '3d'] as const) {
131
+ for (const x of [-0.1, 0.1]) {
132
+ expect(resolveRoomHit(index, levelId, west, [x, 2], view)?.id).toBe(left.id)
133
+ expect(resolveRoomHit(cache, levelId, west, [x, 2], view)?.id).toBe(left.id)
134
+ expect(resolveRoomHit(index, levelId, east, [8 + x, 2], view)?.id).toBe(right.id)
135
+ }
136
+ }
137
+ })
138
+ test('exterior faces of a wall spanning two rooms resolve the room behind the hit', () => {
139
+ const { index, south } = fixture()
140
+ for (const view of ['2d', '3d'] as const) {
141
+ expect(resolveRoomHit(index, levelId, south, [2, -0.1], view)?.id).toBe(
142
+ index.roomAtPoint(levelId, [2, 2])!.id,
143
+ )
144
+ expect(resolveRoomHit(index, levelId, south, [6, -0.1], view)?.id).toBe(
145
+ index.roomAtPoint(levelId, [6, 2])!.id,
146
+ )
147
+ }
148
+ })
149
+ test("a wall's only room resolves past the stretch it bounds", () => {
150
+ const long = wall('long', [0, 0], [8, 0])
151
+ const { index, nodes } = fixture(
152
+ [],
153
+ [
154
+ long,
155
+ wall('right', [4, 0], [4, 3]),
156
+ wall('top', [4, 3], [0, 3]),
157
+ wall('left', [0, 3], [0, 0]),
158
+ ],
159
+ )
160
+ const cache = new RoomSelectionIndex(levelId)
161
+ cache.update(nodes)
162
+ const room = index.roomAtPoint(levelId, [2, 1])!
163
+ for (const view of ['2d', '3d'] as const) {
164
+ for (const z of [-0.1, 0.1]) {
165
+ expect(resolveRoomHit(index, levelId, long, [6, z], view)?.id).toBe(room.id)
166
+ expect(resolveRoomHit(cache, levelId, long, [6, z], view)?.id).toBe(room.id)
167
+ }
168
+ }
169
+ })
170
+ test('T-junction splits host spans and both T-stem faces', () => {
171
+ const stem = wall('stem', [4, 2], [8, 2])
172
+ const { index, shared } = fixture([stem])
173
+ const lower = index.roomAtPoint(levelId, [6, 1])!
174
+ const upper = index.roomAtPoint(levelId, [6, 3])!
175
+ expect(resolveRoomHit(index, levelId, shared, [4.1, 1])?.id).toBe(lower.id)
176
+ expect(resolveRoomHit(index, levelId, shared, [4.1, 3])?.id).toBe(upper.id)
177
+ expect(resolveRoomHit(index, levelId, stem, [6, 1.9])?.id).toBe(lower.id)
178
+ expect(resolveRoomHit(index, levelId, stem, [6, 2.1])?.id).toBe(upper.id)
179
+ })
180
+ test('dangling stems and free walls fall through', () => {
181
+ const stem = wall('stem', [2, 0], [2, 1])
182
+ const free = wall('free', [10, 0], [10, 3])
183
+ const { index } = fixture([stem, free])
184
+ for (const view of ['2d', '3d'] as const) {
185
+ for (const x of [1.9, 2.1]) {
186
+ expect(resolveRoomHit(index, levelId, stem, [x, 0.5], view)).toBeNull()
187
+ expect(resolveRoomHit(index, levelId, free, [x + 8, 1], view)).toBeNull()
188
+ }
189
+ }
190
+ })
191
+ test('justified 3D faces use the body frame; 2D uses the reference side', () => {
192
+ const { index, nodes, shared } = fixture()
193
+ const justified = { ...shared, justification: 'a' as const }
194
+ index.rebuild({ ...nodes, [shared.id]: justified })
195
+ expect(resolveRoomHit(index, levelId, justified, [4, 2], '3d')?.id).toBe(
196
+ index.roomAtPoint(levelId, [6, 2])!.id,
197
+ )
198
+ expect(resolveRoomHit(index, levelId, justified, [3.8, 2], '3d')?.id).toBe(
199
+ index.roomAtPoint(levelId, [2, 2])!.id,
200
+ )
201
+ expect(resolveRoomHit(index, levelId, justified, [4.01, 2], '2d')?.id).toBe(
202
+ index.roomAtPoint(levelId, [6, 2])!.id,
203
+ )
204
+ })
205
+ test('curved faces use the arc normal and topology chord station', () => {
206
+ const { index, nodes, shared } = fixture()
207
+ const curved = { ...shared, curveOffset: 0.5 }
208
+ index.rebuild({ ...nodes, [shared.id]: curved })
209
+ const frame = getWallCurveFrameAt(curved, 0.3)
210
+ for (const sign of [-1, 1]) {
211
+ const hit: [number, number] = [
212
+ frame.point.x + sign * 0.1 * frame.normal.x,
213
+ frame.point.y + sign * 0.1 * frame.normal.y,
214
+ ]
215
+ const face = sign > 0 ? 'a' : 'b'
216
+ const expected = index.roomAtPoint(levelId, sign > 0 ? [2, 2] : [6, 2])!
217
+ expect(index.roomForWallHit(levelId, curved.id, face, hit[1] / 4)?.id).toBe(expected.id)
218
+ for (const view of ['2d', '3d'] as const)
219
+ expect(resolveRoomHit(index, levelId, curved, hit, view)?.id).toBe(expected.id)
220
+ }
221
+ })
222
+ test('records use the persistent zone id, name and area', () => {
223
+ const polygon: [number, number][] = [
224
+ [0, 0],
225
+ [4, 0],
226
+ [4, 4],
227
+ [0, 4],
228
+ ]
229
+ const slab = SlabNode.parse({ polygon, parentId: levelId, autoFromWalls: true, name: 'Oak' })
230
+ const ceiling = CeilingNode.parse({
231
+ polygon,
232
+ parentId: levelId,
233
+ autoFromWalls: true,
234
+ name: 'Plaster',
235
+ })
236
+ const zone = ZoneNode.parse({ polygon, parentId: levelId, name: 'Kitchen' })
237
+ const { index, nodes } = fixture([slab, ceiling, zone])
238
+ const room = new RoomSelectionIndex(levelId)
239
+ .update(nodes)
240
+ .find((record) => record.id === index.roomAtPoint(levelId, [2, 2])!.id)!
241
+ expect(room).toMatchObject({
242
+ name: 'Kitchen',
243
+ slabId: slab.id,
244
+ ceilingId: ceiling.id,
245
+ zoneId: zone.id,
246
+ })
247
+ expect(room.area).toBe(16)
248
+ expect(room.boundaryWallIds).toHaveLength(4)
249
+ expect(room.key).toEqual({ levelId, zoneId: zone.id })
250
+ })
251
+ // Prod "Wawa House": hand-drawn zones, some walls a few centimetres short of their corners.
252
+ test('hovering a hand-drawn zone resolves the room it became, by its own id and name', () => {
253
+ const source = JSON.parse(
254
+ readFileSync(
255
+ new URL(
256
+ '../../../core/src/utils/__fixtures__/project_hrY3qVVq16yo5Out.json',
257
+ import.meta.url,
258
+ ),
259
+ 'utf8',
260
+ ),
261
+ ) as Record<string, AnyNode>
262
+ const nodes = migrateRoomZones(source).nodes as Record<string, AnyNode>
263
+ const level = 'level_49wv4wg9qwu8cdk9'
264
+ const index = new RoomSelectionIndex(level)
265
+ const records = index.update(nodes)
266
+ for (const name of ['Living Room', 'Master Bedroom', 'Entrance', 'Bathroom']) {
267
+ const zone = Object.values(source).find(
268
+ (node): node is ZoneNode => node.type === 'zone' && node.name === name,
269
+ )!
270
+ const room = nodes[zone.id] as ZoneNode
271
+ const hit = index.roomAtPoint(level, room.seed!)
272
+ expect(hit?.key, name).toEqual({ levelId: level, zoneId: zone.id })
273
+ expect(records.find((record) => record.zoneId === zone.id)?.name).toBe(name)
274
+ }
275
+ expect(records.filter((record) => record.name === 'Room').length).toBe(
276
+ records.filter((record) => !(nodes[record.zoneId] as ZoneNode).name).length,
277
+ )
278
+ expect(new Set(records.map((record) => record.zoneId)).size).toBe(records.length)
279
+ })
280
+ test('room area subtracts holes from the authoritative zone polygon', () => {
281
+ const inner: [number, number][] = [
282
+ [1, 1],
283
+ [3, 1],
284
+ [3, 3],
285
+ [1, 3],
286
+ ]
287
+ const { nodes } = fixture(
288
+ inner.map((start, i) => wall(`inner_${i}`, start, inner[(i + 1) % 4]!)),
289
+ )
290
+ const records = new RoomSelectionIndex(levelId).update(nodes)
291
+ const outer = records.find((room) => room.holes.length === 1)!
292
+ expect(outer.area).toBe(12)
293
+ expect(outer.key).toEqual({ levelId, zoneId: outer.zoneId })
294
+ expect(nodes[outer.zoneId]?.type).toBe('zone')
295
+ })
296
+ test('unrelated node-map edits preserve records; surface metadata preserves geometry', () => {
297
+ const slab = SlabNode.parse({
298
+ id: 'slab_auto',
299
+ parentId: levelId,
300
+ autoFromWalls: true,
301
+ polygon: [
302
+ [0, 0],
303
+ [4, 0],
304
+ [4, 4],
305
+ [0, 4],
306
+ ],
307
+ name: 'Old floor',
308
+ })
309
+ const { nodes } = fixture([slab])
310
+ const cache = new RoomSelectionIndex(levelId)
311
+ const records = cache.update(nodes)
312
+ const room = records.find((record) => record.slabId === slab.id)!
313
+ const delta = spyOn(cache.topology, 'applyWallDelta')
314
+ const metadata = { ...nodes, [buildingId]: { ...nodes[buildingId]!, name: 'Renamed' } }
315
+ expect(cache.update(metadata)).toBe(records)
316
+ expect(
317
+ cache.update({
318
+ ...metadata,
319
+ wall_shared: { ...(nodes.wall_shared as WallNode), name: 'Renamed wall' },
320
+ }),
321
+ ).toBe(records)
322
+ expect(delta).not.toHaveBeenCalled()
323
+ const renamed = cache
324
+ .update({ ...nodes, [slab.id]: { ...slab, name: 'New floor' } })
325
+ .find((record) => record.slabId === slab.id)!
326
+ expect(renamed.slabName).toBe('New floor')
327
+ expect(renamed.geometry).toBe(room.geometry)
328
+ expect(renamed.clearPolygon).toBe(room.clearPolygon)
329
+ expect(cache.roomAtPoint(levelId, [2, 2])?.id).toBe(room.id)
330
+ const removed = { ...nodes }
331
+ delete removed[slab.id]
332
+ expect(
333
+ cache.update(removed).find((record) => record.key.zoneId === room.key.zoneId)?.slabId,
334
+ ).toBeNull()
335
+ delta.mockRestore()
336
+ })
337
+ test('hover queries reuse topology; edits use an incremental delta', () => {
338
+ const { nodes, shared } = fixture()
339
+ const cache = new RoomSelectionIndex(levelId)
340
+ const records = cache.update(nodes)
341
+ const rebuild = spyOn(cache.topology, 'rebuildLevel')
342
+ const delta = spyOn(cache.topology, 'applyWallDelta')
343
+ for (let i = 0; i < 100; i++) {
344
+ expect(cache.update(nodes)).toBe(records)
345
+ resolveRoomHit(cache.topology, levelId, shared, [3.9, 2])
346
+ }
347
+ expect(rebuild).not.toHaveBeenCalled()
348
+ cache.update({ ...nodes, [shared.id]: { ...shared, thickness: 0.3 } })
349
+ expect(delta).toHaveBeenCalledTimes(1)
350
+ expect(rebuild).not.toHaveBeenCalled()
351
+ rebuild.mockRestore()
352
+ delta.mockRestore()
353
+ })
354
+ })
355
+
356
+ async function withRooms(
357
+ run: (harness: {
358
+ click: (
359
+ id: string,
360
+ point: [number, number],
361
+ modifiers?: Partial<typeof plain>,
362
+ options?: {
363
+ grid?: boolean
364
+ handle?: boolean
365
+ handleBehind?: boolean
366
+ event?: 'click' | 'enter' | 'move' | 'pointerdown'
367
+ },
368
+ ) => Promise<boolean>
369
+ left: RoomKey
370
+ right: RoomKey
371
+ nodes: Record<string, AnyNode>
372
+ selectedRoom: () => ReturnType<typeof useSelectedRoom>
373
+ /** Whether the purple room highlight (its outline) is mounted. */
374
+ highlighted: () => boolean
375
+ }) => Promise<void>,
376
+ ) {
377
+ await withSelectionHarness(async ({ render, canvas }) => {
378
+ const polygon: [number, number][] = [
379
+ [0, 0],
380
+ [4, 0],
381
+ [4, 4],
382
+ [0, 4],
383
+ ]
384
+ const { index, nodes } = fixture([
385
+ SlabNode.parse({ id: 'slab_test', parentId: levelId, polygon }),
386
+ CeilingNode.parse({ id: 'ceiling_test', parentId: levelId, polygon }),
387
+ wall('free', [10, 0], [10, 3]),
388
+ ])
389
+ useScene.setState({ nodes })
390
+ useViewer.setState({
391
+ selection: { buildingId, levelId, zoneId: null, selectedIds: [] },
392
+ inputDragging: false,
393
+ focusedUnitId: null,
394
+ })
395
+ useEditor.setState({
396
+ phase: 'building',
397
+ mode: 'select',
398
+ toolMode: { mode: 'select' },
399
+ room: null,
400
+ hoveredRoom: null,
401
+ })
402
+ useInteractionScope.getState().end()
403
+ useSessionGroups.getState().clearGroups()
404
+ sceneRegistry.nodes.set(levelId, new Group())
405
+ let selected: ReturnType<typeof useSelectedRoom> = null
406
+ function Observer() {
407
+ selected = useSelectedRoom()
408
+ return null
409
+ }
410
+ try {
411
+ await render(
412
+ createElement('group', null, createElement(SelectionManager), createElement(Observer)),
413
+ )
414
+ const click = async (
415
+ id: string,
416
+ point: [number, number],
417
+ modifiers: Partial<typeof plain> = {},
418
+ options: {
419
+ grid?: boolean
420
+ handle?: boolean
421
+ /** An editor handle's hit area lies further along the same ray. */
422
+ handleBehind?: boolean
423
+ event?: 'click' | 'enter' | 'move' | 'pointerdown'
424
+ } = {},
425
+ ) => {
426
+ const node = useScene.getState().nodes[id as AnyNode['id']]!
427
+ let stopped = false
428
+ const object = new Group()
429
+ object.name = options.grid ? 'ceiling-grid' : 'surface'
430
+ const event = {
431
+ node,
432
+ object,
433
+ position: [point[0], 0, point[1]],
434
+ localPosition: [point[0], 0, point[1]],
435
+ viaHandle: options.handle,
436
+ stopPropagation: () => {
437
+ stopped = true
438
+ },
439
+ nativeEvent: {
440
+ object,
441
+ intersections: options.handleBehind
442
+ ? [{ object }, { object: { userData: { editorHandleHitArea: true } } }]
443
+ : [{ object }],
444
+ altKey: false,
445
+ shiftKey: false,
446
+ ctrlKey: false,
447
+ metaKey: false,
448
+ button: 0,
449
+ ...Object.fromEntries(
450
+ Object.entries(modifiers).map(([key, value]) => [`${key}Key`, value]),
451
+ ),
452
+ },
453
+ } as unknown as NodeEvent
454
+ await act(async () =>
455
+ emitter.emit(
456
+ `${node.type}:${options.event ?? 'click'}` as 'wall:click',
457
+ event as NodeEvent<WallNode>,
458
+ ),
459
+ )
460
+ return stopped
461
+ }
462
+ await run({
463
+ click,
464
+ nodes,
465
+ left: new RoomSelectionIndex(levelId)
466
+ .update(nodes)
467
+ .find((room) => room.id === index.roomAtPoint(levelId, [2, 2])!.id)!.key,
468
+ right: new RoomSelectionIndex(levelId)
469
+ .update(nodes)
470
+ .find((room) => room.id === index.roomAtPoint(levelId, [6, 2])!.id)!.key,
471
+ selectedRoom: () => selected,
472
+ highlighted: () => {
473
+ let found = false
474
+ _roots
475
+ .get(canvas)!
476
+ .store.getState()
477
+ .scene.traverse((object) => {
478
+ if ((object as LineSegments).isLineSegments) found = true
479
+ })
480
+ return found
481
+ },
482
+ })
483
+ } finally {
484
+ await render(null)
485
+ sceneRegistry.nodes.delete(levelId)
486
+ }
487
+ })
488
+ }
489
+
490
+ describe('room drill-down state through the mounted selection manager', () => {
491
+ test('plain clicks select a room, drill into its wall, and replace it with another room', async () => {
492
+ await withRooms(async ({ click, left, right }) => {
493
+ await click('wall_south', [2, 0.1])
494
+ expect(useEditor.getState().room).toEqual(left)
495
+ expect(useViewer.getState().selection.selectedIds).toEqual([])
496
+ await click('wall_south', [2, 0.1])
497
+ expect(useViewer.getState().selection.selectedIds).toEqual(['wall_south'])
498
+ await click('wall_shared', [4.1, 2])
499
+ expect(useEditor.getState().room).toEqual(right)
500
+ expect(useViewer.getState().selection.selectedIds).toEqual([])
501
+ })
502
+ })
503
+ test("a single-room wall's exterior face hovers and selects its room, then drills into the wall", async () => {
504
+ await withRooms(async ({ click, left }) => {
505
+ await click('wall_west', [-0.1, 2], {}, { event: 'enter' })
506
+ expect(useEditor.getState().hoveredRoom).toEqual(left)
507
+ expect(useViewer.getState().hoveredId).toBeNull()
508
+ await click('wall_west', [-0.1, 2])
509
+ expect(useEditor.getState().room).toEqual(left)
510
+ expect(useViewer.getState().selection.selectedIds).toEqual([])
511
+ await click('wall_west', [-0.1, 2])
512
+ expect(useViewer.getState().selection.selectedIds).toEqual(['wall_west'])
513
+ expect(useEditor.getState().room).toEqual(left)
514
+ })
515
+ })
516
+ test('a stair flight hovers the same whole stair its click selects', async () => {
517
+ await withRooms(async ({ click }) => {
518
+ const stair = StairNode.parse({ id: 'stair_hover', parentId: levelId, position: [6, 0, 2] })
519
+ const segment = {
520
+ id: 'stair-segment_hover',
521
+ type: 'stair-segment',
522
+ parentId: stair.id,
523
+ } as unknown as AnyNode
524
+ await act(async () =>
525
+ useScene.setState({
526
+ nodes: { ...useScene.getState().nodes, [stair.id]: stair, [segment.id]: segment },
527
+ }),
528
+ )
529
+ await click(segment.id, [6, 2], {}, { event: 'enter' })
530
+ expect(useViewer.getState().hoveredId).toBe(stair.id)
531
+ await click(segment.id, [6, 2])
532
+ expect(useViewer.getState().selection.selectedIds).toEqual([stair.id])
533
+ })
534
+ })
535
+
536
+ test('from site any hit on the house hovers and picks the building, then goes inside', async () => {
537
+ for (const [id, point] of [
538
+ ['wall_west', [-0.1, 2]],
539
+ ['slab_test', [2, 2]],
540
+ ['ceiling_test', [2, 2]],
541
+ ] as const) {
542
+ await withRooms(async ({ click, highlighted, left }) => {
543
+ await act(async () => {
544
+ useEditor.getState().setPhase('site')
545
+ useViewer.getState().setSelection({ buildingId: null })
546
+ })
547
+ expect(await click(id, [...point], {}, { event: 'enter' })).toBe(true)
548
+ expect(useViewer.getState().hoveredId).toBe(buildingId)
549
+ expect(useEditor.getState().hoveredRoom).toBeNull()
550
+ expect(highlighted()).toBe(false)
551
+ expect(await click(id, [...point])).toBe(true)
552
+ expect(useEditor.getState().phase).toBe('building')
553
+ expect(useViewer.getState().selection.buildingId).toBe(buildingId)
554
+ expect(useViewer.getState().selection.selectedIds).toEqual([])
555
+ expect(useEditor.getState().room).toBeNull()
556
+ // Inside, the same spot is room-first again.
557
+ await click(id, [...point])
558
+ expect(useEditor.getState().phase).toBe('building')
559
+ expect(useEditor.getState().room).toEqual(left)
560
+ })
561
+ }
562
+ })
563
+ test('room clicks and the drill click into a wall never change the mode', async () => {
564
+ await withRooms(async ({ click, left, right }) => {
565
+ expect(await click('wall_south', [2, 0.1])).toBe(true)
566
+ expect(useEditor.getState().phase).toBe('building')
567
+ expect(useEditor.getState().room).toEqual(left)
568
+ expect(useViewer.getState().selection.selectedIds).toEqual([])
569
+ expect(await click('wall_shared', [4.1, 2])).toBe(true)
570
+ expect(useEditor.getState().room).toEqual(right)
571
+ // Drilling past the room selects the wall, the room stays its context, and
572
+ // Escape climbs back to the room.
573
+ await click('wall_shared', [4.1, 2])
574
+ expect(useEditor.getState().phase).toBe('building')
575
+ expect(useViewer.getState().selection.selectedIds).toEqual(['wall_shared'])
576
+ expect(useEditor.getState().room).toEqual(right)
577
+ await act(async () => cancelActiveTool())
578
+ expect(useViewer.getState().selection.selectedIds).toEqual([])
579
+ expect(useEditor.getState().room).toEqual(right)
580
+ })
581
+ })
582
+ test('going to site drops the room', async () => {
583
+ await withRooms(async ({ click, left }) => {
584
+ await click('wall_south', [2, 0.1])
585
+ expect(useEditor.getState().room).toEqual(left)
586
+ await act(async () => useEditor.getState().setPhase('site'))
587
+ expect(useEditor.getState().room).toBeNull()
588
+ })
589
+ })
590
+ test('furniture, stairs, spawn and walls all select in place; furniture and ground end the room', async () => {
591
+ jest.useFakeTimers()
592
+ const chair = ItemNode.parse({
593
+ id: 'item_chair',
594
+ parentId: levelId,
595
+ position: [2, 0, 2],
596
+ asset: {
597
+ id: 'asset:chair',
598
+ category: 'furniture',
599
+ name: 'Chair',
600
+ thumbnail: '/chair.jpg',
601
+ src: '/chair.glb',
602
+ },
603
+ })
604
+ const stair = StairNode.parse({ id: 'stair_test', parentId: levelId, position: [6, 0, 2] })
605
+ const spawn = SpawnNode.parse({ id: 'spawn_test', parentId: levelId, position: [1, 0, 1] })
606
+ await withRooms(async ({ click, left }) => {
607
+ await act(async () => {
608
+ useScene.setState({
609
+ nodes: {
610
+ ...useScene.getState().nodes,
611
+ [chair.id]: chair,
612
+ [stair.id]: stair,
613
+ [spawn.id]: spawn,
614
+ },
615
+ })
616
+ })
617
+ await click('wall_south', [2, 0.1])
618
+ expect(useEditor.getState().room).toEqual(left)
619
+ await click(chair.id, [2, 2])
620
+ expect(useEditor.getState().phase).toBe('building')
621
+ expect(useViewer.getState().selection.selectedIds).toEqual([chair.id])
622
+ expect(useEditor.getState().room).toBeNull()
623
+
624
+ jest.advanceTimersByTime(50)
625
+ await click(stair.id, [6, 2])
626
+ expect(useEditor.getState().phase).toBe('building')
627
+ expect(useViewer.getState().selection.selectedIds).toEqual([stair.id])
628
+ jest.advanceTimersByTime(50)
629
+ await click(spawn.id, [1, 1])
630
+ expect(useEditor.getState().phase).toBe('building')
631
+ expect(useViewer.getState().selection.selectedIds).toEqual([spawn.id])
632
+
633
+ jest.advanceTimersByTime(50)
634
+ await click('wall_south', [2, 0.1])
635
+ expect(useEditor.getState().phase).toBe('building')
636
+ expect(useEditor.getState().room).toEqual(left)
637
+ // The room click guards the same pointer's ground click for 50 ms.
638
+ jest.advanceTimersByTime(50)
639
+ await act(async () =>
640
+ emitter.emit('grid:click', {
641
+ position: [20, 0, 20],
642
+ localPosition: [20, 0, 20],
643
+ nativeEvent: {},
644
+ } as never),
645
+ )
646
+ expect(useEditor.getState().room).toBeNull()
647
+ })
648
+ })
649
+ test('plan and box furniture selections end the selected room', async () => {
650
+ await withRooms(async ({ click, left }) => {
651
+ const chair = ItemNode.parse({
652
+ id: 'item_plan_chair',
653
+ parentId: levelId,
654
+ asset: {
655
+ id: 'asset:chair',
656
+ category: 'furniture',
657
+ name: 'Chair',
658
+ thumbnail: '/chair.jpg',
659
+ src: '/chair.glb',
660
+ },
661
+ })
662
+ await act(async () => {
663
+ useScene.setState({ nodes: { ...useScene.getState().nodes, [chair.id]: chair } })
664
+ })
665
+ await click('wall_south', [2, 0.1])
666
+ expect(useEditor.getState().room).toEqual(left)
667
+ await act(async () => useViewer.getState().setSelection({ selectedIds: [chair.id] }))
668
+ expect(useEditor.getState().room).toBeNull()
669
+ expect(useViewer.getState().selection.selectedIds).toEqual([chair.id])
670
+ await act(async () => cancelActiveTool())
671
+ expect(useViewer.getState().selection.selectedIds).toEqual([])
672
+ expect(useEditor.getState().room).toBeNull()
673
+ })
674
+ })
675
+ test('arming furniture placement from a selected room leaves floor and wall clicks to the tool', async () => {
676
+ await withRooms(async ({ click, left }) => {
677
+ await click('wall_south', [2, 0.1])
678
+ expect(useEditor.getState().room).toEqual(left)
679
+ await act(async () => useEditor.getState().armToolMode({ mode: 'build', tool: 'item' }))
680
+ expect(useEditor.getState().room).toBeNull()
681
+ expect(await click('slab_test', [2, 2])).toBe(false)
682
+ expect(await click('wall_south', [2, 0.1])).toBe(false)
683
+ expect(useEditor.getState().phase).toBe('building')
684
+ expect(useEditor.getState().tool).toBe('item')
685
+ expect(useViewer.getState().selection.selectedIds).toEqual([])
686
+ })
687
+ })
688
+ test('item placement owns floor and wall hits until the placement ends', async () => {
689
+ for (const attachTo of ['floor', 'wall'] as const) {
690
+ await withRooms(async ({ click, highlighted, left }) => {
691
+ const item = ItemNode.parse({
692
+ asset: {
693
+ id: 'asset:placement',
694
+ category: 'furniture',
695
+ name: 'Placement item',
696
+ thumbnail: '/item.jpg',
697
+ src: '/item.glb',
698
+ ...(attachTo === 'wall' ? { attachTo } : {}),
699
+ },
700
+ })
701
+ await act(async () => {
702
+ useInteractionScope.getState().begin({
703
+ kind: 'placing',
704
+ node: item,
705
+ nodeId: item.id,
706
+ nodeType: item.type,
707
+ view: '3d',
708
+ pressDrag: false,
709
+ driver: 'registry-tool',
710
+ })
711
+ })
712
+ const id = attachTo === 'floor' ? 'slab_test' : 'wall_west'
713
+ const point: [number, number] = attachTo === 'floor' ? [2, 2] : [-0.1, 2]
714
+ expect(await click(id, point, {}, { event: 'move' })).toBe(false)
715
+ expect(await click(id, point, {}, { event: 'pointerdown' })).toBe(false)
716
+ expect(await click(id, point)).toBe(false)
717
+ expect(useEditor.getState().hoveredRoom).toBeNull()
718
+ expect(useEditor.getState().room).toBeNull()
719
+ expect(useEditor.getState().phase).toBe('building')
720
+ expect(highlighted()).toBe(false)
721
+ await act(async () => useInteractionScope.getState().end())
722
+ await click(id, point, {}, { event: 'move' })
723
+ expect(useEditor.getState().hoveredRoom).toEqual(left)
724
+ expect(highlighted()).toBe(true)
725
+ })
726
+ }
727
+ })
728
+ test('room hover returns after paint Escape and after cancelling Divide with Escape or undo', async () => {
729
+ await withRooms(async ({ click, left, right, highlighted }) => {
730
+ await act(async () => useEditor.getState().armMaterialPaint())
731
+ expect(highlighted()).toBe(false)
732
+ await act(async () => cancelActiveTool())
733
+ await click('wall_west', [-0.1, 2], {}, { event: 'move' })
734
+ expect(useEditor.getState().hoveredRoom).toEqual(left)
735
+ expect(highlighted()).toBe(true)
736
+ await click('wall_west', [-0.1, 2])
737
+ for (const cancel of [cancelActiveTool, () => runHistoryShortcut('undo')]) {
738
+ await act(async () => startRoomDivide(left.zoneId, levelId))
739
+ expect(useInteractionScope.getState().scope.kind).toBe('room-divide')
740
+ expect(await click('wall_east', [7.9, 2], {}, { event: 'move' })).toBe(false)
741
+ expect(highlighted()).toBe(false)
742
+ await act(async () => cancel())
743
+ expect(useInteractionScope.getState().scope.kind).toBe('idle')
744
+ await click('wall_east', [7.9, 2], {}, { event: 'move' })
745
+ expect(useEditor.getState().hoveredRoom).toEqual(right)
746
+ expect(highlighted()).toBe(true)
747
+ }
748
+ })
749
+ })
750
+ test('room arrow release, cancel and undo restore hover picking', async () => {
751
+ const withCursorDocument = (run: () => void) => {
752
+ const previousDocument = globalThis.document
753
+ globalThis.document ??= { body: { style: { cursor: '' } } } as unknown as Document
754
+ try {
755
+ run()
756
+ } finally {
757
+ globalThis.document = previousDocument
758
+ }
759
+ }
760
+ for (const finish of [
761
+ () => window.dispatchEvent(new Event('pointerup')),
762
+ () => window.dispatchEvent(new Event('pointercancel')),
763
+ () => window.dispatchEvent(Object.assign(new Event('keydown'), { key: 'Escape' })),
764
+ () => runHistoryShortcut('undo'),
765
+ ]) {
766
+ await withRooms(async ({ click, left, right, highlighted }) => {
767
+ await click('wall_west', [-0.1, 2])
768
+ await act(async () =>
769
+ withCursorDocument(() => {
770
+ runRoomHandleDrag({
771
+ label: 'wall-push',
772
+ nodeId: 'wall_west',
773
+ zoneId: left.zoneId,
774
+ levelId,
775
+ requires: [left.zoneId, 'wall_west'],
776
+ sample: () => 0,
777
+ onValue: () => {},
778
+ onCommit: () => {},
779
+ onCancel: () => {},
780
+ })
781
+ }),
782
+ )
783
+ expect(useViewer.getState().inputDragging).toBe(true)
784
+ expect(highlighted()).toBe(false)
785
+ expect(await click('wall_east', [7.9, 2], {}, { event: 'move' })).toBe(false)
786
+ await act(async () => {
787
+ withCursorDocument(() => {
788
+ finish()
789
+ })
790
+ })
791
+ expect(useInteractionScope.getState().scope.kind).toBe('idle')
792
+ expect(useViewer.getState().inputDragging).toBe(false)
793
+ await click('wall_east', [7.9, 2], {}, { event: 'move' })
794
+ expect(useEditor.getState().hoveredRoom).toEqual(right)
795
+ expect(highlighted()).toBe(true)
796
+ })
797
+ }
798
+ })
799
+ test('a handle behind a wall owns the hover, as it owns the press', async () => {
800
+ await withRooms(async ({ click, left }) => {
801
+ // Without a handle the wall's exterior hover resolves its room and stops there.
802
+ expect(await click('wall_west', [-0.1, 2], {}, { event: 'enter' })).toBe(true)
803
+ expect(useEditor.getState().hoveredRoom).toEqual(left)
804
+ // With a handle further along the ray: no stopPropagation (the handle hears
805
+ // its enter) and the wall / room hover steps aside.
806
+ expect(await click('wall_west', [-0.1, 2], {}, { event: 'move', handleBehind: true })).toBe(
807
+ false,
808
+ )
809
+ expect(useEditor.getState().hoveredRoom).toBeNull()
810
+ expect(useViewer.getState().hoveredId).toBeNull()
811
+ expect(await click('wall_free', [9.9, 1], {}, { event: 'enter', handleBehind: true })).toBe(
812
+ false,
813
+ )
814
+ expect(useViewer.getState().hoveredId).toBeNull()
815
+ })
816
+ })
817
+ test('a wall bounding no room hovers and selects the wall directly', async () => {
818
+ await withRooms(async ({ click }) => {
819
+ await click('wall_free', [9.9, 1], {}, { event: 'enter' })
820
+ expect(useEditor.getState().hoveredRoom).toBeNull()
821
+ expect(useViewer.getState().hoveredId).toBe('wall_free')
822
+ await click('wall_free', [9.9, 1])
823
+ expect(useEditor.getState().room).toBeNull()
824
+ expect(useViewer.getState().selection.selectedIds).toEqual(['wall_free'])
825
+ })
826
+ })
827
+ test('the 2D plan resolves walls room-first and drills on the second click', async () => {
828
+ await withRooms(async ({ left, right }) => {
829
+ expect(resolvePlanRoomHit('wall_west', [-0.05, 2])).toEqual(left)
830
+ expect(resolvePlanRoomHit('wall_shared', [3.95, 2])).toEqual(left)
831
+ expect(resolvePlanRoomHit('wall_shared', [4.05, 2])).toEqual(right)
832
+ expect(resolvePlanRoomHit('wall_free', [9.95, 1])).toBeNull()
833
+ const hit = resolvePlanRoomHit('wall_west', [-0.05, 2])
834
+ const select = async (modifiers = plain) => {
835
+ let selected = false
836
+ await act(async () => {
837
+ selected = selectRoomFromHit(hit, modifiers, 'wall_west')
838
+ })
839
+ return selected
840
+ }
841
+ expect(await select()).toBe(true)
842
+ expect(useEditor.getState().room).toEqual(left)
843
+ expect(await select()).toBe(false)
844
+ expect(await select({ ...plain, alt: true })).toBe(false)
845
+ })
846
+ })
847
+ test('the 2D plan picks rooms inside the building, not in site', async () => {
848
+ await withRooms(async () => {
849
+ for (const [phase, enabled] of [
850
+ ['building', true],
851
+ ['site', false],
852
+ ] as const) {
853
+ await act(async () => useEditor.setState({ phase }))
854
+ expect(roomPickingEnabled()).toBe(enabled)
855
+ }
856
+ })
857
+ })
858
+ test('Escape climbs from element to room to nothing', async () => {
859
+ await withRooms(async ({ click, left }) => {
860
+ await click('wall_south', [2, 0.1])
861
+ await click('wall_south', [2, 0.1])
862
+ await act(async () => cancelActiveTool())
863
+ expect(useViewer.getState().selection.selectedIds).toEqual([])
864
+ expect(useEditor.getState().room).toEqual(left)
865
+ await act(async () => cancelActiveTool())
866
+ expect(useEditor.getState().room).toBeNull()
867
+ })
868
+ })
869
+ test('Alt bypass and toggles select elements while preserving room context', async () => {
870
+ await withRooms(async ({ click, left, right }) => {
871
+ await click('wall_south', [2, 0.1])
872
+ await click('wall_shared', [4.1, 2], { alt: true })
873
+ expect(useViewer.getState().selection.selectedIds).toEqual(['wall_shared'])
874
+ expect(useEditor.getState().room).toEqual(left)
875
+ for (const modifier of ['shift', 'ctrl', 'meta'] as const) {
876
+ await click('wall_east', [7.9, 2], { [modifier]: true })
877
+ expect(useViewer.getState().selection.selectedIds).toEqual(['wall_shared', 'wall_east'])
878
+ await click('wall_east', [7.9, 2], { [modifier]: true })
879
+ expect(useViewer.getState().selection.selectedIds).toEqual(['wall_shared'])
880
+ expect(useEditor.getState().room).toEqual(left)
881
+ expect(shouldSelectRoom(null, right, { ...plain, [modifier]: true })).toBe(false)
882
+ }
883
+ await act(async () => useEditor.getState().clearRoom())
884
+ expect(useEditor.getState().room).toBeNull()
885
+ })
886
+ })
887
+ test('moving the shared boundary widens the selected room without changing its key', async () => {
888
+ await withRooms(async ({ click, left, nodes, selectedRoom }) => {
889
+ await click('wall_south', [2, 0.1])
890
+ const previous = selectedRoom()!
891
+ const moved: Record<string, AnyNode> = {
892
+ ...nodes,
893
+ wall_shared: { ...(nodes.wall_shared as WallNode), start: [5, 0], end: [5, 4] } as WallNode,
894
+ }
895
+ const plan = reconcileLevelStructure({
896
+ levelId,
897
+ nodes: moved,
898
+ mintId: (kind) => {
899
+ if (kind === 'zone') throw new Error('Move must retain identity')
900
+ return `${kind}_moved_preview`
901
+ },
902
+ })
903
+ for (const patch of plan.patches)
904
+ if (patch.op === 'update')
905
+ moved[patch.id] = { ...moved[patch.id], ...patch.data } as AnyNode
906
+ await act(async () => useScene.setState({ nodes: moved }))
907
+ const widened = selectedRoom()!
908
+ expect(useEditor.getState().room).toEqual(left)
909
+ expect(widened.key).toEqual(left)
910
+ expect(widened.id).not.toBe(previous.id)
911
+ expect(widened.area).toBeGreaterThan(previous.area)
912
+ expect(widened.geometry).not.toBe(previous.geometry)
913
+ })
914
+ })
915
+ test('live session groups expand before room interception; Alt isolates and deleted peers are inert', async () => {
916
+ await withRooms(async ({ click, left, nodes }) => {
917
+ useSessionGroups
918
+ .getState()
919
+ .setGroups([{ id: 'group-test', label: 'Test', memberIds: ['wall_south', 'wall_north'] }])
920
+ expect(shouldInterceptRoom(left, plain, 'wall_south')).toBe(false)
921
+ expect(selectRoomFromHit(left, plain, 'wall_south')).toBe(false)
922
+ await click('wall_south', [2, 0.1])
923
+ expect(useEditor.getState().room).toBeNull()
924
+ expect(useViewer.getState().selection.selectedIds).toEqual(['wall_south', 'wall_north'])
925
+ await click('wall_south', [2, 0.1], { alt: true })
926
+ expect(useViewer.getState().selection.selectedIds).toEqual(['wall_south'])
927
+ await click('wall_south', [2, 0.1])
928
+ expect(useViewer.getState().selection.selectedIds).toEqual(['wall_south', 'wall_north'])
929
+ const { wall_north: _, ...remaining } = nodes
930
+ await act(async () => useScene.setState({ nodes: remaining }))
931
+ expect(shouldInterceptRoom(left, plain, 'wall_south')).toBe(true)
932
+ })
933
+ })
934
+ test('ceiling grids pass through without propagation stops; real surfaces and handles select', async () => {
935
+ await withRooms(async ({ click, left }) => {
936
+ for (const event of ['click', 'enter', 'pointerdown'] as const) {
937
+ expect(await click('ceiling_test', [2, 2], {}, { grid: true, event })).toBe(false)
938
+ expect(useEditor.getState().room).toBeNull()
939
+ expect(useViewer.getState().selection.selectedIds).toEqual([])
940
+ }
941
+ expect(await click('ceiling_test', [2, 2])).toBe(true)
942
+ expect(useEditor.getState().room).toEqual(left)
943
+ expect(await click('ceiling_test', [2, 2], {}, { handle: true })).toBe(true)
944
+ expect(useViewer.getState().selection.selectedIds).toEqual(['ceiling_test'])
945
+ expect(await click('ceiling_test', [2, 2], {}, { grid: true })).toBe(false)
946
+ expect(await click('wall_south', [2, 0.1])).toBe(true)
947
+ expect(useViewer.getState().selection.selectedIds).toEqual(['wall_south'])
948
+ })
949
+ })
950
+ })
951
+
952
+ test('room store setters only write editor state; the selection command owns cross-store updates', () => {
953
+ const { index, nodes } = fixture()
954
+ useScene.setState({ nodes })
955
+ const key = new RoomSelectionIndex(levelId)
956
+ .update(nodes)
957
+ .find((room) => room.id === index.roomAtPoint(levelId, [2, 2])!.id)!.key
958
+ const selection = useViewer.getState().selection
959
+ useEditor.getState().selectRoom(key)
960
+ expect(useEditor.getState().room).toEqual(key)
961
+ expect(useViewer.getState().selection).toBe(selection)
962
+ selectRoom(key)
963
+ expect(useViewer.getState().selection).toMatchObject({
964
+ buildingId,
965
+ levelId,
966
+ selectedIds: [],
967
+ zoneId: null,
968
+ })
969
+ const nextSelection = useViewer.getState().selection
970
+ useEditor.getState().clearRoom()
971
+ expect(useEditor.getState().room).toBeNull()
972
+ expect(useViewer.getState().selection).toBe(nextSelection)
973
+ })