@pascal-app/editor 0.7.0 → 0.9.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 (244) hide show
  1. package/package.json +12 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +755 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +62 -54
  6. package/src/components/editor/export-manager.tsx +32 -3
  7. package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
  8. package/src/components/editor/first-person/build-collider-world.ts +203 -87
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
  10. package/src/components/editor/first-person-controls.tsx +836 -31
  11. package/src/components/editor/floating-action-menu.tsx +287 -196
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floorplan-panel.tsx +5837 -12006
  14. package/src/components/editor/grid.tsx +51 -17
  15. package/src/components/editor/group-move-handle.tsx +316 -0
  16. package/src/components/editor/group-rotate-handle.tsx +382 -0
  17. package/src/components/editor/group-transform-shared.test.ts +205 -0
  18. package/src/components/editor/group-transform-shared.ts +256 -0
  19. package/src/components/editor/handles/handle-arrow.tsx +523 -0
  20. package/src/components/editor/handles/use-handle-drag.ts +213 -0
  21. package/src/components/editor/index.tsx +234 -190
  22. package/src/components/editor/measurement-pill.tsx +75 -0
  23. package/src/components/editor/node-arrow-handles.tsx +1453 -0
  24. package/src/components/editor/selection-manager.tsx +435 -158
  25. package/src/components/editor/site-edge-labels.tsx +44 -20
  26. package/src/components/editor/slab-hole-highlights.tsx +466 -0
  27. package/src/components/editor/snapshot-capture-overlay.tsx +539 -0
  28. package/src/components/editor/thumbnail-generator.tsx +27 -64
  29. package/src/components/editor/use-floorplan-background-placement.ts +122 -35
  30. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  31. package/src/components/editor/use-floorplan-scene-data.ts +14 -2
  32. package/src/components/editor/wall-measurement-label.tsx +11 -6
  33. package/src/components/editor/wall-move-side-handles.tsx +855 -0
  34. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  35. package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
  36. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
  37. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  38. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  39. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
  41. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  42. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
  43. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  44. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
  45. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  46. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
  47. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  49. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
  50. package/src/components/editor-2d/svg-paths.ts +10 -2
  51. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
  52. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  53. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  54. package/src/components/systems/stair/stair-edit-system.tsx +2 -2
  55. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  56. package/src/components/systems/zone/zone-system.tsx +0 -0
  57. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  58. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  59. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  60. package/src/components/tools/fence/fence-drafting.ts +64 -22
  61. package/src/components/tools/item/move-tool.tsx +40 -107
  62. package/src/components/tools/item/placement-math.test.ts +10 -0
  63. package/src/components/tools/item/placement-math.ts +33 -9
  64. package/src/components/tools/item/placement-strategies.ts +201 -25
  65. package/src/components/tools/item/placement-types.ts +10 -1
  66. package/src/components/tools/item/use-draft-node.ts +20 -2
  67. package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
  68. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  69. package/src/components/tools/roof/roof-tool.tsx +108 -12
  70. package/src/components/tools/select/box-select-state.ts +77 -0
  71. package/src/components/tools/select/box-select-tool.tsx +324 -543
  72. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  73. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  74. package/src/components/tools/select/select-candidates.test.ts +109 -0
  75. package/src/components/tools/select/select-candidates.ts +85 -0
  76. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  77. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  78. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  79. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  80. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  81. package/src/components/tools/shared/placement-box.tsx +123 -0
  82. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  83. package/src/components/tools/shared/segment-angle.ts +57 -1
  84. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  85. package/src/components/tools/site/site-flag-model.ts +18 -0
  86. package/src/components/tools/stair/stair-defaults.ts +1 -1
  87. package/src/components/tools/stair/stair-tool.tsx +269 -35
  88. package/src/components/tools/tool-manager.tsx +172 -56
  89. package/src/components/tools/wall/wall-drafting.ts +98 -98
  90. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  91. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  92. package/src/components/tools/zone/zone-tool.tsx +36 -11
  93. package/src/components/ui/action-menu/action-button.tsx +21 -1
  94. package/src/components/ui/action-menu/camera-actions.tsx +0 -0
  95. package/src/components/ui/action-menu/control-modes.tsx +5 -76
  96. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  97. package/src/components/ui/action-menu/index.tsx +33 -196
  98. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  99. package/src/components/ui/action-menu/view-toggles.tsx +262 -37
  100. package/src/components/ui/command-palette/editor-commands.tsx +24 -17
  101. package/src/components/ui/command-palette/index.tsx +6 -256
  102. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  103. package/src/components/ui/controls/material-picker.tsx +17 -35
  104. package/src/components/ui/controls/metric-control.tsx +33 -11
  105. package/src/components/ui/floating-level-selector.tsx +48 -11
  106. package/src/components/ui/helpers/helper-manager.tsx +21 -17
  107. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  108. package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
  109. package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
  110. package/src/components/ui/level-duplicate-dialog.tsx +5 -6
  111. package/src/components/ui/panels/node-display.ts +1 -0
  112. package/src/components/ui/panels/paint-panel.tsx +66 -41
  113. package/src/components/ui/panels/panel-manager.tsx +33 -74
  114. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  115. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  116. package/src/components/ui/panels/reference-panel.tsx +28 -13
  117. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  118. package/src/components/ui/primitives/number-input.tsx +1 -1
  119. package/src/components/ui/primitives/sidebar.tsx +0 -0
  120. package/src/components/ui/scene-loader.tsx +1 -3
  121. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  122. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  123. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  124. package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  127. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  128. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  129. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  132. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  133. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  134. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  135. package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
  136. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  137. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  138. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  139. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  140. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  141. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  142. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  143. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
  144. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
  145. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
  146. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  147. package/src/components/ui/slider.tsx +1 -1
  148. package/src/components/viewer-overlay.tsx +169 -52
  149. package/src/components/viewer-zone-system.tsx +1 -1
  150. package/src/hooks/use-auto-save.ts +24 -3
  151. package/src/hooks/use-ceiling-events.ts +175 -0
  152. package/src/hooks/use-drag-action.ts +124 -0
  153. package/src/hooks/use-keyboard.ts +125 -62
  154. package/src/hooks/use-selection.ts +64 -0
  155. package/src/index.tsx +243 -4
  156. package/src/lib/constants.ts +6 -2
  157. package/src/lib/editor-api.ts +62 -0
  158. package/src/lib/elevator-support.ts +96 -0
  159. package/src/lib/floorplan/apply-alignment.ts +111 -0
  160. package/src/lib/floorplan/index.ts +7 -0
  161. package/src/lib/floorplan/selection-tool.ts +40 -0
  162. package/src/lib/floorplan/stairs.ts +12 -1
  163. package/src/lib/fresh-planar-placement.test.ts +102 -0
  164. package/src/lib/fresh-planar-placement.ts +63 -0
  165. package/src/lib/history.ts +2 -1
  166. package/src/lib/level-duplication.test.ts +0 -2
  167. package/src/lib/level-duplication.ts +55 -43
  168. package/src/lib/level-name.ts +11 -0
  169. package/src/lib/material-paint.ts +212 -20
  170. package/src/lib/placement-metadata.ts +29 -0
  171. package/src/lib/planar-cursor-placement.test.ts +43 -0
  172. package/src/lib/planar-cursor-placement.ts +42 -0
  173. package/src/lib/roof-duplication.ts +2 -2
  174. package/src/lib/scene-bounds.ts +1 -1
  175. package/src/lib/scene-clipboard.ts +267 -0
  176. package/src/lib/scene.ts +13 -4
  177. package/src/lib/sfx-bus.ts +25 -1
  178. package/src/lib/sfx-player.ts +75 -27
  179. package/src/lib/site-boundary.ts +1 -0
  180. package/src/lib/stair-duplication.ts +2 -2
  181. package/src/lib/stair-levels.test.ts +146 -0
  182. package/src/lib/stair-levels.ts +177 -0
  183. package/src/lib/world-grid-snap.ts +199 -0
  184. package/src/lib/zone-content.ts +130 -0
  185. package/src/store/use-alignment-guides.ts +21 -0
  186. package/src/store/use-editor.tsx +342 -91
  187. package/src/store/use-placement-preview.ts +31 -0
  188. package/src/store/use-wall-move-ghosts.ts +36 -0
  189. package/src/store/use-wall-snap-indicator.ts +32 -0
  190. package/tsconfig.json +2 -1
  191. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  192. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  193. package/src/components/feedback-dialog.tsx +0 -265
  194. package/src/components/pascal-radio.tsx +0 -280
  195. package/src/components/preview-button.tsx +0 -16
  196. package/src/components/tools/building/move-building-tool.tsx +0 -157
  197. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  198. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  199. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  200. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
  201. package/src/components/tools/column/column-tool.tsx +0 -97
  202. package/src/components/tools/column/move-column-tool.tsx +0 -105
  203. package/src/components/tools/door/door-math.ts +0 -110
  204. package/src/components/tools/door/door-tool.tsx +0 -324
  205. package/src/components/tools/door/move-door-tool.tsx +0 -412
  206. package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
  207. package/src/components/tools/fence/fence-tool.tsx +0 -346
  208. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
  209. package/src/components/tools/fence/move-fence-tool.tsx +0 -298
  210. package/src/components/tools/item/item-tool.tsx +0 -26
  211. package/src/components/tools/roof/move-roof-tool.tsx +0 -359
  212. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  213. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  214. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  215. package/src/components/tools/slab/slab-tool.tsx +0 -322
  216. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  217. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  218. package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
  219. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
  220. package/src/components/tools/wall/move-wall-tool.tsx +0 -356
  221. package/src/components/tools/wall/wall-tool.tsx +0 -332
  222. package/src/components/tools/window/move-window-tool.tsx +0 -447
  223. package/src/components/tools/window/window-math.ts +0 -117
  224. package/src/components/tools/window/window-tool.tsx +0 -332
  225. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  226. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  227. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  228. package/src/components/ui/panels/ceiling-panel.tsx +0 -250
  229. package/src/components/ui/panels/column-panel.tsx +0 -715
  230. package/src/components/ui/panels/door-panel.tsx +0 -1292
  231. package/src/components/ui/panels/fence-panel.tsx +0 -227
  232. package/src/components/ui/panels/item-panel.tsx +0 -306
  233. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  234. package/src/components/ui/panels/roof-panel.tsx +0 -230
  235. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  236. package/src/components/ui/panels/slab-panel.tsx +0 -250
  237. package/src/components/ui/panels/spawn-panel.tsx +0 -155
  238. package/src/components/ui/panels/stair-panel.tsx +0 -525
  239. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  240. package/src/components/ui/panels/wall-panel.tsx +0 -177
  241. package/src/components/ui/panels/window-panel.tsx +0 -960
  242. package/src/components/ui/viewer-toolbar.tsx +0 -436
  243. package/src/contexts/presets-context.tsx +0 -121
  244. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -1,12 +1,70 @@
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 } from '@react-three/fiber'
4
+ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
5
+ import {
6
+ BufferGeometry,
7
+ Color,
8
+ CylinderGeometry,
9
+ ExtrudeGeometry,
10
+ Float32BufferAttribute,
11
+ type Line,
12
+ type Object3D,
13
+ Shape,
14
+ } from 'three'
15
+ import { MeshBasicNodeMaterial } from 'three/webgpu'
5
16
  import { EDITOR_LAYER } from '../../../lib/constants'
