@pascal-app/editor 0.8.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (350) hide show
  1. package/package.json +15 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +168 -0
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +795 -28
  5. package/src/components/editor/editor-layout-mobile.tsx +8 -3
  6. package/src/components/editor/editor-layout-v2.tsx +76 -51
  7. package/src/components/editor/export-manager.tsx +69 -32
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +182 -0
  10. package/src/components/editor/first-person/build-collider-world.ts +228 -83
  11. package/src/components/editor/first-person-controls.tsx +943 -27
  12. package/src/components/editor/floating-action-menu.tsx +676 -208
  13. package/src/components/editor/floating-building-action-menu.tsx +2 -2
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +6916 -12137
  17. package/src/components/editor/grid.tsx +197 -36
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +420 -0
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +396 -0
  24. package/src/components/editor/group-transform-shared.ts +489 -0
  25. package/src/components/editor/handles/handle-arrow.tsx +581 -0
  26. package/src/components/editor/handles/use-handle-drag.ts +246 -0
  27. package/src/components/editor/index.tsx +412 -162
  28. package/src/components/editor/measurement-pill.tsx +115 -0
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +1646 -0
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +1106 -483
  36. package/src/components/editor/site-edge-labels.tsx +45 -21
  37. package/src/components/editor/slab-hole-highlights.tsx +474 -0
  38. package/src/components/editor/snapshot-capture-overlay.tsx +369 -155
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +140 -41
  41. package/src/components/editor/use-floorplan-background-placement.ts +190 -35
  42. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  43. package/src/components/editor/use-floorplan-scene-data.ts +19 -7
  44. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  45. package/src/components/editor/wall-measurement-label.tsx +14 -19
  46. package/src/components/editor/wall-move-side-handles.tsx +870 -0
  47. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  48. package/src/components/editor/wall-snap-beacon-layer.tsx +304 -0
  49. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +15 -6
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  52. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  53. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  54. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  56. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +392 -0
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +857 -0
  59. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  60. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +77 -0
  61. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  62. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  63. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  64. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  65. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  66. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +62 -0
  67. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  68. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +3325 -0
  69. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  70. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  71. package/src/components/editor-2d/svg-paths.ts +10 -2
  72. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +439 -57
  73. package/src/components/systems/ceiling/ceiling-system.tsx +109 -12
  74. package/src/components/systems/roof/roof-edit-system.tsx +1851 -22
  75. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  76. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  77. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  78. package/src/components/systems/zone/zone-system.tsx +42 -13
  79. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  80. package/src/components/tools/elevator/elevator-tool.tsx +375 -0
  81. package/src/components/tools/elevator/move-elevator-tool.tsx +263 -0
  82. package/src/components/tools/fence/fence-drafting.ts +141 -27
  83. package/src/components/tools/item/move-tool.tsx +45 -105
  84. package/src/components/tools/item/placement-math.test.ts +22 -0
  85. package/src/components/tools/item/placement-math.ts +59 -7
  86. package/src/components/tools/item/placement-strategies.ts +432 -16
  87. package/src/components/tools/item/placement-types.ts +22 -1
  88. package/src/components/tools/item/use-draft-node.ts +51 -2
  89. package/src/components/tools/item/use-placement-coordinator.tsx +1399 -256
  90. package/src/components/tools/registry/move-registry-node-tool.tsx +1064 -0
  91. package/src/components/tools/roof/roof-tool.tsx +350 -28
  92. package/src/components/tools/select/box-select-state.ts +88 -0
  93. package/src/components/tools/select/box-select-tool.tsx +442 -520
  94. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  95. package/src/components/tools/select/marquee-geometry.ts +155 -0
  96. package/src/components/tools/select/plane-box-select-tool.tsx +570 -0
  97. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  98. package/src/components/tools/select/select-candidates.test.ts +109 -0
  99. package/src/components/tools/select/select-candidates.ts +85 -0
  100. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  101. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  102. package/src/components/tools/shared/drag-bounding-box.tsx +195 -0
  103. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  104. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  105. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  106. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  107. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  108. package/src/components/tools/shared/placement-box.tsx +305 -0
  109. package/src/components/tools/shared/polygon-editor.tsx +866 -175
  110. package/src/components/tools/shared/segment-angle.ts +56 -0
  111. package/src/components/tools/site/site-boundary-editor.tsx +520 -16
  112. package/src/components/tools/site/site-flag-model.ts +18 -0
  113. package/src/components/tools/stair/stair-defaults.ts +2 -1
  114. package/src/components/tools/stair/stair-tool.tsx +342 -37
  115. package/src/components/tools/tool-manager.tsx +239 -62
  116. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  117. package/src/components/tools/wall/wall-drafting.ts +248 -171
  118. package/src/components/tools/wall/wall-snap-geometry.test.ts +123 -0
  119. package/src/components/tools/wall/wall-snap-geometry.ts +331 -0
  120. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  121. package/src/components/tools/zone/zone-tool.tsx +86 -82
  122. package/src/components/ui/action-menu/action-button.tsx +21 -1
  123. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  124. package/src/components/ui/action-menu/control-modes.tsx +40 -170
  125. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  126. package/src/components/ui/action-menu/index.tsx +3 -117
  127. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  128. package/src/components/ui/action-menu/structure-tools.tsx +27 -93
  129. package/src/components/ui/action-menu/view-toggles.tsx +279 -90
  130. package/src/components/ui/command-palette/editor-commands.tsx +20 -15
  131. package/src/components/ui/command-palette/index.tsx +5 -3
  132. package/src/components/ui/controls/material-paint-panel.tsx +152 -0
  133. package/src/components/ui/controls/material-picker.tsx +87 -177
  134. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  135. package/src/components/ui/controls/metric-control.tsx +142 -46
  136. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  137. package/src/components/ui/controls/slider-control.tsx +88 -38
  138. package/src/components/ui/floating-level-selector.tsx +47 -10
  139. package/src/components/ui/helpers/building-helper.tsx +10 -23
  140. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  141. package/src/components/ui/helpers/helper-manager.tsx +235 -23
  142. package/src/components/ui/helpers/item-helper.tsx +28 -32
  143. package/src/components/ui/helpers/registered-tool-helper.tsx +59 -0
  144. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  145. package/src/components/ui/icon-ref.tsx +47 -0
  146. package/src/components/ui/item-catalog/catalog-items.tsx +40 -1
  147. package/src/components/ui/item-catalog/item-catalog.tsx +16 -36
  148. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  149. package/src/components/ui/panels/node-display.ts +17 -16
  150. package/src/components/ui/panels/panel-manager.tsx +55 -58
  151. package/src/components/ui/panels/panel-wrapper.tsx +238 -12
  152. package/src/components/ui/panels/parametric-inspector.tsx +482 -0
  153. package/src/components/ui/panels/reference-panel.tsx +54 -19
  154. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  155. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  156. package/src/components/ui/primitives/sidebar.tsx +1 -0
  157. package/src/components/ui/scene-loader.tsx +1 -3
  158. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  159. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  160. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  161. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  162. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  163. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +122 -41
  164. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +66 -7
  165. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +267 -0
  166. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  167. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +9 -5
  168. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +86 -0
  169. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  170. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  171. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +92 -0
  172. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  173. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +3 -3
  174. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +86 -0
  175. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -21
  176. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  177. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  178. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +140 -0
  179. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +48 -8
  180. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +132 -0
  181. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -5
  182. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +86 -0
  183. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  184. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  185. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +112 -39
  186. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  187. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  188. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  189. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  190. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  191. package/src/components/ui/sidebar/tab-bar.tsx +75 -1
  192. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  193. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  194. package/src/components/ui/snap-target-badge.tsx +88 -0
  195. package/src/components/viewer-overlay.tsx +253 -61
  196. package/src/components/viewer-zone-system.tsx +7 -2
  197. package/src/hooks/use-auto-save.test.ts +14 -0
  198. package/src/hooks/use-auto-save.ts +64 -10
  199. package/src/hooks/use-ceiling-events.ts +176 -0
  200. package/src/hooks/use-drag-action.ts +127 -0
  201. package/src/hooks/use-keyboard.ts +493 -95
  202. package/src/hooks/use-selection.ts +64 -0
  203. package/src/index.tsx +451 -6
  204. package/src/lib/active-placement-surface.ts +35 -0
  205. package/src/lib/ceiling-plan-snap.ts +24 -0
  206. package/src/lib/constants.ts +6 -2
  207. package/src/lib/contextual-help.test.ts +112 -0
  208. package/src/lib/contextual-help.ts +144 -0
  209. package/src/lib/continuation.ts +64 -0
  210. package/src/lib/direct-manipulation.test.ts +192 -0
  211. package/src/lib/direct-manipulation.ts +109 -0
  212. package/src/lib/editor-api.ts +50 -0
  213. package/src/lib/elevator-support.ts +96 -0
  214. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  215. package/src/lib/floorplan/apply-alignment.ts +117 -0
  216. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  217. package/src/lib/floorplan/geometry.ts +53 -0
  218. package/src/lib/floorplan/index.ts +10 -0
  219. package/src/lib/floorplan/items.ts +5 -2
  220. package/src/lib/floorplan/plan-coords.ts +21 -0
  221. package/src/lib/floorplan/selection-tool.ts +40 -0
  222. package/src/lib/floorplan/stairs.ts +12 -1
  223. package/src/lib/fresh-planar-placement.test.ts +339 -0
  224. package/src/lib/fresh-planar-placement.ts +130 -0
  225. package/src/lib/glb-export.test.ts +441 -0
  226. package/src/lib/glb-export.ts +874 -0
  227. package/src/lib/history.ts +11 -1
  228. package/src/lib/interaction/hot-set.test.ts +133 -0
  229. package/src/lib/interaction/hot-set.ts +67 -0
  230. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  231. package/src/lib/interaction/overlay-policy.ts +59 -0
  232. package/src/lib/interaction/scope.ts +211 -0
  233. package/src/lib/level-duplication.ts +54 -42
  234. package/src/lib/level-selection.ts +2 -2
  235. package/src/lib/material-paint.ts +227 -48
  236. package/src/lib/measurement-kind.test.ts +30 -0
  237. package/src/lib/measurement-kind.ts +19 -0
  238. package/src/lib/measurement-label.test.ts +47 -0
  239. package/src/lib/measurement-label.ts +68 -0
  240. package/src/lib/measurement-parser.ts +116 -0
  241. package/src/lib/measurements.test.ts +183 -0
  242. package/src/lib/measurements.ts +199 -0
  243. package/src/lib/paint-scope.test.ts +454 -0
  244. package/src/lib/paint-scope.ts +461 -0
  245. package/src/lib/placement-drag-release.ts +23 -0
  246. package/src/lib/placement-metadata.ts +29 -0
  247. package/src/lib/planar-cursor-placement.test.ts +100 -0
  248. package/src/lib/planar-cursor-placement.ts +42 -0
  249. package/src/lib/plugin-panels.test.ts +19 -0
  250. package/src/lib/plugin-panels.ts +91 -0
  251. package/src/lib/quick-measurement.test.ts +77 -0
  252. package/src/lib/quick-measurement.ts +109 -0
  253. package/src/lib/roof-duplication.ts +7 -7
  254. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  255. package/src/lib/roof-wall-hit.ts +164 -0
  256. package/src/lib/scene-clipboard.test.ts +196 -0
  257. package/src/lib/scene-clipboard.ts +332 -0
  258. package/src/lib/scene.ts +40 -8
  259. package/src/lib/selection-routing.test.ts +298 -0
  260. package/src/lib/selection-routing.ts +175 -0
  261. package/src/lib/sfx/index.ts +1 -0
  262. package/src/lib/sfx/movement-tick.test.ts +65 -0
  263. package/src/lib/sfx/movement-tick.ts +18 -0
  264. package/src/lib/sfx-bus.ts +69 -10
  265. package/src/lib/sfx-player.test.ts +124 -0
  266. package/src/lib/sfx-player.ts +148 -55
  267. package/src/lib/site-boundary.ts +1 -0
  268. package/src/lib/slab-plan-snap.test.ts +93 -0
  269. package/src/lib/slab-plan-snap.ts +108 -0
  270. package/src/lib/snapping-mode.test.ts +138 -0
  271. package/src/lib/snapping-mode.ts +179 -0
  272. package/src/lib/stair-duplication.ts +4 -4
  273. package/src/lib/stair-levels.test.ts +146 -0
  274. package/src/lib/stair-levels.ts +177 -0
  275. package/src/lib/surface-plan-snap.test.ts +71 -0
  276. package/src/lib/surface-plan-snap.ts +256 -0
  277. package/src/lib/use-linear-display.ts +40 -0
  278. package/src/lib/world-grid-snap.ts +231 -0
  279. package/src/lib/zone-content.ts +130 -0
  280. package/src/store/use-alignment-guides.ts +21 -0
  281. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  282. package/src/store/use-editor.tsx +767 -114
  283. package/src/store/use-facing-pose.ts +44 -0
  284. package/src/store/use-fence-curve-draft.ts +21 -0
  285. package/src/store/use-floorplan-draft-preview.ts +101 -0
  286. package/src/store/use-floorplan-marquee.ts +50 -0
  287. package/src/store/use-interaction-scope.test.ts +186 -0
  288. package/src/store/use-interaction-scope.ts +132 -0
  289. package/src/store/use-measurement-draft.test.ts +530 -0
  290. package/src/store/use-measurement-draft.ts +543 -0
  291. package/src/store/use-opening-guides.ts +41 -0
  292. package/src/store/use-placement-preview.ts +39 -0
  293. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  294. package/src/store/use-quick-measurement-hud.ts +77 -0
  295. package/src/store/use-segment-draft-chain.ts +28 -0
  296. package/src/store/use-stair-build-preview.ts +43 -0
  297. package/src/store/use-wall-move-ghosts.ts +36 -0
  298. package/src/store/use-wall-snap-indicator.ts +34 -0
  299. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  300. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  301. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  302. package/src/components/tools/building/move-building-tool.tsx +0 -157
  303. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  304. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  305. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  306. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  307. package/src/components/tools/column/column-tool.tsx +0 -97
  308. package/src/components/tools/column/move-column-tool.tsx +0 -105
  309. package/src/components/tools/door/door-math.ts +0 -110
  310. package/src/components/tools/door/door-tool.tsx +0 -324
  311. package/src/components/tools/door/move-door-tool.tsx +0 -412
  312. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  313. package/src/components/tools/fence/fence-tool.tsx +0 -346
  314. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  315. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  316. package/src/components/tools/item/item-tool.tsx +0 -26
  317. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  318. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  319. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  320. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  321. package/src/components/tools/slab/slab-tool.tsx +0 -322
  322. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  323. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  324. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  325. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  326. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  327. package/src/components/tools/wall/wall-tool.tsx +0 -332
  328. package/src/components/tools/window/move-window-tool.tsx +0 -447
  329. package/src/components/tools/window/window-math.ts +0 -117
  330. package/src/components/tools/window/window-tool.tsx +0 -332
  331. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  332. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  333. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  334. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  335. package/src/components/ui/panels/column-panel.tsx +0 -759
  336. package/src/components/ui/panels/door-panel.tsx +0 -1299
  337. package/src/components/ui/panels/fence-panel.tsx +0 -222
  338. package/src/components/ui/panels/item-panel.tsx +0 -306
  339. package/src/components/ui/panels/paint-panel.tsx +0 -163
  340. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  341. package/src/components/ui/panels/roof-panel.tsx +0 -280
  342. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  343. package/src/components/ui/panels/slab-panel.tsx +0 -250
  344. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  345. package/src/components/ui/panels/stair-panel.tsx +0 -577
  346. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  347. package/src/components/ui/panels/wall-panel.tsx +0 -273
  348. package/src/components/ui/panels/window-panel.tsx +0 -968
  349. package/src/contexts/presets-context.tsx +0 -121
  350. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -1,12 +1,86 @@
