@pascal-app/editor 0.9.1 → 1.0.0-beta.1

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 (366) hide show
  1. package/package.json +14 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +49 -0
  4. package/src/components/editor/bake-thumbnail.tsx +95 -0
  5. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  6. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  7. package/src/components/editor/custom-camera-controls.tsx +332 -199
  8. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  9. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  10. package/src/components/editor/editor-layout-v2.tsx +18 -0
  11. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  12. package/src/components/editor/export-manager.tsx +71 -63
  13. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  14. package/src/components/editor/first-person/build-collider-world.test.ts +102 -5
  15. package/src/components/editor/first-person/build-collider-world.ts +52 -5
  16. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  17. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  18. package/src/components/editor/first-person-controls.tsx +410 -136
  19. package/src/components/editor/floating-action-menu.tsx +435 -64
  20. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  21. package/src/components/editor/floorplan-background-selection.test.ts +72 -0
  22. package/src/components/editor/floorplan-background-selection.ts +7 -20
  23. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  24. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  25. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  26. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  27. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  28. package/src/components/editor/floorplan-panel.tsx +2517 -1492
  29. package/src/components/editor/grid.tsx +166 -39
  30. package/src/components/editor/group-actions.ts +597 -0
  31. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  32. package/src/components/editor/group-move-3d.ts +397 -0
  33. package/src/components/editor/group-rotate-handle.tsx +102 -64
  34. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  35. package/src/components/editor/group-transform-shared.test.ts +192 -1
  36. package/src/components/editor/group-transform-shared.ts +240 -7
  37. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  38. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  39. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  40. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  41. package/src/components/editor/handles/preview-overrides.ts +13 -0
  42. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  43. package/src/components/editor/handles/resize-snap.ts +23 -0
  44. package/src/components/editor/handles/use-handle-drag.ts +49 -6
  45. package/src/components/editor/index.tsx +289 -35
  46. package/src/components/editor/measurement-pill.tsx +68 -23
  47. package/src/components/editor/node-action-menu.tsx +14 -1
  48. package/src/components/editor/node-arrow-handles.tsx +409 -180
  49. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  50. package/src/components/editor/quick-measurement-card.tsx +76 -0
  51. package/src/components/editor/quick-measurement-hud.tsx +34 -0
  52. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  53. package/src/components/editor/selection-manager.tsx +814 -466
  54. package/src/components/editor/site-edge-labels.tsx +28 -6
  55. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  56. package/src/components/editor/snapshot-capture-overlay.tsx +258 -115
  57. package/src/components/editor/three-context-bridge.ts +22 -0
  58. package/src/components/editor/thumbnail-generator.tsx +118 -253
  59. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  60. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  61. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  62. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  63. package/src/components/editor/use-mesh-settle-epoch.ts +27 -0
  64. package/src/components/editor/wall-measurement-label.tsx +10 -28
  65. package/src/components/editor/wall-move-side-handles.tsx +229 -39
  66. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  67. package/src/components/editor/wall-snap-beacon-layer.tsx +162 -6
  68. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  69. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +40 -9
  71. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  72. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  73. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  74. package/src/components/editor-2d/floorplan-group-move.tsx +744 -0
  75. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  76. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1672 -0
  77. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  78. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  79. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +219 -20
  80. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +327 -59
  81. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  82. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  83. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  84. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  85. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  86. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  88. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  89. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  90. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +89 -0
  91. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +106 -0
  92. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +94 -26
  93. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +587 -0
  94. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +2280 -578
  95. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  96. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  97. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +425 -61
  98. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  99. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  100. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  101. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  102. package/src/components/systems/zone/zone-system.tsx +42 -13
  103. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  104. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  105. package/src/components/tools/fence/fence-drafting.ts +107 -8
  106. package/src/components/tools/item/move-tool.tsx +17 -13
  107. package/src/components/tools/item/placement-math.test.ts +13 -1
  108. package/src/components/tools/item/placement-math.ts +28 -2
  109. package/src/components/tools/item/placement-strategies.ts +246 -5
  110. package/src/components/tools/item/placement-types.ts +13 -1
  111. package/src/components/tools/item/use-draft-node.ts +146 -57
  112. package/src/components/tools/item/use-placement-coordinator.tsx +813 -125
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +636 -103
  114. package/src/components/tools/roof/roof-tool.tsx +336 -94
  115. package/src/components/tools/select/box-select-state.ts +18 -7
  116. package/src/components/tools/select/box-select-tool.tsx +176 -40
  117. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  118. package/src/components/tools/select/marquee-geometry.ts +155 -0
  119. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  120. package/src/components/tools/select/select-candidates.ts +1 -1
  121. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  122. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  123. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  124. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  125. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  126. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  127. package/src/components/tools/shared/placement-box.tsx +183 -1
  128. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  129. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  130. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  131. package/src/components/tools/site/site-boundary-editor.tsx +101 -42
  132. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  133. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  134. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  135. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  136. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  137. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  138. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  139. package/src/components/tools/stair/stair-defaults.ts +1 -0
  140. package/src/components/tools/stair/stair-tool.tsx +164 -42
  141. package/src/components/tools/tool-manager.tsx +149 -30
  142. package/src/components/tools/wall/wall-drafting.test.ts +447 -0
  143. package/src/components/tools/wall/wall-drafting.ts +304 -108
  144. package/src/components/tools/wall/wall-snap-geometry.test.ts +52 -0
  145. package/src/components/tools/wall/wall-snap-geometry.ts +137 -9
  146. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  147. package/src/components/tools/zone/zone-tool.tsx +98 -88
  148. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  149. package/src/components/ui/action-menu/control-modes.tsx +44 -94
  150. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  151. package/src/components/ui/action-menu/index.tsx +2 -4
  152. package/src/components/ui/action-menu/measurement-control.tsx +267 -0
  153. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  154. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  155. package/src/components/ui/command-palette/editor-commands.tsx +16 -3
  156. package/src/components/ui/command-palette/index.tsx +4 -3
  157. package/src/components/ui/controls/material-paint-panel.tsx +93 -18
  158. package/src/components/ui/controls/material-picker.tsx +162 -152
  159. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  160. package/src/components/ui/controls/metric-control.tsx +118 -44
  161. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  162. package/src/components/ui/controls/slider-control.tsx +88 -38
  163. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  164. package/src/components/ui/floating-level-selector.tsx +74 -20
  165. package/src/components/ui/helpers/building-helper.tsx +10 -23
  166. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  167. package/src/components/ui/helpers/helper-manager.tsx +273 -14
  168. package/src/components/ui/helpers/item-helper.tsx +28 -32
  169. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  170. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  171. package/src/components/ui/icon-ref.tsx +47 -0
  172. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  173. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  174. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  175. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  176. package/src/components/ui/panels/node-display.ts +17 -17
  177. package/src/components/ui/panels/panel-manager.tsx +36 -14
  178. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  179. package/src/components/ui/panels/parametric-inspector.tsx +19 -4
  180. package/src/components/ui/panels/reference-panel.tsx +54 -19
  181. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  182. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  183. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  184. package/src/components/ui/primitives/sidebar.tsx +1 -0
  185. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  186. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  187. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  188. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +82 -41
  189. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +76 -6
  190. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  191. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  192. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  193. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  194. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  195. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  196. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  197. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  198. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  199. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  200. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -17
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  211. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  215. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  216. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  217. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  218. package/src/components/ui/snap-target-badge.tsx +88 -0
  219. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  220. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  221. package/src/components/viewer-overlay.tsx +36 -596
  222. package/src/components/viewer-zone-system.tsx +6 -1
  223. package/src/components/walkthrough-hud.tsx +111 -0
  224. package/src/hooks/use-auto-save.test.ts +14 -0
  225. package/src/hooks/use-auto-save.ts +54 -7
  226. package/src/hooks/use-ceiling-events.ts +3 -2
  227. package/src/hooks/use-drag-action.ts +4 -1
  228. package/src/hooks/use-grid-events.ts +24 -0
  229. package/src/hooks/use-keyboard.ts +438 -70
  230. package/src/index.tsx +355 -9
  231. package/src/lib/active-placement-surface.test.ts +24 -0
  232. package/src/lib/active-placement-surface.ts +42 -0
  233. package/src/lib/camera-pose.test.ts +237 -0
  234. package/src/lib/camera-pose.ts +172 -0
  235. package/src/lib/ceiling-plan-snap.ts +24 -0
  236. package/src/lib/contextual-help.test.ts +112 -0
  237. package/src/lib/contextual-help.ts +144 -0
  238. package/src/lib/continuation.ts +64 -0
  239. package/src/lib/direct-manipulation.test.ts +275 -0
  240. package/src/lib/direct-manipulation.ts +118 -0
  241. package/src/lib/door-interaction.ts +1 -1
  242. package/src/lib/editor-api.ts +23 -35
  243. package/src/lib/elevation-guides.test.ts +107 -0
  244. package/src/lib/elevation-guides.ts +236 -0
  245. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  246. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  247. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  248. package/src/lib/floorplan/apply-alignment.ts +18 -12
  249. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  250. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  251. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  252. package/src/lib/floorplan/floorplan-export.tsx +978 -0
  253. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  254. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  255. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  256. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  257. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  258. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  259. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  260. package/src/lib/floorplan/geometry.ts +52 -0
  261. package/src/lib/floorplan/index.ts +3 -0
  262. package/src/lib/floorplan/items.ts +5 -2
  263. package/src/lib/floorplan/plan-coords.ts +21 -0
  264. package/src/lib/fresh-planar-placement.test.ts +283 -3
  265. package/src/lib/fresh-planar-placement.ts +85 -1
  266. package/src/lib/glb-export.test.ts +556 -0
  267. package/src/lib/glb-export.ts +1071 -0
  268. package/src/lib/ground-surface.ts +95 -0
  269. package/src/lib/history.test.ts +96 -0
  270. package/src/lib/history.ts +31 -0
  271. package/src/lib/interaction/hot-set.test.ts +133 -0
  272. package/src/lib/interaction/hot-set.ts +67 -0
  273. package/src/lib/interaction/overlay-policy.test.ts +52 -0
  274. package/src/lib/interaction/overlay-policy.ts +59 -0
  275. package/src/lib/interaction/scope.ts +243 -0
  276. package/src/lib/level-duplication.test.ts +2 -1
  277. package/src/lib/level-selection.ts +2 -2
  278. package/src/lib/material-paint.ts +36 -49
  279. package/src/lib/measurement-kind.test.ts +30 -0
  280. package/src/lib/measurement-kind.ts +19 -0
  281. package/src/lib/measurement-label.test.ts +47 -0
  282. package/src/lib/measurement-label.ts +68 -0
  283. package/src/lib/measurement-parser.ts +116 -0
  284. package/src/lib/measurements.test.ts +188 -0
  285. package/src/lib/measurements.ts +210 -0
  286. package/src/lib/paint-scope.test.ts +454 -0
  287. package/src/lib/paint-scope.ts +461 -0
  288. package/src/lib/placement-drag-release.ts +23 -0
  289. package/src/lib/planar-cursor-placement.test.ts +57 -0
  290. package/src/lib/plugin-panels.test.ts +19 -0
  291. package/src/lib/plugin-panels.ts +91 -0
  292. package/src/lib/quick-action-feedback.ts +33 -0
  293. package/src/lib/quick-action-nodes.test.ts +94 -0
  294. package/src/lib/quick-action-nodes.ts +47 -0
  295. package/src/lib/quick-measurement.test.ts +77 -0
  296. package/src/lib/quick-measurement.ts +109 -0
  297. package/src/lib/roof-duplication.ts +6 -6
  298. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  299. package/src/lib/roof-wall-hit.ts +164 -0
  300. package/src/lib/scene-clipboard.test.ts +325 -0
  301. package/src/lib/scene-clipboard.ts +310 -21
  302. package/src/lib/scene.ts +28 -5
  303. package/src/lib/selection-routing.test.ts +313 -0
  304. package/src/lib/selection-routing.ts +180 -0
  305. package/src/lib/sfx/index.ts +1 -0
  306. package/src/lib/sfx/movement-tick.test.ts +65 -0
  307. package/src/lib/sfx/movement-tick.ts +18 -0
  308. package/src/lib/sfx-bus.ts +82 -14
  309. package/src/lib/sfx-player.test.ts +266 -0
  310. package/src/lib/sfx-player.ts +262 -69
  311. package/src/lib/site-boundary.test.ts +53 -0
  312. package/src/lib/site-boundary.ts +27 -0
  313. package/src/lib/slab-plan-snap.test.ts +93 -0
  314. package/src/lib/slab-plan-snap.ts +108 -0
  315. package/src/lib/snapping-mode.test.ts +165 -0
  316. package/src/lib/snapping-mode.ts +191 -0
  317. package/src/lib/stair-duplication.test.ts +103 -0
  318. package/src/lib/stair-duplication.ts +58 -86
  319. package/src/lib/stair-levels.ts +2 -0
  320. package/src/lib/surface-plan-snap.test.ts +71 -0
  321. package/src/lib/surface-plan-snap.ts +256 -0
  322. package/src/lib/terrain-sculpt.test.ts +429 -0
  323. package/src/lib/terrain-sculpt.ts +301 -0
  324. package/src/lib/terrain-verb-color.test.ts +51 -0
  325. package/src/lib/terrain-verb-color.ts +58 -0
  326. package/src/lib/touch-gesture-priority.test.ts +45 -0
  327. package/src/lib/touch-gesture-priority.ts +30 -0
  328. package/src/lib/use-linear-display.ts +40 -0
  329. package/src/lib/window-interaction.ts +1 -1
  330. package/src/lib/world-grid-snap.ts +37 -5
  331. package/src/store/camera-pose-store.test.ts +29 -0
  332. package/src/store/camera-pose-store.ts +27 -0
  333. package/src/store/live-draft-preview-stores.test.ts +136 -0
  334. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  335. package/src/store/navigation-sync-pose-store.ts +27 -0
  336. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  337. package/src/store/use-delete-confirmation.ts +27 -0
  338. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  339. package/src/store/use-drawing-view.test.ts +23 -0
  340. package/src/store/use-drawing-view.ts +84 -0
  341. package/src/store/use-editor.tsx +665 -126
  342. package/src/store/use-elevation-guides.ts +40 -0
  343. package/src/store/use-facing-pose.ts +44 -0
  344. package/src/store/use-fence-curve-draft.ts +51 -0
  345. package/src/store/use-first-person-hud.ts +40 -0
  346. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  347. package/src/store/use-floorplan-draft-preview.ts +151 -0
  348. package/src/store/use-floorplan-marquee.ts +50 -0
  349. package/src/store/use-floorplan-mode.ts +110 -0
  350. package/src/store/use-interaction-scope.test.ts +206 -0
  351. package/src/store/use-interaction-scope.ts +140 -0
  352. package/src/store/use-measurement-draft.test.ts +559 -0
  353. package/src/store/use-measurement-draft.ts +570 -0
  354. package/src/store/use-opening-guides.ts +41 -0
  355. package/src/store/use-path-draft-preview.ts +90 -0
  356. package/src/store/use-placement-preview.ts +11 -3
  357. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  358. package/src/store/use-quick-measurement-hud.ts +77 -0
  359. package/src/store/use-segment-draft-chain.ts +28 -0
  360. package/src/store/use-stair-build-preview.ts +53 -0
  361. package/src/store/use-wall-snap-indicator.ts +2 -0
  362. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  363. package/src/components/editor/group-move-handle.tsx +0 -316
  364. package/src/components/ui/panels/paint-panel.tsx +0 -163
  365. package/src/lib/floorplan/selection-tool.ts +0 -271
  366. package/src/lib/level-name.ts +0 -11