6
17
  import { sfxEmitter } from '../../../lib/sfx-bus'
18
+ import {
19
+ createMoveCrossHandleGeometry,
20
+ ARROW_COLOR as EDGE_ARROW_COLOR,
21
+ ARROW_HOVER_COLOR as EDGE_ARROW_HOVER_COLOR,
22
+ ARROW_SCALE as EDGE_ARROW_SCALE,
23
+ useArrowMaterial,
24
+ useInvisibleHitAreaMaterial,
25
+ } from '../../editor/node-arrow-handles'
7
26
  import { snapToHalf } from '../item/placement-math'
8
27
 
9
28
  const Y_OFFSET = 0.02
29
+ // Per-side resize arrows: indigo chevrons that match the registry arrow
30
+ // handles (wall / column / fence). Each arrow sits just outside an edge
31
+ // midpoint, pointing along the edge's outward normal — dragging an arrow
32
+ // translates that edge only (its two vertices), leaving the opposite side
33
+ // fixed. Reuses the existing 'edge' drag mode in PolygonEditor.
34
+ const EDGE_ARROW_OFFSET = 0.34
35
+
36
+ // Disables R3F pointer-picking on a mesh. Used on the visual-only meshes —
37
+ // the edge bar and the border line — so they render without stealing pointer
38
+ // events that belong to the vertex/midpoint handles overlapping them. Mirrors
39
+ // the `NO_RAYCAST` sentinel in node-arrow-handles.tsx.
40
+ const NO_RAYCAST = () => null
41
+
42
+ function createEdgeArrowGeometry() {
43
+ const shape = new Shape()
44
+ shape.moveTo(0.22, 0)
45
+ shape.lineTo(-0.04, 0.12)
46
+ shape.lineTo(-0.04, 0.035)
47
+ shape.lineTo(-0.2, 0.035)
48
+ shape.lineTo(-0.2, -0.035)
49
+ shape.lineTo(-0.04, -0.035)
50
+ shape.lineTo(-0.04, -0.12)
51
+ shape.lineTo(0.22, 0)
52
+ const geometry = new ExtrudeGeometry(shape, {
53
+ depth: 0.045,
54
+ bevelEnabled: true,
55
+ bevelThickness: 0.018,
56
+ bevelSize: 0.02,
57
+ bevelOffset: 0,
58
+ bevelSegments: 8,
59
+ curveSegments: 16,
60
+ steps: 1,
61
+ })
62
+ geometry.translate(0, 0, -0.0225)
63
+ geometry.rotateX(-Math.PI / 2)
64
+ geometry.computeVertexNormals()
65
+ geometry.computeBoundingSphere()
66
+ return geometry
67
+ }
10
68
 
