@pascal-app/editor 1.0.5 → 1.0.7

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 (317) hide show
  1. package/package.json +20 -17
  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 +20 -8
  6. package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
  7. package/src/components/editor/first-person-controls.tsx +72 -61
  8. package/src/components/editor/floating-action-menu.tsx +27 -9
  9. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  10. package/src/components/editor/floorplan-panel.test.ts +220 -0
  11. package/src/components/editor/floorplan-panel.tsx +285 -132
  12. package/src/components/editor/footprint-height-handle.tsx +1 -1
  13. package/src/components/editor/grid.tsx +24 -15
  14. package/src/components/editor/group-actions.ts +19 -1
  15. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  16. package/src/components/editor/group-move-3d.ts +27 -2
  17. package/src/components/editor/group-selection-box-3d.tsx +4 -1
  18. package/src/components/editor/group-transform-shared.ts +20 -0
  19. package/src/components/editor/index.tsx +130 -20
  20. package/src/components/editor/inspector-extension-actions.tsx +36 -0
  21. package/src/components/editor/localized-html.tsx +22 -0
  22. package/src/components/editor/node-action-menu.tsx +46 -12
  23. package/src/components/editor/node-arrow-handles.tsx +1 -2
  24. package/src/components/editor/open-wall-ends-3d-layer.tsx +1 -1
  25. package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
  26. package/src/components/editor/registry-action-contributions.test.tsx +67 -6
  27. package/src/components/editor/registry-action-contributions.tsx +115 -15
  28. package/src/components/editor/riser-diagram-panel.tsx +59 -9
  29. package/src/components/editor/room-controls.tsx +1 -1
  30. package/src/components/editor/room-handles-2d.tsx +156 -6
  31. package/src/components/editor/room-handles.tsx +241 -51
  32. package/src/components/editor/room-highlight.tsx +2 -0
  33. package/src/components/editor/room-labels.tsx +29 -15
  34. package/src/components/editor/selection-manager.tsx +15 -102
  35. package/src/components/editor/separator-end-grip.tsx +74 -0
  36. package/src/components/editor/site-edge-labels.tsx +1 -1
  37. package/src/components/editor/thumbnail-generator.test.ts +15 -6
  38. package/src/components/editor/thumbnail-generator.tsx +26 -1
  39. package/src/components/editor/use-floorplan-background-placement.ts +55 -52
  40. package/src/components/editor/views/view-bar.tsx +111 -26
  41. package/src/components/editor/wall-measurement-label.tsx +1 -1
  42. package/src/components/editor/wall-move-side-handles.tsx +5 -1
  43. package/src/components/editor/wall-pull-grip.tsx +193 -0
  44. package/src/components/editor/wall-snap-beacon-layer.tsx +81 -1
  45. package/src/components/editor-2d/floorplan-adjacent-walls-layer.test.ts +76 -0
  46. package/src/components/editor-2d/floorplan-adjacent-walls-layer.tsx +186 -0
  47. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +63 -3
  48. package/src/components/editor-2d/floorplan-group-move.tsx +31 -3
  49. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +6 -0
  50. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +22 -13
  51. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +11 -3
  52. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +7 -1
  53. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +96 -4
  54. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +52 -12
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +12 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +197 -37
  57. package/src/components/procedural-items/inspector.tsx +59 -12
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +2 -0
  59. package/src/components/systems/ceiling/ceiling-system.test.ts +141 -0
  60. package/src/components/systems/ceiling/ceiling-system.tsx +53 -18
  61. package/src/components/tools/distribution/distribution-run-tool.tsx +50 -26
  62. package/src/components/tools/item/placement-history.test.tsx +1 -1
  63. package/src/components/tools/item/placement-strategies.test.ts +64 -1
  64. package/src/components/tools/item/placement-strategies.ts +5 -9
  65. package/src/components/tools/item/use-placement-coordinator.tsx +30 -34
  66. package/src/components/tools/registry/item-surface-move.test.ts +11 -0
  67. package/src/components/tools/select/select-candidates.test.ts +45 -0
  68. package/src/components/tools/select/select-candidates.ts +13 -2
  69. package/src/components/tools/shared/cursor-sphere.tsx +10 -3
  70. package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
  71. package/src/components/tools/shared/placement-box.tsx +1 -1
  72. package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
  73. package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
  74. package/src/components/tools/wall/wall-drafting.test.ts +299 -23
  75. package/src/components/tools/wall/wall-drafting.ts +132 -24
  76. package/src/components/tools/wall/wall-pull-snap.test.ts +274 -0
  77. package/src/components/tools/wall/wall-pull-snap.ts +563 -0
  78. package/src/components/tools/wall/wall-snap-geometry.test.ts +113 -1
  79. package/src/components/tools/wall/wall-snap-geometry.ts +137 -0
  80. package/src/components/tools/zone/zone-tool.tsx +3 -3
  81. package/src/components/ui/action-menu/action-button.tsx +2 -0
  82. package/src/components/ui/action-menu/camera-actions.tsx +8 -7
  83. package/src/components/ui/action-menu/control-modes.tsx +9 -5
  84. package/src/components/ui/action-menu/follow-pascal-pill.tsx +3 -1
  85. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +7 -1
  86. package/src/components/ui/action-menu/furnish-tools.tsx +7 -6
  87. package/src/components/ui/action-menu/index.tsx +1 -0
  88. package/src/components/ui/action-menu/measurement-control.tsx +35 -29
  89. package/src/components/ui/action-menu/point-ask-button.tsx +4 -3
  90. package/src/components/ui/action-menu/structure-tools.tsx +25 -23
  91. package/src/components/ui/action-menu/view-toggles.test.ts +18 -0
  92. package/src/components/ui/action-menu/view-toggles.tsx +256 -51
  93. package/src/components/ui/build-panel/build-panel.tsx +163 -25
  94. package/src/components/ui/command-palette/editor-commands.tsx +38 -11
  95. package/src/components/ui/controls/finish-swatch.tsx +45 -19
  96. package/src/components/ui/controls/material-picker.tsx +24 -17
  97. package/src/components/ui/controls/metric-control.test.ts +52 -0
  98. package/src/components/ui/controls/metric-control.tsx +62 -24
  99. package/src/components/ui/controls/segmented-control.tsx +5 -2
  100. package/src/components/ui/controls/slider-control.test.tsx +23 -0
  101. package/src/components/ui/controls/slider-control.tsx +30 -14
  102. package/src/components/ui/floating-level-selector.tsx +100 -48
  103. package/src/components/ui/helpers/building-helper.tsx +5 -3
  104. package/src/components/ui/helpers/contextual-helper-panel.tsx +97 -37
  105. package/src/components/ui/helpers/helper-manager.test.tsx +53 -3
  106. package/src/components/ui/helpers/helper-manager.tsx +82 -57
  107. package/src/components/ui/helpers/item-helper.tsx +6 -4
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +3 -1
  109. package/src/components/ui/level-duplicate-dialog.tsx +32 -23
  110. package/src/components/ui/level-wall-copy-prompt.tsx +124 -0
  111. package/src/components/ui/panels/collections/collections-popover.tsx +11 -11
  112. package/src/components/ui/panels/deck-boards-fields.tsx +136 -0
  113. package/src/components/ui/panels/floor-foundation-panel.tsx +113 -38
  114. package/src/components/ui/panels/floor-opening-panel.tsx +73 -25
  115. package/src/components/ui/panels/inspector-extensions.tsx +66 -0
  116. package/src/components/ui/panels/mobile-panel-sheet.tsx +3 -1
  117. package/src/components/ui/panels/mobile-selection-bar.tsx +8 -6
  118. package/src/components/ui/panels/multi-field-value.test.ts +27 -0
  119. package/src/components/ui/panels/multi-field-value.ts +20 -1
  120. package/src/components/ui/panels/multi-height-mode.tsx +8 -7
  121. package/src/components/ui/panels/multi-parametric-inspector.tsx +122 -34
  122. package/src/components/ui/panels/multi-selection-panel.tsx +75 -35
  123. package/src/components/ui/panels/node-display.ts +35 -24
  124. package/src/components/ui/panels/panel-manager.tsx +5 -3
  125. package/src/components/ui/panels/panel-wrapper.tsx +22 -109
  126. package/src/components/ui/panels/parametric-field-control.tsx +13 -10
  127. package/src/components/ui/panels/parametric-inspector.tsx +18 -12
  128. package/src/components/ui/panels/reference-panel.tsx +51 -35
  129. package/src/components/ui/panels/room-construction-rows.tsx +464 -65
  130. package/src/components/ui/panels/room-name-options.test.ts +36 -3
  131. package/src/components/ui/panels/room-name-options.ts +110 -11
  132. package/src/components/ui/panels/room-openings-field.tsx +21 -12
  133. package/src/components/ui/panels/room-panel.tsx +206 -99
  134. package/src/components/ui/panels/shape-choice.tsx +7 -5
  135. package/src/components/ui/panels/wall-region-list.tsx +7 -7
  136. package/src/components/ui/primitives/shortcut-token.tsx +10 -6
  137. package/src/components/ui/sidebar/icon-rail.tsx +11 -9
  138. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +10 -10
  139. package/src/components/ui/sidebar/panels/items-panel/index.tsx +10 -9
  140. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +3 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +102 -32
  142. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -2
  144. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +4 -2
  145. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +7 -5
  146. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +4 -2
  147. package/src/components/ui/sidebar/panels/site-panel/default-names.ts +75 -0
  148. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +4 -2
  149. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +4 -2
  150. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +4 -2
  151. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +4 -2
  152. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +4 -2
  153. package/src/components/ui/sidebar/panels/site-panel/index.tsx +117 -64
  154. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +36 -3
  155. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +4 -2
  156. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -4
  157. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +8 -2
  158. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +8 -5
  159. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +4 -2
  160. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +8 -3
  161. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +4 -2
  162. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +4 -2
  163. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +8 -7
  164. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +7 -6
  165. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +16 -1
  166. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +15 -8
  167. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +4 -2
  168. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +4 -2
  169. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +3 -1
  170. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +18 -8
  171. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +25 -14
  172. package/src/components/ui/sidebar/tab-bar.tsx +4 -1
  173. package/src/components/ui/sidebar/use-plugin-panels.tsx +19 -10
  174. package/src/components/ui/snap-target-badge.tsx +7 -6
  175. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +721 -0
  176. package/src/components/ui/wall-type/wall-type-menu.tsx +167 -0
  177. package/src/components/viewer/floorplan-compass-button.tsx +12 -3
  178. package/src/components/viewer/floorplan-preview.tsx +27 -19
  179. package/src/components/viewer/render-settings-menu.tsx +294 -0
  180. package/src/components/viewer/sky-control.tsx +710 -0
  181. package/src/components/viewer/use-viewer-ui-sounds.test.ts +15 -0
  182. package/src/components/viewer/use-viewer-ui-sounds.ts +46 -0
  183. package/src/components/viewer/viewer-controls-bar.tsx +278 -260
  184. package/src/components/viewer/viewer-hover-label.tsx +12 -4
  185. package/src/components/viewer/viewer-rooms.tsx +61 -4
  186. package/src/components/viewer/viewer-scene-header.tsx +28 -17
  187. package/src/components/viewer/viewer-stage-switcher.tsx +4 -2
  188. package/src/components/viewer-overlay.tsx +8 -0
  189. package/src/components/walkthrough-hud.tsx +75 -2
  190. package/src/hooks/use-keyboard.test.ts +85 -4
  191. package/src/hooks/use-keyboard.ts +65 -7
  192. package/src/hooks/use-selected-room.ts +12 -1
  193. package/src/i18n/floor-footprint-name.ts +30 -0
  194. package/src/i18n/level-name.ts +39 -0
  195. package/src/i18n/locales/en/messages.po +15117 -0
  196. package/src/i18n/locales/en/messages.ts +1 -0
  197. package/src/i18n/locales/en-XA/messages.ts +1 -0
  198. package/src/i18n/locales/es/messages.po +37501 -0
  199. package/src/i18n/locales/es/messages.ts +1 -0
  200. package/src/i18n/locales/fr/messages.po +37501 -0
  201. package/src/i18n/locales/fr/messages.ts +1 -0
  202. package/src/i18n/locales/ja/messages.po +37501 -0
  203. package/src/i18n/locales/ja/messages.ts +1 -0
  204. package/src/i18n/locales/zh-CN/messages.po +37501 -0
  205. package/src/i18n/locales/zh-CN/messages.ts +1 -0
  206. package/src/i18n/locales.test.ts +63 -0
  207. package/src/i18n/locales.ts +32 -0
  208. package/src/i18n/message-slot.tsx +25 -0
  209. package/src/i18n/node-messages.ts +169 -0
  210. package/src/i18n/pseudo-locale.ts +17 -0
  211. package/src/i18n/published-entry.test.ts +17 -0
  212. package/src/i18n/scene-theme-name.ts +16 -0
  213. package/src/index.tsx +156 -4
  214. package/src/lib/add-storey.test.ts +3 -3
  215. package/src/lib/contextual-help.test.ts +20 -1
  216. package/src/lib/contextual-help.ts +95 -19
  217. package/src/lib/continuation.ts +38 -11
  218. package/src/lib/floor-footprints.test.ts +2 -1
  219. package/src/lib/floor-foundation-support.test.ts +7 -3
  220. package/src/lib/floorplan/dimension-drive.test.ts +36 -2
  221. package/src/lib/floorplan/dimension-drive.ts +68 -3
  222. package/src/lib/floorplan/floorplan-export.test.ts +16 -5
  223. package/src/lib/floorplan/floorplan-export.tsx +13 -16
  224. package/src/lib/floorplan/floorplan-extension.test.ts +11 -12
  225. package/src/lib/floorplan/floorplan-extension.ts +37 -18
  226. package/src/lib/floorplan/floorplan-mode.test.ts +13 -6
  227. package/src/lib/floorplan/floorplan-mode.ts +14 -12
  228. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +1 -1
  229. package/src/lib/floorplan/floorplan-readonly.ts +3 -3
  230. package/src/lib/floorplan/schedules.ts +10 -6
  231. package/src/lib/floorplan/site-plan/site-annotations.test.ts +46 -1
  232. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -0
  233. package/src/lib/floorplan/site-plan/site-parts.ts +6 -3
  234. package/src/lib/geometry-script/author.test.ts +114 -0
  235. package/src/lib/geometry-script/author.ts +55 -23
  236. package/src/lib/geometry-script/client.ts +3 -0
  237. package/src/lib/geometry-script/finish-scripts.ts +7 -7
  238. package/src/lib/glb-export.test.ts +327 -0
  239. package/src/lib/glb-export.ts +70 -2
  240. package/src/lib/grid-frame.test.ts +56 -3
  241. package/src/lib/grid-frame.ts +28 -1
  242. package/src/lib/history.test.ts +2 -1
  243. package/src/lib/hud-title.ts +48 -24
  244. package/src/lib/ifc-export.test.ts +41 -1
  245. package/src/lib/inspector-card-mode.test.ts +3 -31
  246. package/src/lib/inspector-card-mode.ts +5 -10
  247. package/src/lib/inspector-expanded.test.ts +13 -0
  248. package/src/lib/inspector-expanded.ts +13 -1
  249. package/src/lib/keyboard-pan.test.ts +7 -0
  250. package/src/lib/keyboard-pan.ts +9 -2
  251. package/src/lib/layer-thickness.test.ts +22 -0
  252. package/src/lib/layer-thickness.ts +79 -0
  253. package/src/lib/lot/drop-in.test.ts +59 -1
  254. package/src/lib/lot/drop-in.ts +42 -50
  255. package/src/lib/material-swatch-style.ts +24 -0
  256. package/src/lib/measurement-parser.ts +7 -3
  257. package/src/lib/measurements.test.ts +17 -0
  258. package/src/lib/measurements.ts +20 -5
  259. package/src/lib/mezzanine-selection.test.ts +3 -1
  260. package/src/lib/orbit-near-plane.test.ts +12 -0
  261. package/src/lib/orbit-near-plane.ts +13 -0
  262. package/src/lib/paint-regions.test.ts +8 -5
  263. package/src/lib/paint-scope.test.ts +53 -0
  264. package/src/lib/paint-scope.ts +18 -4
  265. package/src/lib/panel-rows.ts +17 -8
  266. package/src/lib/reference-scale.ts +83 -0
  267. package/src/lib/room-built-on.test.ts +26 -12
  268. package/src/lib/room-built-on.ts +20 -2
  269. package/src/lib/room-construction.test.ts +28 -1
  270. package/src/lib/room-construction.ts +14 -0
  271. package/src/lib/room-controls-phase6.test.ts +140 -0
  272. package/src/lib/room-handle-drag.ts +557 -42
  273. package/src/lib/room-name-catalog.test.ts +8 -0
  274. package/src/lib/room-name-catalog.ts +8 -1
  275. package/src/lib/room-name-messages.test.ts +132 -0
  276. package/src/lib/room-name-messages.ts +147 -0
  277. package/src/lib/room-name-storage.test.ts +334 -0
  278. package/src/lib/room-push-dimensions.test.ts +37 -0
  279. package/src/lib/room-push-dimensions.ts +226 -2
  280. package/src/lib/room-selection.test.ts +23 -6
  281. package/src/lib/scene-clipboard.test.ts +70 -49
  282. package/src/lib/scene-clipboard.ts +9 -4
  283. package/src/lib/selection-cycle.ts +63 -0
  284. package/src/lib/separator-edit.test.ts +431 -0
  285. package/src/lib/separator-edit.ts +179 -0
  286. package/src/lib/separator-lines.ts +38 -0
  287. package/src/lib/sfx-bus.ts +27 -1
  288. package/src/lib/sfx-player.ts +16 -0
  289. package/src/lib/tool-cursor-icon.ts +5 -1
  290. package/src/lib/tool-shortcuts.ts +130 -0
  291. package/src/lib/viewer-selection.test.ts +28 -24
  292. package/src/lib/viewer-selection.ts +20 -5
  293. package/src/lib/wall-draw-variant.test.ts +7 -6
  294. package/src/lib/wall-draw-variant.ts +107 -6
  295. package/src/lib/wall-nudge.test.ts +44 -0
  296. package/src/lib/wall-nudge.ts +131 -0
  297. package/src/lib/wall-parallel-snap.test.ts +83 -0
  298. package/src/lib/wall-parallel-snap.ts +176 -0
  299. package/src/lib/wall-placement-pointer.test.ts +23 -0
  300. package/src/lib/wall-placement-pointer.ts +18 -0
  301. package/src/lib/wall-pull.test.ts +245 -0
  302. package/src/lib/wall-pull.ts +209 -0
  303. package/src/lib/wall-push-line-up.test.ts +132 -0
  304. package/src/lib/wall-region-list.ts +14 -5
  305. package/src/lib/wall-run-snap.test.ts +188 -0
  306. package/src/lib/wall-run-snap.ts +83 -0
  307. package/src/lib/wall-type.test.ts +125 -0
  308. package/src/lib/wall-type.ts +119 -0
  309. package/src/lib/window-interaction.test.ts +42 -0
  310. package/src/lib/window-interaction.ts +47 -5
  311. package/src/lib/world-grid-snap.test.ts +139 -2
  312. package/src/lib/world-grid-snap.ts +102 -105
  313. package/src/store/use-alignment-guides.ts +27 -1
  314. package/src/store/use-editor.tsx +48 -0
  315. package/src/store/use-floorplan-annotation-visibility.test.ts +64 -0
  316. package/src/store/use-floorplan-annotation-visibility.ts +47 -22
  317. package/src/store/use-wall-snap-indicator.ts +15 -2
