@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
@@ -16,13 +16,20 @@ import {
16
16
  type Point,
17
17
  planWallDivision,
18
18
  resolveMovedWallSupportSlabPatch,
19
+ roomFloorElevation,
20
+ roomOwnPlate,
19
21
  runAsSingleSceneHistoryStep,
22
+ SEPARATOR_JOIN_REACH,
23
+ type SeparatorNode,
24
+ type SeparatorSpan,
20
25
  type StructureNodes,
21
26
  type StructurePlan,
27
+ separatorFloorElevation,
22
28
  setRoomFloorConstruction,
23
29
  setWallGeometry,
24
30
  setZoneEdges,
25
31
  setZoneIntent,
32
+ translateSeparators,
26
33
  upperFloorHeightControl,
27
34
  useLiveNodeOverrides,
28
35
  useScene,
@@ -42,23 +49,37 @@ import { clientToPlan } from './floorplan/plan-coords'
42
49
  import { beginGesture } from './gesture-lifecycle'
43
50
  import { isHistoryShortcut } from './history'
44
51
  import { MEZZANINE_FAILED_MESSAGE, mezzanineConflictMessage } from './mezzanine-messages'
45
- import { roomOwnPlate } from './room-built-on'
46
52
  import {
47
53
  mezzanineEdgeDimensions,
48
54
  type RoomDimension,
55
+ sameRoomDimension,
56
+ separatorEdgeDimensionRooms,
57
+ type WallDimensionFaces,
58
+ type WallSelectionDimension,
49
59
  wallPushDimensionRooms,
50
60
  wallPushDimensions,
61
+ wallSelectionDimensions,
51
62
  } from './room-push-dimensions'
52
63
  import { applyRoomPlan } from './room-structure-commands'
64
+ import { levelSeparatorLines, separatorWallLine } from './separator-lines'
53
65
  import { sfxEmitter } from './sfx-bus'
54
66
  import { type SpatialPointerId, spatialPointerInput } from './spatial-pointer-input'
55
67
  import { isTypingTarget } from './typing-target'
68
+ import {
69
+ nearestWallParallelTarget,
70
+ publishWallParallelGuide,
71
+ type WallParallelTarget,
72
+ wallParallelTargets,
73
+ } from './wall-parallel-snap'
74
+ import { isWallRunFree } from './wall-run-snap'
56
75
 
57
76
  export const ROOM_ELEVATION_DRAG_LABEL = 'room-elevation'
58
77
  /** Scope label of every wall push: a selected wall's side arrow and a room's boundary arrow. */
59
78
  export const WALL_PUSH_DRAG_LABEL = 'wall-push'
60
79
  /** Scope label of a mezzanine edge push (its plate grows or shrinks). */
61
80
  export const MEZZANINE_EDGE_DRAG_LABEL = 'mezzanine-edge'
81
+ /** What a wall push the planner refuses reads. */
82
+ export const PUSH_REFUSED_MESSAGE = "Can't move this wall there"
62
83
 
63
84
  /** A wall a push would create (a moved span piece, a bridge), drawn as a ghost. */
64
85
  export type RoomPushGhost = { start: Point; end: Point; thickness: number; height: number }
@@ -93,6 +114,20 @@ export type RoomHandleDrag =
93
114
  dimensions: RoomDimension[]
94
115
  message?: string
95
116
  }
