@pascal-app/editor 1.0.1 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (498) hide show
  1. package/package.json +16 -9
  2. package/src/components/editor/action-menu-button.tsx +83 -0
  3. package/src/components/editor/bake-thumbnail.tsx +315 -4
  4. package/src/components/editor/capture-level-picker.tsx +72 -0
  5. package/src/components/editor/custom-camera-controls.tsx +221 -128
  6. package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
  7. package/src/components/editor/duplicate-node.ts +244 -0
  8. package/src/components/editor/editor-layout-mobile.tsx +37 -25
  9. package/src/components/editor/editor-layout-v2.tsx +131 -58
  10. package/src/components/editor/export-manager.tsx +34 -8
  11. package/src/components/editor/first-person/build-collider-world.test.ts +51 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +6 -2
  13. package/src/components/editor/first-person/mechanism-targets.test.ts +75 -0
  14. package/src/components/editor/first-person/mechanism-targets.ts +36 -0
  15. package/src/components/editor/first-person-controls.tsx +205 -2
  16. package/src/components/editor/floating-action-menu.tsx +63 -207
  17. package/src/components/editor/floor-aware-camera-controls.test.ts +442 -0
  18. package/src/components/editor/floor-aware-camera-controls.ts +324 -0
  19. package/src/components/editor/floor-openings-overlay.tsx +243 -0
  20. package/src/components/editor/floor-region-controls.tsx +846 -0
  21. package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
  22. package/src/components/editor/floor-surface.test.ts +165 -0
  23. package/src/components/editor/floor-surface.ts +182 -0
  24. package/src/components/editor/floorplan-background-selection.test.ts +11 -2
  25. package/src/components/editor/floorplan-background-selection.ts +1 -3
  26. package/src/components/editor/floorplan-panel.tsx +536 -487
  27. package/src/components/editor/footprint-height-handle.tsx +204 -0
  28. package/src/components/editor/grid.tsx +31 -7
  29. package/src/components/editor/group-actions.ts +59 -11
  30. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  31. package/src/components/editor/group-move-3d.ts +4 -6
  32. package/src/components/editor/group-rotate-handle.tsx +81 -35
  33. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  34. package/src/components/editor/group-transform-shared.test.ts +126 -1
  35. package/src/components/editor/group-transform-shared.ts +79 -73
  36. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  37. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  38. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  39. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  40. package/src/components/editor/handles/use-handle-drag.ts +272 -131
  41. package/src/components/editor/index.tsx +316 -176
  42. package/src/components/editor/join-walls-pill.tsx +44 -0
  43. package/src/components/editor/node-action-menu.tsx +70 -70
  44. package/src/components/editor/node-arrow-handles.tsx +167 -14
  45. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  46. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  47. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  48. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  49. package/src/components/editor/registry-action-contributions.tsx +132 -0
  50. package/src/components/editor/room-controls.tsx +847 -0
  51. package/src/components/editor/room-floor-highlight.tsx +128 -0
  52. package/src/components/editor/room-handles-2d.tsx +354 -0
  53. package/src/components/editor/room-handles.tsx +675 -0
  54. package/src/components/editor/room-highlight.tsx +225 -0
  55. package/src/components/editor/room-labels.test.ts +357 -0
  56. package/src/components/editor/room-labels.tsx +990 -0
  57. package/src/components/editor/room-transform-ghost.tsx +256 -0
  58. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  59. package/src/components/editor/selection-manager.tsx +451 -317
  60. package/src/components/editor/slab-hole-highlights.tsx +3 -0
  61. package/src/components/editor/snapshot-capture-overlay.tsx +4 -0
  62. package/src/components/editor/snapshot-capture.test.ts +52 -4
  63. package/src/components/editor/snapshot-capture.ts +30 -5
  64. package/src/components/editor/thumbnail-generator.test.ts +308 -0
  65. package/src/components/editor/thumbnail-generator.tsx +1035 -229
  66. package/src/components/editor/use-floorplan-background-placement.ts +31 -11
  67. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  68. package/src/components/editor/views/host-view.tsx +61 -0
  69. package/src/components/editor/views/plugins-view.tsx +670 -0
  70. package/src/components/editor/views/scene-region.tsx +59 -0
  71. package/src/components/editor/views/use-editor-views.ts +57 -0
  72. package/src/components/editor/views/view-bar.tsx +220 -0
  73. package/src/components/editor/wall-justification.test.ts +140 -0
  74. package/src/components/editor/wall-move-side-handles.tsx +114 -163
  75. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  76. package/src/components/editor/wall-push-arrow.tsx +92 -0
  77. package/src/components/editor/wall-region-handles.tsx +509 -0
  78. package/src/components/editor/wall-snap-beacon-layer.tsx +3 -0
  79. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  80. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  81. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  82. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  83. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  84. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  85. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +242 -126
  86. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  88. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  89. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +166 -0
  90. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  91. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +87 -8
  92. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +355 -245
  93. package/src/components/editor-2d/svg-paths.ts +12 -15
  94. package/src/components/procedural-items/inspector.tsx +40 -15
  95. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  96. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  97. package/src/components/systems/zone/zone-system.test.ts +47 -0
  98. package/src/components/systems/zone/zone-system.tsx +31 -66
  99. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  100. package/src/components/tools/item/item-surface-placement.test.ts +3 -9
  101. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
  102. package/src/components/tools/item/placement-history.test.tsx +474 -0
  103. package/src/components/tools/item/placement-strategies.ts +63 -47
  104. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
  105. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  106. package/src/components/tools/item/use-draft-node.ts +207 -97
  107. package/src/components/tools/item/use-placement-coordinator.tsx +251 -111
  108. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  109. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  110. package/src/components/tools/registry/item-surface-move.test.ts +10 -2
  111. package/src/components/tools/registry/item-surface-move.ts +46 -24
  112. package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +26 -10
  114. package/src/components/tools/select/box-select-tool.tsx +2 -12
  115. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  116. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  117. package/src/components/tools/select/select-candidates.test.ts +37 -4
  118. package/src/components/tools/select/select-candidates.ts +15 -11
  119. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  120. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  121. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  122. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  123. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  124. package/src/components/tools/shared/pointer-support-cap.ts +17 -5
  125. package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
  126. package/src/components/tools/shared/surface-rejection.tsx +14 -0
  127. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  128. package/src/components/tools/stair/stair-defaults.ts +0 -3
  129. package/src/components/tools/stair/stair-tool.tsx +90 -42
  130. package/src/components/tools/tool-manager.tsx +82 -23
  131. package/src/components/tools/wall/wall-drafting.test.ts +19 -4
  132. package/src/components/tools/zone/zone-tool.tsx +4 -12
  133. package/src/components/ui/action-menu/action-button.tsx +3 -0
  134. package/src/components/ui/action-menu/camera-actions.tsx +2 -1
  135. package/src/components/ui/action-menu/control-modes.tsx +2 -35
  136. package/src/components/ui/action-menu/measurement-control.tsx +3 -10
  137. package/src/components/ui/build-panel/build-panel.tsx +235 -0
  138. package/src/components/ui/command-palette/editor-commands.tsx +4 -30
  139. package/src/components/ui/command-palette/index.tsx +5 -24
  140. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  141. package/src/components/ui/controls/material-paint-panel.tsx +101 -91
  142. package/src/components/ui/controls/material-picker.tsx +8 -67
  143. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  144. package/src/components/ui/controls/select-control.tsx +65 -0
  145. package/src/components/ui/controls/slider-control.tsx +76 -31
  146. package/src/components/ui/floating-level-selector.tsx +9 -14
  147. package/src/components/ui/helpers/building-helper.tsx +4 -1
  148. package/src/components/ui/helpers/contextual-helper-panel.tsx +154 -70
  149. package/src/components/ui/helpers/helper-manager.test.tsx +69 -0
  150. package/src/components/ui/helpers/helper-manager.tsx +348 -19
  151. package/src/components/ui/helpers/item-helper.tsx +8 -0
  152. package/src/components/ui/helpers/registered-tool-helper.tsx +10 -33
  153. package/src/components/ui/icon-ref.tsx +45 -1
  154. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  155. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  156. package/src/components/ui/node-icon-image.tsx +16 -0
  157. package/src/components/ui/panels/collections/collections-popover.tsx +20 -14
  158. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  159. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  160. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  161. package/src/components/ui/panels/mobile-selection-bar.tsx +2 -2
  162. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  163. package/src/components/ui/panels/multi-field-value.ts +6 -3
  164. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  165. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  166. package/src/components/ui/panels/multi-selection-panel.tsx +43 -4
  167. package/src/components/ui/panels/node-display.ts +8 -3
  168. package/src/components/ui/panels/panel-manager.tsx +103 -68
  169. package/src/components/ui/panels/panel-wrapper.tsx +78 -36
  170. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  171. package/src/components/ui/panels/parametric-inspector.tsx +7 -19
  172. package/src/components/ui/panels/reference-panel.tsx +2 -3
  173. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  174. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  175. package/src/components/ui/panels/room-name-options.ts +47 -0
  176. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  177. package/src/components/ui/panels/room-panel.tsx +725 -0
  178. package/src/components/ui/panels/shape-choice.tsx +58 -0
  179. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  180. package/src/components/ui/primitives/color-dot.tsx +36 -24
  181. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  182. package/src/components/ui/primitives/sidebar.tsx +2 -0
  183. package/src/components/ui/right-stack.test.ts +29 -0
  184. package/src/components/ui/right-stack.tsx +145 -0
  185. package/src/components/ui/sidebar/icon-rail.tsx +5 -0
  186. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  187. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +615 -372
  188. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +7 -7
  189. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  190. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  191. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  192. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  193. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  194. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  195. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  196. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  197. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  198. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  199. package/src/components/ui/sidebar/panels/site-panel/index.tsx +176 -211
  200. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +0 -27
  211. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  215. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +0 -2
  216. package/src/components/ui/sidebar/tab-bar.tsx +10 -0
  217. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  218. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  219. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  220. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  221. package/src/components/viewer/floorplan-preview.tsx +6 -18
  222. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  223. package/src/components/viewer/viewer-rooms.tsx +175 -0
  224. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  225. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  226. package/src/components/viewer/viewer-stage.tsx +2 -2
  227. package/src/components/viewer-zone-system.tsx +9 -9
  228. package/src/hooks/use-auto-save.integration.test.tsx +311 -0
  229. package/src/hooks/use-auto-save.ts +55 -14
  230. package/src/hooks/use-ceiling-events.test.ts +1 -0
  231. package/src/hooks/use-ceiling-events.ts +2 -0
  232. package/src/hooks/use-grid-events.test.ts +2 -0
  233. package/src/hooks/use-grid-events.ts +16 -11
  234. package/src/hooks/use-keyboard.test.ts +182 -0
  235. package/src/hooks/use-keyboard.ts +122 -91
  236. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  237. package/src/hooks/use-open-wall-ends.ts +238 -0
  238. package/src/hooks/use-placement-notice.test.ts +48 -0
  239. package/src/hooks/use-placement-notice.ts +42 -0
  240. package/src/hooks/use-selected-room.ts +143 -0
  241. package/src/index.tsx +306 -39
  242. package/src/lib/catalog-panel-model.ts +39 -0
  243. package/src/lib/catalog-selection.test.ts +27 -0
  244. package/src/lib/catalog-selection.ts +8 -0
  245. package/src/lib/ceiling-edit-session.test.ts +321 -0
  246. package/src/lib/ceiling-edit-session.ts +206 -0
  247. package/src/lib/ceiling-region-session.test.ts +179 -0
  248. package/src/lib/contextual-help.test.ts +27 -1
  249. package/src/lib/contextual-help.ts +16 -9
  250. package/src/lib/continuation.test.ts +19 -1
  251. package/src/lib/continuation.ts +25 -4
  252. package/src/lib/door-interaction.ts +14 -0
  253. package/src/lib/editor-level-display.test.ts +96 -0
  254. package/src/lib/editor-level-display.ts +48 -0
  255. package/src/lib/editor-views.ts +144 -0
  256. package/src/lib/export-geometry-wait.test.ts +90 -0
  257. package/src/lib/floor-edit-session.ts +153 -0
  258. package/src/lib/floor-footprints.test.ts +563 -0
  259. package/src/lib/floor-footprints.ts +395 -0
  260. package/src/lib/floor-foundation-support.test.ts +283 -0
  261. package/src/lib/floor-opening-draft.ts +218 -0
  262. package/src/lib/floor-openings.test.ts +216 -0
  263. package/src/lib/floor-region-geometry.ts +191 -0
  264. package/src/lib/floor-region-session.test.ts +485 -0
  265. package/src/lib/floor-region-session.ts +589 -0
  266. package/src/lib/floor-region-snap.ts +101 -0
  267. package/src/lib/floor-step-hover.test.ts +32 -0
  268. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  269. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  270. package/src/lib/floorplan/dimension-drive.ts +479 -0
  271. package/src/lib/floorplan/floorplan-export.test.ts +15 -9
  272. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  273. package/src/lib/floorplan/floorplan-extension.ts +48 -0
  274. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  275. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  276. package/src/lib/floorplan/floorplan-pdfkit-document.ts +339 -18
  277. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +138 -8
  278. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +56 -25
  279. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  280. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  281. package/src/lib/floorplan/geometry.ts +13 -6
  282. package/src/lib/floorplan/index.ts +0 -9
  283. package/src/lib/floorplan/items.ts +5 -2
  284. package/src/lib/floorplan/marks.test.ts +213 -0
  285. package/src/lib/floorplan/marks.ts +238 -0
  286. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  287. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  288. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  289. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  290. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  291. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  292. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  293. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  294. package/src/lib/floorplan/schedules.test.ts +189 -0
  295. package/src/lib/floorplan/schedules.ts +339 -0
  296. package/src/lib/floorplan/sheet-export.test.ts +100 -0
  297. package/src/lib/floorplan/sheet-export.ts +318 -0
  298. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +779 -0
  299. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  300. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  301. package/src/lib/floorplan/site-plan/coverage.ts +232 -0
  302. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  303. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  304. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  305. package/src/lib/floorplan/site-plan/front-edge.ts +156 -0
  306. package/src/lib/floorplan/site-plan/geometry.test.ts +275 -0
  307. package/src/lib/floorplan/site-plan/geometry.ts +557 -0
  308. package/src/lib/floorplan/site-plan/index.ts +71 -0
  309. package/src/lib/floorplan/site-plan/site-annotations.test.ts +366 -0
  310. package/src/lib/floorplan/site-plan/site-annotations.ts +1083 -0
  311. package/src/lib/floorplan/site-plan/site-parts.ts +379 -0
  312. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  313. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  314. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  315. package/src/lib/floorplan/site-plan/yards.test.ts +69 -0
  316. package/src/lib/floorplan/types.ts +1 -27
  317. package/src/lib/fresh-planar-placement.test.ts +36 -0
  318. package/src/lib/fresh-planar-placement.ts +191 -36
  319. package/src/lib/geometry-script/author.ts +152 -0
  320. package/src/lib/geometry-script/client.ts +152 -0
  321. package/src/lib/geometry-script/protocol.ts +12 -0
  322. package/src/lib/gesture-lifecycle.test.ts +595 -0
  323. package/src/lib/gesture-lifecycle.ts +290 -0
  324. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  325. package/src/lib/glb-export.test.ts +544 -16
  326. package/src/lib/glb-export.ts +256 -125
  327. package/src/lib/grid-frame.test.ts +19 -0
  328. package/src/lib/grid-frame.ts +18 -0
  329. package/src/lib/history.test.ts +6 -0
  330. package/src/lib/history.ts +7 -0
  331. package/src/lib/hud-title.ts +109 -0
  332. package/src/lib/hydration-gated-storage.ts +49 -0
  333. package/src/lib/ifc-export.test.ts +174 -0
  334. package/src/lib/ifc-export.ts +201 -0
  335. package/src/lib/ifc-parts.ts +15 -0
  336. package/src/lib/inspector-expanded.test.ts +44 -0
  337. package/src/lib/inspector-expanded.ts +36 -0
  338. package/src/lib/interaction/scope.ts +27 -1
  339. package/src/lib/keyboard-pan.test.ts +184 -0
  340. package/src/lib/keyboard-pan.ts +238 -0
  341. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  342. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  343. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  344. package/src/lib/lot/clean-ring.test.ts +186 -0
  345. package/src/lib/lot/clean-ring.ts +217 -0
  346. package/src/lib/lot/dossier.test.ts +202 -0
  347. package/src/lib/lot/dossier.ts +498 -0
  348. package/src/lib/lot/drop-in.test.ts +326 -0
  349. package/src/lib/lot/drop-in.ts +407 -0
  350. package/src/lib/lot/index.ts +43 -0
  351. package/src/lib/lot/lot-patch.test.ts +340 -0
  352. package/src/lib/lot/lot-patch.ts +394 -0
  353. package/src/lib/lot/parcel-provider.ts +43 -0
  354. package/src/lib/lot/terrain.test.ts +161 -0
  355. package/src/lib/lot/terrain.ts +266 -0
  356. package/src/lib/material-catalog-model.ts +74 -0
  357. package/src/lib/material-paint-panel-model.ts +80 -0
  358. package/src/lib/material-paint.ts +4 -0
  359. package/src/lib/mezzanine-actions.test.ts +342 -0
  360. package/src/lib/mezzanine-draft.test.ts +501 -0
  361. package/src/lib/mezzanine-draft.ts +278 -0
  362. package/src/lib/mezzanine-messages.ts +26 -0
  363. package/src/lib/mezzanine-selection.test.ts +207 -0
  364. package/src/lib/mezzanine-stairs.ts +46 -0
  365. package/src/lib/model-export.ts +3 -1
  366. package/src/lib/node-action-movement.ts +17 -6
  367. package/src/lib/node-display-label.ts +37 -0
  368. package/src/lib/orbit-floor-clearance.test.ts +165 -0
  369. package/src/lib/orbit-floor-clearance.ts +106 -0
  370. package/src/lib/overlay-transform.test.ts +28 -0
  371. package/src/lib/overlay-transform.ts +12 -0
  372. package/src/lib/paint-commit-route.test.ts +37 -0
  373. package/src/lib/paint-commit-route.ts +17 -0
  374. package/src/lib/paint-eyedropper.test.tsx +165 -0
  375. package/src/lib/paint-eyedropper.ts +99 -0
  376. package/src/lib/paint-pass-through.test.ts +111 -0
  377. package/src/lib/paint-pass-through.ts +76 -0
  378. package/src/lib/paint-region-hover.ts +25 -0
  379. package/src/lib/paint-region-mode.ts +154 -0
  380. package/src/lib/paint-regions.test.ts +166 -0
  381. package/src/lib/paint-regions.ts +192 -0
  382. package/src/lib/paint-scope.test.ts +317 -35
  383. package/src/lib/paint-scope.ts +266 -25
  384. package/src/lib/panel-rows.ts +96 -0
  385. package/src/lib/panel-tool-options.test.ts +34 -0
  386. package/src/lib/panel-tool-options.ts +46 -0
  387. package/src/lib/parametric-node-update.ts +37 -0
  388. package/src/lib/plate-paint-affected.test.ts +300 -0
  389. package/src/lib/plate-paint-affected.ts +177 -0
  390. package/src/lib/plugin-chat.ts +50 -0
  391. package/src/lib/plugin-panels.test.ts +25 -0
  392. package/src/lib/plugin-panels.ts +94 -0
  393. package/src/lib/print-shell-compiler.ts +1 -0
  394. package/src/lib/registered-draft-snap.ts +12 -0
  395. package/src/lib/roof-duplication.ts +10 -2
  396. package/src/lib/room-assembly-overlay.test.ts +275 -0
  397. package/src/lib/room-assembly-overlay.ts +266 -0
  398. package/src/lib/room-built-on.test.ts +379 -0
  399. package/src/lib/room-built-on.ts +138 -0
  400. package/src/lib/room-construction-commands.ts +712 -0
  401. package/src/lib/room-construction.test.ts +746 -0
  402. package/src/lib/room-construction.ts +385 -0
  403. package/src/lib/room-controls-phase6.test.ts +630 -0
  404. package/src/lib/room-divide-session.ts +523 -0
  405. package/src/lib/room-handle-drag.ts +1068 -0
  406. package/src/lib/room-name-catalog.test.ts +90 -0
  407. package/src/lib/room-name-catalog.ts +168 -0
  408. package/src/lib/room-push-dimensions.test.ts +311 -0
  409. package/src/lib/room-push-dimensions.ts +251 -0
  410. package/src/lib/room-selection-commands.ts +62 -0
  411. package/src/lib/room-selection.test.ts +973 -0
  412. package/src/lib/room-selection.ts +417 -0
  413. package/src/lib/room-structure-commands.ts +94 -0
  414. package/src/lib/room-structure.test.ts +921 -0
  415. package/src/lib/room-transform-session.ts +570 -0
  416. package/src/lib/room-zone-routing.test.ts +294 -0
  417. package/src/lib/room-zone-routing.ts +197 -0
  418. package/src/lib/scene-clipboard.test.ts +364 -0
  419. package/src/lib/scene-clipboard.ts +229 -48
  420. package/src/lib/scene-material-refs.test.ts +79 -0
  421. package/src/lib/scene-material-refs.ts +79 -0
  422. package/src/lib/scene.test.ts +87 -0
  423. package/src/lib/scene.ts +39 -45
  424. package/src/lib/selection-routing.test.ts +129 -45
  425. package/src/lib/selection-routing.ts +53 -74
  426. package/src/lib/session-writes.test.ts +120 -0
  427. package/src/lib/session-writes.ts +205 -0
  428. package/src/lib/sidebar-panel.ts +62 -0
  429. package/src/lib/site-boundary.test.ts +4 -6
  430. package/src/lib/site-boundary.ts +1 -1
  431. package/src/lib/snapping-mode.test.ts +17 -2
  432. package/src/lib/snapping-mode.ts +19 -7
  433. package/src/lib/spatial-drag-plane.test.ts +53 -0
  434. package/src/lib/spatial-drag-plane.ts +19 -0
  435. package/src/lib/spatial-pointer-input.test.ts +72 -0
  436. package/src/lib/spatial-pointer-input.ts +62 -0
  437. package/src/lib/spatial-snap.test.ts +47 -0
  438. package/src/lib/terrace-draft.test.ts +145 -0
  439. package/src/lib/terrace-draft.ts +213 -0
  440. package/src/lib/terrain-panel-model.ts +117 -0
  441. package/src/lib/tool-cursor-icon.test.ts +16 -0
  442. package/src/lib/tool-cursor-icon.ts +11 -0
  443. package/src/lib/typing-target.ts +9 -0
  444. package/src/lib/units.ts +77 -39
  445. package/src/lib/vector-edges.test.ts +58 -0
  446. package/src/lib/vector-edges.ts +400 -0
  447. package/src/lib/viewer-selection.test.ts +577 -0
  448. package/src/lib/viewer-selection.ts +569 -0
  449. package/src/lib/wall-draw-variant.test.ts +71 -0
  450. package/src/lib/wall-draw-variant.ts +91 -0
  451. package/src/lib/wall-paint-region-session.ts +246 -0
  452. package/src/lib/wall-paint-region.test.ts +373 -0
  453. package/src/lib/wall-polygon-draft.test.ts +262 -0
  454. package/src/lib/wall-polygon-draft.ts +286 -0
  455. package/src/lib/wall-push.test.ts +453 -0
  456. package/src/lib/wall-region-face.ts +352 -0
  457. package/src/lib/wall-region-handles.test.ts +304 -0
  458. package/src/lib/wall-region-handles.ts +368 -0
  459. package/src/lib/wall-region-list.test.ts +119 -0
  460. package/src/lib/wall-region-list.ts +136 -0
  461. package/src/lib/wall-region-snap.ts +110 -0
  462. package/src/lib/window-interaction.ts +14 -0
  463. package/src/lib/world-grid-snap.test.ts +48 -0
  464. package/src/lib/world-grid-snap.ts +9 -5
  465. package/src/lib/zone-content.ts +4 -1
  466. package/src/lib/zone-name.test.ts +17 -0
  467. package/src/lib/zone-name.ts +17 -0
  468. package/src/store/opens-in-select-mode.test.ts +223 -0
  469. package/src/store/paint-mode.test.ts +188 -0
  470. package/src/store/rotation-snapping.test.ts +53 -0
  471. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  472. package/src/store/tool-mode.test.ts +137 -6
  473. package/src/store/use-delete-confirmation.ts +28 -1
  474. package/src/store/use-drawing-view.test.ts +24 -3
  475. package/src/store/use-drawing-view.ts +29 -1
  476. package/src/store/use-editor.tsx +442 -323
  477. package/src/store/use-floorplan-draft-preview.ts +23 -1
  478. package/src/store/use-floorplan-marquee.ts +12 -2
  479. package/src/store/use-interaction-scope.ts +155 -6
  480. package/src/store/use-quick-measurement-hud.ts +3 -3
  481. package/src/store/use-stair-build-preview.ts +13 -5
  482. package/src/store/use-wall-move-ghosts.ts +4 -2
  483. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  484. package/src/store/use-wall-region-handles-selection.ts +139 -0
  485. package/src/store/view-layout.test.ts +175 -0
  486. package/src/store/view-layout.ts +196 -0
  487. package/src/test-utils/immediate-animation-frames.ts +16 -0
  488. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  489. package/src/test-utils/selection-harness.ts +82 -0
  490. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  491. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  492. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  493. package/src/components/tools/fence/fence-drafting.ts +0 -266
  494. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  495. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  496. package/src/lib/floorplan/stairs.ts +0 -489
  497. package/src/lib/level-duplication.test.ts +0 -144
  498. package/src/lib/level-duplication.ts +0 -195