@@ -17,12 +17,16 @@ import {
17
17
  planWallDivision,
18
18
  resolveMovedWallSupportSlabPatch,
19
19
  runAsSingleSceneHistoryStep,
20
+ SEPARATOR_JOIN_REACH,
21
+ type SeparatorNode,
22
+ type SeparatorSpan,
20
23
  type StructureNodes,
21
24
  type StructurePlan,
22
25
  setRoomFloorConstruction,
23
26
  setWallGeometry,
24
27
  setZoneEdges,
25
28
  setZoneIntent,
29
+ translateSeparators,
26
30
  upperFloorHeightControl,
27
31
  useLiveNodeOverrides,
28
32
  useScene,
@@ -46,19 +50,34 @@ import { roomOwnPlate } from './room-built-on'
46
50
  import {
47
51
  mezzanineEdgeDimensions,
48
52
  type RoomDimension,
53
+ sameRoomDimension,
54
+ separatorEdgeDimensionRooms,
55
+ type WallDimensionFaces,
56
+ type WallSelectionDimension,
49
57
  wallPushDimensionRooms,
50
58
  wallPushDimensions,
59
+ wallSelectionDimensions,
51
60
  } from './room-push-dimensions'
52
61
  import { applyRoomPlan } from './room-structure-commands'
62
+ import { levelSeparatorLines, separatorWallLine } from './separator-lines'
53
63
  import { sfxEmitter } from './sfx-bus'
54
64
  import { type SpatialPointerId, spatialPointerInput } from './spatial-pointer-input'
55
65
  import { isTypingTarget } from './typing-target'
66
+ import {
67
+ nearestWallParallelTarget,
68
+ publishWallParallelGuide,
69
+ type WallParallelTarget,
70
+ wallParallelTargets,
71
+ } from './wall-parallel-snap'
72
+ import { isWallRunFree } from './wall-run-snap'
56
73
 
57
74
  export const ROOM_ELEVATION_DRAG_LABEL = 'room-elevation'
58
75
  /** Scope label of every wall push: a selected wall's side arrow and a room's boundary arrow. */
59
76
  export const WALL_PUSH_DRAG_LABEL = 'wall-push'
60
77
  /** Scope label of a mezzanine edge push (its plate grows or shrinks). */
61
78
  export const MEZZANINE_EDGE_DRAG_LABEL = 'mezzanine-edge'
79
+ /** What a wall push the planner refuses reads. */
80
+ export const PUSH_REFUSED_MESSAGE = "Can't move this wall there"
62
81
 
63
82
  /** A wall a push would create (a moved span piece, a bridge), drawn as a ghost. */
64
83
  export type RoomPushGhost = { start: Point; end: Point; thickness: number; height: number }
@@ -93,6 +112,20 @@ export type RoomHandleDrag =
93
112
  dimensions: RoomDimension[]
94
113
  message?: string
95
114
  }
