@pascal-app/editor 1.0.3 → 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 (440) hide show
  1. package/package.json +11 -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 +202 -57
  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 +36 -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 +344 -342
  27. package/src/components/editor/footprint-height-handle.tsx +204 -0
  28. package/src/components/editor/group-actions.ts +54 -2
  29. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  30. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  31. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  32. package/src/components/editor/handles/use-handle-drag.ts +269 -197
  33. package/src/components/editor/index.tsx +294 -169
  34. package/src/components/editor/join-walls-pill.tsx +44 -0
  35. package/src/components/editor/node-action-menu.tsx +68 -70
  36. package/src/components/editor/node-arrow-handles.tsx +25 -6
  37. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  38. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  39. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  40. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  41. package/src/components/editor/registry-action-contributions.tsx +80 -1
  42. package/src/components/editor/room-controls.tsx +847 -0
  43. package/src/components/editor/room-floor-highlight.tsx +128 -0
  44. package/src/components/editor/room-handles-2d.tsx +354 -0
  45. package/src/components/editor/room-handles.tsx +675 -0
  46. package/src/components/editor/room-highlight.tsx +225 -0
  47. package/src/components/editor/room-labels.test.ts +357 -0
  48. package/src/components/editor/room-labels.tsx +990 -0
  49. package/src/components/editor/room-transform-ghost.tsx +256 -0
  50. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  51. package/src/components/editor/selection-manager.tsx +451 -317
  52. package/src/components/editor/slab-hole-highlights.tsx +3 -0
  53. package/src/components/editor/snapshot-capture-overlay.tsx +4 -0
  54. package/src/components/editor/snapshot-capture.test.ts +52 -4
  55. package/src/components/editor/snapshot-capture.ts +30 -5
  56. package/src/components/editor/thumbnail-generator.test.ts +308 -0
  57. package/src/components/editor/thumbnail-generator.tsx +1035 -229
  58. package/src/components/editor/use-floorplan-background-placement.ts +31 -11
  59. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  60. package/src/components/editor/views/host-view.tsx +61 -0
  61. package/src/components/editor/views/plugins-view.tsx +670 -0
  62. package/src/components/editor/views/scene-region.tsx +59 -0
  63. package/src/components/editor/views/use-editor-views.ts +57 -0
  64. package/src/components/editor/views/view-bar.tsx +220 -0
  65. package/src/components/editor/wall-justification.test.ts +140 -0
  66. package/src/components/editor/wall-move-side-handles.tsx +43 -142
  67. package/src/components/editor/wall-push-arrow.tsx +92 -0
  68. package/src/components/editor/wall-region-handles.tsx +509 -0
  69. package/src/components/editor/wall-snap-beacon-layer.tsx +3 -0
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  71. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  72. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  73. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  74. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +113 -102
  75. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  76. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  77. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  78. package/src/components/editor-2d/renderers/{floorplan-draft-wall-measurement.tsx → floorplan-draft-measurement.tsx} +8 -8
  79. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  80. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +38 -8
  81. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +291 -227
  82. package/src/components/editor-2d/svg-paths.ts +12 -15
  83. package/src/components/procedural-items/inspector.tsx +40 -15
  84. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  85. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  86. package/src/components/systems/zone/zone-system.test.ts +47 -0
  87. package/src/components/systems/zone/zone-system.tsx +29 -68
  88. package/src/components/tools/item/placement-history.test.tsx +474 -0
  89. package/src/components/tools/item/placement-strategies.ts +8 -4
  90. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  91. package/src/components/tools/item/use-draft-node.ts +166 -95
  92. package/src/components/tools/item/use-placement-coordinator.tsx +93 -46
  93. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  94. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  95. package/src/components/tools/registry/move-registry-node-tool.tsx +3 -1
  96. package/src/components/tools/select/box-select-tool.tsx +2 -12
  97. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  98. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  99. package/src/components/tools/select/select-candidates.test.ts +37 -4
  100. package/src/components/tools/select/select-candidates.ts +15 -11
  101. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  102. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  103. package/src/components/tools/stair/stair-defaults.ts +0 -3
  104. package/src/components/tools/stair/stair-tool.tsx +90 -42
  105. package/src/components/tools/tool-manager.tsx +46 -20
  106. package/src/components/tools/wall/wall-drafting.test.ts +19 -4
  107. package/src/components/tools/zone/zone-tool.tsx +4 -12
  108. package/src/components/ui/action-menu/action-button.tsx +3 -0
  109. package/src/components/ui/action-menu/camera-actions.tsx +2 -1
  110. package/src/components/ui/action-menu/control-modes.tsx +2 -35
  111. package/src/components/ui/action-menu/measurement-control.tsx +3 -10
  112. package/src/components/ui/build-panel/build-panel.tsx +235 -0
  113. package/src/components/ui/command-palette/editor-commands.tsx +4 -30
  114. package/src/components/ui/command-palette/index.tsx +5 -24
  115. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  116. package/src/components/ui/controls/material-paint-panel.tsx +97 -26
  117. package/src/components/ui/controls/material-picker.tsx +4 -1
  118. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  119. package/src/components/ui/controls/select-control.tsx +65 -0
  120. package/src/components/ui/floating-level-selector.tsx +9 -14
  121. package/src/components/ui/helpers/building-helper.tsx +4 -1
  122. package/src/components/ui/helpers/contextual-helper-panel.tsx +154 -70
  123. package/src/components/ui/helpers/helper-manager.test.tsx +69 -0
  124. package/src/components/ui/helpers/helper-manager.tsx +325 -14
  125. package/src/components/ui/helpers/item-helper.tsx +8 -0
  126. package/src/components/ui/helpers/registered-tool-helper.tsx +8 -1
  127. package/src/components/ui/icon-ref.tsx +45 -1
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/node-icon-image.tsx +16 -0
  130. package/src/components/ui/panels/collections/collections-popover.tsx +20 -14
  131. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  132. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  133. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  134. package/src/components/ui/panels/mobile-selection-bar.tsx +2 -2
  135. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  136. package/src/components/ui/panels/multi-field-value.ts +6 -3
  137. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  138. package/src/components/ui/panels/multi-selection-panel.tsx +43 -4
  139. package/src/components/ui/panels/node-display.ts +8 -3
  140. package/src/components/ui/panels/panel-manager.tsx +103 -68
  141. package/src/components/ui/panels/panel-wrapper.tsx +78 -36
  142. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  143. package/src/components/ui/panels/parametric-inspector.tsx +4 -4
  144. package/src/components/ui/panels/reference-panel.tsx +2 -3
  145. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  146. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  147. package/src/components/ui/panels/room-name-options.ts +47 -0
  148. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  149. package/src/components/ui/panels/room-panel.tsx +725 -0
  150. package/src/components/ui/panels/shape-choice.tsx +58 -0
  151. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  152. package/src/components/ui/primitives/color-dot.tsx +36 -24
  153. package/src/components/ui/primitives/sidebar.tsx +2 -0
  154. package/src/components/ui/right-stack.test.ts +29 -0
  155. package/src/components/ui/right-stack.tsx +145 -0
  156. package/src/components/ui/sidebar/icon-rail.tsx +5 -0
  157. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +31 -4
  158. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +5 -5
  159. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  160. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  161. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  162. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  163. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  164. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  165. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  166. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  167. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  168. package/src/components/ui/sidebar/panels/site-panel/index.tsx +176 -211
  169. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  170. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  171. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  172. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  173. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  174. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  175. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  176. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  177. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  178. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  179. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +0 -27
  180. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  181. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  182. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  183. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  184. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +0 -2
  185. package/src/components/ui/sidebar/tab-bar.tsx +10 -0
  186. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  187. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  188. package/src/components/viewer/floorplan-preview.tsx +5 -6
  189. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  190. package/src/components/viewer/viewer-rooms.tsx +175 -0
  191. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  192. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  193. package/src/components/viewer/viewer-stage.tsx +2 -2
  194. package/src/components/viewer-zone-system.tsx +3 -8
  195. package/src/hooks/use-auto-save.integration.test.tsx +123 -9
  196. package/src/hooks/use-auto-save.ts +34 -8
  197. package/src/hooks/use-keyboard.test.ts +178 -3
  198. package/src/hooks/use-keyboard.ts +89 -71
  199. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  200. package/src/hooks/use-open-wall-ends.ts +238 -0
  201. package/src/hooks/use-placement-notice.test.ts +48 -0
  202. package/src/hooks/use-placement-notice.ts +42 -0
  203. package/src/hooks/use-selected-room.ts +143 -0
  204. package/src/index.tsx +246 -39
  205. package/src/lib/ceiling-edit-session.test.ts +321 -0
  206. package/src/lib/ceiling-edit-session.ts +206 -0
  207. package/src/lib/ceiling-region-session.test.ts +179 -0
  208. package/src/lib/continuation.test.ts +19 -1
  209. package/src/lib/continuation.ts +25 -4
  210. package/src/lib/door-interaction.ts +14 -0
  211. package/src/lib/editor-level-display.test.ts +96 -0
  212. package/src/lib/editor-level-display.ts +48 -0
  213. package/src/lib/editor-views.ts +144 -0
  214. package/src/lib/export-geometry-wait.test.ts +90 -0
  215. package/src/lib/floor-edit-session.ts +153 -0
  216. package/src/lib/floor-footprints.test.ts +563 -0
  217. package/src/lib/floor-footprints.ts +395 -0
  218. package/src/lib/floor-foundation-support.test.ts +283 -0
  219. package/src/lib/floor-opening-draft.ts +218 -0
  220. package/src/lib/floor-openings.test.ts +216 -0
  221. package/src/lib/floor-region-geometry.ts +191 -0
  222. package/src/lib/floor-region-session.test.ts +485 -0
  223. package/src/lib/floor-region-session.ts +589 -0
  224. package/src/lib/floor-region-snap.ts +101 -0
  225. package/src/lib/floor-step-hover.test.ts +32 -0
  226. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  227. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  228. package/src/lib/floorplan/dimension-drive.ts +479 -0
  229. package/src/lib/floorplan/floorplan-export.test.ts +12 -8
  230. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  231. package/src/lib/floorplan/floorplan-extension.ts +32 -0
  232. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  233. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  234. package/src/lib/floorplan/floorplan-pdfkit-document.ts +339 -18
  235. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +138 -8
  236. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +56 -25
  237. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  238. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  239. package/src/lib/floorplan/geometry.ts +13 -6
  240. package/src/lib/floorplan/index.ts +0 -9
  241. package/src/lib/floorplan/items.ts +5 -2
  242. package/src/lib/floorplan/marks.test.ts +213 -0
  243. package/src/lib/floorplan/marks.ts +238 -0
  244. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  245. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  246. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  247. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  248. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  249. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  250. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  251. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  252. package/src/lib/floorplan/schedules.test.ts +189 -0
  253. package/src/lib/floorplan/schedules.ts +339 -0
  254. package/src/lib/floorplan/sheet-export.test.ts +100 -0
  255. package/src/lib/floorplan/sheet-export.ts +318 -0
  256. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +779 -0
  257. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  258. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  259. package/src/lib/floorplan/site-plan/coverage.ts +232 -0
  260. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  261. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  262. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  263. package/src/lib/floorplan/site-plan/front-edge.ts +156 -0
  264. package/src/lib/floorplan/site-plan/geometry.test.ts +275 -0
  265. package/src/lib/floorplan/site-plan/geometry.ts +557 -0
  266. package/src/lib/floorplan/site-plan/index.ts +71 -0
  267. package/src/lib/floorplan/site-plan/site-annotations.test.ts +366 -0
  268. package/src/lib/floorplan/site-plan/site-annotations.ts +1083 -0
  269. package/src/lib/floorplan/site-plan/site-parts.ts +379 -0
  270. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  271. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  272. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  273. package/src/lib/floorplan/site-plan/yards.test.ts +69 -0
  274. package/src/lib/floorplan/types.ts +1 -27
  275. package/src/lib/fresh-planar-placement.test.ts +36 -0
  276. package/src/lib/fresh-planar-placement.ts +59 -36
  277. package/src/lib/geometry-script/author.ts +152 -0
  278. package/src/lib/geometry-script/client.ts +152 -0
  279. package/src/lib/geometry-script/protocol.ts +12 -0
  280. package/src/lib/gesture-lifecycle.test.ts +595 -0
  281. package/src/lib/gesture-lifecycle.ts +290 -0
  282. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  283. package/src/lib/glb-export.test.ts +485 -0
  284. package/src/lib/glb-export.ts +237 -119
  285. package/src/lib/history.test.ts +6 -0
  286. package/src/lib/history.ts +7 -0
  287. package/src/lib/hud-title.ts +109 -0
  288. package/src/lib/hydration-gated-storage.ts +49 -0
  289. package/src/lib/ifc-export.test.ts +174 -0
  290. package/src/lib/ifc-export.ts +201 -0
  291. package/src/lib/ifc-parts.ts +15 -0
  292. package/src/lib/inspector-expanded.test.ts +44 -0
  293. package/src/lib/inspector-expanded.ts +36 -0
  294. package/src/lib/interaction/scope.ts +18 -0
  295. package/src/lib/keyboard-pan.test.ts +129 -7
  296. package/src/lib/keyboard-pan.ts +161 -12
  297. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  298. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  299. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  300. package/src/lib/lot/clean-ring.test.ts +186 -0
  301. package/src/lib/lot/clean-ring.ts +217 -0
  302. package/src/lib/lot/dossier.test.ts +202 -0
  303. package/src/lib/lot/dossier.ts +498 -0
  304. package/src/lib/lot/drop-in.test.ts +326 -0
  305. package/src/lib/lot/drop-in.ts +407 -0
  306. package/src/lib/lot/index.ts +43 -0
  307. package/src/lib/lot/lot-patch.test.ts +340 -0
  308. package/src/lib/lot/lot-patch.ts +394 -0
  309. package/src/lib/lot/parcel-provider.ts +43 -0
  310. package/src/lib/lot/terrain.test.ts +161 -0
  311. package/src/lib/lot/terrain.ts +266 -0
  312. package/src/lib/material-catalog-model.ts +3 -1
  313. package/src/lib/material-paint-panel-model.ts +9 -5
  314. package/src/lib/material-paint.ts +4 -0
  315. package/src/lib/mezzanine-actions.test.ts +342 -0
  316. package/src/lib/mezzanine-draft.test.ts +501 -0
  317. package/src/lib/mezzanine-draft.ts +278 -0
  318. package/src/lib/mezzanine-messages.ts +26 -0
  319. package/src/lib/mezzanine-selection.test.ts +207 -0
  320. package/src/lib/mezzanine-stairs.ts +46 -0
  321. package/src/lib/model-export.ts +3 -1
  322. package/src/lib/node-action-movement.ts +17 -6
  323. package/src/lib/node-display-label.ts +37 -0
  324. package/src/lib/orbit-floor-clearance.test.ts +165 -0
  325. package/src/lib/orbit-floor-clearance.ts +106 -0
  326. package/src/lib/paint-commit-route.test.ts +37 -0
  327. package/src/lib/paint-commit-route.ts +17 -0
  328. package/src/lib/paint-eyedropper.test.tsx +165 -0
  329. package/src/lib/paint-eyedropper.ts +99 -0
  330. package/src/lib/paint-pass-through.test.ts +111 -0
  331. package/src/lib/paint-pass-through.ts +76 -0
  332. package/src/lib/paint-region-hover.ts +25 -0
  333. package/src/lib/paint-region-mode.ts +154 -0
  334. package/src/lib/paint-regions.test.ts +166 -0
  335. package/src/lib/paint-regions.ts +192 -0
  336. package/src/lib/paint-scope.test.ts +317 -35
  337. package/src/lib/paint-scope.ts +266 -25
  338. package/src/lib/panel-rows.ts +21 -0
  339. package/src/lib/parametric-node-update.ts +1 -1
  340. package/src/lib/plate-paint-affected.test.ts +300 -0
  341. package/src/lib/plate-paint-affected.ts +177 -0
  342. package/src/lib/plugin-chat.ts +50 -0
  343. package/src/lib/plugin-panels.test.ts +25 -0
  344. package/src/lib/plugin-panels.ts +94 -0
  345. package/src/lib/print-shell-compiler.ts +1 -0
  346. package/src/lib/registered-draft-snap.ts +12 -0
  347. package/src/lib/roof-duplication.ts +10 -2
  348. package/src/lib/room-assembly-overlay.test.ts +275 -0
  349. package/src/lib/room-assembly-overlay.ts +266 -0
  350. package/src/lib/room-built-on.test.ts +379 -0
  351. package/src/lib/room-built-on.ts +138 -0
  352. package/src/lib/room-construction-commands.ts +712 -0
  353. package/src/lib/room-construction.test.ts +746 -0
  354. package/src/lib/room-construction.ts +385 -0
  355. package/src/lib/room-controls-phase6.test.ts +630 -0
  356. package/src/lib/room-divide-session.ts +523 -0
  357. package/src/lib/room-handle-drag.ts +1068 -0
  358. package/src/lib/room-name-catalog.test.ts +90 -0
  359. package/src/lib/room-name-catalog.ts +168 -0
  360. package/src/lib/room-push-dimensions.test.ts +311 -0
  361. package/src/lib/room-push-dimensions.ts +251 -0
  362. package/src/lib/room-selection-commands.ts +62 -0
  363. package/src/lib/room-selection.test.ts +973 -0
  364. package/src/lib/room-selection.ts +417 -0
  365. package/src/lib/room-structure-commands.ts +94 -0
  366. package/src/lib/room-structure.test.ts +921 -0
  367. package/src/lib/room-transform-session.ts +570 -0
  368. package/src/lib/room-zone-routing.test.ts +294 -0
  369. package/src/lib/room-zone-routing.ts +197 -0
  370. package/src/lib/scene-clipboard.test.ts +364 -0
  371. package/src/lib/scene-clipboard.ts +229 -48
  372. package/src/lib/scene-material-refs.test.ts +79 -0
  373. package/src/lib/scene-material-refs.ts +79 -0
  374. package/src/lib/scene.test.ts +24 -36
  375. package/src/lib/scene.ts +33 -57
  376. package/src/lib/selection-routing.test.ts +129 -45
  377. package/src/lib/selection-routing.ts +53 -74
  378. package/src/lib/session-writes.test.ts +120 -0
  379. package/src/lib/session-writes.ts +205 -0
  380. package/src/lib/sidebar-panel.ts +62 -0
  381. package/src/lib/site-boundary.test.ts +4 -6
  382. package/src/lib/site-boundary.ts +1 -1
  383. package/src/lib/snapping-mode.test.ts +4 -0
  384. package/src/lib/snapping-mode.ts +8 -1
  385. package/src/lib/terrace-draft.test.ts +145 -0
  386. package/src/lib/terrace-draft.ts +213 -0
  387. package/src/lib/tool-cursor-icon.test.ts +16 -0
  388. package/src/lib/tool-cursor-icon.ts +11 -0
  389. package/src/lib/typing-target.ts +9 -0
  390. package/src/lib/units.ts +77 -39
  391. package/src/lib/vector-edges.test.ts +58 -0
  392. package/src/lib/vector-edges.ts +400 -0
  393. package/src/lib/viewer-selection.test.ts +577 -0
  394. package/src/lib/viewer-selection.ts +569 -0
  395. package/src/lib/wall-draw-variant.test.ts +71 -0
  396. package/src/lib/wall-draw-variant.ts +91 -0
  397. package/src/lib/wall-paint-region-session.ts +246 -0
  398. package/src/lib/wall-paint-region.test.ts +373 -0
  399. package/src/lib/wall-polygon-draft.test.ts +262 -0
  400. package/src/lib/wall-polygon-draft.ts +286 -0
  401. package/src/lib/wall-push.test.ts +453 -0
  402. package/src/lib/wall-region-face.ts +352 -0
  403. package/src/lib/wall-region-handles.test.ts +304 -0
  404. package/src/lib/wall-region-handles.ts +368 -0
  405. package/src/lib/wall-region-list.test.ts +119 -0
  406. package/src/lib/wall-region-list.ts +136 -0
  407. package/src/lib/wall-region-snap.ts +110 -0
  408. package/src/lib/window-interaction.ts +14 -0
  409. package/src/lib/zone-content.ts +4 -1
  410. package/src/lib/zone-name.test.ts +17 -0
  411. package/src/lib/zone-name.ts +17 -0
  412. package/src/store/opens-in-select-mode.test.ts +223 -0
  413. package/src/store/paint-mode.test.ts +188 -0
  414. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  415. package/src/store/tool-mode.test.ts +137 -6
  416. package/src/store/use-delete-confirmation.ts +28 -1
  417. package/src/store/use-drawing-view.test.ts +24 -3
  418. package/src/store/use-drawing-view.ts +29 -1
  419. package/src/store/use-editor.tsx +432 -321
  420. package/src/store/use-floorplan-draft-preview.ts +13 -0
  421. package/src/store/use-floorplan-marquee.ts +12 -2
  422. package/src/store/use-quick-measurement-hud.ts +3 -3
  423. package/src/store/use-stair-build-preview.ts +13 -5
  424. package/src/store/use-wall-move-ghosts.ts +4 -2
  425. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  426. package/src/store/use-wall-region-handles-selection.ts +139 -0
  427. package/src/store/view-layout.test.ts +175 -0
  428. package/src/store/view-layout.ts +196 -0
  429. package/src/test-utils/immediate-animation-frames.ts +16 -0
  430. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  431. package/src/test-utils/selection-harness.ts +82 -0
  432. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  433. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  434. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  435. package/src/components/tools/fence/fence-drafting.ts +0 -266
  436. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  437. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  438. package/src/lib/floorplan/stairs.ts +0 -489
  439. package/src/lib/level-duplication.test.ts +0 -144
  440. package/src/lib/level-duplication.ts +0 -195
