@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
@@ -43,6 +43,9 @@ type FloorplanDraftPreviewState = {
43
43
  /** First corner of an open rectangle-wall draft; the live cursor is the
44
44
  * opposite corner, so the rectangle tool publishes only per click. */
45
45
  wallRectangleDraftStart: WallPlanPoint | null
46
+ /** Corners placed so far by an open Polygon room. Draft only — no wall is
47
+ * written until the polygon completes (`lib/wall-polygon-draft.ts`). */
48
+ wallPolygonDraftPoints: WallPlanPoint[]
46
49
  roofDraftQuarterTurn: boolean
47
50
  polygonDraftType: FloorplanPolygonDraftType | null
48
51
  polygonDraftPoints: WallPlanPoint[]
@@ -59,6 +62,7 @@ type FloorplanDraftPreviewState = {
59
62
  setFenceDraftStart(point: WallPlanPoint | null): void
60
63
  setRoofDraftStart(point: WallPlanPoint | null): void
61
64
  setWallRectangleDraftStart(point: WallPlanPoint | null): void
65
+ setWallPolygonDraftPoints(points: readonly WallPlanPoint[]): void
62
66
  setRoofDraftQuarterTurn(quarterTurn: boolean): void
63
67
  setPolygonDraft(type: FloorplanPolygonDraftType | null, points: readonly WallPlanPoint[]): void
64
68
  reset(): void
@@ -102,6 +106,7 @@ export const useFloorplanDraftPreview = create<FloorplanDraftPreviewState>((set)
102
106
  fenceDraftStart: null,
103
107
  roofDraftStart: null,
104
108
  wallRectangleDraftStart: null,
109
+ wallPolygonDraftPoints: [],
105
110
  roofDraftQuarterTurn: false,
106
111
  polygonDraftType: null,
107
112
  polygonDraftPoints: [],
@@ -126,6 +131,12 @@ export const useFloorplanDraftPreview = create<FloorplanDraftPreviewState>((set)
126
131
  setFenceDraftStart: (point) => set(setPlanPointField('fenceDraftStart', point)),
127
132
  setRoofDraftStart: (point) => set(setPlanPointField('roofDraftStart', point)),
128
133
  setWallRectangleDraftStart: (point) => set(setPlanPointField('wallRectangleDraftStart', point)),
134
+ setWallPolygonDraftPoints: (points) =>
135
+ set((state) =>
136
+ planPointsEqual(state.wallPolygonDraftPoints, points)
137
+ ? state
138
+ : { wallPolygonDraftPoints: points.map(([x, z]) => [x, z]) },
139
+ ),
129
140
  setRoofDraftQuarterTurn: (quarterTurn) =>
130
141
  set((state) =>
131
142
  state.roofDraftQuarterTurn === quarterTurn ? state : { roofDraftQuarterTurn: quarterTurn },
@@ -147,6 +158,7 @@ export const useFloorplanDraftPreview = create<FloorplanDraftPreviewState>((set)
147
158
  state.fenceDraftStart === null &&
148
159
  state.roofDraftStart === null &&
149
160
  state.wallRectangleDraftStart === null &&
161
+ state.wallPolygonDraftPoints.length === 0 &&
150
162
  state.roofDraftQuarterTurn === false &&
151
163
  state.polygonDraftType === null &&
152
164
  state.polygonDraftPoints.length === 0
@@ -161,6 +173,7 @@ export const useFloorplanDraftPreview = create<FloorplanDraftPreviewState>((set)
161
173
  fenceDraftStart: null,
162
174
  roofDraftStart: null,
163
175
  wallRectangleDraftStart: null,
176
+ wallPolygonDraftPoints: [],
164
177
  roofDraftQuarterTurn: false,
165
178
  polygonDraftType: null,
166
179
  polygonDraftPoints: [],
@@ -13,6 +13,7 @@
13
13
 
14
14
  import type { WallPlanPoint } from '@pascal-app/core'
15
15
  import { create } from 'zustand'
16
+ import useInteractionScope from './use-interaction-scope'
16
17
 
17
18
  export type FloorplanMarqueeDrag = {
18
19
  pointerId: number
@@ -34,7 +35,10 @@ type FloorplanMarqueeState = {
34
35
 
35
36
  export const useFloorplanMarquee = create<FloorplanMarqueeState>((set) => ({
36
37
  drag: null,
37
- begin: (drag) => set({ drag }),
38
+ begin: (drag) => {
39
+ useInteractionScope.getState().begin({ kind: 'box-select' })
40
+ set({ drag })
41
+ },
38
42
  setCurrent: (point) =>
39
43
  set((state) => {
40
44
  const prev = state.drag
@@ -44,7 +48,13 @@ export const useFloorplanMarquee = create<FloorplanMarqueeState>((set) => ({
44
48
  }
45
49
  return { drag: { ...prev, currentPlanPoint: point } }
46
50
  }),
47
- reset: () => set((state) => (state.drag === null ? state : { drag: null })),
51
+ reset: () => {
52
+ set((state) => {
53
+ if (!state.drag) return state
54
+ useInteractionScope.getState().endIf((scope) => scope.kind === 'box-select')
55
+ return { drag: null }
56
+ })
57
+ },
48
58
  }))
49
59
 
50
60
  export default useFloorplanMarquee
@@ -1,6 +1,6 @@
1
1
  import type { QuickMeasurementReport } from '@pascal-app/core'
2
2
  import { create } from 'zustand'
3
- import type { ViewMode } from './use-editor'
3
+ import type { VisibleScene } from './view-layout'
4
4
 
5
5
  export type QuickMeasurementHudSource = '2d' | '3d'
6
6
 
@@ -55,9 +55,9 @@ export const useQuickMeasurementHud = create<QuickMeasurementHudState>((set) =>
55
55
 
56
56
  export function selectQuickMeasurementHudEntry(
57
57
  state: QuickMeasurementHudState,
58
- viewMode: ViewMode,
58
+ scene: VisibleScene | null,
59
59
  ): QuickMeasurementHudEntry | null {
60
- const source = viewMode === 'split' ? state.activeSource : viewMode
60
+ const source = scene === 'split' ? state.activeSource : scene
61
61
  return source ? state.sources[source] : null
62
62
  }
63
63
 
@@ -17,11 +17,12 @@ type StairBuildPreviewState = {
17
17
  point: StairPreviewPoint | null
18
18
  /** Yaw (radians), cycled by R / T. */
19
19
  rotation: number
20
+ rise: number | null
20
21
  /** Set the snapped point. No-ops (skips the store update, so subscribers
21
22
  * don't re-render) when the point is unchanged — `grid:move` fires far more
22
23
  * often than the snapped cell actually changes. */
23
24
  setPoint(point: StairPreviewPoint | null): void
24
- setPreview(point: StairPreviewPoint | null, rotation: number): void
25
+ setPreview(point: StairPreviewPoint | null, rotation: number, rise?: number | null): void
25
26
  rotateBy(deltaRadians: number): void
26
27
  reset(): void
27
28
  }
@@ -29,6 +30,7 @@ type StairBuildPreviewState = {
29
30
  export const useStairBuildPreview = create<StairBuildPreviewState>((set) => ({
30
31
  point: null,
31
32
  rotation: 0,
33
+ rise: null,
32
34
  setPoint: (point) =>
33
35
  set((state) => {
34
36
  const prev = state.point
@@ -36,18 +38,24 @@ export const useStairBuildPreview = create<StairBuildPreviewState>((set) => ({
36
38
  if (point && prev && prev[0] === point[0] && prev[1] === point[1]) return state
37
39
  return { point: point ? [point[0], point[1]] : null }
38
40
  }),
39
- setPreview: (point, rotation) =>
41
+ setPreview: (point, rotation, rise) =>
40
42
  set((state) => {
41
43
  const samePoint =
42
44
  (!point && !state.point) ||
43
45
  Boolean(point && state.point && state.point[0] === point[0] && state.point[1] === point[1])
44
- return samePoint && state.rotation === rotation
46
+ return samePoint && state.rotation === rotation && (rise === undefined || rise === state.rise)
45
47
  ? state
46
- : { point: point ? [point[0], point[1]] : null, rotation }
48
+ : {
49
+ point: point ? [point[0], point[1]] : null,
50
+ rotation,
51
+ rise: rise === undefined ? state.rise : rise,
52
+ }
47
53
  }),
48
54
  rotateBy: (deltaRadians) => set((state) => ({ rotation: state.rotation + deltaRadians })),
49
55
  reset: () =>
50
56
  set((state) =>
51
- state.point === null && state.rotation === 0 ? state : { point: null, rotation: 0 },
57
+ state.point === null && state.rotation === 0 && state.rise === null
58
+ ? state
59
+ : { point: null, rotation: 0, rise: null },
52
60
  ),
53
61
  }))
@@ -10,8 +10,10 @@ import { create } from 'zustand'
10
10
  * as translucent ghost meshes mid-drag; the 2D `wallFloorplanMoveTarget`
11
11
  * writes here so the floor-plan SVG layer can render the same hint.
12
12
  *
13
- * Writer: `packages/nodes/src/wall/floorplan-move.ts` (on each `apply`,
14
- * cleared on `commit` and by the move overlay's cleanup).
13
+ * Writers: `packages/nodes/src/wall/floorplan-move.ts` (on each `apply`,
14
+ * cleared on `commit` and by the move overlay's cleanup), and the wall push
15
+ * preview in `lib/room-handle-drag.ts` (the pieces a push would create,
16
+ * cleared when the push previews again, commits or cancels).
15
17
  * Reader: `packages/editor/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx`.
16
18
  */
17
19
  export type WallMoveGhostBridge = {
@@ -0,0 +1,222 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { type AnyNodeId, useScene, WallNode } from '@pascal-app/core'
3
+ import { useViewer } from '@pascal-app/viewer'
4
+ import useInteractionScope from './use-interaction-scope'
5
+ import {
6
+ handleWallRegionDeleteKey,
7
+ installWallRegionSceneSync,
8
+ resolveActiveWallRegion,
9
+ syncWallRegionSelection,
10
+ useWallRegionSelection,
11
+ } from './use-wall-region-handles-selection'
12
+
13
+ type RafFn = (callback: (time: number) => void) => number
14
+ ;(globalThis as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (callback) => {
15
+ callback(0)
16
+ return 0
17
+ }
18
+ ;(globalThis as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= () => {}
19
+
20
+ const WALL_ID = 'wall_region-selection' as AnyNodeId
21
+ const OTHER_WALL_ID = 'wall_region-selection-other' as AnyNodeId
22
+
23
+ function keyEvent(key: string, target: unknown = null) {
24
+ const calls = { prevented: 0, stopped: 0 }
25
+ return {
26
+ calls,
27
+ event: {
28
+ key,
29
+ target: target as EventTarget | null,
30
+ preventDefault: () => {
31
+ calls.prevented += 1
32
+ },
33
+ stopPropagation: () => {
34
+ calls.stopped += 1
35
+ },
36
+ },
37
+ }
38
+ }
39
+
40
+ // The bun env has no DOM: just enough of `document` and elements for the focus check.
41
+ const originalDocument = (globalThis as { document?: unknown }).document
42
+ const body = { tagName: 'BODY', closest: () => null }
43
+ ;(globalThis as { document?: unknown }).document = { body, documentElement: { tagName: 'HTML' } }
44
+
45
+ function element(tagName: string, ancestors: string[] = []) {
46
+ return {
47
+ tagName,
48
+ closest: (selector: string) => (ancestors.includes(selector) ? {} : null),
49
+ }
50
+ }
51
+
52
+ const uninstallSceneSync = installWallRegionSceneSync()
53
+
54
+ const wall = () => useScene.getState().nodes[WALL_ID] as WallNode | undefined
55
+
56
+ describe('active wall region', () => {
57
+ beforeEach(() => {
58
+ useScene.setState({ nodes: {}, rootNodeIds: [], dirtyNodes: new Set() } as never)
59
+ for (const id of [WALL_ID, OTHER_WALL_ID]) {
60
+ useScene.getState().createNode(
61
+ WallNode.parse({
62
+ id,
63
+ start: [0, 0],
64
+ end: [4, 0],
65
+ faceRegions: [
66
+ { id: 'r1', face: 'a', v1: 0.9, finish: 'library:paint-white' },
67
+ { id: 'r2', face: 'b', u0: 1, finish: 'library:paint-white' },
68
+ ],
69
+ }),
70
+ )
71
+ }
72
+ useScene.temporal.getState().clear()
73
+ useScene.temporal.getState().resume()
74
+ useViewer.getState().setSelection({ selectedIds: [WALL_ID] })
75
+ useWallRegionSelection.setState({ active: null, stale: null })
76
+ useInteractionScope.getState().end()
77
+ })
78
+
79
+ afterAll(() => {
80
+ uninstallSceneSync()
81
+ useWallRegionSelection.setState({ active: null, stale: null })
82
+ useInteractionScope.getState().end()
83
+ useViewer.getState().setSelection({ selectedIds: [] })
84
+ ;(globalThis as { document?: unknown }).document = originalDocument
85
+ })
86
+
87
+ test('Delete removes only the active region, as one undo step', () => {
88
+ useWallRegionSelection.getState().setActive({ wallId: WALL_ID, regionId: 'r2' })
89
+ const { event, calls } = keyEvent('Delete')
90
+ expect(handleWallRegionDeleteKey(event)).toBe(true)
91
+ expect(calls).toEqual({ prevented: 1, stopped: 1 })
92
+ expect(wall()).toBeDefined()
93
+ expect(wall()!.faceRegions!.map((region) => region.id)).toEqual(['r1'])
94
+ expect(useWallRegionSelection.getState().active).toBeNull()
95
+ expect(useScene.temporal.getState().pastStates).toHaveLength(1)
96
+ useScene.temporal.getState().undo()
97
+ expect(wall()!.faceRegions!.map((region) => region.id)).toEqual(['r1', 'r2'])
98
+ })
99
+
100
+ test('Backspace works too; the last region clears the field', () => {
101
+ useWallRegionSelection.getState().setActive({ wallId: WALL_ID, regionId: 'r1' })
102
+ expect(handleWallRegionDeleteKey(keyEvent('Backspace').event)).toBe(true)
103
+ useWallRegionSelection.getState().setActive({ wallId: WALL_ID, regionId: 'r2' })
104
+ expect(handleWallRegionDeleteKey(keyEvent('Backspace').event)).toBe(true)
105
+ expect(wall()!.faceRegions).toBeUndefined()
106
+ })
107
+
108
+ test('without an active region the key passes through to the wall delete', () => {
109
+ const { event, calls } = keyEvent('Delete')
110
+ expect(handleWallRegionDeleteKey(event)).toBe(false)
111
+ expect(calls).toEqual({ prevented: 0, stopped: 0 })
112
+ expect(wall()!.faceRegions).toHaveLength(2)
113
+ })
114
+
115
+ test('typing in a field and other keys are left alone', () => {
116
+ useWallRegionSelection.getState().setActive({ wallId: WALL_ID, regionId: 'r1' })
117
+ const input = { closest: (selector: string) => (selector.includes('input') ? {} : null) }
118
+ expect(handleWallRegionDeleteKey(keyEvent('Backspace', input).event)).toBe(false)
119
+ expect(handleWallRegionDeleteKey(keyEvent('Escape').event)).toBe(false)
120
+ expect(wall()!.faceRegions).toHaveLength(2)
121
+ })
122
+
123
+ test('an active region on a wall that is no longer selected is ignored and cleared', () => {
124
+ useWallRegionSelection.getState().setActive({ wallId: WALL_ID, regionId: 'r1' })
125
+ useViewer.getState().setSelection({ selectedIds: [OTHER_WALL_ID] })
126
+ expect(resolveActiveWallRegion()).toBeNull()
127
+ expect(handleWallRegionDeleteKey(keyEvent('Delete').event)).toBe(false)
128
+ syncWallRegionSelection([OTHER_WALL_ID])
129
+ expect(useWallRegionSelection.getState().active).toBeNull()
130
+ const other = useScene.getState().nodes[OTHER_WALL_ID] as WallNode
131
+ expect(other.faceRegions).toHaveLength(2)
132
+ })
133
+
134
+ test('a selection that still holds just the wall keeps the active region', () => {
135
+ useWallRegionSelection.getState().setActive({ wallId: WALL_ID, regionId: 'r1' })
136
+ syncWallRegionSelection([WALL_ID])
137
+ expect(useWallRegionSelection.getState().active).toEqual({ wallId: WALL_ID, regionId: 'r1' })
138
+ syncWallRegionSelection([WALL_ID, OTHER_WALL_ID])
139
+ expect(useWallRegionSelection.getState().active).toBeNull()
140
+ })
141
+
142
+ test('focus on the body or the canvas acts on the region', () => {
143
+ useWallRegionSelection.getState().setActive({ wallId: WALL_ID, regionId: 'r1' })
144
+ expect(handleWallRegionDeleteKey(keyEvent('Delete', body).event)).toBe(true)
145
+ useWallRegionSelection.getState().setActive({ wallId: WALL_ID, regionId: 'r2' })
146
+ expect(handleWallRegionDeleteKey(keyEvent('Delete', element('CANVAS')).event)).toBe(true)
147
+ expect(wall()!.faceRegions).toBeUndefined()
148
+ })
149
+
150
+ test('focus on a toolbar button leaves the key alone', () => {
151
+ useWallRegionSelection.getState().setActive({ wallId: WALL_ID, regionId: 'r1' })
152
+ const { event, calls } = keyEvent('Delete', element('BUTTON'))
153
+ expect(handleWallRegionDeleteKey(event)).toBe(false)
154
+ expect(calls).toEqual({ prevented: 0, stopped: 0 })
155
+ expect(wall()!.faceRegions).toHaveLength(2)
156
+ })
157
+
158
+ test('focus inside the wall region list acts on the region', () => {
159
+ useWallRegionSelection.getState().setActive({ wallId: WALL_ID, regionId: 'r1' })
160
+ const row = element('BUTTON', ['[data-wall-region-list]'])
161
+ expect(handleWallRegionDeleteKey(keyEvent('Delete', row).event)).toBe(true)
162
+ expect(wall()!.faceRegions!.map((region) => region.id)).toEqual(['r2'])
163
+ })
164
+
165
+ test('mid-gesture the key is swallowed: nothing deleted, the wall stays', () => {
166
+ useWallRegionSelection.getState().setActive({ wallId: WALL_ID, regionId: 'r1' })
167
+ useInteractionScope
168
+ .getState()
169
+ .begin({ kind: 'handle-drag', nodeId: WALL_ID, handle: 'paint-region-bound' })
170
+ const { event, calls } = keyEvent('Delete')
171
+ expect(handleWallRegionDeleteKey(event)).toBe(true)
172
+ expect(calls).toEqual({ prevented: 1, stopped: 1 })
173
+ expect(wall()!.faceRegions).toHaveLength(2)
174
+ expect(useWallRegionSelection.getState().active).toEqual({ wallId: WALL_ID, regionId: 'r1' })
175
+ })
176
+
177
+ test('mid-gesture without an active region the key passes through', () => {
178
+ useInteractionScope
179
+ .getState()
180
+ .begin({ kind: 'handle-drag', nodeId: WALL_ID, handle: 'paint-region-bound' })
181
+ expect(handleWallRegionDeleteKey(keyEvent('Delete').event)).toBe(false)
182
+ })
183
+
184
+ test('a region that vanished under the user swallows the next Delete once', () => {
185
+ useWallRegionSelection.getState().setActive({ wallId: WALL_ID, regionId: 'r1' })
186
+ const regions = wall()!.faceRegions!.filter((region) => region.id !== 'r1')
187
+ useScene.getState().updateNode(WALL_ID, { faceRegions: regions })
188
+ expect(useWallRegionSelection.getState().active).toBeNull()
189
+ expect(useWallRegionSelection.getState().stale).toEqual({ wallId: WALL_ID, regionId: 'r1' })
190
+
191
+ const { event, calls } = keyEvent('Delete')
192
+ expect(handleWallRegionDeleteKey(event)).toBe(true)
193
+ expect(calls).toEqual({ prevented: 1, stopped: 1 })
194
+ expect(wall()).toBeDefined()
195
+ expect(wall()!.faceRegions!.map((region) => region.id)).toEqual(['r2'])
196
+ expect(useWallRegionSelection.getState().stale).toBeNull()
197
+ expect(handleWallRegionDeleteKey(keyEvent('Delete').event)).toBe(false)
198
+ })
199
+
200
+ test('removing the active region with Delete does not leave it stale', () => {
201
+ useWallRegionSelection.getState().setActive({ wallId: WALL_ID, regionId: 'r1' })
202
+ expect(handleWallRegionDeleteKey(keyEvent('Delete').event)).toBe(true)
203
+ expect(useWallRegionSelection.getState().stale).toBeNull()
204
+ expect(handleWallRegionDeleteKey(keyEvent('Delete').event)).toBe(false)
205
+ })
206
+
207
+ test('a selection change clears the stale region, so Delete falls through', () => {
208
+ useWallRegionSelection.getState().setActive({ wallId: WALL_ID, regionId: 'r1' })
209
+ const regions = wall()!.faceRegions!.filter((region) => region.id !== 'r1')
210
+ useScene.getState().updateNode(WALL_ID, { faceRegions: regions })
211
+ expect(useWallRegionSelection.getState().stale).not.toBeNull()
212
+
213
+ useViewer.getState().setSelection({ selectedIds: [OTHER_WALL_ID] })
214
+ syncWallRegionSelection([OTHER_WALL_ID])
215
+ useViewer.getState().setSelection({ selectedIds: [WALL_ID] })
216
+ syncWallRegionSelection([WALL_ID])
217
+ expect(useWallRegionSelection.getState().stale).toBeNull()
218
+ const { event, calls } = keyEvent('Delete')
219
+ expect(handleWallRegionDeleteKey(event)).toBe(false)
220
+ expect(calls).toEqual({ prevented: 0, stopped: 0 })
221
+ })
222
+ })
@@ -0,0 +1,139 @@
1
+ import { type AnyNodeId, useScene } from '@pascal-app/core'
2
+ import { useViewer } from '@pascal-app/viewer'
3
+ import { create } from 'zustand'
4
+ import { hasLiveGestures } from '../lib/gesture-lifecycle'
5
+ import { removeWallRegion } from '../lib/paint-regions'
6
+ import { sfxEmitter } from '../lib/sfx-bus'
7
+ import { isTypingTarget } from '../lib/typing-target'
8
+ import useDeleteConfirmation from './use-delete-confirmation'
9
+ import useInteractionScope from './use-interaction-scope'
10
+
11
+ // The paint region the user last picked on the selected wall (a handle, an
12
+ // outline or a panel row). While it is set, Delete removes that region instead
13
+ // of the wall. Cleared whenever the wall selection changes.
14
+ //
15
+ // `stale` remembers an active region that vanished under the user (undo, another
16
+ // edit) while its wall stayed selected: the next Delete is swallowed rather than
17
+ // deleting the wall the user was not aiming at.
18
+
19
+ export type ActiveWallRegion = { wallId: string; regionId: string }
20
+
21
+ type WallRegionSelectionState = {
22
+ active: ActiveWallRegion | null
23
+ stale: ActiveWallRegion | null
24
+ setActive: (active: ActiveWallRegion | null) => void
25
+ setStale: (stale: ActiveWallRegion | null) => void
26
+ }
27
+
28
+ export const useWallRegionSelection = create<WallRegionSelectionState>((set) => ({
29
+ active: null,
30
+ stale: null,
31
+ setActive: (active) =>
32
+ set((state) =>
33
+ state.active?.wallId === active?.wallId && state.active?.regionId === active?.regionId
34
+ ? state
35
+ : active
36
+ ? { active, stale: null }
37
+ : { active },
38
+ ),
39
+ setStale: (stale) => set((state) => (state.stale === stale ? state : { stale })),
40
+ }))
41
+
42
+ function isSoleSelection(wallId: string, selectedIds: readonly string[]) {
43
+ return selectedIds.length === 1 && selectedIds[0] === wallId
44
+ }
45
+
46
+ function regionExists(wallId: string, regionId: string) {
47
+ const wall = useScene.getState().nodes[wallId as AnyNodeId]
48
+ if (wall?.type !== 'wall') return false
49
+ return wall.faceRegions?.some((region) => region.id === regionId) ?? false
50
+ }
51
+
52
+ /** The active region, only while its wall is the sole selection and the region still exists. */
53
+ export function resolveActiveWallRegion(): ActiveWallRegion | null {
54
+ const active = useWallRegionSelection.getState().active
55
+ if (!active) return null
56
+ if (!isSoleSelection(active.wallId, useViewer.getState().selection.selectedIds)) return null
57
+ return regionExists(active.wallId, active.regionId) ? active : null
58
+ }
59
+
60
+ function resolveStaleWallRegion(): ActiveWallRegion | null {
61
+ const stale = useWallRegionSelection.getState().stale
62
+ if (!stale) return null
63
+ return isSoleSelection(stale.wallId, useViewer.getState().selection.selectedIds) ? stale : null
64
+ }
65
+
66
+ /** Clears the active (and stale) region unless the selection is still exactly its wall. */
67
+ export function syncWallRegionSelection(selectedIds: readonly string[]) {
68
+ const { active, stale, setActive, setStale } = useWallRegionSelection.getState()
69
+ if (active && !isSoleSelection(active.wallId, selectedIds)) setActive(null)
70
+ if (stale && !isSoleSelection(stale.wallId, selectedIds)) setStale(null)
71
+ }
72
+
73
+ /** Drops the active region once it no longer exists in the scene. */
74
+ export function syncWallRegionWithScene() {
75
+ const { active, setActive, setStale } = useWallRegionSelection.getState()
76
+ if (!active || regionExists(active.wallId, active.regionId)) return
77
+ setActive(null)
78
+ const wallStillThere = useScene.getState().nodes[active.wallId as AnyNodeId]?.type === 'wall'
79
+ const selectedIds = useViewer.getState().selection.selectedIds
80
+ if (wallStillThere && isSoleSelection(active.wallId, selectedIds)) setStale(active)
81
+ }
82
+
83
+ /** Keeps the active region in step with scene edits (undo, remote edits). Returns the unsubscribe. */
84
+ export function installWallRegionSceneSync() {
85
+ return useScene.subscribe((state, previous) => {
86
+ if (state.nodes !== previous.nodes) syncWallRegionWithScene()
87
+ })
88
+ }
89
+
90
+ // Delete belongs to the region only when focus is on the scene or the wall's
91
+ // region list; a focused toolbar button or panel control keeps its own key.
92
+ function isSceneOrRegionListTarget(target: EventTarget | null) {
93
+ if (!target) return true
94
+ if (
95
+ typeof document !== 'undefined' &&
96
+ (target === document || target === document.body || target === document.documentElement)
97
+ ) {
98
+ return true
99
+ }
100
+ const element = target as { tagName?: string; closest?: (selector: string) => unknown }
101
+ if (element.tagName?.toUpperCase() === 'CANVAS') return true
102
+ return !!element.closest?.('[data-wall-region-list]')
103
+ }
104
+
105
+ function consume(event: KeyEventLike) {
106
+ event.preventDefault()
107
+ event.stopPropagation()
108
+ }
109
+
110
+ type KeyEventLike = Pick<KeyboardEvent, 'key' | 'target' | 'preventDefault' | 'stopPropagation'>
111
+
112
+ /**
113
+ * Delete / Backspace with an active region removes that region (one undo step)
114
+ * and consumes the key so the global delete never sees it. Mid-gesture, or
115
+ * right after the active region vanished, the key is swallowed so it cannot
116
+ * fall through to deleting the wall. Returns whether the key was consumed.
117
+ */
118
+ export function handleWallRegionDeleteKey(event: KeyEventLike): boolean {
119
+ if (event.key !== 'Delete' && event.key !== 'Backspace') return false
120
+ if (isTypingTarget(event.target)) return false
121
+ if (!isSceneOrRegionListTarget(event.target)) return false
122
+ if (useDeleteConfirmation.getState().request) return false
123
+ const active = resolveActiveWallRegion()
124
+ const stale = active ? null : resolveStaleWallRegion()
125
+ if (!(active || stale)) return false
126
+
127
+ consume(event)
128
+ const busy = useInteractionScope.getState().scope.kind !== 'idle' || hasLiveGestures()
129
+ if (busy) return true
130
+ if (stale) {
131
+ useWallRegionSelection.getState().setStale(null)
132
+ return true
133
+ }
134
+ if (!active) return true
135
+ // Cleared first so the scene sync does not mistake this removal for a vanished region.
136
+ useWallRegionSelection.getState().setActive(null)
137
+ if (removeWallRegion(active.wallId, active.regionId)) sfxEmitter.emit('sfx:structure-delete')
138
+ return true
139
+ }