1
1
  import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
2
- import { createPortal } from '@react-three/fiber'
3
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
4
- import { BufferGeometry, Float32BufferAttribute, type Line, type Object3D } from 'three'
2
+ import { SCENE_LAYER, useViewer } from '@pascal-app/viewer'
3
+ import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
4
+ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
5
+ import {
6
+ BoxGeometry,
7
+ BufferGeometry,
8
+ Color,
9
+ CylinderGeometry,
10
+ DoubleSide,
11
+ ExtrudeGeometry,
12
+ Float32BufferAttribute,
13
+ type Line,
14
+ type Object3D,
15
+ Shape,
16
+ Vector3,
17
+ } from 'three'
18
+ import { MeshBasicNodeMaterial } from 'three/webgpu'
5
19
  import { EDITOR_LAYER } from '../../../lib/constants'
6
20
  import { sfxEmitter } from '../../../lib/sfx-bus'
21
+ import {
22
+ createMoveCrossHandleGeometry,
23
+ ARROW_COLOR as EDGE_ARROW_COLOR,
24
+ ARROW_HOVER_COLOR as EDGE_ARROW_HOVER_COLOR,
25
+ ARROW_SCALE as EDGE_ARROW_SCALE,
26
+ useInvisibleHitAreaMaterial,
27
+ } from '../../editor/node-arrow-handles'
7
28
  import { snapToHalf } from '../item/placement-math'