@@ -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
+ })
@@ -0,0 +1,138 @@
1
+ import {
2
+ type AnyNode,
3
+ roomFloorChoices as coreRoomFloorChoices,
4
+ roomDrawnFloor,
5
+ type SlabNode,
6
+ type ZoneNode,
7
+ } from '@pascal-app/core'
8
+ import { levelFootprints } from './floor-footprints'
9
+
10
+ // "Built on": which floor a room stands on. By default a room is part of its
11
+ // connected footprint's shared floor (`floor.footprint` absent); a floor key
12
+ // puts it on a separate floor — a lanai a step below the house, with its own
13
+ // height, thickness and foundation — shared by every touching room with the
14
+ // same key. These helpers read that choice in the words the room panel uses.
15
+
16
+ type Nodes = Readonly<Record<string, AnyNode>>
17
+
18
+ /**
19
+ * One floor a room can stand on: its key (null = the shared floor), plate and
20
+ * name. A null plate is the shared floor of a room no shared floor touches:
21
+ * picking it gives the room a shared floor of its own.
22
+ */
23
+ export type FloorChoice = { key: string | null; plateId: string | null; name: string }
24
+ /** A floor that exists: its plate is in the scene. */
25
+ export type PlateChoice = FloorChoice & { plateId: string }
26
+
27
+ const SHARED_FLOOR: FloorChoice = { key: null, plateId: null, name: 'Shared floor' }
28
+
29
+ const isRoom = (node: AnyNode | undefined): node is ZoneNode =>
30
+ node?.type === 'zone' && node.spaceRole === 'room'
31
+
32
+ const isGroundRoom = (node: AnyNode | undefined): node is ZoneNode =>
33
+ isRoom(node) && node.floor?.support !== 'open'
34
+
35
+ /** The room's floor key: null on the shared floor. */
36
+ export function roomFloorKey(nodes: Nodes, zoneId: string): string | null {
37
+ const zone = nodes[zoneId]
38
+ return (isRoom(zone) && zone.floor?.footprint) || null
39
+ }
40
+
41
+ /** The base plate listing the room, if any. */
42
+ function listingPlate(nodes: Nodes, zone: ZoneNode): SlabNode | null {
43
+ if (!zone.parentId) return null
44
+ return (
45
+ levelFootprints(nodes as Record<string, AnyNode>, zone.parentId).find((plate) =>
46
+ plate.zoneIds?.includes(zone.id),
47
+ ) ?? null
48
+ )
49
+ }
50
+
51
+ /**
52
+ * The floors a room can stand on (core's `roomFloorChoices`): the one it is
53
+ * on first, then every floor of its level it touches, each by
54
+ * `floorFootprintName`. Drawn slabs and mezzanine decks are no floor to move
55
+ * a room onto.
56
+ */
57
+ export function roomFloorChoices(nodes: Nodes, zoneId: string): PlateChoice[] {
58
+ return coreRoomFloorChoices(nodes, zoneId)
59
+ .filter((choice) => !choice.drawn && !choice.mezzanine)
60
+ .map(({ key, plateId, name }) => ({ key, plateId, name }))
61
+ }
62
+
63
+ /**
64
+ * The plate a room on a separate floor stands on alone: its height is that
65
+ * plate's floor height (nothing else stands on it). Null for a room on the
66
+ * shared floor or sharing its separate floor with other rooms.
67
+ */
68
+ export function roomOwnPlate(nodes: Nodes, zoneId: string): SlabNode | null {
69
+ const zone = nodes[zoneId]
70
+ if (!isRoom(zone) || !zone.floor?.footprint || zone.floor.support === 'open') return null
71
+ const plate = listingPlate(nodes, zone)
72
+ if (!plate) return null
73
+ const alone = !(plate.zoneIds ?? []).some((id) => id !== zoneId && isGroundRoom(nodes[id]))
74
+ return alone ? plate : null
75
+ }
76
+
77
+ /**
78
+ * The shared floor beside a room on a separate floor, where it goes back to.
79
+ * For a room on the shared floor, its own plate.
80
+ */
81
+ export function roomSharedFootprint(nodes: Nodes, zoneId: string): SlabNode | null {
82
+ const zone = nodes[zoneId]
83
+ if (!isRoom(zone) || !zone.parentId) return null
84
+ if (!zone.floor?.footprint) return listingPlate(nodes, zone)
85
+ const shared = roomFloorChoices(nodes, zoneId).find((choice) => choice.key === null)
86
+ const plate = shared ? nodes[shared.plateId] : undefined
87
+ return plate?.type === 'slab' ? plate : null
88
+ }
89
+
90
+ export type RoomBuiltOnModel = {
91
+ /** The floor the room stands on (checked in the picker). */
92
+ current: PlateChoice
93
+ /** Every floor it can stand on, the current one first. */
94
+ choices: FloorChoice[]
95
+ /** The shared floor beside a room on a separate floor, when one touches it. */
96
+ shared: PlateChoice | null
97
+ }
98
+
99
+ /**
100
+ * What the "Built on" picker shows, or null when it has nothing to offer: a
101
+ * mezzanine, a room whose floor is a drawn slab, and a room on the shared
102
+ * floor alone on its footprint with no other floor touching it. A room on a
103
+ * separate floor can always go back to the shared floor, touching one or not.
104
+ */
105
+ export function roomBuiltOn(nodes: Nodes, zoneId: string): RoomBuiltOnModel | null {
106
+ const zone = nodes[zoneId]
107
+ if (!isGroundRoom(zone)) return null
108
+ if (roomDrawnFloor(nodes, zoneId)) return null
109
+ const choices = roomFloorChoices(nodes, zoneId)
110
+ const plate = listingPlate(nodes, zone)
111
+ const current = plate && choices.find((choice) => choice.plateId === plate.id)
112
+ if (!plate || !current) return null
113
+ const key = current.key
114
+ const alone = !(plate.zoneIds ?? []).some((id) => id !== zoneId && isGroundRoom(nodes[id]))
115
+ if (key === null && alone && choices.length === 1) return null
116
+ const sharedPlate = key === null ? null : roomSharedFootprint(nodes, zoneId)
117
+ const shared = sharedPlate
118
+ ? (choices.find((choice) => choice.plateId === sharedPlate.id) ?? null)
119
+ : null
120
+ const offersShared = choices.some((choice) => choice.key === null)
121
+ return { current, choices: offersShared ? choices : [...choices, SHARED_FLOOR], shared }
122
+ }
123
+
124
+ /**
125
+ * The Floor row's line for a room on a separate floor: "Lanai floor · 0.15 m
126
+ * below Shared floor", "… above …", or "… level with …", `offset` being the
127
+ * step from the shared floor's walking surface to the room's
128
+ * (`separateFloorOffset`).
129
+ */
130
+ export function separateFloorSummary(
131
+ model: { current: { name: string }; shared: { name: string } | null; offset: number | null },
132
+ length: (meters: number) => string,
133
+ ): string {
134
+ const { current, shared, offset } = model
135
+ if (!shared || offset === null) return current.name
136
+ if (Math.abs(offset) < 0.005) return `${current.name} · level with ${shared.name}`
137
+ return `${current.name} · ${length(Math.abs(offset))} ${offset > 0 ? 'above' : 'below'} ${shared.name}`
138
+ }