@@ -0,0 +1,1672 @@
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
+ const metricNotation = useViewer((state) => state.metricNotation)
790
+
791
+ useEffect(() => {
792
+ if (active) {
793
+ cancelVertexGesture.current()
794
+ useMeasurementDraft.getState().setKind(kind)
795
+ }
796
+ }, [active, kind])
797
+
798
+ useEffect(() => {
799
+ const draft = useMeasurementDraft.getState()
800
+ if (draft.levelId && draft.levelId !== activeLevelId) {
801
+ cancelVertexGesture.current()
802
+ draft.reset()
803
+ }
804
+ }, [activeLevelId])
805
+
806
+ useEffect(() => {
807
+ const group = groupRef.current
808
+ const svg = group?.ownerSVGElement
809
+ if (!(active && group && svg) || owner === '3d') return
810
+ let projectedGeometry:
811
+ | (ReturnType<typeof collectRegisteredFloorplanSnapGeometry> & {
812
+ proximityAnchors: MeasurementPoint[]
813
+ })
814
+ | null = null
815
+ let projectedGeometryTimestamp = Number.NEGATIVE_INFINITY
816
+
817
+ const getProjectedGeometry = () => {
818
+ const now = performance.now()
819
+ if (!projectedGeometry || now - projectedGeometryTimestamp > 120) {
820
+ const geometry = collectRegisteredFloorplanSnapGeometry(svg)
821
+ const seen = new Set<string>()
822
+ const sceneAnchors = collectAlignmentAnchors(
823
+ useScene.getState().nodes,
824
+ '',
825
+ activeLevelId,
826
+ ).map(({ x, z }) => [x, 0, z] as MeasurementPoint)
827
+ const proximityAnchors = [
828
+ ...sceneAnchors,
829
+ ...geometry.vertices.flatMap((vertex) => {
830
+ const plan = clientToPlanPoint(group, vertex.x, vertex.z)
831
+ if (!plan) return []
832
+ return [[plan.x, 0, plan.z] as MeasurementPoint]
833
+ }),
834
+ ].filter((anchor) => {
835
+ const key = `${Math.round(anchor[0] * 1_000)}:${Math.round(anchor[2] * 1_000)}`
836
+ if (seen.has(key)) return false
837
+ seen.add(key)
838
+ return true
839
+ })
840
+ projectedGeometry = { ...geometry, proximityAnchors }
841
+ projectedGeometryTimestamp = now
842
+ }
843
+ return projectedGeometry
844
+ }
845
+
846
+ const consume = (event: MouseEvent | PointerEvent) => {
847
+ event.preventDefault()
848
+ event.stopPropagation()
849
+ event.stopImmediatePropagation()
850
+ }
851
+
852
+ const closestVertexIndex = (
853
+ event: MouseEvent | PointerEvent,
854
+ points: readonly MeasurementPoint[],
855
+ threshold = VERTEX_HANDLE_DISTANCE_PX,
856
+ ) => {
857
+ let closestIndex: number | null = null
858
+ let closestDistance = threshold
859
+ for (let index = 0; index < points.length; index += 1) {
860
+ const distance = screenDistanceToPlanPoint(
861
+ group,
862
+ points[index]!,
863
+ event.clientX,
864
+ event.clientY,
865
+ )
866
+ if (distance > closestDistance) continue
867
+ closestDistance = distance
868
+ closestIndex = index
869
+ }
870
+ return closestIndex
871
+ }
872
+
873
+ const resolveEventPoint = (
874
+ event: MouseEvent | PointerEvent,
875
+ anchorsOverride?: readonly MeasurementPoint[],
876
+ ) => {
877
+ const plan = clientToPlanPoint(group, event.clientX, event.clientY)
878
+ if (!plan) return null
879
+ const draft = useMeasurementDraft.getState()
880
+ const projectedGeometry = getProjectedGeometry()
881
+ const projectedSnap = resolveProjectedFloorplanSnap(
882
+ { x: event.clientX, z: event.clientY },
883
+ projectedGeometry.vertices,
884
+ projectedGeometry.segments,
885
+ )
886
+ const projectedPlan = projectedSnap
887
+ ? clientToPlanPoint(group, projectedSnap.point.x, projectedSnap.point.z)
888
+ : null
889
+ // Measurement anchors always bind to real geometry — the construction
890
+ // snapping-mode chip doesn't govern this analysis tool. Alt bypasses,
891
+ // including the projected registry-geometry pull.
892
+ const surfaceSnap = resolveSurfacePlanPointSnap({
893
+ rawPoint: [plan.x, plan.z],
894
+ fallbackPoint:
895
+ projectedPlan && !event.altKey ? [projectedPlan.x, projectedPlan.z] : [plan.x, plan.z],
896
+ levelId: activeLevelId,
897
+ align: false,
898
+ magnetic: !event.altKey,
899
+ })
900
+ // A discrete wall snap (corner / midpoint / crossing) is the strongest
901
+ // signal; a locked axis must not pull the point off it.
902
+ const discreteWallSnap =
903
+ surfaceSnap.wallSnap === 'endpoint' ||
904
+ surfaceSnap.wallSnap === 'midpoint' ||
905
+ surfaceSnap.wallSnap === 'intersection'
906
+ const raw: MeasurementPoint = [surfaceSnap.point[0], 0, surfaceSnap.point[1]]
907
+ const targetNodeId = surfaceSnap.wallIds[0] ?? projectedSnap?.nodeId ?? null
908
+ const lastPoint = draft.points.at(-1)
909
+ const resolved = snapPlanPoint(
910
+ group,
911
+ raw,
912
+ anchorsOverride ?? (lastPoint ? [lastPoint] : []),
913
+ event.clientX,
914
+ event.clientY,
915
+ !discreteWallSnap && !event.altKey,
916
+ !event.altKey &&
917
+ !discreteWallSnap &&
918
+ draft.axisGuide?.snapped &&
919
+ draft.axisGuide.axis !== 'y'
920
+ ? draft.axisGuide
921
+ : null,
922
+ projectedGeometry.proximityAnchors,
923
+ )
924
+ if (targetNodeId && resolved.guide?.snapped) {
925
+ const candidate = planPointToClient(group, resolved.point)
926
+ if (
927
+ !candidate ||
928
+ !isProjectedFloorplanAxisPointVerified(
929
+ { x: candidate.x, z: candidate.y },
930
+ targetNodeId,
931
+ projectedGeometry.vertices,
932
+ projectedGeometry.segments,
933
+ )
934
+ ) {
935
+ const surfaceOnly = snapPlanPoint(
936
+ group,
937
+ raw,
938
+ anchorsOverride ?? (lastPoint ? [lastPoint] : []),
939
+ event.clientX,
940
+ event.clientY,
941
+ false,
942
+ null,
943
+ projectedGeometry.proximityAnchors,
944
+ )
945
+ return { ...surfaceOnly, targetNodeId }
946
+ }
947
+ }
948
+ return { ...resolved, targetNodeId }
949
+ }
950
+
951
+ const clearVertexGesture = (finish: boolean) => {
952
+ const gesture = vertexGesture.current
953
+ if (!gesture) return
954
+ if (gesture.engaged) {
955
+ const draft = useMeasurementDraft.getState()
956
+ if (finish) draft.finishVertexDrag('2d')
957
+ else draft.cancelVertexDrag('2d')
958
+ useViewer.getState().setInputDragging(gesture.previousInputDragging)
959
+ }
960
+ document.body.style.cursor = gesture.previousCursor
961
+ if (svg.hasPointerCapture?.(gesture.pointerId)) svg.releasePointerCapture(gesture.pointerId)
962
+ vertexGesture.current = null
963
+ }
964
+ cancelVertexGesture.current = () => clearVertexGesture(false)
965
+
966
+ const onPointerDown = (event: PointerEvent) => {
967
+ const draft = useMeasurementDraft.getState()
968
+ if (event.button !== 0 || (draft.owner && draft.owner !== '2d')) return
969
+ if (isExtrusionControlTarget(event.target)) return
970
+ consume(event)
971
+ if (draft.owner !== '2d' || draft.stage !== 'collecting' || draft.vertexDrag) return
972
+ if (draft.kind === 'distance') return
973
+ const polygon = draft.kind === 'area' || draft.kind === 'perimeter' || draft.kind === 'volume'
974
+ const threshold = event.pointerType === 'touch' ? 22 : VERTEX_HANDLE_DISTANCE_PX
975
+ const vertexIndex = closestVertexIndex(event, draft.points, threshold)
976
+ const midpointIndex =
977
+ vertexIndex === null && polygon
978
+ ? closestVertexIndex(
979
+ event,
980
+ measurementPolygonMidpoints(draft.points).map((midpoint) => midpoint.point),
981
+ event.pointerType === 'touch' ? 18 : MIDPOINT_HANDLE_DISTANCE_PX,
982
+ )
983
+ : null
984
+ if (vertexIndex === null && midpointIndex === null) return
985
+ svg.setPointerCapture?.(event.pointerId)
986
+ vertexGesture.current = {
987
+ pointerId: event.pointerId,
988
+ source: vertexIndex !== null ? 'vertex' : 'midpoint',
989
+ index: vertexIndex ?? midpointIndex!,
990
+ startX: event.clientX,
991
+ startY: event.clientY,
992
+ engaged: false,
993
+ previousInputDragging: useViewer.getState().inputDragging,
994
+ previousCursor: document.body.style.cursor,
995
+ }
996
+ }
997
+
998
+ const onPointerMove = (event: PointerEvent) => {
999
+ const draft = useMeasurementDraft.getState()
1000
+ if (draft.owner && draft.owner !== '2d') return
1001
+
1002
+ const gesture = vertexGesture.current
1003
+ if (gesture?.pointerId === event.pointerId) {
1004
+ consume(event)
1005
+ if (!gesture.engaged) {
1006
+ if (
1007
+ Math.hypot(event.clientX - gesture.startX, event.clientY - gesture.startY) <
1008
+ VERTEX_DRAG_ACTIVATION_PX
1009
+ ) {
1010
+ return
1011
+ }
1012
+ const began =
1013
+ gesture.source === 'vertex'
1014
+ ? draft.beginVertexDrag('2d', gesture.index)
1015
+ : draft.beginMidpointVertexDrag('2d', gesture.index)
1016
+ if (!began) {
1017
+ clearVertexGesture(false)
1018
+ return
1019
+ }
1020
+ if (gesture.source === 'midpoint') gesture.index += 1
1021
+ gesture.engaged = true
1022
+ useViewer.getState().setInputDragging(true)
1023
+ document.body.style.cursor = 'grabbing'
1024
+ }
1025
+
1026
+ const activeDraft = useMeasurementDraft.getState()
1027
+ const anchors = measurementVertexSnapAnchors(
1028
+ activeDraft.points,
1029
+ gesture.index,
1030
+ activeDraft.kind === 'area' ||
1031
+ activeDraft.kind === 'perimeter' ||
1032
+ activeDraft.kind === 'volume',
1033
+ )
1034
+ const resolved = resolveEventPoint(event, anchors)
1035
+ if (resolved) {
1036
+ const associated = associatePlanPoint(
1037
+ resolved.point,
1038
+ resolved.targetNodeId,
1039
+ event.altKey ? SEMANTIC_FEATURE_BYPASS_DISTANCE : SEMANTIC_FEATURE_SNAP_DISTANCE,
1040
+ )
1041
+ activeDraft.updateDraggedVertex(
1042
+ '2d',
1043
+ {
1044
+ point: associated.point,
1045
+ normal: [0, 1, 0],
1046
+ targetNodeId: resolved.targetNodeId,
1047
+ anchor: associated.anchor,
1048
+ semantic: associated.semantic,
1049
+ },
1050
+ resolved.guide,
1051
+ )
1052
+ }
1053
+ return
1054
+ }
1055
+
1056
+ if (draft.stage !== 'collecting') return
1057
+ const resolved = resolveEventPoint(event)
1058
+ const associated = resolved
1059
+ ? associatePlanPoint(
1060
+ resolved.point,
1061
+ resolved.targetNodeId,
1062
+ event.altKey ? SEMANTIC_FEATURE_BYPASS_DISTANCE : SEMANTIC_FEATURE_SNAP_DISTANCE,
1063
+ )
1064
+ : null
1065
+ draft.setHover(
1066
+ '2d',
1067
+ resolved && associated
1068
+ ? {
1069
+ point: associated.point,
1070
+ normal: [0, 1, 0],
1071
+ targetNodeId: resolved.targetNodeId,
1072
+ anchor: associated.anchor,
1073
+ semantic: associated.semantic,
1074
+ }
1075
+ : null,
1076
+ resolved?.guide ?? null,
1077
+ )
1078
+ }
1079
+
1080
+ const onPointerLeave = () => {
1081
+ if (vertexGesture.current) return
1082
+ clearSurfacePlanSnapFeedback()
1083
+ const draft = useMeasurementDraft.getState()
1084
+ if (!draft.owner || draft.owner === '2d') draft.setHover('2d', null)
1085
+ }
1086
+
1087
+ const onPointerUp = (event: PointerEvent) => {
1088
+ const gesture = vertexGesture.current
1089
+ if (!gesture || gesture.pointerId !== event.pointerId) return
1090
+ consume(event)
1091
+ const wasEngaged = gesture.engaged
1092
+ const source = gesture.source
1093
+ const vertexIndex = gesture.index
1094
+ clearVertexGesture(true)
1095
+ if (!wasEngaged && source === 'vertex') {
1096
+ const draft = useMeasurementDraft.getState()
1097
+ if (vertexIndex === 0 && draft.points.length >= 3 && draft.kind !== 'angle') {
1098
+ closeBaseAndMaybeCommit()
1099
+ }
1100
+ }
1101
+ suppressNextClick.current = true
1102
+ setTimeout(() => {
1103
+ suppressNextClick.current = false
1104
+ }, 300)
1105
+ }
1106
+
1107
+ const onPointerCancel = (event: PointerEvent) => {
1108
+ const gesture = vertexGesture.current
1109
+ if (!gesture || gesture.pointerId !== event.pointerId) return
1110
+ consume(event)
1111
+ clearVertexGesture(false)
1112
+ clearSurfacePlanSnapFeedback()
1113
+ }
1114
+
1115
+ const onClick = (event: MouseEvent) => {
1116
+ const draft = useMeasurementDraft.getState()
1117
+ if (event.button !== 0 || (draft.owner && draft.owner !== '2d')) return
1118
+ if (isExtrusionControlTarget(event.target)) return
1119
+ consume(event)
1120
+ if (suppressNextClick.current) {
1121
+ suppressNextClick.current = false
1122
+ return
1123
+ }
1124
+ if (event.detail > 1 || draft.stage !== 'collecting') return
1125
+
1126
+ const vertexIndex = draft.kind === 'distance' ? null : closestVertexIndex(event, draft.points)
1127
+ if (vertexIndex !== null) {
1128
+ if (vertexIndex === 0 && draft.points.length >= 3 && draft.kind !== 'angle') {
1129
+ closeBaseAndMaybeCommit()
1130
+ }
1131
+ return
1132
+ }
1133
+ const resolved = resolveEventPoint(event)
1134
+ if (!resolved) return
1135
+ const associated = associatePlanPoint(
1136
+ resolved.point,
1137
+ resolved.targetNodeId,
1138
+ event.altKey ? SEMANTIC_FEATURE_BYPASS_DISTANCE : SEMANTIC_FEATURE_SNAP_DISTANCE,
1139
+ )
1140
+ if (!draft.addPoint('2d', associated.point, associated.anchor)) return
1141
+ if (useMeasurementDraft.getState().stage === 'ready') commitMeasurementDraft('2d')
1142
+ }
1143
+
1144
+ const onDoubleClick = (event: MouseEvent) => {
1145
+ const draft = useMeasurementDraft.getState()
1146
+ if (draft.owner !== '2d' || draft.stage !== 'collecting') return
1147
+ if (draft.kind === 'distance' || draft.kind === 'angle') return
1148
+ if (isExtrusionControlTarget(event.target)) return
1149
+ consume(event)
1150
+ if (suppressNextClick.current) {
1151
+ suppressNextClick.current = false
1152
+ return
1153
+ }
1154
+ closeBaseAndMaybeCommit()
1155
+ }
1156
+
1157
+ const onBlur = () => {
1158
+ clearVertexGesture(false)
1159
+ clearSurfacePlanSnapFeedback()
1160
+ }
1161
+ const onToolCancel = () => {
1162
+ clearVertexGesture(false)
1163
+ clearSurfacePlanSnapFeedback()
1164
+ }
1165
+
1166
+ emitter.on('tool:cancel', onToolCancel)
1167
+ svg.addEventListener('pointerdown', onPointerDown, true)
1168
+ svg.addEventListener('pointermove', onPointerMove, true)
1169
+ svg.addEventListener('pointerup', onPointerUp, true)
1170
+ svg.addEventListener('pointercancel', onPointerCancel, true)
1171
+ svg.addEventListener('pointerleave', onPointerLeave, true)
1172
+ svg.addEventListener('click', onClick, true)
1173
+ svg.addEventListener('dblclick', onDoubleClick, true)
1174
+ window.addEventListener('blur', onBlur)
1175
+ return () => {
1176
+ cancelVertexGesture.current = () => {}
1177
+ clearVertexGesture(false)
1178
+ clearSurfacePlanSnapFeedback()
1179
+ emitter.off('tool:cancel', onToolCancel)
1180
+ svg.removeEventListener('pointerdown', onPointerDown, true)
1181
+ svg.removeEventListener('pointermove', onPointerMove, true)
1182
+ svg.removeEventListener('pointerup', onPointerUp, true)
1183
+ svg.removeEventListener('pointercancel', onPointerCancel, true)
1184
+ svg.removeEventListener('pointerleave', onPointerLeave, true)
1185
+ svg.removeEventListener('click', onClick, true)
1186
+ svg.removeEventListener('dblclick', onDoubleClick, true)
1187
+ window.removeEventListener('blur', onBlur)
1188
+ }
1189
+ }, [active, activeLevelId, owner])
1190
+
1191
+ const preview = useMemo(() => {
1192
+ const livePoints =
1193
+ stage === 'collecting' && hover && !vertexDrag ? [...points, hover.point] : points
1194
+ const planPoints = livePoints.map((point) => ({ x: point[0], y: point[2] }))
1195
+ const polygonPoints = planPoints.map((point) => `${point.x},${point.y}`).join(' ')
1196
+ const angleArc =
1197
+ kind === 'angle' && livePoints.length >= 3
1198
+ ? buildMeasurementAngleArcPoints(livePoints[0]!, livePoints[1]!, livePoints[2]!)
1199
+ : []
1200
+ const angleArcPoints = angleArc.map((point) => `${point[0]},${point[2]}`).join(' ')
1201
+ const first = planPoints[0]
1202
+ const last = planPoints[planPoints.length - 1]
1203
+ const center = measurementPolygonLabelAnchor(points)
1204
+ const polygon = kind === 'area' || kind === 'perimeter' || kind === 'volume'
1205
+ const midpointHandles =
1206
+ polygon && stage === 'collecting' && !vertexDrag ? measurementPolygonMidpoints(points) : []
1207
+ const activeEdges: Array<[MeasurementPoint, MeasurementPoint]> = vertexDrag
1208
+ ? measurementVertexSnapAnchors(points, vertexDrag.index, polygon).map((neighbor) => [
1209
+ neighbor,
1210
+ points[vertexDrag.index]!,
1211
+ ])
1212
+ : []
1213
+ let label: {
1214
+ angle: number
1215
+ point: MeasurementPoint
1216
+ screenUpright: boolean
1217
+ text: string
1218
+ } | null = null
1219
+ let segmentLabel: { angle: number; point: MeasurementPoint; text: string } | null = null
1220
+
1221
+ if (kind === 'distance' && livePoints.length >= 2) {
1222
+ const start = livePoints[0]!
1223
+ const end = livePoints[livePoints.length - 1]!
1224
+ label = {
1225
+ angle: Math.atan2(end[2] - start[2], end[0] - start[0]),
1226
+ point: [(start[0] + end[0]) / 2, 0, (start[2] + end[2]) / 2],
1227
+ screenUpright: false,
1228
+ text: formatLinearMeasurement(measurementDistance(start, end), unit, metricNotation),
1229
+ }
1230
+ } else if (kind === 'angle' && livePoints.length >= 3) {
1231
+ const anglePoints = livePoints.slice(0, 3) as [
1232
+ MeasurementPoint,
1233
+ MeasurementPoint,
1234
+ MeasurementPoint,
1235
+ ]
1236
+ label = {
1237
+ angle: 0,
1238
+ point: angleArc[Math.floor(angleArc.length / 2)] ?? anglePoints[1],
1239
+ screenUpright: true,
1240
+ text: formatAngleRadians(measurementAngle(...anglePoints)),
1241
+ }
1242
+ } else if ((kind === 'area' || kind === 'perimeter') && livePoints.length >= 3) {
1243
+ const liveCenter = measurementPolygonLabelAnchor(livePoints)
1244
+ if (liveCenter) {
1245
+ label = {
1246
+ angle: 0,
1247
+ point: liveCenter,
1248
+ screenUpright: true,
1249
+ text:
1250
+ kind === 'area'
1251
+ ? `A ${formatAreaLabel(measurementArea(livePoints), unit)}`
1252
+ : `P ${formatLinearMeasurement(measurementPerimeter(livePoints), unit, metricNotation)}`,
1253
+ }
1254
+ }
1255
+ } else if (kind === 'volume' && center && baseNormal) {
1256
+ const extrusion: MeasurementPoint = [
1257
+ baseNormal[0] * extrusionHeight,
1258
+ baseNormal[1] * extrusionHeight,
1259
+ baseNormal[2] * extrusionHeight,
1260
+ ]
1261
+ if (stage !== 'extruding') {
1262
+ label = {
1263
+ angle: 0,
1264
+ point: center,
1265
+ screenUpright: true,
1266
+ text: `V ${formatVolumeLabel(measurementPrismVolume(points, extrusion), unit)}`,
1267
+ }
1268
+ }
1269
+ }
1270
+
1271
+ if (kind !== 'distance' && stage === 'collecting') {
1272
+ const segmentEnd = vertexDrag ? points[vertexDrag.index] : hover?.point
1273
+ const segmentStart = vertexDrag
1274
+ ? (axisGuide?.from ?? points[(vertexDrag.index - 1 + points.length) % points.length])
1275
+ : points.at(-1)
1276
+ if (segmentStart && segmentEnd && measurementDistance(segmentStart, segmentEnd) > 1e-6) {
1277
+ segmentLabel = {
1278
+ angle: Math.atan2(segmentEnd[2] - segmentStart[2], segmentEnd[0] - segmentStart[0]),
1279
+ point: [
1280
+ (segmentStart[0] + segmentEnd[0]) / 2,
1281
+ (segmentStart[1] + segmentEnd[1]) / 2,
1282
+ (segmentStart[2] + segmentEnd[2]) / 2,
1283
+ ],
1284
+ text: formatLinearMeasurement(
1285
+ measurementDistance(segmentStart, segmentEnd),
1286
+ unit,
1287
+ metricNotation,
1288
+ ),
1289
+ }
1290
+ }
1291
+ }
1292
+
1293
+ return {
1294
+ activeEdges,
1295
+ angleArcPoints,
1296
+ center,
1297
+ first,
1298
+ label,
1299
+ last,
1300
+ livePoints,
1301
+ midpointHandles,
1302
+ planPoints,
1303
+ polygonPoints,
1304
+ segmentLabel,
1305
+ }
1306
+ }, [
1307
+ axisGuide,
1308
+ baseNormal,
1309
+ extrusionHeight,
1310
+ hover,
1311
+ kind,
1312
+ metricNotation,
1313
+ points,
1314
+ stage,
1315
+ unit,
1316
+ vertexDrag,
1317
+ ])
1318
+
1319
+ if (smartActive) return <FloorplanQuickMeasureLayer />
1320
+ if (!active || (draftLevelId && draftLevelId !== activeLevelId)) return null
1321
+ const unitsPerPixel = renderContext?.unitsPerPixel ?? 0.01
1322
+ const sceneRotationDeg = renderContext?.sceneRotationDeg ?? 0
1323
+ const anchorRadius = 4.5 * unitsPerPixel
1324
+ const labelFontSize = 12 * unitsPerPixel
1325
+ const reticleRadius = 16 * unitsPerPixel
1326
+ const reticleInnerRadius = 9 * unitsPerPixel
1327
+ const cornerSnap = hover?.semantic?.snapKind === 'endpoint'
1328
+ const reticleColor = axisGuide?.snapped
1329
+ ? axisGuide.axis === 'x'
1330
+ ? X_AXIS_COLOR
1331
+ : axisGuide.axis === 'z'
1332
+ ? Z_AXIS_COLOR
1333
+ : RETICLE_COLOR
1334
+ : hover?.semantic
1335
+ ? '#22c55e'
1336
+ : RETICLE_COLOR
1337
+ const distanceStrokeColor =
1338
+ kind === 'distance' && axisGuide?.snapped
1339
+ ? axisGuide.axis === 'x'
1340
+ ? X_AXIS_COLOR
1341
+ : Z_AXIS_COLOR
1342
+ : DRAFT_COLOR
1343
+ const guideAnchor = vertexDrag && axisGuide ? axisGuide.from : points.at(-1)
1344
+ const guideHalfLength = AXIS_GUIDE_HALF_LENGTH_PX * unitsPerPixel
1345
+ const labelBackground = renderContext?.palette.measurementLabelBackground ?? '#0f172a'
1346
+ const labelText = renderContext?.palette.measurementLabelText ?? '#f8fafc'
1347
+
1348
+ return (
1349
+ <g ref={groupRef}>
1350
+ {guideAnchor ? (
1351
+ <g pointerEvents="none">
1352
+ <line
1353
+ stroke={X_AXIS_COLOR}
1354
+ strokeDasharray={axisGuide?.axis === 'x' && axisGuide.snapped ? undefined : '6 5'}
1355
+ strokeOpacity={axisGuide?.axis === 'x' ? (axisGuide.snapped ? 1 : 0.8) : 0.28}
1356
+ strokeWidth={axisGuide?.axis === 'x' ? (axisGuide.snapped ? 3.5 : 2) : 1}
1357
+ vectorEffect="non-scaling-stroke"
1358
+ x1={guideAnchor[0] - guideHalfLength}
1359
+ x2={guideAnchor[0] + guideHalfLength}
1360
+ y1={guideAnchor[2]}
1361
+ y2={guideAnchor[2]}
1362
+ />
1363
+ <line
1364
+ stroke={Z_AXIS_COLOR}
1365
+ strokeDasharray={axisGuide?.axis === 'z' && axisGuide.snapped ? undefined : '6 5'}
1366
+ strokeOpacity={axisGuide?.axis === 'z' ? (axisGuide.snapped ? 1 : 0.8) : 0.28}
1367
+ strokeWidth={axisGuide?.axis === 'z' ? (axisGuide.snapped ? 3.5 : 2) : 1}
1368
+ vectorEffect="non-scaling-stroke"
1369
+ x1={guideAnchor[0]}
1370
+ x2={guideAnchor[0]}
1371
+ y1={guideAnchor[2] - guideHalfLength}
1372
+ y2={guideAnchor[2] + guideHalfLength}
1373
+ />
1374
+ </g>
1375
+ ) : null}
1376
+ {kind === 'distance' && preview.planPoints.length >= 2 ? (
1377
+ <line
1378
+ stroke={distanceStrokeColor}
1379
+ strokeLinecap="round"
1380
+ strokeWidth={2}
1381
+ vectorEffect="non-scaling-stroke"
1382
+ x1={preview.planPoints[0]!.x}
1383
+ x2={preview.planPoints[preview.planPoints.length - 1]!.x}
1384
+ y1={preview.planPoints[0]!.y}
1385
+ y2={preview.planPoints[preview.planPoints.length - 1]!.y}
1386
+ />
1387
+ ) : null}
1388
+ {kind === 'angle' && preview.planPoints.length >= 2 ? (
1389
+ <>
1390
+ <polyline
1391
+ fill="none"
1392
+ points={preview.polygonPoints}
1393
+ stroke={DRAFT_COLOR}
1394
+ strokeLinecap="round"
1395
+ strokeLinejoin="round"
1396
+ strokeWidth={2}
1397
+ vectorEffect="non-scaling-stroke"
1398
+ />
1399
+ {preview.angleArcPoints ? (
1400
+ <polyline
1401
+ fill="none"
1402
+ points={preview.angleArcPoints}
1403
+ stroke={DRAFT_COLOR}
1404
+ strokeLinecap="round"
1405
+ strokeLinejoin="round"
1406
+ strokeWidth={3}
1407
+ vectorEffect="non-scaling-stroke"
1408
+ />
1409
+ ) : null}
1410
+ </>
1411
+ ) : null}
1412
+ {(kind === 'area' || kind === 'perimeter' || kind === 'volume') &&
1413
+ preview.planPoints.length >= 2 ? (
1414
+ <>
1415
+ {kind !== 'perimeter' && preview.planPoints.length >= 3 ? (
1416
+ <polygon fill={DRAFT_COLOR} fillOpacity={0.12} points={preview.polygonPoints} />
1417
+ ) : null}
1418
+ <polyline
1419
+ fill="none"
1420
+ points={preview.polygonPoints}
1421
+ stroke={DRAFT_COLOR}
1422
+ strokeLinecap="round"
1423
+ strokeLinejoin="round"
1424
+ strokeWidth={2}
1425
+ vectorEffect="non-scaling-stroke"
1426
+ />
1427
+ {preview.first && preview.last ? (
1428
+ <line
1429
+ stroke={DRAFT_COLOR}
1430
+ strokeDasharray="5 4"
1431
+ strokeOpacity={0.65}
1432
+ strokeWidth={1.5}
1433
+ vectorEffect="non-scaling-stroke"
1434
+ x1={preview.last.x}
1435
+ x2={preview.first.x}
1436
+ y1={preview.last.y}
1437
+ y2={preview.first.y}
1438
+ />
1439
+ ) : null}
1440
+ </>
1441
+ ) : null}
1442
+ {preview.activeEdges.map(([start, end], index) => (
1443
+ <line
1444
+ key={`measurement-active-edge-${index}`}
1445
+ stroke="#f8fafc"
1446
+ strokeLinecap="round"
1447
+ strokeOpacity={0.95}
1448
+ strokeWidth={3}
1449
+ vectorEffect="non-scaling-stroke"
1450
+ x1={start[0]}
1451
+ x2={end[0]}
1452
+ y1={start[2]}
1453
+ y2={end[2]}
1454
+ />
1455
+ ))}
1456
+ {axisGuide ? (
1457
+ <g pointerEvents="none">
1458
+ <line
1459
+ stroke="#f8fafc"
1460
+ strokeOpacity={axisGuide.snapped ? 0.8 : 0.35}
1461
+ strokeWidth={4}
1462
+ vectorEffect="non-scaling-stroke"
1463
+ x1={axisGuide.from[0]}
1464
+ x2={axisGuide.to[0]}
1465
+ y1={axisGuide.from[2]}
1466
+ y2={axisGuide.to[2]}
1467
+ />
1468
+ <line
1469
+ stroke={axisGuide.axis === 'x' ? X_AXIS_COLOR : Z_AXIS_COLOR}
1470
+ strokeDasharray={axisGuide.snapped ? undefined : '5 4'}
1471
+ strokeOpacity={axisGuide.snapped ? 1 : 0.72}
1472
+ strokeWidth={axisGuide.snapped ? 2.5 : 1.75}
1473
+ vectorEffect="non-scaling-stroke"
1474
+ x1={axisGuide.from[0]}
1475
+ x2={axisGuide.to[0]}
1476
+ y1={axisGuide.from[2]}
1477
+ y2={axisGuide.to[2]}
1478
+ />
1479
+ <text
1480
+ dominantBaseline="central"
1481
+ fill={axisGuide.axis === 'x' ? X_AXIS_COLOR : Z_AXIS_COLOR}
1482
+ fontSize={labelFontSize}
1483
+ fontWeight="700"
1484
+ textAnchor="middle"
1485
+ transform={`rotate(${-sceneRotationDeg} ${axisGuide.to[0]} ${axisGuide.to[2]})`}
1486
+ x={axisGuide.to[0]}
1487
+ y={axisGuide.to[2] - 24 * unitsPerPixel}
1488
+ >
1489
+ {axisGuide.proximity ? 'ALIGN ' : ''}
1490
+ {axisGuide.axis.toUpperCase()}
1491
+ </text>
1492
+ {axisGuide.snapped ? (
1493
+ <>
1494
+ <circle
1495
+ cx={axisGuide.to[0]}
1496
+ cy={axisGuide.to[2]}
1497
+ fill={labelBackground}
1498
+ r={6 * unitsPerPixel}
1499
+ stroke={axisGuide.axis === 'x' ? X_AXIS_COLOR : Z_AXIS_COLOR}
1500
+ strokeWidth={3}
1501
+ vectorEffect="non-scaling-stroke"
1502
+ />
1503
+ <circle
1504
+ cx={axisGuide.to[0]}
1505
+ cy={axisGuide.to[2]}
1506
+ fill={axisGuide.axis === 'x' ? X_AXIS_COLOR : Z_AXIS_COLOR}
1507
+ r={2 * unitsPerPixel}
1508
+ />
1509
+ </>
1510
+ ) : null}
1511
+ {axisGuide.proximity && axisGuide.snapped ? (
1512
+ <circle
1513
+ cx={axisGuide.from[0]}
1514
+ cy={axisGuide.from[2]}
1515
+ fill={labelBackground}
1516
+ r={5 * unitsPerPixel}
1517
+ stroke={axisGuide.axis === 'x' ? X_AXIS_COLOR : Z_AXIS_COLOR}
1518
+ strokeWidth={2}
1519
+ vectorEffect="non-scaling-stroke"
1520
+ />
1521
+ ) : null}
1522
+ </g>
1523
+ ) : null}
1524
+ {points.map((point, index) => (
1525
+ <circle
1526
+ cx={point[0]}
1527
+ cy={point[2]}
1528
+ fill={vertexDrag?.index === index ? '#f8fafc' : DRAFT_COLOR}
1529
+ key={`measurement-draft-point-${index}`}
1530
+ pointerEvents="none"
1531
+ r={vertexDrag?.index === index ? anchorRadius * 1.2 : anchorRadius}
1532
+ stroke="white"
1533
+ strokeWidth={1}
1534
+ vectorEffect="non-scaling-stroke"
1535
+ />
1536
+ ))}
1537
+ {preview.midpointHandles.map(({ edgeIndex, point }) => (
1538
+ <circle
1539
+ cx={point[0]}
1540
+ cy={point[2]}
1541
+ fill="#a3e635"
1542
+ key={`measurement-draft-midpoint-${edgeIndex}`}
1543
+ pointerEvents="none"
1544
+ r={3 * unitsPerPixel}
1545
+ stroke="white"
1546
+ strokeWidth={1}
1547
+ vectorEffect="non-scaling-stroke"
1548
+ />
1549
+ ))}
1550
+ {hover && hoverOwner === '2d' ? (
1551
+ <g pointerEvents="none">
1552
+ {cornerSnap ? (
1553
+ <circle
1554
+ cx={hover.point[0]}
1555
+ cy={hover.point[2]}
1556
+ fill="none"
1557
+ r={reticleRadius + 6 * unitsPerPixel}
1558
+ stroke="#a3e635"
1559
+ strokeOpacity={0.9}
1560
+ strokeWidth={3}
1561
+ vectorEffect="non-scaling-stroke"
1562
+ />
1563
+ ) : null}
1564
+ <circle
1565
+ cx={hover.point[0]}
1566
+ cy={hover.point[2]}
1567
+ fill="none"
1568
+ r={reticleRadius}
1569
+ stroke={reticleColor}
1570
+ strokeWidth={2}
1571
+ vectorEffect="non-scaling-stroke"
1572
+ />
1573
+ <circle
1574
+ cx={hover.point[0]}
1575
+ cy={hover.point[2]}
1576
+ fill="none"
1577
+ r={reticleInnerRadius}
1578
+ stroke={reticleColor}
1579
+ strokeOpacity={0.72}
1580
+ strokeWidth={1.5}
1581
+ vectorEffect="non-scaling-stroke"
1582
+ />
1583
+ <line
1584
+ stroke={X_AXIS_COLOR}
1585
+ strokeWidth={2}
1586
+ vectorEffect="non-scaling-stroke"
1587
+ x1={hover.point[0] - reticleInnerRadius}
1588
+ x2={hover.point[0] + reticleInnerRadius}
1589
+ y1={hover.point[2]}
1590
+ y2={hover.point[2]}
1591
+ />
1592
+ <line
1593
+ stroke={Z_AXIS_COLOR}
1594
+ strokeWidth={2}
1595
+ vectorEffect="non-scaling-stroke"
1596
+ x1={hover.point[0]}
1597
+ x2={hover.point[0]}
1598
+ y1={hover.point[2] - reticleInnerRadius}
1599
+ y2={hover.point[2] + reticleInnerRadius}
1600
+ />
1601
+ </g>
1602
+ ) : null}
1603
+ {hover && hoverOwner === '2d' && hover.semantic ? (
1604
+ <FloorplanDraftLabel
1605
+ background={labelBackground}
1606
+ border="#22c55e"
1607
+ offsetPx={30}
1608
+ point={hover.point}
1609
+ sceneRotationDeg={sceneRotationDeg}
1610
+ screenUpright
1611
+ text={`${hover.semantic.label}${
1612
+ hover.semantic.length === null
1613
+ ? ''
1614
+ : ` · ${formatLinearMeasurement(hover.semantic.length, unit, metricNotation)}`
1615
+ }`}
1616
+ textColor={labelText}
1617
+ unitsPerPixel={unitsPerPixel}
1618
+ />
1619
+ ) : null}
1620
+ {preview.label ? (
1621
+ <FloorplanDraftLabel
1622
+ angle={preview.label.angle}
1623
+ appearance="outlined"
1624
+ background={labelBackground}
1625
+ border={DRAFT_COLOR}
1626
+ offsetPx={preview.label.screenUpright ? 0 : 14}
1627
+ point={preview.label.point}
1628
+ sceneRotationDeg={sceneRotationDeg}
1629
+ screenUpright={preview.label.screenUpright}
1630
+ text={preview.label.text}
1631
+ textColor={labelText}
1632
+ unitsPerPixel={unitsPerPixel}
1633
+ />
1634
+ ) : null}
1635
+ {preview.segmentLabel ? (
1636
+ <FloorplanDraftLabel
1637
+ angle={preview.segmentLabel.angle}
1638
+ background={labelBackground}
1639
+ border={DRAFT_COLOR}
1640
+ offsetPx={12}
1641
+ point={preview.segmentLabel.point}
1642
+ sceneRotationDeg={sceneRotationDeg}
1643
+ text={preview.segmentLabel.text}
1644
+ textColor={labelText}
1645
+ unitsPerPixel={unitsPerPixel}
1646
+ />
1647
+ ) : null}
1648
+ {error && preview.last ? (
1649
+ <FloorplanDraftLabel
1650
+ background={labelBackground}
1651
+ border="var(--destructive)"
1652
+ offsetPx={-16}
1653
+ point={[preview.last.x, 0, preview.last.y]}
1654
+ sceneRotationDeg={sceneRotationDeg}
1655
+ screenUpright
1656
+ text={error}
1657
+ textColor="var(--destructive)"
1658
+ unitsPerPixel={unitsPerPixel}
1659
+ />
1660
+ ) : null}
1661
+ {owner === '2d' && stage === 'extruding' && preview.center ? (
1662
+ <FloorplanExtrusionControl
1663
+ center={preview.center}
1664
+ sceneRotationDeg={sceneRotationDeg}
1665
+ unitsPerPixel={unitsPerPixel}
1666
+ />
1667
+ ) : null}
1668
+ </g>
1669
+ )
1670
+ }
1671
+
1672
+ export default FloorplanMeasurementToolLayer