@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,218 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ area,
5
+ cutFloorOpening,
6
+ type FloorOpeningHint,
7
+ generateId,
8
+ type LevelNode,
9
+ type MultiPolygon,
10
+ type StructurePlan,
11
+ structureChangeBatch,
12
+ useScene,
13
+ } from '@pascal-app/core'
14
+ import { useViewer } from '@pascal-app/viewer'
15
+ import { create } from 'zustand'
16
+ import { getRoomSelectionIndex } from '../hooks/use-selected-room'
17
+ import useEditor from '../store/use-editor'
18
+ import {
19
+ CROSSING_MESSAGE,
20
+ clipFloorRegion,
21
+ floorRegionAxisAngle,
22
+ floorRegionSelfIntersects,
23
+ } from './floor-region-geometry'
24
+ import {
25
+ cancelFloorRegion,
26
+ type FloorDraftCommit,
27
+ type FloorDraftPurpose,
28
+ type FloorRegionRoom,
29
+ useFloorRegionDraft,
30
+ } from './floor-region-session'
31
+ import { type FloorRegionPoint, floorRegionSnapTargets } from './floor-region-snap'
32
+ import { beginGesture, type GestureHandle } from './gesture-lifecycle'
33
+ import { resolveRoomAssemblyHeights } from './room-assembly-overlay'
34
+ import { roomFloorElevation } from './room-handle-drag'
35
+ import { sfxEmitter } from './sfx-bus'
36
+
37
+ // "Cut opening": draws an opening on a room's floor (or its ceiling) with the
38
+ // floor region draft, rectangle or polygon, snapped to the room's wall faces
39
+ // and its other openings. One outline is one `floor-opening` node (core's
40
+ // `cutFloorOpening`), one undo step; the reconciler cuts the surfaces. The new
41
+ // opening is then selected so its shape and switch are a click away, and Done
42
+ // or Delete returns to the room.
43
+
44
+ export const OPENING_DRAFT_HANDLE = 'floor-opening-draft'
45
+ export const OPENING_OUTSIDE_MESSAGE = 'Draw the opening inside the room.'
46
+ export const OPENING_TOO_SMALL_MESSAGE = 'Too small for an opening.'
47
+ export const OPENING_FAILED_MESSAGE = "Can't cut an opening here."
48
+ /** Smallest opening worth cutting, m². */
49
+ const MIN_OPENING_AREA = 0.04
50
+
51
+ export type OpeningShape = 'rectangle' | 'polygon'
52
+ export type OpeningSurface = 'floor' | 'ceiling'
53
+
54
+ export const useOpeningDraft = create<{
55
+ host: (FloorRegionRoom & { drawnOn: OpeningSurface }) | null
56
+ shape: OpeningShape
57
+ /** Hints the last cut returned (a manual ceiling below that is not cut). */
58
+ hints: FloorOpeningHint[]
59
+ }>(() => ({ host: null, shape: 'rectangle', hints: [] }))
60
+
61
+ /** The plan for an outline, or why it cannot be cut. */
62
+ export function planFloorOpening(
63
+ nodes: Record<string, AnyNode>,
64
+ host: FloorRegionRoom,
65
+ drawnOn: OpeningSurface,
66
+ polygon: readonly FloorRegionPoint[],
67
+ ):
68
+ | { plan: StructurePlan & { openingIds: string[]; hints: FloorOpeningHint[] } }
69
+ | { message: string } {
70
+ const outline = polygon.map(([x, z]): [number, number] => [x, z])
71
+ if (floorRegionSelfIntersects(outline)) return { message: CROSSING_MESSAGE }
72
+ if (area([{ outer: outline, holes: [] }]) < MIN_OPENING_AREA)
73
+ return { message: OPENING_TOO_SMALL_MESSAGE }
74
+ if (!clipFloorRegion(polygon, host.clear)) return { message: OPENING_OUTSIDE_MESSAGE }
75
+ try {
76
+ const plan = cutFloorOpening(nodes, {
77
+ zoneId: host.zoneId,
78
+ polygon: outline,
79
+ drawnOn,
80
+ mintId: generateId,
81
+ })
82
+ const conflict = plan.conflicts?.[0]
83
+ return conflict ? { message: conflict.message || OPENING_FAILED_MESSAGE } : { plan }
84
+ } catch {
85
+ return { message: OPENING_FAILED_MESSAGE }
86
+ }
87
+ }
88
+
89
+ let tool: GestureHandle | null = null
90
+
91
+ function reset() {
92
+ if (useOpeningDraft.getState().host) useOpeningDraft.setState({ host: null })
93
+ const { draft } = useFloorRegionDraft.getState()
94
+ if (draft?.room.purpose === OPENING) useFloorRegionDraft.setState({ draft: null, hover: null })
95
+ }
96
+
97
+ function commitOpening(room: FloorRegionRoom, polygon: readonly FloorRegionPoint[]) {
98
+ const host = useOpeningDraft.getState().host
99
+ const drawnOn = host?.drawnOn ?? 'floor'
100
+ const result = planFloorOpening(useScene.getState().nodes, room, drawnOn, polygon)
101
+ if ('message' in result) return { ok: false, message: result.message } as FloorDraftCommit
102
+ const apply = () =>
103
+ useScene.getState().applyNodeChanges(structureChangeBatch(result.plan.changes))
104
+ const handle = tool
105
+ tool = null
106
+ try {
107
+ if (handle?.active) handle.finish(apply)
108
+ else apply()
109
+ } finally {
110
+ reset()
111
+ }
112
+ sfxEmitter.emit('sfx:structure-build')
113
+ useOpeningDraft.setState({ hints: result.plan.hints })
114
+ const openingId = result.plan.openingIds[0]
115
+ if (openingId) useViewer.getState().setSelection({ selectedIds: [openingId as AnyNodeId] })
116
+ return { ok: true } as const
117
+ }
118
+
119
+ function hostGone(host: FloorRegionRoom) {
120
+ const scene = useScene.getState()
121
+ const zone = scene.nodes[host.zoneId as AnyNodeId]
122
+ return scene.readOnly || zone?.type !== 'zone' || zone.parentId !== host.levelId
123
+ }
124
+
125
+ function beginTool(host: FloorRegionRoom & { drawnOn: OpeningSurface }) {
126
+ const handle = beginGesture({
127
+ kind: 'floor-opening-draft',
128
+ scope: { kind: 'handle-drag', nodeId: host.zoneId, handle: OPENING_DRAFT_HANDLE },
129
+ stale: () => hostGone(host),
130
+ onCancel: () => {
131
+ if (tool === handle) tool = null
132
+ reset()
133
+ },
134
+ })
135
+ tool = handle
136
+ useOpeningDraft.setState({ host, hints: [] })
137
+ return handle
138
+ }
139
+
140
+ const OPENING: FloorDraftPurpose = {
141
+ persistent: true,
142
+ own: (room) => {
143
+ if (tool?.active) return tool
144
+ const host = useOpeningDraft.getState().host
145
+ return beginTool({ ...room, drawnOn: host?.drawnOn ?? 'floor' })
146
+ },
147
+ commit: commitOpening,
148
+ fill: '#f59e0b',
149
+ }
150
+
151
+ /**
152
+ * The room an opening is drawn in: its clear floor bounds and snaps the
153
+ * outline (with the level's other openings); the draft plane is its floor, or
154
+ * its ceiling for a ceiling opening. A mezzanine draws on its own deck.
155
+ */
156
+ export function describeOpeningHost(
157
+ nodes: Record<string, AnyNode>,
158
+ zoneId: string,
159
+ drawnOn: OpeningSurface,
160
+ ): (FloorRegionRoom & { drawnOn: OpeningSurface }) | null {
161
+ const zone = nodes[zoneId]
162
+ if (zone?.type !== 'zone' || zone.spaceRole !== 'room' || !zone.parentId) return null
163
+ if (zone.floor?.support === 'open' && drawnOn !== 'floor') return null
164
+ const record = getRoomSelectionIndex(zone.parentId)
165
+ .update(nodes as ReturnType<typeof useScene.getState>['nodes'])
166
+ .find((room) => room.zoneId === zoneId)
167
+ if (!record) return null
168
+ const clear: MultiPolygon = record.clearPolygon
169
+ const others = Object.values(nodes).flatMap((node) =>
170
+ node.type === 'floor-opening' && node.parentId === zone.parentId ? [node.polygon] : [],
171
+ )
172
+ const heights = resolveRoomAssemblyHeights(record, nodes)
173
+ const elevation =
174
+ drawnOn === 'ceiling'
175
+ ? (heights.ceiling?.y ?? heights.floorY + 2.5)
176
+ : roomFloorElevation(nodes, zoneId)
177
+ return {
178
+ zoneId,
179
+ levelId: zone.parentId,
180
+ clear,
181
+ elevation,
182
+ angle: floorRegionAxisAngle(clear),
183
+ targets: floorRegionSnapTargets(clear, others),
184
+ purpose: OPENING,
185
+ drawnOn,
186
+ }
187
+ }
188
+
189
+ /** Arms "Cut opening" in a room, on its floor or its ceiling. False when it cannot. */
190
+ export function startOpeningDraft(
191
+ zoneId: string,
192
+ drawnOn: OpeningSurface = 'floor',
193
+ shape: OpeningShape = 'rectangle',
194
+ ): boolean {
195
+ const nodes = useScene.getState().nodes
196
+ if (useScene.getState().readOnly) return false
197
+ const host = describeOpeningHost(nodes, zoneId, drawnOn)
198
+ if (!host) return false
199
+ cancelOpeningDraft()
200
+ cancelFloorRegion()
201
+ useOpeningDraft.setState({ shape })
202
+ if (useEditor.getState().mode !== 'select') useEditor.getState().setMode('select')
203
+ if (useViewer.getState().selection.levelId !== host.levelId)
204
+ useViewer.getState().setSelection({ levelId: host.levelId as LevelNode['id'] })
205
+ beginTool(host)
206
+ return true
207
+ }
208
+
209
+ export function cancelOpeningDraft() {
210
+ const current = tool
211
+ tool = null
212
+ if (current?.active) current.cancel()
213
+ else reset()
214
+ }
215
+
216
+ export function openingDraftActive() {
217
+ return !!useOpeningDraft.getState().host
218
+ }
@@ -0,0 +1,216 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ BuildingNode,
5
+ createMezzanine,
6
+ createZone,
7
+ type FloorOpeningNode,
8
+ generateId,
9
+ initSpaceDetectionSync,
10
+ LevelNode,
11
+ structureChangeBatch,
12
+ useScene,
13
+ } from '@pascal-app/core'
14
+ import { useViewer } from '@pascal-app/viewer'
15
+ import useEditor from '../store/use-editor'
16
+ import { installImmediateAnimationFrames } from '../test-utils/immediate-animation-frames'
17
+ import {
18
+ deleteFloorOpening,
19
+ finishFloorEdit,
20
+ finishFloorOpening,
21
+ roomSurfaceOpenings,
22
+ startFloorEdit,
23
+ useFloorEditSession,
24
+ } from './floor-edit-session'
25
+ import {
26
+ cancelOpeningDraft,
27
+ describeOpeningHost,
28
+ OPENING_OUTSIDE_MESSAGE,
29
+ OPENING_TOO_SMALL_MESSAGE,
30
+ openingDraftActive,
31
+ planFloorOpening,
32
+ startOpeningDraft,
33
+ useOpeningDraft,
34
+ } from './floor-opening-draft'
35
+ import { applyRoomPlan } from './room-structure-commands'
36
+
37
+ const GROUND = 'level_openings_ground'
38
+ const UPPER = 'level_openings_upper'
39
+ let stop = () => {}
40
+ let restoreFrames = () => {}
41
+ const nodes = () => useScene.getState().nodes as Record<string, AnyNode>
42
+ const history = () => useScene.temporal.getState().pastStates.length
43
+ const groundCeilingHoles = () =>
44
+ Object.values(nodes()).flatMap((n) =>
45
+ n.type === 'ceiling' && n.parentId === GROUND ? (n.holes ?? []) : [],
46
+ )
47
+ const upperPlateHoles = () =>
48
+ Object.values(nodes()).flatMap((n) =>
49
+ n.type === 'slab' && n.parentId === UPPER ? (n.holes ?? []) : [],
50
+ )
51
+ const openings = () =>
52
+ Object.values(nodes()).filter((n): n is FloorOpeningNode => n.type === 'floor-opening')
53
+
54
+ const rect = (x0: number, z0: number, x1: number, z1: number): [number, number][] => [
55
+ [x0, z0],
56
+ [x1, z0],
57
+ [x1, z1],
58
+ [x0, z1],
59
+ ]
60
+ const room = (levelId: string, name: string) => {
61
+ const plan = createZone(nodes(), {
62
+ levelId,
63
+ polygon: rect(0, 0, 8, 5),
64
+ enclose: true,
65
+ mintId: generateId,
66
+ name,
67
+ })
68
+ applyRoomPlan(plan)
69
+ return plan.zoneId
70
+ }
71
+
72
+ let groundRoom: string
73
+ let upperRoom: string
74
+
75
+ beforeEach(() => {
76
+ restoreFrames = installImmediateAnimationFrames()
77
+ const building = BuildingNode.parse({ id: 'building_openings', children: [GROUND, UPPER] })
78
+ const ground = LevelNode.parse({ id: GROUND, parentId: building.id, level: 0 })
79
+ const upper = LevelNode.parse({ id: UPPER, parentId: building.id, level: 1 })
80
+ useScene.setState({
81
+ nodes: { [building.id]: building, [ground.id]: ground, [upper.id]: upper },
82
+ rootNodeIds: [building.id],
83
+ dirtyNodes: new Set(),
84
+ materials: {},
85
+ collections: {},
86
+ readOnly: false,
87
+ })
88
+ useScene.temporal.getState().resume()
89
+ stop = initSpaceDetectionSync(useScene, { getState: () => ({ spaces: {}, setSpaces: () => {} }) })
90
+ groundRoom = room(GROUND, 'Hall')
91
+ upperRoom = room(UPPER, 'Landing')
92
+ useScene.temporal.getState().clear()
93
+ useEditor.setState({ phase: 'building', mode: 'select', room: null })
94
+ useViewer.getState().setSelection({ buildingId: building.id, levelId: UPPER, selectedIds: [] })
95
+ })
96
+ afterEach(() => {
97
+ cancelOpeningDraft()
98
+ finishFloorEdit()
99
+ stop()
100
+ restoreFrames()
101
+ })
102
+
103
+ const cutVoid = () => {
104
+ const host = describeOpeningHost(nodes(), upperRoom, 'floor')!
105
+ const result = planFloorOpening(nodes(), host, 'floor', rect(2, 1, 4, 2))
106
+ if ('message' in result) throw Error(result.message)
107
+ useScene.getState().applyNodeChanges(structureChangeBatch(result.plan.changes))
108
+ return result.plan.openingIds[0]!
109
+ }
110
+
111
+ describe('Cut opening (point 8)', () => {
112
+ test('an outline outside the room or too small is refused, in words', () => {
113
+ const host = describeOpeningHost(nodes(), upperRoom, 'floor')!
114
+ expect(planFloorOpening(nodes(), host, 'floor', rect(20, 20, 22, 22))).toEqual({
115
+ message: OPENING_OUTSIDE_MESSAGE,
116
+ })
117
+ expect(planFloorOpening(nodes(), host, 'floor', rect(1, 1, 1.1, 1.1))).toEqual({
118
+ message: OPENING_TOO_SMALL_MESSAGE,
119
+ })
120
+ })
121
+
122
+ test('a floor opening over a room below also cuts its ceiling, in one undo step', () => {
123
+ const id = cutVoid()
124
+ expect(history()).toBe(1)
125
+ const opening = nodes()[id] as FloorOpeningNode
126
+ expect(opening).toMatchObject({ parentId: UPPER, drawnOn: 'floor', cutsAdjacent: true })
127
+ expect(roomSurfaceOpenings(nodes(), upperRoom, 'floor').map((o) => o.id as string)).toEqual([
128
+ id,
129
+ ])
130
+ expect(roomSurfaceOpenings(nodes(), groundRoom, 'ceiling').map((o) => o.id as string)).toEqual([
131
+ id,
132
+ ])
133
+ expect(groundCeilingHoles()).toHaveLength(1)
134
+ expect(upperPlateHoles()).toHaveLength(1)
135
+ useScene.temporal.getState().undo()
136
+ expect(openings()).toEqual([])
137
+ expect(groundCeilingHoles()).toEqual([])
138
+ })
139
+
140
+ test('with its switch off, only the floor is cut', () => {
141
+ const id = cutVoid()
142
+ useScene.getState().updateNode(id as FloorOpeningNode['id'], { cutsAdjacent: false })
143
+ expect(roomSurfaceOpenings(nodes(), upperRoom, 'floor')).toHaveLength(1)
144
+ expect(roomSurfaceOpenings(nodes(), groundRoom, 'ceiling')).toEqual([])
145
+ expect(groundCeilingHoles()).toEqual([])
146
+ })
147
+
148
+ test('a ceiling opening on the top storey cuts only the ceiling', () => {
149
+ const host = describeOpeningHost(nodes(), upperRoom, 'ceiling')!
150
+ const result = planFloorOpening(nodes(), host, 'ceiling', rect(2, 1, 4, 2))
151
+ if ('message' in result) throw Error(result.message)
152
+ useScene.getState().applyNodeChanges(structureChangeBatch(result.plan.changes))
153
+ const [opening] = openings()
154
+ expect(opening).toMatchObject({ drawnOn: 'ceiling', cutsAdjacent: false })
155
+ expect(roomSurfaceOpenings(nodes(), upperRoom, 'ceiling')).toHaveLength(1)
156
+ })
157
+
158
+ test('a hatch in a mezzanine cuts only the mezzanine', () => {
159
+ const plan = createMezzanine(nodes(), {
160
+ hostZoneId: groundRoom,
161
+ polygon: rect(1, 1, 5, 4),
162
+ mintId: generateId,
163
+ })
164
+ applyRoomPlan(plan)
165
+ const host = describeOpeningHost(nodes(), plan.zoneId, 'floor')!
166
+ const result = planFloorOpening(nodes(), host, 'floor', rect(2, 2, 3, 3))
167
+ if ('message' in result) throw Error(result.message)
168
+ useScene.getState().applyNodeChanges(structureChangeBatch(result.plan.changes))
169
+ const [hatch] = openings()
170
+ expect(hatch).toMatchObject({ hostZoneId: plan.zoneId, cutsAdjacent: false })
171
+ expect(roomSurfaceOpenings(nodes(), plan.zoneId, 'floor').map((o) => o.id)).toEqual([hatch!.id])
172
+ expect(roomSurfaceOpenings(nodes(), groundRoom, 'floor')).toEqual([])
173
+ })
174
+
175
+ test('the shape is picked before drawing, not toggled during it', () => {
176
+ expect(startOpeningDraft(upperRoom, 'floor', 'polygon')).toBe(true)
177
+ expect(useOpeningDraft.getState().shape).toBe('polygon')
178
+ cancelOpeningDraft()
179
+ expect(startOpeningDraft(upperRoom, 'floor')).toBe(true)
180
+ expect(useOpeningDraft.getState().shape).toBe('rectangle')
181
+ })
182
+
183
+ test('the tool arms on the room and drops cleanly on cancel, writing nothing', () => {
184
+ expect(startOpeningDraft(upperRoom, 'floor')).toBe(true)
185
+ expect(openingDraftActive()).toBe(true)
186
+ cancelOpeningDraft()
187
+ expect(openingDraftActive()).toBe(false)
188
+ expect(history()).toBe(0)
189
+ })
190
+ })
191
+
192
+ describe('Edit floor, Done and Delete return to the room', () => {
193
+ test('Edit floor is a session on the room; Done ends it on the room', () => {
194
+ useEditor.getState().selectRoom({ levelId: UPPER, zoneId: upperRoom })
195
+ expect(startFloorEdit(upperRoom)).toBe(true)
196
+ expect(useFloorEditSession.getState().session?.zoneId).toBe(upperRoom)
197
+ expect(finishFloorEdit()).toBe(true)
198
+ expect(useFloorEditSession.getState().session).toBeNull()
199
+ expect(useEditor.getState().room).toEqual({ levelId: UPPER, zoneId: upperRoom })
200
+ })
201
+
202
+ test('Done in the opening panel and Delete both land on the room', () => {
203
+ const id = cutVoid()
204
+ useViewer.getState().setSelection({ selectedIds: [id as FloorOpeningNode['id']] })
205
+ expect(finishFloorOpening(id)).toBe(true)
206
+ expect(useViewer.getState().selection.selectedIds).toEqual([])
207
+ expect(useEditor.getState().room).toEqual({ levelId: UPPER, zoneId: upperRoom })
208
+
209
+ useViewer.getState().setSelection({ selectedIds: [id as FloorOpeningNode['id']] })
210
+ const before = history()
211
+ expect(deleteFloorOpening(id)).toBe(true)
212
+ expect(openings()).toEqual([])
213
+ expect(history()).toBe(before + 1)
214
+ expect(useEditor.getState().room).toEqual({ levelId: UPPER, zoneId: upperRoom })
215
+ })
216
+ })
@@ -0,0 +1,191 @@
1
+ import { area, containsPoint, intersection, type MultiPolygon } from '@pascal-app/core'
2
+ import type { FloorRegionPoint } from './floor-region-snap'
3
+
4
+ // Pure geometry for "Paint part of the floor": which room a plan point is in,
5
+ // the box a rectangle drag draws, crossing edges, and the clip to the room.
6
+
7
+ /** Smaller than this (m²) is a click, not a region. */
8
+ export const MIN_FLOOR_REGION_AREA = 0.01
9
+ export const CROSSING_MESSAGE = "Edges can't cross"
10
+ export const OUTSIDE_MESSAGE = 'Draw inside the room'
11
+
12
+ /** The first room (in the given order) whose clear floor holds the point. */
13
+ /**
14
+ * The room whose clear floor holds `point`. Rooms can stack (a mezzanine over
15
+ * its host): with `elevationOf`, the topmost floor wins, as the plan shows it
16
+ * and as a 3D ray from above would land.
17
+ */
18
+ export function floorRegionRoomAt<T extends { clearPolygon: MultiPolygon }>(
19
+ rooms: readonly T[],
20
+ point: FloorRegionPoint,
21
+ elevationOf?: (room: T) => number,
22
+ ): T | null {
23
+ let best: T | null = null
24
+ let bestElevation = Number.NEGATIVE_INFINITY
25
+ for (const room of rooms) {
26
+ if (!containsPoint(room.clearPolygon, point)) continue
27
+ if (!elevationOf) return room
28
+ const elevation = elevationOf(room)
29
+ if (elevation > bestElevation) {
30
+ best = room
31
+ bestElevation = elevation
32
+ }
33
+ }
34
+ return best
35
+ }
36
+
37
+ /**
38
+ * The rectangle frame of a room: the direction of its longest boundary edge,
39
+ * folded into [-45°, 45°) so an axis-aligned room reads exactly 0 and a box
40
+ * drawn in a room at an angle follows the room.
41
+ */
42
+ export function floorRegionAxisAngle(clear: MultiPolygon): number {
43
+ let longest = 0
44
+ let angle = 0
45
+ for (const { outer } of clear) {
46
+ for (let i = 0; i < outer.length; i++) {
47
+ const a = outer[i]!
48
+ const b = outer[(i + 1) % outer.length]!
49
+ const length = Math.hypot(b[0] - a[0], b[1] - a[1])
50
+ if (length > longest + 1e-9) {
51
+ longest = length
52
+ angle = Math.atan2(b[1] - a[1], b[0] - a[0])
53
+ }
54
+ }
55
+ }
56
+ const quarter = Math.PI / 2
57
+ let folded = angle - Math.round(angle / quarter) * quarter
58
+ if (folded >= Math.PI / 4) folded -= quarter
59
+ return Math.abs(folded) < 1e-9 ? 0 : folded
60
+ }
61
+
62
+ /** The box with opposite corners `start` and `end`, its sides along `angle` and across it. */
63
+ export function floorRegionRectangle(
64
+ start: FloorRegionPoint,
65
+ end: FloorRegionPoint,
66
+ angle: number,
67
+ ): FloorRegionPoint[] {
68
+ const dx = end[0] - start[0]
69
+ const dz = end[1] - start[1]
70
+ if (angle === 0)
71
+ return [
72
+ [start[0], start[1]],
73
+ [end[0], start[1]],
74
+ [end[0], end[1]],
75
+ [start[0], end[1]],
76
+ ]
77
+ const ux = Math.cos(angle)
78
+ const uz = Math.sin(angle)
79
+ const along = dx * ux + dz * uz
80
+ const across = -dx * uz + dz * ux
81
+ const corner = (u: number, v: number): FloorRegionPoint => [
82
+ start[0] + u * ux - v * uz,
83
+ start[1] + u * uz + v * ux,
84
+ ]
85
+ return [corner(0, 0), corner(along, 0), corner(along, across), corner(0, across)]
86
+ }
87
+
88
+ function orientation(a: FloorRegionPoint, b: FloorRegionPoint, c: FloorRegionPoint) {
89
+ return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
90
+ }
91
+
92
+ const EPSILON = 1e-9
93
+
94
+ function onSegment(a: FloorRegionPoint, b: FloorRegionPoint, p: FloorRegionPoint) {
95
+ return (
96
+ Math.min(a[0], b[0]) - EPSILON <= p[0] &&
97
+ p[0] <= Math.max(a[0], b[0]) + EPSILON &&
98
+ Math.min(a[1], b[1]) - EPSILON <= p[1] &&
99
+ p[1] <= Math.max(a[1], b[1]) + EPSILON
100
+ )
101
+ }
102
+
103
+ /** Whether two segments touch or cross (end points included). */
104
+ export function segmentsTouch(
105
+ a: FloorRegionPoint,
106
+ b: FloorRegionPoint,
107
+ c: FloorRegionPoint,
108
+ d: FloorRegionPoint,
109
+ ): boolean {
110
+ const o1 = orientation(a, b, c)
111
+ const o2 = orientation(a, b, d)
112
+ const o3 = orientation(c, d, a)
113
+ const o4 = orientation(c, d, b)
114
+ const s = (value: number) => (Math.abs(value) < EPSILON ? 0 : Math.sign(value))
115
+ if (s(o1) * s(o2) < 0 && s(o3) * s(o4) < 0) return true
116
+ return (
117
+ (s(o1) === 0 && onSegment(a, b, c)) ||
118
+ (s(o2) === 0 && onSegment(a, b, d)) ||
119
+ (s(o3) === 0 && onSegment(c, d, a)) ||
120
+ (s(o4) === 0 && onSegment(c, d, b))
121
+ )
122
+ }
123
+
124
+ /** Whether a segment folds back along its neighbour (shared end, overlapping). */
125
+ function foldsBack(shared: FloorRegionPoint, previous: FloorRegionPoint, next: FloorRegionPoint) {
126
+ const cross = orientation(shared, previous, next)
127
+ const dot =
128
+ (previous[0] - shared[0]) * (next[0] - shared[0]) +
129
+ (previous[1] - shared[1]) * (next[1] - shared[1])
130
+ return Math.abs(cross) < EPSILON && dot > 0
131
+ }
132
+
133
+ /**
134
+ * Whether adding `next` after the open path `points` makes an edge cross (or
135
+ * fold back onto) an earlier one. With `closing`, `next` is the first point and
136
+ * the new edge closes the ring.
137
+ */
138
+ export function floorRegionEdgeCrosses(
139
+ points: readonly FloorRegionPoint[],
140
+ next: FloorRegionPoint,
141
+ closing = false,
142
+ ): boolean {
143
+ const last = points.at(-1)
144
+ if (!last) return false
145
+ if (points.length >= 2 && foldsBack(last, points.at(-2)!, next)) return true
146
+ if (closing && points.length >= 2 && foldsBack(points[0]!, points[1]!, last)) return true
147
+ // Edges i → i+1 that do not share an end with the new edge.
148
+ const firstEdge = closing ? 1 : 0
149
+ for (let i = firstEdge; i < points.length - 2; i++)
150
+ if (segmentsTouch(points[i]!, points[i + 1]!, last, next)) return true
151
+ return false
152
+ }
153
+
154
+ /** Whether a closed ring has two edges that cross. */
155
+ export function floorRegionSelfIntersects(ring: readonly FloorRegionPoint[]): boolean {
156
+ for (let i = 1; i < ring.length; i++)
157
+ if (floorRegionEdgeCrosses(ring.slice(0, i), ring[i]!)) return true
158
+ return ring.length >= 3 && floorRegionEdgeCrosses(ring, ring[0]!, true)
159
+ }
160
+
161
+ export type FloorRegionClip = {
162
+ /** What gets stored: the clipped outline, or the drawn one when the room splits it. */
163
+ polygon: FloorRegionPoint[]
164
+ /** The painted pieces (what the floor will show). */
165
+ pieces: MultiPolygon
166
+ area: number
167
+ }
168
+
169
+ /**
170
+ * Clips a drawn outline to the room's clear floor. One piece stores its outer
171
+ * ring (the plate partition clips any hole, like a column, again at render).
172
+ * When the room splits the outline into several pieces (a box across a
173
+ * U-shaped room's notch), the drawn outline is stored as is: the renderer
174
+ * intersects every region with the room, so all pieces paint in one region and
175
+ * one undo step. Nothing inside the room, or less than a click's worth, is null.
176
+ */
177
+ export function clipFloorRegion(
178
+ polygon: readonly FloorRegionPoint[],
179
+ clear: MultiPolygon,
180
+ ): FloorRegionClip | null {
181
+ if (polygon.length < 3) return null
182
+ const ring = polygon.map(([x, z]) => [x, z] as FloorRegionPoint)
183
+ const pieces = intersection(ring, clear)
184
+ const clipped = area(pieces)
185
+ if (clipped < MIN_FLOOR_REGION_AREA) return null
186
+ return {
187
+ polygon: pieces.length === 1 ? pieces[0]!.outer.map(([x, z]) => [x, z]) : ring,
188
+ pieces,
189
+ area: clipped,
190
+ }
191
+ }