29
+ import { suppressBoxSelectForPointer } from '../select/box-select-state'
8
30
 
9
31
  const Y_OFFSET = 0.02
32
+ // Per-side resize arrows: indigo chevrons that match the registry arrow
33
+ // handles (wall / column / fence). Each arrow sits just outside an edge
34
+ // midpoint, pointing along the edge's outward normal — dragging an arrow
35
+ // translates that edge only (its two vertices), leaving the opposite side
36
+ // fixed. Reuses the existing 'edge' drag mode in PolygonEditor.
37
+ const EDGE_ARROW_OFFSET = 0.34
38
+
39
+ // Disables R3F pointer-picking on a mesh. Used on the visual-only meshes —
40
+ // the edge bar and the border line — so they render without stealing pointer
41
+ // events that belong to the vertex/midpoint handles overlapping them. Mirrors
42
+ // the `NO_RAYCAST` sentinel in node-arrow-handles.tsx.
43
+ const NO_RAYCAST = () => null
44
+
45
+ // Maps the screen-space direction of an edge's outward normal to the closest
46
+ // directional resize cursor, so an edge arrow advertises the axis it will
47
+ // actually drag along regardless of camera orbit. Input is an NDC delta
48
+ // (y up); the angle is folded to [0°, 180°) since resize cursors are
49
+ // bidirectional.
50
+ function resizeCursorForScreenDirection(dx: number, dy: number): string {
51
+ const angle = ((((Math.atan2(dy, dx) * 180) / Math.PI) % 180) + 180) % 180
52
+ if (angle < 22.5 || angle >= 157.5) return 'ew-resize'
53
+ if (angle < 67.5) return 'nesw-resize'
54
+ if (angle < 112.5) return 'ns-resize'
55
+ return 'nwse-resize'
56
+ }
57
+
58
+ function createEdgeArrowGeometry() {
59
+ const shape = new Shape()
60
+ shape.moveTo(0.22, 0)
61
+ shape.lineTo(-0.04, 0.12)
62
+ shape.lineTo(-0.04, 0.035)
63
+ shape.lineTo(-0.2, 0.035)
64
+ shape.lineTo(-0.2, -0.035)
65
+ shape.lineTo(-0.04, -0.035)
66
+ shape.lineTo(-0.04, -0.12)
67
+ shape.lineTo(0.22, 0)
68
+ const geometry = new ExtrudeGeometry(shape, {
69
+ depth: 0.045,
70
+ bevelEnabled: true,
71
+ bevelThickness: 0.018,
72
+ bevelSize: 0.02,
73
+ bevelOffset: 0,
74
+ bevelSegments: 8,
75
+ curveSegments: 16,
76
+ steps: 1,
77
+ })
78
+ geometry.translate(0, 0, -0.0225)
79
+ geometry.rotateX(-Math.PI / 2)
80
+ geometry.computeVertexNormals()
81
+ geometry.computeBoundingSphere()
82
+ return geometry
83
+ }
10
84
 
