@pascal-app/editor 1.0.3 → 1.0.5

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 (624) hide show
  1. package/README.md +106 -0
  2. package/package.json +42 -38
  3. package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
  4. package/src/components/editor/action-menu-button.tsx +83 -0
  5. package/src/components/editor/bake-thumbnail.tsx +307 -5
  6. package/src/components/editor/capture-level-picker.tsx +72 -0
  7. package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
  8. package/src/components/editor/construction-reveal-audio.tsx +40 -0
  9. package/src/components/editor/custom-camera-controls.tsx +275 -57
  10. package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
  11. package/src/components/editor/duplicate-node.ts +244 -0
  12. package/src/components/editor/editor-layout-mobile.tsx +37 -26
  13. package/src/components/editor/editor-layout-v2.tsx +132 -59
  14. package/src/components/editor/export-manager.tsx +47 -26
  15. package/src/components/editor/first-person/build-collider-world.test.ts +320 -0
  16. package/src/components/editor/first-person/build-collider-world.ts +28 -7
  17. package/src/components/editor/first-person/ground-holes.test.ts +128 -0
  18. package/src/components/editor/first-person/ground-holes.ts +130 -0
  19. package/src/components/editor/first-person/mechanism-targets.test.ts +162 -0
  20. package/src/components/editor/first-person/mechanism-targets.ts +88 -0
  21. package/src/components/editor/first-person-controls.tsx +220 -9
  22. package/src/components/editor/floating-action-menu.tsx +63 -207
  23. package/src/components/editor/floor-aware-camera-controls.test.ts +466 -0
  24. package/src/components/editor/floor-aware-camera-controls.ts +324 -0
  25. package/src/components/editor/floor-openings-overlay.tsx +243 -0
  26. package/src/components/editor/floor-region-controls.tsx +846 -0
  27. package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
  28. package/src/components/editor/floor-surface.test.ts +165 -0
  29. package/src/components/editor/floor-surface.ts +182 -0
  30. package/src/components/editor/floorplan-background-selection.test.ts +11 -2
  31. package/src/components/editor/floorplan-background-selection.ts +1 -3
  32. package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
  33. package/src/components/editor/floorplan-panel.tsx +414 -363
  34. package/src/components/editor/follow-pascal.test.tsx +912 -0
  35. package/src/components/editor/follow-pascal.tsx +354 -0
  36. package/src/components/editor/footprint-height-handle.tsx +204 -0
  37. package/src/components/editor/group-actions.ts +56 -2
  38. package/src/components/editor/group-move-3d.ts +2 -0
  39. package/src/components/editor/group-rotate-handle.tsx +2 -0
  40. package/src/components/editor/group-transform-shared.test.ts +460 -0
  41. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  42. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  43. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  44. package/src/components/editor/handles/use-handle-drag.ts +269 -197
  45. package/src/components/editor/index.tsx +409 -197
  46. package/src/components/editor/join-walls-pill.tsx +44 -0
  47. package/src/components/editor/node-action-menu.tsx +68 -70
  48. package/src/components/editor/node-arrow-handles.tsx +25 -6
  49. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  50. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  51. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  52. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  53. package/src/components/editor/registry-action-contributions.tsx +80 -1
  54. package/src/components/editor/riser-diagram-panel.tsx +14 -7
  55. package/src/components/editor/room-controls.tsx +847 -0
  56. package/src/components/editor/room-floor-highlight.tsx +128 -0
  57. package/src/components/editor/room-handles-2d.tsx +354 -0
  58. package/src/components/editor/room-handles.tsx +675 -0
  59. package/src/components/editor/room-highlight.tsx +233 -0
  60. package/src/components/editor/room-labels.test.ts +850 -0
  61. package/src/components/editor/room-labels.tsx +1890 -0
  62. package/src/components/editor/room-transform-ghost.tsx +256 -0
  63. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  64. package/src/components/editor/selection-manager.tsx +474 -419
  65. package/src/components/editor/slab-hole-highlights.tsx +5 -2
  66. package/src/components/editor/snapshot-capture-overlay.tsx +7 -2
  67. package/src/components/editor/snapshot-capture.test.ts +119 -60
  68. package/src/components/editor/snapshot-capture.ts +36 -5
  69. package/src/components/editor/thumbnail-generator.test.ts +654 -0
  70. package/src/components/editor/thumbnail-generator.tsx +1103 -223
  71. package/src/components/editor/use-floorplan-background-placement.ts +34 -12
  72. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  73. package/src/components/editor/views/host-view.tsx +61 -0
  74. package/src/components/editor/views/plugins-view.tsx +663 -0
  75. package/src/components/editor/views/scene-region.tsx +59 -0
  76. package/src/components/editor/views/use-editor-views.ts +57 -0
  77. package/src/components/editor/views/view-bar.test.tsx +54 -0
  78. package/src/components/editor/views/view-bar.tsx +343 -0
  79. package/src/components/editor/wall-justification.test.ts +140 -0
  80. package/src/components/editor/wall-move-side-handles.tsx +43 -142
  81. package/src/components/editor/wall-push-arrow.tsx +92 -0
  82. package/src/components/editor/wall-region-handles.tsx +509 -0
  83. package/src/components/editor/wall-snap-beacon-layer.tsx +8 -4
  84. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  85. package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
  86. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  87. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  88. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
  89. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  90. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +116 -106
  91. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  92. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  93. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  94. package/src/components/editor-2d/renderers/{floorplan-draft-wall-measurement.tsx → floorplan-draft-measurement.tsx} +8 -8
  95. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  96. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +38 -8
  97. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +319 -233
  98. package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
  99. package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
  100. package/src/components/editor-2d/svg-paths.ts +12 -15
  101. package/src/components/point-ask/bubble.tsx +538 -0
  102. package/src/components/point-ask/hover-chip.tsx +91 -0
  103. package/src/components/point-ask/mode-pill.tsx +92 -0
  104. package/src/components/point-ask/optional-motion.ts +22 -0
  105. package/src/components/point-ask/outline-corners.tsx +93 -0
  106. package/src/components/point-ask/pins.tsx +711 -0
  107. package/src/components/point-ask/point-ask-layer.tsx +83 -0
  108. package/src/components/point-ask/point-ask-styles.ts +105 -0
  109. package/src/components/point-ask/region-marquee-styles.ts +15 -0
  110. package/src/components/point-ask/region-marquee.tsx +64 -0
  111. package/src/components/procedural-items/inspector.tsx +40 -15
  112. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
  113. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  114. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  115. package/src/components/systems/roof/roof-edit-system.tsx +1 -0
  116. package/src/components/systems/zone/zone-system.test.ts +47 -0
  117. package/src/components/systems/zone/zone-system.tsx +29 -68
  118. package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
  119. package/src/components/tools/distribution/connection-compatibility.ts +43 -0
  120. package/src/components/tools/distribution/distribution-public.ts +9 -0
  121. package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
  122. package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
  123. package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
  124. package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
  125. package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
  126. package/src/components/tools/distribution/draw-alignment.ts +35 -0
  127. package/src/components/tools/distribution/ports.test.ts +56 -0
  128. package/src/components/tools/distribution/ports.ts +378 -0
  129. package/src/components/tools/distribution/run-cursor.test.ts +135 -0
  130. package/src/components/tools/distribution/run-cursor.ts +36 -0
  131. package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
  132. package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
  133. package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
  134. package/src/components/tools/distribution/run-port-snap.ts +25 -0
  135. package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
  136. package/src/components/tools/distribution/run-screen-direction.ts +30 -0
  137. package/src/components/tools/elevator/elevator-tool.tsx +3 -6
  138. package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
  139. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
  140. package/src/components/tools/item/placement-history.test.tsx +512 -0
  141. package/src/components/tools/item/placement-strategies.test.ts +46 -1
  142. package/src/components/tools/item/placement-strategies.ts +11 -10
  143. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
  144. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  145. package/src/components/tools/item/use-draft-node.ts +166 -95
  146. package/src/components/tools/item/use-placement-coordinator.tsx +148 -95
  147. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  148. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  149. package/src/components/tools/registry/item-surface-move.test.ts +62 -63
  150. package/src/components/tools/registry/item-surface-move.ts +2 -0
  151. package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
  152. package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
  153. package/src/components/tools/registry/move-registry-node-tool.tsx +66 -30
  154. package/src/components/tools/select/box-select-tool.tsx +2 -12
  155. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  156. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  157. package/src/components/tools/select/select-candidates.test.ts +37 -4
  158. package/src/components/tools/select/select-candidates.ts +15 -11
  159. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  160. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  161. package/src/components/tools/tool-manager.tsx +59 -24
  162. package/src/components/tools/wall/wall-drafting.test.ts +175 -47
  163. package/src/components/tools/zone/zone-tool.tsx +4 -12
  164. package/src/components/ui/action-menu/action-button.tsx +9 -1
  165. package/src/components/ui/action-menu/camera-actions.tsx +9 -4
  166. package/src/components/ui/action-menu/control-modes.tsx +15 -42
  167. package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
  168. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
  169. package/src/components/ui/action-menu/index.tsx +56 -33
  170. package/src/components/ui/action-menu/measurement-control.tsx +14 -19
  171. package/src/components/ui/action-menu/placement.ts +20 -0
  172. package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
  173. package/src/components/ui/action-menu/view-toggles.tsx +47 -42
  174. package/src/components/ui/build-panel/build-panel.tsx +242 -0
  175. package/src/components/ui/command-palette/editor-commands.tsx +7 -42
  176. package/src/components/ui/command-palette/index.tsx +50 -39
  177. package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
  178. package/src/components/ui/controls/action-button.tsx +5 -2
  179. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  180. package/src/components/ui/controls/material-paint-panel.tsx +97 -26
  181. package/src/components/ui/controls/material-picker.tsx +4 -1
  182. package/src/components/ui/controls/metric-control.tsx +1 -1
  183. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  184. package/src/components/ui/controls/segmented-control.tsx +21 -8
  185. package/src/components/ui/controls/select-control.tsx +65 -0
  186. package/src/components/ui/controls/slider-control.tsx +1 -1
  187. package/src/components/ui/controls/toggle-control.tsx +3 -3
  188. package/src/components/ui/controls/tool-options-panel.tsx +1 -1
  189. package/src/components/ui/floating-level-selector.tsx +159 -59
  190. package/src/components/ui/helpers/building-helper.tsx +4 -1
  191. package/src/components/ui/helpers/contextual-helper-panel.tsx +181 -72
  192. package/src/components/ui/helpers/helper-manager.test.tsx +121 -0
  193. package/src/components/ui/helpers/helper-manager.tsx +331 -14
  194. package/src/components/ui/helpers/item-helper.tsx +17 -1
  195. package/src/components/ui/helpers/registered-tool-helper.tsx +8 -1
  196. package/src/components/ui/icon-ref.tsx +45 -1
  197. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  198. package/src/components/ui/node-icon-image.tsx +16 -0
  199. package/src/components/ui/panels/collections/collections-popover.tsx +28 -22
  200. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  201. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  202. package/src/components/ui/panels/mobile-panel-sheet.tsx +4 -4
  203. package/src/components/ui/panels/mobile-selection-bar.tsx +8 -5
  204. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  205. package/src/components/ui/panels/multi-field-value.ts +6 -3
  206. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  207. package/src/components/ui/panels/multi-selection-panel.tsx +161 -5
  208. package/src/components/ui/panels/node-display.ts +8 -3
  209. package/src/components/ui/panels/panel-manager.tsx +103 -68
  210. package/src/components/ui/panels/panel-wrapper.tsx +130 -62
  211. package/src/components/ui/panels/parametric-field-control.tsx +3 -3
  212. package/src/components/ui/panels/parametric-inspector.tsx +7 -7
  213. package/src/components/ui/panels/reference-panel.tsx +2 -3
  214. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  215. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  216. package/src/components/ui/panels/room-name-options.ts +47 -0
  217. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  218. package/src/components/ui/panels/room-panel.tsx +790 -0
  219. package/src/components/ui/panels/shape-choice.tsx +58 -0
  220. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  221. package/src/components/ui/primitives/badge.tsx +8 -0
  222. package/src/components/ui/primitives/color-dot.tsx +36 -24
  223. package/src/components/ui/primitives/sidebar.tsx +3 -1
  224. package/src/components/ui/right-stack.test.ts +29 -0
  225. package/src/components/ui/right-stack.tsx +164 -0
  226. package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
  227. package/src/components/ui/sidebar/icon-rail.tsx +8 -3
  228. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  229. package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
  230. package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
  231. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
  232. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
  233. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
  234. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +138 -27
  235. package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
  236. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +5 -5
  237. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
  238. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
  239. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  240. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  241. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  242. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  243. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  244. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  245. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  246. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  247. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  248. package/src/components/ui/sidebar/panels/site-panel/index.tsx +181 -223
  249. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  250. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  251. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  252. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  253. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  254. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  255. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  256. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  257. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  258. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  259. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -37
  260. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  261. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  262. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  263. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  264. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -4
  265. package/src/components/ui/sidebar/tab-bar.tsx +14 -4
  266. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  267. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  268. package/src/components/viewer/floorplan-preview.tsx +5 -6
  269. package/src/components/viewer/viewer-controls-bar.tsx +18 -6
  270. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  271. package/src/components/viewer/viewer-rooms.tsx +191 -0
  272. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  273. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  274. package/src/components/viewer/viewer-stage.tsx +2 -2
  275. package/src/components/viewer-zone-system.tsx +3 -8
  276. package/src/hooks/use-auto-save.integration.test.tsx +215 -43
  277. package/src/hooks/use-auto-save.ts +60 -9
  278. package/src/hooks/use-ceiling-events.test.ts +123 -118
  279. package/src/hooks/use-grid-events.test.ts +105 -105
  280. package/src/hooks/use-keyboard.test.ts +243 -19
  281. package/src/hooks/use-keyboard.ts +118 -83
  282. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  283. package/src/hooks/use-open-wall-ends.ts +238 -0
  284. package/src/hooks/use-placement-notice.test.ts +71 -0
  285. package/src/hooks/use-placement-notice.ts +73 -0
  286. package/src/hooks/use-selected-room.ts +143 -0
  287. package/src/index.tsx +312 -58
  288. package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
  289. package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
  290. package/src/lib/add-storey.test.ts +177 -0
  291. package/src/lib/add-storey.ts +59 -0
  292. package/src/lib/below-grade.test.tsx +171 -0
  293. package/src/lib/below-grade.ts +133 -0
  294. package/src/lib/camera-facing.test.ts +24 -0
  295. package/src/lib/camera-facing.ts +24 -0
  296. package/src/lib/ceiling-edit-session.test.ts +321 -0
  297. package/src/lib/ceiling-edit-session.ts +206 -0
  298. package/src/lib/ceiling-region-session.test.ts +179 -0
  299. package/src/lib/continuation.test.ts +19 -1
  300. package/src/lib/continuation.ts +25 -4
  301. package/src/lib/direct-manipulation.ts +2 -0
  302. package/src/lib/door-interaction.ts +14 -0
  303. package/src/lib/download-project-file.ts +17 -0
  304. package/src/lib/editor-level-display.test.ts +96 -0
  305. package/src/lib/editor-level-display.ts +55 -0
  306. package/src/lib/editor-views.ts +178 -0
  307. package/src/lib/export-geometry-wait.test.ts +90 -0
  308. package/src/lib/export-texture-utils.ts +2 -4
  309. package/src/lib/floor-edit-session.ts +153 -0
  310. package/src/lib/floor-footprints.test.ts +563 -0
  311. package/src/lib/floor-footprints.ts +395 -0
  312. package/src/lib/floor-foundation-support.test.ts +284 -0
  313. package/src/lib/floor-opening-draft.ts +218 -0
  314. package/src/lib/floor-openings.test.ts +216 -0
  315. package/src/lib/floor-region-geometry.ts +191 -0
  316. package/src/lib/floor-region-session.test.ts +485 -0
  317. package/src/lib/floor-region-session.ts +589 -0
  318. package/src/lib/floor-region-snap.ts +101 -0
  319. package/src/lib/floor-step-hover.test.ts +32 -0
  320. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  321. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  322. package/src/lib/floorplan/dimension-drive.ts +479 -0
  323. package/src/lib/floorplan/floorplan-export.test.ts +12 -8
  324. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  325. package/src/lib/floorplan/floorplan-extension.ts +32 -0
  326. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  327. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  328. package/src/lib/floorplan/floorplan-pdfkit-document.ts +363 -27
  329. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +177 -8
  330. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +60 -25
  331. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  332. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  333. package/src/lib/floorplan/geometry.ts +13 -6
  334. package/src/lib/floorplan/index.ts +0 -9
  335. package/src/lib/floorplan/items.ts +5 -2
  336. package/src/lib/floorplan/marks.test.ts +213 -0
  337. package/src/lib/floorplan/marks.ts +238 -0
  338. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  339. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  340. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  341. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  342. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  343. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  344. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  345. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  346. package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
  347. package/src/lib/floorplan/schedules.test.ts +189 -0
  348. package/src/lib/floorplan/schedules.ts +339 -0
  349. package/src/lib/floorplan/sheet-export.test.ts +124 -0
  350. package/src/lib/floorplan/sheet-export.ts +318 -0
  351. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +768 -0
  352. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  353. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  354. package/src/lib/floorplan/site-plan/coverage.ts +230 -0
  355. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  356. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  357. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  358. package/src/lib/floorplan/site-plan/front-edge.ts +157 -0
  359. package/src/lib/floorplan/site-plan/geometry.test.ts +269 -0
  360. package/src/lib/floorplan/site-plan/geometry.ts +142 -0
  361. package/src/lib/floorplan/site-plan/index.ts +55 -0
  362. package/src/lib/floorplan/site-plan/site-annotations.test.ts +483 -0
  363. package/src/lib/floorplan/site-plan/site-annotations.ts +1084 -0
  364. package/src/lib/floorplan/site-plan/site-parts.ts +287 -0
  365. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  366. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  367. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  368. package/src/lib/floorplan/site-plan/yards.test.ts +70 -0
  369. package/src/lib/floorplan/types.ts +1 -27
  370. package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
  371. package/src/lib/follow-pascal-exterior.test.ts +171 -0
  372. package/src/lib/follow-pascal-exterior.ts +39 -0
  373. package/src/lib/follow-pascal-presentation.test.ts +203 -0
  374. package/src/lib/follow-pascal-presentation.ts +129 -0
  375. package/src/lib/follow-pascal-sight.ts +84 -0
  376. package/src/lib/follow-pascal-targets.test.ts +406 -0
  377. package/src/lib/follow-pascal-targets.ts +198 -0
  378. package/src/lib/follow-pascal.test.ts +350 -0
  379. package/src/lib/follow-pascal.ts +284 -0
  380. package/src/lib/fresh-planar-placement.test.ts +148 -0
  381. package/src/lib/fresh-planar-placement.ts +114 -37
  382. package/src/lib/geometry-script/author.ts +157 -0
  383. package/src/lib/geometry-script/client.ts +159 -0
  384. package/src/lib/geometry-script/finish-scripts.ts +127 -0
  385. package/src/lib/geometry-script/protocol.ts +13 -0
  386. package/src/lib/gesture-lifecycle.test.ts +595 -0
  387. package/src/lib/gesture-lifecycle.ts +290 -0
  388. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  389. package/src/lib/glb-export.test.ts +636 -126
  390. package/src/lib/glb-export.ts +241 -120
  391. package/src/lib/history.test.ts +18 -7
  392. package/src/lib/history.ts +7 -0
  393. package/src/lib/hud-title.ts +109 -0
  394. package/src/lib/hydration-gated-storage.ts +49 -0
  395. package/src/lib/ifc-export.test.ts +174 -0
  396. package/src/lib/ifc-export.ts +201 -0
  397. package/src/lib/ifc-parts.ts +15 -0
  398. package/src/lib/inspector-expanded.test.ts +44 -0
  399. package/src/lib/inspector-expanded.ts +36 -0
  400. package/src/lib/interaction/scope.ts +22 -0
  401. package/src/lib/keyboard-pan.test.ts +129 -7
  402. package/src/lib/keyboard-pan.ts +161 -12
  403. package/src/lib/level-selection.test.ts +57 -0
  404. package/src/lib/level-selection.ts +16 -1
  405. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  406. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  407. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  408. package/src/lib/lot/clean-ring.test.ts +187 -0
  409. package/src/lib/lot/clean-ring.ts +217 -0
  410. package/src/lib/lot/dossier.test.ts +202 -0
  411. package/src/lib/lot/dossier.ts +498 -0
  412. package/src/lib/lot/drop-in.test.ts +326 -0
  413. package/src/lib/lot/drop-in.ts +392 -0
  414. package/src/lib/lot/index.ts +43 -0
  415. package/src/lib/lot/lot-patch.test.ts +340 -0
  416. package/src/lib/lot/lot-patch.ts +394 -0
  417. package/src/lib/lot/parcel-provider.ts +43 -0
  418. package/src/lib/lot/terrain.test.ts +161 -0
  419. package/src/lib/lot/terrain.ts +266 -0
  420. package/src/lib/material-catalog-model.ts +3 -1
  421. package/src/lib/material-paint-panel-model.ts +9 -5
  422. package/src/lib/material-paint.ts +4 -0
  423. package/src/lib/measurement-kind.test.ts +6 -8
  424. package/src/lib/measurements.test.ts +23 -0
  425. package/src/lib/mezzanine-actions.test.ts +342 -0
  426. package/src/lib/mezzanine-draft.test.ts +501 -0
  427. package/src/lib/mezzanine-draft.ts +278 -0
  428. package/src/lib/mezzanine-messages.ts +26 -0
  429. package/src/lib/mezzanine-selection.test.ts +207 -0
  430. package/src/lib/mezzanine-stairs.ts +46 -0
  431. package/src/lib/model-export.ts +3 -1
  432. package/src/lib/node-action-movement.ts +17 -6
  433. package/src/lib/node-display-label.ts +37 -0
  434. package/src/lib/orbit-floor-clearance.test.ts +201 -0
  435. package/src/lib/orbit-floor-clearance.ts +154 -0
  436. package/src/lib/paint-commit-route.test.ts +37 -0
  437. package/src/lib/paint-commit-route.ts +17 -0
  438. package/src/lib/paint-eyedropper.test.tsx +165 -0
  439. package/src/lib/paint-eyedropper.ts +99 -0
  440. package/src/lib/paint-pass-through.test.ts +122 -0
  441. package/src/lib/paint-pass-through.ts +78 -0
  442. package/src/lib/paint-region-hover.ts +25 -0
  443. package/src/lib/paint-region-mode.ts +154 -0
  444. package/src/lib/paint-regions.test.ts +166 -0
  445. package/src/lib/paint-regions.ts +192 -0
  446. package/src/lib/paint-scope.test.ts +317 -35
  447. package/src/lib/paint-scope.ts +267 -25
  448. package/src/lib/panel-rows.ts +21 -0
  449. package/src/lib/parametric-node-update.ts +16 -7
  450. package/src/lib/plate-paint-affected.test.ts +300 -0
  451. package/src/lib/plate-paint-affected.ts +177 -0
  452. package/src/lib/plugin-chat.ts +65 -0
  453. package/src/lib/plugin-panels.test.ts +25 -0
  454. package/src/lib/plugin-panels.ts +94 -0
  455. package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
  456. package/src/lib/point-ask/__fixtures__/house.ts +81 -0
  457. package/src/lib/point-ask/ask-created.test.ts +195 -0
  458. package/src/lib/point-ask/ask-flow.test.ts +380 -0
  459. package/src/lib/point-ask/build-context.test.ts +173 -0
  460. package/src/lib/point-ask/build-context.ts +94 -0
  461. package/src/lib/point-ask/capture-crop.ts +129 -0
  462. package/src/lib/point-ask/choreography.ts +69 -0
  463. package/src/lib/point-ask/compare.test.ts +145 -0
  464. package/src/lib/point-ask/compare.ts +73 -0
  465. package/src/lib/point-ask/describe-target.test.ts +165 -0
  466. package/src/lib/point-ask/describe-target.ts +234 -0
  467. package/src/lib/point-ask/dictation.ts +79 -0
  468. package/src/lib/point-ask/edge-marker.test.ts +61 -0
  469. package/src/lib/point-ask/edge-marker.ts +27 -0
  470. package/src/lib/point-ask/frame-loop.ts +25 -0
  471. package/src/lib/point-ask/history-marks.test.ts +94 -0
  472. package/src/lib/point-ask/history-marks.ts +39 -0
  473. package/src/lib/point-ask/host-api.ts +32 -0
  474. package/src/lib/point-ask/outline-box.test.ts +30 -0
  475. package/src/lib/point-ask/outline-box.ts +11 -0
  476. package/src/lib/point-ask/overlay-metrics.ts +6 -0
  477. package/src/lib/point-ask/pick.test.ts +220 -0
  478. package/src/lib/point-ask/pick.ts +121 -0
  479. package/src/lib/point-ask/pin-machine.test.ts +494 -0
  480. package/src/lib/point-ask/pin-machine.ts +217 -0
  481. package/src/lib/point-ask/placement.test.ts +203 -0
  482. package/src/lib/point-ask/placement.ts +141 -0
  483. package/src/lib/point-ask/pointer-tracker.ts +34 -0
  484. package/src/lib/point-ask/projector.ts +83 -0
  485. package/src/lib/point-ask/region.test.ts +157 -0
  486. package/src/lib/point-ask/region.ts +136 -0
  487. package/src/lib/point-ask/retry.test.ts +217 -0
  488. package/src/lib/point-ask/retry.ts +37 -0
  489. package/src/lib/point-ask/scene-commit.test.ts +312 -0
  490. package/src/lib/point-ask/scene-commit.ts +32 -0
  491. package/src/lib/point-ask/session.ts +1391 -0
  492. package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
  493. package/src/lib/point-ask/spring-keyframes.ts +38 -0
  494. package/src/lib/point-ask/spring.test.ts +63 -0
  495. package/src/lib/point-ask/spring.ts +107 -0
  496. package/src/lib/point-ask/suggestions.test.ts +80 -0
  497. package/src/lib/point-ask/suggestions.ts +125 -0
  498. package/src/lib/point-ask/target-scope.test.ts +622 -0
  499. package/src/lib/point-ask/target-scope.ts +338 -0
  500. package/src/lib/point-ask/types.ts +48 -0
  501. package/src/lib/portable-export.test.ts +34 -1
  502. package/src/lib/portable-export.ts +119 -53
  503. package/src/lib/print-feature-thickness.ts +10 -1
  504. package/src/lib/print-roof-solids.test.ts +39 -1
  505. package/src/lib/print-roof-solids.ts +43 -10
  506. package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
  507. package/src/lib/print-shell-compiler.ts +1 -0
  508. package/src/lib/registered-draft-snap.ts +12 -0
  509. package/src/lib/roof-duplication.ts +10 -2
  510. package/src/lib/room-assembly-overlay.test.ts +275 -0
  511. package/src/lib/room-assembly-overlay.ts +265 -0
  512. package/src/lib/room-built-on.test.ts +379 -0
  513. package/src/lib/room-built-on.ts +138 -0
  514. package/src/lib/room-construction-commands.ts +712 -0
  515. package/src/lib/room-construction.test.ts +746 -0
  516. package/src/lib/room-construction.ts +385 -0
  517. package/src/lib/room-controls-phase6.test.ts +630 -0
  518. package/src/lib/room-divide-session.ts +523 -0
  519. package/src/lib/room-handle-drag.ts +1068 -0
  520. package/src/lib/room-name-catalog.test.ts +90 -0
  521. package/src/lib/room-name-catalog.ts +168 -0
  522. package/src/lib/room-push-dimensions.test.ts +311 -0
  523. package/src/lib/room-push-dimensions.ts +251 -0
  524. package/src/lib/room-selection-commands.ts +62 -0
  525. package/src/lib/room-selection.test.ts +1017 -0
  526. package/src/lib/room-selection.ts +417 -0
  527. package/src/lib/room-structure-commands.ts +94 -0
  528. package/src/lib/room-structure.test.ts +921 -0
  529. package/src/lib/room-transform-session.ts +570 -0
  530. package/src/lib/room-zone-routing.test.ts +294 -0
  531. package/src/lib/room-zone-routing.ts +197 -0
  532. package/src/lib/scene-clipboard.test.ts +610 -1
  533. package/src/lib/scene-clipboard.ts +269 -58
  534. package/src/lib/scene-material-refs.test.ts +79 -0
  535. package/src/lib/scene-material-refs.ts +79 -0
  536. package/src/lib/scene.test.ts +24 -36
  537. package/src/lib/scene.ts +33 -57
  538. package/src/lib/selection-routing.test.ts +129 -45
  539. package/src/lib/selection-routing.ts +54 -75
  540. package/src/lib/session-writes.test.ts +120 -0
  541. package/src/lib/session-writes.ts +205 -0
  542. package/src/lib/sfx-bus.ts +13 -0
  543. package/src/lib/sfx-player.test.ts +70 -9
  544. package/src/lib/sfx-player.ts +6 -0
  545. package/src/lib/sidebar-panel.ts +62 -0
  546. package/src/lib/site-boundary.test.ts +4 -6
  547. package/src/lib/site-boundary.ts +1 -1
  548. package/src/lib/snapping-mode.test.ts +4 -0
  549. package/src/lib/snapping-mode.ts +8 -1
  550. package/src/lib/stair-levels.test.ts +25 -0
  551. package/src/lib/stair-levels.ts +22 -3
  552. package/src/lib/terrace-draft.test.ts +145 -0
  553. package/src/lib/terrace-draft.ts +214 -0
  554. package/src/lib/tool-cursor-icon.test.ts +16 -0
  555. package/src/lib/tool-cursor-icon.ts +11 -0
  556. package/src/lib/typing-target.ts +9 -0
  557. package/src/lib/units.ts +77 -39
  558. package/src/lib/vector-edges.test.ts +58 -0
  559. package/src/lib/vector-edges.ts +400 -0
  560. package/src/lib/viewer-selection.test.ts +577 -0
  561. package/src/lib/viewer-selection.ts +580 -0
  562. package/src/lib/wall-draw-variant.test.ts +71 -0
  563. package/src/lib/wall-draw-variant.ts +91 -0
  564. package/src/lib/wall-paint-region-session.ts +246 -0
  565. package/src/lib/wall-paint-region.test.ts +373 -0
  566. package/src/lib/wall-placement-pointer.test.ts +42 -0
  567. package/src/lib/wall-placement-pointer.ts +57 -0
  568. package/src/lib/wall-polygon-draft.test.ts +262 -0
  569. package/src/lib/wall-polygon-draft.ts +286 -0
  570. package/src/lib/wall-push.test.ts +453 -0
  571. package/src/lib/wall-region-face.ts +352 -0
  572. package/src/lib/wall-region-handles.test.ts +304 -0
  573. package/src/lib/wall-region-handles.ts +368 -0
  574. package/src/lib/wall-region-list.test.ts +119 -0
  575. package/src/lib/wall-region-list.ts +136 -0
  576. package/src/lib/wall-region-snap.ts +110 -0
  577. package/src/lib/window-changes.ts +35 -0
  578. package/src/lib/window-interaction.ts +14 -0
  579. package/src/lib/zone-content.ts +4 -1
  580. package/src/lib/zone-name.test.ts +17 -0
  581. package/src/lib/zone-name.ts +17 -0
  582. package/src/store/opens-in-select-mode.test.ts +223 -0
  583. package/src/store/paint-mode.test.ts +188 -0
  584. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  585. package/src/store/tool-mode.test.ts +137 -6
  586. package/src/store/use-audio.test.ts +146 -0
  587. package/src/store/use-audio.tsx +127 -3
  588. package/src/store/use-construction-reveal.test.ts +173 -0
  589. package/src/store/use-construction-reveal.ts +145 -0
  590. package/src/store/use-corner-snap-hint.test.ts +24 -0
  591. package/src/store/use-corner-snap-hint.ts +47 -0
  592. package/src/store/use-delete-confirmation.ts +28 -1
  593. package/src/store/use-drawing-view.test.ts +24 -3
  594. package/src/store/use-drawing-view.ts +29 -1
  595. package/src/store/use-editor.tsx +432 -321
  596. package/src/store/use-floorplan-draft-preview.ts +13 -0
  597. package/src/store/use-floorplan-marquee.ts +12 -2
  598. package/src/store/use-follow-pascal.test.ts +62 -0
  599. package/src/store/use-follow-pascal.ts +53 -0
  600. package/src/store/use-point-ask.ts +247 -0
  601. package/src/store/use-quick-measurement-hud.ts +3 -3
  602. package/src/store/use-stair-build-preview.ts +33 -5
  603. package/src/store/use-wall-move-ghosts.ts +4 -2
  604. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  605. package/src/store/use-wall-region-handles-selection.ts +139 -0
  606. package/src/store/view-layout.test.ts +201 -0
  607. package/src/store/view-layout.ts +199 -0
  608. package/src/test-utils/immediate-animation-frames.ts +16 -0
  609. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  610. package/src/test-utils/render-current-markup.tsx +16 -0
  611. package/src/test-utils/selection-harness.ts +82 -0
  612. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  613. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  614. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  615. package/src/components/tools/fence/fence-drafting.ts +0 -266
  616. package/src/components/tools/stair/stair-click-guard.test.ts +0 -58
  617. package/src/components/tools/stair/stair-click-guard.ts +0 -72
  618. package/src/components/tools/stair/stair-defaults.ts +0 -18
  619. package/src/components/tools/stair/stair-tool.tsx +0 -634
  620. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  621. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  622. package/src/lib/floorplan/stairs.ts +0 -489
  623. package/src/lib/level-duplication.test.ts +0 -144
  624. package/src/lib/level-duplication.ts +0 -195