11
69
  type DragState = {
12
70
  isDragging: boolean
@@ -23,6 +81,14 @@ export interface PolygonEditorProps {
23
81
  polygon: Array<[number, number]>
24
82
  color?: string
25
83
  onPolygonChange: (polygon: Array<[number, number]>) => void
84
+ /**
85
+ * Fires on every drag tick with the in-flight polygon, then once with
86
+ * `null` when the drag commits or is otherwise cleared. Hosts wire
87
+ * this to `useLiveNodeOverrides` so the underlying mesh rebuilds at
88
+ * pointer rate while `onPolygonChange` stays a single store commit
89
+ * on release.
90
+ */
91
+ onPolygonPreview?: (polygon: ReadonlyArray<readonly [number, number]> | null) => void
26
92
  minVertices?: number
27
93
  /** Level ID to mount the editor to. If provided, uses createPortal for automatic level animation following. */
28
94
  levelId?: string
@@ -32,6 +98,26 @@ export interface PolygonEditorProps {
32
98
  allowPolygonMove?: boolean
33
99
  /** Whether polygon edges can be dragged along their perpendicular normal. */
34
100
  allowEdgeMove?: boolean
101
+ /** Called just before a vertex drag session starts. */
102
+ onBeforeVertexDrag?: (vertexIndex: number, position: [number, number]) => void
103
+ /** Called when a vertex handle enters or leaves hover. */
104
+ onVertexHoverChange?: (vertexIndex: number | null) => void
105
+ /** Called when a midpoint add-vertex handle enters or leaves hover. */
106
+ onMidpointHoverChange?: (edgeIndex: number | null) => void
107
+ /** Called when any polygon drag starts or ends. */
108
+ onDragStateChange?: (isDragging: boolean) => void
109
+ /** Called once when a polygon drag starts. */
110
+ onDragStart?: () => void
111
+ /** Called once when a polygon drag commits on pointer release. */
112
+ onDragCommit?: () => void
113
+ /** Whether to render the editor-owned polygon outline. */
114
+ showBorderLine?: boolean
115
+ /** Whether midpoint handles can add new vertices. */
116
+ showMidpointHandles?: boolean
117
+ /** Optional vertex handle renderer for host-specific affordances. */
118
+ renderVertexHandle?: PolygonVertexHandleRenderer
119
+ /** Optional midpoint handle renderer for host-specific add-vertex affordances. */
120
+ renderMidpointHandle?: PolygonMidpointHandleRenderer
35
121
  }
36
122
 
37
123
  /**
@@ -51,15 +137,200 @@ function getEdgeNormal(start: [number, number], end: [number, number]): [number,
51
137
  return [-dz / length, dx / length]
52
138
  }
53
139
 
140
+ type HandleClickHandler = (event: ThreeEvent<MouseEvent>) => void
141
+ type HandlePointerHandler = (event: ThreeEvent<PointerEvent>) => void
142
+
143
+ export type PolygonHandleHandlers = {
144
+ onClick?: HandleClickHandler
145
+ onDoubleClick?: HandleClickHandler
146
+ onPointerDown?: HandlePointerHandler
147
+ onPointerEnter?: HandlePointerHandler
148
+ onPointerLeave?: HandlePointerHandler
149
+ }
150
+
151
+ export type PolygonVertexHandleRenderProps = {
152
+ canDelete: boolean
153
+ handleProps: PolygonHandleHandlers
154
+ height: number
155
+ index: number
156
+ isDragging: boolean
157
+ isHovered: boolean
158
+ point: [number, number]
159
+ position: [number, number, number]
160
+ radius: number
161
+ }
162
+
163
+ export type PolygonVertexHandleRenderer = (props: PolygonVertexHandleRenderProps) => React.ReactNode
164
+
165
+ export type PolygonMidpointHandleRenderProps = {
166
+ handleProps: PolygonHandleHandlers
167
+ height: number
168
+ index: number
169
+ isHovered: boolean
170
+ point: [number, number]
171
+ position: [number, number, number]
172
+ radius: number
173
+ }
174
+
175
+ export type PolygonMidpointHandleRenderer = (
176
+ props: PolygonMidpointHandleRenderProps,
177
+ ) => React.ReactNode
178
+
179
+ function usePolygonNodeMaterial(color: string, opacity = 1): MeshBasicNodeMaterial {
180
+ const material = useMemo(
181
+ () =>
182
+ new MeshBasicNodeMaterial({
183
+ color: new Color('#ffffff'),
184
+ depthTest: false,
185
+ depthWrite: true,
186
+ opacity: 1,
187
+ transparent: true,
188
+ }),
189
+ [],
190
+ )
191
+
192
+ useEffect(() => {
193
+ material.color.set(color)
194
+ material.opacity = opacity
195
+ }, [color, material, opacity])
196
+ useEffect(() => () => material.dispose(), [material])
197
+
198
+ return material
199
+ }
200
+
201
+ // One mesh per handle: lives on SCENE_LAYER with a node material so the
202
+ // post-processing ink-edge pass outlines it, and carries the pointer handlers
203
+ // directly so it stays grabbable — matching the registry arrow gizmos in
204
+ // node-arrow-handles.tsx. No paired hit mesh is needed; the R3F event
205
+ // raycaster picks SCENE_LAYER meshes too.
206
+ function OutlinedCylinderHandle({
207
+ radius,
208
+ height,
209
+ color,
210
+ opacity = 1,
211
+ position,
212
+ ...handlers
213
+ }: {
214
+ radius: number
215
+ height: number
216
+ color: string
217
+ opacity?: number
218
+ position: [number, number, number]
219
+ } & PolygonHandleHandlers) {
220
+ const geometry = useMemo(() => new CylinderGeometry(radius, radius, height, 16), [height, radius])
221
+ const material = usePolygonNodeMaterial(color, opacity)
222
+ useEffect(() => () => geometry.dispose(), [geometry])
223
+
224
+ return (
225
+ <mesh
226
+ frustumCulled={false}
227
+ geometry={geometry}
228
+ layers={SCENE_LAYER}
229
+ material={material}
230
+ position={position}
231
+ renderOrder={1010}
232
+ {...handlers}
233
+ />
234
+ )
235
+ }
236
+
237
+ // Whole-polygon move grip — the generic 4-way cross-arrow (matching the node
238
+ // move handles) with an invisible cylinder hit area, replacing the old sphere.
239
+ // The cross sits on SCENE_LAYER (so the ink pass outlines it) while the
240
+ // cylinder hit mesh is on EDITOR_LAYER (grabbable, out of the MRT scene pass).
241
+ function OutlinedCrossHandle({
242
+ color,
243
+ position,
244
+ ...handlers
245
+ }: {
246
+ color: string
247
+ position: [number, number, number]
248
+ } & PolygonHandleHandlers) {
249
+ const geometry = useMemo(() => createMoveCrossHandleGeometry(), [])
250
+ const material = usePolygonNodeMaterial(color)
251
+ const hitGeometry = useMemo(() => new CylinderGeometry(0.24, 0.24, 0.18, 24), [])
252
+ const hitMaterial = useInvisibleHitAreaMaterial()
253
+ useEffect(() => () => geometry.dispose(), [geometry])
254
+ useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
255
+
256
+ return (
257
+ <group position={position}>
258
+ <mesh
259
+ frustumCulled={false}
260
+ geometry={geometry}
261
+ layers={SCENE_LAYER}
262
+ material={material}
263
+ raycast={NO_RAYCAST}
264
+ renderOrder={1010}
265
+ scale={EDGE_ARROW_SCALE}
266
+ />
267
+ <mesh
268
+ frustumCulled={false}
269
+ geometry={hitGeometry}
270
+ layers={EDITOR_LAYER}
271
+ material={hitMaterial}
272
+ renderOrder={1011}
273
+ {...handlers}
274
+ />
275
+ </group>
276
+ )
277
+ }
278
+
279
+ function OutlinedEdgeArrowHandle({
280
+ geometry,
281
+ color,
282
+ position,
283
+ rotationY,
284
+ scale,
285
+ ...handlers
286
+ }: {
287
+ geometry: BufferGeometry
288
+ color: string
289
+ position: [number, number, number]
290
+ rotationY: number
291
+ scale: number
292
+ } & PolygonHandleHandlers) {
293
+ const material = useArrowMaterial()
294
+ useEffect(() => {
295
+ material.color.set(color)
296
+ }, [color, material])
297
+ useEffect(() => () => material.dispose(), [material])
298
+
299
+ return (
300
+ <mesh
301
+ frustumCulled={false}
302
+ geometry={geometry}
303
+ layers={SCENE_LAYER}
304
+ material={material}
305
+ position={position}
306
+ renderOrder={1010}
307
+ rotation={[0, rotationY, 0]}
308
+ scale={scale}
309
+ {...handlers}
310
+ />
311
+ )
312
+ }
313
+
54
314
  export const PolygonEditor: React.FC<PolygonEditorProps> = ({
55
315
  polygon,
56
316
  color = '#3b82f6',
57
317
  onPolygonChange,
318
+ onPolygonPreview,
58
319
  minVertices = 3,
59
320
  levelId,
60
321
  surfaceHeight = 0,
61
322
  allowPolygonMove = false,
62
323
  allowEdgeMove = false,
324
+ onBeforeVertexDrag,
325
+ onVertexHoverChange,
326
+ onMidpointHoverChange,
327
+ onDragStateChange,
328
+ onDragStart,
329
+ onDragCommit,
330
+ showBorderLine = true,
331
+ showMidpointHandles = true,
332
+ renderMidpointHandle,
333
+ renderVertexHandle,
63
334
  }) => {
64
335
  const [levelNode, setLevelNode] = useState<Object3D | null>(() =>
65
336
  levelId ? (sceneRegistry.nodes.get(levelId) ?? null) : null,
@@ -104,10 +375,36 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
104
375
  const [dragState, setDragState] = useState<DragState | null>(null)
105
376
  const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
106
377
  const previewPolygonRef = useRef<Array<[number, number]> | null>(null)
378
+ const previousInputDraggingRef = useRef(false)
379
+
380
+ const onDragStateChangeRef = useRef(onDragStateChange)
381
+ useEffect(() => {
382
+ onDragStateChangeRef.current = onDragStateChange
383
+ }, [onDragStateChange])
384
+
385
+ const onDragStartRef = useRef(onDragStart)
386
+ useEffect(() => {
387
+ onDragStartRef.current = onDragStart
388
+ }, [onDragStart])
389
+
390
+ const onDragCommitRef = useRef(onDragCommit)
391
+ useEffect(() => {
392
+ onDragCommitRef.current = onDragCommit
393
+ }, [onDragCommit])
394
+
395
+ const onPolygonPreviewRef = useRef(onPolygonPreview)
396
+ useEffect(() => {
397
+ onPolygonPreviewRef.current = onPolygonPreview
398
+ }, [onPolygonPreview])
107
399
 
108
400
  const updatePreviewPolygon = useCallback((nextPolygon: Array<[number, number]> | null) => {
109
401
  previewPolygonRef.current = nextPolygon
110
402
  setPreviewPolygon(nextPolygon)
403
+ // Notify the host so it can mirror the in-flight polygon onto
404
+ // `useLiveNodeOverrides` (drag rebuilds the mesh at pointer rate)
405
+ // and clear that override when we hand `null` back at commit /
406
+ // cancel / external-undo time.
407
+ onPolygonPreviewRef.current?.(nextPolygon)
111
408
  }, [])
112
409
 
113
410
  // Keep ref in sync
@@ -120,18 +417,77 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
120
417
  const [hoveredEdge, setHoveredEdge] = useState<number | null>(null)
121
418
  const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
122
419
 
420
+ useEffect(() => {
421
+ onVertexHoverChange?.(hoveredVertex)
422
+ }, [hoveredVertex, onVertexHoverChange])
423
+
424
+ useEffect(() => () => onVertexHoverChange?.(null), [onVertexHoverChange])
425
+
426
+ useEffect(() => {
427
+ onMidpointHoverChange?.(hoveredMidpoint)
428
+ }, [hoveredMidpoint, onMidpointHoverChange])
429
+
430
+ useEffect(() => () => onMidpointHoverChange?.(null), [onMidpointHoverChange])
431
+
123
432
  const lineRef = useRef<Line>(null!)
124
433
  const previousPositionRef = useRef<[number, number] | null>(null)
125
434
 
126
- // Track the last polygon prop to detect external changes (undo/redo)
435
+ useEffect(() => {
436
+ onDragStateChangeRef.current?.(dragState?.isDragging ?? false)
437
+ }, [dragState?.isDragging])
438
+
439
+ useEffect(() => () => onDragStateChangeRef.current?.(false), [])
440
+
441
+ const startDrag = useCallback((nextDragState: DragState) => {
442
+ previousInputDraggingRef.current = useViewer.getState().inputDragging
443
+ useViewer.getState().setInputDragging(true)
444
+ setDragState(nextDragState)
445
+ onDragStartRef.current?.()
446
+ }, [])
447
+
448
+ useEffect(() => {
449
+ if (!dragState?.isDragging) return
450
+
451
+ return () => {
452
+ useViewer.getState().setInputDragging(previousInputDraggingRef.current)
453
+ }
454
+ }, [dragState?.isDragging])
455
+
456
+ // Track the last polygon prop to detect external changes (undo/redo) or
457
+ // our own post-commit prop update arriving while a preview is still in
458
+ // flight. Either way, drop the stale preview/drag.
459
+ //
460
+ // This block runs during render, so it may only touch THIS component's
461
+ // own state (setPreviewPolygon / setDragState — React re-renders in
462
+ // place, which is the sanctioned "adjust state on prop change" pattern).
463
+ // The host `onPolygonPreview(null)` notification clears
464
+ // `useLiveNodeOverrides` — a store OTHER components subscribe to (e.g.
465
+ // NodeArrowHandles) — so calling it here throws "Cannot update a
466
+ // component while rendering a different component". Defer it to the
467
+ // effect below.
127
468
  const lastPolygonRef = useRef(polygon)
469
+ const pendingPreviewClearRef = useRef(false)
128
470
  if (polygon !== lastPolygonRef.current) {
129
471
  lastPolygonRef.current = polygon
130
- // External change (e.g. undo/redo) clear any stale preview/drag state
131
- if (previewPolygon) updatePreviewPolygon(null)
472
+ // `previewPolygonRef` is the synchronously-updated source of truth for
473
+ // an in-flight preview (state can lag it by a render).
474
+ if (previewPolygonRef.current !== null || previewPolygon !== null) {
475
+ previewPolygonRef.current = null
476
+ setPreviewPolygon(null)
477
+ pendingPreviewClearRef.current = true
478
+ }
132
479
  if (dragState) setDragState(null)
133
480
  }
134
481
 
482
+ // Flush the deferred host preview-clear (see note above) after the
483
+ // commit, where writing to other stores is allowed.
484
+ useEffect(() => {
485
+ if (pendingPreviewClearRef.current) {
486
+ pendingPreviewClearRef.current = false
487
+ onPolygonPreviewRef.current?.(null)
488
+ }
489
+ })
490
+
135
491
  // The polygon to display (preview during drag, or actual polygon)
136
492
  const displayPolygon = previewPolygon ?? polygon
137
493
 
@@ -159,6 +515,15 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
159
515
  const edgeHandles = useMemo(() => {
160
516
  if (displayPolygon.length < 2) return []
161
517
 
518
+ let cx = 0
519
+ let cz = 0
520
+ for (const [x, z] of displayPolygon) {
521
+ cx += x
522
+ cz += z
523
+ }
524
+ cx /= displayPolygon.length
525
+ cz /= displayPolygon.length
526
+
162
527
  return displayPolygon.flatMap(([x1, z1], index) => {
163
528
  const nextIndex = (index + 1) % displayPolygon.length
164
529
  const [x2, z2] = displayPolygon[nextIndex]!
@@ -167,17 +532,33 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
167
532
  const length = Math.hypot(dx, dz)
168
533
  if (length < 1e-6) return []
169
534
 
535
+ const midpoint: [number, number] = [(x1 + x2) / 2, (z1 + z2) / 2]
536
+ // Outward normal: edge perpendicular flipped to point away from the
537
+ // polygon centroid. Independent of winding order so arrows always
538
+ // face outward even on hand-traced (mixed-winding) polygons.
539
+ let nx = -dz / length
540
+ let nz = dx / length
541
+ if (nx * (midpoint[0] - cx) + nz * (midpoint[1] - cz) < 0) {
542
+ nx = -nx
543
+ nz = -nz
544
+ }
545
+
170
546
  return [
171
547
  {
172
548
  index,
173
549
  length,
174
- midpoint: [(x1 + x2) / 2, (z1 + z2) / 2] as [number, number],
550
+ midpoint,
175
551
  rotationY: -Math.atan2(dz, dx),
552
+ outwardNormal: [nx, nz] as [number, number],
553
+ outwardAngle: -Math.atan2(nz, nx),
176
554
  },
177
555
  ]
178
556
  })
179
557
  }, [displayPolygon])
180
558
 
559
+ const arrowGeometry = useMemo(() => createEdgeArrowGeometry(), [])
560
+ useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
561
+
181
562
  // Update vertex position using grid cursor position
182
563
  const handleVertexDrag = useCallback(
183
564
  (vertexIndex: number, position: [number, number]) => {
@@ -194,6 +575,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
194
575
  if (previewPolygonRef.current) {
195
576
  onPolygonChange(previewPolygonRef.current)
196
577
  }
578
+ onDragCommitRef.current?.()
197
579
  updatePreviewPolygon(null)
198
580
  setDragState(null)
199
581
  }, [onPolygonChange, updatePreviewPolygon])
@@ -233,8 +615,9 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
233
615
  // Listen to grid:move events to track cursor position
234
616
  useEffect(() => {
235
617
  const onGridMove = (event: GridEvent) => {
236
- const gridX = snapToHalf(event.localPosition[0])
237
- const gridZ = snapToHalf(event.localPosition[2])
618
+ const point = levelNode ? event.localPosition : event.position
619
+ const gridX = snapToHalf(point[0])
620
+ const gridZ = snapToHalf(point[2])
238
621
  const newPosition: [number, number] = [gridX, gridZ]
239
622
 
240
623
  // Play snap sound when cursor moves to a new grid cell during drag
@@ -290,7 +673,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
290
673
  return () => {
291
674
  emitter.off('grid:move', onGridMove)
292
675
  }
293
- }, [dragState, handleVertexDrag, updatePreviewPolygon])
676
+ }, [dragState, handleVertexDrag, levelNode, updatePreviewPolygon])
294
677
 
295
678
  // Set up pointer up listener for ending drag
296
679
  useEffect(() => {
@@ -335,7 +718,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
335
718
 
336
719
  // Update line geometry when polygon changes
337
720
  useEffect(() => {
338
- if (!lineRef.current || displayPolygon.length < 2) return
721
+ if (!showBorderLine || !lineRef.current || displayPolygon.length < 2) return
339
722
 
340
723
  const positions: number[] = []
341
724
  for (const [x, z] of displayPolygon) {
@@ -350,7 +733,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
350
733
 
351
734
  lineRef.current.geometry.dispose()
352
735
  lineRef.current.geometry = geometry
353
- }, [displayPolygon, editY])
736
+ }, [displayPolygon, editY, showBorderLine])
354
737
 
355
738
  if (displayPolygon.length < minVertices) return null
356
739
 
@@ -358,27 +741,35 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
358
741
  const handleHeight = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)
359
742
  const edgeHandleY = editY + handleHeight - EDGE_HANDLE_HEIGHT / 2
360
743
 
744
+ // Interactive handles are single SCENE_LAYER node-material meshes (like the
745
+ // registry arrow gizmos) so the ink-edge pass outlines them while they stay
746
+ // grabbable. The edge BAR and border line stay on EDITOR_LAYER, visual-only
747
+ // (raycast disabled) so they never steal clicks from the vertex/midpoint
748
+ // handles overlapping them — edge dragging starts from the chevron arrow
749
+ // outside the polygon edge.
361
750
  const editorContent = (
362
751
  <group>
363
752
  {/* 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>
753
+ {showBorderLine && (
754
+ <line
755
+ frustumCulled={false}
756
+ layers={EDITOR_LAYER}
757
+ raycast={NO_RAYCAST}
758
+ // @ts-expect-error R3F <line> element conflicts with SVG <line> type
759
+ ref={lineRef}
760
+ renderOrder={10}
761
+ >
762
+ <bufferGeometry />
763
+ <lineBasicNodeMaterial
764
+ color={color}
765
+ depthTest={false}
766
+ depthWrite={false}
767
+ linewidth={2}
768
+ opacity={0.8}
769
+ transparent
770
+ />
771
+ </line>
772
+ )}
382
773
 
383
774
  {/* Vertex handles - blue cylinders that match surface height */}
384
775
  {displayPolygon.map(([x, z], index) => {
@@ -386,58 +777,77 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
386
777
  const isDragging = dragState?.mode === 'vertex' && dragState.vertexIndex === index
387
778
  const radius = 0.1
388
779
  const height = handleHeight
780
+ const point: [number, number] = [x!, z!]
781
+ const position: [number, number, number] = [x!, editY + height / 2, z!]
782
+ const handleProps: PolygonHandleHandlers = {
783
+ onClick: (e) => {
784
+ if (e.button !== 0) return
785
+ e.stopPropagation()
786
+ },
787
+ onDoubleClick: (e) => {
788
+ if (e.button !== 0) return
789
+ e.stopPropagation()
790
+ if (canDelete) {
791
+ handleDeleteVertex(index)
792
+ }
793
+ },
794
+ onPointerDown: (e) => {
795
+ if (e.button !== 0) return
796
+ e.stopPropagation()
797
+ setHoveredEdge(null)
798
+ onBeforeVertexDrag?.(index, point)
799
+ startDrag({
800
+ isDragging: true,
801
+ mode: 'vertex',
802
+ vertexIndex: index,
803
+ initialPosition: [x!, z!],
804
+ initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
805
+ pointerId: e.pointerId,
806
+ })
807
+ },
808
+ onPointerEnter: (e) => {
809
+ e.stopPropagation()
810
+ setHoveredVertex(index)
811
+ },
812
+ onPointerLeave: (e) => {
813
+ e.stopPropagation()
814
+ setHoveredVertex(null)
815
+ },
816
+ }
817
+
818
+ if (renderVertexHandle) {
819
+ return (
820
+ <Fragment key={`vertex-${index}`}>
821
+ {renderVertexHandle({
822
+ canDelete,
823
+ handleProps,
824
+ height,
825
+ index,
826
+ isDragging,
827
+ isHovered,
828
+ point,
829
+ position,
830
+ radius,
831
+ })}
832
+ </Fragment>
833
+ )
834
+ }
389
835
 
390
836
  return (
391
- <mesh
392
- castShadow
837
+ <OutlinedCylinderHandle
838
+ color={isDragging || isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
839
+ height={height}
393
840
  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>
841
+ {...handleProps}
842
+ position={position}
843
+ radius={radius}
844
+ />
434
845
  )
435
846
  })}
436
847
 
437
848
  {allowPolygonMove && (
438
- <mesh
439
- castShadow
440
- layers={EDITOR_LAYER}
849
+ <OutlinedCrossHandle
850
+ color={dragState?.mode === 'polygon' ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
441
851
  onClick={(e) => {
442
852
  if (e.button !== 0) return
443
853
  e.stopPropagation()
@@ -446,7 +856,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
446
856
  if (e.button !== 0) return
447
857
  e.stopPropagation()
448
858
  setHoveredEdge(null)
449
- setDragState({
859
+ startDrag({
450
860
  isDragging: true,
451
861
  mode: 'polygon',
452
862
  vertexIndex: null,
@@ -456,116 +866,156 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
456
866
  })
457
867
  }}
458
868
  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>
869
+ />
463
870
  )}
464
871
 
465
872
  {allowEdgeMove &&
466
- edgeHandles.map(({ index, length, midpoint, rotationY }) => {
873
+ edgeHandles.map(({ index, length, midpoint, rotationY, outwardNormal, outwardAngle }) => {
467
874
  const isHovered = hoveredEdge === index
468
875
  const isDragging = dragState?.mode === 'edge' && dragState.edgeIndex === index
876
+ const arrowX = midpoint[0] + outwardNormal[0] * EDGE_ARROW_OFFSET
877
+ const arrowZ = midpoint[1] + outwardNormal[1] * EDGE_ARROW_OFFSET
878
+
879
+ const beginEdgeDrag = (e: { button: number; pointerId: number }) => {
880
+ const start = displayPolygon[index]
881
+ const end = displayPolygon[(index + 1) % displayPolygon.length]
882
+ if (!(start && end)) return
883
+
884
+ const edgeNormal = getEdgeNormal(start, end)
885
+ if (!edgeNormal) return
886
+
887
+ setHoveredEdge(null)
888
+ startDrag({
889
+ isDragging: true,
890
+ mode: 'edge',
891
+ vertexIndex: null,
892
+ edgeIndex: index,
893
+ edgeNormal,
894
+ initialPosition: cursorPosition,
895
+ initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
896
+ pointerId: e.pointerId,
897
+ })
898
+ }
469
899
 
470
900
  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
901
+ <group key={`edge-${index}`}>
902
+ {/* Edge bar — VISUAL ONLY (raycast disabled). It runs the full
903
+ length of the edge and overlaps the vertex/midpoint handles at
904
+ its ends + centre, so making it pickable let edges steal those
905
+ clicks. Edge dragging runs through the chevron arrow below,
906
+ which sits outside the polygon and never overlaps a
907
+ vertex/midpoint handle. */}
908
+ <mesh
909
+ layers={EDITOR_LAYER}
910
+ position={[midpoint[0], edgeHandleY, midpoint[1]]}
911
+ raycast={NO_RAYCAST}
912
+ rotation={[0, rotationY, 0]}
913
+ >
914
+ <boxGeometry args={[length, EDGE_HANDLE_HEIGHT, EDGE_HANDLE_THICKNESS]} />
915
+ <meshBasicMaterial
916
+ color={isDragging ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
917
+ opacity={isDragging ? 0.5 : isHovered ? 0.38 : 0.14}
918
+ transparent
919
+ />
920
+ </mesh>
921
+ {/* Per-side resize arrow — the interactive edge-drag handle.
922
+ Points outward from the edge; dragging it translates only this
923
+ edge's two vertices along the outward normal. */}
924
+ <OutlinedEdgeArrowHandle
925
+ color={isDragging || isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
926
+ geometry={arrowGeometry}
927
+ onClick={(e) => {
928
+ if (e.button !== 0) return
929
+ e.stopPropagation()
930
+ }}
931
+ onPointerDown={(e) => {
932
+ if (e.button !== 0) return
933
+ e.stopPropagation()
934
+ beginEdgeDrag(e)
935
+ }}
936
+ onPointerEnter={(e) => {
937
+ e.stopPropagation()
938
+ setHoveredEdge(index)
939
+ }}
940
+ onPointerLeave={(e) => {
941
+ e.stopPropagation()
942
+ setHoveredEdge(null)
943
+ }}
944
+ position={[arrowX, edgeHandleY, arrowZ]}
945
+ rotationY={outwardAngle}
946
+ scale={EDGE_ARROW_SCALE}
516
947
  />
517
- </mesh>
948
+ </group>
518
949
  )
519
950
  })}
520
951
 
521
952
  {/* Midpoint handles - smaller green cylinders for adding vertices (hidden while dragging) */}
522
- {!dragState &&
953
+ {showMidpointHandles &&
954
+ !dragState &&
523
955
  midpoints.map(([x, z], index) => {
524
956
  const isHovered = hoveredMidpoint === index
525
957
  const radius = 0.06
526
958
  const height = handleHeight
959
+ const point: [number, number] = [x!, z!]
960
+ const position: [number, number, number] = [x!, editY + height / 2, z!]
961
+ const handleProps: PolygonHandleHandlers = {
962
+ onClick: (e) => {
963
+ if (e.button !== 0) return
964
+ e.stopPropagation()
965
+ },
966
+ onPointerDown: (e) => {
967
+ if (e.button !== 0) return
968
+ e.stopPropagation()
969
+ onBeforeVertexDrag?.(index + 1, point)
970
+ const insertedVertex = handleAddVertex(index, point)
971
+ if (insertedVertex.vertexIndex >= 0) {
972
+ startDrag({
973
+ isDragging: true,
974
+ mode: 'vertex',
975
+ vertexIndex: insertedVertex.vertexIndex,
976
+ initialPosition: point,
977
+ initialPolygon: insertedVertex.polygon,
978
+ pointerId: e.pointerId,
979
+ })
980
+ setHoveredMidpoint(null)
981
+ }
982
+ },
983
+ onPointerEnter: (e) => {
984
+ e.stopPropagation()
985
+ setHoveredMidpoint(index)
986
+ },
987
+ onPointerLeave: (e) => {
988
+ e.stopPropagation()
989
+ setHoveredMidpoint(null)
990
+ },
991
+ }
992
+
993
+ if (renderMidpointHandle) {
994
+ return (
995
+ <Fragment key={`midpoint-${index}`}>
996
+ {renderMidpointHandle({
997
+ handleProps,
998
+ height,
999
+ index,
1000
+ isHovered,
1001
+ point,
1002
+ position,
1003
+ radius,
1004
+ })}
1005
+ </Fragment>
1006
+ )
1007
+ }
527
1008
 
528
1009
  return (
529
- <mesh
1010
+ <OutlinedCylinderHandle
1011
+ color={isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
1012
+ height={height}
530
1013
  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>
1014
+ {...handleProps}
1015
+ opacity={isHovered ? 1 : 0.7}
1016
+ position={position}
1017
+ radius={radius}
1018
+ />
569
1019
  )
570
1020
  })}
571
1021
  </group>