115
+ | {
116
+ /** Several selected walls moved together, each along its own normal. */
117
+ kind: 'wall-group-push'
118
+ levelId: string
119
+ /** Where a refusal reads: over the moved walls' middle, in level plan coordinates. */
120
+ anchor: Point
121
+ height: number
122
+ /** The lowest moved wall's base, where the ghost pieces stand. */
123
+ base: number
124
+ ghosts: RoomPushGhost[]
125
+ /** Every moved wall's live room widths, as its own push shows them. */
126
+ dimensions: RoomDimension[]
127
+ message?: string
128
+ }
96
129
  | {
97
130
  kind: 'mezzanine-edge'
98
131
  zoneId: string
@@ -129,9 +162,16 @@ export function roomFloorElevation(nodes: Nodes, zoneId: string): number {
129
162
  /**
130
163
  * The stretch of a wall a push moves: `[0, 1]` is the whole wall (its
131
164
  * neighbours follow through the junction planner), anything shorter is split
132
- * off first and only that piece moves.
165
+ * off first and only that piece moves. An edge of invisible walls carries its
166
+ * stretches in `separators` (`wallId` is the first one's): they move as one
167
+ * line, the invisible walls joined to them following (`translateSeparators`).
133
168
  */
134
- export type WallPushSpan = { wallId: string; t0: number; t1: number }
169
+ export type WallPushSpan = {
170
+ wallId: string
171
+ t0: number
172
+ t1: number
173
+ separators?: readonly SeparatorSpan[]
174
+ }
135
175
 
136
176
  /** One push/pull arrow: a selected wall's side arrow or a room's boundary arrow. */
137
177
  export type WallPushHandle = WallPushSpan & {
@@ -191,22 +231,27 @@ export function wallPushHandles(wall: WallNode, nodes: Nodes): WallPushHandle[]
191
231
  })
192
232
  }
