@pascal-app/editor 1.0.6 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (382) hide show
  1. package/package.json +18 -15
  2. package/src/components/editor/action-menu-button.tsx +4 -1
  3. package/src/components/editor/alignment-3d-guide-layer.tsx +102 -11
  4. package/src/components/editor/bake-thumbnail.tsx +3 -2
  5. package/src/components/editor/custom-camera-controls.tsx +110 -42
  6. package/src/components/editor/duplicate-node.ts +2 -1
  7. package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
  8. package/src/components/editor/first-person-controls.tsx +77 -68
  9. package/src/components/editor/fit-scene-framing.test.ts +318 -0
  10. package/src/components/editor/fit-scene-framing.ts +142 -0
  11. package/src/components/editor/floating-action-menu.tsx +31 -11
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floor-openings-overlay.tsx +1 -1
  14. package/src/components/editor/floor-region-controls.tsx +2 -2
  15. package/src/components/editor/floorplan-panel.test.ts +220 -0
  16. package/src/components/editor/floorplan-panel.tsx +458 -140
  17. package/src/components/editor/floorplan-wall-typing-keys.test.tsx +152 -0
  18. package/src/components/editor/floorplan-wall-typing-keys.ts +79 -0
  19. package/src/components/editor/footprint-height-handle.tsx +2 -2
  20. package/src/components/editor/grid.tsx +24 -15
  21. package/src/components/editor/group-actions.ts +19 -1
  22. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  23. package/src/components/editor/group-move-3d.ts +27 -2
  24. package/src/components/editor/group-selection-box-3d.tsx +4 -1
  25. package/src/components/editor/group-transform-shared.ts +23 -1
  26. package/src/components/editor/index.tsx +150 -20
  27. package/src/components/editor/inspector-extension-actions.tsx +36 -0
  28. package/src/components/editor/localized-html.tsx +22 -0
  29. package/src/components/editor/measurement-pill.tsx +1 -1
  30. package/src/components/editor/node-action-menu.tsx +46 -12
  31. package/src/components/editor/node-arrow-handles.tsx +2 -3
  32. package/src/components/editor/open-wall-ends-3d-layer.tsx +2 -3
  33. package/src/components/editor/open-wall-ends-hint.tsx +1 -1
  34. package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
  35. package/src/components/editor/quick-measurement-card.tsx +1 -1
  36. package/src/components/editor/registry-action-contributions.test.tsx +67 -6
  37. package/src/components/editor/registry-action-contributions.tsx +115 -15
  38. package/src/components/editor/riser-diagram-panel.tsx +59 -9
  39. package/src/components/editor/room-controls.tsx +4 -3
  40. package/src/components/editor/room-handles-2d.tsx +158 -7
  41. package/src/components/editor/room-handles.tsx +244 -54
  42. package/src/components/editor/room-highlight.tsx +2 -0
  43. package/src/components/editor/room-labels.tsx +33 -17
  44. package/src/components/editor/selection-manager.tsx +15 -102
  45. package/src/components/editor/separator-end-grip.tsx +74 -0
  46. package/src/components/editor/site-edge-labels.tsx +7 -3
  47. package/src/components/editor/thumbnail-generator.test.ts +15 -6
  48. package/src/components/editor/thumbnail-generator.tsx +26 -1
  49. package/src/components/editor/use-floorplan-background-placement.ts +55 -52
  50. package/src/components/editor/views/view-bar.tsx +111 -26
  51. package/src/components/editor/wall-measurement-label.tsx +31 -15
  52. package/src/components/editor/wall-move-side-handles.tsx +15 -7
  53. package/src/components/editor/wall-pull-grip.tsx +193 -0
  54. package/src/components/editor/wall-region-handles.tsx +2 -2
  55. package/src/components/editor/wall-snap-beacon-layer.tsx +134 -33
  56. package/src/components/editor-2d/floorplan-adjacent-walls-layer.test.ts +76 -0
  57. package/src/components/editor-2d/floorplan-adjacent-walls-layer.tsx +186 -0
  58. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +63 -3
  59. package/src/components/editor-2d/floorplan-group-move.tsx +32 -4
  60. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +6 -0
  61. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +33 -24
  62. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +12 -5
  63. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +2 -1
  64. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +29 -5
  65. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +96 -4
  66. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +52 -12
  67. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +7 -1
  68. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +13 -1
  69. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.test.tsx +35 -0
  70. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.tsx +65 -0
  71. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +34 -1
  72. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +211 -39
  73. package/src/components/procedural-items/inspector.tsx +59 -12
  74. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +2 -0
  75. package/src/components/systems/ceiling/ceiling-system.test.ts +141 -0
  76. package/src/components/systems/ceiling/ceiling-system.tsx +53 -18
  77. package/src/components/systems/roof/roof-edit-system.test.tsx +15 -1
  78. package/src/components/systems/roof/roof-edit-system.tsx +30 -19
  79. package/src/components/tools/distribution/distribution-run-tool.test.tsx +371 -3
  80. package/src/components/tools/distribution/distribution-run-tool.tsx +200 -58
  81. package/src/components/tools/item/move-tool.tsx +1 -6
  82. package/src/components/tools/item/placement-history.test.tsx +1 -1
  83. package/src/components/tools/item/placement-strategies.test.ts +64 -1
  84. package/src/components/tools/item/placement-strategies.ts +5 -9
  85. package/src/components/tools/item/use-placement-coordinator.tsx +31 -35
  86. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +1 -1
  87. package/src/components/tools/registry/item-surface-move.test.ts +11 -0
  88. package/src/components/tools/registry/move-registry-node-tool.test.ts +42 -1
  89. package/src/components/tools/registry/move-registry-node-tool.tsx +38 -14
  90. package/src/components/tools/select/select-candidates.test.ts +45 -0
  91. package/src/components/tools/select/select-candidates.ts +13 -2
  92. package/src/components/tools/shared/cursor-sphere.tsx +10 -3
  93. package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
  94. package/src/components/tools/shared/placement-box.tsx +2 -6
  95. package/src/components/tools/shared/placement-coordinate-input.tsx +94 -0
  96. package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
  97. package/src/components/tools/shared/segment-angle.ts +0 -4
  98. package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
  99. package/src/components/tools/tool-manager.tsx +1 -24
  100. package/src/components/tools/wall/wall-drafting.test.ts +299 -23
  101. package/src/components/tools/wall/wall-drafting.ts +132 -24
  102. package/src/components/tools/wall/wall-pull-snap.test.ts +274 -0
  103. package/src/components/tools/wall/wall-pull-snap.ts +563 -0
  104. package/src/components/tools/wall/wall-snap-geometry.test.ts +113 -1
  105. package/src/components/tools/wall/wall-snap-geometry.ts +137 -0
  106. package/src/components/tools/wall/wall-typed-entry.test.ts +99 -0
  107. package/src/components/tools/wall/wall-typed-entry.ts +70 -0
  108. package/src/components/tools/zone/zone-tool.tsx +3 -3
  109. package/src/components/ui/action-menu/action-button.tsx +2 -0
  110. package/src/components/ui/action-menu/camera-actions.tsx +8 -7
  111. package/src/components/ui/action-menu/control-modes.tsx +9 -5
  112. package/src/components/ui/action-menu/follow-pascal-pill.tsx +3 -1
  113. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +7 -1
  114. package/src/components/ui/action-menu/furnish-tools.tsx +7 -6
  115. package/src/components/ui/action-menu/index.tsx +1 -0
  116. package/src/components/ui/action-menu/measurement-control.tsx +35 -29
  117. package/src/components/ui/action-menu/point-ask-button.tsx +4 -3
  118. package/src/components/ui/action-menu/structure-tools.tsx +25 -23
  119. package/src/components/ui/action-menu/view-toggles.test.ts +18 -0
  120. package/src/components/ui/action-menu/view-toggles.tsx +256 -51
  121. package/src/components/ui/build-panel/build-panel.tsx +163 -25
  122. package/src/components/ui/command-palette/editor-commands.tsx +38 -11
  123. package/src/components/ui/controls/finish-swatch.tsx +45 -19
  124. package/src/components/ui/controls/material-picker.tsx +24 -17
  125. package/src/components/ui/controls/metric-control.test.ts +52 -0
  126. package/src/components/ui/controls/metric-control.tsx +62 -24
  127. package/src/components/ui/controls/segmented-control.tsx +5 -2
  128. package/src/components/ui/controls/slider-control.test.tsx +23 -0
  129. package/src/components/ui/controls/slider-control.tsx +47 -19
  130. package/src/components/ui/controls/tool-options-panel.tsx +74 -44
  131. package/src/components/ui/floating-level-selector.tsx +100 -48
  132. package/src/components/ui/helpers/building-helper.tsx +5 -3
  133. package/src/components/ui/helpers/contextual-helper-panel.tsx +97 -37
  134. package/src/components/ui/helpers/helper-manager.test.tsx +106 -3
  135. package/src/components/ui/helpers/helper-manager.tsx +136 -57
  136. package/src/components/ui/helpers/item-helper.tsx +6 -4
  137. package/src/components/ui/helpers/registered-tool-helper.tsx +3 -1
  138. package/src/components/ui/level-duplicate-dialog.tsx +32 -23
  139. package/src/components/ui/level-wall-copy-prompt.tsx +124 -0
  140. package/src/components/ui/panels/collections/collections-popover.tsx +11 -11
  141. package/src/components/ui/panels/deck-boards-fields.tsx +136 -0
  142. package/src/components/ui/panels/floor-foundation-panel.tsx +114 -39
  143. package/src/components/ui/panels/floor-opening-panel.tsx +74 -26
  144. package/src/components/ui/panels/inspector-extensions.tsx +66 -0
  145. package/src/components/ui/panels/mobile-panel-sheet.tsx +3 -1
  146. package/src/components/ui/panels/mobile-selection-bar.tsx +34 -28
  147. package/src/components/ui/panels/multi-field-value.test.ts +27 -0
  148. package/src/components/ui/panels/multi-field-value.ts +20 -1
  149. package/src/components/ui/panels/multi-height-mode.tsx +11 -10
  150. package/src/components/ui/panels/multi-parametric-inspector.tsx +122 -34
  151. package/src/components/ui/panels/multi-selection-panel.tsx +76 -36
  152. package/src/components/ui/panels/node-display.ts +35 -24
  153. package/src/components/ui/panels/panel-manager.tsx +9 -5
  154. package/src/components/ui/panels/panel-wrapper.tsx +63 -127
  155. package/src/components/ui/panels/parametric-field-control.tsx +13 -10
  156. package/src/components/ui/panels/parametric-inspector.tsx +18 -12
  157. package/src/components/ui/panels/reference-panel.tsx +51 -35
  158. package/src/components/ui/panels/room-construction-rows.tsx +465 -66
  159. package/src/components/ui/panels/room-name-options.test.ts +36 -3
  160. package/src/components/ui/panels/room-name-options.ts +110 -11
  161. package/src/components/ui/panels/room-openings-field.tsx +22 -13
  162. package/src/components/ui/panels/room-panel.tsx +207 -100
  163. package/src/components/ui/panels/shape-choice.tsx +7 -5
  164. package/src/components/ui/panels/wall-region-list.tsx +7 -7
  165. package/src/components/ui/primitives/shortcut-token.tsx +10 -6
  166. package/src/components/ui/right-stack.test.ts +13 -6
  167. package/src/components/ui/right-stack.tsx +21 -10
  168. package/src/components/ui/sidebar/icon-rail.tsx +11 -9
  169. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +10 -10
  170. package/src/components/ui/sidebar/panels/items-panel/index.tsx +10 -9
  171. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +3 -0
  172. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +102 -32
  173. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +1 -5
  174. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +4 -3
  175. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -3
  176. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +4 -2
  177. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +7 -5
  178. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +4 -2
  179. package/src/components/ui/sidebar/panels/site-panel/default-names.ts +75 -0
  180. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +4 -2
  181. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +4 -2
  182. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +4 -2
  183. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +4 -2
  184. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +4 -2
  185. package/src/components/ui/sidebar/panels/site-panel/index.tsx +118 -72
  186. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +36 -3
  187. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +4 -2
  188. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -4
  189. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +8 -2
  190. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +8 -5
  191. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +4 -2
  192. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -4
  193. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +4 -2
  194. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +4 -2
  195. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +8 -7
  196. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +7 -6
  197. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +16 -1
  198. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +15 -8
  199. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +4 -2
  200. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +4 -2
  201. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +3 -1
  202. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +19 -9
  203. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +25 -14
  204. package/src/components/ui/sidebar/tab-bar.tsx +4 -1
  205. package/src/components/ui/sidebar/use-plugin-panels.tsx +19 -10
  206. package/src/components/ui/snap-target-badge.tsx +7 -6
  207. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +721 -0
  208. package/src/components/ui/wall-type/wall-type-menu.tsx +167 -0
  209. package/src/components/viewer/floorplan-compass-button.tsx +12 -3
  210. package/src/components/viewer/floorplan-preview.tsx +27 -19
  211. package/src/components/viewer/render-settings-menu.tsx +294 -0
  212. package/src/components/viewer/sky-control.tsx +710 -0
  213. package/src/components/viewer/use-viewer-ui-sounds.test.ts +15 -0
  214. package/src/components/viewer/use-viewer-ui-sounds.ts +46 -0
  215. package/src/components/viewer/viewer-controls-bar.tsx +278 -260
  216. package/src/components/viewer/viewer-hover-label.tsx +12 -4
  217. package/src/components/viewer/viewer-rooms.tsx +62 -5
  218. package/src/components/viewer/viewer-scene-header.tsx +28 -17
  219. package/src/components/viewer/viewer-stage-switcher.tsx +4 -2
  220. package/src/components/viewer-overlay.tsx +8 -0
  221. package/src/components/walkthrough-hud.tsx +75 -2
  222. package/src/hooks/use-keyboard.test.ts +85 -4
  223. package/src/hooks/use-keyboard.ts +65 -7
  224. package/src/hooks/use-selected-room.ts +12 -1
  225. package/src/i18n/floor-footprint-name.ts +30 -0
  226. package/src/i18n/level-name.ts +39 -0
  227. package/src/i18n/locales/en/messages.po +15849 -0
  228. package/src/i18n/locales/en/messages.ts +1 -0
  229. package/src/i18n/locales/en-XA/messages.ts +1 -0
  230. package/src/i18n/locales/es/messages.po +39331 -0
  231. package/src/i18n/locales/es/messages.ts +1 -0
  232. package/src/i18n/locales/fr/messages.po +39331 -0
  233. package/src/i18n/locales/fr/messages.ts +1 -0
  234. package/src/i18n/locales/ja/messages.po +39331 -0
  235. package/src/i18n/locales/ja/messages.ts +1 -0
  236. package/src/i18n/locales/zh-CN/messages.po +39331 -0
  237. package/src/i18n/locales/zh-CN/messages.ts +1 -0
  238. package/src/i18n/locales.test.ts +63 -0
  239. package/src/i18n/locales.ts +32 -0
  240. package/src/i18n/message-slot.tsx +25 -0
  241. package/src/i18n/node-messages.ts +169 -0
  242. package/src/i18n/pseudo-locale.ts +17 -0
  243. package/src/i18n/published-entry.test.ts +17 -0
  244. package/src/i18n/scene-theme-name.ts +16 -0
  245. package/src/index.tsx +224 -45
  246. package/src/lib/add-storey.test.ts +3 -3
  247. package/src/lib/contextual-help.test.ts +20 -1
  248. package/src/lib/contextual-help.ts +101 -19
  249. package/src/lib/continuation.ts +38 -11
  250. package/src/lib/elevation-guides.ts +21 -6
  251. package/src/lib/elevator-support.ts +1 -72
  252. package/src/lib/floor-footprints.test.ts +3 -2
  253. package/src/lib/floor-footprints.ts +1 -9
  254. package/src/lib/floor-foundation-support.test.ts +15 -6
  255. package/src/lib/floor-opening-draft.ts +1 -1
  256. package/src/lib/floor-region-session.ts +1 -1
  257. package/src/lib/floorplan/dimension-drive.test.ts +36 -2
  258. package/src/lib/floorplan/dimension-drive.ts +68 -3
  259. package/src/lib/floorplan/floorplan-export.test.ts +16 -5
  260. package/src/lib/floorplan/floorplan-export.tsx +13 -16
  261. package/src/lib/floorplan/floorplan-extension.test.ts +11 -12
  262. package/src/lib/floorplan/floorplan-extension.ts +37 -18
  263. package/src/lib/floorplan/floorplan-mode.test.ts +13 -6
  264. package/src/lib/floorplan/floorplan-mode.ts +14 -12
  265. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +1 -1
  266. package/src/lib/floorplan/floorplan-readonly.ts +3 -3
  267. package/src/lib/floorplan/open-wall-ends.ts +2 -1
  268. package/src/lib/floorplan/schedules.ts +10 -6
  269. package/src/lib/floorplan/site-plan/site-annotations.test.ts +46 -1
  270. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -0
  271. package/src/lib/floorplan/site-plan/site-parts.ts +6 -3
  272. package/src/lib/geometry-script/author.test.ts +114 -0
  273. package/src/lib/geometry-script/author.ts +55 -23
  274. package/src/lib/geometry-script/client.ts +3 -0
  275. package/src/lib/geometry-script/finish-scripts.ts +7 -7
  276. package/src/lib/gesture-lifecycle.ts +3 -1
  277. package/src/lib/glb-export.test.ts +327 -0
  278. package/src/lib/glb-export.ts +70 -2
  279. package/src/lib/grid-frame.test.ts +56 -3
  280. package/src/lib/grid-frame.ts +28 -1
  281. package/src/lib/history.test.ts +2 -1
  282. package/src/lib/hud-title.ts +48 -24
  283. package/src/lib/ifc-export.test.ts +41 -1
  284. package/src/lib/inspector-card-mode.test.ts +3 -31
  285. package/src/lib/inspector-card-mode.ts +5 -10
  286. package/src/lib/inspector-expanded.test.ts +13 -0
  287. package/src/lib/inspector-expanded.ts +13 -1
  288. package/src/lib/interaction/scope.ts +1 -0
  289. package/src/lib/keyboard-pan.test.ts +7 -0
  290. package/src/lib/keyboard-pan.ts +9 -2
  291. package/src/lib/layer-thickness.test.ts +22 -0
  292. package/src/lib/layer-thickness.ts +79 -0
  293. package/src/lib/level-selection.test.ts +18 -2
  294. package/src/lib/level-selection.ts +31 -8
  295. package/src/lib/linear-display.ts +1 -1
  296. package/src/lib/live-gesture-round-trip.test.ts +1 -1
  297. package/src/lib/lot/drop-in.test.ts +59 -1
  298. package/src/lib/lot/drop-in.ts +42 -50
  299. package/src/lib/material-swatch-style.ts +24 -0
  300. package/src/lib/measurement-label.test.ts +1 -1
  301. package/src/lib/measurement-parser.ts +7 -3
  302. package/src/lib/measurements.test.ts +24 -6
  303. package/src/lib/measurements.ts +1 -203
  304. package/src/lib/mezzanine-selection.test.ts +3 -1
  305. package/src/lib/node-action-movement.test.tsx +115 -0
  306. package/src/lib/node-action-movement.ts +2 -0
  307. package/src/lib/orbit-near-plane.test.ts +12 -0
  308. package/src/lib/orbit-near-plane.ts +13 -0
  309. package/src/lib/paint-regions.test.ts +8 -5
  310. package/src/lib/paint-scope.test.ts +53 -0
  311. package/src/lib/paint-scope.ts +18 -4
  312. package/src/lib/panel-rows.ts +17 -8
  313. package/src/lib/placement-metadata.ts +4 -0
  314. package/src/lib/point-ask/describe-target.ts +6 -8
  315. package/src/lib/point-ask/region.ts +1 -1
  316. package/src/lib/print-shell-compiler.ts +12 -1
  317. package/src/lib/print-wall-solids.test.ts +11 -0
  318. package/src/lib/print-wall-solids.ts +9 -0
  319. package/src/lib/reference-scale.ts +83 -0
  320. package/src/lib/room-assembly-overlay.ts +1 -1
  321. package/src/lib/room-built-on.test.ts +28 -17
  322. package/src/lib/room-built-on.ts +21 -17
  323. package/src/lib/room-construction-commands.ts +3 -2
  324. package/src/lib/room-construction.test.ts +28 -1
  325. package/src/lib/room-construction.ts +14 -0
  326. package/src/lib/room-controls-phase6.test.ts +141 -1
  327. package/src/lib/room-handle-drag.ts +554 -58
  328. package/src/lib/room-name-catalog.test.ts +8 -0
  329. package/src/lib/room-name-catalog.ts +8 -1
  330. package/src/lib/room-name-messages.test.ts +132 -0
  331. package/src/lib/room-name-messages.ts +147 -0
  332. package/src/lib/room-name-storage.test.ts +334 -0
  333. package/src/lib/room-push-dimensions.test.ts +38 -1
  334. package/src/lib/room-push-dimensions.ts +226 -2
  335. package/src/lib/room-selection.test.ts +23 -6
  336. package/src/lib/scene-bounds.test.ts +144 -0
  337. package/src/lib/scene-bounds.ts +79 -15
  338. package/src/lib/scene-clipboard.test.ts +70 -49
  339. package/src/lib/scene-clipboard.ts +11 -5
  340. package/src/lib/selection-cycle.ts +63 -0
  341. package/src/lib/separator-edit.test.ts +431 -0
  342. package/src/lib/separator-edit.ts +179 -0
  343. package/src/lib/separator-lines.ts +38 -0
  344. package/src/lib/sfx-bus.ts +27 -1
  345. package/src/lib/sfx-player.ts +16 -0
  346. package/src/lib/snapping-mode.test.ts +23 -1
  347. package/src/lib/tool-cursor-icon.ts +5 -1
  348. package/src/lib/tool-shortcuts.ts +130 -0
  349. package/src/lib/viewer-selection.test.ts +28 -24
  350. package/src/lib/viewer-selection.ts +21 -6
  351. package/src/lib/wall-draw-variant.test.ts +7 -6
  352. package/src/lib/wall-draw-variant.ts +107 -6
  353. package/src/lib/wall-nudge.test.ts +44 -0
  354. package/src/lib/wall-nudge.ts +131 -0
  355. package/src/lib/wall-parallel-snap.test.ts +83 -0
  356. package/src/lib/wall-parallel-snap.ts +176 -0
  357. package/src/lib/wall-placement-pointer.test.ts +23 -0
  358. package/src/lib/wall-placement-pointer.ts +18 -0
  359. package/src/lib/wall-pull.test.ts +245 -0
  360. package/src/lib/wall-pull.ts +209 -0
  361. package/src/lib/wall-push-line-up.test.ts +132 -0
  362. package/src/lib/wall-region-list.ts +17 -6
  363. package/src/lib/wall-run-snap.test.ts +188 -0
  364. package/src/lib/wall-run-snap.ts +83 -0
  365. package/src/lib/wall-type.test.ts +125 -0
  366. package/src/lib/wall-type.ts +119 -0
  367. package/src/lib/window-interaction.test.ts +44 -0
  368. package/src/lib/world-grid-snap.test.ts +139 -2
  369. package/src/lib/world-grid-snap.ts +102 -105
  370. package/src/store/use-alignment-guides.ts +27 -1
  371. package/src/store/use-editor.tsx +53 -0
  372. package/src/store/use-floorplan-annotation-visibility.test.ts +64 -0
  373. package/src/store/use-floorplan-annotation-visibility.ts +47 -22
  374. package/src/store/use-placement-typing.test.ts +165 -0
  375. package/src/store/use-placement-typing.ts +186 -0
  376. package/src/store/use-wall-snap-indicator.ts +15 -2
  377. package/src/components/tools/elevator/elevator-defaults.ts +0 -8
  378. package/src/components/tools/elevator/elevator-tool.tsx +0 -372
  379. package/src/components/tools/elevator/move-elevator-tool.tsx +0 -262
  380. package/src/lib/door-interaction.ts +0 -102
  381. package/src/lib/measurement-label.ts +0 -68
  382. package/src/lib/window-interaction.ts +0 -100
