@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,265 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ area,
5
+ getWallArcData,
6
+ getWallBaseElevationForNodes,
7
+ getWallCurveFrameAt,
8
+ getWallFaceOffsets,
9
+ getWallTop,
10
+ resolveCeilingHeight,
11
+ roomDrawnFloor,
12
+ type WallNode,
13
+ } from '@pascal-app/core'
14
+ import { ShapeUtils, Vector2 } from 'three'
15
+ import type { RoomSelectionGeometry, RoomSelectionRecord } from './room-selection'
16
+
17
+ type Point = readonly [number, number]
18
+ type Ring = readonly Point[]
19
+
20
+ /** Everything the assembly depends on besides the room's topology geometry. */
21
+ export type RoomAssemblyHeights = {
22
+ /** Level-local Y of the room's plate top (the level plane without a plate). */
23
+ floorY: number
24
+ /** Level-local Y of each boundary wall's top. */
25
+ wallTops: ReadonlyMap<string, number>
26
+ ceiling: { polygon: Ring; holes: readonly Ring[]; y: number } | null
27
+ }
28
+
29
+ export type RoomWallFacePortion = { wallId: string; face: 'a' | 'b'; from: Point; to: Point }
30
+
31
+ /** Flat level-local position buffers; the renderer only wraps them. */
32
+ export type RoomAssembly = {
33
+ floor: Float32Array
34
+ surfaces: Float32Array
35
+ outline: Float32Array
36
+ faces: RoomWallFacePortion[]
37
+ }
38
+
39
+ const ON_FACE = 1e-3
40
+ const FLOOR_LIFT = 0.005
41
+ const CEILING_DROP = 0.02
42
+ const CURVE_SAMPLES = 24
43
+ const MIN_PIECE_AREA = 0.01
44
+
45
+ /**
46
+ * Curved boundaries leave hairline slivers between the sampled room face and
47
+ * the sampled wall footprint; they would add stray face runs.
48
+ */
49
+ function clearPieces(geometry: RoomSelectionGeometry) {
50
+ return geometry.clearPolygon.filter((piece) => area([piece]) >= MIN_PIECE_AREA)
51
+ }
52
+
53
+ export function resolveRoomAssemblyHeights(
54
+ room: RoomSelectionRecord,
55
+ nodes: Readonly<Record<string, AnyNode>>,
56
+ ): RoomAssemblyHeights {
57
+ const sceneNodes = nodes as Record<AnyNodeId, AnyNode>
58
+ const { levelId } = room.key
59
+ const zone = nodes[room.zoneId]
60
+ let floorY = 0
61
+ // A drawn slab floors the room even with its floor switched off.
62
+ if (zone?.type === 'zone' && (zone.hasFloor !== false || roomDrawnFloor(nodes, zone.id))) {
63
+ const level = nodes[levelId]
64
+ const plate =
65
+ level?.type === 'level'
66
+ ? level.children
67
+ .map((id) => nodes[id])
68
+ .find((node) => node?.type === 'slab' && node.zoneIds?.includes(room.zoneId))
69
+ : undefined
70
+ const drawn = plate ? null : roomDrawnFloor(nodes, room.zoneId)
71
+ const linked =
72
+ plate ?? (drawn ? nodes[drawn.slabId] : room.slabId ? nodes[room.slabId] : undefined)
73
+ floorY = (linked?.type === 'slab' ? linked.elevation : zone.floor?.elevation) ?? 0
74
+ }
75
+ const wallTops = new Map<string, number>()
76
+ for (const id of room.boundaryWallIds) {
77
+ const wall = nodes[id]
78
+ if (wall?.type !== 'wall') continue
79
+ const base = getWallBaseElevationForNodes(wall, sceneNodes)
80
+ wallTops.set(id, getWallTop(wall, levelId, sceneNodes, base))
81
+ }
82
+ const ceilingNode = room.ceilingId ? nodes[room.ceilingId] : undefined
83
+ const ceiling =
84
+ ceilingNode?.type === 'ceiling'
85
+ ? {
86
+ polygon: ceilingNode.polygon,
87
+ holes: ceilingNode.holes,
88
+ y: resolveCeilingHeight(ceilingNode, sceneNodes),
89
+ }
90
+ : null
91
+ return { floorY, wallTops, ceiling }
92
+ }
93
+
94
+ function heightsKey({ floorY, wallTops, ceiling }: RoomAssemblyHeights) {
95
+ const rings = ceiling ? [ceiling.polygon, ...ceiling.holes].map((ring) => ring.join(' ')) : []
96
+ return [floorY, ...wallTops, ceiling?.y, ...rings].join('|')
97
+ }
98
+
99
+ const cache = new WeakMap<RoomSelectionGeometry, { key: string; assembly: RoomAssembly }>()
100
+
101
+ /**
102
+ * The room geometry object is reused for as long as the level topology
103
+ * revision stands, so it keys the cache; heights and the ceiling outline are
104
+ * compared by value. Hover changes and pointer moves only ever hit.
105
+ */
106
+ export function getRoomAssembly(
107
+ geometry: RoomSelectionGeometry,
108
+ heights: RoomAssemblyHeights,
109
+ ): RoomAssembly {
110
+ const key = heightsKey(heights)
111
+ const cached = cache.get(geometry)
112
+ if (cached?.key === key) return cached.assembly
113
+ const assembly = buildRoomAssembly(geometry, heights)
114
+ cache.set(geometry, { key, assembly })
115
+ return assembly
116
+ }
117
+
118
+ function faceTest(wall: WallNode, face: 'a' | 'b') {
119
+ const offset = getWallFaceOffsets(wall)[face]
120
+ const arc = getWallArcData(wall)
121
+ if (arc) {
122
+ const radius = arc.radius - arc.direction * offset
123
+ return ([x, z]: Point) =>
124
+ Math.abs(Math.hypot(x - arc.center.x, z - arc.center.y) - radius) < ON_FACE
125
+ }
126
+ const dx = wall.end[0] - wall.start[0]
127
+ const dz = wall.end[1] - wall.start[1]
128
+ const length = Math.hypot(dx, dz)
129
+ if (length < 1e-9) return () => false
130
+ return ([x, z]: Point) =>
131
+ Math.abs(((x - wall.start[0]) * -dz + (z - wall.start[1]) * dx) / length - offset) < ON_FACE
132
+ }
133
+
134
+ function* ringEdges(ring: Ring) {
135
+ for (let i = 0; i < ring.length; i++) yield [ring[i]!, ring[(i + 1) % ring.length]!] as const
136
+ }
137
+
138
+ function overlap(p: Point, q: Point, a: Point, b: Point): [Point, Point] | null {
139
+ const ex = b[0] - a[0]
140
+ const ez = b[1] - a[1]
141
+ const lengthSq = ex * ex + ez * ez
142
+ if (lengthSq < 1e-12) return null
143
+ const length = Math.sqrt(lengthSq)
144
+ const offLine = ([x, z]: Point) => Math.abs((x - a[0]) * ez - (z - a[1]) * ex) / length
145
+ if (offLine(p) > ON_FACE || offLine(q) > ON_FACE) return null
146
+ const up = ((p[0] - a[0]) * ex + (p[1] - a[1]) * ez) / lengthSq
147
+ const uq = ((q[0] - a[0]) * ex + (q[1] - a[1]) * ez) / lengthSq
148
+ const lo = Math.max(0, Math.min(up, uq))
149
+ const hi = Math.min(1, Math.max(up, uq))
150
+ if ((hi - lo) * length < 1e-4) return null
151
+ return [
152
+ [a[0] + ex * lo, a[1] + ez * lo],
153
+ [a[0] + ex * hi, a[1] + ez * hi],
154
+ ]
155
+ }
156
+
157
+ /**
158
+ * The room-facing part of each boundary wall face. The clear polygon's
159
+ * boundary is exactly the mitered faces the room sees, clipped by T-stems
160
+ * and separators, so its edges are matched against each wall's footprint
161
+ * face edges; that also keeps collinear walls apart.
162
+ */
163
+ export function roomWallFaces(geometry: RoomSelectionGeometry): RoomWallFacePortion[] {
164
+ const rings = clearPieces(geometry).flatMap(({ outer, holes }) => [outer, ...holes])
165
+ const faces: RoomWallFacePortion[] = []
166
+ const seen = new Set<string>()
167
+ for (const span of geometry.spans) {
168
+ if (span.kind !== 'wall') continue
169
+ const wallId = span.boundaryId
170
+ const wall = geometry.context.walls.get(wallId)
171
+ if (!wall) continue
172
+ if (!rings.length) {
173
+ faces.push(...sampledFace(wall, span.face, span.t0, span.t1))
174
+ continue
175
+ }
176
+ const key = `${wallId}:${span.face}`
177
+ const footprint = geometry.context.wallFootprints.get(wallId)
178
+ if (seen.has(key) || !footprint) continue
179
+ seen.add(key)
180
+ const onFace = faceTest(wall, span.face)
181
+ const faceEdges = [...ringEdges(footprint)].filter(([a, b]) => onFace(a) && onFace(b))
182
+ for (const ring of rings) {
183
+ for (const [p, q] of ringEdges(ring)) {
184
+ if (!(onFace(p) && onFace(q))) continue
185
+ for (const [a, b] of faceEdges) {
186
+ const portion = overlap(p, q, a, b)
187
+ if (portion) faces.push({ wallId, face: span.face, from: portion[0], to: portion[1] })
188
+ }
189
+ }
190
+ }
191
+ }
192
+ return faces
193
+ }
194
+
195
+ // Only used when the clear polygon could not be computed for the room.
196
+ function sampledFace(wall: WallNode, face: 'a' | 'b', t0: number, t1: number) {
197
+ const offset = getWallFaceOffsets(wall)[face]
198
+ const steps = getWallArcData(wall) ? CURVE_SAMPLES : 1
199
+ const points = Array.from({ length: steps + 1 }, (_, i): Point => {
200
+ const { point, normal } = getWallCurveFrameAt(wall, t0 + ((t1 - t0) * i) / steps)
201
+ return [point.x + normal.x * offset, point.y + normal.y * offset]
202
+ })
203
+ return points
204
+ .slice(1)
205
+ .map((to, i): RoomWallFacePortion => ({ wallId: wall.id, face, from: points[i]!, to }))
206
+ }
207
+
208
+ function pushTriangles(target: number[], outer: Ring, holes: readonly Ring[], y: number) {
209
+ const contour = outer.map(([x, z]) => new Vector2(x, z))
210
+ const holePoints = holes.map((ring) => ring.map(([x, z]) => new Vector2(x, z)))
211
+ const points = [...contour, ...holePoints.flat()]
212
+ for (const triangle of ShapeUtils.triangulateShape(contour, holePoints)) {
213
+ for (const index of triangle) target.push(points[index]!.x, y, points[index]!.y)
214
+ }
215
+ }
216
+
217
+ function pushRing(target: number[], ring: Ring, y: number) {
218
+ for (const [[ax, az], [bx, bz]] of ringEdges(ring)) target.push(ax, y, az, bx, y, bz)
219
+ }
220
+
221
+ export function buildRoomAssembly(
222
+ geometry: RoomSelectionGeometry,
223
+ heights: RoomAssemblyHeights,
224
+ ): RoomAssembly {
225
+ const floor: number[] = []
226
+ const surfaces: number[] = []
227
+ const outline: number[] = []
228
+ const floorY = heights.floorY + FLOOR_LIFT
229
+ for (const { outer, holes } of clearPieces(geometry)) {
230
+ pushTriangles(floor, outer, holes, floorY)
231
+ for (const ring of [outer, ...holes]) pushRing(outline, ring, floorY)
232
+ }
233
+ const faces = roomWallFaces(geometry)
234
+ const ends = new Map<string, { point: Point; top: number; count: number }>()
235
+ for (const { wallId, from, to } of faces) {
236
+ const top = heights.wallTops.get(wallId)
237
+ if (top === undefined || top <= floorY) continue
238
+ const [ax, az] = from
239
+ const [bx, bz] = to
240
+ surfaces.push(ax, floorY, az, bx, floorY, bz, bx, top, bz)
241
+ surfaces.push(ax, floorY, az, bx, top, bz, ax, top, az)
242
+ outline.push(ax, top, az, bx, top, bz)
243
+ for (const point of [from, to]) {
244
+ const key = `${wallId}:${point[0].toFixed(4)},${point[1].toFixed(4)}`
245
+ const end = ends.get(key)
246
+ if (end) end.count++
247
+ else ends.set(key, { point, top, count: 1 })
248
+ }
249
+ }
250
+ // Verticals only where a face run starts or stops, not between curve chords.
251
+ for (const { point, top, count } of ends.values()) {
252
+ if (count === 1) outline.push(point[0], floorY, point[1], point[0], top, point[1])
253
+ }
254
+ if (heights.ceiling) {
255
+ const { polygon, holes, y } = heights.ceiling
256
+ pushTriangles(surfaces, polygon, holes, y - CEILING_DROP)
257
+ for (const ring of [polygon, ...holes]) pushRing(outline, ring, y - CEILING_DROP)
258
+ }
259
+ return {
260
+ floor: new Float32Array(floor),
261
+ surfaces: new Float32Array(surfaces),
262
+ outline: new Float32Array(outline),
263
+ faces,
264
+ }
265
+ }
@@ -0,0 +1,379 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ BuildingNode,
5
+ clearSceneHistory,
6
+ createZone,
7
+ generateId,
8
+ initSpaceDetectionSync,
9
+ LevelNode,
10
+ SlabNode,
11
+ type SlabNode as SlabNodeType,
12
+ useScene,
13
+ ZoneNode,
14
+ type ZoneNode as ZoneNodeType,
15
+ } from '@pascal-app/core'
16
+ import { roomBuiltOn, roomFloorChoices, roomFloorKey, separateFloorSummary } from './room-built-on'
17
+ import { roomConstructionState } from './room-construction'
18
+ import {
19
+ commitRoomFloorElevation,
20
+ roomRelativeFloorHeight,
21
+ separateFloorOffset,
22
+ setRoomFloor,
23
+ setRoomRelativeFloorHeight,
24
+ stepRoomFloorElevation,
25
+ } from './room-construction-commands'
26
+ import {
27
+ commitRoomElevation,
28
+ ownFloorElevationBounds,
29
+ roomFloorElevation,
30
+ } from './room-handle-drag'
31
+ import { applyRoomPlan } from './room-structure-commands'
32
+ import { showRoomNotice, useRoomTransform } from './room-transform-session'
33
+
34
+ const LEVEL = 'level_built_on'
35
+ type Ring = [number, number][]
36
+
37
+ const rect = (x0: number, z0: number, x1: number, z1: number): Ring => [
38
+ [x0, z0],
39
+ [x1, z0],
40
+ [x1, z1],
41
+ [x0, z1],
42
+ ]
43
+
44
+ /** A room; `key` puts it on a separate floor (set past the schema, which may lag the key type). */
45
+ const zone = (id: string, name: string, polygon: Ring, key?: string) => {
46
+ const room = ZoneNode.parse({ id, name, parentId: LEVEL, polygon, spaceRole: 'room' })
47
+ return (key ? { ...room, floor: { ...room.floor, footprint: key } } : room) as ZoneNode
48
+ }
49
+
50
+ const plate = (id: string, polygon: Ring, zoneIds: string[], elevation: number, name?: string) =>
51
+ SlabNode.parse({
52
+ id,
53
+ name,
54
+ parentId: LEVEL,
55
+ polygon,
56
+ plateRole: 'base',
57
+ boundary: 'auto',
58
+ zoneIds,
59
+ elevation,
60
+ })
61
+
62
+ const scene = (...nodes: AnyNode[]): Record<string, AnyNode> =>
63
+ Object.fromEntries(
64
+ [LevelNode.parse({ id: LEVEL }), ...nodes].map((node) => [node.id, node as AnyNode]),
65
+ )
66
+
67
+ const living = zone('zone_living', 'Living', rect(0, 0, 4, 4))
68
+ const lanai = zone('zone_lanai', 'Lanai', rect(4, 0, 6, 4))
69
+ const keyedLanai = zone('zone_lanai', 'Lanai', rect(4, 0, 6, 4), 'floor_lanai')
70
+ const length = (meters: number) => `${meters.toFixed(2)} m`
71
+
72
+ describe('Built on: when the picker shows and what it offers', () => {
73
+ test('a room sharing its footprint offers that floor, named as the footprint is', () => {
74
+ const house = plate('slab_house', rect(0, 0, 6, 4), [living.id, lanai.id], 0.15)
75
+ const model = roomBuiltOn(scene(living, lanai, house), lanai.id)
76
+ expect(model?.current).toEqual({ key: null, plateId: house.id, name: 'Shared floor' })
77
+ expect(model?.choices).toHaveLength(1)
78
+ // A floor the user named shows that name.
79
+ const named = plate('slab_house', rect(0, 0, 6, 4), [living.id, lanai.id], 0.15, 'Cabin')
80
+ expect(roomBuiltOn(scene(living, lanai, named), lanai.id)?.current.name).toBe('Cabin')
81
+ })
82
+
83
+ test('a room alone on the shared floor with no other floor touching has no choice to make', () => {
84
+ const shed = plate('slab_shed', rect(10, 0, 12, 4), [lanai.id], 0.05)
85
+ expect(roomBuiltOn(scene(lanai, shed), lanai.id)).toBeNull()
86
+ })
87
+
88
+ test('a room on a separate floor lists the floors it touches, current first, and reads against the shared floor', () => {
89
+ const house = plate('slab_house', rect(0, 0, 4, 4), [living.id], 0.15)
90
+ const own = plate('slab_lanai', rect(4, 0, 6, 4), [keyedLanai.id], 0)
91
+ const far = plate('slab_far', rect(20, 0, 24, 4), ['zone_far'], 0.05)
92
+ const farRoom = zone('zone_far', 'Studio', rect(20, 0, 24, 4))
93
+ const nodes = scene(living, keyedLanai, house, own, far, farRoom)
94
+ const model = roomBuiltOn(nodes, keyedLanai.id)!
95
+ expect(model.choices).toEqual([
96
+ { key: 'floor_lanai', plateId: own.id, name: 'Lanai floor' },
97
+ { key: null, plateId: house.id, name: 'Living floor' },
98
+ ])
99
+ expect(model.shared?.plateId).toBe(house.id)
100
+ expect(separateFloorOffset(nodes, keyedLanai.id)).toBeCloseTo(-0.15)
101
+ // The house room touches the lanai's floor: it may join it.
102
+ expect(roomFloorChoices(nodes, living.id).map((choice) => choice.key)).toEqual([
103
+ null,
104
+ 'floor_lanai',
105
+ ])
106
+ })
107
+
108
+ test('a room on a separate floor no shared floor touches can still go back to the shared floor', () => {
109
+ const own = plate('slab_lanai', rect(4, 0, 6, 4), [keyedLanai.id], 0)
110
+ const nodes = scene(keyedLanai, own)
111
+ const model = roomBuiltOn(nodes, keyedLanai.id)!
112
+ expect(model.choices).toEqual([
113
+ { key: 'floor_lanai', plateId: own.id, name: 'Lanai floor' },
114
+ { key: null, plateId: null, name: 'Shared floor' },
115
+ ])
116
+ expect(model.shared).toBeNull()
117
+ expect(separateFloorOffset(nodes, keyedLanai.id)).toBeNull()
118
+ expect(
119
+ separateFloorSummary({ ...model, offset: separateFloorOffset(nodes, keyedLanai.id) }, length),
120
+ ).toBe('Lanai floor')
121
+ })
122
+
123
+ test('a room alone on the shared floor still sees a separate floor it touches', () => {
124
+ const house = plate('slab_house', rect(0, 0, 4, 4), [living.id], 0.15)
125
+ const own = plate('slab_lanai', rect(4, 0, 6, 4), [keyedLanai.id], 0)
126
+ expect(roomBuiltOn(scene(living, keyedLanai, house, own), living.id)?.choices).toHaveLength(2)
127
+ })
128
+
129
+ test('a room standing on a drawn slab has its floor on the slab: no picker', () => {
130
+ const drawn = SlabNode.parse({ id: 'slab_drawn', parentId: LEVEL, polygon: rect(4, 0, 6, 4) })
131
+ const onSlab = ZoneNode.parse({
132
+ id: 'zone_lanai',
133
+ name: 'Lanai',
134
+ parentId: LEVEL,
135
+ polygon: rect(4, 0, 6, 4),
136
+ spaceRole: 'room',
137
+ floor: { sourceSlabId: drawn.id },
138
+ })
139
+ const house = plate('slab_house', rect(0, 0, 4, 4), [living.id], 0.15)
140
+ expect(roomBuiltOn(scene(living, onSlab, house, drawn), onSlab.id)).toBeNull()
141
+ })
142
+
143
+ test('a mezzanine has no footprint: no picker', () => {
144
+ const mezzanine = ZoneNode.parse({
145
+ id: 'zone_mezz',
146
+ name: 'Loft',
147
+ parentId: LEVEL,
148
+ polygon: rect(0, 0, 2, 2),
149
+ spaceRole: 'room',
150
+ hostZoneId: living.id,
151
+ floor: { support: 'open', elevation: 2.4 },
152
+ })
153
+ const house = plate('slab_house', rect(0, 0, 6, 4), [living.id, lanai.id, mezzanine.id], 0.15)
154
+ expect(roomBuiltOn(scene(living, lanai, mezzanine, house), mezzanine.id)).toBeNull()
155
+ })
156
+ })
157
+
158
+ describe('Built on: the Floor row summary', () => {
159
+ const lanaiFloor = { name: 'Lanai floor' }
160
+ test('says how a separate floor sits against the shared floor', () => {
161
+ const shared = { name: 'Shared floor' }
162
+ expect(separateFloorSummary({ current: lanaiFloor, shared, offset: -0.15 }, length)).toBe(
163
+ 'Lanai floor · 0.15 m below Shared floor',
164
+ )
165
+ expect(
166
+ separateFloorSummary({ current: lanaiFloor, shared: { name: 'Cabin' }, offset: 0.3 }, length),
167
+ ).toBe('Lanai floor · 0.30 m above Cabin')
168
+ expect(separateFloorSummary({ current: lanaiFloor, shared, offset: 0.002 }, length)).toBe(
169
+ 'Lanai floor · level with Shared floor',
170
+ )
171
+ expect(separateFloorSummary({ current: lanaiFloor, shared: null, offset: null }, length)).toBe(
172
+ 'Lanai floor',
173
+ )
174
+ })
175
+
176
+ test("compares the room's walking surface with the shared-floor room beside it, not plate with plate", () => {
177
+ const wall = 'wall_between'
178
+ // The living room is raised 0.15 on the 0.15 house plate: it walks at 0.30.
179
+ const raised = {
180
+ ...living,
181
+ boundaryWallIds: [wall],
182
+ floor: { ...living.floor, elevation: 0.3 },
183
+ } as ZoneNodeType
184
+ const walled = { ...keyedLanai, boundaryWallIds: [wall] } as ZoneNodeType
185
+ const house = plate('slab_house', rect(0, 0, 4, 4), [living.id], 0.15)
186
+ const own = plate('slab_lanai', rect(4, 0, 6, 4), [keyedLanai.id], 0)
187
+ const nodes = scene(raised, walled, house, own)
188
+ expect(separateFloorOffset(nodes, keyedLanai.id)).toBeCloseTo(-0.3)
189
+ const model = roomBuiltOn(nodes, keyedLanai.id)!
190
+ expect(
191
+ separateFloorSummary({ ...model, offset: separateFloorOffset(nodes, keyedLanai.id) }, length),
192
+ ).toBe('Lanai floor · 0.30 m below Living floor')
193
+ // Nothing shared across a wall or open side: the shared plate's top.
194
+ expect(separateFloorOffset(scene(raised, keyedLanai, house, own), keyedLanai.id)).toBeCloseTo(
195
+ -0.15,
196
+ )
197
+ // A room on the shared floor has no step to state.
198
+ expect(separateFloorOffset(nodes, living.id)).toBeNull()
199
+ })
200
+ })
201
+
202
+ describe('Built on: moving between floors, through core', () => {
203
+ const INT = 'level_built_on_live'
204
+ const live = () => useScene.getState().nodes as Record<string, AnyNode>
205
+ const steps = () => useScene.temporal.getState().pastStates.length
206
+ let stop = () => {}
207
+ let houseRoom = ''
208
+ let lanaiRoom = ''
209
+
210
+ const draw = (polygon: Ring, name: string) => {
211
+ const plan = createZone(live(), {
212
+ levelId: INT,
213
+ polygon,
214
+ enclose: true,
215
+ mintId: generateId,
216
+ name,
217
+ })
218
+ applyRoomPlan(plan)
219
+ return plan.zoneId
220
+ }
221
+
222
+ beforeEach(() => {
223
+ globalThis.requestAnimationFrame ??= (callback) => {
224
+ callback(0)
225
+ return 0
226
+ }
227
+ globalThis.cancelAnimationFrame ??= () => {}
228
+ const building = BuildingNode.parse({ id: 'building_built_on', children: [INT] })
229
+ const level = LevelNode.parse({ id: INT, parentId: building.id })
230
+ useScene.setState({
231
+ nodes: { [building.id]: building, [level.id]: level },
232
+ rootNodeIds: [building.id],
233
+ dirtyNodes: new Set(),
234
+ materials: {},
235
+ collections: {},
236
+ readOnly: false,
237
+ })
238
+ useScene.temporal.getState().resume()
239
+ stop = initSpaceDetectionSync(useScene, {
240
+ getState: () => ({ spaces: {}, setSpaces: () => {} }),
241
+ })
242
+ houseRoom = draw(rect(0, 0, 5, 4), 'Living')
243
+ draw(rect(5, 0, 8, 4), 'Bedroom')
244
+ lanaiRoom = draw(rect(0, 4, 5, 7), 'Lanai')
245
+ expect(setRoomRelativeFloorHeight(lanaiRoom, -0.15).status).toBe('applied')
246
+ clearSceneHistory()
247
+ })
248
+ afterEach(() => {
249
+ stop()
250
+ stop = () => {}
251
+ })
252
+
253
+ test('New floor detaches in one undo step, keeps the height, and reads against the shared floor', () => {
254
+ expect(roomRelativeFloorHeight(live(), lanaiRoom)).toBeCloseTo(-0.15)
255
+ expect(roomBuiltOn(live(), lanaiRoom)?.current.key).toBeNull()
256
+ expect(setRoomFloor(lanaiRoom, 'new').status).toBe('applied')
257
+ expect(steps()).toBe(1)
258
+ const model = roomBuiltOn(live(), lanaiRoom)!
259
+ expect(model.current.key).not.toBeNull()
260
+ expect(model.current.name).toBe('Lanai floor')
261
+ expect(model.shared?.name).toBe('Shared floor')
262
+ expect(separateFloorOffset(live(), lanaiRoom)).toBeCloseTo(-0.15)
263
+ // Its height reads the same: measured from the shared floor beside it.
264
+ expect(roomRelativeFloorHeight(live(), lanaiRoom)).toBeCloseTo(-0.15)
265
+ // The shared wall stands on the house floor: the lanai is still fully floored.
266
+ expect(roomConstructionState(live(), lanaiRoom)?.floor.state).toBe('present')
267
+ // The house room touches the new floor: it can join it.
268
+ expect(roomBuiltOn(live(), houseRoom)?.choices.map((choice) => choice.name)).toEqual([
269
+ 'Shared floor',
270
+ 'Lanai floor',
271
+ ])
272
+ useScene.temporal.getState().undo()
273
+ expect(roomBuiltOn(live(), lanaiRoom)?.current.key).toBeNull()
274
+ })
275
+
276
+ test('a touching room joins the separate floor and keeps its walking height', () => {
277
+ setRoomFloor(lanaiRoom, 'new')
278
+ const key = roomBuiltOn(live(), lanaiRoom)!.current.key
279
+ const top = roomFloorElevation(live(), houseRoom)
280
+ clearSceneHistory()
281
+ expect(setRoomFloor(houseRoom, key).status).toBe('applied')
282
+ expect(steps()).toBe(1)
283
+ const joined = roomBuiltOn(live(), houseRoom)!
284
+ expect(joined.current).toEqual(roomBuiltOn(live(), lanaiRoom)!.current)
285
+ expect(joined.current).toMatchObject({ key, name: 'Lanai floor' })
286
+ expect(roomFloorElevation(live(), houseRoom)).toBeCloseTo(top)
287
+ })
288
+
289
+ test("a separate floor's Height raises it on a foundation, and picking the shared floor rejoins", () => {
290
+ setRoomFloor(lanaiRoom, 'new')
291
+ clearSceneHistory()
292
+ expect(setRoomRelativeFloorHeight(lanaiRoom, 0.1).status).toBe('applied')
293
+ expect(steps()).toBe(1)
294
+ const model = roomBuiltOn(live(), lanaiRoom)!
295
+ const own = live()[model.current.plateId] as SlabNodeType
296
+ const shared = live()[model.shared!.plateId] as SlabNodeType
297
+ expect(own.elevation - shared.elevation).toBeCloseTo(0.1)
298
+ expect(own.foundation?.type).toBe('solid')
299
+ expect(setRoomFloor(lanaiRoom, null).status).toBe('applied')
300
+ expect(roomBuiltOn(live(), lanaiRoom)?.current.key).toBeNull()
301
+ expect(roomRelativeFloorHeight(live(), lanaiRoom)).toBeCloseTo(0.1)
302
+ })
303
+
304
+ test('the plan stepper and the 3D handle move a separate floor through its foundation', () => {
305
+ setRoomFloor(lanaiRoom, 'new')
306
+ clearSceneHistory()
307
+ // The detached lanai kept its legacy sunk top; its first edit lands its slab on the ground.
308
+ const next = stepRoomFloorElevation(live(), lanaiRoom, 0.05)
309
+ expect(next).not.toBeNull()
310
+ commitRoomElevation(lanaiRoom, next!)
311
+ expect(steps()).toBe(1)
312
+ expect(roomRelativeFloorHeight(live(), lanaiRoom)).toBeCloseTo(0)
313
+ const bounds = ownFloorElevationBounds(live(), lanaiRoom)!
314
+ expect(bounds.min).toBeCloseTo(roomFloorElevation(live(), lanaiRoom))
315
+ expect(bounds.max).toBeGreaterThan(roomFloorElevation(live(), lanaiRoom))
316
+ commitRoomElevation(lanaiRoom, stepRoomFloorElevation(live(), lanaiRoom, 0.05)!)
317
+ expect(roomRelativeFloorHeight(live(), lanaiRoom)).toBeCloseTo(0.05)
318
+ })
319
+
320
+ test('a separate floor steps from the ground onto a 0.05 foundation and back', () => {
321
+ setRoomFloor(lanaiRoom, 'new')
322
+ expect(commitRoomElevation(lanaiRoom, 0)).toBe(true)
323
+ const plateId = roomBuiltOn(live(), lanaiRoom)!.current.plateId
324
+ useScene.getState().updateNode(plateId as SlabNodeType['id'], {
325
+ foundation: { type: 'none', material: 'library:stone' },
326
+ })
327
+ const before = live()
328
+ const own = before[plateId] as SlabNodeType
329
+ // On the ground: the slab sits on grade, so the top is its thickness.
330
+ expect(own.elevation).toBeCloseTo(own.thickness)
331
+ expect(ownFloorElevationBounds(before, lanaiRoom)!.min).toBeCloseTo(own.thickness)
332
+ for (let trip = 0; trip < 2; trip++) {
333
+ const up = stepRoomFloorElevation(live(), lanaiRoom, 0.05)!
334
+ expect(up).toBeCloseTo(own.thickness + 0.05)
335
+ expect(commitRoomElevation(lanaiRoom, up)).toBe(true)
336
+ expect(live()[plateId]).toMatchObject({
337
+ elevation: up,
338
+ thickness: own.thickness,
339
+ foundation: { type: 'solid', material: 'library:stone' },
340
+ })
341
+ expect(stepRoomFloorElevation(live(), lanaiRoom, -0.05)).toBeCloseTo(own.thickness)
342
+ // Below the ground is out of reach: the slab stays on grade.
343
+ expect(commitRoomElevation(lanaiRoom, own.thickness - 0.05)).toBe(true)
344
+ expect(live()).toEqual(before)
345
+ }
346
+ })
347
+
348
+ test('a separate floor no shared-floor room touches still goes back to the shared floor', () => {
349
+ setRoomFloor(lanaiRoom, 'new')
350
+ const key = roomBuiltOn(live(), lanaiRoom)!.current.key
351
+ // The house room joins the lanai's floor: nothing on the shared floor touches the lanai now.
352
+ expect(setRoomFloor(houseRoom, key).status).toBe('applied')
353
+ const model = roomBuiltOn(live(), lanaiRoom)!
354
+ expect(model.shared).toBeNull()
355
+ expect(model.choices.at(-1)).toEqual({ key: null, plateId: null, name: 'Shared floor' })
356
+ clearSceneHistory()
357
+ expect(setRoomFloor(lanaiRoom, null).status).toBe('applied')
358
+ expect(steps()).toBe(1)
359
+ expect(roomFloorKey(live(), lanaiRoom)).toBeNull()
360
+ })
361
+
362
+ test('a refused plan-stepper or 3D-handle height says why under the room pill', () => {
363
+ showRoomNotice(null)
364
+ const result = commitRoomFloorElevation(houseRoom, 10, length)
365
+ expect(result.status).toBe('conflict')
366
+ const notice = useRoomTransform.getState().notice
367
+ expect(notice?.zoneId).toBe(houseRoom)
368
+ expect(notice?.message).toMatch(/^Too high: the floor can rise at most \d+\.\d\d m/)
369
+ expect(steps()).toBe(0)
370
+ showRoomNotice(null)
371
+ })
372
+
373
+ test('a refused move comes back as a message, never a throw', () => {
374
+ useScene.getState().updateNodes([{ id: lanaiRoom as AnyNode['id'], data: { hasFloor: false } }])
375
+ const result = setRoomFloor(lanaiRoom, 'new')
376
+ expect(result.status).toBe('conflict')
377
+ expect(result.status === 'conflict' && result.message).toContain('Level the floors first')
378
+ })
379
+ })