@pascal-app/editor 1.0.3 → 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 (440) hide show
  1. package/package.json +11 -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 +202 -57
  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 +36 -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 +344 -342
  27. package/src/components/editor/footprint-height-handle.tsx +204 -0
  28. package/src/components/editor/group-actions.ts +54 -2
  29. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  30. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  31. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  32. package/src/components/editor/handles/use-handle-drag.ts +269 -197
  33. package/src/components/editor/index.tsx +294 -169
  34. package/src/components/editor/join-walls-pill.tsx +44 -0
  35. package/src/components/editor/node-action-menu.tsx +68 -70
  36. package/src/components/editor/node-arrow-handles.tsx +25 -6
  37. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  38. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  39. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  40. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  41. package/src/components/editor/registry-action-contributions.tsx +80 -1
  42. package/src/components/editor/room-controls.tsx +847 -0
  43. package/src/components/editor/room-floor-highlight.tsx +128 -0
  44. package/src/components/editor/room-handles-2d.tsx +354 -0
  45. package/src/components/editor/room-handles.tsx +675 -0
  46. package/src/components/editor/room-highlight.tsx +225 -0
  47. package/src/components/editor/room-labels.test.ts +357 -0
  48. package/src/components/editor/room-labels.tsx +990 -0
  49. package/src/components/editor/room-transform-ghost.tsx +256 -0
  50. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  51. package/src/components/editor/selection-manager.tsx +451 -317
  52. package/src/components/editor/slab-hole-highlights.tsx +3 -0
  53. package/src/components/editor/snapshot-capture-overlay.tsx +4 -0
  54. package/src/components/editor/snapshot-capture.test.ts +52 -4
  55. package/src/components/editor/snapshot-capture.ts +30 -5
  56. package/src/components/editor/thumbnail-generator.test.ts +308 -0
  57. package/src/components/editor/thumbnail-generator.tsx +1035 -229
  58. package/src/components/editor/use-floorplan-background-placement.ts +31 -11
  59. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  60. package/src/components/editor/views/host-view.tsx +61 -0
  61. package/src/components/editor/views/plugins-view.tsx +670 -0
  62. package/src/components/editor/views/scene-region.tsx +59 -0
  63. package/src/components/editor/views/use-editor-views.ts +57 -0
  64. package/src/components/editor/views/view-bar.tsx +220 -0
  65. package/src/components/editor/wall-justification.test.ts +140 -0
  66. package/src/components/editor/wall-move-side-handles.tsx +43 -142
  67. package/src/components/editor/wall-push-arrow.tsx +92 -0
  68. package/src/components/editor/wall-region-handles.tsx +509 -0
  69. package/src/components/editor/wall-snap-beacon-layer.tsx +3 -0
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  71. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  72. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  73. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  74. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +113 -102
  75. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  76. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  77. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  78. package/src/components/editor-2d/renderers/{floorplan-draft-wall-measurement.tsx → floorplan-draft-measurement.tsx} +8 -8
  79. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  80. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +38 -8
  81. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +291 -227
  82. package/src/components/editor-2d/svg-paths.ts +12 -15
  83. package/src/components/procedural-items/inspector.tsx +40 -15
  84. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  85. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  86. package/src/components/systems/zone/zone-system.test.ts +47 -0
  87. package/src/components/systems/zone/zone-system.tsx +29 -68
  88. package/src/components/tools/item/placement-history.test.tsx +474 -0
  89. package/src/components/tools/item/placement-strategies.ts +8 -4
  90. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  91. package/src/components/tools/item/use-draft-node.ts +166 -95
  92. package/src/components/tools/item/use-placement-coordinator.tsx +93 -46
  93. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  94. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  95. package/src/components/tools/registry/move-registry-node-tool.tsx +3 -1
  96. package/src/components/tools/select/box-select-tool.tsx +2 -12
  97. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  98. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  99. package/src/components/tools/select/select-candidates.test.ts +37 -4
  100. package/src/components/tools/select/select-candidates.ts +15 -11
  101. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  102. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  103. package/src/components/tools/stair/stair-defaults.ts +0 -3
  104. package/src/components/tools/stair/stair-tool.tsx +90 -42
  105. package/src/components/tools/tool-manager.tsx +46 -20
  106. package/src/components/tools/wall/wall-drafting.test.ts +19 -4
  107. package/src/components/tools/zone/zone-tool.tsx +4 -12
  108. package/src/components/ui/action-menu/action-button.tsx +3 -0
  109. package/src/components/ui/action-menu/camera-actions.tsx +2 -1
  110. package/src/components/ui/action-menu/control-modes.tsx +2 -35
  111. package/src/components/ui/action-menu/measurement-control.tsx +3 -10
  112. package/src/components/ui/build-panel/build-panel.tsx +235 -0
  113. package/src/components/ui/command-palette/editor-commands.tsx +4 -30
  114. package/src/components/ui/command-palette/index.tsx +5 -24
  115. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  116. package/src/components/ui/controls/material-paint-panel.tsx +97 -26
  117. package/src/components/ui/controls/material-picker.tsx +4 -1
  118. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  119. package/src/components/ui/controls/select-control.tsx +65 -0
  120. package/src/components/ui/floating-level-selector.tsx +9 -14
  121. package/src/components/ui/helpers/building-helper.tsx +4 -1
  122. package/src/components/ui/helpers/contextual-helper-panel.tsx +154 -70
  123. package/src/components/ui/helpers/helper-manager.test.tsx +69 -0
  124. package/src/components/ui/helpers/helper-manager.tsx +325 -14
  125. package/src/components/ui/helpers/item-helper.tsx +8 -0
  126. package/src/components/ui/helpers/registered-tool-helper.tsx +8 -1
  127. package/src/components/ui/icon-ref.tsx +45 -1
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/node-icon-image.tsx +16 -0
  130. package/src/components/ui/panels/collections/collections-popover.tsx +20 -14
  131. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  132. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  133. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  134. package/src/components/ui/panels/mobile-selection-bar.tsx +2 -2
  135. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  136. package/src/components/ui/panels/multi-field-value.ts +6 -3
  137. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  138. package/src/components/ui/panels/multi-selection-panel.tsx +43 -4
  139. package/src/components/ui/panels/node-display.ts +8 -3
  140. package/src/components/ui/panels/panel-manager.tsx +103 -68
  141. package/src/components/ui/panels/panel-wrapper.tsx +78 -36
  142. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  143. package/src/components/ui/panels/parametric-inspector.tsx +4 -4
  144. package/src/components/ui/panels/reference-panel.tsx +2 -3
  145. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  146. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  147. package/src/components/ui/panels/room-name-options.ts +47 -0
  148. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  149. package/src/components/ui/panels/room-panel.tsx +725 -0
  150. package/src/components/ui/panels/shape-choice.tsx +58 -0
  151. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  152. package/src/components/ui/primitives/color-dot.tsx +36 -24
  153. package/src/components/ui/primitives/sidebar.tsx +2 -0
  154. package/src/components/ui/right-stack.test.ts +29 -0
  155. package/src/components/ui/right-stack.tsx +145 -0
  156. package/src/components/ui/sidebar/icon-rail.tsx +5 -0
  157. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +31 -4
  158. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +5 -5
  159. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  160. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  161. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  162. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  163. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  164. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  165. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  166. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  167. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  168. package/src/components/ui/sidebar/panels/site-panel/index.tsx +176 -211
  169. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  170. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  171. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  172. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  173. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  174. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  175. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  176. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  177. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  178. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  179. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +0 -27
  180. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  181. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  182. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  183. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  184. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +0 -2
  185. package/src/components/ui/sidebar/tab-bar.tsx +10 -0
  186. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  187. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  188. package/src/components/viewer/floorplan-preview.tsx +5 -6
  189. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  190. package/src/components/viewer/viewer-rooms.tsx +175 -0
  191. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  192. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  193. package/src/components/viewer/viewer-stage.tsx +2 -2
  194. package/src/components/viewer-zone-system.tsx +3 -8
  195. package/src/hooks/use-auto-save.integration.test.tsx +123 -9
  196. package/src/hooks/use-auto-save.ts +34 -8
  197. package/src/hooks/use-keyboard.test.ts +178 -3
  198. package/src/hooks/use-keyboard.ts +89 -71
  199. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  200. package/src/hooks/use-open-wall-ends.ts +238 -0
  201. package/src/hooks/use-placement-notice.test.ts +48 -0
  202. package/src/hooks/use-placement-notice.ts +42 -0
  203. package/src/hooks/use-selected-room.ts +143 -0
  204. package/src/index.tsx +246 -39
  205. package/src/lib/ceiling-edit-session.test.ts +321 -0
  206. package/src/lib/ceiling-edit-session.ts +206 -0
  207. package/src/lib/ceiling-region-session.test.ts +179 -0
  208. package/src/lib/continuation.test.ts +19 -1
  209. package/src/lib/continuation.ts +25 -4
  210. package/src/lib/door-interaction.ts +14 -0
  211. package/src/lib/editor-level-display.test.ts +96 -0
  212. package/src/lib/editor-level-display.ts +48 -0
  213. package/src/lib/editor-views.ts +144 -0
  214. package/src/lib/export-geometry-wait.test.ts +90 -0
  215. package/src/lib/floor-edit-session.ts +153 -0
  216. package/src/lib/floor-footprints.test.ts +563 -0
  217. package/src/lib/floor-footprints.ts +395 -0
  218. package/src/lib/floor-foundation-support.test.ts +283 -0
  219. package/src/lib/floor-opening-draft.ts +218 -0
  220. package/src/lib/floor-openings.test.ts +216 -0
  221. package/src/lib/floor-region-geometry.ts +191 -0
  222. package/src/lib/floor-region-session.test.ts +485 -0
  223. package/src/lib/floor-region-session.ts +589 -0
  224. package/src/lib/floor-region-snap.ts +101 -0
  225. package/src/lib/floor-step-hover.test.ts +32 -0
  226. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  227. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  228. package/src/lib/floorplan/dimension-drive.ts +479 -0
  229. package/src/lib/floorplan/floorplan-export.test.ts +12 -8
  230. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  231. package/src/lib/floorplan/floorplan-extension.ts +32 -0
  232. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  233. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  234. package/src/lib/floorplan/floorplan-pdfkit-document.ts +339 -18
  235. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +138 -8
  236. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +56 -25
  237. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  238. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  239. package/src/lib/floorplan/geometry.ts +13 -6
  240. package/src/lib/floorplan/index.ts +0 -9
  241. package/src/lib/floorplan/items.ts +5 -2
  242. package/src/lib/floorplan/marks.test.ts +213 -0
  243. package/src/lib/floorplan/marks.ts +238 -0
  244. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  245. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  246. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  247. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  248. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  249. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  250. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  251. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  252. package/src/lib/floorplan/schedules.test.ts +189 -0
  253. package/src/lib/floorplan/schedules.ts +339 -0
  254. package/src/lib/floorplan/sheet-export.test.ts +100 -0
  255. package/src/lib/floorplan/sheet-export.ts +318 -0
  256. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +779 -0
  257. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  258. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  259. package/src/lib/floorplan/site-plan/coverage.ts +232 -0
  260. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  261. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  262. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  263. package/src/lib/floorplan/site-plan/front-edge.ts +156 -0
  264. package/src/lib/floorplan/site-plan/geometry.test.ts +275 -0
  265. package/src/lib/floorplan/site-plan/geometry.ts +557 -0
  266. package/src/lib/floorplan/site-plan/index.ts +71 -0
  267. package/src/lib/floorplan/site-plan/site-annotations.test.ts +366 -0
  268. package/src/lib/floorplan/site-plan/site-annotations.ts +1083 -0
  269. package/src/lib/floorplan/site-plan/site-parts.ts +379 -0
  270. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  271. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  272. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  273. package/src/lib/floorplan/site-plan/yards.test.ts +69 -0
  274. package/src/lib/floorplan/types.ts +1 -27
  275. package/src/lib/fresh-planar-placement.test.ts +36 -0
  276. package/src/lib/fresh-planar-placement.ts +59 -36
  277. package/src/lib/geometry-script/author.ts +152 -0
  278. package/src/lib/geometry-script/client.ts +152 -0
  279. package/src/lib/geometry-script/protocol.ts +12 -0
  280. package/src/lib/gesture-lifecycle.test.ts +595 -0
  281. package/src/lib/gesture-lifecycle.ts +290 -0
  282. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  283. package/src/lib/glb-export.test.ts +485 -0
  284. package/src/lib/glb-export.ts +237 -119
  285. package/src/lib/history.test.ts +6 -0
  286. package/src/lib/history.ts +7 -0
  287. package/src/lib/hud-title.ts +109 -0
  288. package/src/lib/hydration-gated-storage.ts +49 -0
  289. package/src/lib/ifc-export.test.ts +174 -0
  290. package/src/lib/ifc-export.ts +201 -0
  291. package/src/lib/ifc-parts.ts +15 -0
  292. package/src/lib/inspector-expanded.test.ts +44 -0
  293. package/src/lib/inspector-expanded.ts +36 -0
  294. package/src/lib/interaction/scope.ts +18 -0
  295. package/src/lib/keyboard-pan.test.ts +129 -7
  296. package/src/lib/keyboard-pan.ts +161 -12
  297. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  298. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  299. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  300. package/src/lib/lot/clean-ring.test.ts +186 -0
  301. package/src/lib/lot/clean-ring.ts +217 -0
  302. package/src/lib/lot/dossier.test.ts +202 -0
  303. package/src/lib/lot/dossier.ts +498 -0
  304. package/src/lib/lot/drop-in.test.ts +326 -0
  305. package/src/lib/lot/drop-in.ts +407 -0
  306. package/src/lib/lot/index.ts +43 -0
  307. package/src/lib/lot/lot-patch.test.ts +340 -0
  308. package/src/lib/lot/lot-patch.ts +394 -0
  309. package/src/lib/lot/parcel-provider.ts +43 -0
  310. package/src/lib/lot/terrain.test.ts +161 -0
  311. package/src/lib/lot/terrain.ts +266 -0
  312. package/src/lib/material-catalog-model.ts +3 -1
  313. package/src/lib/material-paint-panel-model.ts +9 -5
  314. package/src/lib/material-paint.ts +4 -0
  315. package/src/lib/mezzanine-actions.test.ts +342 -0
  316. package/src/lib/mezzanine-draft.test.ts +501 -0
  317. package/src/lib/mezzanine-draft.ts +278 -0
  318. package/src/lib/mezzanine-messages.ts +26 -0
  319. package/src/lib/mezzanine-selection.test.ts +207 -0
  320. package/src/lib/mezzanine-stairs.ts +46 -0
  321. package/src/lib/model-export.ts +3 -1
  322. package/src/lib/node-action-movement.ts +17 -6
  323. package/src/lib/node-display-label.ts +37 -0
  324. package/src/lib/orbit-floor-clearance.test.ts +165 -0
  325. package/src/lib/orbit-floor-clearance.ts +106 -0
  326. package/src/lib/paint-commit-route.test.ts +37 -0
  327. package/src/lib/paint-commit-route.ts +17 -0
  328. package/src/lib/paint-eyedropper.test.tsx +165 -0
  329. package/src/lib/paint-eyedropper.ts +99 -0
  330. package/src/lib/paint-pass-through.test.ts +111 -0
  331. package/src/lib/paint-pass-through.ts +76 -0
  332. package/src/lib/paint-region-hover.ts +25 -0
  333. package/src/lib/paint-region-mode.ts +154 -0
  334. package/src/lib/paint-regions.test.ts +166 -0
  335. package/src/lib/paint-regions.ts +192 -0
  336. package/src/lib/paint-scope.test.ts +317 -35
  337. package/src/lib/paint-scope.ts +266 -25
  338. package/src/lib/panel-rows.ts +21 -0
  339. package/src/lib/parametric-node-update.ts +1 -1
  340. package/src/lib/plate-paint-affected.test.ts +300 -0
  341. package/src/lib/plate-paint-affected.ts +177 -0
  342. package/src/lib/plugin-chat.ts +50 -0
  343. package/src/lib/plugin-panels.test.ts +25 -0
  344. package/src/lib/plugin-panels.ts +94 -0
  345. package/src/lib/print-shell-compiler.ts +1 -0
  346. package/src/lib/registered-draft-snap.ts +12 -0
  347. package/src/lib/roof-duplication.ts +10 -2
  348. package/src/lib/room-assembly-overlay.test.ts +275 -0
  349. package/src/lib/room-assembly-overlay.ts +266 -0
  350. package/src/lib/room-built-on.test.ts +379 -0
  351. package/src/lib/room-built-on.ts +138 -0
  352. package/src/lib/room-construction-commands.ts +712 -0
  353. package/src/lib/room-construction.test.ts +746 -0
  354. package/src/lib/room-construction.ts +385 -0
  355. package/src/lib/room-controls-phase6.test.ts +630 -0
  356. package/src/lib/room-divide-session.ts +523 -0
  357. package/src/lib/room-handle-drag.ts +1068 -0
  358. package/src/lib/room-name-catalog.test.ts +90 -0
  359. package/src/lib/room-name-catalog.ts +168 -0
  360. package/src/lib/room-push-dimensions.test.ts +311 -0
  361. package/src/lib/room-push-dimensions.ts +251 -0
  362. package/src/lib/room-selection-commands.ts +62 -0
  363. package/src/lib/room-selection.test.ts +973 -0
  364. package/src/lib/room-selection.ts +417 -0
  365. package/src/lib/room-structure-commands.ts +94 -0
  366. package/src/lib/room-structure.test.ts +921 -0
  367. package/src/lib/room-transform-session.ts +570 -0
  368. package/src/lib/room-zone-routing.test.ts +294 -0
  369. package/src/lib/room-zone-routing.ts +197 -0
  370. package/src/lib/scene-clipboard.test.ts +364 -0
  371. package/src/lib/scene-clipboard.ts +229 -48
  372. package/src/lib/scene-material-refs.test.ts +79 -0
  373. package/src/lib/scene-material-refs.ts +79 -0
  374. package/src/lib/scene.test.ts +24 -36
  375. package/src/lib/scene.ts +33 -57
  376. package/src/lib/selection-routing.test.ts +129 -45
  377. package/src/lib/selection-routing.ts +53 -74
  378. package/src/lib/session-writes.test.ts +120 -0
  379. package/src/lib/session-writes.ts +205 -0
  380. package/src/lib/sidebar-panel.ts +62 -0
  381. package/src/lib/site-boundary.test.ts +4 -6
  382. package/src/lib/site-boundary.ts +1 -1
  383. package/src/lib/snapping-mode.test.ts +4 -0
  384. package/src/lib/snapping-mode.ts +8 -1
  385. package/src/lib/terrace-draft.test.ts +145 -0
  386. package/src/lib/terrace-draft.ts +213 -0
  387. package/src/lib/tool-cursor-icon.test.ts +16 -0
  388. package/src/lib/tool-cursor-icon.ts +11 -0
  389. package/src/lib/typing-target.ts +9 -0
  390. package/src/lib/units.ts +77 -39
  391. package/src/lib/vector-edges.test.ts +58 -0
  392. package/src/lib/vector-edges.ts +400 -0
  393. package/src/lib/viewer-selection.test.ts +577 -0
  394. package/src/lib/viewer-selection.ts +569 -0
  395. package/src/lib/wall-draw-variant.test.ts +71 -0
  396. package/src/lib/wall-draw-variant.ts +91 -0
  397. package/src/lib/wall-paint-region-session.ts +246 -0
  398. package/src/lib/wall-paint-region.test.ts +373 -0
  399. package/src/lib/wall-polygon-draft.test.ts +262 -0
  400. package/src/lib/wall-polygon-draft.ts +286 -0
  401. package/src/lib/wall-push.test.ts +453 -0
  402. package/src/lib/wall-region-face.ts +352 -0
  403. package/src/lib/wall-region-handles.test.ts +304 -0
  404. package/src/lib/wall-region-handles.ts +368 -0
  405. package/src/lib/wall-region-list.test.ts +119 -0
  406. package/src/lib/wall-region-list.ts +136 -0
  407. package/src/lib/wall-region-snap.ts +110 -0
  408. package/src/lib/window-interaction.ts +14 -0
  409. package/src/lib/zone-content.ts +4 -1
  410. package/src/lib/zone-name.test.ts +17 -0
  411. package/src/lib/zone-name.ts +17 -0
  412. package/src/store/opens-in-select-mode.test.ts +223 -0
  413. package/src/store/paint-mode.test.ts +188 -0
  414. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  415. package/src/store/tool-mode.test.ts +137 -6
  416. package/src/store/use-delete-confirmation.ts +28 -1
  417. package/src/store/use-drawing-view.test.ts +24 -3
  418. package/src/store/use-drawing-view.ts +29 -1
  419. package/src/store/use-editor.tsx +432 -321
  420. package/src/store/use-floorplan-draft-preview.ts +13 -0
  421. package/src/store/use-floorplan-marquee.ts +12 -2
  422. package/src/store/use-quick-measurement-hud.ts +3 -3
  423. package/src/store/use-stair-build-preview.ts +13 -5
  424. package/src/store/use-wall-move-ghosts.ts +4 -2
  425. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  426. package/src/store/use-wall-region-handles-selection.ts +139 -0
  427. package/src/store/view-layout.test.ts +175 -0
  428. package/src/store/view-layout.ts +196 -0
  429. package/src/test-utils/immediate-animation-frames.ts +16 -0
  430. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  431. package/src/test-utils/selection-harness.ts +82 -0
  432. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  433. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  434. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  435. package/src/components/tools/fence/fence-drafting.ts +0 -266
  436. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  437. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  438. package/src/lib/floorplan/stairs.ts +0 -489
  439. package/src/lib/level-duplication.test.ts +0 -144
  440. package/src/lib/level-duplication.ts +0 -195
