@pascal-app/editor 0.8.0 → 0.9.2

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 (350) hide show
  1. package/package.json +15 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +168 -0
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +795 -28
  5. package/src/components/editor/editor-layout-mobile.tsx +8 -3
  6. package/src/components/editor/editor-layout-v2.tsx +76 -51
  7. package/src/components/editor/export-manager.tsx +69 -32
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +182 -0
  10. package/src/components/editor/first-person/build-collider-world.ts +228 -83
  11. package/src/components/editor/first-person-controls.tsx +943 -27
  12. package/src/components/editor/floating-action-menu.tsx +676 -208
  13. package/src/components/editor/floating-building-action-menu.tsx +2 -2
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +6916 -12137
  17. package/src/components/editor/grid.tsx +197 -36
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +420 -0
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +396 -0
  24. package/src/components/editor/group-transform-shared.ts +489 -0
  25. package/src/components/editor/handles/handle-arrow.tsx +581 -0
  26. package/src/components/editor/handles/use-handle-drag.ts +246 -0
  27. package/src/components/editor/index.tsx +412 -162
  28. package/src/components/editor/measurement-pill.tsx +115 -0
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +1646 -0
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +1106 -483
  36. package/src/components/editor/site-edge-labels.tsx +45 -21
  37. package/src/components/editor/slab-hole-highlights.tsx +474 -0
  38. package/src/components/editor/snapshot-capture-overlay.tsx +369 -155
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +140 -41
  41. package/src/components/editor/use-floorplan-background-placement.ts +190 -35
  42. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  43. package/src/components/editor/use-floorplan-scene-data.ts +19 -7
  44. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  45. package/src/components/editor/wall-measurement-label.tsx +14 -19
  46. package/src/components/editor/wall-move-side-handles.tsx +870 -0
  47. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  48. package/src/components/editor/wall-snap-beacon-layer.tsx +304 -0
  49. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +15 -6
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  52. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  53. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  54. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  56. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +392 -0
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +857 -0
  59. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  60. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +77 -0
  61. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  62. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  63. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  64. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  65. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  66. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +62 -0
  67. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  68. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +3325 -0
  69. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  70. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  71. package/src/components/editor-2d/svg-paths.ts +10 -2
  72. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +439 -57
  73. package/src/components/systems/ceiling/ceiling-system.tsx +109 -12
  74. package/src/components/systems/roof/roof-edit-system.tsx +1851 -22
  75. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  76. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  77. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  78. package/src/components/systems/zone/zone-system.tsx +42 -13
  79. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  80. package/src/components/tools/elevator/elevator-tool.tsx +375 -0
  81. package/src/components/tools/elevator/move-elevator-tool.tsx +263 -0
  82. package/src/components/tools/fence/fence-drafting.ts +141 -27
  83. package/src/components/tools/item/move-tool.tsx +45 -105
  84. package/src/components/tools/item/placement-math.test.ts +22 -0
  85. package/src/components/tools/item/placement-math.ts +59 -7
  86. package/src/components/tools/item/placement-strategies.ts +432 -16
  87. package/src/components/tools/item/placement-types.ts +22 -1
  88. package/src/components/tools/item/use-draft-node.ts +51 -2
  89. package/src/components/tools/item/use-placement-coordinator.tsx +1399 -256
  90. package/src/components/tools/registry/move-registry-node-tool.tsx +1064 -0
  91. package/src/components/tools/roof/roof-tool.tsx +350 -28
  92. package/src/components/tools/select/box-select-state.ts +88 -0
  93. package/src/components/tools/select/box-select-tool.tsx +442 -520
  94. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  95. package/src/components/tools/select/marquee-geometry.ts +155 -0
  96. package/src/components/tools/select/plane-box-select-tool.tsx +570 -0
  97. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  98. package/src/components/tools/select/select-candidates.test.ts +109 -0
  99. package/src/components/tools/select/select-candidates.ts +85 -0
  100. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  101. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  102. package/src/components/tools/shared/drag-bounding-box.tsx +195 -0
  103. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  104. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  105. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  106. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  107. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  108. package/src/components/tools/shared/placement-box.tsx +305 -0
  109. package/src/components/tools/shared/polygon-editor.tsx +866 -175
  110. package/src/components/tools/shared/segment-angle.ts +56 -0
  111. package/src/components/tools/site/site-boundary-editor.tsx +520 -16
  112. package/src/components/tools/site/site-flag-model.ts +18 -0
  113. package/src/components/tools/stair/stair-defaults.ts +2 -1
  114. package/src/components/tools/stair/stair-tool.tsx +342 -37
  115. package/src/components/tools/tool-manager.tsx +239 -62
  116. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  117. package/src/components/tools/wall/wall-drafting.ts +248 -171
  118. package/src/components/tools/wall/wall-snap-geometry.test.ts +123 -0
  119. package/src/components/tools/wall/wall-snap-geometry.ts +331 -0
  120. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  121. package/src/components/tools/zone/zone-tool.tsx +86 -82
  122. package/src/components/ui/action-menu/action-button.tsx +21 -1
  123. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  124. package/src/components/ui/action-menu/control-modes.tsx +40 -170
  125. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  126. package/src/components/ui/action-menu/index.tsx +3 -117
  127. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  128. package/src/components/ui/action-menu/structure-tools.tsx +27 -93
  129. package/src/components/ui/action-menu/view-toggles.tsx +279 -90
  130. package/src/components/ui/command-palette/editor-commands.tsx +20 -15
  131. package/src/components/ui/command-palette/index.tsx +5 -3
  132. package/src/components/ui/controls/material-paint-panel.tsx +152 -0
  133. package/src/components/ui/controls/material-picker.tsx +87 -177
  134. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  135. package/src/components/ui/controls/metric-control.tsx +142 -46
  136. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  137. package/src/components/ui/controls/slider-control.tsx +88 -38
  138. package/src/components/ui/floating-level-selector.tsx +47 -10
  139. package/src/components/ui/helpers/building-helper.tsx +10 -23
  140. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  141. package/src/components/ui/helpers/helper-manager.tsx +235 -23
  142. package/src/components/ui/helpers/item-helper.tsx +28 -32
  143. package/src/components/ui/helpers/registered-tool-helper.tsx +59 -0
  144. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  145. package/src/components/ui/icon-ref.tsx +47 -0
  146. package/src/components/ui/item-catalog/catalog-items.tsx +40 -1
  147. package/src/components/ui/item-catalog/item-catalog.tsx +16 -36
  148. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  149. package/src/components/ui/panels/node-display.ts +17 -16
  150. package/src/components/ui/panels/panel-manager.tsx +55 -58
  151. package/src/components/ui/panels/panel-wrapper.tsx +238 -12
  152. package/src/components/ui/panels/parametric-inspector.tsx +482 -0
  153. package/src/components/ui/panels/reference-panel.tsx +54 -19
  154. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  155. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  156. package/src/components/ui/primitives/sidebar.tsx +1 -0
  157. package/src/components/ui/scene-loader.tsx +1 -3
  158. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  159. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  160. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  161. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  162. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  163. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +122 -41
  164. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +66 -7
  165. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +267 -0
  166. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  167. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +9 -5
  168. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +86 -0
  169. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  170. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  171. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +92 -0
  172. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  173. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +3 -3
  174. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +86 -0
  175. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -21
  176. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  177. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  178. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +140 -0
  179. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +48 -8
  180. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +132 -0
  181. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -5
  182. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +86 -0
  183. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  184. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  185. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +112 -39
  186. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  187. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  188. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  189. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  190. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  191. package/src/components/ui/sidebar/tab-bar.tsx +75 -1
  192. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  193. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  194. package/src/components/ui/snap-target-badge.tsx +88 -0
  195. package/src/components/viewer-overlay.tsx +253 -61
  196. package/src/components/viewer-zone-system.tsx +7 -2
  197. package/src/hooks/use-auto-save.test.ts +14 -0
  198. package/src/hooks/use-auto-save.ts +64 -10
  199. package/src/hooks/use-ceiling-events.ts +176 -0
  200. package/src/hooks/use-drag-action.ts +127 -0
  201. package/src/hooks/use-keyboard.ts +493 -95
  202. package/src/hooks/use-selection.ts +64 -0
  203. package/src/index.tsx +451 -6
  204. package/src/lib/active-placement-surface.ts +35 -0
  205. package/src/lib/ceiling-plan-snap.ts +24 -0
  206. package/src/lib/constants.ts +6 -2
  207. package/src/lib/contextual-help.test.ts +112 -0
  208. package/src/lib/contextual-help.ts +144 -0
  209. package/src/lib/continuation.ts +64 -0
  210. package/src/lib/direct-manipulation.test.ts +192 -0
  211. package/src/lib/direct-manipulation.ts +109 -0
  212. package/src/lib/editor-api.ts +50 -0
  213. package/src/lib/elevator-support.ts +96 -0
  214. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  215. package/src/lib/floorplan/apply-alignment.ts +117 -0
  216. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  217. package/src/lib/floorplan/geometry.ts +53 -0
  218. package/src/lib/floorplan/index.ts +10 -0
  219. package/src/lib/floorplan/items.ts +5 -2
  220. package/src/lib/floorplan/plan-coords.ts +21 -0
  221. package/src/lib/floorplan/selection-tool.ts +40 -0
  222. package/src/lib/floorplan/stairs.ts +12 -1
  223. package/src/lib/fresh-planar-placement.test.ts +339 -0
  224. package/src/lib/fresh-planar-placement.ts +130 -0
  225. package/src/lib/glb-export.test.ts +441 -0
  226. package/src/lib/glb-export.ts +874 -0
  227. package/src/lib/history.ts +11 -1
  228. package/src/lib/interaction/hot-set.test.ts +133 -0
  229. package/src/lib/interaction/hot-set.ts +67 -0
  230. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  231. package/src/lib/interaction/overlay-policy.ts +59 -0
  232. package/src/lib/interaction/scope.ts +211 -0
  233. package/src/lib/level-duplication.ts +54 -42
  234. package/src/lib/level-selection.ts +2 -2
  235. package/src/lib/material-paint.ts +227 -48
  236. package/src/lib/measurement-kind.test.ts +30 -0
  237. package/src/lib/measurement-kind.ts +19 -0
  238. package/src/lib/measurement-label.test.ts +47 -0
  239. package/src/lib/measurement-label.ts +68 -0
  240. package/src/lib/measurement-parser.ts +116 -0
  241. package/src/lib/measurements.test.ts +183 -0
  242. package/src/lib/measurements.ts +199 -0
  243. package/src/lib/paint-scope.test.ts +454 -0
  244. package/src/lib/paint-scope.ts +461 -0
  245. package/src/lib/placement-drag-release.ts +23 -0
  246. package/src/lib/placement-metadata.ts +29 -0
  247. package/src/lib/planar-cursor-placement.test.ts +100 -0
  248. package/src/lib/planar-cursor-placement.ts +42 -0
  249. package/src/lib/plugin-panels.test.ts +19 -0
  250. package/src/lib/plugin-panels.ts +91 -0
  251. package/src/lib/quick-measurement.test.ts +77 -0
  252. package/src/lib/quick-measurement.ts +109 -0
  253. package/src/lib/roof-duplication.ts +7 -7
  254. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  255. package/src/lib/roof-wall-hit.ts +164 -0
  256. package/src/lib/scene-clipboard.test.ts +196 -0
  257. package/src/lib/scene-clipboard.ts +332 -0
  258. package/src/lib/scene.ts +40 -8
  259. package/src/lib/selection-routing.test.ts +298 -0
  260. package/src/lib/selection-routing.ts +175 -0
  261. package/src/lib/sfx/index.ts +1 -0
  262. package/src/lib/sfx/movement-tick.test.ts +65 -0
  263. package/src/lib/sfx/movement-tick.ts +18 -0
  264. package/src/lib/sfx-bus.ts +69 -10
  265. package/src/lib/sfx-player.test.ts +124 -0
  266. package/src/lib/sfx-player.ts +148 -55
  267. package/src/lib/site-boundary.ts +1 -0
  268. package/src/lib/slab-plan-snap.test.ts +93 -0
  269. package/src/lib/slab-plan-snap.ts +108 -0
  270. package/src/lib/snapping-mode.test.ts +138 -0
  271. package/src/lib/snapping-mode.ts +179 -0
  272. package/src/lib/stair-duplication.ts +4 -4
  273. package/src/lib/stair-levels.test.ts +146 -0
  274. package/src/lib/stair-levels.ts +177 -0
  275. package/src/lib/surface-plan-snap.test.ts +71 -0
  276. package/src/lib/surface-plan-snap.ts +256 -0
  277. package/src/lib/use-linear-display.ts +40 -0
  278. package/src/lib/world-grid-snap.ts +231 -0
  279. package/src/lib/zone-content.ts +130 -0
  280. package/src/store/use-alignment-guides.ts +21 -0
  281. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  282. package/src/store/use-editor.tsx +767 -114
  283. package/src/store/use-facing-pose.ts +44 -0
  284. package/src/store/use-fence-curve-draft.ts +21 -0
  285. package/src/store/use-floorplan-draft-preview.ts +101 -0
  286. package/src/store/use-floorplan-marquee.ts +50 -0
  287. package/src/store/use-interaction-scope.test.ts +186 -0
  288. package/src/store/use-interaction-scope.ts +132 -0
  289. package/src/store/use-measurement-draft.test.ts +530 -0
  290. package/src/store/use-measurement-draft.ts +543 -0
  291. package/src/store/use-opening-guides.ts +41 -0
  292. package/src/store/use-placement-preview.ts +39 -0
  293. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  294. package/src/store/use-quick-measurement-hud.ts +77 -0
  295. package/src/store/use-segment-draft-chain.ts +28 -0
  296. package/src/store/use-stair-build-preview.ts +43 -0
  297. package/src/store/use-wall-move-ghosts.ts +36 -0
  298. package/src/store/use-wall-snap-indicator.ts +34 -0
  299. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  300. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  301. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  302. package/src/components/tools/building/move-building-tool.tsx +0 -157
  303. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  304. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  305. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  306. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  307. package/src/components/tools/column/column-tool.tsx +0 -97
  308. package/src/components/tools/column/move-column-tool.tsx +0 -105
  309. package/src/components/tools/door/door-math.ts +0 -110
  310. package/src/components/tools/door/door-tool.tsx +0 -324
  311. package/src/components/tools/door/move-door-tool.tsx +0 -412
  312. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  313. package/src/components/tools/fence/fence-tool.tsx +0 -346
  314. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  315. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  316. package/src/components/tools/item/item-tool.tsx +0 -26
  317. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  318. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  319. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  320. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  321. package/src/components/tools/slab/slab-tool.tsx +0 -322
  322. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  323. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  324. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  325. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  326. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  327. package/src/components/tools/wall/wall-tool.tsx +0 -332
  328. package/src/components/tools/window/move-window-tool.tsx +0 -447
  329. package/src/components/tools/window/window-math.ts +0 -117
  330. package/src/components/tools/window/window-tool.tsx +0 -332
  331. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  332. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  333. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  334. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  335. package/src/components/ui/panels/column-panel.tsx +0 -759
  336. package/src/components/ui/panels/door-panel.tsx +0 -1299
  337. package/src/components/ui/panels/fence-panel.tsx +0 -222
  338. package/src/components/ui/panels/item-panel.tsx +0 -306
  339. package/src/components/ui/panels/paint-panel.tsx +0 -163
  340. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  341. package/src/components/ui/panels/roof-panel.tsx +0 -280
  342. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  343. package/src/components/ui/panels/slab-panel.tsx +0 -250
  344. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  345. package/src/components/ui/panels/stair-panel.tsx +0 -577
  346. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  347. package/src/components/ui/panels/wall-panel.tsx +0 -273
  348. package/src/components/ui/panels/window-panel.tsx +0 -968
  349. package/src/contexts/presets-context.tsx +0 -121
  350. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -0,0 +1,1656 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ closestMeasurementFeatureBinding,
