@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,847 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ DEFAULT_LEVEL_HEIGHT,
7
+ deleteZone,
8
+ emitter,
9
+ type Point,
10
+ type StructureNodes,
11
+ sceneRegistry,
12
+ useScene,
13
+ } from '@pascal-app/core'
14
+ import { getSceneTheme, setSurfaceRaycastLayers, useViewer } from '@pascal-app/viewer'
15
+ import { Html } from '@react-three/drei'
16
+ import { useFrame, useThree } from '@react-three/fiber'
17
+ import { SquareSplitHorizontal } from 'lucide-react'
18
+ import Image from 'next/image'
19
+ import { type MouseEvent, type RefObject, useEffect, useMemo, useRef, useState } from 'react'
20
+ import { createPortal } from 'react-dom'
21
+ import {
22
+ type Camera,
23
+ DoubleSide,
24
+ type Group,
25
+ Matrix4,
26
+ type Mesh,
27
+ type Object3D,
28
+ type OrthographicCamera,
29
+ type PerspectiveCamera,
30
+ Raycaster,
31
+ Vector2,
32
+ Vector3,
33
+ } from 'three'
34
+ import { markToolCancelConsumed } from '../../hooks/use-keyboard'
35
+ import { getRoomSelectionIndex } from '../../hooks/use-selected-room'
36
+ import { EDITOR_LAYER } from '../../lib/constants'
37
+ import { getFloatingMenuScale } from '../../lib/floating-menu-scale'
38
+ import { clientToPlan } from '../../lib/floorplan/plan-coords'
39
+ import { formatLinearMeasurement } from '../../lib/measurements'
40
+ import { addMezzanineStairs } from '../../lib/mezzanine-stairs'
41
+ import {
42
+ cancelRoomDivide,
43
+ clickRoomDivide,
44
+ finishRoomDivide,
45
+ previewRoomDivide,
46
+ removeLastRoomDividePoint,
47
+ roomDivideContains,
48
+ startRoomDivide,
49
+ } from '../../lib/room-divide-session'
50
+ import { roomFloorElevation, useRoomHandleDrag } from '../../lib/room-handle-drag'
51
+ import type { RoomSelectionRecord } from '../../lib/room-selection'
52
+ import { requestRoomDeletion } from '../../lib/room-structure-commands'
53
+ import {
54
+ type RoomTransformKind,
55
+ rotateRoom,
56
+ showRoomNotice,
57
+ startRoomTransform,
58
+ useRoomTransform,
59
+ } from '../../lib/room-transform-session'
60
+ import { isTypingTarget } from '../../lib/typing-target'
61
+ import useEditor from '../../store/use-editor'
62
+ import useInteractionScope from '../../store/use-interaction-scope'
63
+ import { isViewVisible, VIEW_2D } from '../../store/view-layout'
64
+ import { useFloorplanRender } from '../editor-2d/floorplan-render-context'
65
+ import { CursorSphere } from '../tools/shared/cursor-sphere'
66
+ import {
67
+ DRAFT_LABEL_Y_OFFSET,
68
+ DraftMeasurementLabel,
69
+ } from '../tools/shared/draft-measurement-label'
70
+ import { ActionMenuButton } from './action-menu-button'
71
+ import { getMenuYOffset } from './floating-action-menu'
72
+ import { NodeActionMenu } from './node-action-menu'
73
+ import { RoomFloorHighlight3D } from './room-floor-highlight'
74
+ import { RoomHandleDragPreview3D, RoomHandles3D } from './room-handles'
75
+ import { RoomFloorHeightStepper, useRoomControls } from './room-handles-2d'
76
+ import { RoomTransformGhost2D, RoomTransformGhost3D } from './room-transform-ghost'
77
+
78
+ // The wall draft's colours: indigo while the draft is buildable, red when not.
79
+ const DRAFT_COLOR = '#818cf8'
80
+ const DRAFT_COLOR_LIGHT_PLAN = '#6366f1'
81
+ const INVALID_COLOR = '#ef4444'
82
+
83
+ type RoomDivideScope = Extract<
84
+ ReturnType<typeof useInteractionScope.getState>['scope'],
85
+ { kind: 'room-divide' }
86
+ >
87
+
88
+ function useSceneIsDark() {
89
+ return useViewer((s) => getSceneTheme(s.sceneTheme).appearance === 'dark')
90
+ }
91
+
92
+ const stopPointer = (event: { stopPropagation: () => void }) => event.stopPropagation()
93
+
94
+ /** What a room pick-up carries: its outline and wall footprints, and its floor height. */
95
+ export function roomTransformSource(room: RoomSelectionRecord) {
96
+ const walls = room.boundaryWallIds.flatMap((id) => {
97
+ const ring = room.context.wallFootprints.get(id)
98
+ return ring ? [ring.map(([x, z]) => [x, z] as Point)] : []
99
+ })
100
+ return {
101
+ zoneId: room.zoneId,
102
+ levelId: room.key.levelId,
103
+ outline: [room.polygon, ...room.holes].map((ring) => ring.map(([x, z]) => [x, z] as Point)),
104
+ walls,
105
+ floorY: roomFloorElevation(useScene.getState().nodes, room.zoneId),
106
+ }
107
+ }
108
+
109
+ /**
110
+ * A room's zone picked on its own (its label, the zones layer) moves and
111
+ * duplicates as the room: the room pick-up with its real outline and walls,
112
+ * never a zone-only drag whose preview is the zone's bounding box. False when
113
+ * the zone bounds no room (a free-drawn zone keeps its own move).
114
+ */
115
+ export function startZoneRoomTransform(node: AnyNode, kind: RoomTransformKind): boolean {
116
+ if (node.type !== 'zone' || !node.parentId) return false
117
+ const record = getRoomSelectionIndex(node.parentId)
118
+ .update(useScene.getState().nodes)
119
+ .find((room) => room.zoneId === node.id)
120
+ if (!record) return false
121
+ useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
122
+ useEditor.getState().selectRoom(record.key)
123
+ return startRoomTransform(kind, roomTransformSource(record))
124
+ }
125
+
126
+ /**
127
+ * Why the room's trash is off, or undefined when it can be deleted: a room
128
+ * inside walls other rooms share has nothing of its own to remove.
129
+ */
130
+ export function roomDeleteBlockedReason(nodes: StructureNodes, zoneId: string) {
131
+ if (nodes[zoneId]?.type !== 'zone') return undefined
132
+ const plan = deleteZone(nodes, { zoneId, contents: 'keep' })
133
+ return plan.payload.mode === 'blocked' ? plan.conflicts?.[0]?.message : undefined
134
+ }
135
+
136
+ /**
137
+ * The selected room's action pill — the node action menu every selection
138
+ * gets, in a wall pill's order: Move, Rotate left / right (the Curve slot), the room's own
139
+ * contribution where a wall shows Split (Divide), Duplicate,
140
+ * Delete.
141
+ */
142
+ export function RoomActionMenu({ room }: { room: RoomSelectionRecord }) {
143
+ const zoneId = room.zoneId
144
+ const levelId = room.key.levelId
145
+ const nodes = useScene((s) => s.nodes)
146
+ const deleteBlocked = useMemo(() => roomDeleteBlockedReason(nodes, zoneId), [nodes, zoneId])
147
+ const pickUp = (kind: 'move' | 'duplicate') => (event: MouseEvent) => {
148
+ event.stopPropagation()
149
+ startRoomTransform(kind, roomTransformSource(room))
150
+ }
151
+ // Alt-click slides a door or window the turn would cut through.
152
+ const turn = (direction: 'left' | 'right') => (event: MouseEvent) => {
153
+ event.stopPropagation()
154
+ rotateRoom(room.key, direction, { force: event.altKey })
155
+ }
156
+ const notice = useRoomTransform((s) => (s.notice?.zoneId === zoneId ? s.notice.message : null))
157
+ // A mezzanine moves, turns and copies inside its host (core refuses what
158
+ // leaves it); it has no walls to divide, and it gets its own stairs.
159
+ const mezzanine = !!room.mezzanine
160
+ const addStairs = (event: MouseEvent) => {
161
+ event.stopPropagation()
162
+ const result = addMezzanineStairs(zoneId)
163
+ if (!result.ok) showRoomNotice({ zoneId, message: result.message })
164
+ }
165
+ return (
166
+ <div className="flex flex-col items-center gap-1">
167
+ <NodeActionMenu
168
+ deleteDisabledReason={deleteBlocked}
169
+ deleteLabel={mezzanine ? 'Delete mezzanine' : 'Delete room'}
170
+ onDelete={(event) => {
171
+ event.stopPropagation()
172
+ requestRoomDeletion(zoneId)
173
+ }}
174
+ onDuplicate={pickUp('duplicate')}
175
+ onMove={pickUp('move')}
176
+ onPointerDown={stopPointer}
177
+ onPointerUp={stopPointer}
178
+ onRotateLeft={turn('left')}
179
+ onRotateRight={turn('right')}
180
+ >
181
+ {mezzanine && (
182
+ <ActionMenuButton label="Add stairs" onClick={addStairs}>
183
+ <span className="flex h-4 w-4 shrink-0 items-center justify-center">
184
+ <Image
185
+ alt=""
186
+ className="h-4 w-4 object-contain"
187
+ height={16}
188
+ src="/icons/stairs.webp"
189
+ width={16}
190
+ />
191
+ </span>
192
+ </ActionMenuButton>
193
+ )}
194
+ {!mezzanine && (
195
+ <ActionMenuButton
196
+ label="Divide room"
197
+ onClick={(event: MouseEvent<HTMLButtonElement>) => {
198
+ event.stopPropagation()
199
+ startRoomDivide(zoneId, levelId)
200
+ }}
201
+ >
202
+ <SquareSplitHorizontal className="h-4 w-4" />
203
+ </ActionMenuButton>
204
+ )}
205
+ </NodeActionMenu>
206
+ {notice && (
207
+ <div
208
+ className="pointer-events-none whitespace-nowrap rounded-full border border-destructive/40 bg-background/95 px-3 py-1 text-destructive text-xs shadow-md backdrop-blur-md"
209
+ role="status"
210
+ >
211
+ {notice}
212
+ </div>
213
+ )}
214
+ </div>
215
+ )
216
+ }
217
+
218
+ /** Floats the pill above the room like `FloatingActionMenu` floats a node's. */
219
+ function RoomFloatingMenu({
220
+ room,
221
+ zone,
222
+ height,
223
+ }: {
224
+ room: RoomSelectionRecord
225
+ zone: AnyNode
226
+ height: number
227
+ }) {
228
+ const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
229
+ const anchor = useRef<Group>(null)
230
+ const scale = useRef<HTMLDivElement>(null)
231
+ const world = useMemo(() => new Vector3(), [])
232
+ useFrame(({ camera }) => {
233
+ if (!(anchor.current && scale.current)) return
234
+ anchor.current.getWorldPosition(world)
235
+ scale.current.style.transform = `scale(${getFloatingMenuScale(camera, world)})`
236
+ })
237
+ if (zone.type !== 'zone' || !zone.seed || isFloorplanHovered) return null
238
+ // A mezzanine's pill floats over its host's walls, not a storey above its own floor.
239
+ const elevation = zone.floor?.support === 'open' ? 0.05 : (zone.floor?.elevation ?? 0.05)
240
+ return (
241
+ <group
242
+ // Above the room's walls, as a wall's pill floats above the wall — clear
243
+ // of the floor a second click drills into.
244
+ position={[zone.seed[0], elevation + height + getMenuYOffset(zone), zone.seed[1]]}
245
+ ref={anchor}
246
+ >
247
+ <Html center style={{ pointerEvents: 'auto', touchAction: 'none' }} zIndexRange={[25, 0]}>
248
+ <div ref={scale} style={{ transformOrigin: 'center center' }}>
249
+ <RoomActionMenu room={room} />
250
+ </div>
251
+ </Html>
252
+ </group>
253
+ )
254
+ }
255
+
256
+ /**
257
+ * The 2D pill: above the room's footprint, like a slab's on the plan, with the
258
+ * floor height stepper under it. Shown while the room is selected and the plan
259
+ * is on screen — not only while it is hovered, so split view does not flicker.
260
+ */
261
+ function RoomFloorplanMenu({
262
+ room,
263
+ plan,
264
+ }: {
265
+ room: RoomSelectionRecord
266
+ plan: RefObject<SVGGElement | null>
267
+ }) {
268
+ const planShown = useEditor((s) => isViewVisible(s, VIEW_2D))
269
+ const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
270
+ const rings = room.geometry.clearPolygon
271
+ useEffect(() => {
272
+ if (!planShown) {
273
+ setPosition(null)
274
+ return
275
+ }
276
+ let raf = 0
277
+ const tick = () => {
278
+ raf = requestAnimationFrame(tick)
279
+ const group = plan.current
280
+ const svg = group?.ownerSVGElement
281
+ const ctm = group?.getScreenCTM()
282
+ if (!(svg && ctm)) return setPosition(null)
283
+ let minX = Number.POSITIVE_INFINITY
284
+ let maxX = Number.NEGATIVE_INFINITY
285
+ let top = Number.POSITIVE_INFINITY
286
+ const point = svg.createSVGPoint()
287
+ for (const { outer } of rings)
288
+ for (const [x, y] of outer) {
289
+ point.x = x
290
+ point.y = y
291
+ const screen = point.matrixTransform(ctm)
292
+ minX = Math.min(minX, screen.x)
293
+ maxX = Math.max(maxX, screen.x)
294
+ top = Math.min(top, screen.y)
295
+ }
296
+ const next = Number.isFinite(top) ? { left: (minX + maxX) / 2, top } : null
297
+ setPosition((prev) =>
298
+ prev && next && Math.abs(prev.left - next.left) < 0.5 && Math.abs(prev.top - next.top) < 0.5
299
+ ? prev
300
+ : next,
301
+ )
302
+ }
303
+ raf = requestAnimationFrame(tick)
304
+ return () => cancelAnimationFrame(raf)
305
+ }, [planShown, plan, rings])
306
+ if (!(planShown && position) || typeof document === 'undefined') return null
307
+ return createPortal(
308
+ <div
309
+ className="pointer-events-none fixed z-30 flex w-max flex-col items-center"
310
+ style={{
311
+ left: position.left,
312
+ top: position.top,
313
+ transform: 'translate(-50%, calc(-100% - 32px))',
314
+ }}
315
+ >
316
+ <div className="flex flex-col items-center gap-1">
317
+ <RoomActionMenu room={room} />
318
+ <RoomFloorHeightStepper zoneId={room.zoneId} />
319
+ </div>
320
+ </div>,
321
+ document.body,
322
+ )
323
+ }
324
+
325
+ /**
326
+ * Where a divide pointer landed. `boundaryId` is set when the pointer resolved
327
+ * against a boundary's own surface rather than a free point on the floor.
328
+ */
329
+ export type DividePointerTarget = { point: Point; boundaryId?: string }
330
+
331
+ /** Keys typed into a field never reach the Divide draft. */
332
+ export const isDivideTypingTarget = isTypingTarget
333
+
334
+ function divideKey(event: KeyboardEvent) {
335
+ if (useInteractionScope.getState().scope.kind !== 'room-divide') return
336
+ if (event.defaultPrevented || isDivideTypingTarget(event.target)) return
337
+ if (event.key === 'Escape') cancelRoomDivide()
338
+ else if (event.key === 'Backspace') removeLastRoomDividePoint()
339
+ else if (event.key === 'Enter') finishRoomDivide()
340
+ else return
341
+ event.preventDefault()
342
+ event.stopPropagation()
343
+ }
344
+
345
+ // One keyboard listener per target however many surfaces bind to it: split
346
+ // view mounts the 2D and 3D controls together, and each key must act once.
347
+ const keyboardOwners = new WeakMap<EventTarget, number>()
348
+ function acquireDivideKeyboard(target: EventTarget) {
349
+ const owners = keyboardOwners.get(target) ?? 0
350
+ if (owners === 0) target.addEventListener('keydown', divideKey as EventListener, true)
351
+ keyboardOwners.set(target, owners + 1)
352
+ let released = false
353
+ return () => {
354
+ if (released) return
355
+ released = true
356
+ const left = (keyboardOwners.get(target) ?? 1) - 1
357
+ keyboardOwners.set(target, left)
358
+ if (left === 0) target.removeEventListener('keydown', divideKey as EventListener, true)
359
+ }
360
+ }
361
+
362
+ export function bindDividePointer(
363
+ surface: EventTarget,
364
+ resolve: (event: PointerEvent) => DividePointerTarget | null,
365
+ keyboard: EventTarget = window,
366
+ ) {
367
+ let pressed: number | null = null
368
+ // Pointer moves are coalesced to one preview per animation frame; a press
369
+ // drops the pending move and previews its own position at once.
370
+ let frame = 0
371
+ let pending: PointerEvent | null = null
372
+ const flush = () => {
373
+ frame = 0
374
+ const event = pending
375
+ pending = null
376
+ if (!event || useInteractionScope.getState().scope.kind !== 'room-divide') return
377
+ const target = resolve(event)
378
+ if (target) previewRoomDivide(target.point, target.boundaryId)
379
+ }
380
+ const dropPending = () => {
381
+ if (frame) cancelAnimationFrame(frame)
382
+ frame = 0
383
+ pending = null
384
+ }
385
+ const move = (event: PointerEvent) => {
386
+ if (useInteractionScope.getState().scope.kind !== 'room-divide') return
387
+ pending = event
388
+ if (!frame) frame = requestAnimationFrame(flush) || 0
389
+ }
390
+ const down = (event: PointerEvent) => {
391
+ if (event.button !== 0 || useInteractionScope.getState().scope.kind !== 'room-divide') return
392
+ const target = resolve(event)
393
+ if (!target) return
394
+ dropPending()
395
+ event.preventDefault()
396
+ event.stopPropagation()
397
+ previewRoomDivide(target.point, target.boundaryId)
398
+ pressed = event.pointerId
399
+ }
400
+ const up = (event: PointerEvent) => {
401
+ if (pressed !== event.pointerId) return
402
+ pressed = null
403
+ dropPending()
404
+ event.preventDefault()
405
+ event.stopPropagation()
406
+ useViewer.getState().setInputDragging(true)
407
+ clickRoomDivide()
408
+ setTimeout(() => useViewer.getState().setInputDragging(false), 0)
409
+ }
410
+ const onMove = (event: Event) => move(event as PointerEvent)
411
+ const onDown = (event: Event) => down(event as PointerEvent)
412
+ surface.addEventListener('pointermove', onMove, true)
413
+ surface.addEventListener('pointerdown', onDown, true)
414
+ keyboard.addEventListener('pointerup', up as EventListener, true)
415
+ const releaseKeyboard = acquireDivideKeyboard(keyboard)
416
+ return () => {
417
+ dropPending()
418
+ surface.removeEventListener('pointermove', onMove, true)
419
+ surface.removeEventListener('pointerdown', onDown, true)
420
+ keyboard.removeEventListener('pointerup', up as EventListener, true)
421
+ releaseKeyboard()
422
+ }
423
+ }
424
+
425
+ /**
426
+ * Escape (`tool:cancel`) ends the draft and is consumed, so the tool stays;
427
+ * the surface unmounting ends it too. Everything else — leaving the structure
428
+ * select tool or the room, another level, a selection change, a boundary
429
+ * change under it, any history command — is the gesture lifecycle owner's
430
+ * (`startRoomDivide`).
431
+ */
432
+ export function useDivideLifetime() {
433
+ useEffect(() => {
434
+ const onToolCancel = () => {
435
+ if (useInteractionScope.getState().scope.kind !== 'room-divide') return
436
+ cancelRoomDivide()
437
+ markToolCancelConsumed()
438
+ }
439
+ emitter.on('tool:cancel', onToolCancel)
440
+ return () => {
441
+ emitter.off('tool:cancel', onToolCancel)
442
+ cancelRoomDivide()
443
+ }
444
+ }, [])
445
+ }
446
+
447
+ /**
448
+ * Where a Divide pointer lands in 3D: the ray (level space) meets the room's
449
+ * floor plane. Walls never answer — the room's floor is drawn on top of them
450
+ * for the session, so a point behind a near wall is placed where the user
451
+ * sees it; the session snaps onto the room's edges within its capture.
452
+ */
453
+ export function divideFloorPoint(
454
+ origin: readonly [number, number, number],
455
+ direction: readonly [number, number, number],
456
+ elevation: number,
457
+ ): Point | null {
458
+ if (Math.abs(direction[1]) < 1e-9) return null
459
+ const t = (elevation - origin[1]) / direction[1]
460
+ if (!(t > 0) || !Number.isFinite(t)) return null
461
+ return [origin[0] + direction[0] * t, origin[2] + direction[2] * t]
462
+ }
463
+
464
+ /** The live segment: from the last placed point to the pointer's point. */
465
+ function liveSegment(scope: RoomDivideScope): [Point, Point] | null {
466
+ const from = scope.points.at(-1)
467
+ return from && scope.end ? [from, scope.end] : null
468
+ }
469
+
470
+ /** What the draft label reads: the live segment's length, or why it cannot be built. */
471
+ function useDivideLabel(scope: RoomDivideScope) {
472
+ const unit = useViewer((s) => s.unit)
473
+ const metricNotation = useViewer((s) => s.metricNotation)
474
+ if (!scope.valid) return scope.message ?? null
475
+ const live = liveSegment(scope)
476
+ if (!live) return null
477
+ const length = Math.hypot(live[1][0] - live[0][0], live[1][1] - live[0][1])
478
+ return length > 0.01 ? formatLinearMeasurement(length, unit, metricNotation) : null
479
+ }
480
+
481
+ function labelPoint(scope: RoomDivideScope): Point | null {
482
+ const live = liveSegment(scope)
483
+ if (!scope.valid) return scope.end
484
+ return live ? [(live[0][0] + live[1][0]) / 2, (live[0][1] + live[1][1]) / 2] : null
485
+ }
486
+
487
+ // The draft draws over the room's floor highlight (998 / 999), which draws
488
+ // over every wall, so the line and its points stay readable wherever they are.
489
+ const DRAFT_RENDER_ORDER = 1000
490
+ const noRaycast = () => {}
491
+ /** The line's and a point's size on screen, however far the camera is. */
492
+ const STRIP_WIDTH_PX = 3
493
+ const VERTEX_RADIUS_PX = 7
494
+ const STRIP_MIN_WIDTH = 0.06
495
+ const VERTEX_MIN_RADIUS = 0.16
496
+
497
+ const scratch = new Vector3()
498
+ const cameraScratch = new Vector3()
499
+
500
+ /** World metres one screen pixel spans at `object`'s origin. */
501
+ function metresPerPixel(object: Object3D, camera: Camera, viewportHeight: number) {
502
+ if (viewportHeight <= 0) return 0
503
+ if ((camera as OrthographicCamera).isOrthographicCamera) {
504
+ const ortho = camera as OrthographicCamera
505
+ return (ortho.top - ortho.bottom) / ortho.zoom / viewportHeight
506
+ }
507
+ const perspective = camera as PerspectiveCamera
508
+ const distance = perspective
509
+ .getWorldPosition(cameraScratch)
510
+ .distanceTo(object.getWorldPosition(scratch))
511
+ return (2 * distance * Math.tan((perspective.fov * Math.PI) / 360)) / viewportHeight
512
+ }
513
+
514
+ function DraftStrip({
515
+ from,
516
+ to,
517
+ color,
518
+ elevation,
519
+ }: {
520
+ from: Point
521
+ to: Point
522
+ color: string
523
+ elevation: number
524
+ }) {
525
+ const ref = useRef<Mesh>(null)
526
+ useFrame(({ camera, size }) => {
527
+ const mesh = ref.current
528
+ if (!mesh) return
529
+ mesh.scale.z = Math.max(
530
+ STRIP_MIN_WIDTH,
531
+ STRIP_WIDTH_PX * metresPerPixel(mesh, camera, size.height),
532
+ )
533
+ })
534
+ const length = Math.hypot(to[0] - from[0], to[1] - from[1])
535
+ if (length < 0.01) return null
536
+ return (
537
+ <mesh
538
+ layers={EDITOR_LAYER}
539
+ position={[(from[0] + to[0]) / 2, elevation + 0.01, (from[1] + to[1]) / 2]}
540
+ raycast={noRaycast}
541
+ ref={ref}
542
+ renderOrder={DRAFT_RENDER_ORDER}
543
+ rotation={[0, -Math.atan2(to[1] - from[1], to[0] - from[0]), 0]}
544
+ scale-z={STRIP_MIN_WIDTH}
545
+ >
546
+ <boxGeometry args={[length, 0.02, 1]} />
547
+ <meshBasicMaterial
548
+ color={color}
549
+ depthTest={false}
550
+ depthWrite={false}
551
+ opacity={0.95}
552
+ side={DoubleSide}
553
+ transparent
554
+ />
555
+ </mesh>
556
+ )
557
+ }
558
+
559
+ /** A placed point: a solid dot in a soft ring, flat on the floor, over the highlight. */
560
+ function DraftVertex({ at, color, elevation }: { at: Point; color: string; elevation: number }) {
561
+ const ref = useRef<Group>(null)
562
+ useFrame(({ camera, size }) => {
563
+ const group = ref.current
564
+ if (!group) return
565
+ const radius = Math.max(
566
+ VERTEX_MIN_RADIUS,
567
+ VERTEX_RADIUS_PX * metresPerPixel(group, camera, size.height),
568
+ )
569
+ group.scale.setScalar(radius)
570
+ })
571
+ return (
572
+ <group
573
+ position={[at[0], elevation + 0.012, at[1]]}
574
+ ref={ref}
575
+ rotation={[-Math.PI / 2, 0, 0]}
576
+ scale={VERTEX_MIN_RADIUS}
577
+ >
578
+ <mesh layers={EDITOR_LAYER} raycast={noRaycast} renderOrder={DRAFT_RENDER_ORDER}>
579
+ <circleGeometry args={[1, 32]} />
580
+ <meshBasicMaterial
581
+ color={color}
582
+ depthTest={false}
583
+ depthWrite={false}
584
+ opacity={0.3}
585
+ transparent
586
+ />
587
+ </mesh>
588
+ <mesh layers={EDITOR_LAYER} raycast={noRaycast} renderOrder={DRAFT_RENDER_ORDER + 1}>
589
+ <circleGeometry args={[0.45, 32]} />
590
+ <meshBasicMaterial
591
+ color={color}
592
+ depthTest={false}
593
+ depthWrite={false}
594
+ opacity={0.95}
595
+ transparent
596
+ />
597
+ </mesh>
598
+ </group>
599
+ )
600
+ }
601
+
602
+ /** A pointer off the room reads grey (not the red of a refused cut). */
603
+ const OUTSIDE_COLOR = '#94a3b8'
604
+
605
+ /** Whether the live point sits off the room: not on its edge, not inside it. */
606
+ export function divideEndOutside(scope: RoomDivideScope): boolean {
607
+ if (!scope.end || scope.endKind === 'edge' || scope.endKind === 'close') return false
608
+ if (scope.endKind === 'start' && scope.endBoundaryId) return false
609
+ return !roomDivideContains(scope.end)
610
+ }
611
+
612
+ /** The divide draft in 3D, drawn like a wall draft: cursor beam, strips, length. */
613
+ function RoomDividePreview3D({
614
+ scope,
615
+ elevation,
616
+ height,
617
+ }: {
618
+ scope: RoomDivideScope
619
+ elevation: number
620
+ height: number
621
+ }) {
622
+ const isDark = useSceneIsDark()
623
+ const label = useDivideLabel(scope)
624
+ const liveColor = scope.valid ? DRAFT_COLOR : INVALID_COLOR
625
+ const outside = divideEndOutside(scope)
626
+ const live = liveSegment(scope)
627
+ const at = labelPoint(scope)
628
+ const { points, end } = scope
629
+ return (
630
+ <group>
631
+ {points.map((point, index) => (
632
+ <DraftVertex at={point} color={DRAFT_COLOR} elevation={elevation} key={index} />
633
+ ))}
634
+ {points.slice(1).map((point, index) => (
635
+ <DraftStrip
636
+ color={DRAFT_COLOR}
637
+ elevation={elevation}
638
+ from={points[index]!}
639
+ key={index}
640
+ to={point}
641
+ />
642
+ ))}
643
+ {live && <DraftStrip color={liveColor} elevation={elevation} from={live[0]} to={live[1]} />}
644
+ {end && (
645
+ <CursorSphere
646
+ color={outside ? OUTSIDE_COLOR : liveColor}
647
+ height={height}
648
+ position={[end[0], elevation, end[1]]}
649
+ tooltipContent={<SquareSplitHorizontal className="h-5 w-5 text-white" />}
650
+ />
651
+ )}
652
+ {label && at && (
653
+ <DraftMeasurementLabel
654
+ color={scope.valid ? (isDark ? '#ffffff' : '#111111') : INVALID_COLOR}
655
+ label={label}
656
+ position={[at[0], elevation + DRAFT_LABEL_Y_OFFSET + (scope.valid ? 0 : 0.2), at[1]]}
657
+ shadowColor={isDark ? '#111111' : '#ffffff'}
658
+ />
659
+ )}
660
+ </group>
661
+ )
662
+ }
663
+
664
+ export function RoomControls3D() {
665
+ const room = useRoomControls()
666
+ const scope = useInteractionScope((s) => s.scope)
667
+ const active = scope.kind === 'room-divide' ? scope : null
668
+ const handleDrag = useRoomHandleDrag((s) => s.drag)
669
+ const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
670
+ const levelId = active?.levelId ?? room?.key.levelId ?? handleDrag?.levelId
671
+ const zoneId = active?.nodeId ?? room?.zoneId
672
+ const zone = useScene((s) => (zoneId ? s.nodes[zoneId as AnyNodeId] : undefined))
673
+ const levelHeight = useScene((s) => {
674
+ const level = levelId ? s.nodes[levelId as AnyNodeId] : undefined
675
+ return level?.type === 'level' ? (level.height ?? DEFAULT_LEVEL_HEIGHT) : DEFAULT_LEVEL_HEIGHT
676
+ })
677
+ const elevation = zone?.type === 'zone' ? (zone.floor?.elevation ?? 0.05) : 0.05
678
+ const root = useRef<Group>(null)
679
+ const { gl, camera, invalidate } = useThree()
680
+ useDivideLifetime()
681
+ useEffect(
682
+ () =>
683
+ bindDividePointer(gl.domElement, (event) => {
684
+ const current = useInteractionScope.getState().scope
685
+ if (current.kind !== 'room-divide' || event.target !== gl.domElement) return null
686
+ const level = sceneRegistry.nodes.get(current.levelId)
687
+ if (!level) return null
688
+ level.updateWorldMatrix(true, false)
689
+ const inverse = new Matrix4().copy(level.matrixWorld).invert()
690
+ const bounds = gl.domElement.getBoundingClientRect()
691
+ const raycaster = new Raycaster()
692
+ raycaster.setFromCamera(
693
+ new Vector2(
694
+ ((event.clientX - bounds.left) / bounds.width) * 2 - 1,
695
+ 1 - ((event.clientY - bounds.top) / bounds.height) * 2,
696
+ ),
697
+ camera,
698
+ )
699
+ setSurfaceRaycastLayers(raycaster.layers)
700
+ const ray = raycaster.ray.clone().applyMatrix4(inverse)
701
+ const point = divideFloorPoint(
702
+ [ray.origin.x, ray.origin.y, ray.origin.z],
703
+ [ray.direction.x, ray.direction.y, ray.direction.z],
704
+ elevation,
705
+ )
706
+ return point ? { point } : null
707
+ }),
708
+ [gl, camera, elevation],
709
+ )
710
+ useFrame(() => {
711
+ const level = levelId ? sceneRegistry.nodes.get(levelId) : null
712
+ if (!root.current) return
713
+ root.current.visible = !!level
714
+ if (level) {
715
+ level.updateWorldMatrix(true, false)
716
+ root.current.matrix.copy(level.matrixWorld)
717
+ }
718
+ })
719
+ return (
720
+ <>
721
+ <group matrixAutoUpdate={false} ref={root}>
722
+ {typeof document !== 'undefined' && room && zone && (
723
+ <RoomFloatingMenu height={levelHeight} room={room} zone={zone} />
724
+ )}
725
+ {room && !isFloorplanHovered && <RoomHandles3D room={room} />}
726
+ <RoomHandleDragPreview3D />
727
+ {active && (
728
+ <RoomFloorHighlight3D
729
+ elevation={elevation}
730
+ levelId={active.levelId}
731
+ zoneId={active.nodeId}
732
+ />
733
+ )}
734
+ {active && (
735
+ <RoomDividePreview3D elevation={elevation} height={levelHeight} scope={active} />
736
+ )}
737
+ </group>
738
+ <RoomTransformGhost3D />
739
+ </>
740
+ )
741
+ }
742
+
743
+ export function resolveRoomDividePlanPoint(
744
+ clientX: number,
745
+ clientY: number,
746
+ ): DividePointerTarget | null {
747
+ const point = clientToPlan(clientX, clientY)
748
+ return point ? { point: [point[0], point[1]] } : null
749
+ }
750
+
751
+ // The plan's draft cursor marker: a soft glow under a solid core, in pixels.
752
+ const MARKER_GLOW_PX = 10
753
+ const MARKER_CORE_PX = 3
754
+
755
+ function PlanMarker({ at, color, upp }: { at: Point; color: string; upp: number }) {
756
+ return (
757
+ <>
758
+ <circle cx={at[0]} cy={at[1]} fill={color} fillOpacity={0.25} r={MARKER_GLOW_PX * upp} />
759
+ <circle cx={at[0]} cy={at[1]} fill={color} fillOpacity={0.9} r={MARKER_CORE_PX * upp} />
760
+ </>
761
+ )
762
+ }
763
+
764
+ /** The divide draft on the plan, drawn like the plan's wall draft. */
765
+ function RoomDividePreview2D({ scope }: { scope: RoomDivideScope }) {
766
+ const context = useFloorplanRender()
767
+ const isDark = useSceneIsDark()
768
+ const label = useDivideLabel(scope)
769
+ const upp = context?.unitsPerPixel ?? 0.01
770
+ const draft = isDark ? DRAFT_COLOR : DRAFT_COLOR_LIGHT_PLAN
771
+ const liveColor = scope.valid ? draft : INVALID_COLOR
772
+ const live = liveSegment(scope)
773
+ const at = labelPoint(scope)
774
+ const { points, end } = scope
775
+ return (
776
+ <g data-room-divide-preview pointerEvents="none">
777
+ {points.length > 1 && (
778
+ <polyline
779
+ fill="none"
780
+ points={points.map((point) => point.join(',')).join(' ')}
781
+ stroke={draft}
782
+ strokeLinecap="round"
783
+ strokeLinejoin="round"
784
+ strokeWidth={2 * upp}
785
+ />
786
+ )}
787
+ {live && (
788
+ <line
789
+ stroke={liveColor}
790
+ strokeLinecap="round"
791
+ strokeWidth={2 * upp}
792
+ x1={live[0][0]}
793
+ x2={live[1][0]}
794
+ y1={live[0][1]}
795
+ y2={live[1][1]}
796
+ />
797
+ )}
798
+ {points.map((point, index) => (
799
+ <PlanMarker at={point} color={draft} key={index} upp={upp} />
800
+ ))}
801
+ {end && <PlanMarker at={end} color={liveColor} upp={upp} />}
802
+ {label && at && (
803
+ <g
804
+ transform={`translate(${at[0]} ${at[1]}) rotate(${-(context?.sceneRotationDeg ?? 0)}) scale(${upp})`}
805
+ >
806
+ <text
807
+ dominantBaseline="middle"
808
+ fill={scope.valid ? (context?.palette.measurementStroke ?? draft) : INVALID_COLOR}
809
+ fontSize={12}
810
+ fontWeight={600}
811
+ paintOrder="stroke"
812
+ stroke={isDark ? '#0f172a' : '#ffffff'}
813
+ strokeLinejoin="round"
814
+ strokeWidth={3}
815
+ textAnchor="middle"
816
+ y={-16}
817
+ >
818
+ {label}
819
+ </text>
820
+ </g>
821
+ )}
822
+ </g>
823
+ )
824
+ }
825
+
826
+ export function RoomControls2D({ levelId }: { levelId: string | null }) {
827
+ const room = useRoomControls()
828
+ const scope = useInteractionScope((s) => s.scope)
829
+ const active = scope.kind === 'room-divide' && scope.levelId === levelId ? scope : null
830
+ const ref = useRef<SVGGElement>(null)
831
+ useDivideLifetime()
832
+ useEffect(() => {
833
+ const svg = ref.current?.ownerSVGElement
834
+ if (!svg) return
835
+ return bindDividePointer(svg, (event) => {
836
+ if (!(event.target instanceof Element) || event.target.closest('foreignObject')) return null
837
+ return resolveRoomDividePlanPoint(event.clientX, event.clientY)
838
+ })
839
+ }, [])
840
+ return (
841
+ <g ref={ref}>
842
+ {room && room.key.levelId === levelId && <RoomFloorplanMenu plan={ref} room={room} />}
843
+ {active && <RoomDividePreview2D scope={active} />}
844
+ <RoomTransformGhost2D levelId={levelId} />
845
+ </g>
846
+ )
847
+ }