@@ -0,0 +1,921 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNodeId,
4
+ BuildingNode,
5
+ clearSceneHistory,
6
+ createZone,
7
+ DoorNode,
8
+ divideZone,
9
+ generateId,
10
+ ItemNode,
11
+ initSpaceDetectionSync,
12
+ LevelNode,
13
+ SHARED_WALLS_DELETE_MESSAGE,
14
+ structureChangeBatch,
15
+ useScene,
16
+ WallNode,
17
+ } from '@pascal-app/core'
18
+ import { useViewer } from '@pascal-app/viewer'
19
+ import { createElement } from 'react'
20
+ import { renderToStaticMarkup } from 'react-dom/server'
21
+ import { deleteConfirmationContent } from '../components/editor/delete-confirmation-dialog'
22
+ import { deleteSelection } from '../components/editor/group-actions'
23
+ import { NodeActionMenu } from '../components/editor/node-action-menu'
24
+ import {
25
+ bindDividePointer,
26
+ divideEndOutside,
27
+ divideFloorPoint,
28
+ isDivideTypingTarget,
29
+ resolveRoomDividePlanPoint,
30
+ roomDeleteBlockedReason,
31
+ useDivideLifetime,
32
+ } from '../components/editor/room-controls'
33
+ import { runHistoryShortcut } from '../hooks/use-keyboard'
34
+ import useDeleteConfirmation from '../store/use-delete-confirmation'
35
+ import useEditor from '../store/use-editor'
36
+ import useInteractionScope from '../store/use-interaction-scope'
37
+ import useWallSnapIndicator from '../store/use-wall-snap-indicator'
38
+ import { installImmediateAnimationFrames } from '../test-utils/immediate-animation-frames'
39
+ import { withSelectionHarness } from '../test-utils/selection-harness'
40
+ import { resolveOverlayPolicy } from './interaction/overlay-policy'
41
+ import {
42
+ cancelRoomDivide,
43
+ clickRoomDivide,
44
+ finishRoomDivide,
45
+ previewRoomDivide,
46
+ removeLastRoomDividePoint,
47
+ roomDivideContext,
48
+ startRoomDivide,
49
+ } from './room-divide-session'
50
+ import { roomNameSections } from './room-name-catalog'
51
+ import {
52
+ applyRoomPlan,
53
+ renameRoom,
54
+ requestRoomDeletion,
55
+ separatorMergePlan,
56
+ } from './room-structure-commands'
57
+ import { useRoomTransform } from './room-transform-session'
58
+ import { sfxEmitter } from './sfx-bus'
59
+
60
+ let zoneId: string
61
+ let stop = () => {}
62
+ let restoreFrames = () => {}
63
+ beforeEach(() => {
64
+ restoreFrames = installImmediateAnimationFrames()
65
+ useInteractionScope.getState().end()
66
+ const building = BuildingNode.parse({
67
+ id: 'building_room_controls',
68
+ children: ['level_room_controls'],
69
+ })
70
+ const level = LevelNode.parse({ id: 'level_room_controls', parentId: building.id })
71
+ useScene.setState({
72
+ nodes: { [building.id]: building, [level.id]: level },
73
+ rootNodeIds: [building.id],
74
+ dirtyNodes: new Set(),
75
+ materials: {},
76
+ collections: {},
77
+ readOnly: false,
78
+ })
79
+ useScene.temporal.getState().resume()
80
+ stop = initSpaceDetectionSync(useScene, { getState: () => ({ spaces: {}, setSpaces: () => {} }) })
81
+ const plan = createZone(useScene.getState().nodes, {
82
+ levelId: level.id,
83
+ polygon: [
84
+ [0, 0],
85
+ [8, 0],
86
+ [8, 4],
87
+ [0, 4],
88
+ ],
89
+ enclose: true,
90
+ mintId: generateId,
91
+ })
92
+ applyRoomPlan(plan)
93
+ zoneId = plan.zoneId
94
+ useViewer.getState().setSelection({ buildingId: building.id, levelId: level.id, selectedIds: [] })
95
+ useEditor.setState({
96
+ phase: 'building',
97
+ mode: 'select',
98
+ room: { zoneId, levelId: level.id },
99
+ gridSnapStep: 0.5,
100
+ snappingModeByContext: { wall: 'grid', item: 'lines', polygon: 'grid', rotation: 'angles' },
101
+ })
102
+ clearSceneHistory()
103
+ })
104
+ afterEach(() => {
105
+ stop()
106
+ cancelRoomDivide()
107
+ useDeleteConfirmation.getState().cancel()
108
+ restoreFrames()
109
+ })
110
+
111
+ describe('room structure controls', () => {
112
+ test('Divide start/preview/click-click commit is transient until the second click', () => {
113
+ const before = useScene.getState().nodes
114
+ startRoomDivide(zoneId, 'level_room_controls')
115
+ previewRoomDivide([2.2, 0.1])
116
+ expect(useInteractionScope.getState().scope).toMatchObject({
117
+ kind: 'room-divide',
118
+ points: [],
119
+ end: [2, 0],
120
+ endKind: 'start',
121
+ })
122
+ expect(clickRoomDivide()).toBe(false)
123
+ previewRoomDivide([2.2, 3.9])
124
+ expect(useScene.getState().nodes).toBe(before)
125
+ expect(useScene.temporal.getState().pastStates).toHaveLength(0)
126
+ expect(clickRoomDivide()).toBe(true)
127
+ expect(useInteractionScope.getState().scope).toEqual({ kind: 'idle' })
128
+ expect(Object.values(useScene.getState().nodes).filter((n) => n.type === 'zone')).toHaveLength(
129
+ 2,
130
+ )
131
+ expect(useScene.temporal.getState().pastStates).toHaveLength(1)
132
+ })
133
+ test('Divide sounds and snaps like a wall draft: start, ticks, beacons, built', () => {
134
+ const sounds: string[] = []
135
+ const listeners = {
136
+ 'sfx:structure-build-start': () => sounds.push('start'),
137
+ 'sfx:grid-snap': () => sounds.push('tick'),
138
+ 'sfx:structure-build': () => sounds.push('build'),
139
+ } as const
140
+ for (const [event, listener] of Object.entries(listeners))
141
+ sfxEmitter.on(event as keyof typeof listeners, listener)
142
+ try {
143
+ const snap = () => useWallSnapIndicator.getState().point
144
+ startRoomDivide(zoneId, 'level_room_controls')
145
+ // On the boundary: the beacon marks the wall the cut starts on.
146
+ previewRoomDivide([2.2, 0.1])
147
+ expect(snap()).toMatchObject({ x: 2, z: 0, kind: 'wall' })
148
+ clickRoomDivide()
149
+ expect(sounds).toEqual(['start'])
150
+ // A free point inside the room: no beacon; each new snapped spot ticks.
151
+ previewRoomDivide([2.1, 2.1])
152
+ expect(snap()).toBeNull()
153
+ previewRoomDivide([2.1, 2.1])
154
+ previewRoomDivide([3, 2])
155
+ expect(sounds).toEqual(['start', 'tick', 'tick'])
156
+ // Back on the boundary to finish: the beacon again, then the build cue.
157
+ previewRoomDivide([3, 3.9])
158
+ expect(snap()).toMatchObject({ x: 3, z: 4, kind: 'wall' })
159
+ expect(clickRoomDivide()).toBe(true)
160
+ expect(sounds).toEqual(['start', 'tick', 'tick', 'tick', 'build'])
161
+ expect(snap()).toBeNull()
162
+ } finally {
163
+ for (const [event, listener] of Object.entries(listeners))
164
+ sfxEmitter.off(event as keyof typeof listeners, listener)
165
+ }
166
+ })
167
+ test('an island closing on its first point shows the corner beacon there', () => {
168
+ startRoomDivide(zoneId, 'level_room_controls')
169
+ for (const point of [
170
+ [2, 1],
171
+ [5, 1],
172
+ [5, 3],
173
+ [2, 3],
174
+ ] as const) {
175
+ previewRoomDivide([point[0], point[1]])
176
+ clickRoomDivide()
177
+ }
178
+ previewRoomDivide([2.1, 1.1])
179
+ expect(useWallSnapIndicator.getState().point).toMatchObject({ x: 2, z: 1, kind: 'endpoint' })
180
+ cancelRoomDivide()
181
+ expect(useWallSnapIndicator.getState().point).toBeNull()
182
+ })
183
+ test('a pointer resolved on a wall surface cuts to that wall, past the free-pointer cap', () => {
184
+ const nodes = useScene.getState().nodes
185
+ const walls = Object.values(nodes).filter((node) => node.type === 'wall')
186
+ const near = walls.find((wall) => wall.start[1] === 0 && wall.end[1] === 0)!
187
+ const far = walls.find((wall) => wall.start[1] === 4 && wall.end[1] === 4)!
188
+ startRoomDivide(zoneId, 'level_room_controls')
189
+ // What the floor plane returns for a pointer aimed at a wall in 3D: the ray
190
+ // carries past the wall and lands metres outside the room.
191
+ previewRoomDivide([2, -2.3])
192
+ expect(useInteractionScope.getState().scope).toMatchObject({ valid: false, end: [2, -2.3] })
193
+ previewRoomDivide([2, -2.3], near.id)
194
+ expect(useInteractionScope.getState().scope).toMatchObject({ end: [2, 0], valid: true })
195
+ expect(clickRoomDivide()).toBe(false)
196
+ previewRoomDivide([2.1, 6.4], far.id)
197
+ expect(useInteractionScope.getState().scope).toMatchObject({
198
+ points: [[2, 0]],
199
+ end: [2, 4],
200
+ endKind: 'edge',
201
+ valid: true,
202
+ })
203
+ expect(clickRoomDivide()).toBe(true)
204
+ expect(Object.values(useScene.getState().nodes).filter((n) => n.type === 'zone')).toHaveLength(
205
+ 2,
206
+ )
207
+ expect(useScene.temporal.getState().pastStates).toHaveLength(1)
208
+ })
209
+ test('Divide snaps through the wall tool snapping modes', () => {
210
+ const snapTo = (wall: 'grid' | 'lines' | 'angles' | 'off') =>
211
+ useEditor.setState({
212
+ snappingModeByContext: { wall, item: 'lines', polygon: 'grid', rotation: 'angles' },
213
+ })
214
+ startRoomDivide(zoneId, 'level_room_controls')
215
+ previewRoomDivide([2, 0])
216
+ clickRoomDivide()
217
+ // Lines squares the cut to the edge it starts from, within 15 cm.
218
+ snapTo('lines')
219
+ previewRoomDivide([2.12, 4])
220
+ expect(useInteractionScope.getState().scope).toMatchObject({ end: [2, 4], valid: true })
221
+ previewRoomDivide([2.2, 4])
222
+ expect(useInteractionScope.getState().scope).toMatchObject({ end: [2.2, 4], valid: true })
223
+ // Off keeps the free boundary station.
224
+ snapTo('off')
225
+ previewRoomDivide([2.12, 4])
226
+ expect(useInteractionScope.getState().scope).toMatchObject({ end: [2.12, 4], valid: true })
227
+ // Grid steps along the edge.
228
+ snapTo('grid')
229
+ previewRoomDivide([2.12, 4])
230
+ expect(useInteractionScope.getState().scope).toMatchObject({ end: [2, 4], valid: true })
231
+ // Angles locks the cut to 15° rays: 46° from the start reads as 45°.
232
+ snapTo('angles')
233
+ const t = Math.tan((46 * Math.PI) / 180)
234
+ previewRoomDivide([2 + 4 / t, 4])
235
+ const scope = useInteractionScope.getState().scope
236
+ if (scope.kind !== 'room-divide' || !scope.end) throw Error('expected a divide preview')
237
+ expect(scope.end[0]).toBeCloseTo(6)
238
+ expect(scope.end[1]).toBeCloseTo(4)
239
+ })
240
+ test('invalid previews carry a short cursor label', () => {
241
+ startRoomDivide(zoneId, 'level_room_controls')
242
+ previewRoomDivide([4, 7])
243
+ expect(useInteractionScope.getState().scope).toMatchObject({
244
+ valid: false,
245
+ message: 'Move to an edge',
246
+ })
247
+ previewRoomDivide([2, 0])
248
+ clickRoomDivide()
249
+ previewRoomDivide([2, 0.02])
250
+ expect(useInteractionScope.getState().scope).toMatchObject({ valid: false })
251
+ const scope = useInteractionScope.getState().scope
252
+ const message = scope.kind === 'room-divide' ? scope.message : undefined
253
+ expect(message).toBeTruthy()
254
+ expect(message!.length).toBeLessThan(20)
255
+ })
256
+ test('a path adds free points inside the room and finishes on the boundary', () => {
257
+ startRoomDivide(zoneId, 'level_room_controls')
258
+ previewRoomDivide([2, 0.2])
259
+ clickRoomDivide()
260
+ // Away from the boundary the point is free (grid-snapped), not an edge.
261
+ previewRoomDivide([3.1, 1.9])
262
+ expect(useInteractionScope.getState().scope).toMatchObject({
263
+ end: [3, 2],
264
+ endKind: 'point',
265
+ valid: true,
266
+ })
267
+ expect(clickRoomDivide()).toBe(false)
268
+ previewRoomDivide([5, 1.2])
269
+ clickRoomDivide()
270
+ expect(useInteractionScope.getState().scope).toMatchObject({
271
+ points: [
272
+ [2, 0],
273
+ [3, 2],
274
+ [5, 1],
275
+ ],
276
+ })
277
+ // Crossing an earlier segment is refused where the pointer is.
278
+ previewRoomDivide([2, 1.5])
279
+ expect(useInteractionScope.getState().scope).toMatchObject({
280
+ endKind: 'point',
281
+ valid: false,
282
+ message: 'Crosses itself',
283
+ })
284
+ // Back on the boundary the live point finishes the cut.
285
+ previewRoomDivide([5.1, 3.8])
286
+ expect(useInteractionScope.getState().scope).toMatchObject({ end: [5, 4], endKind: 'edge' })
287
+ // Backspace drops the last point and re-reads the pointer.
288
+ expect(removeLastRoomDividePoint()).toBe(true)
289
+ expect(useInteractionScope.getState().scope).toMatchObject({
290
+ points: [
291
+ [2, 0],
292
+ [3, 2],
293
+ ],
294
+ end: [5, 4],
295
+ endKind: 'edge',
296
+ })
297
+ expect(useScene.temporal.getState().pastStates).toHaveLength(0)
298
+ })
299
+ test('an open path commits in one step with a separator per segment', () => {
300
+ startRoomDivide(zoneId, 'level_room_controls')
301
+ for (const point of [
302
+ [2, 0.2],
303
+ [3, 2],
304
+ ] as const) {
305
+ previewRoomDivide([point[0], point[1]])
306
+ clickRoomDivide()
307
+ }
308
+ previewRoomDivide([5.1, 3.8])
309
+ expect(useInteractionScope.getState().scope).toMatchObject({
310
+ end: [5, 4],
311
+ endKind: 'edge',
312
+ valid: true,
313
+ })
314
+ expect(finishRoomDivide()).toBe(true)
315
+ const nodes = Object.values(useScene.getState().nodes)
316
+ expect(nodes.filter((n) => n.type === 'zone')).toHaveLength(2)
317
+ expect(nodes.filter((n) => n.type === 'separator')).toHaveLength(2)
318
+ expect(useScene.temporal.getState().pastStates).toHaveLength(1)
319
+ })
320
+ test('Enter finishes a straight cut on the boundary; nothing else', () => {
321
+ startRoomDivide(zoneId, 'level_room_controls')
322
+ previewRoomDivide([2, 0.2])
323
+ expect(finishRoomDivide()).toBe(false)
324
+ clickRoomDivide()
325
+ previewRoomDivide([3, 2])
326
+ expect(finishRoomDivide()).toBe(false)
327
+ previewRoomDivide([2.1, 3.9])
328
+ expect(finishRoomDivide()).toBe(true)
329
+ expect(Object.values(useScene.getState().nodes).filter((n) => n.type === 'zone')).toHaveLength(
330
+ 2,
331
+ )
332
+ })
333
+ test('an island starts inside the room and closes on its first point', () => {
334
+ startRoomDivide(zoneId, 'level_room_controls')
335
+ previewRoomDivide([2, 1.1])
336
+ expect(useInteractionScope.getState().scope).toMatchObject({
337
+ end: [2, 1],
338
+ endKind: 'start',
339
+ valid: true,
340
+ })
341
+ clickRoomDivide()
342
+ expect(useInteractionScope.getState().scope).toMatchObject({ startBoundaryId: undefined })
343
+ // An island cannot finish on the boundary.
344
+ previewRoomDivide([2, 0.1])
345
+ expect(useInteractionScope.getState().scope).toMatchObject({
346
+ valid: false,
347
+ message: 'Close the loop',
348
+ })
349
+ for (const point of [
350
+ [5, 1],
351
+ [5, 3],
352
+ [2, 3],
353
+ ] as const) {
354
+ previewRoomDivide([point[0], point[1]])
355
+ clickRoomDivide()
356
+ }
357
+ previewRoomDivide([2.1, 1.15])
358
+ expect(useInteractionScope.getState().scope).toMatchObject({ end: [2, 1], endKind: 'close' })
359
+ // Past the close radius, a grid step that lands on the first point closes too.
360
+ previewRoomDivide([2.2, 1.24])
361
+ expect(useInteractionScope.getState().scope).toMatchObject({ end: [2, 1], endKind: 'close' })
362
+ expect(useInteractionScope.getState().scope).toMatchObject({ valid: true })
363
+ expect(clickRoomDivide()).toBe(true)
364
+ const zones = Object.values(useScene.getState().nodes).filter((n) => n.type === 'zone')
365
+ expect(zones).toHaveLength(2)
366
+ expect(
367
+ Object.values(useScene.getState().nodes).filter((n) => n.type === 'separator'),
368
+ ).toHaveLength(4)
369
+ expect(useScene.temporal.getState().pastStates).toHaveLength(1)
370
+ })
371
+ test('Divide cancel clears the scope without history or scene writes', () => {
372
+ const before = useScene.getState().nodes
373
+ startRoomDivide(zoneId, 'level_room_controls')
374
+ previewRoomDivide([2, 0])
375
+ clickRoomDivide()
376
+ previewRoomDivide([3, 4])
377
+ cancelRoomDivide()
378
+ expect(useInteractionScope.getState().scope).toEqual({ kind: 'idle' })
379
+ expect(useScene.getState().nodes).toBe(before)
380
+ expect(useScene.temporal.getState().pastStates).toHaveLength(0)
381
+ })
382
+ test('delete confirmation previews IDs and waits before applying', () => {
383
+ const wall = Object.values(useScene.getState().nodes).find((n) => n.type === 'wall')!
384
+ const door = DoorNode.parse({
385
+ id: 'door_confirmation',
386
+ parentId: wall.id,
387
+ wallId: wall.id,
388
+ position: [2, 0, 0],
389
+ })
390
+ const item = ItemNode.parse({
391
+ id: 'item_confirmation',
392
+ parentId: 'level_room_controls',
393
+ position: [2, 0, 2],
394
+ asset: {
395
+ id: 'chair',
396
+ category: 'chairs',
397
+ name: 'Chair',
398
+ thumbnail: '',
399
+ src: 'https://example.com/chair.glb',
400
+ },
401
+ })
402
+ useScene.getState().applyNodeChanges({
403
+ create: [
404
+ { node: door, parentId: wall.id },
405
+ { node: item, parentId: 'level_room_controls' },
406
+ ],
407
+ })
408
+ clearSceneHistory()
409
+ requestRoomDeletion(zoneId)
410
+ const request = useDeleteConfirmation.getState().request!
411
+ expect(request.room).toMatchObject({
412
+ zoneId,
413
+ contents: 'delete',
414
+ keptSharedWallIds: [],
415
+ openingIds: [door.id],
416
+ itemIds: [item.id],
417
+ })
418
+ expect(request.room!.wallIds).toHaveLength(4)
419
+ expect(useScene.getState().nodes[zoneId as AnyNodeId]).toBeDefined()
420
+ useDeleteConfirmation.getState().confirm()
421
+ expect(useScene.getState().nodes[zoneId as AnyNodeId]).toBeUndefined()
422
+ expect(useScene.temporal.getState().pastStates).toHaveLength(1)
423
+ })
424
+ test('separator Delete merges through the primitive in one history step', () => {
425
+ const plan = divideZone(useScene.getState().nodes, {
426
+ zoneId,
427
+ cut: [
428
+ [2, 0],
429
+ [2, 4],
430
+ ],
431
+ mintId: generateId,
432
+ })
433
+ useScene.getState().applyNodeChanges(structureChangeBatch(plan.changes))
434
+ clearSceneHistory()
435
+ const id = plan.separatorId as AnyNodeId
436
+ expect(separatorMergePlan(useScene.getState().nodes, id).changes).toEqual([
437
+ { op: 'delete', id },
438
+ ])
439
+ useViewer.getState().setSelection({ selectedIds: [id] })
440
+ expect(deleteSelection()).toBe(true)
441
+ expect(Object.values(useScene.getState().nodes).filter((n) => n.type === 'zone')).toHaveLength(
442
+ 1,
443
+ )
444
+ expect(useScene.temporal.getState().pastStates).toHaveLength(1)
445
+ })
446
+ test('combobox accepts verbatim custom names and never lists other rooms’ names', () => {
447
+ expect(renameRoom(zoneId, ' Garden studio ')).toBe(true)
448
+ expect(useScene.getState().nodes[zoneId as AnyNodeId]?.name).toBe(' Garden studio ')
449
+ const nodes = useScene.getState().nodes
450
+ const offered = roomNameSections(nodes, 'zone_other').flatMap((s) => s.names)
451
+ expect(offered).not.toContain('Garden studio')
452
+ expect(useScene.temporal.getState().pastStates).toHaveLength(1)
453
+ })
454
+ test('Backspace drops a point; Escape releases the scope and overlays without writing the scene', () => {
455
+ const surface = new EventTarget(),
456
+ keyboard = new EventTarget()
457
+ const dispose = bindDividePointer(surface, () => ({ point: [2, 0] }), keyboard)
458
+ const before = useScene.getState().nodes
459
+ try {
460
+ startRoomDivide(zoneId, 'level_room_controls')
461
+ previewRoomDivide([2, 0])
462
+ clickRoomDivide()
463
+ previewRoomDivide([3, 2])
464
+ clickRoomDivide()
465
+ keyboard.dispatchEvent(Object.assign(new Event('keydown'), { key: 'Backspace' }))
466
+ expect(useInteractionScope.getState().scope).toMatchObject({ points: [[2, 0]] })
467
+ keyboard.dispatchEvent(Object.assign(new Event('keydown'), { key: 'Escape' }))
468
+ expect(useInteractionScope.getState().scope.kind).toBe('idle')
469
+ expect(resolveOverlayPolicy(useInteractionScope.getState().scope).conflictingControls).toBe(
470
+ 'shown',
471
+ )
472
+ expect(useScene.getState().nodes).toBe(before)
473
+ } finally {
474
+ dispose()
475
+ }
476
+ })
477
+ test('split view: two bound surfaces act on one Backspace once', () => {
478
+ const keyboard = new EventTarget()
479
+ const plan = bindDividePointer(new EventTarget(), () => null, keyboard)
480
+ const scene = bindDividePointer(new EventTarget(), () => null, keyboard)
481
+ const backspace = () =>
482
+ keyboard.dispatchEvent(Object.assign(new Event('keydown'), { key: 'Backspace' }))
483
+ try {
484
+ startRoomDivide(zoneId, 'level_room_controls')
485
+ for (const point of [
486
+ [2, 0.2],
487
+ [3, 2],
488
+ [5, 1],
489
+ ] as const) {
490
+ previewRoomDivide([point[0], point[1]])
491
+ clickRoomDivide()
492
+ }
493
+ backspace()
494
+ expect(useInteractionScope.getState().scope).toMatchObject({
495
+ points: [
496
+ [2, 0],
497
+ [3, 2],
498
+ ],
499
+ })
500
+ scene()
501
+ backspace()
502
+ expect(useInteractionScope.getState().scope).toMatchObject({ points: [[2, 0]] })
503
+ } finally {
504
+ plan()
505
+ scene()
506
+ }
507
+ backspace()
508
+ expect(useInteractionScope.getState().scope).toMatchObject({ points: [[2, 0]] })
509
+ })
510
+ test('keys typed into a field never reach the Divide draft', () => {
511
+ const field = (selector: string) => ({
512
+ closest: (query: string) => (query.split(', ').includes(selector) ? {} : null),
513
+ })
514
+ expect(isDivideTypingTarget(field('input') as unknown as EventTarget)).toBe(true)
515
+ expect(isDivideTypingTarget(field('[role="combobox"]') as unknown as EventTarget)).toBe(true)
516
+ expect(isDivideTypingTarget(field('[role="dialog"]') as unknown as EventTarget)).toBe(true)
517
+ expect(isDivideTypingTarget({ isContentEditable: true } as unknown as EventTarget)).toBe(true)
518
+ expect(isDivideTypingTarget({ closest: () => null } as unknown as EventTarget)).toBe(false)
519
+ expect(isDivideTypingTarget(null)).toBe(false)
520
+
521
+ const keyboard = new EventTarget()
522
+ const dispose = bindDividePointer(new EventTarget(), () => null, keyboard)
523
+ const press = (key: string, target?: unknown) => {
524
+ const event = Object.assign(new Event('keydown', { cancelable: true }), { key })
525
+ if (target) Object.defineProperty(event, 'target', { value: target })
526
+ keyboard.dispatchEvent(event)
527
+ }
528
+ try {
529
+ startRoomDivide(zoneId, 'level_room_controls')
530
+ previewRoomDivide([2, 0.2])
531
+ clickRoomDivide()
532
+ previewRoomDivide([3, 2])
533
+ clickRoomDivide()
534
+ for (const key of ['Backspace', 'Enter', 'Escape']) press(key, field('input'))
535
+ expect(useInteractionScope.getState().scope).toMatchObject({
536
+ kind: 'room-divide',
537
+ points: [
538
+ [2, 0],
539
+ [3, 2],
540
+ ],
541
+ })
542
+ // Another handler that already took the key keeps it.
543
+ const taken = Object.assign(new Event('keydown', { cancelable: true }), { key: 'Backspace' })
544
+ taken.preventDefault()
545
+ keyboard.dispatchEvent(taken)
546
+ expect(useInteractionScope.getState().scope).toMatchObject({
547
+ points: [
548
+ [2, 0],
549
+ [3, 2],
550
+ ],
551
+ })
552
+ press('Backspace')
553
+ expect(useInteractionScope.getState().scope).toMatchObject({ points: [[2, 0]] })
554
+ } finally {
555
+ dispose()
556
+ }
557
+ })
558
+ test('selecting an element or ⌘Z ends the draft; the next ⌘Z walks history', async () => {
559
+ function Lifetime() {
560
+ useDivideLifetime()
561
+ return null
562
+ }
563
+ await withSelectionHarness(async ({ render }) => {
564
+ await render(createElement(Lifetime))
565
+ const wall = Object.values(useScene.getState().nodes).find((n) => n.type === 'wall')!
566
+ startRoomDivide(zoneId, 'level_room_controls')
567
+ previewRoomDivide([2, 0.2])
568
+ clickRoomDivide()
569
+ useViewer.getState().setSelection({ selectedIds: [wall.id] })
570
+ expect(useInteractionScope.getState().scope.kind).toBe('idle')
571
+ useViewer.getState().setSelection({ selectedIds: [] })
572
+
573
+ expect(renameRoom(zoneId, 'Studio')).toBe(true)
574
+ startRoomDivide(zoneId, 'level_room_controls')
575
+ previewRoomDivide([2, 0.2])
576
+ clickRoomDivide()
577
+ expect(runHistoryShortcut('undo')).toBe(false)
578
+ expect(useInteractionScope.getState().scope.kind).toBe('idle')
579
+ expect(useScene.getState().nodes[zoneId as AnyNodeId]?.name).toBe('Studio')
580
+ expect(runHistoryShortcut('undo')).toBe(true)
581
+ expect(useScene.getState().nodes[zoneId as AnyNodeId]?.name).not.toBe('Studio')
582
+ await render(null)
583
+ })
584
+ })
585
+ test('a boundary change ends the draft; renaming the room does not', async () => {
586
+ function Lifetime() {
587
+ useDivideLifetime()
588
+ return null
589
+ }
590
+ await withSelectionHarness(async ({ render }) => {
591
+ await render(createElement(Lifetime))
592
+ startRoomDivide(zoneId, 'level_room_controls')
593
+ previewRoomDivide([2, 0.2])
594
+ clickRoomDivide()
595
+ renameRoom(zoneId, 'Kitchen')
596
+ expect(useInteractionScope.getState().scope).toMatchObject({
597
+ kind: 'room-divide',
598
+ points: [[2, 0]],
599
+ })
600
+ const wall = Object.values(useScene.getState().nodes).find(
601
+ (n): n is WallNode => n.type === 'wall' && n.start[1] === 0 && n.end[1] === 0,
602
+ )!
603
+ useScene.getState().updateNode(
604
+ wall.id as AnyNodeId,
605
+ {
606
+ start: [wall.start[0], -0.5],
607
+ end: [wall.end[0], -0.5],
608
+ } as never,
609
+ )
610
+ expect(useInteractionScope.getState().scope.kind).toBe('idle')
611
+ await render(null)
612
+ })
613
+ })
614
+ test('an unchanged preview candidate writes nothing', () => {
615
+ startRoomDivide(zoneId, 'level_room_controls')
616
+ previewRoomDivide([2, 0.2])
617
+ clickRoomDivide()
618
+ let writes = 0
619
+ const stop = useInteractionScope.subscribe(() => writes++)
620
+ try {
621
+ previewRoomDivide([2.1, 3.9])
622
+ previewRoomDivide([2.2, 3.8])
623
+ previewRoomDivide([1.9, 3.95])
624
+ expect(writes).toBe(1)
625
+ } finally {
626
+ stop()
627
+ }
628
+ })
629
+ test('unmount releases a pending Divide scope', async () => {
630
+ function Lifetime() {
631
+ useDivideLifetime()
632
+ return null
633
+ }
634
+ await withSelectionHarness(async ({ render }) => {
635
+ await render(createElement(Lifetime))
636
+ startRoomDivide(zoneId, 'level_room_controls')
637
+ previewRoomDivide([2, 0])
638
+ clickRoomDivide()
639
+ await render(null)
640
+ expect(useInteractionScope.getState().scope.kind).toBe('idle')
641
+ expect(resolveOverlayPolicy(useInteractionScope.getState().scope).sceneObjectsPickable).toBe(
642
+ true,
643
+ )
644
+ })
645
+ })
646
+ test('3D Divide points land on the room floor behind a near wall; off the room reads outside', () => {
647
+ // A camera south of the room looking down past the near (z = 0) wall: the
648
+ // ray crosses the wall's footprint before it meets the floor inside.
649
+ const origin: [number, number, number] = [4, 6, -6]
650
+ const target: [number, number, number] = [4, 0.05, 1]
651
+ const direction: [number, number, number] = [
652
+ target[0] - origin[0],
653
+ target[1] - origin[1],
654
+ target[2] - origin[2],
655
+ ]
656
+ const point = divideFloorPoint(origin, direction, 0.05)!
657
+ expect(point[0]).toBeCloseTo(4)
658
+ expect(point[1]).toBeCloseTo(1)
659
+ // A ray rising away from the floor never lands.
660
+ expect(divideFloorPoint(origin, [0, 1, 1], 0.05)).toBeNull()
661
+ startRoomDivide(zoneId, 'level_room_controls')
662
+ previewRoomDivide(point)
663
+ const scope = useInteractionScope.getState().scope as Parameters<typeof divideEndOutside>[0]
664
+ // Snapped onto the near edge: on the room, not outside.
665
+ expect(divideEndOutside(scope)).toBe(false)
666
+ previewRoomDivide([2, 0.1])
667
+ clickRoomDivide()
668
+ previewRoomDivide([4, 2])
669
+ expect(divideEndOutside(useInteractionScope.getState().scope as typeof scope)).toBe(false)
670
+ previewRoomDivide([4, -3])
671
+ expect(divideEndOutside(useInteractionScope.getState().scope as typeof scope)).toBe(true)
672
+ })
673
+ test('missing or open rooms and distant pointers invalidate instead of escaping DOM handlers', () => {
674
+ startRoomDivide(zoneId, 'level_room_controls')
675
+ expect(() => previewRoomDivide([4, 7])).not.toThrow()
676
+ expect(useInteractionScope.getState().scope).toMatchObject({ valid: false, end: [4, 7] })
677
+ expect(clickRoomDivide()).toBe(false)
678
+ previewRoomDivide([2, 0])
679
+ clickRoomDivide()
680
+ previewRoomDivide([2, 4])
681
+ const nodes = { ...useScene.getState().nodes }
682
+ delete nodes[zoneId as AnyNodeId]
683
+ useScene.setState({ nodes })
684
+ // The gesture owner ends a draft whose room is gone; nothing throws after.
685
+ expect(useInteractionScope.getState().scope.kind).toBe('idle')
686
+ expect(clickRoomDivide()).toBe(false)
687
+ expect(() => previewRoomDivide([2, 4])).not.toThrow()
688
+ expect(useScene.temporal.getState().pastStates).toHaveLength(1)
689
+ useScene.temporal.getState().undo()
690
+ startRoomDivide(zoneId, 'level_room_controls')
691
+ const wallsRemoved = Object.fromEntries(
692
+ Object.entries(useScene.getState().nodes).filter(([, n]) => n.type !== 'wall'),
693
+ )
694
+ useScene.setState({ nodes: wallsRemoved })
695
+ expect(useInteractionScope.getState().scope.kind).toBe('idle')
696
+ expect(() => previewRoomDivide([2, 4])).not.toThrow()
697
+ expect(clickRoomDivide()).toBe(false)
698
+ })
699
+ test('session face cache survives intent changes and rebuilds for topology changes', () => {
700
+ startRoomDivide(zoneId, 'level_room_controls')
701
+ const nodes = useScene.getState().nodes
702
+ const first = roomDivideContext(nodes, zoneId)
703
+ for (let i = 0; i < 10; i++) previewRoomDivide([2 + i * 0.01, 0])
704
+ expect(roomDivideContext(nodes, zoneId)).toBe(first)
705
+ renameRoom(zoneId, 'New name')
706
+ expect(roomDivideContext(useScene.getState().nodes, zoneId)).toBe(first)
707
+ const wall = Object.values(nodes).find((n) => n.type === 'wall')!
708
+ const changed = { ...nodes, [wall.id]: { ...wall, end: [7, 0] as [number, number] } }
709
+ expect(roomDivideContext(changed, zoneId)).not.toBe(first)
710
+ })
711
+ test('2D screen conversion and 3D plan pointers produce the same preview and one-step commit', async () => {
712
+ const originalDocument = globalThis.document
713
+ const before = useScene.getState().nodes
714
+ let expected: ReturnType<typeof useInteractionScope.getState>['scope'] | undefined
715
+ try {
716
+ globalThis.document = {
717
+ querySelector: () => ({
718
+ getScreenCTM: () => ({ inverse: () => ({}) }),
719
+ ownerSVGElement: {
720
+ createSVGPoint: () => ({
721
+ x: 0,
722
+ y: 0,
723
+ matrixTransform() {
724
+ return { x: (this.x - 10) / 100, y: (this.y - 20) / 100 }
725
+ },
726
+ }),
727
+ },
728
+ }),
729
+ } as unknown as Document
730
+ for (const view of ['3d', '2d']) {
731
+ useScene.setState({ nodes: before })
732
+ clearSceneHistory()
733
+ const surface = new EventTarget(),
734
+ keyboard = new EventTarget()
735
+ const dispose = bindDividePointer(
736
+ surface,
737
+ (event) =>
738
+ view === '2d'
739
+ ? resolveRoomDividePlanPoint(event.clientX, event.clientY)
740
+ : { point: [event.clientX, event.clientY] },
741
+ keyboard,
742
+ )
743
+ const send = (type: string, x: number, y: number) =>
744
+ (type === 'pointerup' ? keyboard : surface).dispatchEvent(
745
+ Object.assign(new Event(type), {
746
+ button: 0,
747
+ pointerId: 1,
748
+ altKey: false,
749
+ clientX: view === '2d' ? x * 100 + 10 : x,
750
+ clientY: view === '2d' ? y * 100 + 20 : y,
751
+ }),
752
+ )
753
+ try {
754
+ startRoomDivide(zoneId, 'level_room_controls')
755
+ send('pointerdown', 2.2, 0.1)
756
+ send('pointerup', 2.2, 0.1)
757
+ send('pointermove', 2.2, 3.9)
758
+ const scope = useInteractionScope.getState().scope
759
+ expect(scope).toMatchObject({ points: [[2, 0]], end: [2, 4], valid: true })
760
+ if (view === '3d') expected = scope
761
+ else expect(scope).toEqual(expected!)
762
+ expect(useScene.temporal.getState().pastStates).toHaveLength(0)
763
+ send('pointerdown', 2.2, 3.9)
764
+ send('pointerup', 2.2, 3.9)
765
+ expect(useScene.temporal.getState().pastStates).toHaveLength(1)
766
+ expect(
767
+ Object.values(useScene.getState().nodes).filter((n) => n.type === 'zone'),
768
+ ).toHaveLength(2)
769
+ expect(useInteractionScope.getState().scope.kind).toBe('idle')
770
+ } finally {
771
+ dispose()
772
+ }
773
+ }
774
+ await new Promise((resolve) => setTimeout(resolve, 1))
775
+ } finally {
776
+ globalThis.document = originalDocument
777
+ }
778
+ })
779
+ test('confirmation describes counts and names, including opened neighbours, without raw IDs', () => {
780
+ requestRoomDeletion(zoneId)
781
+ const request = useDeleteConfirmation.getState().request!
782
+ const payload = request.room!
783
+ const nodes = useScene.getState().nodes
784
+ const content = deleteConfirmationContent(
785
+ { ...request, room: { ...payload, opensZoneIds: [zoneId] } },
786
+ nodes,
787
+ )
788
+ expect(content.title).toBe(`Delete ${payload.name.trim() || 'this room'}?`)
789
+ expect(content.description).toContain('Removes 4 walls')
790
+ expect(content.description).toContain('Opens into Room.')
791
+ expect(content.description).not.toContain(zoneId)
792
+ for (const id of payload.wallIds) expect(content.description).not.toContain(id)
793
+ const merge = deleteConfirmationContent(
794
+ {
795
+ ...request,
796
+ room: { ...payload, mode: 'merge', wallIds: [], separatorIds: ['separator_a'] },
797
+ },
798
+ nodes,
799
+ )
800
+ expect(merge.description).toContain('Merges it back into')
801
+ expect(merge.description).toContain('removes the dividing line')
802
+ expect(merge.description).toContain('The walls stay')
803
+ expect(merge.description).not.toContain('item')
804
+ })
805
+ test('Keep items is offered only when the room holds items', () => {
806
+ requestRoomDeletion(zoneId)
807
+ const empty = useDeleteConfirmation.getState().request!
808
+ const nodes = useScene.getState().nodes
809
+ expect(deleteConfirmationContent(empty, nodes)).toMatchObject({
810
+ keepLabel: null,
811
+ confirmLabel: 'Delete',
812
+ })
813
+ expect(deleteConfirmationContent(empty, nodes).description).not.toContain('item')
814
+ const furnished = { ...empty, room: { ...empty.room!, itemIds: ['item_a', 'item_b'] } }
815
+ expect(deleteConfirmationContent(furnished, nodes)).toMatchObject({
816
+ keepLabel: 'Keep items',
817
+ confirmLabel: 'Delete all',
818
+ })
819
+ expect(deleteConfirmationContent(furnished, nodes).description).toContain('2 items are inside')
820
+ // A merged area's items stay whatever: no choice to offer, only a mention.
821
+ const merged = { ...furnished, room: { ...furnished.room, mode: 'merge' as const } }
822
+ expect(deleteConfirmationContent(merged, nodes)).toMatchObject({
823
+ keepLabel: null,
824
+ confirmLabel: 'Delete',
825
+ })
826
+ expect(deleteConfirmationContent(merged, nodes).description).toContain(
827
+ '2 items inside stay where they are',
828
+ )
829
+ expect(deleteConfirmationContent({ count: 3, onConfirm: () => {} }, nodes)).toMatchObject({
830
+ title: 'Delete 3 elements?',
831
+ keepLabel: null,
832
+ })
833
+ })
834
+ test('deleting one side of a split merges it into the other; that room stays selected', () => {
835
+ applyRoomPlan(
836
+ divideZone(useScene.getState().nodes, {
837
+ zoneId,
838
+ cut: [
839
+ [2, 0],
840
+ [2, 4],
841
+ ],
842
+ mintId: generateId,
843
+ }),
844
+ )
845
+ const zones = () => Object.values(useScene.getState().nodes).filter((n) => n.type === 'zone')
846
+ const other = zones().find((n) => n.id !== zoneId)!
847
+ const walls = Object.values(useScene.getState().nodes).filter((n) => n.type === 'wall')
848
+ useEditor.getState().selectRoom({ levelId: 'level_room_controls', zoneId: other.id })
849
+ // As the mounted room hooks do: a selected room that is gone clears the selection.
850
+ const stop = useScene.subscribe((state) => {
851
+ const room = useEditor.getState().room
852
+ if (room && !state.nodes[room.zoneId as AnyNodeId]) useEditor.getState().clearRoom()
853
+ })
854
+ requestRoomDeletion(other.id)
855
+ const request = useDeleteConfirmation.getState().request!
856
+ expect(request.room).toMatchObject({ mode: 'merge', mergedIntoZoneId: zoneId })
857
+ expect(request.onKeepContents).toBeUndefined()
858
+ useDeleteConfirmation.getState().confirm()
859
+ stop()
860
+ expect(zones().map((n) => n.id as string)).toEqual([zoneId])
861
+ expect(Object.values(useScene.getState().nodes).filter((n) => n.type === 'separator')).toEqual(
862
+ [],
863
+ )
864
+ expect(Object.values(useScene.getState().nodes).filter((n) => n.type === 'wall')).toEqual(walls)
865
+ expect(useEditor.getState().room).toEqual({ levelId: 'level_room_controls', zoneId })
866
+ expect(useScene.temporal.getState().pastStates).toHaveLength(2)
867
+ })
868
+ test('a room inside shared walls opens no dialog; the pill trash is off and says why', () => {
869
+ const points: [number, number][] = [
870
+ [-2, -2],
871
+ [10, -2],
872
+ [10, 6],
873
+ [-2, 6],
874
+ ]
875
+ useScene.getState().applyNodeChanges({
876
+ create: points.map((start, i) => ({
877
+ node: WallNode.parse({
878
+ parentId: 'level_room_controls',
879
+ start,
880
+ end: points[(i + 1) % 4],
881
+ }),
882
+ parentId: 'level_room_controls' as AnyNodeId,
883
+ })),
884
+ })
885
+ expect(Object.values(useScene.getState().nodes).filter((n) => n.type === 'zone')).toHaveLength(
886
+ 2,
887
+ )
888
+ const before = useScene.getState().nodes
889
+ requestRoomDeletion(zoneId)
890
+ expect(useDeleteConfirmation.getState().request).toBeNull()
891
+ expect(useRoomTransform.getState().notice).toEqual({
892
+ zoneId,
893
+ message: SHARED_WALLS_DELETE_MESSAGE,
894
+ })
895
+ expect(useScene.getState().nodes).toBe(before)
896
+ expect(roomDeleteBlockedReason(useScene.getState().nodes, zoneId)).toBe(
897
+ SHARED_WALLS_DELETE_MESSAGE,
898
+ )
899
+ const pill = (reason?: string) =>
900
+ renderToStaticMarkup(
901
+ createElement(NodeActionMenu, {
902
+ deleteDisabledReason: reason,
903
+ deleteLabel: 'Delete room',
904
+ onDelete: () => {},
905
+ onDuplicate: () => {},
906
+ onMove: () => {},
907
+ onRotateLeft: () => {},
908
+ onRotateRight: () => {},
909
+ }),
910
+ )
911
+ const trash = (html: string) =>
912
+ html.match(/<button[^>]*aria-label="Delete room"[^>]*>/)?.[0] ?? ''
913
+ expect(trash(pill(SHARED_WALLS_DELETE_MESSAGE))).toContain('aria-disabled="true"')
914
+ expect(trash(pill())).not.toContain('aria-disabled')
915
+ // Every pill button is named for the editor tooltip; none uses the browser's title.
916
+ const html = pill()
917
+ for (const label of ['Move', 'Rotate left', 'Rotate right', 'Duplicate', 'Delete room'])
918
+ expect(html).toContain(`aria-label="${label}"`)
919
+ expect(html).not.toMatch(/<button[^>]* title=/)
920
+ })
921
+ })