7
+ collectAlignmentAnchors,
8
+ emitter,
9
+ type GeometryContext,
10
+ type MeasurementFeatureAnchor,
11
+ type MeasurementSnapKind,
12
+ measurementAngle,
13
+ measurementArea,
14
+ measurementDistance,
15
+ measurementFeatureLength,
16
+ measurementPerimeter,
17
+ measurementPrismVolume,
18
+ nodeRegistry,
19
+ useScene,
20
+ } from '@pascal-app/core'
21
+ import { useViewer } from '@pascal-app/viewer'
22
+ import { useEffect, useMemo, useRef, useState } from 'react'
23
+ import { measurementPolygonLabelAnchor } from '../../lib/measurement-label'
24
+ import {
25
+ buildMeasurementAngleArcPoints,
26
+ formatAreaLabel,
27
+ formatLinearMeasurement,
28
+ formatVolumeLabel,
29
+ getLinearUnitLabel,
30
+ linearUnitToMeters,
31
+ MEASUREMENT_ACTIVE_COLOR,
32
+ metersToLinearUnit,
33
+ } from '../../lib/measurements'
34
+ import {
35
+ clearSurfacePlanSnapFeedback,
36
+ resolveSurfacePlanPointSnap,
37
+ } from '../../lib/surface-plan-snap'
38
+ import useEditor from '../../store/use-editor'
39
+ import {
40
+ commitMeasurementDraft,
41
+ type MeasurementAxisGuide,
42
+ type MeasurementPoint,
43
+ measurementPolygonMidpoints,
44
+ useMeasurementDraft,
45
+ } from '../../store/use-measurement-draft'
46
+ import { formatAngleRadians } from '../tools/shared/segment-angle'
47
+ import { FloorplanQuickMeasureLayer } from './floorplan-quick-measure-layer'
48
+ import { useFloorplanRender } from './floorplan-render-context'
49
+ import { resolveFloorplanLabelAngle } from './renderers/floorplan-label-angle'
50
+
51
+ const DRAFT_COLOR = MEASUREMENT_ACTIVE_COLOR
52
+ const X_AXIS_COLOR = '#ef4444'
53
+ const Z_AXIS_COLOR = '#3b82f6'
54
+ const RETICLE_COLOR = MEASUREMENT_ACTIVE_COLOR
55
+ const AXIS_SNAP_DISTANCE_PX = 16
56
+ const AXIS_SNAP_RELEASE_DISTANCE_PX = 24
57
+ const PROXIMITY_GUIDE_DISTANCE_PX = 40
58
+ const VERTEX_HANDLE_DISTANCE_PX = 12
59
+ const MIDPOINT_HANDLE_DISTANCE_PX = 10
60
+ const VERTEX_DRAG_ACTIVATION_PX = 4
61
+ const AXIS_GUIDE_HALF_LENGTH_PX = 1_200
62
+
63
+ type PlanPoint = { x: number; z: number }
64
+ type ProjectedSnapPoint = PlanPoint & { nodeId: string }
65
+ type ProjectedSnapSegment = {
66
+ start: ProjectedSnapPoint
67
+ end: ProjectedSnapPoint
68
+ nodeId: string
69
+ }
70
+
71
+ export type ProjectedFloorplanSnap = {
72
+ kind: 'vertex' | 'edge'
73
+ nodeId: string
74
+ point: PlanPoint
75
+ }
76
+
77
+ const MAX_REGISTERED_SNAP_ELEMENTS = 512
78
+ const MAX_REGISTERED_SNAP_SEGMENTS = 4096
79
+ const MAX_SAMPLED_PATH_SEGMENTS = 48
80
+ const VERTEX_SNAP_DISTANCE_PX = 16
81
+ const EDGE_SNAP_DISTANCE_PX = 10
82
+ const SEMANTIC_FEATURE_SNAP_DISTANCE = 0.2
83
+ // Alt-bypass association mirrors the 3D tool's surface-verify tolerance: a
84
+ // feature binds only when the point already sits on it, never by attraction.
85
+ const SEMANTIC_FEATURE_BYPASS_DISTANCE = 0.012
86
+
87
+ function measurementGeometryContext(
88
+ node: AnyNode,
89
+ nodes: Record<AnyNodeId, AnyNode>,
90
+ ): GeometryContext {
91
+ const resolve: GeometryContext['resolve'] = <N = AnyNode>(id: AnyNodeId) =>
92
+ nodes[id] as N | undefined
93
+ const childIds =
94
+ 'children' in node && Array.isArray(node.children) ? (node.children as AnyNodeId[]) : []
95
+ const children = childIds
96
+ .map((id) => nodes[id])
97
+ .filter((child): child is AnyNode => child !== undefined)
98
+ const parent = node.parentId ? (nodes[node.parentId as AnyNodeId] ?? null) : null
99
+ const siblings =
100
+ parent && 'children' in parent && Array.isArray(parent.children)
101
+ ? (parent.children as AnyNodeId[])
102
+ .map((id) => nodes[id])
103
+ .filter(
104
+ (sibling): sibling is AnyNode => sibling !== undefined && sibling.type === node.type,
105
+ )
106
+ : []
107
+ return { resolve, children, parent, siblings }
108
+ }
109
+
110
+ function associatePlanPoint(
111
+ point: MeasurementPoint,
112
+ targetNodeId: string | null,
113
+ maxDistance = SEMANTIC_FEATURE_SNAP_DISTANCE,
114
+ ): {
115
+ point: MeasurementPoint
116
+ anchor?: MeasurementFeatureAnchor
117
+ semantic?: { label: string; length: number | null; snapKind: MeasurementSnapKind }
118
+ } {
119
+ if (!targetNodeId) return { point }
120
+ const nodes = useScene.getState().nodes
121
+ const node = nodes[targetNodeId as AnyNodeId]
122
+ const contribution = node ? nodeRegistry.get(node.type)?.measurement : undefined
123
+ if (!(node && contribution)) return { point }
124
+ const context = measurementGeometryContext(node, nodes)
125
+ const features = contribution.features(node, context)
126
+ const match =
127
+ contribution.match?.(node, context, point, maxDistance) ??
128
+ closestMeasurementFeatureBinding(features, point, maxDistance)
129
+ if (!match) return { point }
130
+ const reference = {
131
+ nodeId: node.id,
132
+ featureId: match.featureId,
133
+ parameters: match.parameters,
134
+ }
135
+ const feature =
136
+ contribution.resolve?.(node, context, reference) ??
137
+ features.find((candidate) => candidate.id === match.featureId) ??
138
+ null
139
+ return {
140
+ point: match.point,
141
+ anchor: {
142
+ kind: 'feature',
143
+ reference,
144
+ fallback: match.point,
145
+ },
146
+ semantic: feature
147
+ ? {
148
+ label: feature.label,
149
+ length: measurementFeatureLength(feature),
150
+ snapKind: feature.snapKind,
151
+ }
152
+ : undefined,
153
+ }
154
+ }
155
+
156
+ function closestPointOnScreenSegment(
157
+ point: PlanPoint,
158
+ start: PlanPoint,
159
+ end: PlanPoint,
160
+ ): PlanPoint {
161
+ const dx = end.x - start.x
162
+ const dz = end.z - start.z
163
+ const lengthSquared = dx * dx + dz * dz
164
+ if (lengthSquared < 1e-9) return { ...start }
165
+ const t = Math.max(
166
+ 0,
167
+ Math.min(1, ((point.x - start.x) * dx + (point.z - start.z) * dz) / lengthSquared),
168
+ )
169
+ return { x: start.x + dx * t, z: start.z + dz * t }
170
+ }
171
+
172
+ export function resolveProjectedFloorplanSnap(
173
+ pointer: PlanPoint,
174
+ vertices: readonly ProjectedSnapPoint[],
175
+ segments: readonly ProjectedSnapSegment[],
176
+ vertexThreshold = VERTEX_SNAP_DISTANCE_PX,
177
+ edgeThreshold = EDGE_SNAP_DISTANCE_PX,
178
+ ): ProjectedFloorplanSnap | null {
179
+ let nearestVertex: { candidate: ProjectedSnapPoint; distance: number } | null = null
180
+ for (const candidate of vertices) {
181
+ const distance = Math.hypot(pointer.x - candidate.x, pointer.z - candidate.z)
182
+ if (distance <= vertexThreshold && (!nearestVertex || distance < nearestVertex.distance)) {
183
+ nearestVertex = { candidate, distance }
184
+ }
185
+ }
186
+ if (nearestVertex) {
187
+ return {
188
+ kind: 'vertex',
189
+ nodeId: nearestVertex.candidate.nodeId,
190
+ point: { x: nearestVertex.candidate.x, z: nearestVertex.candidate.z },
191
+ }
192
+ }
193
+
194
+ let nearestEdge: { nodeId: string; point: PlanPoint; distance: number } | null = null
195
+ for (const segment of segments) {
196
+ const projected = closestPointOnScreenSegment(pointer, segment.start, segment.end)
197
+ const distance = Math.hypot(pointer.x - projected.x, pointer.z - projected.z)
198
+ if (distance <= edgeThreshold && (!nearestEdge || distance < nearestEdge.distance)) {
199
+ nearestEdge = { nodeId: segment.nodeId, point: projected, distance }
200
+ }
201
+ }
202
+ return nearestEdge ? { kind: 'edge', nodeId: nearestEdge.nodeId, point: nearestEdge.point } : null
203
+ }
204
+
205
+ export function isProjectedFloorplanAxisPointVerified(
206
+ candidate: PlanPoint,
207
+ nodeId: string,
208
+ vertices: readonly ProjectedSnapPoint[],
209
+ segments: readonly ProjectedSnapSegment[],
210
+ tolerance = 1.5,
211
+ ): boolean {
212
+ const resolved = resolveProjectedFloorplanSnap(
213
+ candidate,
214
+ vertices.filter((vertex) => vertex.nodeId === nodeId),
215
+ segments.filter((segment) => segment.nodeId === nodeId),
216
+ tolerance,
217
+ tolerance,
218
+ )
219
+ return Boolean(
220
+ resolved &&
221
+ resolved.nodeId === nodeId &&
222
+ Math.hypot(candidate.x - resolved.point.x, candidate.z - resolved.point.z) <= tolerance,
223
+ )
224
+ }
225
+
226
+ function screenPointForElement(
227
+ element: SVGGeometryElement,
228
+ nodeId: string,
229
+ x: number,
230
+ z: number,
231
+ ): ProjectedSnapPoint | null {
232
+ const matrix = element.getScreenCTM()
233
+ if (!matrix) return null
234
+ const point = new DOMPoint(x, z).matrixTransform(matrix)
235
+ return { x: point.x, z: point.y, nodeId }
236
+ }
237
+
238
+ function collectRegisteredFloorplanSnapGeometry(svg: SVGSVGElement): {
239
+ vertices: ProjectedSnapPoint[]
240
+ segments: ProjectedSnapSegment[]
241
+ } {
242
+ const vertices: ProjectedSnapPoint[] = []
243
+ const segments: ProjectedSnapSegment[] = []
244
+ const nodes = useScene.getState().nodes as Record<
245
+ string,
246
+ { type: string; visible?: boolean } | undefined
247
+ >
248
+ const entries = svg.querySelectorAll<SVGGElement>(
249
+ '.floorplan-registry-base .floorplan-registry-entry[data-node-id]',
250
+ )
251
+ let elementCount = 0
252
+
253
+ const addSegment = (start: ProjectedSnapPoint | null, end: ProjectedSnapPoint | null) => {
254
+ if (!(start && end) || segments.length >= MAX_REGISTERED_SNAP_SEGMENTS) return
255
+ segments.push({ start, end, nodeId: start.nodeId })
256
+ }
257
+
258
+ for (const entry of entries) {
259
+ const nodeId = entry.dataset.nodeId
260
+ const node = nodeId ? nodes[nodeId] : null
261
+ if (
262
+ !nodeId ||
263
+ !node ||
264
+ node.visible === false ||
265
+ node.type === 'measurement' ||
266
+ node.type === 'guide' ||
267
+ node.type === 'scan'
268
+ ) {
269
+ continue
270
+ }
271
+
272
+ const geometry = entry.querySelectorAll<SVGGeometryElement>(
273
+ 'line, polyline, polygon, rect, path, circle, ellipse',
274
+ )
275
+ for (const element of geometry) {
276
+ if (elementCount >= MAX_REGISTERED_SNAP_ELEMENTS) break
277
+ elementCount += 1
278
+ const tag = element.tagName.toLowerCase()
279
+
280
+ if (tag === 'line') {
281
+ const line = element as SVGLineElement
282
+ const start = screenPointForElement(
283
+ element,
284
+ nodeId,
285
+ line.x1.baseVal.value,
286
+ line.y1.baseVal.value,
287
+ )
288
+ const end = screenPointForElement(
289
+ element,
290
+ nodeId,
291
+ line.x2.baseVal.value,
292
+ line.y2.baseVal.value,
293
+ )
294
+ if (start) vertices.push(start)
295
+ if (end) vertices.push(end)
296
+ addSegment(start, end)
297
+ continue
298
+ }
299
+
300
+ if (tag === 'polyline' || tag === 'polygon') {
301
+ const poly = element as SVGPolylineElement | SVGPolygonElement
302
+ const points: ProjectedSnapPoint[] = []
303
+ for (let index = 0; index < poly.points.numberOfItems; index += 1) {
304
+ const item = poly.points.getItem(index)
305
+ const point = screenPointForElement(element, nodeId, item.x, item.y)
306
+ if (point) points.push(point)
307
+ }
308
+ vertices.push(...points)
309
+ for (let index = 1; index < points.length; index += 1) {
310
+ addSegment(points[index - 1]!, points[index]!)
311
+ }
312
+ if (tag === 'polygon' && points.length >= 3) addSegment(points.at(-1)!, points[0]!)
313
+ continue
314
+ }
315
+
316
+ if (tag === 'rect') {
317
+ const rect = element as SVGRectElement
318
+ const x = rect.x.baseVal.value
319
+ const z = rect.y.baseVal.value
320
+ const width = rect.width.baseVal.value
321
+ const height = rect.height.baseVal.value
322
+ const points = [
323
+ screenPointForElement(element, nodeId, x, z),
324
+ screenPointForElement(element, nodeId, x + width, z),
325
+ screenPointForElement(element, nodeId, x + width, z + height),
326
+ screenPointForElement(element, nodeId, x, z + height),
327
+ ].filter((point): point is ProjectedSnapPoint => point !== null)
328
+ vertices.push(...points)
329
+ if (points.length === 4) {
330
+ for (let index = 0; index < points.length; index += 1) {
331
+ addSegment(points[index]!, points[(index + 1) % points.length]!)
332
+ }
333
+ }
334
+ continue
335
+ }
336
+
337
+ let length = 0
338
+ try {
339
+ length = element.getTotalLength()
340
+ } catch {
341
+ continue
342
+ }
343
+ if (!(Number.isFinite(length) && length > 0)) continue
344
+ const segmentCount = Math.min(
345
+ MAX_SAMPLED_PATH_SEGMENTS,
346
+ Math.max(8, Math.ceil(length / 0.25)),
347
+ )
348
+ let previous: ProjectedSnapPoint | null = null
349
+ for (let index = 0; index <= segmentCount; index += 1) {
350
+ const sample = element.getPointAtLength((length * index) / segmentCount)
351
+ const point = screenPointForElement(element, nodeId, sample.x, sample.y)
352
+ if (previous && point) addSegment(previous, point)
353
+ if (tag === 'path' && (index === 0 || index === segmentCount) && point) {
354
+ vertices.push(point)
355
+ }
356
+ previous = point
357
+ }
358
+ }
359
+ if (elementCount >= MAX_REGISTERED_SNAP_ELEMENTS) break
360
+ }
361
+
362
+ return { vertices, segments }
363
+ }
364
+
365
+ export function resolveFloorplanMeasurementAxisSnap(
366
+ raw: MeasurementPoint,
367
+ anchor: MeasurementPoint,
368
+ xAxisScreenDistance: number,
369
+ zAxisScreenDistance: number,
370
+ threshold = AXIS_SNAP_DISTANCE_PX,
371
+ lockedAxis: 'x' | 'z' | null = null,
372
+ releaseThreshold = AXIS_SNAP_RELEASE_DISTANCE_PX,
373
+ proximity = false,
374
+ ): { point: MeasurementPoint; guide: MeasurementAxisGuide } {
375
+ const candidates = [
376
+ {
377
+ axis: 'x' as const,
378
+ screenDistance: xAxisScreenDistance,
379
+ to: [raw[0], anchor[1], anchor[2]] as MeasurementPoint,
380
+ },
381
+ {
382
+ axis: 'z' as const,
383
+ screenDistance: zAxisScreenDistance,
384
+ to: [anchor[0], anchor[1], raw[2]] as MeasurementPoint,
385
+ },
386
+ ]
387
+ const locked = lockedAxis
388
+ ? candidates.find(
389
+ (candidate) =>
390
+ candidate.axis === lockedAxis && candidate.screenDistance <= releaseThreshold,
391
+ )
392
+ : null
393
+ const closest = candidates.reduce((best, candidate) =>
394
+ candidate.screenDistance < best.screenDistance ? candidate : best,
395
+ )
396
+ const selected = locked ?? (closest.screenDistance <= threshold ? closest : null)
397
+ const guideCandidate = selected ?? closest
398
+ return {
399
+ point: selected ? [...selected.to] : [...raw],
400
+ guide: {
401
+ axis: guideCandidate.axis,
402
+ from: [...anchor],
403
+ to: [...guideCandidate.to],
404
+ snapped: selected !== null,
405
+ ...(proximity ? { proximity: true } : {}),
406
+ },
407
+ }
408
+ }
409
+
410
+ function isMeasurementKind(
411
+ value: unknown,
412
+ ): value is 'distance' | 'angle' | 'area' | 'perimeter' | 'volume' {
413
+ return (
414
+ value === 'distance' ||
415
+ value === 'angle' ||
416
+ value === 'area' ||
417
+ value === 'perimeter' ||
418
+ value === 'volume'
419
+ )
420
+ }
421
+
422
+ function clientToPlanPoint(group: SVGGElement, clientX: number, clientY: number): PlanPoint | null {
423
+ const matrix = group.getScreenCTM()
424
+ if (!matrix) return null
425
+ const local = new DOMPoint(clientX, clientY).matrixTransform(matrix.inverse())
426
+ return { x: local.x, z: local.y }
427
+ }
428
+
429
+ function planPointToClient(group: SVGGElement, point: MeasurementPoint): DOMPoint | null {
430
+ const matrix = group.getScreenCTM()
431
+ if (!matrix) return null
432
+ return new DOMPoint(point[0], point[2]).matrixTransform(matrix)
433
+ }
434
+
435
+ function screenDistanceToPlanPoint(
436
+ group: SVGGElement,
437
+ point: MeasurementPoint,
438
+ clientX: number,
439
+ clientY: number,
440
+ ): number {
441
+ const screen = planPointToClient(group, point)
442
+ return screen ? Math.hypot(clientX - screen.x, clientY - screen.y) : Number.POSITIVE_INFINITY
443
+ }
444
+
445
+ function snapPlanPoint(
446
+ group: SVGGElement,
447
+ raw: MeasurementPoint,
448
+ anchors: readonly MeasurementPoint[],
449
+ clientX: number,
450
+ clientY: number,
451
+ allowAxisSnap = true,
452
+ lockedGuide: MeasurementAxisGuide | null = null,
453
+ proximityAnchors: readonly MeasurementPoint[] = [],
454
+ ): { point: MeasurementPoint; guide: MeasurementAxisGuide | null } {
455
+ if (anchors.length === 0 && proximityAnchors.length === 0) return { point: raw, guide: null }
456
+ const candidates = [
457
+ ...anchors.map((anchor) => ({ anchor, proximity: false })),
458
+ ...proximityAnchors.map((anchor) => ({ anchor, proximity: true })),
459
+ ].flatMap(({ anchor, proximity }) => {
460
+ const xPoint: MeasurementPoint = [raw[0], anchor[1], anchor[2]]
461
+ const zPoint: MeasurementPoint = [anchor[0], anchor[1], raw[2]]
462
+ return [
463
+ {
464
+ anchor,
465
+ axis: 'x' as const,
466
+ point: xPoint,
467
+ proximity,
468
+ screenDistance: screenDistanceToPlanPoint(group, xPoint, clientX, clientY),
469
+ },
470
+ {
471
+ anchor,
472
+ axis: 'z' as const,
473
+ point: zPoint,
474
+ proximity,
475
+ screenDistance: screenDistanceToPlanPoint(group, zPoint, clientX, clientY),
476
+ },
477
+ ]
478
+ })
479
+ const threshold = allowAxisSnap ? AXIS_SNAP_DISTANCE_PX : -1
480
+ const releaseThreshold = allowAxisSnap ? AXIS_SNAP_RELEASE_DISTANCE_PX : -1
481
+ const locked = lockedGuide
482
+ ? candidates.reduce<(typeof candidates)[number] | null>((best, candidate) => {
483
+ if (
484
+ candidate.axis !== lockedGuide.axis ||
485
+ candidate.proximity !== Boolean(lockedGuide.proximity) ||
486
+ candidate.anchor.some(
487
+ (value, index) => Math.abs(value - lockedGuide.from[index]!) > 1e-9,
488
+ ) ||
489
+ candidate.screenDistance > releaseThreshold
490
+ )
491
+ return best
492
+ return !best || candidate.screenDistance < best.screenDistance ? candidate : best
493
+ }, null)
494
+ : null
495
+ const closest = candidates
496
+ .filter(
497
+ (candidate) =>
498
+ !candidate.proximity || candidate.screenDistance <= PROXIMITY_GUIDE_DISTANCE_PX,
499
+ )
500
+ .reduce<(typeof candidates)[number] | null>(
501
+ (best, candidate) =>
502
+ !best || candidate.screenDistance < best.screenDistance ? candidate : best,
503
+ null,
504
+ )
505
+ if (!closest) return { point: raw, guide: null }
506
+ const selected = locked ?? (closest.screenDistance <= threshold ? closest : null)
507
+ const guideCandidate = selected ?? closest
508
+ return {
509
+ point: selected ? [...selected.point] : [...raw],
510
+ guide: {
511
+ axis: guideCandidate.axis,
512
+ from: [...guideCandidate.anchor],
513
+ to: [...guideCandidate.point],
514
+ snapped: selected !== null,
515
+ ...(guideCandidate.proximity ? { proximity: true } : {}),
516
+ },
517
+ }
518
+ }
519
+
520
+ function measurementVertexSnapAnchors(
521
+ points: readonly MeasurementPoint[],
522
+ index: number,
523
+ polygon: boolean,
524
+ ): MeasurementPoint[] {
525
+ if (!Number.isInteger(index) || index < 0 || index >= points.length || points.length < 2)
526
+ return []
527
+ const neighborIndices =
528
+ polygon && points.length >= 3
529
+ ? [(index - 1 + points.length) % points.length, (index + 1) % points.length]
530
+ : [index - 1, index + 1]
531
+ return Array.from(new Set(neighborIndices))
532
+ .filter(
533
+ (neighborIndex) =>
534
+ neighborIndex >= 0 && neighborIndex < points.length && neighborIndex !== index,
535
+ )
536
+ .map((neighborIndex) => [...points[neighborIndex]!] as MeasurementPoint)
537
+ }
538
+
539
+ function closeBaseAndMaybeCommit(): boolean {
540
+ const draft = useMeasurementDraft.getState()
541
+ if (draft.kind === 'distance' || draft.kind === 'angle') return false
542
+ if (!draft.closeBase('2d', [0, 1, 0])) return false
543
+ if (draft.kind === 'area' || draft.kind === 'perimeter') commitMeasurementDraft('2d')
544
+ return true
545
+ }
546
+
547
+ function isExtrusionControlTarget(target: EventTarget | null): boolean {
548
+ return (
549
+ target instanceof Element && target.closest('[data-measurement-extrusion-control]') !== null
550
+ )
551
+ }
552
+
553
+ function FloorplanDraftLabel({
554
+ angle = 0,
555
+ appearance = 'plate',
556
+ background,
557
+ border,
558
+ offsetPx = 0,
559
+ point,
560
+ sceneRotationDeg,
561
+ screenUpright = false,
562
+ text,
563
+ textColor,
564
+ unitsPerPixel,
565
+ }: {
566
+ angle?: number
567
+ appearance?: 'plate' | 'outlined'
568
+ background: string
569
+ border: string
570
+ offsetPx?: number
571
+ point: MeasurementPoint
572
+ sceneRotationDeg: number
573
+ screenUpright?: boolean
574
+ text: string
575
+ textColor: string
576
+ unitsPerPixel: number
577
+ }) {
578
+ const labelUnitsPerPixel = Math.max(unitsPerPixel, 1e-6)
579
+ const fontSize = labelUnitsPerPixel * (appearance === 'outlined' ? 12 : 10)
580
+ const padX = labelUnitsPerPixel * 6
581
+ const padY = labelUnitsPerPixel * 3
582
+ const plateWidth = text.length * labelUnitsPerPixel * 6.2 + padX * 2
583
+ const plateHeight = fontSize + padY * 2
584
+ const localAngleDeg = resolveFloorplanLabelAngle(angle, sceneRotationDeg, screenUpright)
585
+
586
+ return (
587
+ <g
588
+ pointerEvents="none"
589
+ transform={`translate(${point[0]} ${point[2]}) rotate(${localAngleDeg}) translate(0 ${-offsetPx * labelUnitsPerPixel})`}
590
+ >
591
+ {appearance === 'outlined' ? null : (
592
+ <rect
593
+ fill={background}
594
+ height={plateHeight}
595
+ opacity={0.94}
596
+ rx={labelUnitsPerPixel * 4}
597
+ ry={labelUnitsPerPixel * 4}
598
+ stroke={border}
599
+ strokeWidth={labelUnitsPerPixel * 0.7}
600
+ vectorEffect="non-scaling-stroke"
601
+ width={plateWidth}
602
+ x={-plateWidth / 2}
603
+ y={-plateHeight / 2}
604
+ />
605
+ )}
606
+ <text
607
+ dominantBaseline="middle"
608
+ fill={appearance === 'outlined' ? '#ffffff' : textColor}
609
+ fontFamily={
610
+ appearance === 'outlined'
611
+ ? 'system-ui, -apple-system, sans-serif'
612
+ : 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
613
+ }
614
+ fontSize={fontSize}
615
+ fontWeight={appearance === 'outlined' ? '500' : '600'}
616
+ paintOrder={appearance === 'outlined' ? 'stroke' : undefined}
617
+ stroke={appearance === 'outlined' ? border : undefined}
618
+ strokeLinecap={appearance === 'outlined' ? 'round' : undefined}
619
+ strokeLinejoin={appearance === 'outlined' ? 'round' : undefined}
620
+ strokeWidth={appearance === 'outlined' ? fontSize * 0.35 : undefined}
621
+ textAnchor="middle"
622
+ >
623
+ {text}
624
+ </text>
625
+ </g>
626
+ )
627
+ }
628
+
629
+ function FloorplanExtrusionControl({
630
+ center,
631
+ sceneRotationDeg,
632
+ unitsPerPixel,
633
+ }: {
634
+ center: MeasurementPoint
635
+ sceneRotationDeg: number
636
+ unitsPerPixel: number
637
+ }) {
638
+ const unit = useViewer((state) => state.unit)
639
+ const extrusionHeight = useMeasurementDraft((state) => state.extrusionHeight)
640
+ const points = useMeasurementDraft((state) => state.points)
641
+ const baseNormal = useMeasurementDraft((state) => state.baseNormal)
642
+ const [value, setValue] = useState(() =>
643
+ extrusionHeight === 0 ? '' : String(metersToLinearUnit(extrusionHeight, unit)),
644
+ )
645
+
646
+ useEffect(() => {
647
+ const height = useMeasurementDraft.getState().extrusionHeight
648
+ setValue(height === 0 ? '' : String(Number(metersToLinearUnit(height, unit).toFixed(3))))
649
+ }, [unit])
650
+
651
+ const width = 260 * unitsPerPixel
652
+ const height = 72 * unitsPerPixel
653
+ const x = center[0] - width / 2
654
+ const y = center[2] - height / 2
655
+
656
+ const commit = () => {
657
+ const numericValue = Number.parseFloat(value)
658
+ if (!Number.isFinite(numericValue)) {
659
+ useMeasurementDraft.getState().setExtrusionHeight('2d', 0)
660
+ return
661
+ }
662
+ useMeasurementDraft.getState().setExtrusionHeight('2d', linearUnitToMeters(numericValue, unit))
663
+ const draft = useMeasurementDraft.getState()
664
+ if (draft.finishExtrusion('2d')) commitMeasurementDraft('2d')
665
+ }
666
+ const extrusion: MeasurementPoint = baseNormal
667
+ ? [
668
+ baseNormal[0] * extrusionHeight,
669
+ baseNormal[1] * extrusionHeight,
670
+ baseNormal[2] * extrusionHeight,
671
+ ]
672
+ : [0, 0, 0]
673
+ const volumeLabel = formatVolumeLabel(measurementPrismVolume(points, extrusion), unit)
674
+
675
+ return (
676
+ <g transform={`rotate(${-sceneRotationDeg} ${center[0]} ${center[2]})`}>
677
+ <foreignObject
678
+ data-measurement-extrusion-control
679
+ height={height}
680
+ overflow="visible"
681
+ pointerEvents="auto"
682
+ width={width}
683
+ x={x}
684
+ y={y}
685
+ >
686
+ <div
687
+ className="flex h-full w-full items-center gap-1.5 rounded-md border border-border/70 bg-background/95 p-1.5 text-foreground shadow-lg"
688
+ data-measurement-extrusion-control
689
+ onClick={(event) => {
690
+ event.stopPropagation()
691
+ event.nativeEvent.stopImmediatePropagation()
692
+ }}
693
+ onPointerDown={(event) => {
694
+ event.stopPropagation()
695
+ event.nativeEvent.stopImmediatePropagation()
696
+ }}
697
+ >
698
+ <label className="sr-only" htmlFor="measurement-extrusion-height">
699
+ Extrusion height
700
+ </label>
701
+ <div className="relative min-w-0 flex-1">
702
+ <span className="pointer-events-none absolute inset-y-0 left-2 flex items-center font-medium text-muted-foreground text-xs">
703
+ H
704
+ </span>
705
+ <input
706
+ aria-label="Extrusion height"
707
+ className="h-8 w-full rounded-md border border-border bg-background pr-7 pl-6 text-sm outline-none focus:border-cyan-400"
708
+ id="measurement-extrusion-height"
709
+ inputMode="decimal"
710
+ onChange={(event) => {
711
+ const next = event.target.value
712
+ setValue(next)
713
+ const numericValue = Number.parseFloat(next)
714
+ if (Number.isFinite(numericValue)) {
715
+ useMeasurementDraft
716
+ .getState()
717
+ .setExtrusionHeight('2d', linearUnitToMeters(numericValue, unit))
718
+ } else {
719
+ useMeasurementDraft.getState().setExtrusionHeight('2d', 0)
720
+ }
721
+ }}
722
+ onKeyDown={(event) => {
723
+ event.stopPropagation()
724
+ if (event.key === 'Enter') {
725
+ event.preventDefault()
726
+ commit()
727
+ }
728
+ }}
729
+ placeholder="0"
730
+ step="0.1"
731
+ type="number"
732
+ value={value}
733
+ />
734
+ <span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-muted-foreground text-xs">
735
+ {getLinearUnitLabel(unit)}
736
+ </span>
737
+ </div>
738
+ <span className="shrink-0 font-mono font-semibold text-[11px] tabular-nums text-foreground">
739
+ V {volumeLabel}
740
+ </span>
741
+ <button
742
+ className="h-8 shrink-0 rounded-full bg-cyan-500 px-3 font-medium text-black text-xs disabled:cursor-not-allowed disabled:opacity-40"
743
+ disabled={Math.abs(extrusionHeight) < 0.001}
744
+ onClick={commit}
745
+ type="button"
746
+ >
747
+ Create
748
+ </button>
749
+ </div>
750
+ </foreignObject>
751
+ </g>
752
+ )
753
+ }
754
+
755
+ export function FloorplanMeasurementToolLayer() {
756
+ const groupRef = useRef<SVGGElement>(null)
757
+ const vertexGesture = useRef<{
758
+ pointerId: number
759
+ source: 'vertex' | 'midpoint'
760
+ index: number
761
+ startX: number
762
+ startY: number
763
+ engaged: boolean
764
+ previousInputDragging: boolean
765
+ previousCursor: string
766
+ } | null>(null)
767
+ const suppressNextClick = useRef(false)
768
+ const cancelVertexGesture = useRef<() => void>(() => {})
769
+ const mode = useEditor((state) => state.mode)
770
+ const tool = useEditor((state) => state.tool)
771
+ const storedKind = useEditor((state) => state.toolDefaults.measurement?.kind)
772
+ const kind = isMeasurementKind(storedKind) ? storedKind : 'distance'
773
+ const smartActive = mode === 'build' && tool === 'measurement' && storedKind === 'smart'
774
+ const active = mode === 'build' && tool === 'measurement' && isMeasurementKind(storedKind)
775
+ const renderContext = useFloorplanRender()
776
+ const points = useMeasurementDraft((state) => state.points)
777
+ const hover = useMeasurementDraft((state) => state.hover)
778
+ const hoverOwner = useMeasurementDraft((state) => state.hoverOwner)
779
+ const owner = useMeasurementDraft((state) => state.owner)
780
+ const stage = useMeasurementDraft((state) => state.stage)
781
+ const axisGuide = useMeasurementDraft((state) => state.axisGuide)
782
+ const vertexDrag = useMeasurementDraft((state) => state.vertexDrag)
783
+ const baseNormal = useMeasurementDraft((state) => state.baseNormal)
784
+ const extrusionHeight = useMeasurementDraft((state) => state.extrusionHeight)
785
+ const error = useMeasurementDraft((state) => state.error)
786
+ const draftLevelId = useMeasurementDraft((state) => state.levelId)
787
+ const activeLevelId = useViewer((state) => state.selection.levelId)
788
+ const unit = useViewer((state) => state.unit)
789
+
790
+ useEffect(() => {
791
+ if (active) {
792
+ cancelVertexGesture.current()
793
+ useMeasurementDraft.getState().setKind(kind)
794
+ }
795
+ }, [active, kind])
796
+
797
+ useEffect(() => {
798
+ const draft = useMeasurementDraft.getState()
799
+ if (draft.levelId && draft.levelId !== activeLevelId) {
800
+ cancelVertexGesture.current()
801
+ draft.reset()
802
+ }
803
+ }, [activeLevelId])
804
+
805
+ useEffect(() => {
806
+ const group = groupRef.current
807
+ const svg = group?.ownerSVGElement
808
+ if (!(active && group && svg) || owner === '3d') return
809
+ let projectedGeometry:
810
+ | (ReturnType<typeof collectRegisteredFloorplanSnapGeometry> & {
811
+ proximityAnchors: MeasurementPoint[]
812
+ })
813
+ | null = null
814
+ let projectedGeometryTimestamp = Number.NEGATIVE_INFINITY
815
+
816
+ const getProjectedGeometry = () => {
817
+ const now = performance.now()
818
+ if (!projectedGeometry || now - projectedGeometryTimestamp > 120) {
819
+ const geometry = collectRegisteredFloorplanSnapGeometry(svg)
820
+ const seen = new Set<string>()
821
+ const sceneAnchors = collectAlignmentAnchors(
822
+ useScene.getState().nodes,
823
+ '',
824
+ activeLevelId,
825
+ ).map(({ x, z }) => [x, 0, z] as MeasurementPoint)
826
+ const proximityAnchors = [
827
+ ...sceneAnchors,
828
+ ...geometry.vertices.flatMap((vertex) => {
829
+ const plan = clientToPlanPoint(group, vertex.x, vertex.z)
830
+ if (!plan) return []
831
+ return [[plan.x, 0, plan.z] as MeasurementPoint]
832
+ }),
833
+ ].filter((anchor) => {
834
+ const key = `${Math.round(anchor[0] * 1_000)}:${Math.round(anchor[2] * 1_000)}`
835
+ if (seen.has(key)) return false
836
+ seen.add(key)
837
+ return true
838
+ })
839
+ projectedGeometry = { ...geometry, proximityAnchors }
840
+ projectedGeometryTimestamp = now
841
+ }
842
+ return projectedGeometry
843
+ }
844
+
845
+ const consume = (event: MouseEvent | PointerEvent) => {
846
+ event.preventDefault()
847
+ event.stopPropagation()
848
+ event.stopImmediatePropagation()
849
+ }
850
+
851
+ const closestVertexIndex = (
852
+ event: MouseEvent | PointerEvent,
853
+ points: readonly MeasurementPoint[],
854
+ threshold = VERTEX_HANDLE_DISTANCE_PX,
855
+ ) => {
856
+ let closestIndex: number | null = null
857
+ let closestDistance = threshold
858
+ for (let index = 0; index < points.length; index += 1) {
859
+ const distance = screenDistanceToPlanPoint(
860
+ group,
861
+ points[index]!,
862
+ event.clientX,
863
+ event.clientY,
864
+ )
865
+ if (distance > closestDistance) continue
866
+ closestDistance = distance
867
+ closestIndex = index
868
+ }
869
+ return closestIndex
870
+ }
871
+
872
+ const resolveEventPoint = (
873
+ event: MouseEvent | PointerEvent,
874
+ anchorsOverride?: readonly MeasurementPoint[],
875
+ ) => {
876
+ const plan = clientToPlanPoint(group, event.clientX, event.clientY)
877
+ if (!plan) return null
878
+ const draft = useMeasurementDraft.getState()
879
+ const projectedGeometry = getProjectedGeometry()
880
+ const projectedSnap = resolveProjectedFloorplanSnap(
881
+ { x: event.clientX, z: event.clientY },
882
+ projectedGeometry.vertices,
883
+ projectedGeometry.segments,
884
+ )
885
+ const projectedPlan = projectedSnap
886
+ ? clientToPlanPoint(group, projectedSnap.point.x, projectedSnap.point.z)
887
+ : null
888
+ // Measurement anchors always bind to real geometry — the construction
889
+ // snapping-mode chip doesn't govern this analysis tool. Alt bypasses,
890
+ // including the projected registry-geometry pull.
891
+ const surfaceSnap = resolveSurfacePlanPointSnap({
892
+ rawPoint: [plan.x, plan.z],
893
+ fallbackPoint:
894
+ projectedPlan && !event.altKey ? [projectedPlan.x, projectedPlan.z] : [plan.x, plan.z],
895
+ levelId: activeLevelId,
896
+ align: false,
897
+ magnetic: !event.altKey,
898
+ })
899
+ // A discrete wall snap (corner / midpoint / crossing) is the strongest
900
+ // signal; a locked axis must not pull the point off it.
901
+ const discreteWallSnap =
902
+ surfaceSnap.wallSnap === 'endpoint' ||
903
+ surfaceSnap.wallSnap === 'midpoint' ||
904
+ surfaceSnap.wallSnap === 'intersection'
905
+ const raw: MeasurementPoint = [surfaceSnap.point[0], 0, surfaceSnap.point[1]]
906
+ const targetNodeId = surfaceSnap.wallIds[0] ?? projectedSnap?.nodeId ?? null
907
+ const lastPoint = draft.points.at(-1)
908
+ const resolved = snapPlanPoint(
909
+ group,
910
+ raw,
911
+ anchorsOverride ?? (lastPoint ? [lastPoint] : []),
912
+ event.clientX,
913
+ event.clientY,
914
+ !discreteWallSnap && !event.altKey,
915
+ !event.altKey &&
916
+ !discreteWallSnap &&
917
+ draft.axisGuide?.snapped &&
918
+ draft.axisGuide.axis !== 'y'
919
+ ? draft.axisGuide
920
+ : null,
921
+ projectedGeometry.proximityAnchors,
922
+ )
923
+ if (targetNodeId && resolved.guide?.snapped) {
924
+ const candidate = planPointToClient(group, resolved.point)
925
+ if (
926
+ !candidate ||
927
+ !isProjectedFloorplanAxisPointVerified(
928
+ { x: candidate.x, z: candidate.y },
929
+ targetNodeId,
930
+ projectedGeometry.vertices,
931
+ projectedGeometry.segments,
932
+ )
933
+ ) {
934
+ const surfaceOnly = snapPlanPoint(
935
+ group,
936
+ raw,
937
+ anchorsOverride ?? (lastPoint ? [lastPoint] : []),
938
+ event.clientX,
939
+ event.clientY,
940
+ false,
941
+ null,
942
+ projectedGeometry.proximityAnchors,
943
+ )
944
+ return { ...surfaceOnly, targetNodeId }
945
+ }
946
+ }
947
+ return { ...resolved, targetNodeId }
948
+ }
949
+
950
+ const clearVertexGesture = (finish: boolean) => {
951
+ const gesture = vertexGesture.current
952
+ if (!gesture) return
953
+ if (gesture.engaged) {
954
+ const draft = useMeasurementDraft.getState()
955
+ if (finish) draft.finishVertexDrag('2d')
956
+ else draft.cancelVertexDrag('2d')
957
+ useViewer.getState().setInputDragging(gesture.previousInputDragging)
958
+ }
959
+ document.body.style.cursor = gesture.previousCursor
960
+ if (svg.hasPointerCapture?.(gesture.pointerId)) svg.releasePointerCapture(gesture.pointerId)
961
+ vertexGesture.current = null
962
+ }
963
+ cancelVertexGesture.current = () => clearVertexGesture(false)
964
+
965
+ const onPointerDown = (event: PointerEvent) => {
966
+ const draft = useMeasurementDraft.getState()
967
+ if (event.button !== 0 || (draft.owner && draft.owner !== '2d')) return
968
+ if (isExtrusionControlTarget(event.target)) return
969
+ consume(event)
970
+ if (draft.owner !== '2d' || draft.stage !== 'collecting' || draft.vertexDrag) return
971
+ if (draft.kind === 'distance') return
972
+ const polygon = draft.kind === 'area' || draft.kind === 'perimeter' || draft.kind === 'volume'
973
+ const threshold = event.pointerType === 'touch' ? 22 : VERTEX_HANDLE_DISTANCE_PX
974
+ const vertexIndex = closestVertexIndex(event, draft.points, threshold)
975
+ const midpointIndex =
976
+ vertexIndex === null && polygon
977
+ ? closestVertexIndex(
978
+ event,
979
+ measurementPolygonMidpoints(draft.points).map((midpoint) => midpoint.point),
980
+ event.pointerType === 'touch' ? 18 : MIDPOINT_HANDLE_DISTANCE_PX,
981
+ )
982
+ : null
983
+ if (vertexIndex === null && midpointIndex === null) return
984
+ svg.setPointerCapture?.(event.pointerId)
985
+ vertexGesture.current = {
986
+ pointerId: event.pointerId,
987
+ source: vertexIndex !== null ? 'vertex' : 'midpoint',
988
+ index: vertexIndex ?? midpointIndex!,
989
+ startX: event.clientX,
990
+ startY: event.clientY,
991
+ engaged: false,
992
+ previousInputDragging: useViewer.getState().inputDragging,
993
+ previousCursor: document.body.style.cursor,
994
+ }
995
+ }
996
+
997
+ const onPointerMove = (event: PointerEvent) => {
998
+ const draft = useMeasurementDraft.getState()
999
+ if (draft.owner && draft.owner !== '2d') return
1000
+
1001
+ const gesture = vertexGesture.current
1002
+ if (gesture?.pointerId === event.pointerId) {
1003
+ consume(event)
1004
+ if (!gesture.engaged) {
1005
+ if (
1006
+ Math.hypot(event.clientX - gesture.startX, event.clientY - gesture.startY) <
1007
+ VERTEX_DRAG_ACTIVATION_PX
1008
+ ) {
1009
+ return
1010
+ }
1011
+ const began =
1012
+ gesture.source === 'vertex'
1013
+ ? draft.beginVertexDrag('2d', gesture.index)
1014
+ : draft.beginMidpointVertexDrag('2d', gesture.index)
1015
+ if (!began) {
1016
+ clearVertexGesture(false)
1017
+ return
1018
+ }
1019
+ if (gesture.source === 'midpoint') gesture.index += 1
1020
+ gesture.engaged = true
1021
+ useViewer.getState().setInputDragging(true)
1022
+ document.body.style.cursor = 'grabbing'
1023
+ }
1024
+
1025
+ const activeDraft = useMeasurementDraft.getState()
1026
+ const anchors = measurementVertexSnapAnchors(
1027
+ activeDraft.points,
1028
+ gesture.index,
1029
+ activeDraft.kind === 'area' ||
1030
+ activeDraft.kind === 'perimeter' ||
1031
+ activeDraft.kind === 'volume',
1032
+ )
1033
+ const resolved = resolveEventPoint(event, anchors)
1034
+ if (resolved) {
1035
+ const associated = associatePlanPoint(
1036
+ resolved.point,
1037
+ resolved.targetNodeId,
1038
+ event.altKey ? SEMANTIC_FEATURE_BYPASS_DISTANCE : SEMANTIC_FEATURE_SNAP_DISTANCE,
1039
+ )
1040
+ activeDraft.updateDraggedVertex(
1041
+ '2d',
1042
+ {
1043
+ point: associated.point,
1044
+ normal: [0, 1, 0],
1045
+ targetNodeId: resolved.targetNodeId,
1046
+ anchor: associated.anchor,
1047
+ semantic: associated.semantic,
1048
+ },
1049
+ resolved.guide,
1050
+ )
1051
+ }
1052
+ return
1053
+ }
1054
+
1055
+ if (draft.stage !== 'collecting') return
1056
+ const resolved = resolveEventPoint(event)
1057
+ const associated = resolved
1058
+ ? associatePlanPoint(
1059
+ resolved.point,
1060
+ resolved.targetNodeId,
1061
+ event.altKey ? SEMANTIC_FEATURE_BYPASS_DISTANCE : SEMANTIC_FEATURE_SNAP_DISTANCE,
1062
+ )
1063
+ : null
1064
+ draft.setHover(
1065
+ '2d',
1066
+ resolved && associated
1067
+ ? {
1068
+ point: associated.point,
1069
+ normal: [0, 1, 0],
1070
+ targetNodeId: resolved.targetNodeId,
1071
+ anchor: associated.anchor,
1072
+ semantic: associated.semantic,
1073
+ }
1074
+ : null,
1075
+ resolved?.guide ?? null,
1076
+ )
1077
+ }
1078
+
1079
+ const onPointerLeave = () => {
1080
+ if (vertexGesture.current) return
1081
+ clearSurfacePlanSnapFeedback()
1082
+ const draft = useMeasurementDraft.getState()
1083
+ if (!draft.owner || draft.owner === '2d') draft.setHover('2d', null)
1084
+ }
1085
+
1086
+ const onPointerUp = (event: PointerEvent) => {
1087
+ const gesture = vertexGesture.current
1088
+ if (!gesture || gesture.pointerId !== event.pointerId) return
1089
+ consume(event)
1090
+ const wasEngaged = gesture.engaged
1091
+ const source = gesture.source
1092
+ const vertexIndex = gesture.index
1093
+ clearVertexGesture(true)
1094
+ if (!wasEngaged && source === 'vertex') {
1095
+ const draft = useMeasurementDraft.getState()
1096
+ if (vertexIndex === 0 && draft.points.length >= 3 && draft.kind !== 'angle') {
1097
+ closeBaseAndMaybeCommit()
1098
+ }
1099
+ }
1100
+ suppressNextClick.current = true
1101
+ setTimeout(() => {
1102
+ suppressNextClick.current = false
1103
+ }, 300)
1104
+ }
1105
+
1106
+ const onPointerCancel = (event: PointerEvent) => {
1107
+ const gesture = vertexGesture.current
1108
+ if (!gesture || gesture.pointerId !== event.pointerId) return
1109
+ consume(event)
1110
+ clearVertexGesture(false)
1111
+ clearSurfacePlanSnapFeedback()
1112
+ }
1113
+
1114
+ const onClick = (event: MouseEvent) => {
1115
+ const draft = useMeasurementDraft.getState()
1116
+ if (event.button !== 0 || (draft.owner && draft.owner !== '2d')) return
1117
+ if (isExtrusionControlTarget(event.target)) return
1118
+ consume(event)
1119
+ if (suppressNextClick.current) {
1120
+ suppressNextClick.current = false
1121
+ return
1122
+ }
1123
+ if (event.detail > 1 || draft.stage !== 'collecting') return
1124
+
1125
+ const vertexIndex = draft.kind === 'distance' ? null : closestVertexIndex(event, draft.points)
1126
+ if (vertexIndex !== null) {
1127
+ if (vertexIndex === 0 && draft.points.length >= 3 && draft.kind !== 'angle') {
1128
+ closeBaseAndMaybeCommit()
1129
+ }
1130
+ return
1131
+ }
1132
+ const resolved = resolveEventPoint(event)
1133
+ if (!resolved) return
1134
+ const associated = associatePlanPoint(
1135
+ resolved.point,
1136
+ resolved.targetNodeId,
1137
+ event.altKey ? SEMANTIC_FEATURE_BYPASS_DISTANCE : SEMANTIC_FEATURE_SNAP_DISTANCE,
1138
+ )
1139
+ if (!draft.addPoint('2d', associated.point, associated.anchor)) return
1140
+ if (useMeasurementDraft.getState().stage === 'ready') commitMeasurementDraft('2d')
1141
+ }
1142
+
1143
+ const onDoubleClick = (event: MouseEvent) => {
1144
+ const draft = useMeasurementDraft.getState()
1145
+ if (draft.owner !== '2d' || draft.stage !== 'collecting') return
1146
+ if (draft.kind === 'distance' || draft.kind === 'angle') return
1147
+ if (isExtrusionControlTarget(event.target)) return
1148
+ consume(event)
1149
+ if (suppressNextClick.current) {
1150
+ suppressNextClick.current = false
1151
+ return
1152
+ }
1153
+ closeBaseAndMaybeCommit()
1154
+ }
1155
+
1156
+ const onBlur = () => {
1157
+ clearVertexGesture(false)
1158
+ clearSurfacePlanSnapFeedback()
1159
+ }
1160
+ const onToolCancel = () => {
1161
+ clearVertexGesture(false)
1162
+ clearSurfacePlanSnapFeedback()
1163
+ }
1164
+
1165
+ emitter.on('tool:cancel', onToolCancel)
1166
+ svg.addEventListener('pointerdown', onPointerDown, true)
1167
+ svg.addEventListener('pointermove', onPointerMove, true)
1168
+ svg.addEventListener('pointerup', onPointerUp, true)
1169
+ svg.addEventListener('pointercancel', onPointerCancel, true)
1170
+ svg.addEventListener('pointerleave', onPointerLeave, true)
1171
+ svg.addEventListener('click', onClick, true)
1172
+ svg.addEventListener('dblclick', onDoubleClick, true)
1173
+ window.addEventListener('blur', onBlur)
1174
+ return () => {
1175
+ cancelVertexGesture.current = () => {}
1176
+ clearVertexGesture(false)
1177
+ clearSurfacePlanSnapFeedback()
1178
+ emitter.off('tool:cancel', onToolCancel)
1179
+ svg.removeEventListener('pointerdown', onPointerDown, true)
1180
+ svg.removeEventListener('pointermove', onPointerMove, true)
1181
+ svg.removeEventListener('pointerup', onPointerUp, true)
1182
+ svg.removeEventListener('pointercancel', onPointerCancel, true)
1183
+ svg.removeEventListener('pointerleave', onPointerLeave, true)
1184
+ svg.removeEventListener('click', onClick, true)
1185
+ svg.removeEventListener('dblclick', onDoubleClick, true)
1186
+ window.removeEventListener('blur', onBlur)
1187
+ }
1188
+ }, [active, activeLevelId, owner])
1189
+
1190
+ const preview = useMemo(() => {
1191
+ const livePoints =
1192
+ stage === 'collecting' && hover && !vertexDrag ? [...points, hover.point] : points
1193
+ const planPoints = livePoints.map((point) => ({ x: point[0], y: point[2] }))
1194
+ const polygonPoints = planPoints.map((point) => `${point.x},${point.y}`).join(' ')
1195
+ const angleArc =
1196
+ kind === 'angle' && livePoints.length >= 3
1197
+ ? buildMeasurementAngleArcPoints(livePoints[0]!, livePoints[1]!, livePoints[2]!)
1198
+ : []
1199
+ const angleArcPoints = angleArc.map((point) => `${point[0]},${point[2]}`).join(' ')
1200
+ const first = planPoints[0]
1201
+ const last = planPoints[planPoints.length - 1]
1202
+ const center = measurementPolygonLabelAnchor(points)
1203
+ const polygon = kind === 'area' || kind === 'perimeter' || kind === 'volume'
1204
+ const midpointHandles =
1205
+ polygon && stage === 'collecting' && !vertexDrag ? measurementPolygonMidpoints(points) : []
1206
+ const activeEdges: Array<[MeasurementPoint, MeasurementPoint]> = vertexDrag
1207
+ ? measurementVertexSnapAnchors(points, vertexDrag.index, polygon).map((neighbor) => [
1208
+ neighbor,
1209
+ points[vertexDrag.index]!,
1210
+ ])
1211
+ : []
1212
+ let label: {
1213
+ angle: number
1214
+ point: MeasurementPoint
1215
+ screenUpright: boolean
1216
+ text: string
1217
+ } | null = null
1218
+ let segmentLabel: { angle: number; point: MeasurementPoint; text: string } | null = null
1219
+
1220
+ if (kind === 'distance' && livePoints.length >= 2) {
1221
+ const start = livePoints[0]!
1222
+ const end = livePoints[livePoints.length - 1]!
1223
+ label = {
1224
+ angle: Math.atan2(end[2] - start[2], end[0] - start[0]),
1225
+ point: [(start[0] + end[0]) / 2, 0, (start[2] + end[2]) / 2],
1226
+ screenUpright: false,
1227
+ text: formatLinearMeasurement(measurementDistance(start, end), unit),
1228
+ }
1229
+ } else if (kind === 'angle' && livePoints.length >= 3) {
1230
+ const anglePoints = livePoints.slice(0, 3) as [
1231
+ MeasurementPoint,
1232
+ MeasurementPoint,
1233
+ MeasurementPoint,
1234
+ ]
1235
+ label = {
1236
+ angle: 0,
1237
+ point: angleArc[Math.floor(angleArc.length / 2)] ?? anglePoints[1],
1238
+ screenUpright: true,
1239
+ text: formatAngleRadians(measurementAngle(...anglePoints)),
1240
+ }
1241
+ } else if ((kind === 'area' || kind === 'perimeter') && livePoints.length >= 3) {
1242
+ const liveCenter = measurementPolygonLabelAnchor(livePoints)
1243
+ if (liveCenter) {
1244
+ label = {
1245
+ angle: 0,
1246
+ point: liveCenter,
1247
+ screenUpright: true,
1248
+ text:
1249
+ kind === 'area'
1250
+ ? `A ${formatAreaLabel(measurementArea(livePoints), unit)}`
1251
+ : `P ${formatLinearMeasurement(measurementPerimeter(livePoints), unit)}`,
1252
+ }
1253
+ }
1254
+ } else if (kind === 'volume' && center && baseNormal) {
1255
+ const extrusion: MeasurementPoint = [
1256
+ baseNormal[0] * extrusionHeight,
1257
+ baseNormal[1] * extrusionHeight,
1258
+ baseNormal[2] * extrusionHeight,
1259
+ ]
1260
+ if (stage !== 'extruding') {
1261
+ label = {
1262
+ angle: 0,
1263
+ point: center,
1264
+ screenUpright: true,
1265
+ text: `V ${formatVolumeLabel(measurementPrismVolume(points, extrusion), unit)}`,
1266
+ }
1267
+ }
1268
+ }
1269
+
1270
+ if (kind !== 'distance' && stage === 'collecting') {
1271
+ const segmentEnd = vertexDrag ? points[vertexDrag.index] : hover?.point
1272
+ const segmentStart = vertexDrag
1273
+ ? (axisGuide?.from ?? points[(vertexDrag.index - 1 + points.length) % points.length])
1274
+ : points.at(-1)
1275
+ if (segmentStart && segmentEnd && measurementDistance(segmentStart, segmentEnd) > 1e-6) {
1276
+ segmentLabel = {
1277
+ angle: Math.atan2(segmentEnd[2] - segmentStart[2], segmentEnd[0] - segmentStart[0]),
1278
+ point: [
1279
+ (segmentStart[0] + segmentEnd[0]) / 2,
1280
+ (segmentStart[1] + segmentEnd[1]) / 2,
1281
+ (segmentStart[2] + segmentEnd[2]) / 2,
1282
+ ],
1283
+ text: formatLinearMeasurement(measurementDistance(segmentStart, segmentEnd), unit),
1284
+ }
1285
+ }
1286
+ }
1287
+
1288
+ return {
1289
+ activeEdges,
1290
+ angleArcPoints,
1291
+ center,
1292
+ first,
1293
+ label,
1294
+ last,
1295
+ livePoints,
1296
+ midpointHandles,
1297
+ planPoints,
1298
+ polygonPoints,
1299
+ segmentLabel,
1300
+ }
1301
+ }, [axisGuide, baseNormal, extrusionHeight, hover, kind, points, stage, unit, vertexDrag])
1302
+
1303
+ if (smartActive) return <FloorplanQuickMeasureLayer />
1304
+ if (!active || (draftLevelId && draftLevelId !== activeLevelId)) return null
1305
+ const unitsPerPixel = renderContext?.unitsPerPixel ?? 0.01
1306
+ const sceneRotationDeg = renderContext?.sceneRotationDeg ?? 0
1307
+ const anchorRadius = 4.5 * unitsPerPixel
1308
+ const labelFontSize = 12 * unitsPerPixel
1309
+ const reticleRadius = 16 * unitsPerPixel
1310
+ const reticleInnerRadius = 9 * unitsPerPixel
1311
+ const cornerSnap = hover?.semantic?.snapKind === 'endpoint'
1312
+ const reticleColor = axisGuide?.snapped
1313
+ ? axisGuide.axis === 'x'
1314
+ ? X_AXIS_COLOR
1315
+ : axisGuide.axis === 'z'
1316
+ ? Z_AXIS_COLOR
1317
+ : RETICLE_COLOR
1318
+ : hover?.semantic
1319
+ ? '#22c55e'
1320
+ : RETICLE_COLOR
1321
+ const distanceStrokeColor =
1322
+ kind === 'distance' && axisGuide?.snapped
1323
+ ? axisGuide.axis === 'x'
1324
+ ? X_AXIS_COLOR
1325
+ : Z_AXIS_COLOR
1326
+ : DRAFT_COLOR
1327
+ const guideAnchor = vertexDrag && axisGuide ? axisGuide.from : points.at(-1)
1328
+ const guideHalfLength = AXIS_GUIDE_HALF_LENGTH_PX * unitsPerPixel
1329
+ const labelBackground = renderContext?.palette.measurementLabelBackground ?? '#0f172a'
1330
+ const labelText = renderContext?.palette.measurementLabelText ?? '#f8fafc'
1331
+
1332
+ return (
1333
+ <g ref={groupRef}>
1334
+ {guideAnchor ? (
1335
+ <g pointerEvents="none">
1336
+ <line
1337
+ stroke={X_AXIS_COLOR}
1338
+ strokeDasharray={axisGuide?.axis === 'x' && axisGuide.snapped ? undefined : '6 5'}
1339
+ strokeOpacity={axisGuide?.axis === 'x' ? (axisGuide.snapped ? 1 : 0.8) : 0.28}
1340
+ strokeWidth={axisGuide?.axis === 'x' ? (axisGuide.snapped ? 3.5 : 2) : 1}
1341
+ vectorEffect="non-scaling-stroke"
1342
+ x1={guideAnchor[0] - guideHalfLength}
1343
+ x2={guideAnchor[0] + guideHalfLength}
1344
+ y1={guideAnchor[2]}
1345
+ y2={guideAnchor[2]}
1346
+ />
1347
+ <line
1348
+ stroke={Z_AXIS_COLOR}
1349
+ strokeDasharray={axisGuide?.axis === 'z' && axisGuide.snapped ? undefined : '6 5'}
1350
+ strokeOpacity={axisGuide?.axis === 'z' ? (axisGuide.snapped ? 1 : 0.8) : 0.28}
1351
+ strokeWidth={axisGuide?.axis === 'z' ? (axisGuide.snapped ? 3.5 : 2) : 1}
1352
+ vectorEffect="non-scaling-stroke"
1353
+ x1={guideAnchor[0]}
1354
+ x2={guideAnchor[0]}
1355
+ y1={guideAnchor[2] - guideHalfLength}
1356
+ y2={guideAnchor[2] + guideHalfLength}
1357
+ />
1358
+ </g>
1359
+ ) : null}
1360
+ {kind === 'distance' && preview.planPoints.length >= 2 ? (
1361
+ <line
1362
+ stroke={distanceStrokeColor}
1363
+ strokeLinecap="round"
1364
+ strokeWidth={2}
1365
+ vectorEffect="non-scaling-stroke"
1366
+ x1={preview.planPoints[0]!.x}
1367
+ x2={preview.planPoints[preview.planPoints.length - 1]!.x}
1368
+ y1={preview.planPoints[0]!.y}
1369
+ y2={preview.planPoints[preview.planPoints.length - 1]!.y}
1370
+ />
1371
+ ) : null}
1372
+ {kind === 'angle' && preview.planPoints.length >= 2 ? (
1373
+ <>
1374
+ <polyline
1375
+ fill="none"
1376
+ points={preview.polygonPoints}
1377
+ stroke={DRAFT_COLOR}
1378
+ strokeLinecap="round"
1379
+ strokeLinejoin="round"
1380
+ strokeWidth={2}
1381
+ vectorEffect="non-scaling-stroke"
1382
+ />
1383
+ {preview.angleArcPoints ? (
1384
+ <polyline
1385
+ fill="none"
1386
+ points={preview.angleArcPoints}
1387
+ stroke={DRAFT_COLOR}
1388
+ strokeLinecap="round"
1389
+ strokeLinejoin="round"
1390
+ strokeWidth={3}
1391
+ vectorEffect="non-scaling-stroke"
1392
+ />
1393
+ ) : null}
1394
+ </>
1395
+ ) : null}
1396
+ {(kind === 'area' || kind === 'perimeter' || kind === 'volume') &&
1397
+ preview.planPoints.length >= 2 ? (
1398
+ <>
1399
+ {kind !== 'perimeter' && preview.planPoints.length >= 3 ? (
1400
+ <polygon fill={DRAFT_COLOR} fillOpacity={0.12} points={preview.polygonPoints} />
1401
+ ) : null}
1402
+ <polyline
1403
+ fill="none"
1404
+ points={preview.polygonPoints}
1405
+ stroke={DRAFT_COLOR}
1406
+ strokeLinecap="round"
1407
+ strokeLinejoin="round"
1408
+ strokeWidth={2}
1409
+ vectorEffect="non-scaling-stroke"
1410
+ />
1411
+ {preview.first && preview.last ? (
1412
+ <line
1413
+ stroke={DRAFT_COLOR}
1414
+ strokeDasharray="5 4"
1415
+ strokeOpacity={0.65}
1416
+ strokeWidth={1.5}
1417
+ vectorEffect="non-scaling-stroke"
1418
+ x1={preview.last.x}
1419
+ x2={preview.first.x}
1420
+ y1={preview.last.y}
1421
+ y2={preview.first.y}
1422
+ />
1423
+ ) : null}
1424
+ </>
1425
+ ) : null}
1426
+ {preview.activeEdges.map(([start, end], index) => (
1427
+ <line
1428
+ key={`measurement-active-edge-${index}`}
1429
+ stroke="#f8fafc"
1430
+ strokeLinecap="round"
1431
+ strokeOpacity={0.95}
1432
+ strokeWidth={3}
1433
+ vectorEffect="non-scaling-stroke"
1434
+ x1={start[0]}
1435
+ x2={end[0]}
1436
+ y1={start[2]}
1437
+ y2={end[2]}
1438
+ />
1439
+ ))}
1440
+ {axisGuide ? (
1441
+ <g pointerEvents="none">
1442
+ <line
1443
+ stroke="#f8fafc"
1444
+ strokeOpacity={axisGuide.snapped ? 0.8 : 0.35}
1445
+ strokeWidth={4}
1446
+ vectorEffect="non-scaling-stroke"
1447
+ x1={axisGuide.from[0]}
1448
+ x2={axisGuide.to[0]}
1449
+ y1={axisGuide.from[2]}
1450
+ y2={axisGuide.to[2]}
1451
+ />
1452
+ <line
1453
+ stroke={axisGuide.axis === 'x' ? X_AXIS_COLOR : Z_AXIS_COLOR}
1454
+ strokeDasharray={axisGuide.snapped ? undefined : '5 4'}
1455
+ strokeOpacity={axisGuide.snapped ? 1 : 0.72}
1456
+ strokeWidth={axisGuide.snapped ? 2.5 : 1.75}
1457
+ vectorEffect="non-scaling-stroke"
1458
+ x1={axisGuide.from[0]}
1459
+ x2={axisGuide.to[0]}
1460
+ y1={axisGuide.from[2]}
1461
+ y2={axisGuide.to[2]}
1462
+ />
1463
+ <text
1464
+ dominantBaseline="central"
1465
+ fill={axisGuide.axis === 'x' ? X_AXIS_COLOR : Z_AXIS_COLOR}
1466
+ fontSize={labelFontSize}
1467
+ fontWeight="700"
1468
+ textAnchor="middle"
1469
+ transform={`rotate(${-sceneRotationDeg} ${axisGuide.to[0]} ${axisGuide.to[2]})`}
1470
+ x={axisGuide.to[0]}
1471
+ y={axisGuide.to[2] - 24 * unitsPerPixel}
1472
+ >
1473
+ {axisGuide.proximity ? 'ALIGN ' : ''}
1474
+ {axisGuide.axis.toUpperCase()}
1475
+ </text>
1476
+ {axisGuide.snapped ? (
1477
+ <>
1478
+ <circle
1479
+ cx={axisGuide.to[0]}
1480
+ cy={axisGuide.to[2]}
1481
+ fill={labelBackground}
1482
+ r={6 * unitsPerPixel}
1483
+ stroke={axisGuide.axis === 'x' ? X_AXIS_COLOR : Z_AXIS_COLOR}
1484
+ strokeWidth={3}
1485
+ vectorEffect="non-scaling-stroke"
1486
+ />
1487
+ <circle
1488
+ cx={axisGuide.to[0]}
1489
+ cy={axisGuide.to[2]}
1490
+ fill={axisGuide.axis === 'x' ? X_AXIS_COLOR : Z_AXIS_COLOR}
1491
+ r={2 * unitsPerPixel}
1492
+ />
1493
+ </>
1494
+ ) : null}
1495
+ {axisGuide.proximity && axisGuide.snapped ? (
1496
+ <circle
1497
+ cx={axisGuide.from[0]}
1498
+ cy={axisGuide.from[2]}
1499
+ fill={labelBackground}
1500
+ r={5 * unitsPerPixel}
1501
+ stroke={axisGuide.axis === 'x' ? X_AXIS_COLOR : Z_AXIS_COLOR}
1502
+ strokeWidth={2}
1503
+ vectorEffect="non-scaling-stroke"
1504
+ />
1505
+ ) : null}
1506
+ </g>
1507
+ ) : null}
1508
+ {points.map((point, index) => (
1509
+ <circle
1510
+ cx={point[0]}
1511
+ cy={point[2]}
1512
+ fill={vertexDrag?.index === index ? '#f8fafc' : DRAFT_COLOR}
1513
+ key={`measurement-draft-point-${index}`}
1514
+ pointerEvents="none"
1515
+ r={vertexDrag?.index === index ? anchorRadius * 1.2 : anchorRadius}
1516
+ stroke="white"
1517
+ strokeWidth={1}
1518
+ vectorEffect="non-scaling-stroke"
1519
+ />
1520
+ ))}
1521
+ {preview.midpointHandles.map(({ edgeIndex, point }) => (
1522
+ <circle
1523
+ cx={point[0]}
1524
+ cy={point[2]}
1525
+ fill="#a3e635"
1526
+ key={`measurement-draft-midpoint-${edgeIndex}`}
1527
+ pointerEvents="none"
1528
+ r={3 * unitsPerPixel}
1529
+ stroke="white"
1530
+ strokeWidth={1}
1531
+ vectorEffect="non-scaling-stroke"
1532
+ />
1533
+ ))}
1534
+ {hover && hoverOwner === '2d' ? (
1535
+ <g pointerEvents="none">
1536
+ {cornerSnap ? (
1537
+ <circle
1538
+ cx={hover.point[0]}
1539
+ cy={hover.point[2]}
1540
+ fill="none"
1541
+ r={reticleRadius + 6 * unitsPerPixel}
1542
+ stroke="#a3e635"
1543
+ strokeOpacity={0.9}
1544
+ strokeWidth={3}
1545
+ vectorEffect="non-scaling-stroke"
1546
+ />
1547
+ ) : null}
1548
+ <circle
1549
+ cx={hover.point[0]}
1550
+ cy={hover.point[2]}
1551
+ fill="none"
1552
+ r={reticleRadius}
1553
+ stroke={reticleColor}
1554
+ strokeWidth={2}
1555
+ vectorEffect="non-scaling-stroke"
1556
+ />
1557
+ <circle
1558
+ cx={hover.point[0]}
1559
+ cy={hover.point[2]}
1560
+ fill="none"
1561
+ r={reticleInnerRadius}
1562
+ stroke={reticleColor}
1563
+ strokeOpacity={0.72}
1564
+ strokeWidth={1.5}
1565
+ vectorEffect="non-scaling-stroke"
1566
+ />
1567
+ <line
1568
+ stroke={X_AXIS_COLOR}
1569
+ strokeWidth={2}
1570
+ vectorEffect="non-scaling-stroke"
1571
+ x1={hover.point[0] - reticleInnerRadius}
1572
+ x2={hover.point[0] + reticleInnerRadius}
1573
+ y1={hover.point[2]}
1574
+ y2={hover.point[2]}
1575
+ />
1576
+ <line
1577
+ stroke={Z_AXIS_COLOR}
1578
+ strokeWidth={2}
1579
+ vectorEffect="non-scaling-stroke"
1580
+ x1={hover.point[0]}
1581
+ x2={hover.point[0]}
1582
+ y1={hover.point[2] - reticleInnerRadius}
1583
+ y2={hover.point[2] + reticleInnerRadius}
1584
+ />
1585
+ </g>
1586
+ ) : null}
1587
+ {hover && hoverOwner === '2d' && hover.semantic ? (
1588
+ <FloorplanDraftLabel
1589
+ background={labelBackground}
1590
+ border="#22c55e"
1591
+ offsetPx={30}
1592
+ point={hover.point}
1593
+ sceneRotationDeg={sceneRotationDeg}
1594
+ screenUpright
1595
+ text={`${hover.semantic.label}${
1596
+ hover.semantic.length === null
1597
+ ? ''
1598
+ : ` · ${formatLinearMeasurement(hover.semantic.length, unit)}`
1599
+ }`}
1600
+ textColor={labelText}
1601
+ unitsPerPixel={unitsPerPixel}
1602
+ />
1603
+ ) : null}
1604
+ {preview.label ? (
1605
+ <FloorplanDraftLabel
1606
+ angle={preview.label.angle}
1607
+ appearance="outlined"
1608
+ background={labelBackground}
1609
+ border={DRAFT_COLOR}
1610
+ offsetPx={preview.label.screenUpright ? 0 : 14}
1611
+ point={preview.label.point}
1612
+ sceneRotationDeg={sceneRotationDeg}
1613
+ screenUpright={preview.label.screenUpright}
1614
+ text={preview.label.text}
1615
+ textColor={labelText}
1616
+ unitsPerPixel={unitsPerPixel}
1617
+ />
1618
+ ) : null}
1619
+ {preview.segmentLabel ? (
1620
+ <FloorplanDraftLabel
1621
+ angle={preview.segmentLabel.angle}
1622
+ background={labelBackground}
1623
+ border={DRAFT_COLOR}
1624
+ offsetPx={12}
1625
+ point={preview.segmentLabel.point}
1626
+ sceneRotationDeg={sceneRotationDeg}
1627
+ text={preview.segmentLabel.text}
1628
+ textColor={labelText}
1629
+ unitsPerPixel={unitsPerPixel}
1630
+ />
1631
+ ) : null}
1632
+ {error && preview.last ? (
1633
+ <FloorplanDraftLabel
1634
+ background={labelBackground}
1635
+ border="var(--destructive)"
1636
+ offsetPx={-16}
1637
+ point={[preview.last.x, 0, preview.last.y]}
1638
+ sceneRotationDeg={sceneRotationDeg}
1639
+ screenUpright
1640
+ text={error}
1641
+ textColor="var(--destructive)"
1642
+ unitsPerPixel={unitsPerPixel}
1643
+ />
1644
+ ) : null}
1645
+ {owner === '2d' && stage === 'extruding' && preview.center ? (
1646
+ <FloorplanExtrusionControl
1647
+ center={preview.center}
1648
+ sceneRotationDeg={sceneRotationDeg}
1649
+ unitsPerPixel={unitsPerPixel}
1650
+ />
1651
+ ) : null}
1652
+ </g>
1653
+ )
1654
+ }
1655
+
1656
+ export default FloorplanMeasurementToolLayer