@@ -744,58 +744,79 @@ describe('scene clipboard', () => {
744
744
  }
745
745
  })
746
746
 
747
- test("a room copied to another project brings its floor script's artifacts", async () => {
748
- let systemClipboardText = ''
749
- Object.defineProperty(navigator, 'clipboard', {
750
- configurable: true,
751
- value: {
752
- readText: async () => systemClipboardText,
753
- writeText: async (text: string) => {
754
- systemClipboardText = text
747
+ const floorScript = {
748
+ kind: 'script',
749
+ script: 'e'.repeat(64),
750
+ artifact: 'f'.repeat(64),
751
+ inputs: 'key',
752
+ manifest: { bounds: { min: [0, 0, 0], max: [3, 0.01, 3] }, triangles: 2 },
753
+ } as const
754
+ const square: [number, number][] = [
755
+ [0, 0],
756
+ [3, 0],
757
+ [3, 3],
758
+ [0, 3],
759
+ ]
760
+ for (const [what, host] of [
761
+ [
762
+ "a room copied to another project brings its floor script's artifacts",
763
+ () =>
764
+ ZoneNode.parse({
765
+ id: 'zone_clipboard-floor-script',
766
+ parentId: sourceLevelId,
767
+ name: 'Hall',
768
+ polygon: square,
769
+ floor: { script: floorScript },
770
+ }),
771
+ ],
772
+ [
773
+ "a slab copied to another project brings its surface script's artifacts",
774
+ () =>
775
+ SlabNode.parse({
776
+ id: 'slab_clipboard-surface-script',
777
+ parentId: sourceLevelId,
778
+ name: 'Terrace',
779
+ polygon: square,
780
+ surfaceScript: floorScript,
781
+ }),
782
+ ],
783
+ ] as const) {
784
+ test(what, async () => {
785
+ let systemClipboardText = ''
786
+ Object.defineProperty(navigator, 'clipboard', {
787
+ configurable: true,
788
+ value: {
789
+ readText: async () => systemClipboardText,
790
+ writeText: async (text: string) => {
791
+ systemClipboardText = text
792
+ },
755
793
  },
756
- },
757
- })
758
- const room = ZoneNode.parse({
759
- id: 'zone_clipboard-floor-script',
760
- parentId: sourceLevelId,
761
- name: 'Hall',
762
- polygon: [
763
- [0, 0],
764
- [3, 0],
765
- [3, 3],
766
- [0, 3],
767
- ],
768
- floor: {
769
- script: {
770
- kind: 'script',
771
- script: 'e'.repeat(64),
772
- artifact: 'f'.repeat(64),
773
- inputs: 'key',
774
- manifest: { bounds: { min: [0, 0, 0], max: [3, 0.01, 3] }, triangles: 2 },
794
+ })
795
+ const node = host()
796
+ useScene.setState((state) => ({
797
+ nodes: {
798
+ ...state.nodes,
799
+ [sourceLevelId]: makeLevel(sourceLevelId, [node.id]),
800
+ [node.id]: node,
775
801
  },
776
- },
802
+ }))
803
+ useViewer.getState().setProjectId('project_clipboard-source')
804
+ expect(copySelectedNodesToEditorClipboard([node.id])).toBe(true)
805
+ useViewer.getState().setProjectId('project_clipboard-target')
806
+ const copyFrom = mock(async (_projectId: string, _sha256s: string[]) => [] as string[])
807
+ const previousStore = getArtifactStore()
808
+ configureArtifactStore({ ...previousStore, copyFrom })
809
+ try {
810
+ await pasteSystemEditorClipboardToLevel(targetLevelId)
811
+ expect(new Set(copyFrom.mock.calls[0]![1])).toEqual(
812
+ new Set(['e'.repeat(64), 'f'.repeat(64)]),
813
+ )
814
+ } finally {
815
+ configureArtifactStore(previousStore)
816
+ useViewer.getState().setProjectId(null)
817
+ }
777
818
  })
778
- useScene.setState((state) => ({
779
- nodes: {
780
- ...state.nodes,
781
- [sourceLevelId]: makeLevel(sourceLevelId, [room.id]),
782
- [room.id]: room,
783
- },
784
- }))
785
- useViewer.getState().setProjectId('project_clipboard-source')
786
- expect(copySelectedNodesToEditorClipboard([room.id])).toBe(true)
787
- useViewer.getState().setProjectId('project_clipboard-target')
788
- const copyFrom = mock(async (_projectId: string, _sha256s: string[]) => [] as string[])
789
- const previousStore = getArtifactStore()
790
- configureArtifactStore({ ...previousStore, copyFrom })
791
- try {
792
- await pasteSystemEditorClipboardToLevel(targetLevelId)
793
- expect(new Set(copyFrom.mock.calls[0]![1])).toEqual(new Set(['e'.repeat(64), 'f'.repeat(64)]))
794
- } finally {
795
- configureArtifactStore(previousStore)
796
- useViewer.getState().setProjectId(null)
797
- }
798
- })
819
+ }
799
820
 
800
821
  test('navigation during artifact copy does not paste into the next project', async () => {
801
822
  copyScriptedWindow()
@@ -6,6 +6,7 @@ import {
6
6
  generateSceneMaterialId,
7
7
  getArtifactStore,
8
8
  isDerivedNode,
9
+ isNodeDuplicable,
9
10
  isStoreyContent,
10
11
  type LevelNode,
11
12
  nodeRegistry,
@@ -156,7 +157,7 @@ function isClipboardRoot(
156
157
  }
157
158
 
158
159
  function isCopyableRootType(node: AnyNode) {
159
- if (isDerivedNode(node)) return false
160
+ if (isDerivedNode(node) || !isNodeDuplicable(node)) return false
160
161
  if (COPYABLE_ROOT_TYPES.has(node.type)) return true
161
162
  const definition = nodeRegistry.get(node.type)
162
163
  return !!definition && definition.capabilities?.duplicable !== false
@@ -488,14 +489,19 @@ export async function pasteSystemEditorClipboardToLevel(
488
489
  return applyClipboardPayloadToLevel(payload, targetLevel.id, refused)
489
490
  }
490
491
 
491
- /** The script and GLB a scripted node, a room's floor script or a wall's face scripts reference. */
492
+ /**
493
+ * The script and GLB a scripted node, a room's floor script, a slab's surface script or a wall's
494
+ * face scripts reference.
495
+ */
492
496
  function artifactHashes(node: AnyNode): string[] {
493
497
  const sources =
494
498
  node.type === 'zone'
495
499
  ? [node.floor?.script]
496
- : node.type === 'wall'
497
- ? [node.finishScripts?.a, node.finishScripts?.b]
498
- : ['source' in node ? node.source : undefined]
500
+ : node.type === 'slab'
501
+ ? [node.surfaceScript]
502
+ : node.type === 'wall'
503
+ ? [node.finishScripts?.a, node.finishScripts?.b]
504
+ : ['source' in node ? node.source : undefined]
499
505
  return sources.flatMap((source) =>
500
506
  typeof source === 'object' && source?.kind === 'script' ? [source.script, source.artifact] : [],
501
507
  )
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Tab steps the selection through everything stacked under the pointer in the
3
+ * floor plan, top first: where walls
4
+ * overlap, or a short wall lies inside a longer one or under a marker, a click
5
+ * takes the top one and Tab the ones below it in turn. Shift+Tab steps back.
6
+ */
7
+
8
+ import type { AnyNodeId } from '@pascal-app/core'
9
+ import { useViewer } from '@pascal-app/viewer'
10
+
11
+ let pointer: { x: number; y: number } | null = null
12
+ let tracking = false
13
+
14
+ /** Start following the pointer, so a Tab knows where it is. Idempotent. */
15
+ export function trackSelectionCyclePointer(): void {
16
+ if (tracking || typeof window === 'undefined') return
17
+ tracking = true
18
+ const follow = (event: PointerEvent) => {
19
+ pointer = { x: event.clientX, y: event.clientY }
20
+ }
21
+ window.addEventListener('pointermove', follow, { capture: true, passive: true })
22
+ window.addEventListener('pointerdown', follow, { capture: true, passive: true })
23
+ }
24
+
25
+ /** The plan nodes under a screen point, topmost first, each once. */
26
+ export function floorplanNodesAt(x: number, y: number): string[] {
27
+ if (typeof document === 'undefined') return []
28
+ const ids: string[] = []
29
+ for (const element of document.elementsFromPoint(x, y)) {
30
+ if (!element.closest('[data-floorplan-scene]')) continue
31
+ const entry = element.closest<SVGGElement>('.floorplan-registry-entry[data-node-id]')
32
+ const id = entry?.dataset.nodeId
33
+ if (id && !ids.includes(id)) ids.push(id)
34
+ }
35
+ return ids
36
+ }
37
+
38
+ /**
39
+ * The id a Tab selects among `stack` (topmost first): the one after the
40
+ * current selection, or before it for Shift+Tab, wrapping round; the top one
41
+ * when the selection is not in the stack.
42
+ */
43
+ export function nextInStack(
44
+ stack: readonly string[],
45
+ selected: readonly string[],
46
+ back: boolean,
47
+ ): string | null {
48
+ if (stack.length === 0) return null
49
+ const index = selected.length === 1 ? stack.indexOf(selected[0]!) : -1
50
+ if (index < 0) return stack[0]!
51
+ return stack[(index + (back ? stack.length - 1 : 1)) % stack.length]!
52
+ }
53
+
54
+ /** Select the next object under the pointer in the floor plan. True when Tab was used. */
55
+ export function cycleSelectionAtPointer(back: boolean): boolean {
56
+ if (!pointer) return false
57
+ const stack = floorplanNodesAt(pointer.x, pointer.y)
58
+ const viewer = useViewer.getState()
59
+ const next = nextInStack(stack, viewer.selection.selectedIds, back)
60
+ if (!next) return false
61
+ viewer.setSelection({ selectedIds: [next as AnyNodeId] })
62
+ return true
63
+ }
@@ -0,0 +1,431 @@
1
+ import { afterAll, afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeId,
5
+ area,
6
+ BuildingNode,
7
+ clearSceneHistory,
8
+ createZone,
9
+ generateId,
10
+ initSpaceDetectionSync,
11
+ LevelNode,
12
+ nodeRegistry,
13
+ planSeparatorInsertion,
14
+ railingWallDefaults,
15
+ type SeparatorNode,
16
+ structureChangeBatch,
17
+ useLiveNodeOverrides,
18
+ useScene,
19
+ WallNode,
20
+ type ZoneNode,
21
+ } from '@pascal-app/core'
22
+ import { useViewer } from '@pascal-app/viewer'
23
+ import { getRoomSelectionIndex } from '../hooks/use-selected-room'
24
+ import useEditor from '../store/use-editor'
25
+ import useInteractionScope from '../store/use-interaction-scope'
26
+ import { installImmediateAnimationFrames } from '../test-utils/immediate-animation-frames'
27
+ import { cancelGestures } from './gesture-lifecycle'
28
+ import { endpointReshapeScope } from './interaction/scope'
29
+ import {
30
+ roomPushHandles,
31
+ runFloorplanRoomPush,
32
+ runFloorplanWallPush,
33
+ separatorPushHandles,
34
+ useRoomHandleDrag,
35
+ type WallPushHandle,
36
+ } from './room-handle-drag'
37
+ import { applyRoomPlan } from './room-structure-commands'
38
+ import {
39
+ beginSeparatorEndDrag,
40
+ runSeparatorEndDrag,
41
+ SEPARATOR_END_DRAG_LABEL,
42
+ } from './separator-edit'
43
+
44
+ // A porch off an 8 × 4 house, closed by invisible walls, pushed and dragged
45
+ // the way the plan and the 3D view do it. The pointer is faked: plan points
46
+ // come straight from the client point (x → plan x, y → plan z).
47
+
48
+ type Point = [number, number]
49
+ const LEVEL = 'level_separator_edit'
50
+ const stubbed: string[] = []
51
+ let stop = () => {}
52
+ let restoreFrames = () => {}
53
+ let registry: { mockRestore: () => void } | null = null
54
+ const toPlan = (clientX: number, clientY: number): [number, number] => [clientX, clientY]
55
+
56
+ function pointer(type: 'pointermove' | 'pointerup', x: number, y: number, altKey = false) {
57
+ window.dispatchEvent(Object.assign(new Event(type), { clientX: x, clientY: y, altKey }))
58
+ }
59
+
60
+ const near = (a: Point, b: Point) => Math.hypot(a[0] - b[0], a[1] - b[1]) < 1e-6
61
+ const separators = () =>
62
+ Object.values(useScene.getState().nodes).filter(
63
+ (node): node is SeparatorNode => node.type === 'separator',
64
+ )
65
+ /** The invisible wall from `a` to `b`, either way round. */
66
+ const between = (a: Point, b: Point) =>
67
+ separators().find(
68
+ (node) =>
69
+ (near(node.start, a) && near(node.end, b)) || (near(node.start, b) && near(node.end, a)),
70
+ )
71
+ const porchZone = () =>
72
+ Object.values(useScene.getState().nodes).find(
73
+ (node): node is ZoneNode =>
74
+ node.type === 'zone' && (node.roomType === 'porch' || node.roomType === 'deck'),
75
+ )
76
+ const porchArea = () => {
77
+ const zone = porchZone()
78
+ return zone ? area([{ outer: zone.polygon, holes: zone.holes }]) : 0
79
+ }
80
+
81
+ /** Draws the porch's sides — invisible walls, or railings where `railed` — through the store. */
82
+ function drawPorch(
83
+ sides: Array<[Point, Point]> = [
84
+ [
85
+ [2, 4],
86
+ [2, 6],
87
+ ],
88
+ [
89
+ [2, 6],
90
+ [6, 6],
91
+ ],
92
+ [
93
+ [6, 6],
94
+ [6, 4],
95
+ ],
96
+ ],
97
+ railed = false,
98
+ ) {
99
+ for (const [start, end] of sides) {
100
+ const nodes = useScene.getState().nodes
101
+ if (railed) {
102
+ const railing = WallNode.parse({
103
+ ...railingWallDefaults(),
104
+ id: generateId('wall'),
105
+ parentId: LEVEL,
106
+ start,
107
+ end,
108
+ })
109
+ useScene.getState().applyNodeChanges(structureChangeBatch([{ op: 'create', node: railing }]))
110
+ continue
111
+ }
112
+ const plan = planSeparatorInsertion(nodes as Record<AnyNodeId, AnyNode>, {
113
+ levelId: LEVEL as AnyNodeId,
114
+ start,
115
+ end,
116
+ joinRadius: 0.05,
117
+ mintId: () => generateId('separator'),
118
+ })
119
+ useScene.getState().applyNodeChanges(plan!.changes)
120
+ }
121
+ clearSceneHistory()
122
+ }
123
+
124
+ function porchHandles(): WallPushHandle[] {
125
+ const zone = porchZone()!
126
+ const record = getRoomSelectionIndex(LEVEL)
127
+ .update(useScene.getState().nodes)
128
+ .find((room) => room.zoneId === zone.id)!
129
+ return roomPushHandles(useScene.getState().nodes, record.spans)
130
+ }
131
+
132
+ /** The porch arrow pointing along `direction`. */
133
+ const arrow = (handles: WallPushHandle[], direction: Point) =>
134
+ handles.find(
135
+ (handle) =>
136
+ handle.outward[0] * direction[0] + handle.outward[1] * direction[1] > 0.99 &&
137
+ !!handle.separators,
138
+ )
139
+
140
+ beforeEach(() => {
141
+ restoreFrames = installImmediateAnimationFrames()
142
+ if (!globalThis.document) {
143
+ globalThis.document = { body: { style: { cursor: '' } } } as unknown as Document
144
+ stubbed.push('document')
145
+ }
146
+ if (!globalThis.window) {
147
+ globalThis.window = new EventTarget() as Window & typeof globalThis
148
+ stubbed.push('window')
149
+ }
150
+ // The nodes package that registers walls and invisible walls is not loaded
151
+ // here: answer their snap context lookup as their definitions do.
152
+ const get = nodeRegistry.get.bind(nodeRegistry)
153
+ registry = spyOn(nodeRegistry, 'get').mockImplementation(((kind: string) =>
154
+ kind === 'wall' || kind === 'separator'
155
+ ? { snapProfile: 'structural' }
156
+ : get(kind)) as typeof nodeRegistry.get)
157
+ cancelGestures('cancel')
158
+ useInteractionScope.getState().end()
159
+ const building = BuildingNode.parse({ id: 'building_separator_edit', children: [LEVEL] })
160
+ const level = LevelNode.parse({ id: LEVEL, parentId: building.id })
161
+ useScene.setState({
162
+ nodes: { [building.id]: building, [level.id]: level },
163
+ rootNodeIds: [building.id],
164
+ dirtyNodes: new Set(),
165
+ materials: {},
166
+ collections: {},
167
+ readOnly: false,
168
+ })
169
+ useScene.temporal.getState().resume()
170
+ stop = initSpaceDetectionSync(useScene, { getState: () => ({ spaces: {}, setSpaces: () => {} }) })
171
+ applyRoomPlan(
172
+ createZone(useScene.getState().nodes, {
173
+ levelId: LEVEL,
174
+ polygon: [
175
+ [0, 0],
176
+ [8, 0],
177
+ [8, 4],
178
+ [0, 4],
179
+ ],
180
+ enclose: true,
181
+ mintId: generateId,
182
+ }),
183
+ )
184
+ useViewer.getState().setSelection({ buildingId: building.id, levelId: LEVEL, selectedIds: [] })
185
+ useEditor.setState({ phase: 'building', mode: 'select', gridSnapStep: 0.5 })
186
+ useEditor.getState().setSnappingMode('polygon', 'grid')
187
+ useEditor.getState().setSnappingMode('wall', 'grid')
188
+ clearSceneHistory()
189
+ })
190
+
191
+ afterEach(() => {
192
+ cancelGestures('cancel')
193
+ stop()
194
+ registry?.mockRestore()
195
+ registry = null
196
+ restoreFrames()
197
+ })
198
+
199
+ afterAll(async () => {
200
+ // Let the click-swallow timers fire before the stubs go.
201
+ await new Promise((resolve) => setTimeout(resolve, 350))
202
+ for (const key of stubbed.splice(0)) delete (globalThis as Record<string, unknown>)[key]
203
+ })
204
+
205
+ describe('a porch closed by invisible walls, selected', () => {
206
+ test('has a push arrow on each invisible-wall edge, pointing off the porch, beside the house wall’s', () => {
207
+ drawPorch()
208
+ expect(porchZone()?.roomType).toBe('porch')
209
+ const handles = porchHandles()
210
+ for (const direction of [
211
+ [-1, 0],
212
+ [0, 1],
213
+ [1, 0],
214
+ ] as Point[])
215
+ expect(arrow(handles, direction)?.separators).toHaveLength(1)
216
+ // The house wall's arrow is the wall push it always was.
217
+ expect(handles.filter((handle) => !handle.separators)).toHaveLength(1)
218
+ const bottom = arrow(handles, [0, 1])!
219
+ expect(bottom.position[0]).toBeCloseTo(4, 6)
220
+ expect(bottom.position[1]).toBeGreaterThan(6)
221
+ })
222
+
223
+ test('an edge drawn in two pieces has one arrow, and it pushes both', () => {
224
+ drawPorch([
225
+ [
226
+ [2, 4],
227
+ [2, 6],
228
+ ],
229
+ [
230
+ [2, 6],
231
+ [4, 6],
232
+ ],
233
+ [
234
+ [4, 6],
235
+ [6, 6],
236
+ ],
237
+ [
238
+ [6, 6],
239
+ [6, 4],
240
+ ],
241
+ ])
242
+ const bottom = arrow(porchHandles(), [0, 1])!
243
+ expect(bottom.separators).toHaveLength(2)
244
+ expect(bottom.position[0]).toBeCloseTo(4, 6)
245
+ runFloorplanRoomPush({ handle: bottom, levelId: LEVEL, clientX: 4, clientY: 6.3, toPlan })
246
+ pointer('pointermove', 4, 7.3)
247
+ pointer('pointerup', 4, 7.3)
248
+ expect(between([2, 7], [4, 7])).toBeDefined()
249
+ expect(between([4, 7], [6, 7])).toBeDefined()
250
+ expect(porchArea()).toBeCloseTo(12, 6)
251
+ })
252
+
253
+ test('its edge pushed out moves the invisible wall, the sides follow, the porch grows; one undo step', () => {
254
+ drawPorch()
255
+ const before = useScene.getState().nodes
256
+ const bottom = arrow(porchHandles(), [0, 1])!
257
+ const zoneId = porchZone()!.id
258
+ expect(
259
+ runFloorplanRoomPush({
260
+ handle: bottom,
261
+ levelId: LEVEL,
262
+ zoneId,
263
+ clientX: 4,
264
+ clientY: 6.3,
265
+ toPlan,
266
+ }),
267
+ ).not.toBeNull()
268
+ const depth = () => {
269
+ const drag = useRoomHandleDrag.getState().drag
270
+ return drag?.kind === 'push' ? drag.dimensions.map((dimension) => dimension.distance) : []
271
+ }
272
+ // The porch's depth, edge to the house wall's outside face.
273
+ const [rest] = depth()
274
+ expect(rest).toBeGreaterThan(1.8)
275
+ expect(rest).toBeLessThan(2)
276
+ // 0.8 m of travel lands the edge on the 0.5 m lattice, a metre out.
277
+ pointer('pointermove', 4, 7.1)
278
+ expect(useRoomHandleDrag.getState().drag).toMatchObject({ kind: 'push', distance: 1 })
279
+ expect(depth()[0]).toBeCloseTo(rest! + 1, 6)
280
+ const live = useLiveNodeOverrides.getState().overrides
281
+ const preview = live.get(bottom.wallId) as Partial<SeparatorNode> | undefined
282
+ expect(preview?.start?.[1]).toBeCloseTo(7, 6)
283
+ // The porch floor follows live; nothing is written until release.
284
+ expect(live.get(zoneId as AnyNodeId)).toHaveProperty('polygon')
285
+ expect(useScene.getState().nodes).toBe(before)
286
+
287
+ pointer('pointerup', 4, 7.1)
288
+ expect(useLiveNodeOverrides.getState().overrides.has(bottom.wallId)).toBe(false)
289
+ expect(between([2, 7], [6, 7])?.id).toBe(bottom.wallId as SeparatorNode['id'])
290
+ expect(between([2, 4], [2, 7])).toBeDefined()
291
+ expect(between([6, 7], [6, 4])).toBeDefined()
292
+ expect(porchArea()).toBeCloseTo(12, 6)
293
+ expect(useScene.temporal.getState().pastStates).toHaveLength(1)
294
+
295
+ useScene.temporal.getState().undo()
296
+ expect(between([2, 6], [6, 6])).toBeDefined()
297
+ expect(between([2, 4], [2, 6])).toBeDefined()
298
+ expect(porchArea()).toBeCloseTo(8, 6)
299
+ })
300
+
301
+ test('pulled in, it shrinks the porch and stays closed', () => {
302
+ drawPorch()
303
+ const bottom = arrow(porchHandles(), [0, 1])!
304
+ runFloorplanRoomPush({ handle: bottom, levelId: LEVEL, clientX: 4, clientY: 6.3, toPlan })
305
+ pointer('pointermove', 4, 5.3)
306
+ pointer('pointerup', 4, 5.3)
307
+ expect(between([2, 5], [6, 5])).toBeDefined()
308
+ expect(porchArea()).toBeCloseTo(4, 6)
309
+ })
310
+
311
+ test('its house-wall arrow still pushes the house wall, the porch following it', () => {
312
+ drawPorch()
313
+ const house = porchHandles().find((handle) => !handle.separators)!
314
+ expect(house.outward[1]).toBeCloseTo(-1, 6)
315
+ runFloorplanRoomPush({
316
+ handle: house,
317
+ levelId: LEVEL,
318
+ clientX: house.position[0],
319
+ clientY: house.position[1],
320
+ toPlan,
321
+ })
322
+ pointer('pointermove', house.position[0], house.position[1] - 1)
323
+ pointer('pointerup', house.position[0], house.position[1] - 1)
324
+ const moved = Object.values(useScene.getState().nodes).find(
325
+ (node): node is WallNode =>
326
+ node.type === 'wall' &&
327
+ Math.abs(node.start[1] - 3) < 1e-6 &&
328
+ Math.abs(node.end[1] - 3) < 1e-6,
329
+ )
330
+ expect(moved).toBeDefined()
331
+ expect(porchArea()).toBeCloseTo(12, 6)
332
+ })
333
+ })
334
+
335
+ test('a deck fenced by railings has the wall arrows on its railings', () => {
336
+ drawPorch(undefined, true)
337
+ const deck = porchZone()
338
+ expect(deck?.roomType).toBe('deck')
339
+ const record = getRoomSelectionIndex(LEVEL)
340
+ .update(useScene.getState().nodes)
341
+ .find((room) => room.zoneId === deck!.id)!
342
+ const handles = roomPushHandles(useScene.getState().nodes, record.spans)
343
+ const railings = handles.filter(
344
+ (handle) =>
345
+ (useScene.getState().nodes[handle.wallId as AnyNodeId] as WallNode).wallType === 'railing',
346
+ )
347
+ expect(railings).toHaveLength(3)
348
+ expect(railings.every((handle) => !handle.separators)).toBe(true)
349
+ })
350
+
351
+ describe('a selected invisible wall', () => {
352
+ test('its side arrow pushes it, the invisible wall joined to it stretching and its end sliding on the house', () => {
353
+ drawPorch()
354
+ const left = between([2, 4], [2, 6])!
355
+ useViewer.getState().setSelection({ selectedIds: [left.id] })
356
+ const side = separatorPushHandles(left).find((handle) => handle.outward[0] < -0.99)!
357
+ expect(
358
+ runFloorplanWallPush(
359
+ { wallId: left.id, side: side.key.endsWith(':a') ? 'a' : 'b' },
360
+ 1.9,
361
+ 5,
362
+ toPlan,
363
+ ),
364
+ ).not.toBeNull()
365
+ pointer('pointermove', 0.9, 5)
366
+ pointer('pointerup', 0.9, 5)
367
+ expect(between([1, 4], [1, 6])?.id).toBe(left.id)
368
+ expect(between([1, 6], [6, 6])).toBeDefined()
369
+ expect(porchArea()).toBeCloseTo(10, 6)
370
+ expect(useScene.temporal.getState().pastStates).toHaveLength(1)
371
+ })
372
+
373
+ /** Drags the end of `separator` at `corner` (its 3D grip) along `path`, Alt held when `alt`. */
374
+ function dragEnd(separator: SeparatorNode, corner: Point, path: Point[], alt = false) {
375
+ const endpoint = near(separator.start, corner) ? 'start' : 'end'
376
+ runSeparatorEndDrag({ separatorId: separator.id, endpoint, levelId: LEVEL, toPlan })
377
+ for (const [x, z] of path) pointer('pointermove', x, z, alt)
378
+ const [x, z] = path.at(-1)!
379
+ pointer('pointerup', x, z, alt)
380
+ }
381
+
382
+ test('an end dragged takes the invisible wall joined there along, square and on the grid', () => {
383
+ drawPorch()
384
+ const bottom = between([2, 6], [6, 6])!
385
+ const endpoint = near(bottom.start, [2, 6]) ? 'start' : 'end'
386
+ runSeparatorEndDrag({ separatorId: bottom.id, endpoint, levelId: LEVEL, toPlan })
387
+ expect(useInteractionScope.getState().scope).toMatchObject({
388
+ kind: 'handle-drag',
389
+ nodeId: bottom.id,
390
+ handle: SEPARATOR_END_DRAG_LABEL,
391
+ })
392
+ pointer('pointermove', 1.07, 5.96)
393
+ const live = useLiveNodeOverrides.getState().overrides.get(bottom.id) as Partial<SeparatorNode>
394
+ expect(live[endpoint]).toEqual([1, 6])
395
+ expect(between([2, 6], [6, 6])).toBeDefined()
396
+ pointer('pointerup', 1.07, 5.96)
397
+ expect(between([1, 6], [6, 6])?.id).toBe(bottom.id)
398
+ expect(between([2, 4], [1, 6])).toBeDefined()
399
+ expect(useLiveNodeOverrides.getState().overrides.has(bottom.id)).toBe(false)
400
+ expect(useScene.temporal.getState().pastStates).toHaveLength(1)
401
+ expect(useInteractionScope.getState().scope.kind).toBe('idle')
402
+ })
403
+
404
+ test('let go with Alt, its end comes back together on its old corner', () => {
405
+ drawPorch()
406
+ const bottom = between([2, 6], [6, 6])!
407
+ dragEnd(bottom, [2, 6], [[1, 7]], true)
408
+ expect(between([2, 4], [2, 6])).toBeDefined()
409
+ expect(between([2, 6], [6, 6])).toBeUndefined()
410
+
411
+ const moved = useScene.getState().nodes[bottom.id as AnyNodeId] as SeparatorNode
412
+ dragEnd(moved, near(moved.start, [6, 6]) ? moved.end : moved.start, [[2.12, 6.09]], true)
413
+ expect(between([2, 6], [6, 6])?.id).toBe(bottom.id)
414
+ expect(porchArea()).toBeCloseTo(8, 6)
415
+ })
416
+
417
+ test('the plan’s end handle runs the same drag', () => {
418
+ drawPorch()
419
+ const bottom = between([2, 6], [6, 6])!
420
+ const endpoint = near(bottom.start, [2, 6]) ? 'start' : 'end'
421
+ // The plan begins its end-drag scope (the wall snapping set) around the session.
422
+ useInteractionScope.getState().begin(endpointReshapeScope(bottom.id, endpoint, 'floorplan'))
423
+ const session = beginSeparatorEndDrag(bottom.id, endpoint)!
424
+ session.move([1.07, 6.04], false)
425
+ expect(session.canCommit()).toBe(true)
426
+ useInteractionScope.getState().end()
427
+ expect(session.commit()).toBe(true)
428
+ expect(between([1, 6], [6, 6])?.id).toBe(bottom.id)
429
+ expect(between([2, 4], [1, 6])).toBeDefined()
430
+ })
431
+ })