@@ -0,0 +1,712 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ area,
5
+ type CeilingNode,
6
+ checkRoomFloor,
7
+ clampRoomFloorHandle,
8
+ containsPoint,
9
+ GROUND_SUPPORT_ID,
10
+ generateId,
11
+ getRoomBaseElevation,
12
+ getRoomRelativeFloorElevation,
13
+ intersection,
14
+ isDerivedNode,
15
+ justificationForFaceOnLine,
16
+ lockOutsideFaces,
17
+ type NodeChange,
18
+ planWallJustification,
19
+ resolveCeilingHeight,
20
+ roomFloorElevationFromRelative,
21
+ roomSideFaces,
22
+ runAsSingleSceneHistoryStep,
23
+ type StructureConflict,
24
+ type StructureNodes,
25
+ type StructurePlan,
26
+ setZoneEdges,
27
+ setZoneIntent,
28
+ useScene,
29
+ } from '@pascal-app/core'
30
+ import useDeleteConfirmation, {
31
+ type DeleteConfirmationRequest,
32
+ } from '../store/use-delete-confirmation'
33
+ import useEditor from '../store/use-editor'
34
+ import { footprintHeightValue } from './floor-footprints'
35
+ import { roomOwnPlate, roomSharedFootprint } from './room-built-on'
36
+ import { roomClearPolygon, roomConstructionState, roomFloorContents } from './room-construction'
37
+ import {
38
+ mezzanineElevationBounds,
39
+ ownFloorElevationBounds,
40
+ planRoomElevation,
41
+ roomFloorElevation,
42
+ } from './room-handle-drag'
43
+ import { applyRoomPlan } from './room-structure-commands'
44
+ import { showRoomNotice } from './room-transform-session'
45
+
46
+ export type RoomConstructionPart = 'floor' | 'walls' | 'ceiling'
47
+ export type RoomConstructionResult =
48
+ | { status: 'applied' | 'unchanged' | 'confirming' }
49
+ | { status: 'conflict'; message: string }
50
+
51
+ // Planner messages address callers ("Pass dropOpenings…"); the panel speaks to people.
52
+ const CONFLICT_MESSAGES: Record<string, string> = {
53
+ 'covered-existing-wall': 'A wall already runs along this edge.',
54
+ 'segment-too-short': 'An edge is too short for a wall.',
55
+ 'occupied-split': 'An opening sits where the wall would split.',
56
+ 'overlapping-spans': 'The room changed. Try again.',
57
+ 'invalid-span': 'The room changed. Try again.',
58
+ 'stale-span': 'The room changed. Try again.',
59
+ 'mixed-exterior-faces': 'A wall is outside on both faces. Divide it first.',
60
+ 'junction-conflict': 'Locking would break a room on this floor.',
61
+ }
62
+
63
+ /** Clear floor area new walls may take before Add walls asks first, m². */
64
+ export const TRIVIAL_AREA_LOSS = 0.01
65
+
66
+ export function roomConflictMessage(conflicts: readonly StructureConflict[] = []) {
67
+ return [...new Set(conflicts.map((c) => CONFLICT_MESSAGES[c.code] ?? c.message))].join(' ')
68
+ }
69
+
70
+ function commit(plan: StructurePlan): RoomConstructionResult {
71
+ if (plan.conflicts?.length)
72
+ return { status: 'conflict', message: roomConflictMessage(plan.conflicts) }
73
+ if (!plan.changes.length) return { status: 'unchanged' }
74
+ applyRoomPlan(plan)
75
+ return { status: 'applied' }
76
+ }
77
+
78
+ const nodes = (): StructureNodes => useScene.getState().nodes
79
+ const sorted = (ids: readonly string[]) => [...ids].sort()
80
+ const deletes = (ids: readonly string[]) =>
81
+ ids.map((id): NodeChange => ({ op: 'delete', id: id as AnyNodeId }))
82
+
83
+ /**
84
+ * Opens the house confirmation for one described change. At confirm time the
85
+ * choice re-derives what the dialog showed; when the scene moved on (a door
86
+ * added, an item placed) it re-asks with the new picture instead of applying.
87
+ */
88
+ function ask(
89
+ zoneId: string,
90
+ request: Omit<DeleteConfirmationRequest, 'onConfirm' | 'onKeepContents'>,
91
+ choices: {
92
+ /** What the dialog shows, recomputed from the scene at the moment of choice. */
93
+ shown: () => unknown
94
+ reask: () => RoomConstructionResult
95
+ confirm: () => void
96
+ keep?: () => void
97
+ },
98
+ ): RoomConstructionResult {
99
+ const shown = JSON.stringify(choices.shown())
100
+ const guarded = (apply: () => void) => () => {
101
+ if (!nodes()[zoneId as AnyNodeId]) return
102
+ if (JSON.stringify(choices.shown()) !== shown) {
103
+ choices.reask()
104
+ return
105
+ }
106
+ apply()
107
+ }
108
+ useDeleteConfirmation.getState().requestConfirmation({
109
+ ...request,
110
+ onConfirm: guarded(choices.confirm),
111
+ onKeepContents: choices.keep && guarded(choices.keep),
112
+ })
113
+ return { status: 'confirming' }
114
+ }
115
+
116
+ /** Turns the room's unshared wall spans into separators; shared walls stay. */
117
+ export function roomWallRemovalPlan(
118
+ scene: StructureNodes,
119
+ zoneId: string,
120
+ dropOpenings: boolean,
121
+ mintId = generateId,
122
+ ): StructurePlan {
123
+ const removable = roomConstructionState(scene, zoneId)?.walls.removable ?? []
124
+ if (!removable.length) return { changes: [] }
125
+ return setZoneEdges(scene, {
126
+ zoneId,
127
+ edges: removable.map((spanRef) => ({ spanRef, kind: 'separator' as const })),
128
+ dropOpenings,
129
+ mintId,
130
+ })
131
+ }
132
+
133
+ /** Turns the room's separator spans into walls with the wall tool's thickness and height. */
134
+ export function roomWallAdditionPlan(
135
+ scene: StructureNodes,
136
+ zoneId: string,
137
+ mintId = generateId,
138
+ ): StructurePlan {
139
+ const separators = roomConstructionState(scene, zoneId)?.walls.separators ?? []
140
+ if (!separators.length) return { changes: [] }
141
+ const defaults = useEditor.getState().toolDefaults.wall
142
+ const wall = {
143
+ ...(typeof defaults?.thickness === 'number' && { thickness: defaults.thickness }),
144
+ ...(typeof defaults?.height === 'number' && { height: defaults.height }),
145
+ }
146
+ return setZoneEdges(scene, {
147
+ zoneId,
148
+ edges: separators.map((spanRef) => ({ spanRef, kind: 'wall' as const, wall })),
149
+ mintId,
150
+ })
151
+ }
152
+
153
+ function applyChanges(scene: StructureNodes, changes: readonly NodeChange[]): StructureNodes {
154
+ const next: Record<string, AnyNode> = { ...scene }
155
+ for (const change of changes) {
156
+ if (change.op === 'delete') delete next[change.id]
157
+ else if (change.op === 'create') next[change.node.id] = change.node
158
+ else if (next[change.id]) next[change.id] = { ...next[change.id], ...change.data } as AnyNode
159
+ }
160
+ return next
161
+ }
162
+
163
+ const clearArea = (scene: StructureNodes, zoneId: string) => {
164
+ const clear = roomClearPolygon(scene, zoneId)
165
+ return clear ? area([clear]) : 0
166
+ }
167
+
168
+ /** Add walls, with how many walls it builds and the clear floor area the room gives up to them. */
169
+ export function roomWallAdditionPreview(
170
+ scene: StructureNodes,
171
+ zoneId: string,
172
+ mintId = generateId,
173
+ ) {
174
+ const plan = roomWallAdditionPlan(scene, zoneId, mintId)
175
+ if (plan.conflicts?.length || !plan.changes.length) return { plan, wallCount: 0, areaLoss: 0 }
176
+ const after = applyChanges(scene, plan.changes)
177
+ return {
178
+ plan,
179
+ // One per separator built on; splitting the walls it meets is not news.
180
+ wallCount: roomConstructionState(scene, zoneId)?.walls.separators.length ?? 0,
181
+ areaLoss: Math.max(0, clearArea(scene, zoneId) - clearArea(after, zoneId)),
182
+ }
183
+ }
184
+
185
+ function requestWallAddition(zoneId: string): RoomConstructionResult {
186
+ const preview = roomWallAdditionPreview(nodes(), zoneId)
187
+ if (preview.areaLoss <= TRIVIAL_AREA_LOSS) return commit(preview.plan)
188
+ const state = roomConstructionState(nodes(), zoneId)!
189
+ return ask(
190
+ zoneId,
191
+ {
192
+ count: preview.wallCount,
193
+ construction: {
194
+ part: 'walls',
195
+ action: 'add',
196
+ roomName: state.name,
197
+ hostedIds: [],
198
+ wallCount: preview.wallCount,
199
+ areaLoss: preview.areaLoss,
200
+ },
201
+ },
202
+ {
203
+ shown: () => {
204
+ const current = roomWallAdditionPreview(nodes(), zoneId)
205
+ return {
206
+ separators: roomConstructionState(nodes(), zoneId)?.walls.separators,
207
+ walls: current.wallCount,
208
+ loss: current.areaLoss.toFixed(2),
209
+ }
210
+ },
211
+ reask: () => requestWallAddition(zoneId),
212
+ confirm: () => commit(roomWallAdditionPlan(nodes(), zoneId)),
213
+ },
214
+ )
215
+ }
216
+
217
+ /** What a wall removal takes along: the openings/objects it hosts and every node it deletes. */
218
+ function wallRemovalOutcome(zoneId: string) {
219
+ const scene = nodes()
220
+ const preview = roomWallRemovalPlan(scene, zoneId, false)
221
+ const final = roomWallRemovalPlan(scene, zoneId, true)
222
+ const deleted = final.changes.flatMap((c) => (c.op === 'delete' ? [c.id as string] : []))
223
+ // The planner stops at the first wall that hosts something, so its conflict
224
+ // names only that wall's openings; the confirmed plan's deletions name them all.
225
+ const asks = preview.conflicts?.some((c) => c.code === 'hosted-openings')
226
+ return {
227
+ preview,
228
+ final,
229
+ hostedIds: asks
230
+ ? deleted.filter((id) => scene[id]?.type !== 'wall' && scene[id]?.type !== 'separator')
231
+ : null,
232
+ deleted: sorted(deleted),
233
+ }
234
+ }
235
+
236
+ function requestWallRemoval(zoneId: string): RoomConstructionResult {
237
+ const outcome = wallRemovalOutcome(zoneId)
238
+ if (!outcome.hostedIds) return commit(outcome.preview)
239
+ const state = roomConstructionState(nodes(), zoneId)!
240
+ return ask(
241
+ zoneId,
242
+ {
243
+ count: outcome.hostedIds.length,
244
+ construction: {
245
+ part: 'walls',
246
+ roomName: state.name,
247
+ hostedIds: outcome.hostedIds,
248
+ wallCount: new Set(state.walls.removable.map((span) => span.boundaryId)).size,
249
+ sharedWalls: state.walls.sharedWallIds.length > 0,
250
+ },
251
+ conflict: outcome.final.conflicts?.length
252
+ ? roomConflictMessage(outcome.final.conflicts)
253
+ : undefined,
254
+ },
255
+ {
256
+ // A door placed while the dialog is open changes both; neither may slip through.
257
+ shown: () => {
258
+ const current = wallRemovalOutcome(zoneId)
259
+ return { hosted: sorted(current.hostedIds ?? []), deleted: current.deleted }
260
+ },
261
+ reask: () => requestWallRemoval(zoneId),
262
+ confirm: () => commit(roomWallRemovalPlan(nodes(), zoneId, true)),
263
+ },
264
+ )
265
+ }
266
+
267
+ /** Moves a ceiling-hung node onto the level at the height it hangs at now. */
268
+ function dropFromCeiling(scene: StructureNodes, id: string): NodeChange[] {
269
+ const node = scene[id]
270
+ const ceiling = node?.parentId ? scene[node.parentId] : undefined
271
+ if (!(node && ceiling?.type === 'ceiling' && 'position' in node)) return []
272
+ const [x, y, z] = node.position as [number, number, number]
273
+ return [
274
+ {
275
+ op: 'update',
276
+ id: node.id,
277
+ data: {
278
+ parentId: ceiling.parentId,
279
+ position: [x, y + resolveCeilingHeight(ceiling, scene), z],
280
+ } as Partial<AnyNode>,
281
+ },
282
+ ]
283
+ }
284
+
285
+ function ceilingRemovalOutcome(zoneId: string) {
286
+ const ceiling = roomConstructionState(nodes(), zoneId)?.ceiling
287
+ return {
288
+ manualIds: ceiling?.manualIds ?? [],
289
+ hostedIds: [...(ceiling?.hostedIds ?? []), ...(ceiling?.manualHostedIds ?? [])],
290
+ }
291
+ }
292
+
293
+ /** Names of the other rooms a hand-drawn ceiling also covers. */
294
+ function alsoCovers(scene: StructureNodes, zoneId: string, ceilings: CeilingNode[]) {
295
+ const outline = ceilings.map((c) => ({ outer: c.polygon, holes: c.holes }))
296
+ return Object.values(scene).flatMap((node) =>
297
+ node.type === 'zone' &&
298
+ node.spaceRole === 'room' &&
299
+ node.id !== zoneId &&
300
+ node.parentId === scene[zoneId]?.parentId &&
301
+ area(intersection({ outer: node.polygon, holes: node.holes ?? [] }, outline)) >
302
+ TRIVIAL_AREA_LOSS
303
+ ? [node.name.trim() || 'another room']
304
+ : [],
305
+ )
306
+ }
307
+
308
+ function requestCeilingRemoval(zoneId: string): RoomConstructionResult {
309
+ const state = roomConstructionState(nodes(), zoneId)
310
+ if (!state) return { status: 'unchanged' }
311
+ const { manualIds, hostedIds } = ceilingRemovalOutcome(zoneId)
312
+ const plan = (keep: boolean): StructurePlan => {
313
+ const scene = nodes()
314
+ const intent = setZoneIntent(scene, { zoneId, patch: { hasCeiling: false } })
315
+ return {
316
+ ...intent,
317
+ changes: [
318
+ ...intent.changes,
319
+ ...(keep ? hostedIds.flatMap((id) => dropFromCeiling(scene, id)) : deletes(hostedIds)),
320
+ ...deletes(manualIds),
321
+ ],
322
+ }
323
+ }
324
+ if (!(hostedIds.length || manualIds.length)) return commit(plan(false))
325
+ const manual = manualIds.map((id) => nodes()[id] as CeilingNode)
326
+ return ask(
327
+ zoneId,
328
+ {
329
+ count: hostedIds.length + manualIds.length,
330
+ construction: {
331
+ part: 'ceiling',
332
+ roomName: state.name,
333
+ hostedIds,
334
+ manualCeilings: manual.map((c) => c.name?.trim() ?? ''),
335
+ alsoCovers: alsoCovers(nodes(), zoneId, manual),
336
+ },
337
+ },
338
+ {
339
+ shown: () => {
340
+ const current = ceilingRemovalOutcome(zoneId)
341
+ return { manual: sorted(current.manualIds), hosted: sorted(current.hostedIds) }
342
+ },
343
+ reask: () => requestCeilingRemoval(zoneId),
344
+ confirm: () => commit(plan(false)),
345
+ keep: hostedIds.length ? () => commit(plan(true)) : undefined,
346
+ },
347
+ )
348
+ }
349
+
350
+ function requestFloorRemoval(zoneId: string): RoomConstructionResult {
351
+ const state = roomConstructionState(nodes(), zoneId)
352
+ if (!state) return { status: 'unchanged' }
353
+ const contents = roomFloorContents(nodes(), zoneId)
354
+ const plan = (keep: boolean): StructurePlan => {
355
+ const scene = nodes()
356
+ const intent = setZoneIntent(scene, { zoneId, patch: { hasFloor: false } })
357
+ const levelId = scene[zoneId]?.parentId
358
+ // Kept items rest on the level base instead, lifted by the plate they stood on.
359
+ const kept = contents.flatMap(({ id, elevation }): NodeChange[] => {
360
+ const node = scene[id]
361
+ if (!(node && 'position' in node)) return []
362
+ const [x, y, z] = node.position as [number, number, number]
363
+ return [
364
+ {
365
+ op: 'update',
366
+ id: node.id,
367
+ data: {
368
+ position: [x, y + elevation, z],
369
+ supportSlabId: GROUND_SUPPORT_ID,
370
+ ...(node.parentId !== levelId && { parentId: levelId }),
371
+ } as Partial<AnyNode>,
372
+ },
373
+ ]
374
+ })
375
+ return {
376
+ ...intent,
377
+ changes: [
378
+ ...intent.changes,
379
+ ...(keep ? kept : deletes(contents.map((content) => content.id))),
380
+ ],
381
+ }
382
+ }
383
+ if (!contents.length) return commit(plan(false))
384
+ const ids = contents.map((content) => content.id)
385
+ return ask(
386
+ zoneId,
387
+ { count: ids.length, construction: { part: 'floor', roomName: state.name, hostedIds: ids } },
388
+ {
389
+ shown: () => roomFloorContents(nodes(), zoneId),
390
+ reask: () => requestFloorRemoval(zoneId),
391
+ confirm: () => commit(plan(false)),
392
+ keep: () => commit(plan(true)),
393
+ },
394
+ )
395
+ }
396
+
397
+ /**
398
+ * Adds a construction part in one undo step: clears the room's opt-out
399
+ * (floor, ceiling) or builds walls on its separators. Walls that would eat
400
+ * into the room's clear floor ask first (`status: 'confirming'`).
401
+ */
402
+ export function addRoomConstruction(
403
+ zoneId: string,
404
+ part: RoomConstructionPart,
405
+ ): RoomConstructionResult {
406
+ if (part === 'walls') return requestWallAddition(zoneId)
407
+ const patch = part === 'floor' ? { hasFloor: true } : { hasCeiling: true }
408
+ return commit(setZoneIntent(nodes(), { zoneId, patch }))
409
+ }
410
+
411
+ /**
412
+ * Removes a construction part in one undo step. Walls that would take doors,
413
+ * windows or objects with them, ceilings holding items or drawn by hand, and
414
+ * floors with furniture standing on them ask first through the house
415
+ * confirmation (`status: 'confirming'`).
416
+ */
417
+ export function removeRoomConstruction(
418
+ zoneId: string,
419
+ part: RoomConstructionPart,
420
+ ): RoomConstructionResult {
421
+ if (part === 'walls') return requestWallRemoval(zoneId)
422
+ if (part === 'ceiling') return requestCeilingRemoval(zoneId)
423
+ return requestFloorRemoval(zoneId)
424
+ }
425
+
426
+ /**
427
+ * Keeps the hand-drawn ceiling over the room as its ceiling: clears the
428
+ * opt-out, and the reconciler, seeing the room covered, generates none.
429
+ */
430
+ export function adoptExistingCeiling(zoneId: string): RoomConstructionResult {
431
+ return commit(setZoneIntent(nodes(), { zoneId, patch: { hasCeiling: true } }))
432
+ }
433
+
434
+ /**
435
+ * Swaps the hand-drawn ceiling(s) over the room for generated room ceilings
436
+ * in one undo step. What hung from them moves onto whichever new ceiling
437
+ * covers it, at the same drop; anything left uncovered stays at its height.
438
+ */
439
+ export function replaceRoomCeiling(zoneId: string): RoomConstructionResult {
440
+ const state = roomConstructionState(nodes(), zoneId)
441
+ if (!state?.ceiling.manualIds.length) return { status: 'unchanged' }
442
+ const scene = nodes()
443
+ const { manualIds, manualHostedIds } = state.ceiling
444
+ const intent = setZoneIntent(scene, { zoneId, patch: { hasCeiling: true } })
445
+ return runAsSingleSceneHistoryStep(useScene, () => {
446
+ const result = commit({
447
+ changes: [
448
+ ...intent.changes,
449
+ ...manualHostedIds.flatMap((id) => dropFromCeiling(scene, id)),
450
+ ...deletes(manualIds),
451
+ ],
452
+ })
453
+ if (result.status !== 'applied') return result
454
+ const after = nodes()
455
+ const generated = Object.values(after).filter(
456
+ (node): node is CeilingNode =>
457
+ node.type === 'ceiling' && node.parentId === scene[zoneId]?.parentId && isDerivedNode(node),
458
+ )
459
+ const rehung = manualHostedIds.flatMap((id): NodeChange[] => {
460
+ const original = scene[id]
461
+ if (!(original && after[id] && 'position' in original)) return []
462
+ const [x, , z] = original.position as [number, number, number]
463
+ const host = generated.find((ceiling) =>
464
+ containsPoint([{ outer: ceiling.polygon, holes: ceiling.holes }], [x, z]),
465
+ )
466
+ return host
467
+ ? [
468
+ {
469
+ op: 'update',
470
+ id: original.id,
471
+ data: { parentId: host.id, position: original.position } as Partial<AnyNode>,
472
+ },
473
+ ]
474
+ : []
475
+ })
476
+ if (rehung.length) applyRoomPlan({ changes: rehung })
477
+ return result
478
+ })
479
+ }
480
+
481
+ /** Justifies the room's exterior walls so their outside faces stay put. One undo step. */
482
+ export function lockRoomOutsideFaces(zoneId: string): RoomConstructionResult {
483
+ return commit(lockOutsideFaces(nodes(), { zoneIds: [zoneId] }))
484
+ }
485
+
486
+ /**
487
+ * The room's walls with an outside (a face no room covers), and whether each
488
+ * already keeps that face on its drawn line — what Keep outside dimensions sets.
489
+ */
490
+ function outsideWalls(scene: StructureNodes, wallIds: readonly string[]) {
491
+ return wallIds.flatMap((wallId) => {
492
+ const wall = scene[wallId]
493
+ const { outside } = wall?.type === 'wall' ? roomSideFaces(scene, wallId) : { outside: null }
494
+ if (wall?.type !== 'wall' || !outside) return []
495
+ return [{ wallId, locked: wall.justification === justificationForFaceOnLine(outside) }]
496
+ })
497
+ }
498
+
499
+ /**
500
+ * Keep outside dimensions as a switch: `null` when the room has no wall with an
501
+ * outside (nothing to lock), else whether every such wall is locked.
502
+ */
503
+ export function roomOutsideFacesLocked(
504
+ scene: StructureNodes,
505
+ wallIds: readonly string[],
506
+ ): boolean | null {
507
+ const walls = outsideWalls(scene, wallIds)
508
+ return walls.length ? walls.every((wall) => wall.locked) : null
509
+ }
510
+
511
+ /** Switches Keep outside dimensions off: the locked walls go back to centred on their line. One undo step. */
512
+ export function unlockRoomOutsideFaces(wallIds: readonly string[]): RoomConstructionResult {
513
+ const scene = nodes()
514
+ return commit({
515
+ changes: outsideWalls(scene, wallIds)
516
+ .filter((wall) => wall.locked)
517
+ .flatMap((wall) => planWallJustification(scene, wall.wallId, undefined))
518
+ .map((patch): NodeChange => ({ op: 'update', ...patch })),
519
+ })
520
+ }
521
+
522
+ /**
523
+ * Moves the room onto another floor, in one undo step (core keeps every
524
+ * surface where it is): a floor key joins that floor, null the shared floor,
525
+ * 'new' a new floor of its own. A refusal says why.
526
+ */
527
+ export function setRoomFloor(zoneId: string, key: string | null): RoomConstructionResult {
528
+ return commit(setZoneIntent(nodes(), { zoneId, patch: { floor: { footprint: key } } }))
529
+ }
530
+
531
+ /**
532
+ * The floor a room's height is measured from: its footprint's floor (a room
533
+ * on a raised house is 0 on the house floor), or for a mezzanine the floor of
534
+ * the room it stands in. A room on its own floor measures from the shared
535
+ * floor beside it, so its height reads the same before and after detaching
536
+ * (from the ground when there is none).
537
+ */
538
+ export function roomFloorBase(scene: StructureNodes, zoneId: string): number {
539
+ const own = roomOwnPlate(scene, zoneId)
540
+ if (own) {
541
+ const shared = roomSharedFootprint(scene, zoneId)
542
+ return shared ? shared.elevation : own.elevation - footprintHeightValue(scene, own)
543
+ }
544
+ const zone = scene[zoneId]
545
+ if (zone?.type === 'zone' && zone.floor?.support === 'open' && zone.hostZoneId) {
546
+ const host = scene[zone.hostZoneId]
547
+ return host?.type === 'zone'
548
+ ? (host.floor?.elevation ?? getRoomBaseElevation(scene, host.id))
549
+ : 0
550
+ }
551
+ return getRoomBaseElevation(scene, zoneId)
552
+ }
553
+
554
+ /** How high the room's floor sits above the floor it is measured from (F − L). */
555
+ export function roomRelativeFloorHeight(scene: StructureNodes, zoneId: string): number {
556
+ const zone = scene[zoneId]
557
+ if (roomOwnPlate(scene, zoneId) || (zone?.type === 'zone' && zone.floor?.support === 'open'))
558
+ return (
559
+ Math.round((roomFloorElevation(scene, zoneId) - roomFloorBase(scene, zoneId)) * 1000) / 1000
560
+ )
561
+ return getRoomRelativeFloorElevation(scene, zoneId)
562
+ }
563
+
564
+ /**
565
+ * Sets the room's floor to `height` above the floor it is measured from, in
566
+ * one undo step. A refused height leaves the scene alone and says why.
567
+ */
568
+ export function setRoomRelativeFloorHeight(
569
+ zoneId: string,
570
+ height: number,
571
+ ): RoomConstructionResult | FloorHeightRefusal {
572
+ const scene = nodes()
573
+ const zone = scene[zoneId]
574
+ const elevation =
575
+ roomOwnPlate(scene, zoneId) || (zone?.type === 'zone' && zone.floor?.support === 'open')
576
+ ? Math.round((roomFloorBase(scene, zoneId) + height) * 1000) / 1000
577
+ : roomFloorElevationFromRelative(scene, zoneId, height)
578
+ return setRoomFloorElevation(zoneId, elevation)
579
+ }
580
+
581
+ /**
582
+ * Sets the room's floor to `elevation` (level-local), in one undo step. A
583
+ * refused elevation leaves the scene alone and says why, a floor limit in
584
+ * heights above the floor the room is measured from.
585
+ */
586
+ export function setRoomFloorElevation(
587
+ zoneId: string,
588
+ elevation: number,
589
+ ): RoomConstructionResult | FloorHeightRefusal {
590
+ const scene = nodes()
591
+ const zone = scene[zoneId]
592
+ const base = roomFloorBase(scene, zoneId)
593
+ const own = roomOwnPlate(scene, zoneId) !== null
594
+ const plan = planRoomElevation(scene, zoneId, elevation)
595
+ const result = commit(plan)
596
+ if (result.status !== 'conflict') return result
597
+ // Core states its limits as floor elevations; the panel speaks in heights
598
+ // above the floor the room stands on, so it gets them in those terms.
599
+ const code = plan.conflicts?.find((c) => FLOOR_LIMIT_CODES.has(c.code))?.code
600
+ if (!code || own || zone?.type !== 'zone' || zone.floor?.support === 'open') return result
601
+ const check = checkRoomFloor(scene, zoneId, elevation)
602
+ return {
603
+ ...result,
604
+ code,
605
+ min: Number.isFinite(check.minElevation) ? check.minElevation - base : null,
606
+ max: Number.isFinite(check.maxElevation) ? check.maxElevation - base : null,
607
+ }
608
+ }
609
+
610
+ /** Refusals whose message states a floor limit, rephrased from `min` / `max`. */
611
+ const FLOOR_LIMIT_CODES = new Set(['floor-headroom', 'floor-sunken-depth', 'floor-opening-fit'])
612
+
613
+ /** A refused room height, with the allowed range above the floor it stands on. */
614
+ export type FloorHeightRefusal = {
615
+ status: 'conflict'
616
+ message: string
617
+ code: string
618
+ min: number | null
619
+ max: number | null
620
+ }
621
+
622
+ /**
623
+ * A refused room height in the panel's terms: the limit as a height above the
624
+ * floor the room stands on (core states it as a floor elevation).
625
+ */
626
+ export function floorHeightRefusalText(
627
+ result: { message: string; code?: string; min?: number | null; max?: number | null },
628
+ length: (meters: number) => string,
629
+ ) {
630
+ const { code, min, max } = result
631
+ if (code === 'floor-sunken-depth' && typeof min === 'number')
632
+ return min < 0
633
+ ? `Too low: the floor can sink at most ${length(-min)} here.`
634
+ : `Too low: the floor must stay at least ${length(min)} up.`
635
+ if ((code === 'floor-headroom' || code === 'floor-opening-fit') && typeof max === 'number') {
636
+ const limit = max < 0 ? `sink at least ${length(-max)}` : `rise at most ${length(max)}`
637
+ return code === 'floor-headroom'
638
+ ? `Too high: the floor can ${limit} and keep headroom.`
639
+ : `Too high: the floor can ${limit} for the doors and windows to fit.`
640
+ }
641
+ return result.message
642
+ }
643
+
644
+ /**
645
+ * The plan stepper's and the 3D elevation handle's commit: the room's floor to
646
+ * `elevation`, a refusal said under the room's pill as the other room actions
647
+ * say theirs.
648
+ */
649
+ export function commitRoomFloorElevation(
650
+ zoneId: string,
651
+ elevation: number,
652
+ length: (meters: number) => string,
653
+ ) {
654
+ const result = setRoomFloorElevation(zoneId, elevation)
655
+ if (result.status === 'conflict')
656
+ showRoomNotice({ zoneId, message: floorHeightRefusalText(result, length) })
657
+ return result
658
+ }
659
+
660
+ /**
661
+ * How far a room on a separate floor walks above (+) or below (−) the shared
662
+ * floor beside it, m: its walking surface against that of the shared-floor
663
+ * room it shares the most walls or open sides with (the shared plate's top
664
+ * when none), not plate against plate. Null on the shared floor, or with no
665
+ * shared floor beside it.
666
+ */
667
+ export function separateFloorOffset(scene: StructureNodes, zoneId: string): number | null {
668
+ const zone = scene[zoneId]
669
+ if (zone?.type !== 'zone' || !zone.floor?.footprint) return null
670
+ const shared = roomSharedFootprint(scene, zoneId)
671
+ if (!shared) return null
672
+ const sides = new Set([...zone.boundaryWallIds, ...zone.boundarySeparatorIds])
673
+ let neighbour: { id: string; count: number } | null = null
674
+ for (const id of shared.zoneIds ?? []) {
675
+ const room = scene[id]
676
+ if (room?.type !== 'zone' || room.spaceRole !== 'room' || room.floor?.support === 'open')
677
+ continue
678
+ const count = [...room.boundaryWallIds, ...room.boundarySeparatorIds].filter((side) =>
679
+ sides.has(side),
680
+ ).length
681
+ if (count > (neighbour?.count ?? 0)) neighbour = { id, count }
682
+ }
683
+ const sharedTop = neighbour ? roomFloorElevation(scene, neighbour.id) : shared.elevation
684
+ return roomFloorElevation(scene, zoneId) - sharedTop
685
+ }
686
+
687
+ /**
688
+ * The floor elevation one plan stepper click lands on: the room's height above
689
+ * the floor it is measured from moved to the next `step` multiple, held to what
690
+ * the 3D handle accepts (a mezzanine's bounds, else the room floor clamp). Null
691
+ * when the clamp leaves the floor where it is.
692
+ */
693
+ export function stepRoomFloorElevation(
694
+ nodes: StructureNodes,
695
+ zoneId: string,
696
+ step: number,
697
+ ): number | null {
698
+ const zone = nodes[zoneId]
699
+ if (zone?.type !== 'zone') return null
700
+ const current = roomFloorElevation(nodes, zoneId)
701
+ const base = roomFloorBase(nodes, zoneId)
702
+ const size = Math.abs(step)
703
+ const relative = (current - base) / size
704
+ const next =
705
+ base + (step > 0 ? Math.floor(relative + 1e-6) + 1 : Math.ceil(relative - 1e-6) - 1) * size
706
+ const bounds = mezzanineElevationBounds(nodes, zoneId) ?? ownFloorElevationBounds(nodes, zoneId)
707
+ const clamped = bounds
708
+ ? Math.min(bounds.max, Math.max(bounds.min, next))
709
+ : clampRoomFloorHandle(nodes, zoneId, next)
710
+ const rounded = Math.round(clamped * 1000) / 1000
711
+ return Math.abs(rounded - current) < 1e-6 ? null : rounded
712
+ }