11
85
  type DragState = {
12
86
  isDragging: boolean
@@ -19,10 +93,29 @@ type DragState = {
19
93
  pointerId: number
20
94
  }
21
95
 
96
+ export type PolygonEditorPlanPointSnapContext = {
97
+ rawPoint: [number, number]
98
+ gridPoint: [number, number]
99
+ mode: DragState['mode']
100
+ vertexIndex: number | null
101
+ edgeIndex?: number
102
+ initialPosition: [number, number]
103
+ initialPolygon: Array<[number, number]>
104
+ nativeEvent?: GridEvent['nativeEvent']
105
+ }
106
+
22
107
  export interface PolygonEditorProps {
23
108
  polygon: Array<[number, number]>
24
109
  color?: string
25
110
  onPolygonChange: (polygon: Array<[number, number]>) => void
111
+ /**
112
+ * Fires on every drag tick with the in-flight polygon, then once with
113
+ * `null` when the drag commits or is otherwise cleared. Hosts wire
114
+ * this to `useLiveNodeOverrides` so the underlying mesh rebuilds at
115
+ * pointer rate while `onPolygonChange` stays a single store commit
116
+ * on release.
117
+ */
118
+ onPolygonPreview?: (polygon: ReadonlyArray<readonly [number, number]> | null) => void
26
119
  minVertices?: number
27
120
  /** Level ID to mount the editor to. If provided, uses createPortal for automatic level animation following. */
28
121
  levelId?: string
@@ -32,6 +125,32 @@ export interface PolygonEditorProps {
32
125
  allowPolygonMove?: boolean
33
126
  /** Whether polygon edges can be dragged along their perpendicular normal. */
34
127
  allowEdgeMove?: boolean
128
+ /** Called just before a vertex drag session starts. */
129
+ onBeforeVertexDrag?: (vertexIndex: number, position: [number, number]) => void
130
+ /** Called when a vertex handle enters or leaves hover. */
131
+ onVertexHoverChange?: (vertexIndex: number | null) => void
132
+ /** Called when a midpoint add-vertex handle enters or leaves hover. */
133
+ onMidpointHoverChange?: (edgeIndex: number | null) => void
134
+ /** Called when an edge move handle enters or leaves hover. */
135
+ onEdgeHoverChange?: (edgeIndex: number | null) => void
136
+ /** Called when any polygon drag starts or ends. */
137
+ onDragStateChange?: (isDragging: boolean) => void
138
+ /** Called once when a polygon drag starts. */
139
+ onDragStart?: () => void
140
+ /** Called once when a polygon drag commits on pointer release. */
141
+ onDragCommit?: () => void
142
+ /** Whether to render the editor-owned polygon outline. */
143
+ showBorderLine?: boolean
144
+ /** Whether midpoint handles can add new vertices. */
145
+ showMidpointHandles?: boolean
146
+ /** Whether hovering a handle should also tint its connected edges and endpoint handles. */
147
+ highlightConnectedHandles?: boolean
148
+ /** Optional host-owned point snapper. Defaults to the existing half-grid snap. */
149
+ resolvePlanPoint?: (context: PolygonEditorPlanPointSnapContext) => [number, number]
150
+ /** Optional vertex handle renderer for host-specific affordances. */
151
+ renderVertexHandle?: PolygonVertexHandleRenderer
152
+ /** Optional midpoint handle renderer for host-specific add-vertex affordances. */
153
+ renderMidpointHandle?: PolygonMidpointHandleRenderer
35
154
  }
36
155
 
37
156
  /**
@@ -41,6 +160,7 @@ export interface PolygonEditorProps {
41
160
  const MIN_HANDLE_HEIGHT = 0.15
42
161
  const EDGE_HANDLE_HEIGHT = 0.06
43
162
  const EDGE_HANDLE_THICKNESS = 0.12
163
+ const EDGE_HANDLE_GEOMETRY = new BoxGeometry(1, 1, 1)
44
164
 
45
165
  function getEdgeNormal(start: [number, number], end: [number, number]): [number, number] | null {
46
166
  const dx = end[0] - start[0]
@@ -51,15 +171,267 @@ function getEdgeNormal(start: [number, number], end: [number, number]): [number,
51
171
  return [-dz / length, dx / length]
52
172
  }
53
173
 
174
+ function stopHandlePointerDown(event: ThreeEvent<PointerEvent>) {
175
+ event.stopPropagation()
176
+ suppressBoxSelectForPointer(event, { markHandled: false })
177
+ }
178
+
179
+ type HandleClickHandler = (event: ThreeEvent<MouseEvent>) => void
180
+ type HandlePointerHandler = (event: ThreeEvent<PointerEvent>) => void
181
+
182
+ export type PolygonHandleHandlers = {
183
+ onClick?: HandleClickHandler
184
+ onDoubleClick?: HandleClickHandler
185
+ onPointerDown?: HandlePointerHandler
186
+ onPointerEnter?: HandlePointerHandler
187
+ onPointerLeave?: HandlePointerHandler
188
+ }
189
+
190
+ export type PolygonVertexHandleRenderProps = {
191
+ canDelete: boolean
192
+ handleProps: PolygonHandleHandlers
193
+ height: number
194
+ index: number
195
+ isDragging: boolean
196
+ isHovered: boolean
197
+ point: [number, number]
198
+ position: [number, number, number]
199
+ radius: number
200
+ }
201
+
202
+ export type PolygonVertexHandleRenderer = (props: PolygonVertexHandleRenderProps) => React.ReactNode
203
+
204
+ export type PolygonMidpointHandleRenderProps = {
205
+ handleProps: PolygonHandleHandlers
206
+ height: number
207
+ index: number
208
+ isHovered: boolean
209
+ point: [number, number]
210
+ position: [number, number, number]
211
+ radius: number
212
+ }
213
+
214
+ export type PolygonMidpointHandleRenderer = (
215
+ props: PolygonMidpointHandleRenderProps,
216
+ ) => React.ReactNode
217
+
218
+ function usePolygonNodeMaterial(
219
+ color: string,
220
+ opacity = 1,
221
+ depthTest = true,
222
+ ): MeshBasicNodeMaterial {
223
+ const material = useMemo(
224
+ () =>
225
+ new MeshBasicNodeMaterial({
226
+ color: new Color('#ffffff'),
227
+ depthTest: true,
228
+ depthWrite: true,
229
+ opacity: 1,
230
+ transparent: true,
231
+ }),
232
+ [],
233
+ )
234
+
235
+ useEffect(() => {
236
+ material.color.set(color)
237
+ material.opacity = opacity
238
+ material.depthTest = depthTest
239
+ }, [color, depthTest, material, opacity])
240
+ useEffect(() => () => material.dispose(), [material])
241
+
242
+ return material
243
+ }
244
+
245
+ function usePolygonArrowMaterial(): MeshBasicNodeMaterial {
246
+ return useMemo(
247
+ () =>
248
+ new MeshBasicNodeMaterial({
249
+ color: new Color(EDGE_ARROW_COLOR),
250
+ depthTest: false,
251
+ depthWrite: true,
252
+ opacity: 1,
253
+ side: DoubleSide,
254
+ transparent: true,
255
+ }),
256
+ [],
257
+ )
258
+ }
259
+
260
+ // One mesh per handle: lives on SCENE_LAYER with a node material so the
261
+ // post-processing ink-edge pass outlines it. Ignores scene depth like the
262
+ // edge arrows so the handle stays visible through walls and slabs.
263
+ function OutlinedCylinderHandle({
264
+ radius,
265
+ height,
266
+ color,
267
+ opacity = 1,
268
+ position,
269
+ ...handlers
270
+ }: {
271
+ radius: number
272
+ height: number
273
+ color: string
274
+ opacity?: number
275
+ position: [number, number, number]
276
+ } & PolygonHandleHandlers) {
277
+ const geometry = useMemo(() => new CylinderGeometry(radius, radius, height, 16), [height, radius])
278
+ const material = usePolygonNodeMaterial(color, opacity, false)
279
+ useEffect(() => () => geometry.dispose(), [geometry])
280
+
281
+ return (
282
+ <mesh
283
+ frustumCulled={false}
284
+ geometry={geometry}
285
+ layers={SCENE_LAYER}
286
+ material={material}
287
+ position={position}
288
+ renderOrder={1010}
289
+ {...handlers}
290
+ />
291
+ )
292
+ }
293
+
294
+ // Whole-polygon move grip — the generic 4-way cross-arrow (matching the node
295
+ // move handles) with an invisible cylinder hit area, replacing the old sphere.
296
+ // The cross sits on SCENE_LAYER (so the ink pass outlines it) while the
297
+ // cylinder hit mesh is on EDITOR_LAYER (grabbable, out of the MRT scene pass).
298
+ function OutlinedCrossHandle({
299
+ color,
300
+ position,
301
+ ...handlers
302
+ }: {
303
+ color: string
304
+ position: [number, number, number]
305
+ } & PolygonHandleHandlers) {
306
+ const geometry = useMemo(() => createMoveCrossHandleGeometry(), [])
307
+ const material = usePolygonNodeMaterial(color)
308
+ const hitGeometry = useMemo(() => new CylinderGeometry(0.24, 0.24, 0.18, 24), [])
309
+ const hitMaterial = useInvisibleHitAreaMaterial()
310
+ useEffect(() => () => geometry.dispose(), [geometry])
311
+ useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
312
+
313
+ return (
314
+ <group position={position}>
315
+ <mesh
316
+ frustumCulled={false}
317
+ geometry={geometry}
318
+ layers={SCENE_LAYER}
319
+ material={material}
320
+ raycast={NO_RAYCAST}
321
+ renderOrder={1010}
322
+ scale={EDGE_ARROW_SCALE}
323
+ />
324
+ <mesh
325
+ frustumCulled={false}
326
+ geometry={hitGeometry}
327
+ layers={EDITOR_LAYER}
328
+ material={hitMaterial}
329
+ renderOrder={1011}
330
+ {...handlers}
331
+ />
332
+ </group>
333
+ )
334
+ }
335
+
336
+ function OutlinedEdgeArrowHandle({
337
+ geometry,
338
+ color,
339
+ position,
340
+ rotationY,
341
+ scale,
342
+ ...handlers
343
+ }: {
344
+ geometry: BufferGeometry
345
+ color: string
346
+ position: [number, number, number]
347
+ rotationY: number
348
+ scale: number
349
+ } & PolygonHandleHandlers) {
350
+ const material = usePolygonArrowMaterial()
351
+ useEffect(() => {
352
+ material.color.set(color)
353
+ }, [color, material])
354
+ useEffect(() => () => material.dispose(), [material])
355
+
356
+ return (
357
+ <mesh
358
+ frustumCulled={false}
359
+ geometry={geometry}
360
+ layers={SCENE_LAYER}
361
+ material={material}
362
+ position={position}
363
+ renderOrder={1010}
364
+ rotation={[0, rotationY, 0]}
365
+ scale={scale}
366
+ {...handlers}
367
+ />
368
+ )
369
+ }
370
+
371
+ function HighlightedEdgeSegment({
372
+ end,
373
+ start,
374
+ y,
375
+ }: {
376
+ end: [number, number]
377
+ start: [number, number]
378
+ y: number
379
+ }) {
380
+ const geometry = useMemo(() => {
381
+ const nextGeometry = new BufferGeometry()
382
+ nextGeometry.setAttribute(
383
+ 'position',
384
+ new Float32BufferAttribute([start[0], y, start[1], end[0], y, end[1]], 3),
385
+ )
386
+ return nextGeometry
387
+ }, [end, start, y])
388
+
389
+ useEffect(() => () => geometry.dispose(), [geometry])
390
+
391
+ return (
392
+ <line
393
+ // @ts-expect-error R3F <line> element conflicts with SVG <line> type
394
+ frustumCulled={false}
395
+ geometry={geometry}
396
+ layers={EDITOR_LAYER}
397
+ raycast={NO_RAYCAST}
398
+ renderOrder={12}
399
+ >
400
+ <lineBasicNodeMaterial
401
+ color={EDGE_ARROW_HOVER_COLOR}
402
+ depthTest
403
+ depthWrite={false}
404
+ linewidth={4}
405
+ opacity={0.95}
406
+ transparent
407
+ />
408
+ </line>
409
+ )
410
+ }
411
+
54
412
  export const PolygonEditor: React.FC<PolygonEditorProps> = ({
55
413
  polygon,
56
414
  color = '#3b82f6',
57
415
  onPolygonChange,
416
+ onPolygonPreview,
58
417
  minVertices = 3,
59
418
  levelId,
60
419
  surfaceHeight = 0,
61
420
  allowPolygonMove = false,
62
421
  allowEdgeMove = false,
422
+ onBeforeVertexDrag,
423
+ onVertexHoverChange,
424
+ onMidpointHoverChange,
425
+ onEdgeHoverChange,
426
+ onDragStateChange,
427
+ onDragStart,
428
+ onDragCommit,
429
+ showBorderLine = true,
430
+ showMidpointHandles = true,
431
+ highlightConnectedHandles = false,
432
+ resolvePlanPoint,
433
+ renderMidpointHandle,
434
+ renderVertexHandle,
63
435
  }) => {
64
436
  const [levelNode, setLevelNode] = useState<Object3D | null>(() =>
65
437
  levelId ? (sceneRegistry.nodes.get(levelId) ?? null) : null,
@@ -100,14 +472,73 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
100
472
  // When not using portal, edit at world origin
101
473
  const editY = levelNode ? Y_OFFSET : 0
102
474
 
475
+ const camera = useThree((state) => state.camera)
476
+
477
+ // Handle-hover cursor, applied to document.body like the registry arrow
478
+ // handles (see handle-arrow.tsx). Tracked in a ref so we only ever clear a
479
+ // cursor we set ourselves.
480
+ const activeBodyCursorRef = useRef<string | null>(null)
481
+ const setBodyCursor = useCallback((cursor: string) => {
482
+ activeBodyCursorRef.current = cursor
483
+ document.body.style.cursor = cursor
484
+ }, [])
485
+ const clearBodyCursor = useCallback(() => {
486
+ if (activeBodyCursorRef.current && document.body.style.cursor === activeBodyCursorRef.current) {
487
+ document.body.style.cursor = ''
488
+ }
489
+ activeBodyCursorRef.current = null
490
+ }, [])
491
+ useEffect(() => () => clearBodyCursor(), [clearBodyCursor])
492
+
493
+ const edgeResizeCursor = useCallback(
494
+ (midpoint: [number, number], outwardNormal: [number, number]) => {
495
+ const from = new Vector3(midpoint[0], editY, midpoint[1])
496
+ const to = new Vector3(midpoint[0] + outwardNormal[0], editY, midpoint[1] + outwardNormal[1])
497
+ if (levelNode) {
498
+ levelNode.localToWorld(from)
499
+ levelNode.localToWorld(to)
500
+ }
501
+ from.project(camera)
502
+ to.project(camera)
503
+ return resizeCursorForScreenDirection(to.x - from.x, to.y - from.y)
504
+ },
505
+ [camera, editY, levelNode],
506
+ )
507
+
103
508
  // Local state for dragging
104
509
  const [dragState, setDragState] = useState<DragState | null>(null)
105
510
  const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
106
511
  const previewPolygonRef = useRef<Array<[number, number]> | null>(null)
512
+ const previousInputDraggingRef = useRef(false)
513
+
514
+ const onDragStateChangeRef = useRef(onDragStateChange)
515
+ useEffect(() => {
516
+ onDragStateChangeRef.current = onDragStateChange
517
+ }, [onDragStateChange])
518
+
519
+ const onDragStartRef = useRef(onDragStart)
520
+ useEffect(() => {
521
+ onDragStartRef.current = onDragStart
522
+ }, [onDragStart])
523
+
524
+ const onDragCommitRef = useRef(onDragCommit)
525
+ useEffect(() => {
526
+ onDragCommitRef.current = onDragCommit
527
+ }, [onDragCommit])
528
+
529
+ const onPolygonPreviewRef = useRef(onPolygonPreview)
530
+ useEffect(() => {
531
+ onPolygonPreviewRef.current = onPolygonPreview
532
+ }, [onPolygonPreview])
107
533
 
108
534
  const updatePreviewPolygon = useCallback((nextPolygon: Array<[number, number]> | null) => {
109
535
  previewPolygonRef.current = nextPolygon
110
536
  setPreviewPolygon(nextPolygon)
537
+ // Notify the host so it can mirror the in-flight polygon onto
538
+ // `useLiveNodeOverrides` (drag rebuilds the mesh at pointer rate)
539
+ // and clear that override when we hand `null` back at commit /
540
+ // cancel / external-undo time.
541
+ onPolygonPreviewRef.current?.(nextPolygon)
111
542
  }, [])
112
543
 
113
544
  // Keep ref in sync
@@ -120,18 +551,83 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
120
551
  const [hoveredEdge, setHoveredEdge] = useState<number | null>(null)
121
552
  const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
122
553
 
554
+ useEffect(() => {
555
+ onVertexHoverChange?.(hoveredVertex)
556
+ }, [hoveredVertex, onVertexHoverChange])
557
+
558
+ useEffect(() => () => onVertexHoverChange?.(null), [onVertexHoverChange])
559
+
560
+ useEffect(() => {
561
+ onMidpointHoverChange?.(hoveredMidpoint)
562
+ }, [hoveredMidpoint, onMidpointHoverChange])
563
+
564
+ useEffect(() => () => onMidpointHoverChange?.(null), [onMidpointHoverChange])
565
+
566
+ useEffect(() => {
567
+ onEdgeHoverChange?.(hoveredEdge)
568
+ }, [hoveredEdge, onEdgeHoverChange])
569
+
570
+ useEffect(() => () => onEdgeHoverChange?.(null), [onEdgeHoverChange])
571
+
123
572
  const lineRef = useRef<Line>(null!)
124
573
  const previousPositionRef = useRef<[number, number] | null>(null)
125
574
 
126
- // Track the last polygon prop to detect external changes (undo/redo)
575
+ useEffect(() => {
576
+ onDragStateChangeRef.current?.(dragState?.isDragging ?? false)
577
+ }, [dragState?.isDragging])
578
+
579
+ useEffect(() => () => onDragStateChangeRef.current?.(false), [])
580
+
581
+ const startDrag = useCallback((nextDragState: DragState) => {
582
+ previousInputDraggingRef.current = useViewer.getState().inputDragging
583
+ useViewer.getState().setInputDragging(true)
584
+ setDragState(nextDragState)
585
+ onDragStartRef.current?.()
586
+ }, [])
587
+
588
+ useEffect(() => {
589
+ if (!dragState?.isDragging) return
590
+
591
+ return () => {
592
+ useViewer.getState().setInputDragging(previousInputDraggingRef.current)
593
+ }
594
+ }, [dragState?.isDragging])
595
+
596
+ // Track the last polygon prop to detect external changes (undo/redo) or
597
+ // our own post-commit prop update arriving while a preview is still in
598
+ // flight. Either way, drop the stale preview/drag.
599
+ //
600
+ // This block runs during render, so it may only touch THIS component's
601
+ // own state (setPreviewPolygon / setDragState — React re-renders in
602
+ // place, which is the sanctioned "adjust state on prop change" pattern).
603
+ // The host `onPolygonPreview(null)` notification clears
604
+ // `useLiveNodeOverrides` — a store OTHER components subscribe to (e.g.
605
+ // NodeArrowHandles) — so calling it here throws "Cannot update a
606
+ // component while rendering a different component". Defer it to the
607
+ // effect below.
127
608
  const lastPolygonRef = useRef(polygon)
609
+ const pendingPreviewClearRef = useRef(false)
128
610
  if (polygon !== lastPolygonRef.current) {
129
611
  lastPolygonRef.current = polygon
130
- // External change (e.g. undo/redo) clear any stale preview/drag state
131
- if (previewPolygon) updatePreviewPolygon(null)
612
+ // `previewPolygonRef` is the synchronously-updated source of truth for
613
+ // an in-flight preview (state can lag it by a render).
614
+ if (previewPolygonRef.current !== null || previewPolygon !== null) {
615
+ previewPolygonRef.current = null
616
+ setPreviewPolygon(null)
617
+ pendingPreviewClearRef.current = true
618
+ }
132
619
  if (dragState) setDragState(null)
133
620
  }
134
621
 
622
+ // Flush the deferred host preview-clear (see note above) after the
623
+ // commit, where writing to other stores is allowed.
624
+ useEffect(() => {
625
+ if (pendingPreviewClearRef.current) {
626
+ pendingPreviewClearRef.current = false
627
+ onPolygonPreviewRef.current?.(null)
628
+ }
629
+ })
630
+
135
631
  // The polygon to display (preview during drag, or actual polygon)
136
632
  const displayPolygon = previewPolygon ?? polygon
137
633
 
@@ -159,6 +655,15 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
159
655
  const edgeHandles = useMemo(() => {
160
656
  if (displayPolygon.length < 2) return []
161
657
 
658
+ let cx = 0
659
+ let cz = 0
660
+ for (const [x, z] of displayPolygon) {
661
+ cx += x
662
+ cz += z
663
+ }
664
+ cx /= displayPolygon.length
665
+ cz /= displayPolygon.length
666
+
162
667
  return displayPolygon.flatMap(([x1, z1], index) => {
163
668
  const nextIndex = (index + 1) % displayPolygon.length
164
669
  const [x2, z2] = displayPolygon[nextIndex]!
@@ -167,17 +672,74 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
167
672
  const length = Math.hypot(dx, dz)
168
673
  if (length < 1e-6) return []
169
674
 
675
+ const midpoint: [number, number] = [(x1 + x2) / 2, (z1 + z2) / 2]
676
+ // Outward normal: edge perpendicular flipped to point away from the
677
+ // polygon centroid. Independent of winding order so arrows always
678
+ // face outward even on hand-traced (mixed-winding) polygons.
679
+ let nx = -dz / length
680
+ let nz = dx / length
681
+ if (nx * (midpoint[0] - cx) + nz * (midpoint[1] - cz) < 0) {
682
+ nx = -nx
683
+ nz = -nz
684
+ }
685
+
170
686
  return [
171
687
  {
172
688
  index,
173
689
  length,
174
- midpoint: [(x1 + x2) / 2, (z1 + z2) / 2] as [number, number],
690
+ midpoint,
175
691
  rotationY: -Math.atan2(dz, dx),
692
+ outwardNormal: [nx, nz] as [number, number],
693
+ outwardAngle: -Math.atan2(nz, nx),
176
694
  },
177
695
  ]
178
696
  })
179
697
  }, [displayPolygon])
180
698
 
699
+ const activeVertexIndex = dragState?.mode === 'vertex' ? dragState.vertexIndex : hoveredVertex
700
+ const activeEdgeIndex = dragState?.mode === 'edge' ? dragState.edgeIndex : hoveredEdge
701
+
702
+ const highlightedEdgeIndices = useMemo(() => {
703
+ const next = new Set<number>()
704
+ const edgeCount = displayPolygon.length
705
+ if (!highlightConnectedHandles || edgeCount < 2) return next
706
+
707
+ if (activeVertexIndex !== null && activeVertexIndex !== undefined) {
708
+ next.add(activeVertexIndex)
709
+ next.add((activeVertexIndex - 1 + edgeCount) % edgeCount)
710
+ }
711
+ if (hoveredMidpoint !== null) {
712
+ next.add(hoveredMidpoint)
713
+ }
714
+ if (activeEdgeIndex !== null && activeEdgeIndex !== undefined) {
715
+ next.add(activeEdgeIndex)
716
+ }
717
+
718
+ return next
719
+ }, [
720
+ activeEdgeIndex,
721
+ activeVertexIndex,
722
+ displayPolygon.length,
723
+ highlightConnectedHandles,
724
+ hoveredMidpoint,
725
+ ])
726
+
727
+ const isVertexLinkedHighlighted = useCallback(
728
+ (index: number) => {
729
+ if (!highlightConnectedHandles || highlightedEdgeIndices.size === 0) return false
730
+ const edgeCount = displayPolygon.length
731
+ if (edgeCount < 2) return false
732
+ return (
733
+ highlightedEdgeIndices.has(index) ||
734
+ highlightedEdgeIndices.has((index - 1 + edgeCount) % edgeCount)
735
+ )
736
+ },
737
+ [displayPolygon.length, highlightConnectedHandles, highlightedEdgeIndices],
738
+ )
739
+
740
+ const arrowGeometry = useMemo(() => createEdgeArrowGeometry(), [])
741
+ useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
742
+
181
743
  // Update vertex position using grid cursor position
182
744
  const handleVertexDrag = useCallback(
183
745
  (vertexIndex: number, position: [number, number]) => {
@@ -194,9 +756,11 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
194
756
  if (previewPolygonRef.current) {
195
757
  onPolygonChange(previewPolygonRef.current)
196
758
  }
759
+ onDragCommitRef.current?.()
197
760
  updatePreviewPolygon(null)
198
761
  setDragState(null)
199
- }, [onPolygonChange, updatePreviewPolygon])
762
+ clearBodyCursor()
763
+ }, [clearBodyCursor, onPolygonChange, updatePreviewPolygon])
200
764
 
201
765
  // Handle adding a new vertex at midpoint
202
766
  const handleAddVertex = useCallback(
@@ -233,9 +797,24 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
233
797
  // Listen to grid:move events to track cursor position
234
798
  useEffect(() => {
235
799
  const onGridMove = (event: GridEvent) => {
236
- const gridX = snapToHalf(event.localPosition[0])
237
- const gridZ = snapToHalf(event.localPosition[2])
238
- const newPosition: [number, number] = [gridX, gridZ]
800
+ const point = levelNode ? event.localPosition : event.position
801
+ const rawPoint: [number, number] = [point[0], point[2]]
802
+ // Snapping follows the active mode (snapToHalf returns raw in Off / non-grid);
803
+ // no Shift bypass — Shift cycles the mode, Off is the bypass.
804
+ const gridPoint: [number, number] = [snapToHalf(rawPoint[0]), snapToHalf(rawPoint[1])]
805
+ const newPosition =
806
+ dragState?.isDragging && resolvePlanPoint
807
+ ? resolvePlanPoint({
808
+ rawPoint,
809
+ gridPoint,
810
+ mode: dragState.mode,
811
+ vertexIndex: dragState.vertexIndex,
812
+ edgeIndex: dragState.edgeIndex,
813
+ initialPosition: dragState.initialPosition,
814
+ initialPolygon: dragState.initialPolygon,
815
+ nativeEvent: event.nativeEvent,
816
+ })
817
+ : gridPoint
239
818
 
240
819
  // Play snap sound when cursor moves to a new grid cell during drag
241
820
  if (
@@ -290,7 +869,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
290
869
  return () => {
291
870
  emitter.off('grid:move', onGridMove)
292
871
  }
293
- }, [dragState, handleVertexDrag, updatePreviewPolygon])
872
+ }, [dragState, handleVertexDrag, levelNode, resolvePlanPoint, updatePreviewPolygon])
294
873
 
295
874
  // Set up pointer up listener for ending drag
296
875
  useEffect(() => {
@@ -335,7 +914,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
335
914
 
336
915
  // Update line geometry when polygon changes
337
916
  useEffect(() => {
338
- if (!lineRef.current || displayPolygon.length < 2) return
917
+ if (!showBorderLine || !lineRef.current || displayPolygon.length < 2) return
339
918
 
340
919
  const positions: number[] = []
341
920
  for (const [x, z] of displayPolygon) {
@@ -350,7 +929,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
350
929
 
351
930
  lineRef.current.geometry.dispose()
352
931
  lineRef.current.geometry = geometry
353
- }, [displayPolygon, editY])
932
+ }, [displayPolygon, editY, showBorderLine])
354
933
 
355
934
  if (displayPolygon.length < minVertices) return null
356
935
 
@@ -358,95 +937,148 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
358
937
  const handleHeight = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)
359
938
  const edgeHandleY = editY + handleHeight - EDGE_HANDLE_HEIGHT / 2
360
939
 
940
+ // Interactive handles are SCENE_LAYER node-material meshes so the ink-edge
941
+ // pass outlines them. Edge arrows ignore scene depth so the active resize
942
+ // affordance remains visible through walls and slabs. The edge BAR and border
943
+ // line stay on EDITOR_LAYER, visual-only
944
+ // (raycast disabled) so they never steal clicks from the vertex/midpoint
945
+ // handles overlapping them — edge dragging starts from the chevron arrow
946
+ // outside the polygon edge.
361
947
  const editorContent = (
362
948
  <group>
363
949
  {/* Border line */}
364
- <line
365
- frustumCulled={false}
366
- layers={EDITOR_LAYER}
367
- raycast={() => {}}
368
- // @ts-expect-error R3F <line> element conflicts with SVG <line> type
369
- ref={lineRef}
370
- renderOrder={10}
371
- >
372
- <bufferGeometry />
373
- <lineBasicNodeMaterial
374
- color={color}
375
- depthTest={false}
376
- depthWrite={false}
377
- linewidth={2}
378
- opacity={0.8}
379
- transparent
380
- />
381
- </line>
950
+ {showBorderLine && (
951
+ <line
952
+ frustumCulled={false}
953
+ layers={EDITOR_LAYER}
954
+ raycast={NO_RAYCAST}
955
+ // @ts-expect-error R3F <line> element conflicts with SVG <line> type
956
+ ref={lineRef}
957
+ renderOrder={10}
958
+ >
959
+ <bufferGeometry />
960
+ <lineBasicNodeMaterial
961
+ color={color}
962
+ depthTest={false}
963
+ depthWrite={false}
964
+ linewidth={2}
965
+ opacity={0.8}
966
+ transparent
967
+ />
968
+ </line>
969
+ )}
970
+
971
+ {highlightConnectedHandles &&
972
+ highlightedEdgeIndices.size > 0 &&
973
+ Array.from(highlightedEdgeIndices).map((edgeIndex) => {
974
+ const start = displayPolygon[edgeIndex]
975
+ const end = displayPolygon[(edgeIndex + 1) % displayPolygon.length]
976
+ if (!(start && end)) return null
977
+ return (
978
+ <HighlightedEdgeSegment
979
+ end={end}
980
+ key={`highlight-edge-${edgeIndex}`}
981
+ start={start}
982
+ y={edgeHandleY}
983
+ />
984
+ )
985
+ })}
382
986
 
383
- {/* Vertex handles - blue cylinders that match surface height */}
987
+ {/* Vertex handles - blue cylinders that match surface height. During a
988
+ drag only the active handle stays mounted so the gesture reads
989
+ clearly (same rule for the cross / edge handles below). */}
384
990
  {displayPolygon.map(([x, z], index) => {
385
991
  const isHovered = hoveredVertex === index
386
992
  const isDragging = dragState?.mode === 'vertex' && dragState.vertexIndex === index
387
- const radius = 0.1
993
+ if (dragState && !isDragging) return null
994
+ const isLinkedHighlighted = isVertexLinkedHighlighted(index)
995
+ const isHighlighted = isDragging || isHovered || isLinkedHighlighted
996
+ const radius = 0.08
388
997
  const height = handleHeight
998
+ const point: [number, number] = [x!, z!]
999
+ const position: [number, number, number] = [x!, editY + height / 2, z!]
1000
+ const handleProps: PolygonHandleHandlers = {
1001
+ onClick: (e) => {
1002
+ if (e.button !== 0) return
1003
+ e.stopPropagation()
1004
+ },
1005
+ onDoubleClick: (e) => {
1006
+ if (e.button !== 0) return
1007
+ e.stopPropagation()
1008
+ if (canDelete) {
1009
+ handleDeleteVertex(index)
1010
+ }
1011
+ },
1012
+ onPointerDown: (e) => {
1013
+ if (e.button !== 0) return
1014
+ stopHandlePointerDown(e)
1015
+ setHoveredEdge(null)
1016
+ onBeforeVertexDrag?.(index, point)
1017
+ startDrag({
1018
+ isDragging: true,
1019
+ mode: 'vertex',
1020
+ vertexIndex: index,
1021
+ initialPosition: [x!, z!],
1022
+ initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
1023
+ pointerId: e.pointerId,
1024
+ })
1025
+ },
1026
+ onPointerEnter: (e) => {
1027
+ e.stopPropagation()
1028
+ setHoveredVertex(index)
1029
+ setBodyCursor('move')
1030
+ },
1031
+ onPointerLeave: (e) => {
1032
+ e.stopPropagation()
1033
+ setHoveredVertex(null)
1034
+ // Keep the cursor for the whole gesture when the pointer slips
1035
+ // off the handle mid-drag; commit clears it.
1036
+ if (!dragState?.isDragging) clearBodyCursor()
1037
+ },
1038
+ }
1039
+
1040
+ if (renderVertexHandle) {
1041
+ return (
1042
+ <Fragment key={`vertex-${index}`}>
1043
+ {renderVertexHandle({
1044
+ canDelete,
1045
+ handleProps,
1046
+ height,
1047
+ index,
1048
+ isDragging,
1049
+ isHovered,
1050
+ point,
1051
+ position,
1052
+ radius,
1053
+ })}
1054
+ </Fragment>
1055
+ )
1056
+ }
389
1057
 
390
1058
  return (
391
- <mesh
392
- castShadow
1059
+ <OutlinedCylinderHandle
1060
+ color={isHighlighted ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
1061
+ height={height}
393
1062
  key={`vertex-${index}`}
394
- layers={EDITOR_LAYER}
395
- onClick={(e) => {
396
- if (e.button !== 0) return
397
- e.stopPropagation()
398
- }}
399
- onDoubleClick={(e) => {
400
- if (e.button !== 0) return
401
- e.stopPropagation()
402
- if (canDelete) {
403
- handleDeleteVertex(index)
404
- }
405
- }}
406
- onPointerDown={(e) => {
407
- if (e.button !== 0) return
408
- e.stopPropagation()
409
- setHoveredEdge(null)
410
- setDragState({
411
- isDragging: true,
412
- mode: 'vertex',
413
- vertexIndex: index,
414
- initialPosition: [x!, z!],
415
- initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
416
- pointerId: e.pointerId,
417
- })
418
- }}
419
- onPointerEnter={(e) => {
420
- e.stopPropagation()
421
- setHoveredVertex(index)
422
- }}
423
- onPointerLeave={(e) => {
424
- e.stopPropagation()
425
- setHoveredVertex(null)
426
- }}
427
- position={[x!, editY + height / 2, z!]}
428
- >
429
- <cylinderGeometry args={[radius, radius, height, 16]} />
430
- <meshStandardMaterial
431
- color={isDragging ? '#22c55e' : isHovered ? '#60a5fa' : '#3b82f6'}
432
- />
433
- </mesh>
1063
+ {...handleProps}
1064
+ position={position}
1065
+ radius={radius}
1066
+ />
434
1067
  )
435
1068
  })}
436
1069
 
437
- {allowPolygonMove && (
438
- <mesh
439
- castShadow
440
- layers={EDITOR_LAYER}
1070
+ {allowPolygonMove && (!dragState || dragState.mode === 'polygon') && (
1071
+ <OutlinedCrossHandle
1072
+ color={dragState?.mode === 'polygon' ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
441
1073
  onClick={(e) => {
442
1074
  if (e.button !== 0) return
443
1075
  e.stopPropagation()
444
1076
  }}
445
1077
  onPointerDown={(e) => {
446
1078
  if (e.button !== 0) return
447
- e.stopPropagation()
1079
+ stopHandlePointerDown(e)
448
1080
  setHoveredEdge(null)
449
- setDragState({
1081
+ startDrag({
450
1082
  isDragging: true,
451
1083
  mode: 'polygon',
452
1084
  vertexIndex: null,
@@ -455,117 +1087,176 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
455
1087
  pointerId: e.pointerId,
456
1088
  })
457
1089
  }}
1090
+ onPointerEnter={(e) => {
1091
+ e.stopPropagation()
1092
+ setBodyCursor('move')
1093
+ }}
1094
+ onPointerLeave={(e) => {
1095
+ e.stopPropagation()
1096
+ if (!dragState?.isDragging) clearBodyCursor()
1097
+ }}
458
1098
  position={[polygonCenter[0], editY + handleHeight + 0.08, polygonCenter[1]]}
459
- >
460
- <sphereGeometry args={[0.09, 20, 20]} />
461
- <meshStandardMaterial color={dragState?.mode === 'polygon' ? '#22c55e' : '#f59e0b'} />
462
- </mesh>
1099
+ />
463
1100
  )}
464
-
465
1101
  {allowEdgeMove &&
466
- edgeHandles.map(({ index, length, midpoint, rotationY }) => {
1102
+ edgeHandles.map(({ index, length, midpoint, rotationY, outwardNormal, outwardAngle }) => {
467
1103
  const isHovered = hoveredEdge === index
468
1104
  const isDragging = dragState?.mode === 'edge' && dragState.edgeIndex === index
1105
+ if (dragState && !isDragging) return null
1106
+ const isLinkedHighlighted = highlightedEdgeIndices.has(index)
1107
+ const isHighlighted = isDragging || isHovered || isLinkedHighlighted
1108
+ const arrowX = midpoint[0] + outwardNormal[0] * EDGE_ARROW_OFFSET
1109
+ const arrowZ = midpoint[1] + outwardNormal[1] * EDGE_ARROW_OFFSET
1110
+
1111
+ const beginEdgeDrag = (e: { button: number; pointerId: number }) => {
1112
+ const start = displayPolygon[index]
1113
+ const end = displayPolygon[(index + 1) % displayPolygon.length]
1114
+ if (!(start && end)) return
1115
+
1116
+ const edgeNormal = getEdgeNormal(start, end)
1117
+ if (!edgeNormal) return
1118
+
1119
+ setHoveredEdge(null)
1120
+ startDrag({
1121
+ isDragging: true,
1122
+ mode: 'edge',
1123
+ vertexIndex: null,
1124
+ edgeIndex: index,
1125
+ edgeNormal,
1126
+ initialPosition: cursorPosition,
1127
+ initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
1128
+ pointerId: e.pointerId,
1129
+ })
1130
+ }
469
1131
 
470
1132
  return (
471
- <mesh
472
- key={`edge-${index}`}
473
- layers={EDITOR_LAYER}
474
- onClick={(e) => {
475
- if (e.button !== 0) return
476
- e.stopPropagation()
477
- }}
478
- onPointerDown={(e) => {
479
- if (e.button !== 0) return
480
- e.stopPropagation()
481
- const start = displayPolygon[index]
482
- const end = displayPolygon[(index + 1) % displayPolygon.length]
483
- if (!(start && end)) return
484
-
485
- const edgeNormal = getEdgeNormal(start, end)
486
- if (!edgeNormal) return
487
-
488
- setHoveredEdge(null)
489
- setDragState({
490
- isDragging: true,
491
- mode: 'edge',
492
- vertexIndex: null,
493
- edgeIndex: index,
494
- edgeNormal,
495
- initialPosition: cursorPosition,
496
- initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
497
- pointerId: e.pointerId,
498
- })
499
- }}
500
- onPointerEnter={(e) => {
501
- e.stopPropagation()
502
- setHoveredEdge(index)
503
- }}
504
- onPointerLeave={(e) => {
505
- e.stopPropagation()
506
- setHoveredEdge(null)
507
- }}
508
- position={[midpoint[0], edgeHandleY, midpoint[1]]}
509
- rotation={[0, rotationY, 0]}
510
- >
511
- <boxGeometry args={[length, EDGE_HANDLE_HEIGHT, EDGE_HANDLE_THICKNESS]} />
512
- <meshStandardMaterial
513
- color={isDragging ? '#22c55e' : '#94a3b8'}
514
- opacity={isDragging ? 0.5 : isHovered ? 0.38 : 0.14}
515
- transparent
1133
+ <group key={`edge-${index}`}>
1134
+ {/* Edge bar — VISUAL ONLY (raycast disabled). It runs the full
1135
+ length of the edge and overlaps the vertex/midpoint handles at
1136
+ its ends + centre, so making it pickable let edges steal those
1137
+ clicks. Edge dragging runs through the chevron arrow below,
1138
+ which sits outside the polygon and never overlaps a
1139
+ vertex/midpoint handle. */}
1140
+ <mesh
1141
+ geometry={EDGE_HANDLE_GEOMETRY}
1142
+ layers={EDITOR_LAYER}
1143
+ position={[midpoint[0], edgeHandleY, midpoint[1]]}
1144
+ raycast={NO_RAYCAST}
1145
+ rotation={[0, rotationY, 0]}
1146
+ scale={[length, EDGE_HANDLE_HEIGHT, EDGE_HANDLE_THICKNESS]}
1147
+ >
1148
+ <meshBasicMaterial
1149
+ color={isHighlighted ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
1150
+ opacity={isDragging ? 0.5 : isHighlighted ? 0.38 : 0.14}
1151
+ transparent
1152
+ />
1153
+ </mesh>
1154
+ {/* Per-side resize arrow — the interactive edge-drag handle.
1155
+ Points outward from the edge; dragging it translates only this
1156
+ edge's two vertices along the outward normal. */}
1157
+ <OutlinedEdgeArrowHandle
1158
+ color={isHighlighted ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
1159
+ geometry={arrowGeometry}
1160
+ onClick={(e) => {
1161
+ if (e.button !== 0) return
1162
+ e.stopPropagation()
1163
+ }}
1164
+ onPointerDown={(e) => {
1165
+ if (e.button !== 0) return
1166
+ stopHandlePointerDown(e)
1167
+ beginEdgeDrag(e)
1168
+ }}
1169
+ onPointerEnter={(e) => {
1170
+ e.stopPropagation()
1171
+ setHoveredEdge(index)
1172
+ setBodyCursor(edgeResizeCursor(midpoint, outwardNormal))
1173
+ }}
1174
+ onPointerLeave={(e) => {
1175
+ e.stopPropagation()
1176
+ setHoveredEdge(null)
1177
+ if (!dragState?.isDragging) clearBodyCursor()
1178
+ }}
1179
+ position={[arrowX, edgeHandleY, arrowZ]}
1180
+ rotationY={outwardAngle}
1181
+ scale={EDGE_ARROW_SCALE}
516
1182
  />
517
- </mesh>
1183
+ </group>
518
1184
  )
519
1185
  })}
520
1186
 
521
1187
  {/* Midpoint handles - smaller green cylinders for adding vertices (hidden while dragging) */}
522
- {!dragState &&
1188
+ {showMidpointHandles &&
1189
+ !dragState &&
523
1190
  midpoints.map(([x, z], index) => {
524
1191
  const isHovered = hoveredMidpoint === index
525
- const radius = 0.06
1192
+ const isLinkedHighlighted = highlightedEdgeIndices.has(index)
1193
+ const isHighlighted = isHovered || isLinkedHighlighted
1194
+ const radius = 0.05
526
1195
  const height = handleHeight
1196
+ const point: [number, number] = [x!, z!]
1197
+ const position: [number, number, number] = [x!, editY + height / 2, z!]
1198
+ const handleProps: PolygonHandleHandlers = {
1199
+ onClick: (e) => {
1200
+ if (e.button !== 0) return
1201
+ e.stopPropagation()
1202
+ },
1203
+ onPointerDown: (e) => {
1204
+ if (e.button !== 0) return
1205
+ stopHandlePointerDown(e)
1206
+ onBeforeVertexDrag?.(index + 1, point)
1207
+ const insertedVertex = handleAddVertex(index, point)
1208
+ if (insertedVertex.vertexIndex >= 0) {
1209
+ startDrag({
1210
+ isDragging: true,
1211
+ mode: 'vertex',
1212
+ vertexIndex: insertedVertex.vertexIndex,
1213
+ initialPosition: point,
1214
+ initialPolygon: insertedVertex.polygon,
1215
+ pointerId: e.pointerId,
1216
+ })
1217
+ setHoveredMidpoint(null)
1218
+ }
1219
+ },
1220
+ onPointerEnter: (e) => {
1221
+ e.stopPropagation()
1222
+ setHoveredMidpoint(index)
1223
+ setBodyCursor('move')
1224
+ },
1225
+ onPointerLeave: (e) => {
1226
+ e.stopPropagation()
1227
+ setHoveredMidpoint(null)
1228
+ clearBodyCursor()
1229
+ },
1230
+ }
1231
+
1232
+ if (renderMidpointHandle) {
1233
+ return (
1234
+ <Fragment key={`midpoint-${index}`}>
1235
+ {renderMidpointHandle({
1236
+ handleProps,
1237
+ height,
1238
+ index,
1239
+ isHovered,
1240
+ point,
1241
+ position,
1242
+ radius,
1243
+ })}
1244
+ </Fragment>
1245
+ )
1246
+ }
527
1247
 
528
1248
  return (
529
- <mesh
1249
+ <OutlinedCylinderHandle
1250
+ // Midpoints read as secondary add-points: the brighter hover
1251
+ // shade at rest distinguishes them from the vertex handles.
1252
+ color={EDGE_ARROW_HOVER_COLOR}
1253
+ height={height}
530
1254
  key={`midpoint-${index}`}
531
- layers={EDITOR_LAYER}
532
- onClick={(e) => {
533
- if (e.button !== 0) return
534
- e.stopPropagation()
535
- }}
536
- onPointerDown={(e) => {
537
- if (e.button !== 0) return
538
- e.stopPropagation()
539
- const insertedVertex = handleAddVertex(index, [x!, z!])
540
- if (insertedVertex.vertexIndex >= 0) {
541
- setDragState({
542
- isDragging: true,
543
- mode: 'vertex',
544
- vertexIndex: insertedVertex.vertexIndex,
545
- initialPosition: [x!, z!],
546
- initialPolygon: insertedVertex.polygon,
547
- pointerId: e.pointerId,
548
- })
549
- setHoveredMidpoint(null)
550
- }
551
- }}
552
- onPointerEnter={(e) => {
553
- e.stopPropagation()
554
- setHoveredMidpoint(index)
555
- }}
556
- onPointerLeave={(e) => {
557
- e.stopPropagation()
558
- setHoveredMidpoint(null)
559
- }}
560
- position={[x!, editY + height / 2, z!]}
561
- >
562
- <cylinderGeometry args={[radius, radius, height, 16]} />
563
- <meshStandardMaterial
564
- color={isHovered ? '#4ade80' : '#22c55e'}
565
- opacity={isHovered ? 1 : 0.7}
566
- transparent
567
- />
568
- </mesh>
1255
+ {...handleProps}
1256
+ opacity={isHighlighted ? 1 : 0.7}
1257
+ position={position}
1258
+ radius={radius}
1259
+ />
569
1260
  )
570
1261
  })}
571
1262
  </group>