@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,321 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeId,
5
+ BuildingNode,
6
+ CeilingNode,
7
+ emitter,
8
+ LevelNode,
9
+ SiteNode,
10
+ useScene,
11
+ ZoneNode,
12
+ } from '@pascal-app/core'
13
+ import { useViewer } from '@pascal-app/viewer'
14
+ import { create } from '@react-three/test-renderer'
15
+ import { createElement } from 'react'
16
+ import { cancelActiveTool } from '../hooks/use-keyboard'
17
+ import useEditor from '../store/use-editor'
18
+ import useInteractionScope from '../store/use-interaction-scope'
19
+ import {
20
+ endCeilingEdit,
21
+ getCeilingEditSession,
22
+ setCeilingHoles,
23
+ startCeilingEdit,
24
+ startCeilingEditFromTreeSelection,
25
+ useCeilingEditSessionOwner,
26
+ } from './ceiling-edit-session'
27
+ import { boundaryReshapeScope, holeEditScope } from './interaction/scope'
28
+
29
+ const square: [number, number][] = [
30
+ [0, 0],
31
+ [4, 0],
32
+ [4, 4],
33
+ [0, 4],
34
+ ]
35
+ const site = SiteNode.parse({ id: 'site_ceiling_edit', children: ['building_ceiling_edit'] })
36
+ const building = BuildingNode.parse({
37
+ id: 'building_ceiling_edit',
38
+ parentId: site.id,
39
+ children: ['level_ceiling_edit_0', 'level_ceiling_edit_1'],
40
+ })
41
+ const level = LevelNode.parse({ id: 'level_ceiling_edit_0', parentId: building.id, level: 0 })
42
+ const upper = LevelNode.parse({ id: 'level_ceiling_edit_1', parentId: building.id, level: 1 })
43
+ const zone = ZoneNode.parse({
44
+ id: 'zone_ceiling_edit',
45
+ parentId: level.id,
46
+ name: 'Kitchen',
47
+ polygon: square,
48
+ spaceRole: 'room',
49
+ })
50
+ const ceiling = CeilingNode.parse({
51
+ id: 'ceiling_linked',
52
+ parentId: level.id,
53
+ polygon: square,
54
+ boundary: 'auto',
55
+ zoneId: zone.id,
56
+ })
57
+ const manual = CeilingNode.parse({
58
+ id: 'ceiling_manual',
59
+ parentId: level.id,
60
+ polygon: square.map(([x, z]) => [x + 10, z] as [number, number]),
61
+ })
62
+ const roomHole: [number, number][] = [
63
+ [1, 1],
64
+ [2, 1],
65
+ [2, 2],
66
+ [1, 2],
67
+ ]
68
+ const cutHole: [number, number][] = [
69
+ [2.5, 2.5],
70
+ [3, 2.5],
71
+ [3, 3],
72
+ [2.5, 3],
73
+ ]
74
+ const holed = CeilingNode.parse({
75
+ id: 'ceiling_holed',
76
+ parentId: upper.id,
77
+ polygon: square,
78
+ boundary: 'auto',
79
+ zoneId: 'zone_ceiling_edit_upper',
80
+ holes: [roomHole],
81
+ holeMetadata: [{ source: 'room' }],
82
+ })
83
+
84
+ const snapshots = {
85
+ scene: useScene.getState(),
86
+ viewer: useViewer.getState(),
87
+ editor: useEditor.getState(),
88
+ scope: useInteractionScope.getState(),
89
+ raf: globalThis.requestAnimationFrame,
90
+ cancelRaf: globalThis.cancelAnimationFrame,
91
+ }
92
+
93
+ beforeEach(() => {
94
+ // Scene updates schedule a dirty-flush frame.
95
+ globalThis.requestAnimationFrame = () => 0
96
+ globalThis.cancelAnimationFrame = () => {}
97
+ const nodes = Object.fromEntries(
98
+ [site, building, level, upper, zone, ceiling, manual, holed].map((node) => [node.id, node]),
99
+ ) as Record<AnyNodeId, AnyNode>
100
+ useScene.setState({ nodes, rootNodeIds: [site.id] })
101
+ useViewer.setState({
102
+ hoveredId: null,
103
+ selection: { buildingId: building.id, levelId: level.id, zoneId: null, selectedIds: [] },
104
+ })
105
+ useEditor.setState({ phase: 'building', mode: 'select', room: null, hoveredRoom: null })
106
+ useInteractionScope.setState({ scope: { kind: 'idle' } })
107
+ })
108
+
109
+ afterEach(() => {
110
+ endCeilingEdit()
111
+ useScene.setState(snapshots.scene)
112
+ useViewer.setState(snapshots.viewer)
113
+ useEditor.setState(snapshots.editor)
114
+ useInteractionScope.setState(snapshots.scope)
115
+ globalThis.requestAnimationFrame = snapshots.raf
116
+ globalThis.cancelAnimationFrame = snapshots.cancelRaf
117
+ })
118
+
119
+ const selectedIds = () => useViewer.getState().selection.selectedIds
120
+
121
+ describe('starting a session', () => {
122
+ test('the pill API opens the room ceiling from its zone id and selects it', () => {
123
+ expect(startCeilingEdit(zone.id)).toBe(true)
124
+ expect(getCeilingEditSession()).toEqual({
125
+ ceilingId: ceiling.id,
126
+ zoneId: zone.id,
127
+ levelId: level.id,
128
+ })
129
+ expect(selectedIds()).toEqual([ceiling.id])
130
+ })
131
+
132
+ test('a ceiling id opens that ceiling; an unknown target or a room without one does nothing', () => {
133
+ expect(startCeilingEdit(manual.id)).toBe(true)
134
+ expect(getCeilingEditSession()).toEqual({
135
+ ceilingId: manual.id,
136
+ zoneId: null,
137
+ levelId: level.id,
138
+ })
139
+ expect(startCeilingEdit('ceiling_missing')).toBe(false)
140
+ useScene.getState().deleteNode(ceiling.id as AnyNodeId)
141
+ endCeilingEdit()
142
+ expect(startCeilingEdit(zone.id)).toBe(false)
143
+ expect(getCeilingEditSession()).toBeNull()
144
+ })
145
+
146
+ test('the pill switches to the ceiling level and goes inside the building', () => {
147
+ useViewer.setState({
148
+ selection: { buildingId: building.id, levelId: upper.id, zoneId: null, selectedIds: [] },
149
+ })
150
+ useEditor.setState({ phase: 'site' })
151
+ expect(startCeilingEdit(zone.id)).toBe(true)
152
+ expect(useEditor.getState().phase).toBe('building')
153
+ expect(useViewer.getState().selection.levelId).toBe(level.id)
154
+ expect(getCeilingEditSession()?.ceilingId).toBe(ceiling.id)
155
+ })
156
+
157
+ test('a scene-graph selection of exactly the ceiling opens it; a multi-selection does not', () => {
158
+ useViewer.getState().setSelection({ selectedIds: [ceiling.id, manual.id] })
159
+ expect(startCeilingEditFromTreeSelection(ceiling.id)).toBe(false)
160
+ expect(getCeilingEditSession()).toBeNull()
161
+ useViewer.getState().setSelection({ selectedIds: [ceiling.id] })
162
+ expect(startCeilingEditFromTreeSelection(ceiling.id)).toBe(true)
163
+ expect(getCeilingEditSession()?.ceilingId).toBe(ceiling.id)
164
+ })
165
+
166
+ test('a canvas selection (drill-down or Alt-click) selects the ceiling without a session', () => {
167
+ useViewer.getState().setSelection({ selectedIds: [ceiling.id] })
168
+ expect(getCeilingEditSession()).toBeNull()
169
+ })
170
+ })
171
+
172
+ describe('ending a session', () => {
173
+ test('Escape returns to the ceiling room', () => {
174
+ startCeilingEdit(ceiling.id)
175
+ expect(cancelActiveTool()).toBe(true)
176
+ expect(getCeilingEditSession()).toBeNull()
177
+ expect(useEditor.getState().room).toEqual({ levelId: level.id, zoneId: zone.id })
178
+ expect(selectedIds()).toEqual([])
179
+ expect(useEditor.getState().mode).toBe('select')
180
+ })
181
+
182
+ test('Escape returns to the room even after a polygon edit detached the link', () => {
183
+ startCeilingEdit(ceiling.id)
184
+ useScene.getState().detachDerivedNode(ceiling.id as AnyNodeId, {
185
+ polygon: square.map(([x, z]) => [x, z + 0.5] as [number, number]),
186
+ })
187
+ const detached = useScene.getState().nodes[ceiling.id as AnyNodeId] as CeilingNode
188
+ expect(detached.boundary).toBeUndefined()
189
+ expect(detached.zoneId).toBeUndefined()
190
+ expect(getCeilingEditSession()?.ceilingId).toBe(ceiling.id)
191
+ cancelActiveTool()
192
+ expect(useEditor.getState().room).toEqual({ levelId: level.id, zoneId: zone.id })
193
+ })
194
+
195
+ test('Escape on a ceiling with no room clears the selection', () => {
196
+ startCeilingEdit(manual.id)
197
+ expect(cancelActiveTool()).toBe(true)
198
+ expect(getCeilingEditSession()).toBeNull()
199
+ expect(useEditor.getState().room).toBeNull()
200
+ expect(selectedIds()).toEqual([])
201
+ })
202
+
203
+ test('Escape during an in-flight reshape is left to the reshape', () => {
204
+ startCeilingEdit(ceiling.id)
205
+ useInteractionScope.getState().begin(boundaryReshapeScope(ceiling.id))
206
+ emitter.emit('tool:cancel')
207
+ expect(getCeilingEditSession()?.ceilingId).toBe(ceiling.id)
208
+ })
209
+
210
+ test('deselection, another selection, a level change, a phase change and deletion end it', () => {
211
+ const ends: Array<() => void> = [
212
+ () => useViewer.getState().setSelection({ selectedIds: [] }),
213
+ () => useViewer.getState().setSelection({ selectedIds: [manual.id] }),
214
+ () => useViewer.getState().setSelection({ selectedIds: [ceiling.id, manual.id] }),
215
+ () => useViewer.getState().setSelection({ levelId: upper.id }),
216
+ () => useEditor.getState().setPhase('site'),
217
+ () => useScene.getState().deleteNode(ceiling.id as AnyNodeId),
218
+ ]
219
+ for (const end of ends) {
220
+ useEditor.setState({ phase: 'building' })
221
+ useViewer.setState({
222
+ selection: { buildingId: building.id, levelId: level.id, zoneId: null, selectedIds: [] },
223
+ })
224
+ if (!useScene.getState().nodes[ceiling.id as AnyNodeId])
225
+ useScene.setState({ nodes: { ...useScene.getState().nodes, [ceiling.id]: ceiling } })
226
+ expect(startCeilingEdit(ceiling.id)).toBe(true)
227
+ end()
228
+ expect(getCeilingEditSession()).toBeNull()
229
+ }
230
+ })
231
+
232
+ test('height and material edits keep the session and the auto boundary', () => {
233
+ startCeilingEdit(ceiling.id)
234
+ useScene.getState().updateNode(ceiling.id as AnyNodeId, { height: 2.4, materialPreset: 'x' })
235
+ const edited = useScene.getState().nodes[ceiling.id as AnyNodeId] as CeilingNode
236
+ expect(edited.boundary).toBe('auto')
237
+ expect(edited.zoneId).toBe(zone.id)
238
+ expect(getCeilingEditSession()?.ceilingId).toBe(ceiling.id)
239
+ })
240
+
241
+ test('an ended session stops listening', () => {
242
+ startCeilingEdit(ceiling.id)
243
+ endCeilingEdit()
244
+ useViewer.getState().setSelection({ selectedIds: [ceiling.id] })
245
+ expect(cancelActiveTool()).toBe(false)
246
+ expect(useEditor.getState().room).toBeNull()
247
+ })
248
+ })
249
+
250
+ describe('session ownership', () => {
251
+ test('unmounting the editor that owns the session ends it', async () => {
252
+ const renderer = await create(
253
+ createElement(function Owner() {
254
+ useCeilingEditSessionOwner()
255
+ return null
256
+ }),
257
+ )
258
+ startCeilingEdit(ceiling.id)
259
+ expect(getCeilingEditSession()?.ceilingId).toBe(ceiling.id)
260
+ await renderer.unmount()
261
+ expect(getCeilingEditSession()).toBeNull()
262
+ useViewer.getState().setSelection({ selectedIds: [ceiling.id] })
263
+ expect(cancelActiveTool()).toBe(false)
264
+ })
265
+
266
+ test("ending the session ends its hole edit, not another node's", () => {
267
+ startCeilingEdit(ceiling.id)
268
+ useInteractionScope.getState().begin(holeEditScope({ nodeId: ceiling.id, holeIndex: 0 }))
269
+ endCeilingEdit()
270
+ expect(useInteractionScope.getState().scope.kind).toBe('idle')
271
+
272
+ startCeilingEdit(ceiling.id)
273
+ useInteractionScope.getState().begin(holeEditScope({ nodeId: 'slab_other', holeIndex: 0 }))
274
+ endCeilingEdit()
275
+ expect(useInteractionScope.getState().scope).toMatchObject({
276
+ reshape: 'hole',
277
+ nodeId: 'slab_other',
278
+ })
279
+ })
280
+ })
281
+
282
+ describe('hole edits', () => {
283
+ const node = (id: string) => useScene.getState().nodes[id as AnyNodeId] as CeilingNode
284
+
285
+ test('a manual hole on an auto ceiling keeps the room link (only polygon edits detach)', () => {
286
+ startCeilingEdit(ceiling.id)
287
+ const before = node(ceiling.id)
288
+ expect(
289
+ setCeilingHoles(ceiling.id, { holes: [cutHole], holeMetadata: [{ source: 'manual' }] }),
290
+ ).toBe(true)
291
+ expect(node(ceiling.id).holes).toEqual([cutHole])
292
+ expect(node(ceiling.id).boundary).toBe(before.boundary)
293
+ expect(node(ceiling.id).zoneId).toBe(before.zoneId)
294
+ expect(getCeilingEditSession()?.ceilingId).toBe(ceiling.id)
295
+ // Deleting it again is a plain update too.
296
+ expect(setCeilingHoles(ceiling.id, { holes: [], holeMetadata: [] })).toBe(true)
297
+ expect(node(ceiling.id).zoneId).toBe(before.zoneId)
298
+ })
299
+
300
+ test('room-cut holes stay protected: a write that changes one is refused', () => {
301
+ const moved = roomHole.map(([x, z]) => [x + 0.5, z] as [number, number])
302
+ expect(setCeilingHoles(holed.id, { holes: [moved] })).toBe(false)
303
+ expect(setCeilingHoles(holed.id, { holes: [], holeMetadata: [] })).toBe(false)
304
+ expect(node(holed.id)).toEqual(holed)
305
+
306
+ expect(
307
+ setCeilingHoles(holed.id, {
308
+ holes: [roomHole, cutHole],
309
+ holeMetadata: [{ source: 'room' }, { source: 'manual' }],
310
+ }),
311
+ ).toBe(true)
312
+ expect(node(holed.id).holes).toEqual([roomHole, cutHole])
313
+ expect(node(holed.id).boundary).toBe(holed.boundary)
314
+ })
315
+
316
+ test('a manual ceiling takes a plain update; a non-ceiling is refused', () => {
317
+ expect(setCeilingHoles(manual.id, { holes: [cutHole] })).toBe(true)
318
+ expect(node(manual.id).holes).toEqual([cutHole])
319
+ expect(setCeilingHoles(zone.id, { holes: [cutHole] })).toBe(false)
320
+ })
321
+ })
@@ -0,0 +1,206 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ type BuildingNode,
5
+ type CeilingNode,
6
+ derivedFieldViolations,
7
+ emitter,
8
+ isDerivedNode,
9
+ type LevelNode,
10
+ resolveLevelId,
11
+ useScene,
12
+ } from '@pascal-app/core'
13
+ import { useViewer } from '@pascal-app/viewer'
14
+ import { useEffect } from 'react'
15
+ import { create } from 'zustand'
16
+ import { markToolCancelConsumed } from '../hooks/use-keyboard'
17
+ import useEditor from '../store/use-editor'
18
+ import useInteractionScope from '../store/use-interaction-scope'
19
+ import { selectRoom } from './room-selection-commands'
20
+ import { completeElementAction } from './room-zone-routing'
21
+
22
+ /**
23
+ * The explicit "Edit ceiling" session: while it holds a ceiling, and only then,
24
+ * that ceiling shows its corner brackets, polygon editor and height handle.
25
+ *
26
+ * A tiny store rather than an interaction scope: a non-idle scope turns off
27
+ * selection picking and hides every overlay, while the session is a standing
28
+ * mode in which the rest of the scene must stay hoverable and clickable. The
29
+ * polygon drags inside it still run their own `reshaping` scope.
30
+ */
31
+ export type CeilingEditSession = {
32
+ ceilingId: CeilingNode['id']
33
+ /** The room the ceiling belonged to at start; a polygon edit detaches the link. */
34
+ zoneId: string | null
35
+ levelId: string
36
+ }
37
+
38
+ export const useCeilingEditSession = create<{ session: CeilingEditSession | null }>(() => ({
39
+ session: null,
40
+ }))
41
+
42
+ /** The ceiling whose edit controls may show, or null outside a session. */
43
+ export function useCeilingEditCeilingId(): string | null {
44
+ return useCeilingEditSession((state) => state.session?.ceilingId ?? null)
45
+ }
46
+
47
+ export function getCeilingEditSession(): CeilingEditSession | null {
48
+ return useCeilingEditSession.getState().session
49
+ }
50
+
51
+ type Nodes = Record<string, AnyNode>
52
+
53
+ function resolveCeiling(nodes: Nodes, target: string): CeilingNode | null {
54
+ const node = nodes[target]
55
+ if (node?.type === 'ceiling') return node
56
+ if (node?.type !== 'zone') return null
57
+ const linked = Object.values(nodes).filter(
58
+ (candidate): candidate is CeilingNode =>
59
+ candidate.type === 'ceiling' && candidate.zoneId === target,
60
+ )
61
+ return (
62
+ linked.find((ceiling) => ceiling.parentId === node.parentId && ceiling.boundary === 'auto') ??
63
+ linked.find((ceiling) => ceiling.parentId === node.parentId) ??
64
+ linked[0] ??
65
+ null
66
+ )
67
+ }
68
+
69
+ let stopLifecycle: (() => void) | null = null
70
+
71
+ /**
72
+ * Starts editing the ceiling `target` names: a ceiling id, or a room zone id
73
+ * (its linked ceiling). Selects the ceiling on its level, going inside the
74
+ * building first. Returns false when there is no such ceiling.
75
+ */
76
+ export function startCeilingEdit(target: string): boolean {
77
+ const nodes = useScene.getState().nodes as Nodes
78
+ const ceiling = resolveCeiling(nodes, target)
79
+ if (!ceiling) return false
80
+ endCeilingEdit()
81
+
82
+ const levelId = resolveLevelId(ceiling, nodes)
83
+ const zone = ceiling.zoneId ? nodes[ceiling.zoneId] : nodes[target]
84
+ const zoneId = zone?.type === 'zone' ? zone.id : null
85
+ if (useEditor.getState().phase === 'site') useEditor.getState().setPhase('building')
86
+
87
+ useCeilingEditSession.setState({ session: { ceilingId: ceiling.id, zoneId, levelId } })
88
+ const level = nodes[levelId]
89
+ const selection = useViewer.getState().selection
90
+ useViewer.getState().setSelection({
91
+ ...(level?.parentId && level.parentId !== selection.buildingId
92
+ ? { buildingId: level.parentId as BuildingNode['id'] }
93
+ : {}),
94
+ ...(levelId !== selection.levelId ? { levelId: levelId as LevelNode['id'] } : {}),
95
+ selectedIds: [ceiling.id],
96
+ })
97
+ stopLifecycle = watchSession()
98
+ return true
99
+ }
100
+
101
+ /**
102
+ * The scene-graph entry: a plain tree click that left exactly this ceiling
103
+ * selected opens its session. Modifier multi-selection does not.
104
+ */
105
+ export function startCeilingEditFromTreeSelection(nodeId: string): boolean {
106
+ const { selectedIds } = useViewer.getState().selection
107
+ if (selectedIds.length !== 1 || selectedIds[0] !== nodeId) return false
108
+ return startCeilingEdit(nodeId)
109
+ }
110
+
111
+ export function endCeilingEdit() {
112
+ stopLifecycle?.()
113
+ stopLifecycle = null
114
+ const session = getCeilingEditSession()
115
+ if (!session) return
116
+ useCeilingEditSession.setState({ session: null })
117
+ // The hole editor mounts only inside the session; a hole scope it leaves behind
118
+ // would keep selection picking off with no editor to end it.
119
+ useInteractionScope
120
+ .getState()
121
+ .endIf(
122
+ (scope) =>
123
+ scope.kind === 'reshaping' &&
124
+ scope.reshape === 'hole' &&
125
+ scope.nodeId === session.ceilingId,
126
+ )
127
+ }
128
+
129
+ /** Ends the session with the editor that owns it, so none outlives an unmount. */
130
+ export function useCeilingEditSessionOwner() {
131
+ useEffect(() => endCeilingEdit, [])
132
+ }
133
+
134
+ /**
135
+ * Writes a ceiling's holes. Manual holes are authored data the derived-node
136
+ * guard allows on an auto ceiling, so this is a plain update that keeps the
137
+ * room link (only a polygon edit detaches); a write that would change a
138
+ * room-cut hole is refused (returns false).
139
+ */
140
+ export function setCeilingHoles(
141
+ ceilingId: string,
142
+ patch: Pick<CeilingNode, 'holes'> & Partial<Pick<CeilingNode, 'holeMetadata'>>,
143
+ ): boolean {
144
+ const scene = useScene.getState()
145
+ const ceiling = scene.nodes[ceilingId as AnyNodeId]
146
+ if (ceiling?.type !== 'ceiling') return false
147
+ if (isDerivedNode(ceiling) && derivedFieldViolations(ceiling, patch).length > 0) return false
148
+ scene.updateNode(ceilingId as AnyNodeId, patch)
149
+ return true
150
+ }
151
+
152
+ /** Escape: leaves the session for the ceiling's room, or for nothing when it has none. */
153
+ export function exitCeilingEditToRoom(): boolean {
154
+ const session = getCeilingEditSession()
155
+ if (!session) return false
156
+ endCeilingEdit()
157
+ const zone = session.zoneId ? useScene.getState().nodes[session.zoneId as AnyNodeId] : undefined
158
+ if (zone?.type !== 'zone') {
159
+ useViewer.getState().setSelection({ selectedIds: [] })
160
+ return true
161
+ }
162
+ const room = { levelId: session.levelId, zoneId: zone.id }
163
+ // A room the index no longer knows (its walls just opened) is still where the user came from.
164
+ if (!completeElementAction({ room, point: null })) selectRoom(room)
165
+ return true
166
+ }
167
+
168
+ function watchSession() {
169
+ const active = () => getCeilingEditSession()
170
+ const stopViewer = useViewer.subscribe((state) => {
171
+ const session = active()
172
+ if (!session) return
173
+ const { selectedIds, levelId } = state.selection
174
+ if (
175
+ levelId !== session.levelId ||
176
+ selectedIds.length !== 1 ||
177
+ selectedIds[0] !== session.ceilingId
178
+ )
179
+ endCeilingEdit()
180
+ })
181
+ const stopEditor = useEditor.subscribe((state, previous) => {
182
+ if (state.phase !== previous.phase) endCeilingEdit()
183
+ })
184
+ const stopScene = useScene.subscribe((state) => {
185
+ const session = active()
186
+ if (session && state.nodes[session.ceilingId as AnyNodeId]?.type !== 'ceiling') endCeilingEdit()
187
+ })
188
+ // A reshape in flight owns Escape (it cancels the drag); only an idle session exits.
189
+ const onToolCancel = () => {
190
+ if (
191
+ !active() ||
192
+ useEditor.getState().mode !== 'select' ||
193
+ useInteractionScope.getState().scope.kind !== 'idle'
194
+ )
195
+ return
196
+ exitCeilingEditToRoom()
197
+ markToolCancelConsumed()
198
+ }
199
+ emitter.on('tool:cancel', onToolCancel)
200
+ return () => {
201
+ stopViewer()
202
+ stopEditor()
203
+ stopScene()
204
+ emitter.off('tool:cancel', onToolCancel)
205
+ }
206
+ }
@@ -0,0 +1,179 @@
1
+ import { afterAll, afterEach, beforeEach, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeId,
5
+ area,
6
+ CeilingNode,
7
+ clearSceneHistory,
8
+ LevelNode,
9
+ useScene,
10
+ ZoneNode,
11
+ } from '@pascal-app/core'
12
+ import useEditor from '../store/use-editor'
13
+ import {
14
+ describeCeilingRegionRoom,
15
+ floorRegionRoomGone,
16
+ moveFloorRegion,
17
+ PICK_MATERIAL_MESSAGE,
18
+ pressFloorRegion,
19
+ releaseFloorRegion,
20
+ useFloorRegionDraft,
21
+ } from './floor-region-session'
22
+ import type { FloorRegionPoint } from './floor-region-snap'
23
+ import { paintRegionHoverHint, usePaintRegionMode } from './paint-region-mode'
24
+
25
+ // Rectangle and Polygon on a ceiling: the floor's outline gesture, drawn on the
26
+ // ceiling's underside, committing one region to the room (an automatic
27
+ // ceiling) or to the ceiling (a manual one) in one undo step.
28
+
29
+ const square = (x: number, z: number, size: number): [number, number][] => [
30
+ [x, z],
31
+ [x + size, z],
32
+ [x + size, z + size],
33
+ [x, z + size],
34
+ ]
35
+ const OFF = { mode: 'off', step: 0.5 } as const
36
+ const CLOSE = 0.2
37
+
38
+ const level = LevelNode.parse({ id: 'level_ceiling_region' })
39
+ const zone = ZoneNode.parse({
40
+ id: 'zone_ceiling_region',
41
+ name: 'Room',
42
+ parentId: level.id,
43
+ polygon: square(0, 0, 4),
44
+ floor: { regions: [{ id: 'rug', polygon: square(1, 1, 1), finish: 'library:tile' }] },
45
+ })
46
+ // A floor opening cut the room's ceiling in two; both parts are the room's.
47
+ const west = CeilingNode.parse({
48
+ id: 'ceiling_west',
49
+ parentId: level.id,
50
+ polygon: [
51
+ [0, 0],
52
+ [2, 0],
53
+ [2, 4],
54
+ [0, 4],
55
+ ],
56
+ boundary: 'auto',
57
+ zoneId: zone.id,
58
+ height: 2.5,
59
+ })
60
+ const east = CeilingNode.parse({
61
+ ...west,
62
+ id: 'ceiling_east',
63
+ polygon: [
64
+ [2.5, 0],
65
+ [4, 0],
66
+ [4, 4],
67
+ [2.5, 4],
68
+ ],
69
+ })
70
+ const manual = CeilingNode.parse({
71
+ id: 'ceiling_manual',
72
+ parentId: level.id,
73
+ polygon: square(6, 0, 2),
74
+ height: 2.2,
75
+ })
76
+ const nodes = Object.fromEntries(
77
+ [level, zone, west, east, manual].map((node) => [node.id, node]),
78
+ ) as Record<string, AnyNode>
79
+
80
+ const stubbed: string[] = []
81
+ const before = useScene.getState()
82
+ const node = <T extends AnyNode>(id: string) => useScene.getState().nodes[id as AnyNodeId] as T
83
+ const history = () => useScene.temporal.getState().pastStates.length
84
+
85
+ function drag(ceiling: CeilingNode, from: FloorRegionPoint, to: FloorRegionPoint) {
86
+ const room = describeCeilingRegionRoom(useScene.getState().nodes, ceiling)
87
+ pressFloorRegion('rectangle', room, from, OFF, CLOSE)
88
+ moveFloorRegion(to, OFF, CLOSE)
89
+ releaseFloorRegion(CLOSE)
90
+ }
91
+
92
+ beforeEach(() => {
93
+ globalThis.requestAnimationFrame ??= (callback) => {
94
+ callback(0)
95
+ return 0
96
+ }
97
+ globalThis.cancelAnimationFrame ??= () => {}
98
+ if (!globalThis.window) {
99
+ globalThis.window = new EventTarget() as Window & typeof globalThis
100
+ stubbed.push('window')
101
+ }
102
+ useScene.setState({ nodes, materials: {}, readOnly: false })
103
+ useScene.temporal.getState().resume()
104
+ clearSceneHistory()
105
+ useEditor.setState({
106
+ mode: 'material-paint',
107
+ activePaintMaterial: { materialPreset: 'library:blue', sourceTarget: 'ceiling' } as never,
108
+ })
109
+ usePaintRegionMode.getState().setMode('rectangle')
110
+ useFloorRegionDraft.setState({ draft: null, hover: null })
111
+ })
112
+ afterEach(() => {
113
+ useScene.setState(before)
114
+ usePaintRegionMode.getState().setMode('surface')
115
+ useFloorRegionDraft.setState({ draft: null, hover: null })
116
+ useEditor.setState({ mode: 'select', activePaintMaterial: null })
117
+ })
118
+ afterAll(async () => {
119
+ await new Promise((resolve) => setTimeout(resolve, 50))
120
+ for (const key of stubbed.splice(0)) delete (globalThis as Record<string, unknown>)[key]
121
+ })
122
+
123
+ test('an automatic ceiling draws on every part of its room, at the plane it draws at', () => {
124
+ const room = describeCeilingRegionRoom(nodes, west)
125
+ expect(room).toMatchObject({ zoneId: zone.id, ceilingId: west.id, levelId: level.id })
126
+ expect(room.elevation).toBeCloseTo(2.49)
127
+ expect(area(room.clear)).toBeCloseTo(8 + 6)
128
+ expect(describeCeilingRegionRoom(nodes, manual)).toMatchObject({ zoneId: manual.id })
129
+ })
130
+
131
+ test('a rectangle on a room ceiling paints one room region, clipped to the ceiling', () => {
132
+ drag(west, [1, 1], [3, 3])
133
+ const regions = node<ZoneNode>(zone.id).ceiling?.regions ?? []
134
+ expect(regions).toHaveLength(1)
135
+ expect(regions[0]!.finish).toBe('library:blue')
136
+ // Across the opening gap: 1 × 2 on the west part, 0.5 × 2 on the east part.
137
+ expect(area([{ outer: regions[0]!.polygon, holes: [] }])).toBeGreaterThan(2.9)
138
+ expect(history()).toBe(1)
139
+ // The floor is untouched.
140
+ expect(node<ZoneNode>(zone.id).floor).toEqual(zone.floor)
141
+ expect(useFloorRegionDraft.getState().draft).toBeNull()
142
+ })
143
+
144
+ test('a polygon on a manual ceiling paints the ceiling itself', () => {
145
+ usePaintRegionMode.getState().setMode('polygon')
146
+ const room = describeCeilingRegionRoom(useScene.getState().nodes, manual)
147
+ for (const point of [
148
+ [6.2, 0.2],
149
+ [7.5, 0.2],
150
+ [7.5, 1.5],
151
+ [6.2, 0.2],
152
+ ] as FloorRegionPoint[]) {
153
+ pressFloorRegion('polygon', room, point, OFF, CLOSE)
154
+ releaseFloorRegion(CLOSE)
155
+ }
156
+ expect(node<CeilingNode>(manual.id).regions).toHaveLength(1)
157
+ expect(node<ZoneNode>(zone.id).ceiling).toBeUndefined()
158
+ expect(history()).toBe(1)
159
+ })
160
+
161
+ test('outside the ceiling, or with nothing to paint with, nothing is written', () => {
162
+ drag(manual, [10, 10], [12, 12])
163
+ expect(node<CeilingNode>(manual.id).regions).toBeUndefined()
164
+ useEditor.setState({ activePaintMaterial: null })
165
+ drag(manual, [6.5, 0.5], [7.5, 1.5])
166
+ expect(node<CeilingNode>(manual.id).regions).toBeUndefined()
167
+ expect(usePaintRegionMode.getState().notice).toBe(PICK_MATERIAL_MESSAGE)
168
+ expect(history()).toBe(0)
169
+ })
170
+
171
+ test('a ceiling draft ends when its ceiling goes; the hints name ceilings', () => {
172
+ const room = describeCeilingRegionRoom(nodes, manual)
173
+ pressFloorRegion('rectangle', room, [6.5, 0.5], OFF, CLOSE)
174
+ const { [manual.id]: _, ...rest } = nodes
175
+ expect(floorRegionRoomGone(rest)).toBe(true)
176
+ expect(floorRegionRoomGone(nodes)).toBe(false)
177
+ expect(paintRegionHoverHint('polygon')).toBe('Hover a floor or ceiling')
178
+ expect(paintRegionHoverHint('rectangle')).toBe('Hover a wall, floor or ceiling')
179
+ })