117
+ | {
118
+ /** Several selected walls moved together, each along its own normal. */
119
+ kind: 'wall-group-push'
120
+ levelId: string
121
+ /** Where a refusal reads: over the moved walls' middle, in level plan coordinates. */
122
+ anchor: Point
123
+ height: number
124
+ /** The lowest moved wall's base, where the ghost pieces stand. */
125
+ base: number
126
+ ghosts: RoomPushGhost[]
127
+ /** Every moved wall's live room widths, as its own push shows them. */
128
+ dimensions: RoomDimension[]
129
+ message?: string
130
+ }
96
131
  | {
97
132
  kind: 'mezzanine-edge'
98
133
  zoneId: string
@@ -113,25 +148,19 @@ export const useRoomHandleDrag = create<{ drag: RoomHandleDrag | null }>(() => (
113
148
 
114
149
  type Nodes = Record<string, AnyNode>
115
150
 
116
- /** The walking surface of a room: its own elevation, else its plate's. */
117
- export function roomFloorElevation(nodes: Nodes, zoneId: string): number {
118
- const zone = nodes[zoneId]
119
- if (zone?.type !== 'zone') return 0
120
- const own = roomOwnPlate(nodes, zoneId)
121
- if (own) return own.elevation
122
- if (typeof zone.floor?.elevation === 'number') return zone.floor.elevation
123
- const plate = Object.values(nodes).find(
124
- (node) => node.type === 'slab' && node.zoneIds?.includes(zoneId),
125
- )
126
- return plate?.type === 'slab' ? (plate.elevation ?? 0.05) : 0.05
127
- }
128
-
129
151
  /**
130
152
  * The stretch of a wall a push moves: `[0, 1]` is the whole wall (its
131
153
  * neighbours follow through the junction planner), anything shorter is split
132
- * off first and only that piece moves.
154
+ * off first and only that piece moves. An edge of invisible walls carries its
155
+ * stretches in `separators` (`wallId` is the first one's): they move as one
156
+ * line, the invisible walls joined to them following (`translateSeparators`).
133
157
  */
134
- export type WallPushSpan = { wallId: string; t0: number; t1: number }
158
+ export type WallPushSpan = {
159
+ wallId: string
160
+ t0: number
161
+ t1: number
162
+ separators?: readonly SeparatorSpan[]
163
+ }
135
164
 
136
165
  /** One push/pull arrow: a selected wall's side arrow or a room's boundary arrow. */
137
166
  export type WallPushHandle = WallPushSpan & {
@@ -166,7 +195,7 @@ function wallPushHandle(
166
195
  const offsets = getWallFaceOffsets(wall)
167
196
  const face = side === 'a' ? offsets.a : -offsets.b
168
197
  const offset = Math.max(face + HANDLE_OFFSET, HANDLE_MIN_OFFSET)
169
- const wallHeight = getWallEffectiveHeightForNodes(wall, nodes)
198
+ const wallHeight = getWallEffectiveHeightForNodes(wall, nodes, 0.5)
170
199
  return {
171
200
  key,
172
201
  wallId: wall.id,
@@ -191,22 +220,27 @@ export function wallPushHandles(wall: WallNode, nodes: Nodes): WallPushHandle[]
191
220
  })
192
221
  }
193
222
 
223
+ type BoundarySpanRef = {
224
+ boundaryId: string
225
+ kind: string
226
+ face: 'a' | 'b'
227
+ t0: number
228
+ t1: number
229
+ }
230
+
194
231
  /**
195
232
  * One push/pull arrow per boundary wall span of the room, outside the wall's
196
233
  * 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).
234
+ * that face sits. `face` is the wall face the room lies on (`a` = front). Its
235
+ * invisible walls get one arrow per straight edge (`separatorEdges`), lying
236
+ * at `floor` (the room's floor height), where their dashed line is drawn.
198
237
  */
199
238
  export function roomPushHandles(
200
239
  nodes: Nodes,
201
- spans: ReadonlyArray<{
202
- boundaryId: string
203
- kind: string
204
- face: 'a' | 'b'
205
- t0: number
206
- t1: number
207
- }>,
240
+ spans: ReadonlyArray<BoundarySpanRef>,
241
+ floor = 0,
208
242
  ): WallPushHandle[] {
209
- return spans.flatMap((span) => {
243
+ const walls = spans.flatMap((span) => {
210
244
  const wall = nodes[span.boundaryId]
211
245
  if (span.kind !== 'wall' || wall?.type !== 'wall') return []
212
246
  const handle = wallPushHandle(
@@ -218,6 +252,137 @@ export function roomPushHandles(
218
252
  )
219
253
  return handle ? [handle] : []
220
254
  })
255
+ const edges = separatorEdges(nodes, spans).map((edge) => {
256
+ const first = edge.members[0]!
257
+ return separatorPushHandle(
258
+ edge,
259
+ edge.outward,
260
+ floor,
261
+ `${first.separatorId}:${edge.face}:${first.t0.toFixed(4)}`,
262
+ )
263
+ })
264
+ return [...walls, ...edges]
265
+ }
266
+
267
+ /** How far outside an invisible wall (m) its push arrow sits, as a mezzanine edge's does. */
268
+ const SEPARATOR_ARROW_OFFSET = 0.3
269
+ /** Its 3D arrow's lift over the floor: where the invisible wall's dashed line is drawn. */
270
+ const SEPARATOR_ARROW_LIFT = 0.02
271
+ /** An invisible-wall edge shorter than this (m) gets no arrow, as a mezzanine edge. */
272
+ const SEPARATOR_EDGE_MIN_LENGTH = 0.2
273
+ /** Stretches this far off each other's line (m) still run along one edge. */
274
+ const EDGE_IN_LINE = 0.01
275
+
276
+ type SeparatorEdge = {
277
+ /** The stretches along the edge, in order. */
278
+ members: SeparatorSpan[]
279
+ start: Point
280
+ end: Point
281
+ /** Away from the room. */
282
+ outward: Point
283
+ face: 'a' | 'b'
284
+ }
285
+
286
+ /**
287
+ * A room's invisible-wall stretches gathered into its straight edges: stretches
288
+ * end to end on one line, the room on the same side, are one edge and push as
289
+ * one (an edge drawn in two pieces, or broken where another one meets it). Ends
290
+ * meet as far as the room graph joins them (`SEPARATOR_JOIN_REACH`).
291
+ */
292
+ function separatorEdges(nodes: Nodes, spans: ReadonlyArray<BoundarySpanRef>): SeparatorEdge[] {
293
+ const pieces = spans.flatMap((span): SeparatorEdge[] => {
294
+ const separator = nodes[span.boundaryId]
295
+ if (span.kind !== 'separator' || separator?.type !== 'separator') return []
296
+ const dx = separator.end[0] - separator.start[0]
297
+ const dz = separator.end[1] - separator.start[1]
298
+ const length = Math.hypot(dx, dz)
299
+ if (length < 1e-6 || span.t1 - span.t0 < WHOLE) return []
300
+ // Face `a` is the +normal side: the arrow points the other way, off the room.
301
+ const sign = span.face === 'a' ? -1 : 1
302
+ const at = (t: number): Point => [separator.start[0] + dx * t, separator.start[1] + dz * t]
303
+ return [
304
+ {
305
+ members: [{ separatorId: separator.id, t0: span.t0, t1: span.t1 }],
306
+ start: at(span.t0),
307
+ end: at(span.t1),
308
+ outward: [(-dz / length) * sign, (dx / length) * sign],
309
+ face: span.face,
310
+ },
311
+ ]
312
+ })
313
+ const across = (edge: SeparatorEdge, [x, z]: Point) =>
314
+ Math.abs((x - edge.start[0]) * edge.outward[0] + (z - edge.start[1]) * edge.outward[1])
315
+ const joins = (a: SeparatorEdge, b: SeparatorEdge) =>
316
+ a.outward[0] * b.outward[0] + a.outward[1] * b.outward[1] > 1 - 1e-4 &&
317
+ across(a, b.start) < EDGE_IN_LINE &&
318
+ across(a, b.end) < EDGE_IN_LINE &&
319
+ [a.start, a.end].some((p) =>
320
+ [b.start, b.end].some((q) => Math.hypot(p[0] - q[0], p[1] - q[1]) <= SEPARATOR_JOIN_REACH),
321
+ )
322
+ let groups: SeparatorEdge[][] = []
323
+ for (const piece of pieces) {
324
+ const touching = groups.filter((group) => group.some((other) => joins(piece, other)))
325
+ groups = [...groups.filter((group) => !touching.includes(group)), [piece, ...touching.flat()]]
326
+ }
327
+ return groups.flatMap((group): SeparatorEdge[] => {
328
+ const { outward, face } = group[0]!
329
+ const dir: Point = [outward[1], -outward[0]]
330
+ const along = ([x, z]: Point) => x * dir[0] + z * dir[1]
331
+ const ends = group
332
+ .flatMap((piece) => [piece.start, piece.end])
333
+ .sort((p, q) => along(p) - along(q))
334
+ const start = ends[0]!
335
+ const end = ends.at(-1)!
336
+ if (Math.hypot(end[0] - start[0], end[1] - start[1]) < SEPARATOR_EDGE_MIN_LENGTH) return []
337
+ const middle = (piece: SeparatorEdge) => along(piece.start) + along(piece.end)
338
+ const members = [...group].sort((p, q) => middle(p) - middle(q)).flatMap((p) => p.members)
339
+ return [{ members, start, end, outward, face }]
340
+ })
341
+ }
342
+
343
+ function separatorPushHandle(
344
+ edge: Pick<SeparatorEdge, 'members' | 'start' | 'end'>,
345
+ outward: Point,
346
+ floor: number,
347
+ key: string,
348
+ ): WallPushHandle {
349
+ const first = edge.members[0]!
350
+ const middle: Point = [(edge.start[0] + edge.end[0]) / 2, (edge.start[1] + edge.end[1]) / 2]
351
+ return {
352
+ key,
353
+ wallId: first.separatorId,
354
+ t0: first.t0,
355
+ t1: first.t1,
356
+ separators: edge.members,
357
+ position: [
358
+ middle[0] + outward[0] * SEPARATOR_ARROW_OFFSET,
359
+ middle[1] + outward[1] * SEPARATOR_ARROW_OFFSET,
360
+ ],
361
+ height: floor + SEPARATOR_ARROW_LIFT,
362
+ outward,
363
+ line: middle[0] * outward[0] + middle[1] * outward[1],
364
+ }
365
+ }
366
+
367
+ /**
368
+ * A selected invisible wall's two side arrows, one off each side, at `floor`:
369
+ * each pushes the whole separator, the ones joined to it following.
370
+ */
371
+ export function separatorPushHandles(separator: SeparatorNode, floor = 0): WallPushHandle[] {
372
+ const dx = separator.end[0] - separator.start[0]
373
+ const dz = separator.end[1] - separator.start[1]
374
+ const length = Math.hypot(dx, dz)
375
+ if (length < 1e-6) return []
376
+ const edge = {
377
+ members: [{ separatorId: separator.id, t0: 0, t1: 1 }],
378
+ start: separator.start,
379
+ end: separator.end,
380
+ }
381
+ return (['a', 'b'] as const).map((side) => {
382
+ const sign = side === 'a' ? 1 : -1
383
+ const outward: Point = [(-dz / length) * sign, (dx / length) * sign]
384
+ return separatorPushHandle(edge, outward, floor, `${separator.id}:${side}`)
385
+ })
221
386
  }
222
387
 
223
388
  /**
@@ -294,7 +459,8 @@ function diffNodes(before: Scratch, after: Scratch): NodeChange[] {
294
459
  * Pushes the room's stretch of a wall `distance` along `outward`. A span that
295
460
  * covers the whole wall moves the wall; a partial span is split off at its
296
461
  * 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.
462
+ * bridged back to the rest by the junction planner. An edge of invisible walls
463
+ * moves as one line (`translateSeparators`). One plan, one undo step.
298
464
  */
299
465
  export function planRoomPush(
300
466
  nodes: StructureNodes,
@@ -303,6 +469,12 @@ export function planRoomPush(
303
469
  distance: number,
304
470
  mintId: (kind: 'zone' | 'wall' | 'separator') => string = generateId,
305
471
  ): StructurePlan {
472
+ if (span.separators?.length)
473
+ return translateSeparators(nodes, {
474
+ spans: span.separators,
475
+ offset: [outward[0] * distance, outward[1] * distance],
476
+ mintId,
477
+ })
306
478
  const wall = nodes[span.wallId]
307
479
  if (wall?.type !== 'wall') throw Error('Select a wall.')
308
480
  const shift = ([x, z]: Point): Point => [x + outward[0] * distance, z + outward[1] * distance]
@@ -388,14 +560,18 @@ function clearPushPreview() {
388
560
  function previewSurfaces(nodes: Nodes, levelId: string, after: Scratch) {
389
561
  if (surfacePreview?.levelId !== levelId || surfacePreview.nodes !== nodes)
390
562
  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
- )
563
+ const level = Object.values(after).filter((node) => node.parentId === levelId)
394
564
  return surfacePreview
395
- .run(walls)
565
+ .run(
566
+ level.filter((node): node is WallNode => node.type === 'wall'),
567
+ level.filter((node): node is SeparatorNode => node.type === 'separator'),
568
+ )
396
569
  .map((patch) => [patch.id as AnyNodeId, patch.data as Partial<AnyNode>] as const)
397
570
  }
398
571
 
572
+ /** The ghost of an invisible wall a push would create (a jog): a thin strip on the floor. */
573
+ const SEPARATOR_GHOST = { thickness: 0.03, height: 0.04 }
574
+
399
575
  /**
400
576
  * Shows the push live: the plan's updates to existing walls — and the room
401
577
  * surfaces reshaped around them — ride `useLiveNodeOverrides` (renderers merge
@@ -410,9 +586,20 @@ export function previewRoomPush(
410
586
  clearPushPreview()
411
587
  if (Math.abs(distance) < 1e-6) return { ghosts: [] }
412
588
  const nodes = useScene.getState().nodes
589
+ return previewPushPlan(nodes, nodes[span.wallId as AnyNodeId]?.parentId, () =>
590
+ planRoomPush(nodes, span, outward, distance, previewMint()),
591
+ )
592
+ }
593
+
594
+ /** Shows a push plan live (`previewRoomPush`); the caller has cleared the last one. */
595
+ function previewPushPlan(
596
+ nodes: Nodes,
597
+ levelId: string | null | undefined,
598
+ planPush: () => StructurePlan,
599
+ ): { code?: string; ghosts: RoomPushGhost[] } {
413
600
  let plan: StructurePlan
414
601
  try {
415
- plan = planRoomPush(nodes, span, outward, distance, previewMint())
602
+ plan = planPush()
416
603
  } catch {
417
604
  return { code: 'invalid-geometry', ghosts: [] }
418
605
  }
@@ -420,25 +607,27 @@ export function previewRoomPush(
420
607
  const updates: Array<readonly [AnyNodeId, Partial<AnyNode>]> = plan.changes.flatMap((change) =>
421
608
  change.op === 'update' ? [[change.id as AnyNodeId, change.data] as const] : [],
422
609
  )
423
- const levelId = nodes[span.wallId as AnyNodeId]?.parentId
424
610
  if (levelId) updates.push(...previewSurfaces(nodes, levelId, applyChanges(nodes, plan.changes)))
425
611
  useLiveNodeOverrides
426
612
  .getState()
427
613
  .setMany(updates.map(([id, data]) => [id, data] as [AnyNodeId, Partial<AnyNode>]))
428
614
  previewIds = updates.map(([id]) => id)
429
615
  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'
616
+ const ghosts = plan.changes.flatMap((change): RoomPushGhost[] => {
617
+ if (change.op !== 'create') return []
618
+ if (change.node.type === 'separator')
619
+ return [{ start: change.node.start, end: change.node.end, ...SEPARATOR_GHOST }]
620
+ return change.node.type === 'wall'
432
621
  ? [
433
622
  {
434
623
  start: change.node.start,
435
624
  end: change.node.end,
436
625
  thickness: change.node.thickness ?? 0.1,
437
- height: getWallEffectiveHeightForNodes(change.node, nodes),
626
+ height: getWallEffectiveHeightForNodes(change.node, nodes, 0),
438
627
  },
439
628
  ]
440
- : [],
441
- )
629
+ : []
630
+ })
442
631
  // The floor plan draws the same pieces through its bridge-ghost layer.
443
632
  if (ghosts.length) {
444
633
  useWallMoveGhosts.getState().setBridges(
@@ -468,9 +657,13 @@ export function endRoomPushPreview() {
468
657
  export function commitRoomPush(span: WallPushSpan, outward: Point, distance: number) {
469
658
  endRoomPushPreview()
470
659
  if (Math.abs(distance) < 1e-6) return true
660
+ return commitPushPlan((nodes) => planRoomPush(nodes, span, outward, distance))
661
+ }
662
+
663
+ function commitPushPlan(planPush: (nodes: StructureNodes) => StructurePlan) {
471
664
  try {
472
665
  return runAsSingleSceneHistoryStep(useScene, () => {
473
- const plan = planRoomPush(useScene.getState().nodes, span, outward, distance)
666
+ const plan = planPush(useScene.getState().nodes)
474
667
  if (!applyRoomPlan(plan)) return false
475
668
  const nodes = useScene.getState().nodes
476
669
  const patches = plan.changes.flatMap((change) => {
@@ -716,12 +909,13 @@ export function cancelRoomHandleDrag() {
716
909
  /**
717
910
  * Runs one handle drag on window listeners, independent of the handle's own
718
911
  * 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
912
+ * the drag's value (`spatial.sample` a controller ray, for a spatial pointer):
913
+ * a distance or a height, or a plan point for an end dragged across the floor.
914
+ * Release commits, Escape / ⌘Z / the gesture owner cancel. Cleanup runs
721
915
  * exactly once whatever happens, and releases only this drag's own scope;
722
916
  * nothing republishes a preview after it.
723
917
  */
724
- export function runRoomHandleDrag({
918
+ export function runRoomHandleDrag<T = number>({
725
919
  label,
726
920
  nodeId,
727
921
  zoneId,
@@ -738,9 +932,9 @@ export function runRoomHandleDrag({
738
932
  zoneId?: string
739
933
  levelId: string
740
934
  requires: string[]
741
- sample: (clientX: number, clientY: number) => number | null
742
- spatial?: { pointerId: SpatialPointerId; sample: (ray: Ray) => number | null }
743
- onValue: (value: number) => void
935
+ sample: (clientX: number, clientY: number) => T | null
936
+ spatial?: { pointerId: SpatialPointerId; sample: (ray: Ray) => T | null }
937
+ onValue: (value: T) => void
744
938
  onCommit: () => void
745
939
  onCancel: () => void
746
940
  }) {
@@ -891,10 +1085,20 @@ export function runWallPushDrag({
891
1085
  spatial?: { pointerId: SpatialPointerId; along: (ray: Ray) => number | null }
892
1086
  }) {
893
1087
  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)
1088
+ const boundary = nodes[handle.wallId as AnyNodeId]
1089
+ // An edge of invisible walls is measured, and lines up, as the one line it moves as.
1090
+ const edge = handle.separators?.length ? separatorEdgeLine(nodes, handle.separators) : null
1091
+ const wall = edge ?? (boundary?.type === 'wall' ? boundary : null)
1092
+ if (!wall) return null
1093
+ const base =
1094
+ wall.type === 'wall'
1095
+ ? getWallBaseElevationForNodes(wall, nodes)
1096
+ : handle.height - SEPARATOR_ARROW_LIFT
897
1097
  const rooms = pushDimensionRooms(nodes, levelId, handle, base)
1098
+ const levelWalls = Object.values(nodes).filter(
1099
+ (node): node is WallNode => node.type === 'wall' && node.parentId === levelId,
1100
+ )
1101
+ const moving = new Set([handle.wallId, ...(handle.separators ?? []).map((s) => s.separatorId)])
898
1102
  let distance = 0
899
1103
  let blocked = false
900
1104
  let ghosts: RoomPushGhost[] = []
@@ -921,15 +1125,49 @@ export function runWallPushDrag({
921
1125
  message,
922
1126
  },
923
1127
  })
924
- const toDistance = (at: number | null) =>
925
- at === null ? null : snapPushDistance(handle.line, at - from)
1128
+ // The pushed wall lines up with the parallel walls on its level
1129
+ // (`wall-parallel-snap`) before the grid; Alt pushes it free. Invisible walls
1130
+ // line up with the walls and with the level's other invisible walls.
1131
+ const lineUpTargets =
1132
+ wall.type === 'wall'
1133
+ ? wallParallelTargets(wall, handle.outward, levelWalls)
1134
+ : wallParallelTargets(separatorWallLine(wall), handle.outward, [
1135
+ ...levelWalls,
1136
+ ...levelSeparatorLines(nodes, levelId, moving),
1137
+ ])
1138
+ let lineUp: WallParallelTarget | null = null
1139
+ let altHeld = false
1140
+ const trackAlt = (event: KeyboardEvent | PointerEvent) => {
1141
+ altHeld = event.altKey
1142
+ }
1143
+ window.addEventListener('pointermove', trackAlt, true)
1144
+ window.addEventListener('keydown', trackAlt, true)
1145
+ window.addEventListener('keyup', trackAlt, true)
1146
+ const stopLineUp = () => {
1147
+ window.removeEventListener('pointermove', trackAlt, true)
1148
+ window.removeEventListener('keydown', trackAlt, true)
1149
+ window.removeEventListener('keyup', trackAlt, true)
1150
+ if (lineUp) publishWallParallelGuide(null)
1151
+ lineUp = null
1152
+ }
1153
+ const toDistance = (at: number | null) => {
1154
+ if (at === null) return null
1155
+ const raw = at - from
1156
+ const next = isWallRunFree({ altKey: altHeld })
1157
+ ? null
1158
+ : nearestWallParallelTarget(lineUpTargets, handle.line + raw)
1159
+ if (next !== lineUp) publishWallParallelGuide(next)
1160
+ lineUp = next
1161
+ if (next) return next.position - handle.line
1162
+ return isWallRunFree({ altKey: altHeld }) ? raw : snapPushDistance(handle.line, raw)
1163
+ }
926
1164
  publish()
927
1165
  return runRoomHandleDrag({
928
1166
  label: WALL_PUSH_DRAG_LABEL,
929
1167
  nodeId: handle.wallId,
930
1168
  zoneId,
931
1169
  levelId,
932
- requires: zoneId ? [zoneId, handle.wallId] : [handle.wallId],
1170
+ requires: zoneId ? [zoneId, ...moving] : [...moving],
933
1171
  sample: (x, y) => toDistance(along(x, y)),
934
1172
  spatial: spatial && {
935
1173
  pointerId: spatial.pointerId,
@@ -942,13 +1180,17 @@ export function runWallPushDrag({
942
1180
  const preview = previewRoomPush(handle, handle.outward, distance)
943
1181
  ghosts = preview.ghosts
944
1182
  blocked = !!preview.code
945
- publish(preview.code ? "Can't move this wall there" : undefined)
1183
+ publish(preview.code ? PUSH_REFUSED_MESSAGE : undefined)
946
1184
  },
947
1185
  onCommit: () => {
1186
+ stopLineUp()
948
1187
  if (blocked) endRoomPushPreview()
949
1188
  else commitRoomPush(handle, handle.outward, distance)
950
1189
  },
951
- onCancel: () => endRoomPushPreview(),
1190
+ onCancel: () => {
1191
+ stopLineUp()
1192
+ endRoomPushPreview()
1193
+ },
952
1194
  })
953
1195
  }
954
1196
 
@@ -960,10 +1202,258 @@ function pushDimensionRooms(nodes: Nodes, levelId: string, span: WallPushSpan, b
960
1202
  const index = getRoomSelectionIndex(levelId)
961
1203
  const records = index.update(nodes)
962
1204
  const topology = index.topology.getLevelTopology(levelId)
963
- return wallPushDimensionRooms(topology?.rooms ?? [], span, (roomId) => {
1205
+ const elevationOf = (roomId: string) => {
964
1206
  const zoneId = records.find((record) => record.id === roomId)?.zoneId
965
1207
  return zoneId ? roomFloorElevation(nodes, zoneId) : base
1208
+ }
1209
+ const edge = span.separators?.length ? separatorEdgeLine(nodes, span.separators) : null
1210
+ return edge && span.separators
1211
+ ? separatorEdgeDimensionRooms(
1212
+ topology?.rooms ?? [],
1213
+ { line: edge, separators: span.separators },
1214
+ (id) => nodes[id],
1215
+ elevationOf,
1216
+ )
1217
+ : wallPushDimensionRooms(topology?.rooms ?? [], span, elevationOf)
1218
+ }
1219
+
1220
+ /**
1221
+ * The one line an edge of invisible walls pushes as: its first separator,
1222
+ * running from the edge's first end to its last.
1223
+ */
1224
+ function separatorEdgeLine(nodes: Nodes, spans: readonly SeparatorSpan[]): SeparatorNode | null {
1225
+ const first = nodes[spans[0]?.separatorId as AnyNodeId]
1226
+ if (first?.type !== 'separator') return null
1227
+ const dx = first.end[0] - first.start[0]
1228
+ const dz = first.end[1] - first.start[1]
1229
+ const length = Math.hypot(dx, dz)
1230
+ if (length < 1e-6) return null
1231
+ const along = ([x, z]: Point) =>
1232
+ ((x - first.start[0]) * dx + (z - first.start[1]) * dz) / (length * length)
1233
+ let lo = Number.POSITIVE_INFINITY
1234
+ let hi = Number.NEGATIVE_INFINITY
1235
+ for (const span of spans) {
1236
+ const separator = nodes[span.separatorId as AnyNodeId]
1237
+ if (separator?.type !== 'separator') return null
1238
+ for (const t of [span.t0, span.t1]) {
1239
+ const u = along([
1240
+ separator.start[0] + (separator.end[0] - separator.start[0]) * t,
1241
+ separator.start[1] + (separator.end[1] - separator.start[1]) * t,
1242
+ ])
1243
+ lo = Math.min(lo, u)
1244
+ hi = Math.max(hi, u)
1245
+ }
1246
+ }
1247
+ const at = (u: number): Point => [first.start[0] + dx * u, first.start[1] + dz * u]
1248
+ return { ...first, start: at(lo), end: at(hi) }
1249
+ }
1250
+
1251
+ /**
1252
+ * A selected wall's temporary dimensions as the scene stands: the room widths
1253
+ * its push shows, before any push, plus the outside size for an exterior wall.
1254
+ */
1255
+ export function selectedWallDimensions(
1256
+ nodes: Nodes,
1257
+ wall: WallNode,
1258
+ faces: WallDimensionFaces,
1259
+ ): WallSelectionDimension[] {
1260
+ const levelId = wall.parentId
1261
+ if (!levelId) return []
1262
+ const base = getWallBaseElevationForNodes(wall, nodes)
1263
+ const walls = Object.values(nodes).filter(
1264
+ (node): node is WallNode => node.type === 'wall' && node.parentId === levelId,
1265
+ )
1266
+ return wallSelectionDimensions({
1267
+ rooms: pushDimensionRooms(nodes, levelId, { wallId: wall.id, t0: 0, t1: 1 }, base),
1268
+ wall,
1269
+ walls,
1270
+ faces,
1271
+ resolve: (id) => nodes[id],
1272
+ baseElevation: base,
1273
+ })
1274
+ }
1275
+
1276
+ /** An end this close to another moved wall is joined to it. */
1277
+ const GROUP_JOIN_REACH = 1e-3
1278
+ /** Walls whose directions cross by less than this (sine) run in line, never meet at a corner. */
1279
+ const GROUP_PARALLEL_SIN = 1e-4
1280
+
1281
+ /** The way a wall's push arrows point: its `a`-side normal at its middle. */
1282
+ function wallPushNormal(wall: WallNode): Point {
1283
+ const frame = getWallCurveFrameAt(wall, 0.5)
1284
+ return [frame.normal.x, frame.normal.y]
1285
+ }
1286
+
1287
+ function wallDirection(wall: WallNode): Point {
1288
+ const dx = wall.end[0] - wall.start[0]
1289
+ const dz = wall.end[1] - wall.start[1]
1290
+ const length = Math.hypot(dx, dz) || 1
1291
+ return [dx / length, dz / length]
1292
+ }
1293
+
1294
+ /** Whether `point` is on `wall`: one of its ends, or along a straight one. */
1295
+ function wallHoldsPoint(wall: WallNode, [x, z]: Point): boolean {
1296
+ const near = ([px, pz]: Point) => Math.hypot(px - x, pz - z) <= GROUP_JOIN_REACH
1297
+ if (near(wall.start) || near(wall.end)) return true
1298
+ if ((wall.curveOffset ?? 0) !== 0) return false
1299
+ const [ux, uz] = wallDirection(wall)
1300
+ const along = (x - wall.start[0]) * ux + (z - wall.start[1]) * uz
1301
+ const length = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
1302
+ return (
1303
+ along > 0 &&
1304
+ along < length &&
1305
+ Math.abs((z - wall.start[1]) * ux - (x - wall.start[0]) * uz) <= GROUP_JOIN_REACH
1306
+ )
1307
+ }
1308
+
1309
+ /**
1310
+ * Moves several walls at once, each as its own push arrow would: along its
1311
+ * normal by `drag` projected on it, so it stays parallel to where it was, the
1312
+ * walls joined to it following through the junction planner. An end on
1313
+ * another moved wall that is not in line with it (a corner or a T between
1314
+ * them) sits on both moved lines, which is where it lands moved by the whole
1315
+ * drag, so they stay joined. Each wall is planned in turn while the others
1316
+ * already stand at their targets, so no moved wall is pulled along as
1317
+ * another's neighbour. One plan, one undo step.
1318
+ */
1319
+ export function planWallGroupPush(
1320
+ nodes: StructureNodes,
1321
+ wallIds: readonly string[],
1322
+ drag: Point,
1323
+ mintId: (kind: 'zone' | 'wall' | 'separator') => string = generateId,
1324
+ ): StructurePlan {
1325
+ const walls = wallIds.flatMap((id) => {
1326
+ const node = nodes[id]
1327
+ return node?.type === 'wall' ? [node] : []
1328
+ })
1329
+ const crosses = (a: WallNode, b: WallNode) => {
1330
+ const [ux, uz] = wallDirection(a)
1331
+ const [vx, vz] = wallDirection(b)
1332
+ return Math.abs(ux * vz - uz * vx) > GROUP_PARALLEL_SIN
1333
+ }
1334
+ let scratch: Scratch = { ...nodes }
1335
+ for (const wall of walls) {
1336
+ const [nx, nz] = wallPushNormal(wall)
1337
+ const distance = drag[0] * nx + drag[1] * nz
1338
+ const move = (point: Point): Point =>
1339
+ walls.some((other) => other !== wall && crosses(wall, other) && wallHoldsPoint(other, point))
1340
+ ? [point[0] + drag[0], point[1] + drag[1]]
1341
+ : [point[0] + nx * distance, point[1] + nz * distance]
1342
+ scratch[wall.id] = { ...wall, start: move(wall.start), end: move(wall.end) }
1343
+ }
1344
+ for (const wall of walls) {
1345
+ const target = scratch[wall.id]
1346
+ if (target?.type !== 'wall') continue
1347
+ scratch[wall.id] = { ...target, start: wall.start, end: wall.end }
1348
+ const moved = setWallGeometry(scratch, {
1349
+ wallId: wall.id,
1350
+ start: target.start,
1351
+ end: target.end,
1352
+ mintId,
1353
+ })
1354
+ if (moved.conflicts?.length) return moved
1355
+ scratch = applyChanges(scratch, moved.changes)
1356
+ }
1357
+ return { changes: diffNodes(nodes, scratch) }
1358
+ }
1359
+
1360
+ /** A multi-wall push in flight (`beginWallGroupPush`). */
1361
+ export type WallGroupPush = {
1362
+ /** Previews the walls moved by the level-plan drag. */
1363
+ preview: (drag: Point) => void
1364
+ /** Writes the last preview as one undo step; false when nothing was written. */
1365
+ commit: () => boolean
1366
+ /** Drops the preview; the scene is untouched. */
1367
+ cancel: () => void
1368
+ }
1369
+
1370
+ /**
1371
+ * The live side of `planWallGroupPush`, for a group move whose selection is
1372
+ * walls only: the walls, their neighbours and the room surfaces preview
1373
+ * through overrides, created pieces as ghosts, and each wall's room widths
1374
+ * show as its own push shows them (`useRoomHandleDrag`, kind
1375
+ * `wall-group-push`). The caller owns the gesture: its pointer, keys and scope.
1376
+ */
1377
+ export function beginWallGroupPush(wallIds: readonly string[], levelId: string): WallGroupPush {
1378
+ const nodes = useScene.getState().nodes
1379
+ const measured = wallIds.flatMap((id) => {
1380
+ const wall = nodes[id as AnyNodeId]
1381
+ if (wall?.type !== 'wall') return []
1382
+ const base = getWallBaseElevationForNodes(wall, nodes)
1383
+ return [
1384
+ {
1385
+ wall,
1386
+ base,
1387
+ top: base + getWallEffectiveHeightForNodes(wall, nodes, 0),
1388
+ normal: wallPushNormal(wall),
1389
+ rooms: pushDimensionRooms(nodes, levelId, { wallId: wall.id, t0: 0, t1: 1 }, base),
1390
+ },
1391
+ ]
966
1392
  })
1393
+ const middle: Point = [0, 0]
1394
+ for (const { wall } of measured) {
1395
+ middle[0] += (wall.start[0] + wall.end[0]) / 2 / measured.length
1396
+ middle[1] += (wall.start[1] + wall.end[1]) / 2 / measured.length
1397
+ }
1398
+ let drag: Point = [0, 0]
1399
+ let blocked = false
1400
+ let shown = false
1401
+ const dimensions = () => {
1402
+ const kept: RoomDimension[] = []
1403
+ for (const { wall, normal, rooms } of measured) {
1404
+ const distance = drag[0] * normal[0] + drag[1] * normal[1]
1405
+ // Two moved walls across one room both measure it: the width shows once.
1406
+ for (const dimension of wallPushDimensions({
1407
+ rooms,
1408
+ wall,
1409
+ outward: normal,
1410
+ distance,
1411
+ resolve: livePreviewNode,
1412
+ }))
1413
+ if (!kept.some((other) => sameRoomDimension(other, dimension)))
1414
+ kept.push({ ...dimension, key: `${wall.id}:${dimension.key}` })
1415
+ }
1416
+ return kept
1417
+ }
1418
+ const publish = (ghosts: RoomPushGhost[], message?: string) =>
1419
+ useRoomHandleDrag.setState({
1420
+ drag: {
1421
+ kind: 'wall-group-push',
1422
+ levelId,
1423
+ anchor: [middle[0] + drag[0], middle[1] + drag[1]],
1424
+ height: Math.max(...measured.map(({ top }) => top)),
1425
+ base: Math.min(...measured.map(({ base }) => base)),
1426
+ ghosts,
1427
+ dimensions: dimensions(),
1428
+ message,
1429
+ },
1430
+ })
1431
+ const end = () => {
1432
+ endRoomPushPreview()
1433
+ useRoomHandleDrag.setState({ drag: null })
1434
+ }
1435
+ return {
1436
+ preview: (next) => {
1437
+ if (shown && next[0] === drag[0] && next[1] === drag[1]) return
1438
+ shown = true
1439
+ drag = [next[0], next[1]]
1440
+ clearPushPreview()
1441
+ const preview =
1442
+ Math.hypot(drag[0], drag[1]) < 1e-6
1443
+ ? { ghosts: [] }
1444
+ : previewPushPlan(useScene.getState().nodes, levelId, () =>
1445
+ planWallGroupPush(useScene.getState().nodes, wallIds, drag, previewMint()),
1446
+ )
1447
+ blocked = !!preview.code
1448
+ publish(preview.ghosts, preview.code ? PUSH_REFUSED_MESSAGE : undefined)
1449
+ },
1450
+ commit: () => {
1451
+ end()
1452
+ if (blocked || Math.hypot(drag[0], drag[1]) < 1e-6) return false
1453
+ return commitPushPlan((current) => planWallGroupPush(current, wallIds, drag))
1454
+ },
1455
+ cancel: end,
1456
+ }
967
1457
  }
968
1458
 
969
1459
  /** A node as the push preview draws it: the scene's, with its live override merged. */
@@ -986,6 +1476,7 @@ export function wallPointAt(wall: WallNode, t: number): Point {
986
1476
  */
987
1477
  export const WALL_PUSH_AFFORDANCE = 'wall-push'
988
1478
 
1479
+ /** A wall's or an invisible wall's side arrow: `wallId` is either. */
989
1480
  export type WallPushArrowPayload = { wallId: string; side: 'a' | 'b' }
990
1481
 
991
1482
  type PlanPointer = (clientX: number, clientY: number) => readonly [number, number] | null
@@ -1001,8 +1492,9 @@ function planAlong(position: Point, outward: Point, toPlan: PlanPointer) {
1001
1492
  }
1002
1493
 
1003
1494
  /**
1004
- * A floor-plan wall arrow: the 3D arrow's handle (`wallPushHandles`), drag
1005
- * (`runWallPushDrag`) and commit, with the pointer read in plan coordinates.
1495
+ * A floor-plan wall arrow: the 3D arrow's handle (`wallPushHandles`, or a
1496
+ * selected invisible wall's `separatorPushHandles`), drag (`runWallPushDrag`)
1497
+ * and commit, with the pointer read in plan coordinates.
1006
1498
  */
1007
1499
  export function runFloorplanWallPush(
1008
1500
  payload: unknown,
@@ -1013,8 +1505,12 @@ export function runFloorplanWallPush(
1013
1505
  const { wallId, side } = (payload ?? {}) as Partial<WallPushArrowPayload>
1014
1506
  const nodes = useScene.getState().nodes
1015
1507
  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}`)
1508
+ if ((wall?.type !== 'wall' && wall?.type !== 'separator') || !wall.parentId) return null
1509
+ const handles =
1510
+ wall.type === 'wall'
1511
+ ? wallPushHandles(wall, nodes)
1512
+ : separatorPushHandles(wall, separatorFloorElevation(nodes, wall.id))
1513
+ const handle = handles.find((h) => h.key === `${wall.id}:${side}`)
1018
1514
  if (!handle) return null
1019
1515
  return runFloorplanRoomPush({ handle, levelId: wall.parentId, clientX, clientY, toPlan })
1020
1516
  }