@@ -0,0 +1,1068 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ createLevelStructurePreview,
5
+ DEFAULT_LEVEL_HEIGHT,
6
+ floorPlateGestureMinimum,
7
+ generateId,
8
+ getStoredLevelHeight,
9
+ getWallBaseElevationForNodes,
10
+ getWallCurveFrameAt,
11
+ getWallCurveLength,
12
+ getWallEffectiveHeightForNodes,
13
+ getWallFaceOffsets,
14
+ groundFloorConstruction,
15
+ type NodeChange,
16
+ type Point,
17
+ planWallDivision,
18
+ resolveMovedWallSupportSlabPatch,
19
+ runAsSingleSceneHistoryStep,
20
+ type StructureNodes,
21
+ type StructurePlan,
22
+ setRoomFloorConstruction,
23
+ setWallGeometry,
24
+ setZoneEdges,
25
+ setZoneIntent,
26
+ upperFloorHeightControl,
27
+ useLiveNodeOverrides,
28
+ useScene,
29
+ type WallNode,
30
+ } from '@pascal-app/core'
31
+ import { useViewer } from '@pascal-app/viewer'
32
+ import type { Ray } from 'three'
33
+ import { create } from 'zustand'
34
+ import { ARROW_COLOR } from '../components/editor/handles/handle-arrow'
35
+ import { resolveResizeSnapValue } from '../components/editor/handles/resize-snap'
36
+ import { swallowNextClick } from '../components/editor/handles/use-handle-drag'
37
+ import { getRoomSelectionIndex } from '../hooks/use-selected-room'
38
+ import useEditor, { isGridSnapActive } from '../store/use-editor'
39
+ import { useWallMoveGhosts } from '../store/use-wall-move-ghosts'
40
+ import { footprintHeightPatch, footprintHeightValue } from './floor-footprints'
41
+ import { clientToPlan } from './floorplan/plan-coords'
42
+ import { beginGesture } from './gesture-lifecycle'
43
+ import { isHistoryShortcut } from './history'
44
+ import { MEZZANINE_FAILED_MESSAGE, mezzanineConflictMessage } from './mezzanine-messages'
45
+ import { roomOwnPlate } from './room-built-on'
46
+ import {
47
+ mezzanineEdgeDimensions,
48
+ type RoomDimension,
49
+ wallPushDimensionRooms,
50
+ wallPushDimensions,
51
+ } from './room-push-dimensions'
52
+ import { applyRoomPlan } from './room-structure-commands'
53
+ import { sfxEmitter } from './sfx-bus'
54
+ import { type SpatialPointerId, spatialPointerInput } from './spatial-pointer-input'
55
+ import { isTypingTarget } from './typing-target'
56
+
57
+ export const ROOM_ELEVATION_DRAG_LABEL = 'room-elevation'
58
+ /** Scope label of every wall push: a selected wall's side arrow and a room's boundary arrow. */
59
+ export const WALL_PUSH_DRAG_LABEL = 'wall-push'
60
+ /** Scope label of a mezzanine edge push (its plate grows or shrinks). */
61
+ export const MEZZANINE_EDGE_DRAG_LABEL = 'mezzanine-edge'
62
+
63
+ /** A wall a push would create (a moved span piece, a bridge), drawn as a ghost. */
64
+ export type RoomPushGhost = { start: Point; end: Point; thickness: number; height: number }
65
+
66
+ /** The drag in flight, read by the preview overlays (handles hide while it runs). */
67
+ export type RoomHandleDrag =
68
+ | {
69
+ kind: 'elevation'
70
+ zoneId: string
71
+ levelId: string
72
+ /** Handle spot and the room's outline, in level plan coordinates. */
73
+ anchor: Point
74
+ outline: Point[][]
75
+ from: number
76
+ value: number
77
+ }
78
+ | {
79
+ kind: 'push'
80
+ /** The room whose arrow started it; absent for a selected wall's own arrow. */
81
+ zoneId?: string
82
+ levelId: string
83
+ wallId: string
84
+ /** Arrow position and outward direction at rest, in level plan coordinates. */
85
+ anchor: Point
86
+ height: number
87
+ /** Base elevation of the pushed wall, where its ghost pieces stand. */
88
+ base: number
89
+ outward: Point
90
+ distance: number
91
+ ghosts: RoomPushGhost[]
92
+ /** The pushed rooms' live widths, moving face to each facing parallel wall. */
93
+ dimensions: RoomDimension[]
94
+ message?: string
95
+ }
96
+ | {
97
+ kind: 'mezzanine-edge'
98
+ zoneId: string
99
+ levelId: string
100
+ /** The plate's walking surface, where the preview outline is drawn. */
101
+ elevation: number
102
+ /** The mezzanine outline the push would leave, in level plan coordinates. */
103
+ outline: Point[]
104
+ anchor: Point
105
+ outward: Point
106
+ distance: number
107
+ /** The mezzanine's live widths, moving edge to each facing parallel edge. */
108
+ dimensions: RoomDimension[]
109
+ message?: string
110
+ }
111
+
112
+ export const useRoomHandleDrag = create<{ drag: RoomHandleDrag | null }>(() => ({ drag: null }))
113
+
114
+ type Nodes = Record<string, AnyNode>
115
+
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
+ /**
130
+ * The stretch of a wall a push moves: `[0, 1]` is the whole wall (its
131
+ * neighbours follow through the junction planner), anything shorter is split
132
+ * off first and only that piece moves.
133
+ */
134
+ export type WallPushSpan = { wallId: string; t0: number; t1: number }
135
+
136
+ /** One push/pull arrow: a selected wall's side arrow or a room's boundary arrow. */
137
+ export type WallPushHandle = WallPushSpan & {
138
+ key: string
139
+ /** Arrow position (plan) just outside the wall face it points out of. */
140
+ position: Point
141
+ /** Arrow height in the level frame (wall base included). */
142
+ height: number
143
+ outward: Point
144
+ /** The wall's reference line as a coordinate along `outward` (grid snap lands it on the lattice). */
145
+ line: number
146
+ }
147
+
148
+ const HANDLE_OFFSET = 0.27
149
+ const HANDLE_MIN_OFFSET = 0.33
150
+ const HANDLE_MIN_HEIGHT = 0.4
151
+ const HANDLE_TOP_INSET = 0.08
152
+ const WHOLE = 1e-4
153
+
154
+ /** The arrow for `[t0, t1]` of `wall`, outside its `side` face and pointing out of it. */
155
+ function wallPushHandle(
156
+ nodes: Nodes,
157
+ wall: WallNode,
158
+ span: { t0: number; t1: number },
159
+ side: 'a' | 'b',
160
+ key: string,
161
+ ): WallPushHandle | null {
162
+ if (Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) < 1e-6) return null
163
+ const frame = getWallCurveFrameAt(wall, (span.t0 + span.t1) / 2)
164
+ const outward: Point =
165
+ side === 'a' ? [frame.normal.x, frame.normal.y] : [-frame.normal.x, -frame.normal.y]
166
+ const offsets = getWallFaceOffsets(wall)
167
+ const face = side === 'a' ? offsets.a : -offsets.b
168
+ const offset = Math.max(face + HANDLE_OFFSET, HANDLE_MIN_OFFSET)
169
+ const wallHeight = getWallEffectiveHeightForNodes(wall, nodes)
170
+ return {
171
+ key,
172
+ wallId: wall.id,
173
+ t0: span.t0,
174
+ t1: span.t1,
175
+ position: [frame.point.x + outward[0] * offset, frame.point.y + outward[1] * offset],
176
+ height:
177
+ getWallBaseElevationForNodes(wall, nodes) +
178
+ Math.max(wallHeight - HANDLE_TOP_INSET, HANDLE_MIN_HEIGHT),
179
+ outward,
180
+ line:
181
+ ((wall.start[0] + wall.end[0]) / 2) * outward[0] +
182
+ ((wall.start[1] + wall.end[1]) / 2) * outward[1],
183
+ }
184
+ }
185
+
186
+ /** A selected wall's two side arrows (front, back): each pushes the whole wall. */
187
+ export function wallPushHandles(wall: WallNode, nodes: Nodes): WallPushHandle[] {
188
+ return (['a', 'b'] as const).flatMap((side) => {
189
+ const handle = wallPushHandle(nodes, wall, { t0: 0, t1: 1 }, side, `${wall.id}:${side}`)
190
+ return handle ? [handle] : []
191
+ })
192
+ }
193
+
194
+ /**
195
+ * One push/pull arrow per boundary wall span of the room, outside the wall's
196
+ * 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).
198
+ */
199
+ export function roomPushHandles(
200
+ nodes: Nodes,
201
+ spans: ReadonlyArray<{
202
+ boundaryId: string
203
+ kind: string
204
+ face: 'a' | 'b'
205
+ t0: number
206
+ t1: number
207
+ }>,
208
+ ): WallPushHandle[] {
209
+ return spans.flatMap((span) => {
210
+ const wall = nodes[span.boundaryId]
211
+ if (span.kind !== 'wall' || wall?.type !== 'wall') return []
212
+ const handle = wallPushHandle(
213
+ nodes,
214
+ wall,
215
+ span,
216
+ span.face === 'a' ? 'b' : 'a',
217
+ `${span.boundaryId}:${span.face}:${span.t0.toFixed(4)}`,
218
+ )
219
+ return handle ? [handle] : []
220
+ })
221
+ }
222
+
223
+ /**
224
+ * Snaps a push the way the wall move always has: the wall's reference line
225
+ * (not the travel) lands on the grid lattice along its normal, so an off-grid
226
+ * wall steps onto grid lines. Free while grid snapping is off.
227
+ */
228
+ export function snapPushDistance(line: number, raw: number): number {
229
+ return (
230
+ resolveResizeSnapValue({
231
+ rawValue: line + raw,
232
+ gridSnapEnabled: true,
233
+ gridSnapActive: isGridSnapActive(),
234
+ gridSnapStep: useEditor.getState().gridSnapStep,
235
+ magneticSnapActive: false,
236
+ }) - line
237
+ )
238
+ }
239
+
240
+ /**
241
+ * Moves a room's floor to `elevation`. A room on its own floor has no height
242
+ * of its own: its plate moves, through the room floor construction command the
243
+ * panel and MCP share — upstairs by its thickness; on the ground by its
244
+ * foundation (the slab stays on it), never below on the ground.
245
+ */
246
+ export function planRoomElevation(nodes: StructureNodes, zoneId: string, elevation: number) {
247
+ const own = roomOwnPlate(nodes, zoneId)
248
+ if (!own) return setZoneIntent(nodes, { zoneId, patch: { floor: { elevation } } })
249
+ const patch = upperFloorHeightControl(nodes, own)
250
+ ? footprintHeightPatch(nodes, own, elevation)
251
+ : footprintHeightPatch(
252
+ nodes,
253
+ own,
254
+ elevation - own.thickness - groundFloorConstruction(nodes, own).grade,
255
+ )
256
+ return setRoomFloorConstruction(nodes, { zoneId, slabId: own.id, patch })
257
+ }
258
+
259
+ type Scratch = Record<string, AnyNode>
260
+
261
+ function applyChanges(scratch: Scratch, changes: readonly NodeChange[]): Scratch {
262
+ const next = { ...scratch }
263
+ for (const change of changes) {
264
+ if (change.op === 'delete') delete next[change.id]
265
+ else if (change.op === 'create') next[change.node.id] = change.node
266
+ else if (next[change.id]) next[change.id] = { ...next[change.id], ...change.data } as AnyNode
267
+ }
268
+ return next
269
+ }
270
+
271
+ function diffNodes(before: Scratch, after: Scratch): NodeChange[] {
272
+ const changes: NodeChange[] = []
273
+ for (const node of Object.values(after)) {
274
+ const previous = before[node.id]
275
+ if (!previous) changes.push({ op: 'create', node })
276
+ else if (previous !== node) {
277
+ const data: Record<string, unknown> = {}
278
+ for (const key of new Set([...Object.keys(previous), ...Object.keys(node)]))
279
+ if (
280
+ JSON.stringify(previous[key as keyof AnyNode]) !==
281
+ JSON.stringify(node[key as keyof AnyNode])
282
+ )
283
+ data[key] = node[key as keyof AnyNode]
284
+ if (Object.keys(data).length)
285
+ changes.push({ op: 'update', id: node.id as AnyNodeId, data: data as Partial<AnyNode> })
286
+ }
287
+ }
288
+ for (const id of Object.keys(before))
289
+ if (!after[id]) changes.push({ op: 'delete', id: id as AnyNodeId })
290
+ return changes
291
+ }
292
+
293
+ /**
294
+ * Pushes the room's stretch of a wall `distance` along `outward`. A span that
295
+ * covers the whole wall moves the wall; a partial span is split off at its
296
+ * 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.
298
+ */
299
+ export function planRoomPush(
300
+ nodes: StructureNodes,
301
+ span: WallPushSpan,
302
+ outward: Point,
303
+ distance: number,
304
+ mintId: (kind: 'zone' | 'wall' | 'separator') => string = generateId,
305
+ ): StructurePlan {
306
+ const wall = nodes[span.wallId]
307
+ if (wall?.type !== 'wall') throw Error('Select a wall.')
308
+ const shift = ([x, z]: Point): Point => [x + outward[0] * distance, z + outward[1] * distance]
309
+ const whole = span.t0 <= WHOLE && span.t1 >= 1 - WHOLE
310
+ if (whole)
311
+ return setWallGeometry(nodes, {
312
+ wallId: wall.id,
313
+ start: shift(wall.start),
314
+ end: shift(wall.end),
315
+ mintId,
316
+ })
317
+ let scratch: Scratch = { ...nodes }
318
+ let targetId = wall.id as WallNode['id']
319
+ const length = getWallCurveLength(wall)
320
+ try {
321
+ // Far end first: the original id keeps the part before each cut, so the
322
+ // piece created by the second cut is exactly [t0, t1].
323
+ for (const t of [span.t1, span.t0]) {
324
+ if (t <= WHOLE || t >= 1 - WHOLE) continue
325
+ const division = planWallDivision(
326
+ scratch as Record<AnyNodeId, AnyNode>,
327
+ wall.id as WallNode['id'],
328
+ t * length,
329
+ () => mintId('wall'),
330
+ )
331
+ for (const { node, parentId } of division.changes.create)
332
+ scratch[node.id] = { ...node, parentId: parentId ?? node.parentId } as AnyNode
333
+ for (const id of division.changes.delete) delete scratch[id]
334
+ for (const { id, data } of division.changes.update)
335
+ if (scratch[id]) scratch[id] = { ...scratch[id], ...data } as AnyNode
336
+ if (t === span.t0) targetId = division.changes.create[0]!.node.id as WallNode['id']
337
+ }
338
+ } catch (error) {
339
+ return {
340
+ changes: [],
341
+ conflicts: [
342
+ {
343
+ code: 'occupied-split',
344
+ nodeIds: [wall.id],
345
+ message: error instanceof Error ? error.message : String(error),
346
+ },
347
+ ],
348
+ }
349
+ }
350
+ const piece = scratch[targetId] as WallNode
351
+ const moved = setWallGeometry(scratch, {
352
+ wallId: piece.id,
353
+ start: shift(piece.start),
354
+ end: shift(piece.end),
355
+ mintId,
356
+ })
357
+ if (moved.conflicts?.length) return moved
358
+ return { changes: diffNodes(nodes, applyChanges(scratch, moved.changes)) }
359
+ }
360
+
361
+ function previewMint() {
362
+ let n = 0
363
+ return (kind: 'zone' | 'wall' | 'separator') => `${kind}_pushpreview${n++}`
364
+ }
365
+
366
+ let previewIds: string[] = []
367
+ let planGhosts = false
368
+ // One structure previewer per drag: it keeps its own draft, and the scene is
369
+ // untouched until the commit, so it is rebuilt only if the scene moves on.
370
+ let surfacePreview: {
371
+ levelId: string
372
+ nodes: Nodes
373
+ run: ReturnType<typeof createLevelStructurePreview>
374
+ } | null = null
375
+
376
+ function clearPushPreview() {
377
+ const overrides = useLiveNodeOverrides.getState()
378
+ for (const id of previewIds) {
379
+ overrides.clear(id as AnyNodeId)
380
+ useScene.getState().markDirty(id as AnyNodeId)
381
+ }
382
+ previewIds = []
383
+ if (planGhosts) useWallMoveGhosts.getState().clear()
384
+ planGhosts = false
385
+ }
386
+
387
+ /** Room floors, ceilings and plates reshaped around the pushed walls, as override patches. */
388
+ function previewSurfaces(nodes: Nodes, levelId: string, after: Scratch) {
389
+ if (surfacePreview?.levelId !== levelId || surfacePreview.nodes !== nodes)
390
+ 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
+ )
394
+ return surfacePreview
395
+ .run(walls)
396
+ .map((patch) => [patch.id as AnyNodeId, patch.data as Partial<AnyNode>] as const)
397
+ }
398
+
399
+ /**
400
+ * Shows the push live: the plan's updates to existing walls — and the room
401
+ * surfaces reshaped around them — ride `useLiveNodeOverrides` (renderers merge
402
+ * them), and the walls it would create come back as ghosts. Returns the
403
+ * conflict code, if any.
404
+ */
405
+ export function previewRoomPush(
406
+ span: WallPushSpan,
407
+ outward: Point,
408
+ distance: number,
409
+ ): { code?: string; ghosts: RoomPushGhost[] } {
410
+ clearPushPreview()
411
+ if (Math.abs(distance) < 1e-6) return { ghosts: [] }
412
+ const nodes = useScene.getState().nodes
413
+ let plan: StructurePlan
414
+ try {
415
+ plan = planRoomPush(nodes, span, outward, distance, previewMint())
416
+ } catch {
417
+ return { code: 'invalid-geometry', ghosts: [] }
418
+ }
419
+ if (plan.conflicts?.length) return { code: plan.conflicts[0]!.code, ghosts: [] }
420
+ const updates: Array<readonly [AnyNodeId, Partial<AnyNode>]> = plan.changes.flatMap((change) =>
421
+ change.op === 'update' ? [[change.id as AnyNodeId, change.data] as const] : [],
422
+ )
423
+ const levelId = nodes[span.wallId as AnyNodeId]?.parentId
424
+ if (levelId) updates.push(...previewSurfaces(nodes, levelId, applyChanges(nodes, plan.changes)))
425
+ useLiveNodeOverrides
426
+ .getState()
427
+ .setMany(updates.map(([id, data]) => [id, data] as [AnyNodeId, Partial<AnyNode>]))
428
+ previewIds = updates.map(([id]) => id)
429
+ 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'
432
+ ? [
433
+ {
434
+ start: change.node.start,
435
+ end: change.node.end,
436
+ thickness: change.node.thickness ?? 0.1,
437
+ height: getWallEffectiveHeightForNodes(change.node, nodes),
438
+ },
439
+ ]
440
+ : [],
441
+ )
442
+ // The floor plan draws the same pieces through its bridge-ghost layer.
443
+ if (ghosts.length) {
444
+ useWallMoveGhosts.getState().setBridges(
445
+ ghosts.map((ghost, index) => ({
446
+ id: `wall-push:${index}`,
447
+ start: ghost.start,
448
+ end: ghost.end,
449
+ thickness: ghost.thickness,
450
+ color: ARROW_COLOR,
451
+ })),
452
+ )
453
+ planGhosts = true
454
+ }
455
+ return { ghosts }
456
+ }
457
+
458
+ export function endRoomPushPreview() {
459
+ clearPushPreview()
460
+ surfacePreview = null
461
+ }
462
+
463
+ /**
464
+ * Commits a push as one undo step; false when the planner refuses. The walls
465
+ * it moved or created then re-elect the slab they stand on, as a wall move
466
+ * always has, inside the same step.
467
+ */
468
+ export function commitRoomPush(span: WallPushSpan, outward: Point, distance: number) {
469
+ endRoomPushPreview()
470
+ if (Math.abs(distance) < 1e-6) return true
471
+ try {
472
+ return runAsSingleSceneHistoryStep(useScene, () => {
473
+ const plan = planRoomPush(useScene.getState().nodes, span, outward, distance)
474
+ if (!applyRoomPlan(plan)) return false
475
+ const nodes = useScene.getState().nodes
476
+ const patches = plan.changes.flatMap((change) => {
477
+ const wall = nodes[change.op === 'create' ? change.node.id : change.id]
478
+ if (change.op === 'delete' || wall?.type !== 'wall') return []
479
+ const patch = resolveMovedWallSupportSlabPatch(wall, nodes)
480
+ return patch.supportSlabId === wall.supportSlabId
481
+ ? []
482
+ : [{ id: wall.id as AnyNodeId, data: patch as Partial<AnyNode> }]
483
+ })
484
+ if (patches.length) useScene.getState().updateNodes(patches)
485
+ return true
486
+ })
487
+ } catch {
488
+ return false
489
+ }
490
+ }
491
+
492
+ /** Commits a floor elevation as one undo step. */
493
+ /**
494
+ * The floor heights a mezzanine may take (core refuses the rest): above its own
495
+ * plate thickness and at least 0.3 m under the storey top. Null for other rooms.
496
+ */
497
+ export function mezzanineElevationBounds(
498
+ nodes: Nodes,
499
+ zoneId: string,
500
+ ): { min: number; max: number } | null {
501
+ const zone = nodes[zoneId]
502
+ if (zone?.type !== 'zone' || zone.floor?.support !== 'open') return null
503
+ const level = zone.parentId ? nodes[zone.parentId] : undefined
504
+ const height = level?.type === 'level' ? (level.height ?? DEFAULT_LEVEL_HEIGHT) : 0
505
+ const thickness = zone.floor.thickness ?? 0.2
506
+ return { min: Math.round((thickness + 0.01) * 100) / 100, max: height - 0.3 }
507
+ }
508
+
509
+ /**
510
+ * The floor heights an own-floor room's handle may take: core's plate minimum
511
+ * (sunken ground floors are allowed; upstairs the underside stays fixed), up
512
+ * to 0.5 m under the storey top. Null for other rooms.
513
+ */
514
+ export function ownFloorElevationBounds(
515
+ nodes: Nodes,
516
+ zoneId: string,
517
+ ): { min: number; max: number } | null {
518
+ const own = roomOwnPlate(nodes, zoneId)
519
+ if (!own) return null
520
+ const level = own.parentId ? nodes[own.parentId] : undefined
521
+ const max = level?.type === 'level' ? getStoredLevelHeight(level) - 0.5 : 3
522
+ // On the ground the handle moves the foundation (0 up to the panel's limit)
523
+ // under the slab; upstairs it moves the top from its resting minimum.
524
+ const upper = upperFloorHeightControl(nodes, own)
525
+ const offset = upper
526
+ ? own.elevation - footprintHeightValue(nodes, own)
527
+ : groundFloorConstruction(nodes, own).grade + own.thickness
528
+ return {
529
+ min: upper ? Math.min(own.elevation, floorPlateGestureMinimum(nodes, own)) : offset,
530
+ max: Math.max(own.elevation, offset + max),
531
+ }
532
+ }
533
+
534
+ /** One push arrow per edge of a mezzanine's outline, just outside the edge at plate height. */
535
+ export type MezzanineEdgeHandle = {
536
+ key: string
537
+ edgeIndex: number
538
+ position: Point
539
+ outward: Point
540
+ /** The edge's offset along `outward`, so a push lands the edge on the grid. */
541
+ line: number
542
+ height: number
543
+ }
544
+
545
+ /** How far outside its edge (m) a mezzanine push arrow sits. */
546
+ const MEZZANINE_ARROW_OFFSET = 0.3
547
+
548
+ export function mezzanineEdgeHandles(nodes: Nodes, zoneId: string): MezzanineEdgeHandle[] {
549
+ const zone = nodes[zoneId]
550
+ if (zone?.type !== 'zone' || zone.floor?.support !== 'open') return []
551
+ const polygon = zone.polygon
552
+ // Core's outward normal for an edge: the polygon's winding decides the side.
553
+ let signed = 0
554
+ for (const [i, p] of polygon.entries()) {
555
+ const q = polygon[(i + 1) % polygon.length]!
556
+ signed += p[0] * q[1] - q[0] * p[1]
557
+ }
558
+ const winding = Math.sign(signed)
559
+ const height = zone.floor.elevation ?? 0
560
+ return polygon.flatMap((start, edgeIndex) => {
561
+ const end = polygon[(edgeIndex + 1) % polygon.length]!
562
+ const dx = end[0] - start[0]
563
+ const dz = end[1] - start[1]
564
+ const length = Math.hypot(dx, dz)
565
+ if (length < 0.2) return []
566
+ const outward: Point = [(winding * dz) / length, (-winding * dx) / length]
567
+ const mid: Point = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2]
568
+ return [
569
+ {
570
+ key: `${zoneId}:${edgeIndex}`,
571
+ edgeIndex,
572
+ position: [
573
+ mid[0] + outward[0] * MEZZANINE_ARROW_OFFSET,
574
+ mid[1] + outward[1] * MEZZANINE_ARROW_OFFSET,
575
+ ],
576
+ outward,
577
+ line: start[0] * outward[0] + start[1] * outward[1],
578
+ height,
579
+ },
580
+ ]
581
+ })
582
+ }
583
+
584
+ /** Core's plan for pushing a mezzanine edge `distance` m outward (negative pulls it in). */
585
+ export function planMezzanineEdge(
586
+ nodes: StructureNodes,
587
+ zoneId: string,
588
+ edgeIndex: number,
589
+ distance: number,
590
+ ) {
591
+ return setZoneEdges(nodes, { zoneId, edgeIndex, distance })
592
+ }
593
+
594
+ /**
595
+ * A mezzanine edge drag: the outline follows the arrow, grid-snapped by where
596
+ * the edge lands; a refused outline turns red with its label and release
597
+ * writes nothing. A valid release is one undo step.
598
+ */
599
+ export function runMezzanineEdgeDrag({
600
+ handle,
601
+ zoneId,
602
+ levelId,
603
+ from,
604
+ along,
605
+ }: {
606
+ handle: MezzanineEdgeHandle
607
+ zoneId: string
608
+ levelId: string
609
+ from: number
610
+ along: (clientX: number, clientY: number) => number | null
611
+ }) {
612
+ const zone = useScene.getState().nodes[zoneId as AnyNodeId]
613
+ if (zone?.type !== 'zone') return null
614
+ const rest = zone.polygon.map(([x, z]) => [x, z] as Point)
615
+ let distance = 0
616
+ let valid = true
617
+ let outline = rest
618
+ const publish = (message?: string) =>
619
+ useRoomHandleDrag.setState({
620
+ drag: {
621
+ kind: 'mezzanine-edge',
622
+ zoneId,
623
+ levelId,
624
+ elevation: handle.height,
625
+ outline,
626
+ anchor: handle.position,
627
+ outward: handle.outward,
628
+ distance,
629
+ dimensions: mezzanineEdgeDimensions({
630
+ rest,
631
+ outline,
632
+ edgeIndex: handle.edgeIndex,
633
+ outward: handle.outward,
634
+ distance,
635
+ elevation: handle.height,
636
+ }),
637
+ message,
638
+ },
639
+ })
640
+ publish()
641
+ return runRoomHandleDrag({
642
+ label: MEZZANINE_EDGE_DRAG_LABEL,
643
+ nodeId: zoneId,
644
+ zoneId,
645
+ levelId,
646
+ requires: [zoneId],
647
+ sample: (x, y) => {
648
+ const at = along(x, y)
649
+ return at === null ? null : snapPushDistance(handle.line, at - from)
650
+ },
651
+ onValue: (next) => {
652
+ if (Math.abs(next - distance) < 1e-6) return
653
+ distance = next
654
+ sfxEmitter.emit('sfx:grid-snap')
655
+ const plan = planMezzanineEdge(useScene.getState().nodes, zoneId, handle.edgeIndex, distance)
656
+ const conflict = plan.conflicts?.[0]
657
+ valid = !conflict
658
+ const update = plan.changes.find((change) => change.op === 'update')
659
+ const polygon =
660
+ update?.op === 'update' ? (update.data as { polygon?: Point[] }).polygon : undefined
661
+ outline = polygon ?? pushedOutline(rest, handle.edgeIndex, handle.outward, distance)
662
+ publish(
663
+ conflict
664
+ ? (mezzanineConflictMessage(conflict.code) ?? MEZZANINE_FAILED_MESSAGE)
665
+ : undefined,
666
+ )
667
+ },
668
+ onCommit: () => {
669
+ if (!valid || Math.abs(distance) < 1e-6) return
670
+ applyRoomPlan(
671
+ planMezzanineEdge(useScene.getState().nodes, zoneId, handle.edgeIndex, distance),
672
+ )
673
+ },
674
+ onCancel: () => {},
675
+ })
676
+ }
677
+
678
+ /** The outline with one edge moved along its outward normal (the refused preview). */
679
+ function pushedOutline(polygon: Point[], edgeIndex: number, outward: Point, distance: number) {
680
+ const next = polygon.map(([x, z]) => [x, z] as Point)
681
+ for (const index of [edgeIndex, (edgeIndex + 1) % next.length]) {
682
+ const [x, z] = next[index]!
683
+ next[index] = [x + outward[0] * distance, z + outward[1] * distance]
684
+ }
685
+ return next
686
+ }
687
+
688
+ export function commitRoomElevation(zoneId: string, elevation: number) {
689
+ return applyRoomPlan(planRoomElevation(useScene.getState().nodes, zoneId, elevation))
690
+ }
691
+
692
+ // ── Drag ownership ────────────────────────────────────────────────────────────
693
+
694
+ type ActiveDrag = {
695
+ label: string
696
+ nodeId: string
697
+ zoneId?: string
698
+ levelId: string
699
+ /** Every node the drag depends on; losing one cancels it. */
700
+ requires: string[]
701
+ cancel: () => void
702
+ }
703
+
704
+ let active: ActiveDrag | null = null
705
+
706
+ /** The drag in flight, for the gesture owner's lifecycle checks. */
707
+ export function getActiveRoomHandleDrag() {
708
+ return active
709
+ }
710
+
711
+ /** Cancels the drag in flight (previews cleared, scope released). Idempotent. */
712
+ export function cancelRoomHandleDrag() {
713
+ active?.cancel()
714
+ }
715
+
716
+ /**
717
+ * Runs one handle drag on window listeners, independent of the handle's own
718
+ * 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
721
+ * exactly once whatever happens, and releases only this drag's own scope;
722
+ * nothing republishes a preview after it.
723
+ */
724
+ export function runRoomHandleDrag({
725
+ label,
726
+ nodeId,
727
+ zoneId,
728
+ levelId,
729
+ requires,
730
+ sample,
731
+ spatial,
732
+ onValue,
733
+ onCommit,
734
+ onCancel,
735
+ }: {
736
+ label: string
737
+ nodeId: string
738
+ zoneId?: string
739
+ levelId: string
740
+ requires: string[]
741
+ sample: (clientX: number, clientY: number) => number | null
742
+ spatial?: { pointerId: SpatialPointerId; sample: (ray: Ray) => number | null }
743
+ onValue: (value: number) => void
744
+ onCommit: () => void
745
+ onCancel: () => void
746
+ }) {
747
+ active?.cancel()
748
+ document.body.style.cursor = 'grabbing'
749
+ sfxEmitter.emit('sfx:item-pick')
750
+ useViewer.getState().setInputDragging(true)
751
+ let done = false
752
+ let frame = 0
753
+ let pending: PointerEvent | null = null
754
+ let releaseSpatial: (() => void) | null = null
755
+ const flush = () => {
756
+ frame = 0
757
+ const event = pending
758
+ pending = null
759
+ if (done || !event) return
760
+ const value = sample(event.clientX, event.clientY)
761
+ if (value !== null) onValue(value)
762
+ }
763
+ const onMove = (event: PointerEvent) => {
764
+ pending = event
765
+ if (!frame) frame = requestAnimationFrame(flush) || 0
766
+ }
767
+ let releasing = false
768
+ const cleanup = () => {
769
+ if (done) return
770
+ done = true
771
+ if (frame) cancelAnimationFrame(frame)
772
+ frame = 0
773
+ pending = null
774
+ window.removeEventListener('pointermove', onMove)
775
+ window.removeEventListener('pointerup', onUp, true)
776
+ window.removeEventListener('pointercancel', onAbort)
777
+ window.removeEventListener('keydown', onKeyDown, true)
778
+ releaseSpatial?.()
779
+ releaseSpatial = null
780
+ if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
781
+ // The release is read in capture, before the scene's own pointerup (which
782
+ // selects what is under the pointer unless input is dragging): keep input
783
+ // dragging until that event is through — its bubbling back to the window,
784
+ // or the next task if something below stops it.
785
+ if (releasing) {
786
+ let released = false
787
+ const release = () => {
788
+ if (released) return
789
+ released = true
790
+ window.removeEventListener('pointerup', release)
791
+ useViewer.getState().setInputDragging(false)
792
+ }
793
+ window.addEventListener('pointerup', release)
794
+ setTimeout(release, 0)
795
+ } else useViewer.getState().setInputDragging(false)
796
+ useRoomHandleDrag.setState({ drag: null })
797
+ if (active === controller) active = null
798
+ }
799
+ // The lifecycle owner claims and releases the scope, and cancels on a mode,
800
+ // level or selection change, a lost scope, a required node deleted or any
801
+ // history command.
802
+ const owner = beginGesture({
803
+ kind: 'room-handle-drag',
804
+ scope: { kind: 'handle-drag', nodeId, handle: label },
805
+ stale: () => {
806
+ const nodes = useScene.getState().nodes
807
+ return requires.some((id) => !nodes[id as AnyNodeId])
808
+ },
809
+ onCancel: () => {
810
+ try {
811
+ onCancel()
812
+ } finally {
813
+ cleanup()
814
+ }
815
+ },
816
+ })
817
+ const onUp = (event?: PointerEvent) => {
818
+ if (done) return
819
+ // Only a release aimed at something below the window can reach the scene.
820
+ releasing = !!event && event.target !== window
821
+ if (event) {
822
+ pending = event
823
+ flush()
824
+ }
825
+ swallowNextClick()
826
+ sfxEmitter.emit('sfx:item-place')
827
+ owner.finish(() => {
828
+ try {
829
+ onCommit()
830
+ } finally {
831
+ cleanup()
832
+ }
833
+ })
834
+ }
835
+ const onAbort = () => {
836
+ if (!done) owner.cancel()
837
+ }
838
+ const onKeyDown = (event: KeyboardEvent) => {
839
+ if (event.defaultPrevented || isTypingTarget(event.target)) return
840
+ if (event.key !== 'Escape' && !isHistoryShortcut(event)) return
841
+ event.preventDefault()
842
+ event.stopPropagation()
843
+ swallowNextClick()
844
+ onAbort()
845
+ }
846
+ const controller: ActiveDrag = { label, nodeId, zoneId, levelId, requires, cancel: onAbort }
847
+ active = controller
848
+ window.addEventListener('pointermove', onMove)
849
+ // Capture: the release belongs to this drag whatever is under the pointer.
850
+ // A scene control that stops the native pointerup (a slab hole's hit box,
851
+ // under an upper floor's height arrows) would otherwise swallow it, and the
852
+ // drag would stay live until the next click.
853
+ window.addEventListener('pointerup', onUp, true)
854
+ window.addEventListener('pointercancel', onAbort)
855
+ window.addEventListener('keydown', onKeyDown, true)
856
+ if (spatial)
857
+ releaseSpatial = spatialPointerInput.capture(spatial.pointerId, {
858
+ onMove: (ray) => {
859
+ const value = done ? null : spatial.sample(ray)
860
+ if (value !== null) onValue(value)
861
+ },
862
+ onRelease: () => onUp(),
863
+ onCancel: onAbort,
864
+ })
865
+ return controller
866
+ }
867
+
868
+ /**
869
+ * The one push/pull drag behind every wall arrow. `along` measures the pointer
870
+ * along `handle.outward` (level plan metres) and `from` is where the press
871
+ * landed on that axis. The push previews live — the wall, its neighbours and
872
+ * the room surfaces through overrides, created pieces as ghosts — and release
873
+ * commits it through `commitRoomPush` (core `setWallGeometry`) as one undo
874
+ * step: the whole wall with its neighbours for a `[0, 1]` span, else the span
875
+ * split off and moved alone.
876
+ */
877
+ export function runWallPushDrag({
878
+ handle,
879
+ levelId,
880
+ zoneId,
881
+ from,
882
+ along,
883
+ spatial,
884
+ }: {
885
+ handle: WallPushHandle
886
+ levelId: string
887
+ /** The room whose arrow this is: the drag cancels if the room goes. */
888
+ zoneId?: string
889
+ from: number
890
+ along: (clientX: number, clientY: number) => number | null
891
+ spatial?: { pointerId: SpatialPointerId; along: (ray: Ray) => number | null }
892
+ }) {
893
+ 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)
897
+ const rooms = pushDimensionRooms(nodes, levelId, handle, base)
898
+ let distance = 0
899
+ let blocked = false
900
+ let ghosts: RoomPushGhost[] = []
901
+ const publish = (message?: string) =>
902
+ useRoomHandleDrag.setState({
903
+ drag: {
904
+ kind: 'push',
905
+ zoneId,
906
+ levelId,
907
+ wallId: handle.wallId,
908
+ anchor: handle.position,
909
+ height: handle.height,
910
+ base,
911
+ outward: handle.outward,
912
+ distance,
913
+ ghosts,
914
+ dimensions: wallPushDimensions({
915
+ rooms,
916
+ wall,
917
+ outward: handle.outward,
918
+ distance,
919
+ resolve: livePreviewNode,
920
+ }),
921
+ message,
922
+ },
923
+ })
924
+ const toDistance = (at: number | null) =>
925
+ at === null ? null : snapPushDistance(handle.line, at - from)
926
+ publish()
927
+ return runRoomHandleDrag({
928
+ label: WALL_PUSH_DRAG_LABEL,
929
+ nodeId: handle.wallId,
930
+ zoneId,
931
+ levelId,
932
+ requires: zoneId ? [zoneId, handle.wallId] : [handle.wallId],
933
+ sample: (x, y) => toDistance(along(x, y)),
934
+ spatial: spatial && {
935
+ pointerId: spatial.pointerId,
936
+ sample: (ray) => toDistance(spatial.along(ray)),
937
+ },
938
+ onValue: (next) => {
939
+ if (Math.abs(next - distance) < 1e-6) return
940
+ distance = next
941
+ sfxEmitter.emit('sfx:grid-snap')
942
+ const preview = previewRoomPush(handle, handle.outward, distance)
943
+ ghosts = preview.ghosts
944
+ blocked = !!preview.code
945
+ publish(preview.code ? "Can't move this wall there" : undefined)
946
+ },
947
+ onCommit: () => {
948
+ if (blocked) endRoomPushPreview()
949
+ else commitRoomPush(handle, handle.outward, distance)
950
+ },
951
+ onCancel: () => endRoomPushPreview(),
952
+ })
953
+ }
954
+
955
+ /**
956
+ * The rooms either side of a pushed stretch, for its live dimensions, each at
957
+ * its own floor height (a room with no zone of its own at the wall's base).
958
+ */
959
+ function pushDimensionRooms(nodes: Nodes, levelId: string, span: WallPushSpan, base: number) {
960
+ const index = getRoomSelectionIndex(levelId)
961
+ const records = index.update(nodes)
962
+ const topology = index.topology.getLevelTopology(levelId)
963
+ return wallPushDimensionRooms(topology?.rooms ?? [], span, (roomId) => {
964
+ const zoneId = records.find((record) => record.id === roomId)?.zoneId
965
+ return zoneId ? roomFloorElevation(nodes, zoneId) : base
966
+ })
967
+ }
968
+
969
+ /** A node as the push preview draws it: the scene's, with its live override merged. */
970
+ function livePreviewNode(id: string): AnyNode | undefined {
971
+ const node = useScene.getState().nodes[id as AnyNodeId]
972
+ const override = useLiveNodeOverrides.getState().overrides.get(id)
973
+ return node && override ? ({ ...node, ...override } as AnyNode) : node
974
+ }
975
+
976
+ /** Plan point on the wall at `t` — exported for tests. */
977
+ export function wallPointAt(wall: WallNode, t: number): Point {
978
+ const frame = getWallCurveFrameAt(wall, t)
979
+ return [frame.point.x, frame.point.y]
980
+ }
981
+
982
+ /**
983
+ * The affordance a floor-plan `move-arrow` carries to be a wall push arrow;
984
+ * its payload is a `WallPushArrowPayload`. The floor plan starts
985
+ * `runFloorplanWallPush` for it instead of a registry affordance session.
986
+ */
987
+ export const WALL_PUSH_AFFORDANCE = 'wall-push'
988
+
989
+ export type WallPushArrowPayload = { wallId: string; side: 'a' | 'b' }
990
+
991
+ type PlanPointer = (clientX: number, clientY: number) => readonly [number, number] | null
992
+
993
+ /** The pointer's plan position measured along an arrow's outward normal from its spot. */
994
+ function planAlong(position: Point, outward: Point, toPlan: PlanPointer) {
995
+ return (x: number, y: number) => {
996
+ const point = toPlan(x, y)
997
+ return point
998
+ ? (point[0] - position[0]) * outward[0] + (point[1] - position[1]) * outward[1]
999
+ : null
1000
+ }
1001
+ }
1002
+
1003
+ /**
1004
+ * A floor-plan wall arrow: the 3D arrow's handle (`wallPushHandles`), drag
1005
+ * (`runWallPushDrag`) and commit, with the pointer read in plan coordinates.
1006
+ */
1007
+ export function runFloorplanWallPush(
1008
+ payload: unknown,
1009
+ clientX: number,
1010
+ clientY: number,
1011
+ toPlan: PlanPointer = clientToPlan,
1012
+ ) {
1013
+ const { wallId, side } = (payload ?? {}) as Partial<WallPushArrowPayload>
1014
+ const nodes = useScene.getState().nodes
1015
+ 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}`)
1018
+ if (!handle) return null
1019
+ return runFloorplanRoomPush({ handle, levelId: wall.parentId, clientX, clientY, toPlan })
1020
+ }
1021
+
1022
+ /**
1023
+ * A floor-plan room arrow (one per boundary span, `roomPushHandles`): the 3D
1024
+ * room arrow's drag and commit — one undo step, the live preview through the
1025
+ * same overrides and ghosts — with the pointer read in plan coordinates.
1026
+ */
1027
+ export function runFloorplanRoomPush({
1028
+ handle,
1029
+ levelId,
1030
+ zoneId,
1031
+ clientX,
1032
+ clientY,
1033
+ toPlan = clientToPlan,
1034
+ }: {
1035
+ handle: WallPushHandle
1036
+ levelId: string
1037
+ zoneId?: string
1038
+ clientX: number
1039
+ clientY: number
1040
+ toPlan?: PlanPointer
1041
+ }) {
1042
+ const along = planAlong(handle.position, handle.outward, toPlan)
1043
+ const from = along(clientX, clientY)
1044
+ if (from === null) return null
1045
+ return runWallPushDrag({ handle, levelId, zoneId, from, along })
1046
+ }
1047
+
1048
+ /** A floor-plan mezzanine edge arrow: the 3D edge drag with the pointer read in plan coordinates. */
1049
+ export function runFloorplanMezzanineEdge({
1050
+ handle,
1051
+ levelId,
1052
+ zoneId,
1053
+ clientX,
1054
+ clientY,
1055
+ toPlan = clientToPlan,
1056
+ }: {
1057
+ handle: MezzanineEdgeHandle
1058
+ levelId: string
1059
+ zoneId: string
1060
+ clientX: number
1061
+ clientY: number
1062
+ toPlan?: PlanPointer
1063
+ }) {
1064
+ const along = planAlong(handle.position, handle.outward, toPlan)
1065
+ const from = along(clientX, clientY)
1066
+ if (from === null) return null
1067
+ return runMezzanineEdgeDrag({ handle, zoneId, levelId, from, along })
1068
+ }