@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
@@ -3,12 +3,21 @@ import { SCENE_LAYER } from '@pascal-app/viewer'
3
3
  import { useGLTF } from '@react-three/drei/core/Gltf'
4
4
  import { useFrame } from '@react-three/fiber'
5
5
  import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
6
- import { Color, CylinderGeometry, DoubleSide, type Mesh, type Object3D, RingGeometry } from 'three'
6
+ import {
7
+ Color,
8
+ CylinderGeometry,
9
+ DoubleSide,
10
+ type Group,
11
+ type Mesh,
12
+ type Object3D,
13
+ RingGeometry,
14
+ } from 'three'
7
15
  import { MeshBasicNodeMaterial } from 'three/webgpu'
8
16
  import { EDITOR_LAYER } from '../../../lib/constants'
9
17
  import { sfxEmitter } from '../../../lib/sfx-bus'
10
18
  import { SITE_BOUNDARY_DRAG_LABEL } from '../../../lib/site-boundary'
11
19
  import useEditor, { selectSiteFloorplanContext } from '../../../store/use-editor'
20
+ import useInteractionScope from '../../../store/use-interaction-scope'
12
21
  import {
13
22
  ARROW_COLOR,
14
23
  ARROW_HOVER_COLOR,
@@ -38,6 +47,7 @@ const SITE_FLAG_HALO_COLOR = '#6366f1'
38
47
 
39
48
  type TintableMaterial = {
40
49
  color?: Color
50
+ depthTest: boolean
41
51
  depthWrite: boolean
42
52
  opacity: number
43
53
  needsUpdate: boolean
@@ -69,10 +79,9 @@ function SiteFlagModel({
69
79
  mesh.frustumCulled = false
70
80
  mesh.raycast = NO_RAYCAST
71
81
  mesh.receiveShadow = false
72
- mesh.renderOrder = 1010
73
82
  mesh.material = new MeshBasicNodeMaterial({
74
83
  color: new Color(ARROW_COLOR),
75
- depthTest: false,
84
+ depthTest: true,
76
85
  depthWrite: opacity >= 0.999,
77
86
  opacity,
78
87
  transparent: opacity < 0.999,
@@ -93,6 +102,7 @@ function SiteFlagModel({
93
102
 
94
103
  for (const material of materials as Array<MeshBasicNodeMaterial & TintableMaterial>) {
95
104
  material.color?.copy(color)
105
+ material.depthTest = true
96
106
  material.opacity = opacity
97
107
  material.transparent = opacity < 0.999
98
108
  material.depthWrite = opacity >= 0.999
@@ -217,11 +227,23 @@ function SiteFlagFallback({
217
227
  <group position={[0, SITE_FLAG_BASE_Y + (active ? SITE_FLAG_ACTIVE_LIFT : 0), 0]}>
218
228
  <mesh layers={SCENE_LAYER} position={[0, 0.16, 0]} raycast={NO_RAYCAST}>
219
229
  <cylinderGeometry args={[0.11, 0.16, 0.32, 24]} />
220
- <meshBasicMaterial color={color} depthTest={false} opacity={opacity} transparent />
230
+ <meshBasicMaterial
231
+ color={color}
232
+ depthTest
233
+ depthWrite={opacity >= 0.999}
234
+ opacity={opacity}
235
+ transparent={opacity < 0.999}
236
+ />
221
237
  </mesh>
222
238
  <mesh layers={SCENE_LAYER} position={[0, 0.34, 0]} raycast={NO_RAYCAST}>
223
239
  <cylinderGeometry args={[0.04, 0.11, 0.14, 24]} />
224
- <meshBasicMaterial color={color} depthTest={false} opacity={opacity} transparent />
240
+ <meshBasicMaterial
241
+ color={color}
242
+ depthTest
243
+ depthWrite={opacity >= 0.999}
244
+ opacity={opacity}
245
+ transparent={opacity < 0.999}
246
+ />
225
247
  </mesh>
226
248
  </group>
227
249
  )
@@ -355,14 +377,20 @@ export const SiteBoundaryEditor: React.FC = () => {
355
377
 
356
378
  if (!siteId) return
357
379
 
358
- const editor = useEditor.getState()
359
380
  if (isDragging) {
360
- editor.setActiveHandleDrag({ nodeId: siteId, label: SITE_BOUNDARY_DRAG_LABEL })
361
- } else if (
362
- editor.activeHandleDrag?.nodeId === siteId &&
363
- editor.activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL
364
- ) {
365
- editor.setActiveHandleDrag(null)
381
+ useInteractionScope
382
+ .getState()
383
+ .begin({ kind: 'handle-drag', nodeId: siteId, handle: SITE_BOUNDARY_DRAG_LABEL })
384
+ } else {
385
+ const scope = useInteractionScope.getState().scope
386
+ const activeHandleDrag =
387
+ scope.kind === 'handle-drag' ? { nodeId: scope.nodeId, label: scope.handle } : null
388
+ if (
389
+ activeHandleDrag?.nodeId === siteId &&
390
+ activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL
391
+ ) {
392
+ useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
393
+ }
366
394
  }
367
395
 
368
396
  if (!isDragging) {
@@ -375,12 +403,14 @@ export const SiteBoundaryEditor: React.FC = () => {
375
403
  useEffect(
376
404
  () => () => {
377
405
  if (!siteId) return
378
- const editor = useEditor.getState()
406
+ const scope = useInteractionScope.getState().scope
407
+ const activeHandleDrag =
408
+ scope.kind === 'handle-drag' ? { nodeId: scope.nodeId, label: scope.handle } : null
379
409
  if (
380
- editor.activeHandleDrag?.nodeId === siteId &&
381
- editor.activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL
410
+ activeHandleDrag?.nodeId === siteId &&
411
+ activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL
382
412
  ) {
383
- editor.setActiveHandleDrag(null)
413
+ useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
384
414
  }
385
415
  useLiveNodeOverrides.getState().clearFields(siteId, ['polygon'])
386
416
  isDraggingSiteBoundaryRef.current = false
@@ -394,12 +424,35 @@ export const SiteBoundaryEditor: React.FC = () => {
394
424
  }
395
425
  }, [isSiteEditing, siteId])
396
426
 
427
+ // The flag models render on SCENE_LAYER (scene-depth occlusion), so unlike
428
+ // EDITOR_LAYER affordances the thumbnail camera can't filter them — hide
429
+ // them around captures (preset/snapshot/auto-save thumbnails), same as
430
+ // `handle-arrow.tsx`.
431
+ const handlesRootRef = useRef<Group>(null)
432
+ useEffect(() => {
433
+ const hideForCapture = () => {
434
+ if (handlesRootRef.current) handlesRootRef.current.visible = false
435
+ }
436
+ const restoreAfterCapture = () => {
437
+ if (handlesRootRef.current) handlesRootRef.current.visible = true
438
+ }
439
+ emitter.on('thumbnail:before-capture', hideForCapture)
440
+ emitter.on('thumbnail:after-capture', restoreAfterCapture)
441
+ return () => {
442
+ emitter.off('thumbnail:before-capture', hideForCapture)
443
+ emitter.off('thumbnail:after-capture', restoreAfterCapture)
444
+ }
445
+ }, [])
446
+
397
447
  const activateSiteEditing = useCallback(() => {
398
448
  isDraggingSiteBoundaryRef.current = true
399
449
  setIsDraggingSiteBoundary(true)
400
- if (useEditor.getState().phase !== 'site') {
401
- useEditor.setState({ catalogCategory: null, mode: 'select', phase: 'site', tool: null })
402
- }
450
+ const editor = useEditor.getState()
451
+ // A boundary drag takes ownership away from the terrain brush. Going
452
+ // through setMode releases the sustained sculpting scope before the
453
+ // PolygonEditor begins its handle-drag scope.
454
+ if (editor.mode !== 'select') editor.setMode('select')
455
+ if (editor.phase !== 'site') editor.setPhase('site')
403
456
  selectSiteFloorplanContext()
404
457
  }, [])
405
458
 
@@ -411,10 +464,14 @@ export const SiteBoundaryEditor: React.FC = () => {
411
464
  }, [])
412
465
 
413
466
  useEffect(() => {
414
- if (!isSiteEditing) return
467
+ // Terrain sculpting also lives in the site phase so the 3D flags stay
468
+ // available, but its ordinary terrain clicks belong to the brush. Only
469
+ // select mode treats an empty site-grid click as "leave site editing".
470
+ if (!isSiteEditing || mode !== 'select') return
415
471
 
416
472
  const onGridClick = () => {
417
- if (useEditor.getState().phase !== 'site') return
473
+ const editor = useEditor.getState()
474
+ if (editor.phase !== 'site' || editor.mode !== 'select') return
418
475
  exitSiteEditing()
419
476
  }
420
477
 
@@ -422,7 +479,7 @@ export const SiteBoundaryEditor: React.FC = () => {
422
479
  return () => {
423
480
  emitter.off('grid:click', onGridClick)
424
481
  }
425
- }, [exitSiteEditing, isSiteEditing])
482
+ }, [exitSiteEditing, isSiteEditing, mode])
426
483
 
427
484
  const renderSiteFlagVertex = useCallback(
428
485
  ({
@@ -470,25 +527,27 @@ export const SiteBoundaryEditor: React.FC = () => {
470
527
  if (!showSiteHandles) return null
471
528
 
472
529
  return (
473
- <PolygonEditor
474
- color={isSiteBoundaryHighlighted ? ARROW_COLOR : '#10b981'}
475
- minVertices={3}
476
- onBeforeVertexDrag={activateSiteEditing}
477
- onDragCommit={() => {
478
- sfxEmitter.emit('sfx:item-place')
479
- exitSiteEditing()
480
- }}
481
- onDragStart={() => sfxEmitter.emit('sfx:item-pick')}
482
- onDragStateChange={handleSiteBoundaryDragChange}
483
- onMidpointHoverChange={setHoveredMidpoint}
484
- onPolygonChange={handlePolygonChange}
485
- onPolygonPreview={handlePolygonPreview}
486
- onVertexHoverChange={setHoveredVertex}
487
- polygon={displayPolygon}
488
- renderMidpointHandle={renderSiteFlagMidpoint}
489
- renderVertexHandle={renderSiteFlagVertex}
490
- showBorderLine={isSiteBoundaryHighlighted}
491
- showMidpointHandles={showSiteHandles}
492
- />
530
+ <group ref={handlesRootRef}>
531
+ <PolygonEditor
532
+ color={isSiteBoundaryHighlighted ? ARROW_COLOR : '#10b981'}
533
+ minVertices={3}
534
+ onBeforeVertexDrag={activateSiteEditing}
535
+ onDragCommit={() => {
536
+ sfxEmitter.emit('sfx:item-place')
537
+ exitSiteEditing()
538
+ }}
539
+ onDragStart={() => sfxEmitter.emit('sfx:item-pick')}
540
+ onDragStateChange={handleSiteBoundaryDragChange}
541
+ onMidpointHoverChange={setHoveredMidpoint}
542
+ onPolygonChange={handlePolygonChange}
543
+ onPolygonPreview={handlePolygonPreview}
544
+ onVertexHoverChange={setHoveredVertex}
545
+ polygon={displayPolygon}
546
+ renderMidpointHandle={renderSiteFlagMidpoint}
547
+ renderVertexHandle={renderSiteFlagVertex}
548
+ showBorderLine={isSiteBoundaryHighlighted}
549
+ showMidpointHandles={showSiteHandles}
550
+ />
551
+ </group>
493
552
  )
494
553
  }
@@ -0,0 +1,91 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ type StrokePointerAction,
4
+ type StrokePointerEvent,
5
+ strokePointerAction,
6
+ } from './stroke-pointer-ownership'
7
+
8
+ /**
9
+ * A stroke carries uncommitted state, so every one of these has a silent failure
10
+ * mode rather than a visible one — which is why the matrix is asserted rather
11
+ * than left to the four call sites.
12
+ */
13
+ describe('strokePointerAction', () => {
14
+ const OWNER = 1
15
+ const OTHER = 2
16
+ const act = (event: StrokePointerEvent, pointerId: number, ownerPointerId: number | null) =>
17
+ strokePointerAction({ event, pointerId, ownerPointerId })
18
+
19
+ describe('with no stroke in flight', () => {
20
+ test('a down starts one', () => {
21
+ expect(act('down', OWNER, null)).toBe('begin')
22
+ })
23
+
24
+ test('every other event is ignored', () => {
25
+ // Notably `up`: the pointerup of a click that never began a stroke (over the
26
+ // eyedropper, or off the field) must not commit anything.
27
+ for (const event of ['move', 'up', 'cancel'] as StrokePointerEvent[]) {
28
+ expect(act(event, OWNER, null)).toBe('ignore')
29
+ }
30
+ })
31
+ })
32
+
33
+ describe('the owning pointer drives the stroke', () => {
34
+ test('move paints, up commits', () => {
35
+ expect(act('move', OWNER, OWNER)).toBe('apply')
36
+ expect(act('up', OWNER, OWNER)).toBe('commit')
37
+ })
38
+
39
+ test('cancel abandons where up commits', () => {
40
+ // The browser fires `cancel` when it takes the gesture away, so the user
41
+ // never finished the stroke. These two must not be the same handler — they
42
+ // were, and a cancelled drag persisted.
43
+ expect(act('cancel', OWNER, OWNER)).toBe('abandon')
44
+ expect(act('cancel', OWNER, OWNER)).not.toBe(act('up', OWNER, OWNER))
45
+ })
46
+ })
47
+
48
+ describe('a second pointer', () => {
49
+ test('down abandons the stroke in flight', () => {
50
+ // Two fingers is a pinch-zoom. Abandoning restores the ground exactly,
51
+ // because nothing was committed yet.
52
+ expect(act('down', OTHER, OWNER)).toBe('abandon')
53
+ })
54
+
55
+ test('never touches the owner’s stroke any other way', () => {
56
+ for (const event of ['move', 'up', 'cancel'] as StrokePointerEvent[]) {
57
+ expect(act(event, OTHER, OWNER)).toBe('ignore')
58
+ }
59
+ })
60
+
61
+ test('lifting it does not commit — the bug this replaced', () => {
62
+ // The old handler keyed off `strokeRef` alone, so a resting finger lifting
63
+ // committed a stroke the other finger was still drawing.
64
+ expect(act('up', OTHER, OWNER)).not.toBe('commit')
65
+ })
66
+ })
67
+
68
+ test('only a down can begin, and only an owning up can commit', () => {
69
+ // Swept over the whole matrix rather than spot-checked: these are the two
70
+ // actions that write to the scene, so anything else reaching them is a bug.
71
+ const events: StrokePointerEvent[] = ['down', 'move', 'up', 'cancel']
72
+ const owners: Array<number | null> = [null, OWNER]
73
+ const seen: Array<[StrokePointerEvent, number, number | null, StrokePointerAction]> = []
74
+ for (const event of events) {
75
+ for (const pointerId of [OWNER, OTHER]) {
76
+ for (const ownerPointerId of owners) {
77
+ seen.push([event, pointerId, ownerPointerId, act(event, pointerId, ownerPointerId)])
78
+ }
79
+ }
80
+ }
81
+ expect(seen.filter(([, , , action]) => action === 'begin').map(([event]) => event)).toEqual([
82
+ 'down',
83
+ 'down',
84
+ ])
85
+ expect(
86
+ seen
87
+ .filter(([, , , action]) => action === 'commit')
88
+ .map(([event, pointerId, ownerPointerId]) => [event, pointerId === ownerPointerId]),
89
+ ).toEqual([['up', true]])
90
+ })
91
+ })
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Which pointer a terrain stroke belongs to, and what every other one does.
3
+ *
4
+ * A stroke is a sustained drag with uncommitted state, so "who owns the pointer"
5
+ * is a real decision rather than plumbing — and each wrong answer fails silently
6
+ * in its own way. The tool used to have no answer at all: every branch below fell
7
+ * through to the owning-pointer path.
8
+ *
9
+ * Extracted as a pure function because the interesting part is the *matrix*, which
10
+ * cannot be asserted through the component — reproducing it needs a WebGL canvas,
11
+ * a camera, and synthetic multi-touch. The tool keeps the DOM plumbing.
12
+ */
13
+ export type StrokePointerAction =
14
+ /** Start a stroke owned by this pointer. */
15
+ | 'begin'
16
+ /** Route the event into the active stroke. */
17
+ | 'apply'
18
+ /** Drop the stroke, restoring the pre-stroke ground. */
19
+ | 'abandon'
20
+ /** Finish and persist the stroke. */
21
+ | 'commit'
22
+ /** Not our pointer, or nothing in flight. */
23
+ | 'ignore'
24
+
25
+ export type StrokePointerEvent = 'down' | 'move' | 'up' | 'cancel'
26
+
27
+ /**
28
+ * `ownerPointerId` is `null` when no stroke is in flight.
29
+ *
30
+ * The rules, in the order they matter:
31
+ *
32
+ * - **A second `down` abandons.** Two fingers on the glass is a pinch-zoom, and by
33
+ * then the first finger has already deposited a dab; abandoning is what makes
34
+ * "pinch to look closer" leave no accidental bump. Falling through was the worst
35
+ * of the options: it orphaned an uncommitted stroke whose dabs were already
36
+ * baked into the live field's snapshot, so they survived on screen while being
37
+ * unreachable by undo.
38
+ * - **A non-owning `up` is ignored, not a commit.** Lifting a resting second finger
39
+ * would otherwise persist a stroke the other finger is still drawing.
40
+ * - **`cancel` abandons where `up` commits.** The browser fires `cancel` when it
41
+ * takes the gesture away (OS edge swipe, palm rejection), meaning the user never
42
+ * finished — committing there persists a half-drag nobody released.
43
+ */
44
+ export function strokePointerAction(args: {
45
+ event: StrokePointerEvent
46
+ pointerId: number
47
+ ownerPointerId: number | null
48
+ }): StrokePointerAction {
49
+ const { event, pointerId, ownerPointerId } = args
50
+
51
+ if (event === 'down') {
52
+ return ownerPointerId === null ? 'begin' : 'abandon'
53
+ }
54
+ if (ownerPointerId === null || pointerId !== ownerPointerId) return 'ignore'
55
+ if (event === 'move') return 'apply'
56
+ return event === 'cancel' ? 'abandon' : 'commit'
57
+ }
@@ -0,0 +1,247 @@
1
+ 'use client'
2
+
3
+ import {
4
+ minBrushRadius,
5
+ raycastTerrain,
6
+ type SiteNode,
7
+ surfaceHeightAt,
8
+ type TerrainField,
9
+ terrainFieldOf,
10
+ useLiveTerrain,
11
+ } from '@pascal-app/core'
12
+ import { useViewer } from '@pascal-app/viewer'
13
+ import { Html } from '@react-three/drei'
14
+ import { useFrame, useThree } from '@react-three/fiber'
15
+ import { type MutableRefObject, useEffect, useMemo, useRef } from 'react'
16
+ import {
17
+ BufferGeometry,
18
+ Float32BufferAttribute,
19
+ type Group,
20
+ type Line,
21
+ Raycaster,
22
+ Vector2,
23
+ } from 'three'
24
+ import { EDITOR_LAYER } from '../../../lib/constants'
25
+ import { sculptFieldForSite, terrainPointInsideSite } from '../../../lib/terrain-sculpt'
26
+ import { brushRingColor } from '../../../lib/terrain-verb-color'
27
+ import useEditor from '../../../store/use-editor'
28
+ import { formatMeasurement } from '../../editor/measurement-pill'
29
+
30
+ /** Segments in the cursor ring. 64 reads as a circle at any usable brush size. */
31
+ const RING_SEGMENTS = 64
32
+
33
+ /**
34
+ * Lift above the surface, so the ring is not z-fighting the ground it traces.
35
+ *
36
+ * Two centimetres only works because the ring samples `surfaceHeightAt` — the
37
+ * plane of the triangle under each point — and not the bilinear `heightAt`. Those
38
+ * differ by up to a quarter of a cell's warp, which on steep ground is decimetres,
39
+ * so a bilinear ring would submerge on half of every cell no matter how large this
40
+ * gets. `depthTest={false}` hides that on screen but not in the exported GLB.
41
+ */
42
+ const RING_LIFT = 0.02
43
+
44
+ /** The ring is decoration; it must never intercept a pointer ray. */
45
+ const NO_RAYCAST = () => null
46
+
47
+ export type TerrainBrushFocus = {
48
+ radius: number
49
+ x: number
50
+ z: number
51
+ }
52
+
53
+ /**
54
+ * The brush ring, drawn *on the terrain surface* rather than as a flat disc.
55
+ *
56
+ * Following the surface is the whole point: a flat ring on sloped ground tells
57
+ * the user nothing about what the brush will touch, because the footprint is a
58
+ * cylinder in XZ and the interesting question is which part of the slope falls
59
+ * inside it. Sampling the ring's height per segment answers that directly.
60
+ *
61
+ * Updated in `useFrame` from a ref, never through React state — the ring tracks
62
+ * the pointer, and routing that through a re-render would re-run every hook in
63
+ * the tool subtree at pointer rate.
64
+ *
65
+ * On `EDITOR_LAYER`, non-negotiably: an overlay mesh left on the scene layer
66
+ * poisons the MRT scene pass and makes FrontSide geometry render see-through.
67
+ */
68
+ export const TerrainBrushCursor: React.FC<{
69
+ focusRef: MutableRefObject<TerrainBrushFocus | null>
70
+ site: SiteNode
71
+ }> = ({ focusRef, site }) => {
72
+ const { camera, gl } = useThree()
73
+ const radius = useEditor((state) => state.terrainBrush.radius)
74
+ const shape = useEditor((state) => state.terrainBrush.shape)
75
+ // The ring is the only thing at the point of action that can say what the next
76
+ // drag will do — its geometry is identical for all four verbs, so colour is the
77
+ // one channel left. Keep the mapping centralized in `lib/terrain-verb-color`.
78
+ const verb = useEditor((state) => state.terrainVerb)
79
+ const sampling = useEditor((state) => state.terrainSampling)
80
+ const color = brushRingColor(verb, sampling)
81
+ const unit = useViewer((state) => state.unit)
82
+
83
+ const lineRef = useRef<Line>(null)
84
+ const centerRef = useRef<Group>(null)
85
+ const heightRef = useRef<HTMLSpanElement>(null)
86
+ const lastHeightLabelRef = useRef('')
87
+ const pointerRef = useRef<{ x: number; y: number } | null>(null)
88
+ const raycaster = useRef(new Raycaster())
89
+ const ndc = useRef(new Vector2())
90
+
91
+ const geometry = useMemo(() => {
92
+ const positions = new Float32Array((RING_SEGMENTS + 1) * 3)
93
+ const buffer = new BufferGeometry()
94
+ buffer.setAttribute('position', new Float32BufferAttribute(positions, 3))
95
+ return buffer
96
+ }, [])
97
+ useEffect(() => () => geometry.dispose(), [geometry])
98
+
99
+ useEffect(() => {
100
+ const canvas = gl.domElement
101
+ const track = (event: PointerEvent) => {
102
+ const rect = canvas.getBoundingClientRect()
103
+ pointerRef.current = {
104
+ x: ((event.clientX - rect.left) / rect.width) * 2 - 1,
105
+ y: -((event.clientY - rect.top) / rect.height) * 2 + 1,
106
+ }
107
+ }
108
+ const freeze = () => {
109
+ pointerRef.current = null
110
+ }
111
+ canvas.addEventListener('pointermove', track)
112
+ canvas.addEventListener('pointerleave', freeze)
113
+ return () => {
114
+ canvas.removeEventListener('pointermove', track)
115
+ canvas.removeEventListener('pointerleave', freeze)
116
+ }
117
+ }, [gl])
118
+
119
+ useFrame(() => {
120
+ const line = lineRef.current
121
+ const center = centerRef.current
122
+ const pointer = pointerRef.current
123
+ if (!(line && center)) return
124
+ if (!(pointer || focusRef.current)) {
125
+ line.visible = false
126
+ center.visible = false
127
+ if (heightRef.current) heightRef.current.style.display = 'none'
128
+ return
129
+ }
130
+
131
+ // Two fields, deliberately. Aiming uses the stroke's *snapshot* so the ring
132
+ // sits exactly where the next dab will land — the tool raycasts the snapshot
133
+ // too, and a cursor that disagreed with the brush by even a few centimetres
134
+ // would read as lag. Drawing uses the live field so the ring drapes over the
135
+ // hill as it rises. Outside a stroke the two are the same field.
136
+ const stroke = useLiveTerrain.getState().strokeOf(site.id)
137
+ const surface: TerrainField = stroke?.field ?? terrainFieldOf(site) ?? sculptFieldForSite(site)
138
+ const aim: TerrainField = stroke?.snapshot ?? surface
139
+
140
+ let hit: { x: number; z: number } | null = null
141
+ if (pointer) {
142
+ ndc.current.set(pointer.x, pointer.y)
143
+ raycaster.current.setFromCamera(ndc.current, camera)
144
+ const { origin, direction } = raycaster.current.ray
145
+ hit = raycastTerrain(
146
+ aim,
147
+ [origin.x, origin.y, origin.z],
148
+ [direction.x, direction.y, direction.z],
149
+ )
150
+ if (hit && terrainPointInsideSite(site, hit.x, hit.z)) {
151
+ focusRef.current = { radius: Math.max(radius, minBrushRadius(aim)), x: hit.x, z: hit.z }
152
+ } else {
153
+ hit = null
154
+ focusRef.current = null
155
+ }
156
+ } else if (focusRef.current) {
157
+ hit = terrainPointInsideSite(site, focusRef.current.x, focusRef.current.z)
158
+ ? focusRef.current
159
+ : null
160
+ }
161
+ if (!hit) {
162
+ line.visible = false
163
+ center.visible = false
164
+ if (heightRef.current) heightRef.current.style.display = 'none'
165
+ return
166
+ }
167
+ line.visible = true
168
+ center.visible = true
169
+
170
+ const attribute = line.geometry.getAttribute('position')
171
+ const array = attribute.array as Float32Array
172
+ // The same floor the tool applies at pointer-down. Drawing the stored radius
173
+ // instead would draw a ring smaller than the footprint the next dab actually
174
+ // gets — on a coarse lot the ring would be the one thing insisting the brush
175
+ // is 2 m wide while the stroke is 1.5 spacings wide.
176
+ const drawn = Math.max(radius, minBrushRadius(aim))
177
+ focusRef.current = { radius: drawn, x: hit.x, z: hit.z }
178
+ for (let index = 0; index <= RING_SEGMENTS; index++) {
179
+ const angle = (index / RING_SEGMENTS) * Math.PI * 2
180
+ // The square brush uses Chebyshev distance, so its rim is a square: scale
181
+ // the unit circle out to the box so the ring shows the actual footprint
182
+ // rather than an inscribed circle that under-reports it.
183
+ const cos = Math.cos(angle)
184
+ const sin = Math.sin(angle)
185
+ const scale =
186
+ shape === 'square' ? 1 / Math.max(Math.abs(cos), Math.abs(sin), Number.EPSILON) : 1
187
+ const x = hit.x + cos * drawn * scale
188
+ const z = hit.z + sin * drawn * scale
189
+ array[index * 3] = x
190
+ array[index * 3 + 1] = surfaceHeightAt(surface, x, z) + RING_LIFT
191
+ array[index * 3 + 2] = z
192
+ }
193
+ attribute.needsUpdate = true
194
+ line.geometry.computeBoundingSphere()
195
+
196
+ const centerHeight = surfaceHeightAt(surface, hit.x, hit.z)
197
+ center.position.set(hit.x, centerHeight + RING_LIFT, hit.z)
198
+ const heightLabel = `H ${formatMeasurement(centerHeight, unit)}`
199
+ if (heightRef.current) {
200
+ heightRef.current.style.display = ''
201
+ if (heightLabel !== lastHeightLabelRef.current) {
202
+ heightRef.current.textContent = heightLabel
203
+ lastHeightLabelRef.current = heightLabel
204
+ }
205
+ }
206
+ })
207
+
208
+ return (
209
+ <>
210
+ <line
211
+ frustumCulled={false}
212
+ geometry={geometry}
213
+ layers={EDITOR_LAYER}
214
+ raycast={NO_RAYCAST}
215
+ // @ts-expect-error R3F <line> element conflicts with SVG <line> type
216
+ ref={lineRef}
217
+ renderOrder={13}
218
+ >
219
+ <lineBasicNodeMaterial color={color} depthTest={false} depthWrite={false} linewidth={2} />
220
+ </line>
221
+ <group ref={centerRef} visible={false}>
222
+ <mesh
223
+ layers={EDITOR_LAYER}
224
+ raycast={NO_RAYCAST}
225
+ renderOrder={14}
226
+ rotation={[-Math.PI / 2, 0, 0]}
227
+ >
228
+ <circleGeometry args={[0.055, 24]} />
229
+ <meshBasicMaterial color={color} depthTest={false} depthWrite={false} />
230
+ </mesh>
231
+ <Html
232
+ center
233
+ position={[0, 0.18, 0]}
234
+ style={{ pointerEvents: 'none', userSelect: 'none' }}
235
+ zIndexRange={[20, 0]}
236
+ >
237
+ <span
238
+ className="whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-2 py-1 font-medium font-mono text-foreground text-xs shadow-sm backdrop-blur"
239
+ ref={heightRef}
240
+ />
241
+ </Html>
242
+ </group>
243
+ </>
244
+ )
245
+ }
246
+
247
+ export default TerrainBrushCursor