193
233
 
234
+ type BoundarySpanRef = {
235
+ boundaryId: string
236
+ kind: string
237
+ face: 'a' | 'b'
238
+ t0: number
239
+ t1: number
240
+ }
241
+
194
242
  /**
195
243
  * One push/pull arrow per boundary wall span of the room, outside the wall's
196
244
  * far face, pointing away from the room — where the wall's own side arrow for
197
- * that face sits. `face` is the wall face the room lies on (`a` = front).
245
+ * that face sits. `face` is the wall face the room lies on (`a` = front). Its
246
+ * invisible walls get one arrow per straight edge (`separatorEdges`), lying
247
+ * at `floor` (the room's floor height), where their dashed line is drawn.
198
248
  */
199
249
  export function roomPushHandles(
200
250
  nodes: Nodes,
201
- spans: ReadonlyArray<{
202
- boundaryId: string
203
- kind: string
204
- face: 'a' | 'b'
205
- t0: number
206
- t1: number
207
- }>,
251
+ spans: ReadonlyArray<BoundarySpanRef>,
252
+ floor = 0,
208
253
  ): WallPushHandle[] {
209
- return spans.flatMap((span) => {
254
+ const walls = spans.flatMap((span) => {
210
255
  const wall = nodes[span.boundaryId]
211
256
  if (span.kind !== 'wall' || wall?.type !== 'wall') return []
212
257
  const handle = wallPushHandle(
@@ -218,6 +263,145 @@ export function roomPushHandles(
218
263
  )
219
264
  return handle ? [handle] : []
220
265
  })
266
+ const edges = separatorEdges(nodes, spans).map((edge) => {
267
+ const first = edge.members[0]!
268
+ return separatorPushHandle(
269
+ edge,
270
+ edge.outward,
271
+ floor,
272
+ `${first.separatorId}:${edge.face}:${first.t0.toFixed(4)}`,
273
+ )
274
+ })
275
+ return [...walls, ...edges]
276
+ }
277
+
278
+ /** How far outside an invisible wall (m) its push arrow sits, as a mezzanine edge's does. */
279
+ const SEPARATOR_ARROW_OFFSET = 0.3
280
+ /** Its 3D arrow's lift over the floor: where the invisible wall's dashed line is drawn. */
281
+ const SEPARATOR_ARROW_LIFT = 0.02
282
+ /** An invisible-wall edge shorter than this (m) gets no arrow, as a mezzanine edge. */
283
+ const SEPARATOR_EDGE_MIN_LENGTH = 0.2
284
+ /** Stretches this far off each other's line (m) still run along one edge. */
285
+ const EDGE_IN_LINE = 0.01
286
+
287
+ type SeparatorEdge = {
288
+ /** The stretches along the edge, in order. */
289
+ members: SeparatorSpan[]
290
+ start: Point
291
+ end: Point
292
+ /** Away from the room. */
293
+ outward: Point
294
+ face: 'a' | 'b'
295
+ }
296
+
297
+ /**
298
+ * A room's invisible-wall stretches gathered into its straight edges: stretches
299
+ * end to end on one line, the room on the same side, are one edge and push as
300
+ * one (an edge drawn in two pieces, or broken where another one meets it). Ends
301
+ * meet as far as the room graph joins them (`SEPARATOR_JOIN_REACH`).
302
+ */
303
+ function separatorEdges(nodes: Nodes, spans: ReadonlyArray<BoundarySpanRef>): SeparatorEdge[] {
304
+ const pieces = spans.flatMap((span): SeparatorEdge[] => {
305
+ const separator = nodes[span.boundaryId]
306
+ if (span.kind !== 'separator' || separator?.type !== 'separator') return []
307
+ const dx = separator.end[0] - separator.start[0]
308
+ const dz = separator.end[1] - separator.start[1]
309
+ const length = Math.hypot(dx, dz)
310
+ if (length < 1e-6 || span.t1 - span.t0 < WHOLE) return []
311
+ // Face `a` is the +normal side: the arrow points the other way, off the room.
312
+ const sign = span.face === 'a' ? -1 : 1
313
+ const at = (t: number): Point => [separator.start[0] + dx * t, separator.start[1] + dz * t]
314
+ return [
315
+ {
316
+ members: [{ separatorId: separator.id, t0: span.t0, t1: span.t1 }],
317
+ start: at(span.t0),
318
+ end: at(span.t1),
319
+ outward: [(-dz / length) * sign, (dx / length) * sign],
320
+ face: span.face,
321
+ },
322
+ ]
323
+ })
324
+ const across = (edge: SeparatorEdge, [x, z]: Point) =>
325
+ Math.abs((x - edge.start[0]) * edge.outward[0] + (z - edge.start[1]) * edge.outward[1])
326
+ const joins = (a: SeparatorEdge, b: SeparatorEdge) =>
327
+ a.outward[0] * b.outward[0] + a.outward[1] * b.outward[1] > 1 - 1e-4 &&
328
+ across(a, b.start) < EDGE_IN_LINE &&
329
+ across(a, b.end) < EDGE_IN_LINE &&
330
+ [a.start, a.end].some((p) =>
331
+ [b.start, b.end].some((q) => Math.hypot(p[0] - q[0], p[1] - q[1]) <= SEPARATOR_JOIN_REACH),
332
+ )
333
+ let groups: SeparatorEdge[][] = []
334
+ for (const piece of pieces) {
335
+ const touching = groups.filter((group) => group.some((other) => joins(piece, other)))
336
+ groups = [...groups.filter((group) => !touching.includes(group)), [piece, ...touching.flat()]]
337
+ }
338
+ return groups.flatMap((group): SeparatorEdge[] => {
339
+ const { outward, face } = group[0]!
340
+ const dir: Point = [outward[1], -outward[0]]
341
+ const along = ([x, z]: Point) => x * dir[0] + z * dir[1]
342
+ const ends = group
343
+ .flatMap((piece) => [piece.start, piece.end])
344
+ .sort((p, q) => along(p) - along(q))
345
+ const start = ends[0]!
346
+ const end = ends.at(-1)!
347
+ if (Math.hypot(end[0] - start[0], end[1] - start[1]) < SEPARATOR_EDGE_MIN_LENGTH) return []
348
+ const middle = (piece: SeparatorEdge) => along(piece.start) + along(piece.end)
349
+ const members = [...group].sort((p, q) => middle(p) - middle(q)).flatMap((p) => p.members)
350
+ return [{ members, start, end, outward, face }]
351
+ })
352
+ }
353
+
354
+ function separatorPushHandle(
355
+ edge: Pick<SeparatorEdge, 'members' | 'start' | 'end'>,
356
+ outward: Point,
357
+ floor: number,
358
+ key: string,
359
+ ): WallPushHandle {
360
+ const first = edge.members[0]!
361
+ const middle: Point = [(edge.start[0] + edge.end[0]) / 2, (edge.start[1] + edge.end[1]) / 2]
362
+ return {
363
+ key,
364
+ wallId: first.separatorId,
365
+ t0: first.t0,
366
+ t1: first.t1,
367
+ separators: edge.members,
368
+ position: [
369
+ middle[0] + outward[0] * SEPARATOR_ARROW_OFFSET,
370
+ middle[1] + outward[1] * SEPARATOR_ARROW_OFFSET,
371
+ ],
372
+ height: floor + SEPARATOR_ARROW_LIFT,
373
+ outward,
374
+ line: middle[0] * outward[0] + middle[1] * outward[1],
375
+ }
376
+ }
377
+
378
+ /**
379
+ * A selected invisible wall's two side arrows, one off each side, at `floor`:
380
+ * each pushes the whole separator, the ones joined to it following.
381
+ */
382
+ export function separatorPushHandles(separator: SeparatorNode, floor = 0): WallPushHandle[] {
383
+ const dx = separator.end[0] - separator.start[0]
384
+ const dz = separator.end[1] - separator.start[1]
385
+ const length = Math.hypot(dx, dz)
386
+ if (length < 1e-6) return []
387
+ const edge = {
388
+ members: [{ separatorId: separator.id, t0: 0, t1: 1 }],
389
+ start: separator.start,
390
+ end: separator.end,
391
+ }
392
+ return (['a', 'b'] as const).map((side) => {
393
+ const sign = side === 'a' ? 1 : -1
394
+ const outward: Point = [(-dz / length) * sign, (dx / length) * sign]
395
+ return separatorPushHandle(edge, outward, floor, `${separator.id}:${side}`)
396
+ })
397
+ }
398
+
399
+ /** The floor an invisible wall lies on: the walking surface of a room it closes, else 0. */
400
+ export function separatorFloorElevation(nodes: Nodes, separatorId: string): number {
401
+ const zone = Object.values(nodes).find(
402
+ (node) => node.type === 'zone' && node.boundarySeparatorIds.includes(separatorId),
403
+ )
404
+ return zone ? roomFloorElevation(nodes, zone.id) : 0
221
405
  }
222
406
 
223
407
  /**
@@ -294,7 +478,8 @@ function diffNodes(before: Scratch, after: Scratch): NodeChange[] {
294
478
  * Pushes the room's stretch of a wall `distance` along `outward`. A span that
295
479
  * covers the whole wall moves the wall; a partial span is split off at its
296
480
  * ends first (hosted openings stay on their pieces) and only that piece moves,
297
- * bridged back to the rest by the junction planner. One plan, one undo step.
481
+ * bridged back to the rest by the junction planner. An edge of invisible walls
482
+ * moves as one line (`translateSeparators`). One plan, one undo step.
298
483
  */
299
484
  export function planRoomPush(
300
485
  nodes: StructureNodes,
@@ -303,6 +488,12 @@ export function planRoomPush(
303
488
  distance: number,
304
489
  mintId: (kind: 'zone' | 'wall' | 'separator') => string = generateId,
305
490
  ): StructurePlan {
491
+ if (span.separators?.length)
492
+ return translateSeparators(nodes, {
493
+ spans: span.separators,
494
+ offset: [outward[0] * distance, outward[1] * distance],
495
+ mintId,
496
+ })
306
497
  const wall = nodes[span.wallId]
307
498
  if (wall?.type !== 'wall') throw Error('Select a wall.')
308
499
  const shift = ([x, z]: Point): Point => [x + outward[0] * distance, z + outward[1] * distance]
@@ -388,14 +579,18 @@ function clearPushPreview() {
388
579
  function previewSurfaces(nodes: Nodes, levelId: string, after: Scratch) {
389
580
  if (surfacePreview?.levelId !== levelId || surfacePreview.nodes !== nodes)
390
581
  surfacePreview = { levelId, nodes, run: createLevelStructurePreview(levelId, nodes) }
391
- const walls = Object.values(after).filter(
392
- (node): node is WallNode => node.type === 'wall' && node.parentId === levelId,
393
- )
582
+ const level = Object.values(after).filter((node) => node.parentId === levelId)
394
583
  return surfacePreview
395
- .run(walls)
584
+ .run(
585
+ level.filter((node): node is WallNode => node.type === 'wall'),
586
+ level.filter((node): node is SeparatorNode => node.type === 'separator'),
587
+ )
396
588
  .map((patch) => [patch.id as AnyNodeId, patch.data as Partial<AnyNode>] as const)
397
589
  }
398
590
 
591
+ /** The ghost of an invisible wall a push would create (a jog): a thin strip on the floor. */
592
+ const SEPARATOR_GHOST = { thickness: 0.03, height: 0.04 }
593
+
399
594
  /**
400
595
  * Shows the push live: the plan's updates to existing walls — and the room
401
596
  * surfaces reshaped around them — ride `useLiveNodeOverrides` (renderers merge
@@ -410,9 +605,20 @@ export function previewRoomPush(
410
605
  clearPushPreview()
411
606
  if (Math.abs(distance) < 1e-6) return { ghosts: [] }
412
607
  const nodes = useScene.getState().nodes
608
+ return previewPushPlan(nodes, nodes[span.wallId as AnyNodeId]?.parentId, () =>
609
+ planRoomPush(nodes, span, outward, distance, previewMint()),
610
+ )
611
+ }
612
+
613
+ /** Shows a push plan live (`previewRoomPush`); the caller has cleared the last one. */
614
+ function previewPushPlan(
615
+ nodes: Nodes,
616
+ levelId: string | null | undefined,
617
+ planPush: () => StructurePlan,
618
+ ): { code?: string; ghosts: RoomPushGhost[] } {
413
619
  let plan: StructurePlan
414
620
  try {
415
- plan = planRoomPush(nodes, span, outward, distance, previewMint())
621
+ plan = planPush()
416
622
  } catch {
417
623
  return { code: 'invalid-geometry', ghosts: [] }
418
624
  }
@@ -420,15 +626,17 @@ export function previewRoomPush(
420
626
  const updates: Array<readonly [AnyNodeId, Partial<AnyNode>]> = plan.changes.flatMap((change) =>
421
627
  change.op === 'update' ? [[change.id as AnyNodeId, change.data] as const] : [],
422
628
  )
423
- const levelId = nodes[span.wallId as AnyNodeId]?.parentId
424
629
  if (levelId) updates.push(...previewSurfaces(nodes, levelId, applyChanges(nodes, plan.changes)))
425
630
  useLiveNodeOverrides
426
631
  .getState()
427
632
  .setMany(updates.map(([id, data]) => [id, data] as [AnyNodeId, Partial<AnyNode>]))
428
633
  previewIds = updates.map(([id]) => id)
429
634
  for (const id of previewIds) useScene.getState().markDirty(id as AnyNodeId)
430
- const ghosts = plan.changes.flatMap((change): RoomPushGhost[] =>
431
- change.op === 'create' && change.node.type === 'wall'
635
+ const ghosts = plan.changes.flatMap((change): RoomPushGhost[] => {
636
+ if (change.op !== 'create') return []
637
+ if (change.node.type === 'separator')
638
+ return [{ start: change.node.start, end: change.node.end, ...SEPARATOR_GHOST }]
639
+ return change.node.type === 'wall'
432
640
  ? [
433
641
  {
434
642
  start: change.node.start,
@@ -437,8 +645,8 @@ export function previewRoomPush(
437
645
  height: getWallEffectiveHeightForNodes(change.node, nodes),
438
646
  },
439
647
  ]
440
- : [],
441
- )
648
+ : []
649
+ })
442
650
  // The floor plan draws the same pieces through its bridge-ghost layer.
443
651
  if (ghosts.length) {
444
652
  useWallMoveGhosts.getState().setBridges(
@@ -468,9 +676,13 @@ export function endRoomPushPreview() {
468
676
  export function commitRoomPush(span: WallPushSpan, outward: Point, distance: number) {
469
677
  endRoomPushPreview()
470
678
  if (Math.abs(distance) < 1e-6) return true
679
+ return commitPushPlan((nodes) => planRoomPush(nodes, span, outward, distance))
680
+ }
681
+
682
+ function commitPushPlan(planPush: (nodes: StructureNodes) => StructurePlan) {
471
683
  try {
472
684
  return runAsSingleSceneHistoryStep(useScene, () => {
473
- const plan = planRoomPush(useScene.getState().nodes, span, outward, distance)
685
+ const plan = planPush(useScene.getState().nodes)
474
686
  if (!applyRoomPlan(plan)) return false
475
687
  const nodes = useScene.getState().nodes
476
688
  const patches = plan.changes.flatMap((change) => {
@@ -716,12 +928,13 @@ export function cancelRoomHandleDrag() {
716
928
  /**
717
929
  * Runs one handle drag on window listeners, independent of the handle's own
718
930
  * mount (handles hide while any scope is active). `sample` maps a pointer to
719
- * the drag's value (`spatial.sample` a controller ray, for a spatial pointer);
720
- * release commits, Escape / ⌘Z / the gesture owner cancel. Cleanup runs
931
+ * the drag's value (`spatial.sample` a controller ray, for a spatial pointer):
932
+ * a distance or a height, or a plan point for an end dragged across the floor.
933
+ * Release commits, Escape / ⌘Z / the gesture owner cancel. Cleanup runs
721
934
  * exactly once whatever happens, and releases only this drag's own scope;
722
935
  * nothing republishes a preview after it.
723
936
  */
724
- export function runRoomHandleDrag({
937
+ export function runRoomHandleDrag<T = number>({
725
938
  label,
726
939
  nodeId,
727
940
  zoneId,
@@ -738,9 +951,9 @@ export function runRoomHandleDrag({
738
951
  zoneId?: string
739
952
  levelId: string
740
953
  requires: string[]
741
- sample: (clientX: number, clientY: number) => number | null
742
- spatial?: { pointerId: SpatialPointerId; sample: (ray: Ray) => number | null }
743
- onValue: (value: number) => void
954
+ sample: (clientX: number, clientY: number) => T | null
955
+ spatial?: { pointerId: SpatialPointerId; sample: (ray: Ray) => T | null }
956
+ onValue: (value: T) => void
744
957
  onCommit: () => void
745
958
  onCancel: () => void
746
959
  }) {
@@ -891,10 +1104,20 @@ export function runWallPushDrag({
891
1104
  spatial?: { pointerId: SpatialPointerId; along: (ray: Ray) => number | null }
892
1105
  }) {
893
1106
  const nodes = useScene.getState().nodes
894
- const wall = nodes[handle.wallId as AnyNodeId]
895
- if (wall?.type !== 'wall') return null
896
- const base = getWallBaseElevationForNodes(wall, nodes)
1107
+ const boundary = nodes[handle.wallId as AnyNodeId]
1108
+ // An edge of invisible walls is measured, and lines up, as the one line it moves as.
1109
+ const edge = handle.separators?.length ? separatorEdgeLine(nodes, handle.separators) : null
1110
+ const wall = edge ?? (boundary?.type === 'wall' ? boundary : null)
1111
+ if (!wall) return null
1112
+ const base =
1113
+ wall.type === 'wall'
1114
+ ? getWallBaseElevationForNodes(wall, nodes)
1115
+ : handle.height - SEPARATOR_ARROW_LIFT
897
1116
  const rooms = pushDimensionRooms(nodes, levelId, handle, base)
1117
+ const levelWalls = Object.values(nodes).filter(
1118
+ (node): node is WallNode => node.type === 'wall' && node.parentId === levelId,
1119
+ )
1120
+ const moving = new Set([handle.wallId, ...(handle.separators ?? []).map((s) => s.separatorId)])
898
1121
  let distance = 0
899
1122
  let blocked = false
900
1123
  let ghosts: RoomPushGhost[] = []
@@ -921,15 +1144,49 @@ export function runWallPushDrag({
921
1144
  message,
922
1145
  },
923
1146
  })
924
- const toDistance = (at: number | null) =>
925
- at === null ? null : snapPushDistance(handle.line, at - from)
1147
+ // The pushed wall lines up with the parallel walls on its level
1148
+ // (`wall-parallel-snap`) before the grid; Alt pushes it free. Invisible walls
1149
+ // line up with the walls and with the level's other invisible walls.
1150
+ const lineUpTargets =
1151
+ wall.type === 'wall'
1152
+ ? wallParallelTargets(wall, handle.outward, levelWalls)
1153
+ : wallParallelTargets(separatorWallLine(wall), handle.outward, [
1154
+ ...levelWalls,
1155
+ ...levelSeparatorLines(nodes, levelId, moving),
1156
+ ])
1157
+ let lineUp: WallParallelTarget | null = null
1158
+ let altHeld = false
1159
+ const trackAlt = (event: KeyboardEvent | PointerEvent) => {
1160
+ altHeld = event.altKey
1161
+ }
1162
+ window.addEventListener('pointermove', trackAlt, true)
1163
+ window.addEventListener('keydown', trackAlt, true)
1164
+ window.addEventListener('keyup', trackAlt, true)
1165
+ const stopLineUp = () => {
1166
+ window.removeEventListener('pointermove', trackAlt, true)
1167
+ window.removeEventListener('keydown', trackAlt, true)
1168
+ window.removeEventListener('keyup', trackAlt, true)
1169
+ if (lineUp) publishWallParallelGuide(null)
1170
+ lineUp = null
1171
+ }
1172
+ const toDistance = (at: number | null) => {
1173
+ if (at === null) return null
1174
+ const raw = at - from
1175
+ const next = isWallRunFree({ altKey: altHeld })
1176
+ ? null
1177
+ : nearestWallParallelTarget(lineUpTargets, handle.line + raw)
1178
+ if (next !== lineUp) publishWallParallelGuide(next)
1179
+ lineUp = next
1180
+ if (next) return next.position - handle.line
1181
+ return isWallRunFree({ altKey: altHeld }) ? raw : snapPushDistance(handle.line, raw)
1182
+ }
926
1183
  publish()
927
1184
  return runRoomHandleDrag({
928
1185
  label: WALL_PUSH_DRAG_LABEL,
929
1186
  nodeId: handle.wallId,
930
1187
  zoneId,
931
1188
  levelId,
932
- requires: zoneId ? [zoneId, handle.wallId] : [handle.wallId],
1189
+ requires: zoneId ? [zoneId, ...moving] : [...moving],
933
1190
  sample: (x, y) => toDistance(along(x, y)),
934
1191
  spatial: spatial && {
935
1192
  pointerId: spatial.pointerId,
@@ -942,13 +1199,17 @@ export function runWallPushDrag({
942
1199
  const preview = previewRoomPush(handle, handle.outward, distance)
943
1200
  ghosts = preview.ghosts
944
1201
  blocked = !!preview.code
945
- publish(preview.code ? "Can't move this wall there" : undefined)
1202
+ publish(preview.code ? PUSH_REFUSED_MESSAGE : undefined)
946
1203
  },
947
1204
  onCommit: () => {
1205
+ stopLineUp()
948
1206
  if (blocked) endRoomPushPreview()
949
1207
  else commitRoomPush(handle, handle.outward, distance)
950
1208
  },
951
- onCancel: () => endRoomPushPreview(),
1209
+ onCancel: () => {
1210
+ stopLineUp()
1211
+ endRoomPushPreview()
1212
+ },
952
1213
  })
953
1214
  }
954
1215
 
@@ -960,12 +1221,260 @@ function pushDimensionRooms(nodes: Nodes, levelId: string, span: WallPushSpan, b
960
1221
  const index = getRoomSelectionIndex(levelId)
961
1222
  const records = index.update(nodes)
962
1223
  const topology = index.topology.getLevelTopology(levelId)
963
- return wallPushDimensionRooms(topology?.rooms ?? [], span, (roomId) => {
1224
+ const elevationOf = (roomId: string) => {
964
1225
  const zoneId = records.find((record) => record.id === roomId)?.zoneId
965
1226
  return zoneId ? roomFloorElevation(nodes, zoneId) : base
1227
+ }
1228
+ const edge = span.separators?.length ? separatorEdgeLine(nodes, span.separators) : null
1229
+ return edge && span.separators
1230
+ ? separatorEdgeDimensionRooms(
1231
+ topology?.rooms ?? [],
1232
+ { line: edge, separators: span.separators },
1233
+ (id) => nodes[id],
1234
+ elevationOf,
1235
+ )
1236
+ : wallPushDimensionRooms(topology?.rooms ?? [], span, elevationOf)
1237
+ }
1238
+
1239
+ /**
1240
+ * The one line an edge of invisible walls pushes as: its first separator,
1241
+ * running from the edge's first end to its last.
1242
+ */
1243
+ function separatorEdgeLine(nodes: Nodes, spans: readonly SeparatorSpan[]): SeparatorNode | null {
1244
+ const first = nodes[spans[0]?.separatorId as AnyNodeId]
1245
+ if (first?.type !== 'separator') return null
1246
+ const dx = first.end[0] - first.start[0]
1247
+ const dz = first.end[1] - first.start[1]
1248
+ const length = Math.hypot(dx, dz)
1249
+ if (length < 1e-6) return null
1250
+ const along = ([x, z]: Point) =>
1251
+ ((x - first.start[0]) * dx + (z - first.start[1]) * dz) / (length * length)
1252
+ let lo = Number.POSITIVE_INFINITY
1253
+ let hi = Number.NEGATIVE_INFINITY
1254
+ for (const span of spans) {
1255
+ const separator = nodes[span.separatorId as AnyNodeId]
1256
+ if (separator?.type !== 'separator') return null
1257
+ for (const t of [span.t0, span.t1]) {
1258
+ const u = along([
1259
+ separator.start[0] + (separator.end[0] - separator.start[0]) * t,
1260
+ separator.start[1] + (separator.end[1] - separator.start[1]) * t,
1261
+ ])
1262
+ lo = Math.min(lo, u)
1263
+ hi = Math.max(hi, u)
1264
+ }
1265
+ }
1266
+ const at = (u: number): Point => [first.start[0] + dx * u, first.start[1] + dz * u]
1267
+ return { ...first, start: at(lo), end: at(hi) }
1268
+ }
1269
+
1270
+ /**
1271
+ * A selected wall's temporary dimensions as the scene stands: the room widths
1272
+ * its push shows, before any push, plus the outside size for an exterior wall.
1273
+ */
1274
+ export function selectedWallDimensions(
1275
+ nodes: Nodes,
1276
+ wall: WallNode,
1277
+ faces: WallDimensionFaces,
1278
+ ): WallSelectionDimension[] {
1279
+ const levelId = wall.parentId
1280
+ if (!levelId) return []
1281
+ const base = getWallBaseElevationForNodes(wall, nodes)
1282
+ const walls = Object.values(nodes).filter(
1283
+ (node): node is WallNode => node.type === 'wall' && node.parentId === levelId,
1284
+ )
1285
+ return wallSelectionDimensions({
1286
+ rooms: pushDimensionRooms(nodes, levelId, { wallId: wall.id, t0: 0, t1: 1 }, base),
1287
+ wall,
1288
+ walls,
1289
+ faces,
1290
+ resolve: (id) => nodes[id],
1291
+ baseElevation: base,
966
1292
  })
967
1293
  }
968
1294
 
1295
+ /** An end this close to another moved wall is joined to it. */
1296
+ const GROUP_JOIN_REACH = 1e-3
1297
+ /** Walls whose directions cross by less than this (sine) run in line, never meet at a corner. */
1298
+ const GROUP_PARALLEL_SIN = 1e-4
1299
+
1300
+ /** The way a wall's push arrows point: its `a`-side normal at its middle. */
1301
+ function wallPushNormal(wall: WallNode): Point {
1302
+ const frame = getWallCurveFrameAt(wall, 0.5)
1303
+ return [frame.normal.x, frame.normal.y]
1304
+ }
1305
+
1306
+ function wallDirection(wall: WallNode): Point {
1307
+ const dx = wall.end[0] - wall.start[0]
1308
+ const dz = wall.end[1] - wall.start[1]
1309
+ const length = Math.hypot(dx, dz) || 1
1310
+ return [dx / length, dz / length]
1311
+ }
1312
+
1313
+ /** Whether `point` is on `wall`: one of its ends, or along a straight one. */
1314
+ function wallHoldsPoint(wall: WallNode, [x, z]: Point): boolean {
1315
+ const near = ([px, pz]: Point) => Math.hypot(px - x, pz - z) <= GROUP_JOIN_REACH
1316
+ if (near(wall.start) || near(wall.end)) return true
1317
+ if ((wall.curveOffset ?? 0) !== 0) return false
1318
+ const [ux, uz] = wallDirection(wall)
1319
+ const along = (x - wall.start[0]) * ux + (z - wall.start[1]) * uz
1320
+ const length = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
1321
+ return (
1322
+ along > 0 &&
1323
+ along < length &&
1324
+ Math.abs((z - wall.start[1]) * ux - (x - wall.start[0]) * uz) <= GROUP_JOIN_REACH
1325
+ )
1326
+ }
1327
+
1328
+ /**
1329
+ * Moves several walls at once, each as its own push arrow would: along its
1330
+ * normal by `drag` projected on it, so it stays parallel to where it was, the
1331
+ * walls joined to it following through the junction planner. An end on
1332
+ * another moved wall that is not in line with it (a corner or a T between
1333
+ * them) sits on both moved lines, which is where it lands moved by the whole
1334
+ * drag, so they stay joined. Each wall is planned in turn while the others
1335
+ * already stand at their targets, so no moved wall is pulled along as
1336
+ * another's neighbour. One plan, one undo step.
1337
+ */
1338
+ export function planWallGroupPush(
1339
+ nodes: StructureNodes,
1340
+ wallIds: readonly string[],
1341
+ drag: Point,
1342
+ mintId: (kind: 'zone' | 'wall' | 'separator') => string = generateId,
1343
+ ): StructurePlan {
1344
+ const walls = wallIds.flatMap((id) => {
1345
+ const node = nodes[id]
1346
+ return node?.type === 'wall' ? [node] : []
1347
+ })
1348
+ const crosses = (a: WallNode, b: WallNode) => {
1349
+ const [ux, uz] = wallDirection(a)
1350
+ const [vx, vz] = wallDirection(b)
1351
+ return Math.abs(ux * vz - uz * vx) > GROUP_PARALLEL_SIN
1352
+ }
1353
+ let scratch: Scratch = { ...nodes }
1354
+ for (const wall of walls) {
1355
+ const [nx, nz] = wallPushNormal(wall)
1356
+ const distance = drag[0] * nx + drag[1] * nz
1357
+ const move = (point: Point): Point =>
1358
+ walls.some((other) => other !== wall && crosses(wall, other) && wallHoldsPoint(other, point))
1359
+ ? [point[0] + drag[0], point[1] + drag[1]]
1360
+ : [point[0] + nx * distance, point[1] + nz * distance]
1361
+ scratch[wall.id] = { ...wall, start: move(wall.start), end: move(wall.end) }
1362
+ }
1363
+ for (const wall of walls) {
1364
+ const target = scratch[wall.id]
1365
+ if (target?.type !== 'wall') continue
1366
+ scratch[wall.id] = { ...target, start: wall.start, end: wall.end }
1367
+ const moved = setWallGeometry(scratch, {
1368
+ wallId: wall.id,
1369
+ start: target.start,
1370
+ end: target.end,
1371
+ mintId,
1372
+ })
1373
+ if (moved.conflicts?.length) return moved
1374
+ scratch = applyChanges(scratch, moved.changes)
1375
+ }
1376
+ return { changes: diffNodes(nodes, scratch) }
1377
+ }
1378
+
1379
+ /** A multi-wall push in flight (`beginWallGroupPush`). */
1380
+ export type WallGroupPush = {
1381
+ /** Previews the walls moved by the level-plan drag. */
1382
+ preview: (drag: Point) => void
1383
+ /** Writes the last preview as one undo step; false when nothing was written. */
1384
+ commit: () => boolean
1385
+ /** Drops the preview; the scene is untouched. */
1386
+ cancel: () => void
1387
+ }
1388
+
1389
+ /**
1390
+ * The live side of `planWallGroupPush`, for a group move whose selection is
1391
+ * walls only: the walls, their neighbours and the room surfaces preview
1392
+ * through overrides, created pieces as ghosts, and each wall's room widths
1393
+ * show as its own push shows them (`useRoomHandleDrag`, kind
1394
+ * `wall-group-push`). The caller owns the gesture: its pointer, keys and scope.
1395
+ */
1396
+ export function beginWallGroupPush(wallIds: readonly string[], levelId: string): WallGroupPush {
1397
+ const nodes = useScene.getState().nodes
1398
+ const measured = wallIds.flatMap((id) => {
1399
+ const wall = nodes[id as AnyNodeId]
1400
+ if (wall?.type !== 'wall') return []
1401
+ const base = getWallBaseElevationForNodes(wall, nodes)
1402
+ return [
1403
+ {
1404
+ wall,
1405
+ base,
1406
+ top: base + getWallEffectiveHeightForNodes(wall, nodes),
1407
+ normal: wallPushNormal(wall),
1408
+ rooms: pushDimensionRooms(nodes, levelId, { wallId: wall.id, t0: 0, t1: 1 }, base),
1409
+ },
1410
+ ]
1411
+ })
1412
+ const middle: Point = [0, 0]
1413
+ for (const { wall } of measured) {
1414
+ middle[0] += (wall.start[0] + wall.end[0]) / 2 / measured.length
1415
+ middle[1] += (wall.start[1] + wall.end[1]) / 2 / measured.length
1416
+ }
1417
+ let drag: Point = [0, 0]
1418
+ let blocked = false
1419
+ let shown = false
1420
+ const dimensions = () => {
1421
+ const kept: RoomDimension[] = []
1422
+ for (const { wall, normal, rooms } of measured) {
1423
+ const distance = drag[0] * normal[0] + drag[1] * normal[1]
1424
+ // Two moved walls across one room both measure it: the width shows once.
1425
+ for (const dimension of wallPushDimensions({
1426
+ rooms,
1427
+ wall,
1428
+ outward: normal,
1429
+ distance,
1430
+ resolve: livePreviewNode,
1431
+ }))
1432
+ if (!kept.some((other) => sameRoomDimension(other, dimension)))
1433
+ kept.push({ ...dimension, key: `${wall.id}:${dimension.key}` })
1434
+ }
1435
+ return kept
1436
+ }
1437
+ const publish = (ghosts: RoomPushGhost[], message?: string) =>
1438
+ useRoomHandleDrag.setState({
1439
+ drag: {
1440
+ kind: 'wall-group-push',
1441
+ levelId,
1442
+ anchor: [middle[0] + drag[0], middle[1] + drag[1]],
1443
+ height: Math.max(...measured.map(({ top }) => top)),
1444
+ base: Math.min(...measured.map(({ base }) => base)),
1445
+ ghosts,
1446
+ dimensions: dimensions(),
1447
+ message,
1448
+ },
1449
+ })
1450
+ const end = () => {
1451
+ endRoomPushPreview()
1452
+ useRoomHandleDrag.setState({ drag: null })
1453
+ }
1454
+ return {
1455
+ preview: (next) => {
1456
+ if (shown && next[0] === drag[0] && next[1] === drag[1]) return
1457
+ shown = true
1458
+ drag = [next[0], next[1]]
1459
+ clearPushPreview()
1460
+ const preview =
1461
+ Math.hypot(drag[0], drag[1]) < 1e-6
1462
+ ? { ghosts: [] }
1463
+ : previewPushPlan(useScene.getState().nodes, levelId, () =>
1464
+ planWallGroupPush(useScene.getState().nodes, wallIds, drag, previewMint()),
1465
+ )
1466
+ blocked = !!preview.code
1467
+ publish(preview.ghosts, preview.code ? PUSH_REFUSED_MESSAGE : undefined)
1468
+ },
1469
+ commit: () => {
1470
+ end()
1471
+ if (blocked || Math.hypot(drag[0], drag[1]) < 1e-6) return false
1472
+ return commitPushPlan((current) => planWallGroupPush(current, wallIds, drag))
1473
+ },
1474
+ cancel: end,
1475
+ }
1476
+ }
1477
+
969
1478
  /** A node as the push preview draws it: the scene's, with its live override merged. */
970
1479
  function livePreviewNode(id: string): AnyNode | undefined {
971
1480
  const node = useScene.getState().nodes[id as AnyNodeId]
@@ -986,6 +1495,7 @@ export function wallPointAt(wall: WallNode, t: number): Point {
986
1495
  */
987
1496
  export const WALL_PUSH_AFFORDANCE = 'wall-push'
988
1497
 
1498
+ /** A wall's or an invisible wall's side arrow: `wallId` is either. */
989
1499
  export type WallPushArrowPayload = { wallId: string; side: 'a' | 'b' }
990
1500
 
991
1501
  type PlanPointer = (clientX: number, clientY: number) => readonly [number, number] | null
@@ -1001,8 +1511,9 @@ function planAlong(position: Point, outward: Point, toPlan: PlanPointer) {
1001
1511
  }
1002
1512
 
1003
1513
  /**
1004
- * A floor-plan wall arrow: the 3D arrow's handle (`wallPushHandles`), drag
1005
- * (`runWallPushDrag`) and commit, with the pointer read in plan coordinates.
1514
+ * A floor-plan wall arrow: the 3D arrow's handle (`wallPushHandles`, or a
1515
+ * selected invisible wall's `separatorPushHandles`), drag (`runWallPushDrag`)
1516
+ * and commit, with the pointer read in plan coordinates.
1006
1517
  */
1007
1518
  export function runFloorplanWallPush(
1008
1519
  payload: unknown,
@@ -1013,8 +1524,12 @@ export function runFloorplanWallPush(
1013
1524
  const { wallId, side } = (payload ?? {}) as Partial<WallPushArrowPayload>
1014
1525
  const nodes = useScene.getState().nodes
1015
1526
  const wall = wallId ? nodes[wallId as AnyNodeId] : undefined
1016
- if (wall?.type !== 'wall' || !wall.parentId) return null
1017
- const handle = wallPushHandles(wall, nodes).find((h) => h.key === `${wall.id}:${side}`)
1527
+ if ((wall?.type !== 'wall' && wall?.type !== 'separator') || !wall.parentId) return null
1528
+ const handles =
1529
+ wall.type === 'wall'
1530
+ ? wallPushHandles(wall, nodes)
1531
+ : separatorPushHandles(wall, separatorFloorElevation(nodes, wall.id))
1532
+ const handle = handles.find((h) => h.key === `${wall.id}:${side}`)
1018
1533
  if (!handle) return null
1019
1534
  return runFloorplanRoomPush({ handle, levelId: wall.parentId, clientX, clientY, toPlan })
1020
1535
  }