@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
@@ -0,0 +1,523 @@
1
+ 'use client'
2
+
3
+ import { type Cursor, emitter } from '@pascal-app/core'
4
+ import type { ThreeEvent } from '@react-three/fiber'
5
+ import { type ReactNode, useEffect, useMemo, useRef } from 'react'
6
+ import {
7
+ BoxGeometry,
8
+ type BufferGeometry,
9
+ CircleGeometry,
10
+ Color,
11
+ CylinderGeometry,
12
+ DoubleSide,
13
+ ExtrudeGeometry,
14
+ type Group,
15
+ Shape,
16
+ TorusGeometry,
17
+ } from 'three'
18
+ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
19
+ import { MeshBasicNodeMaterial } from 'three/webgpu'
20
+ import { EDITOR_LAYER } from '../../../lib/constants'
21
+
22
+ export const ARROW_SCALE = 0.65
23
+ export const ARROW_COLOR = '#8381ed'
24
+ export const ARROW_HOVER_COLOR = '#a5b4fc'
25
+ export const NO_RAYCAST = () => null
26
+ export const HIT_AREA_MARGIN = 0.035
27
+
28
+ const HIT_AREA_RENDER_ORDER = 1011
29
+ const HIT_AREA_THICKNESS = 0.08
30
+ const CHEVRON_MIN_X = -0.2
31
+ const CHEVRON_MAX_X = 0.22
32
+ const CHEVRON_HALF_WIDTH = 0.12
33
+ const CHEVRON_NOTCH_X = -0.04
34
+ const CHEVRON_SHAFT_HALF_WIDTH = 0.035
35
+ const CHEVRON_DEPTH = 0.08
36
+ const CHEVRON_BEVEL_THICKNESS = 0.035
37
+ const CHEVRON_BEVEL_SIZE = 0.03
38
+ const CHEVRON_BEVEL_SEGMENTS = 10
39
+ const MOVE_CROSS_HALF_LENGTH = 0.36
40
+ const MOVE_CROSS_SHAFT_HALF_WIDTH = 0.03
41
+ const MOVE_CROSS_HEAD_HALF_WIDTH = 0.12
42
+ const MOVE_CROSS_HEAD_INSET = 0.2
43
+ const MOVE_CROSS_DEPTH = 0.06
44
+ const MOVE_CROSS_BEVEL_THICKNESS = 0.018
45
+ const MOVE_CROSS_BEVEL_SIZE = 0.012
46
+ const MOVE_CROSS_BEVEL_SEGMENTS = 6
47
+ const ROTATE_HANDLE_RADIUS = 0.2
48
+ const ROTATE_HANDLE_HALF_SWEEP = Math.PI / 3
49
+ const ROTATE_RIBBON_HALF_WIDTH = 0.02
50
+ const ROTATE_HEAD_HALF_WIDTH = 0.045
51
+ const TRACKER_CUBE_SIZE = 0.16
52
+ export const CORNER_HEX_RADIUS = 0.16
53
+
54
+ export type HandleArrowShape = 'chevron' | 'cross' | 'curved-arrow' | 'tracker' | 'corner-picker'
55
+ export type HandleArrowInputShape = HandleArrowShape | 'arrow' | 'move-cross'
56
+
57
+ export type HandleArrowPlacement = {
58
+ position: readonly [number, number, number]
59
+ rotation?: readonly [number, number, number]
60
+ baseScale: number
61
+ }
62
+
63
+ type PointerHandler = (event: ThreeEvent<PointerEvent>) => void
64
+
65
+ export type HandleArrowProps = {
66
+ shape: HandleArrowInputShape
67
+ placement: HandleArrowPlacement
68
+ hover: boolean
69
+ cursor: Cursor
70
+ onHoverChange: (hovered: boolean) => void
71
+ onPointerDown: PointerHandler
72
+ activeCursor?: Cursor
73
+ children?: ReactNode
74
+ hoverScale?: number
75
+ indicatorRotation?: readonly [number, number, number]
76
+ onPointerEnter?: PointerHandler
77
+ onPointerLeave?: PointerHandler
78
+ }
79
+
80
+ function normalizeHandleArrowShape(shape: HandleArrowInputShape, cursor: Cursor): HandleArrowShape {
81
+ if (shape === 'arrow') return 'chevron'
82
+ if (shape === 'move-cross') return 'cross'
83
+ if (shape === 'chevron' && cursor === 'move') return 'cross'
84
+ return shape
85
+ }
86
+
87
+ // Two-headed curved-arrow silhouette for whole-node rotation handles
88
+ // (today: the elevator's corner rotate gizmo). Symmetric arc centred on
89
+ // +X with sweeps to +/-halfSweep, arrowhead wings + tangentially-extended
90
+ // tips at each end. Drawn in 2D then extruded and rotated to lie in the
91
+ // XZ plane - same final-orientation contract as the chevron, so the
92
+ // outer rotation Y and inner-rotation chain in the renderer are reused
93
+ // unchanged.
94
+ export function createRotateArrowHandleGeometry() {
95
+ const R = 0.2
96
+ const ribbonHalfWidth = 0.02
97
+ const halfSweep = Math.PI / 3
98
+ const headHalfWidth = 0.045
99
+ const headOvershoot = 0.075
100
+ const rIn = R - ribbonHalfWidth
101
+ const rOut = R + ribbonHalfWidth
102
+ const a1 = halfSweep
103
+ const a2 = -halfSweep
104
+
105
+ const tip1: [number, number] = [
106
+ R * Math.cos(a1) - headOvershoot * Math.sin(a1),
107
+ R * Math.sin(a1) + headOvershoot * Math.cos(a1),
108
+ ]
109
+ const tip2: [number, number] = [
110
+ R * Math.cos(a2) + headOvershoot * Math.sin(a2),
111
+ R * Math.sin(a2) - headOvershoot * Math.cos(a2),
112
+ ]
113
+ const innerWing1: [number, number] = [
114
+ (rIn - headHalfWidth) * Math.cos(a1),
115
+ (rIn - headHalfWidth) * Math.sin(a1),
116
+ ]
117
+ const outerWing1: [number, number] = [
118
+ (rOut + headHalfWidth) * Math.cos(a1),
119
+ (rOut + headHalfWidth) * Math.sin(a1),
120
+ ]
121
+ const innerWing2: [number, number] = [
122
+ (rIn - headHalfWidth) * Math.cos(a2),
123
+ (rIn - headHalfWidth) * Math.sin(a2),
124
+ ]
125
+ const outerWing2: [number, number] = [
126
+ (rOut + headHalfWidth) * Math.cos(a2),
127
+ (rOut + headHalfWidth) * Math.sin(a2),
128
+ ]
129
+ const innerCorner1: [number, number] = [rIn * Math.cos(a1), rIn * Math.sin(a1)]
130
+ const outerCorner1: [number, number] = [rOut * Math.cos(a1), rOut * Math.sin(a1)]
131
+ const innerCorner2: [number, number] = [rIn * Math.cos(a2), rIn * Math.sin(a2)]
132
+ const outerCorner2: [number, number] = [rOut * Math.cos(a2), rOut * Math.sin(a2)]
133
+
134
+ const shape = new Shape()
135
+ shape.moveTo(innerCorner1[0], innerCorner1[1])
136
+ shape.lineTo(innerWing1[0], innerWing1[1])
137
+ shape.lineTo(tip1[0], tip1[1])
138
+ shape.lineTo(outerWing1[0], outerWing1[1])
139
+ shape.lineTo(outerCorner1[0], outerCorner1[1])
140
+ shape.absarc(0, 0, rOut, a1, a2, true)
141
+ shape.lineTo(outerWing2[0], outerWing2[1])
142
+ shape.lineTo(tip2[0], tip2[1])
143
+ shape.lineTo(innerWing2[0], innerWing2[1])
144
+ shape.lineTo(innerCorner2[0], innerCorner2[1])
145
+ shape.absarc(0, 0, rIn, a2, a1, false)
146
+ shape.closePath()
147
+
148
+ const geometry = new ExtrudeGeometry(shape, {
149
+ depth: 0.06,
150
+ bevelEnabled: true,
151
+ bevelThickness: 0.018,
152
+ bevelSize: 0.012,
153
+ bevelOffset: 0,
154
+ bevelSegments: 6,
155
+ curveSegments: 24,
156
+ steps: 1,
157
+ })
158
+ geometry.translate(0, 0, -0.03)
159
+ geometry.rotateX(-Math.PI / 2)
160
+ geometry.computeVertexNormals()
161
+ geometry.computeBoundingSphere()
162
+ return geometry
163
+ }
164
+
165
+ // Reused chevron+shaft silhouette. The chevron points along +X by default;
166
+ // callers rotate it around Y for Z-axis handles and into a vertical frame for
167
+ // Y-axis handles.
168
+ export function createArrowHandleGeometry() {
169
+ const shape = new Shape()
170
+ shape.moveTo(CHEVRON_MAX_X, 0)
171
+ shape.lineTo(CHEVRON_NOTCH_X, CHEVRON_HALF_WIDTH)
172
+ shape.lineTo(CHEVRON_NOTCH_X, CHEVRON_SHAFT_HALF_WIDTH)
173
+ shape.lineTo(CHEVRON_MIN_X, CHEVRON_SHAFT_HALF_WIDTH)
174
+ shape.lineTo(CHEVRON_MIN_X, -CHEVRON_SHAFT_HALF_WIDTH)
175
+ shape.lineTo(CHEVRON_NOTCH_X, -CHEVRON_SHAFT_HALF_WIDTH)
176
+ shape.lineTo(CHEVRON_NOTCH_X, -CHEVRON_HALF_WIDTH)
177
+ shape.lineTo(CHEVRON_MAX_X, 0)
178
+ const geometry = new ExtrudeGeometry(shape, {
179
+ depth: CHEVRON_DEPTH,
180
+ bevelEnabled: true,
181
+ bevelThickness: CHEVRON_BEVEL_THICKNESS,
182
+ bevelSize: CHEVRON_BEVEL_SIZE,
183
+ bevelOffset: 0,
184
+ bevelSegments: CHEVRON_BEVEL_SEGMENTS,
185
+ curveSegments: 16,
186
+ steps: 1,
187
+ })
188
+ geometry.translate(0, 0, -CHEVRON_DEPTH / 2)
189
+ geometry.rotateX(-Math.PI / 2)
190
+ geometry.computeVertexNormals()
191
+ geometry.computeBoundingSphere()
192
+ return geometry
193
+ }
194
+
195
+ function createDoubleArrowShape(): Shape {
196
+ const shape = new Shape()
197
+ shape.moveTo(MOVE_CROSS_HALF_LENGTH, 0)
198
+ shape.lineTo(MOVE_CROSS_HEAD_INSET, MOVE_CROSS_HEAD_HALF_WIDTH)
199
+ shape.lineTo(MOVE_CROSS_HEAD_INSET, MOVE_CROSS_SHAFT_HALF_WIDTH)
200
+ shape.lineTo(-MOVE_CROSS_HEAD_INSET, MOVE_CROSS_SHAFT_HALF_WIDTH)
201
+ shape.lineTo(-MOVE_CROSS_HEAD_INSET, MOVE_CROSS_HEAD_HALF_WIDTH)
202
+ shape.lineTo(-MOVE_CROSS_HALF_LENGTH, 0)
203
+ shape.lineTo(-MOVE_CROSS_HEAD_INSET, -MOVE_CROSS_HEAD_HALF_WIDTH)
204
+ shape.lineTo(-MOVE_CROSS_HEAD_INSET, -MOVE_CROSS_SHAFT_HALF_WIDTH)
205
+ shape.lineTo(MOVE_CROSS_HEAD_INSET, -MOVE_CROSS_SHAFT_HALF_WIDTH)
206
+ shape.lineTo(MOVE_CROSS_HEAD_INSET, -MOVE_CROSS_HEAD_HALF_WIDTH)
207
+ shape.closePath()
208
+ return shape
209
+ }
210
+
211
+ export function createMoveCrossHandleGeometry() {
212
+ const shape = createDoubleArrowShape()
213
+ const extrudeOpts = {
214
+ depth: MOVE_CROSS_DEPTH,
215
+ bevelEnabled: true,
216
+ bevelThickness: MOVE_CROSS_BEVEL_THICKNESS,
217
+ bevelSize: MOVE_CROSS_BEVEL_SIZE,
218
+ bevelOffset: 0,
219
+ bevelSegments: MOVE_CROSS_BEVEL_SEGMENTS,
220
+ curveSegments: 8,
221
+ steps: 1,
222
+ }
223
+ const armX = new ExtrudeGeometry(shape, extrudeOpts)
224
+ armX.translate(0, 0, -MOVE_CROSS_DEPTH / 2)
225
+ armX.rotateX(-Math.PI / 2)
226
+ const armZ = armX.clone()
227
+ armZ.rotateY(Math.PI / 2)
228
+ const merged = mergeGeometries([armX, armZ], false)
229
+ if (!merged) {
230
+ armZ.dispose()
231
+ armX.computeVertexNormals()
232
+ armX.computeBoundingSphere()
233
+ return armX
234
+ }
235
+ armX.dispose()
236
+ armZ.dispose()
237
+ merged.computeVertexNormals()
238
+ merged.computeBoundingSphere()
239
+ return merged
240
+ }
241
+
242
+ export function createArrowHitAreaGeometry() {
243
+ const length = CHEVRON_MAX_X - CHEVRON_MIN_X + HIT_AREA_MARGIN * 2
244
+ const centerX = (CHEVRON_MIN_X + CHEVRON_MAX_X) / 2
245
+ const geometry = new CylinderGeometry(
246
+ CHEVRON_HALF_WIDTH + HIT_AREA_MARGIN,
247
+ CHEVRON_HALF_WIDTH + HIT_AREA_MARGIN,
248
+ length,
249
+ 16,
250
+ )
251
+ geometry.rotateZ(-Math.PI / 2)
252
+ geometry.translate(centerX, 0, 0)
253
+ geometry.computeBoundingSphere()
254
+ return geometry
255
+ }
256
+
257
+ function createMoveCrossHitAreaGeometry() {
258
+ const geometry = new CylinderGeometry(
259
+ MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN,
260
+ MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN,
261
+ HIT_AREA_THICKNESS,
262
+ 32,
263
+ )
264
+ geometry.computeBoundingSphere()
265
+ return geometry
266
+ }
267
+
268
+ export function createRotateArrowHitAreaGeometry() {
269
+ const halfSweep = ROTATE_HANDLE_HALF_SWEEP + HIT_AREA_MARGIN / ROTATE_HANDLE_RADIUS
270
+ const geometry = new TorusGeometry(
271
+ ROTATE_HANDLE_RADIUS,
272
+ ROTATE_RIBBON_HALF_WIDTH + ROTATE_HEAD_HALF_WIDTH + HIT_AREA_MARGIN,
273
+ 10,
274
+ 64,
275
+ halfSweep * 2,
276
+ )
277
+ geometry.rotateZ(-halfSweep)
278
+ geometry.rotateX(-Math.PI / 2)
279
+ geometry.computeBoundingSphere()
280
+ return geometry
281
+ }
282
+
283
+ function createTrackerHitAreaGeometry() {
284
+ const size = TRACKER_CUBE_SIZE + HIT_AREA_MARGIN * 2
285
+ const geometry = new BoxGeometry(size, size, size)
286
+ geometry.computeBoundingSphere()
287
+ return geometry
288
+ }
289
+
290
+ export function createEndpointHitAreaGeometry(radius: number) {
291
+ const geometry = new CylinderGeometry(
292
+ radius + HIT_AREA_MARGIN,
293
+ radius + HIT_AREA_MARGIN,
294
+ HIT_AREA_THICKNESS,
295
+ 24,
296
+ )
297
+ geometry.rotateX(Math.PI / 2)
298
+ geometry.computeBoundingSphere()
299
+ return geometry
300
+ }
301
+
302
+ function createHandleArrowGeometry(shape: HandleArrowShape) {
303
+ if (shape === 'chevron') return createArrowHandleGeometry()
304
+ if (shape === 'cross') return createMoveCrossHandleGeometry()
305
+ if (shape === 'curved-arrow') return createRotateArrowHandleGeometry()
306
+ if (shape === 'tracker') {
307
+ const geometry = new BoxGeometry(TRACKER_CUBE_SIZE, TRACKER_CUBE_SIZE, TRACKER_CUBE_SIZE)
308
+ geometry.computeBoundingSphere()
309
+ return geometry
310
+ }
311
+ const geometry = new CircleGeometry(CORNER_HEX_RADIUS, 6)
312
+ geometry.computeBoundingSphere()
313
+ return geometry
314
+ }
315
+
316
+ function createHandleArrowHitGeometry(shape: HandleArrowShape) {
317
+ if (shape === 'chevron') return createArrowHitAreaGeometry()
318
+ if (shape === 'cross') return createMoveCrossHitAreaGeometry()
319
+ if (shape === 'curved-arrow') return createRotateArrowHitAreaGeometry()
320
+ if (shape === 'tracker') return createTrackerHitAreaGeometry()
321
+ const geometry = new CircleGeometry(CORNER_HEX_RADIUS, 6)
322
+ geometry.computeBoundingSphere()
323
+ return geometry
324
+ }
325
+
326
+ let sharedHitAreaMaterial: MeshBasicNodeMaterial | null = null
327
+ let sharedHitAreaMaterialRefs = 0
328
+
329
+ function createInvisibleHitAreaMaterial() {
330
+ return new MeshBasicNodeMaterial({
331
+ color: new Color('#ffffff'),
332
+ colorWrite: false,
333
+ depthTest: false,
334
+ depthWrite: false,
335
+ opacity: 0,
336
+ side: DoubleSide,
337
+ transparent: true,
338
+ })
339
+ }
340
+
341
+ export function useInvisibleHitAreaMaterial(): MeshBasicNodeMaterial {
342
+ const materialRef = useRef<MeshBasicNodeMaterial | null>(null)
343
+ if (!materialRef.current) {
344
+ sharedHitAreaMaterial ??= createInvisibleHitAreaMaterial()
345
+ materialRef.current = sharedHitAreaMaterial
346
+ }
347
+ useEffect(() => {
348
+ sharedHitAreaMaterialRefs += 1
349
+ return () => {
350
+ sharedHitAreaMaterialRefs -= 1
351
+ if (sharedHitAreaMaterialRefs <= 0 && sharedHitAreaMaterial) {
352
+ sharedHitAreaMaterial.dispose()
353
+ sharedHitAreaMaterial = null
354
+ sharedHitAreaMaterialRefs = 0
355
+ }
356
+ }
357
+ }, [])
358
+ return materialRef.current
359
+ }
360
+
361
+ export function InvisibleHandleHitArea({
362
+ geometry,
363
+ material,
364
+ onPointerDown,
365
+ onPointerEnter,
366
+ onPointerLeave,
367
+ scale,
368
+ }: {
369
+ geometry: BufferGeometry
370
+ material: MeshBasicNodeMaterial
371
+ onPointerDown: PointerHandler
372
+ onPointerEnter: PointerHandler
373
+ onPointerLeave: PointerHandler
374
+ scale: number
375
+ }) {
376
+ return (
377
+ <mesh
378
+ frustumCulled={false}
379
+ geometry={geometry}
380
+ layers={EDITOR_LAYER}
381
+ material={material}
382
+ onPointerDown={onPointerDown}
383
+ onPointerEnter={onPointerEnter}
384
+ onPointerLeave={onPointerLeave}
385
+ renderOrder={HIT_AREA_RENDER_ORDER}
386
+ scale={scale}
387
+ />
388
+ )
389
+ }
390
+
391
+ export function useArrowMaterial(): MeshBasicNodeMaterial {
392
+ return useMemo(
393
+ () =>
394
+ new MeshBasicNodeMaterial({
395
+ color: new Color(ARROW_COLOR),
396
+ side: DoubleSide,
397
+ // `depthTest: false` keeps the chevron drawing on top of any
398
+ // geometry under it; `depthWrite: true` puts the chevron's depth
399
+ // into the scenePass buffer so the ink-edge shader's depth
400
+ // Laplacian fires on its silhouette from every angle. Without
401
+ // depthWrite, only the normal-discontinuity branch can detect
402
+ // the chevron, and that signal collapses when the arrow's faces
403
+ // happen to align with whatever sits behind them in screen space
404
+ // - which is why the lines used to drop out depending on the view.
405
+ depthTest: false,
406
+ depthWrite: true,
407
+ transparent: true,
408
+ opacity: 1,
409
+ }),
410
+ [],
411
+ )
412
+ }
413
+
414
+ function useHandleArrowMaterial(shape: HandleArrowShape): MeshBasicNodeMaterial {
415
+ return useMemo(
416
+ () =>
417
+ new MeshBasicNodeMaterial({
418
+ color: new Color(ARROW_COLOR),
419
+ side: DoubleSide,
420
+ transparent: true,
421
+ opacity: shape === 'corner-picker' ? 0.95 : 1,
422
+ depthTest: false,
423
+ depthWrite: shape !== 'corner-picker',
424
+ }),
425
+ [shape],
426
+ )
427
+ }
428
+
429
+ function indicatorRenderOrder(shape: HandleArrowShape) {
430
+ return shape === 'tracker' || shape === 'corner-picker' ? 1003 : 1010
431
+ }
432
+
433
+ export function HandleArrow({
434
+ shape,
435
+ placement,
436
+ hover,
437
+ cursor,
438
+ activeCursor,
439
+ children,
440
+ hoverScale = 1.12,
441
+ indicatorRotation,
442
+ onHoverChange,
443
+ onPointerDown,
444
+ onPointerEnter,
445
+ onPointerLeave,
446
+ }: HandleArrowProps) {
447
+ const visualShape = normalizeHandleArrowShape(shape, cursor)
448
+ const geometry = useMemo(() => createHandleArrowGeometry(visualShape), [visualShape])
449
+ const hitGeometry = useMemo(() => createHandleArrowHitGeometry(visualShape), [visualShape])
450
+ const indicatorMaterial = useHandleArrowMaterial(visualShape)
451
+ const hitMaterial = useInvisibleHitAreaMaterial()
452
+ const rootRef = useRef<Group>(null)
453
+ const rotation: [number, number, number] = placement.rotation
454
+ ? [placement.rotation[0], placement.rotation[1], placement.rotation[2]]
455
+ : [0, 0, 0]
456
+ const localRotation: [number, number, number] = indicatorRotation
457
+ ? [indicatorRotation[0], indicatorRotation[1], indicatorRotation[2]]
458
+ : [0, 0, 0]
459
+ const scale = (hover ? hoverScale : 1) * placement.baseScale
460
+ const hitScale = visualShape === 'corner-picker' ? scale : placement.baseScale
461
+
462
+ useEffect(() => {
463
+ indicatorMaterial.color.set(hover ? ARROW_HOVER_COLOR : ARROW_COLOR)
464
+ }, [indicatorMaterial, hover])
465
+ useEffect(() => {
466
+ const hideForCapture = () => {
467
+ if (rootRef.current) rootRef.current.visible = false
468
+ }
469
+ const restoreAfterCapture = () => {
470
+ if (rootRef.current) rootRef.current.visible = true
471
+ }
472
+ emitter.on('thumbnail:before-capture', hideForCapture)
473
+ emitter.on('thumbnail:after-capture', restoreAfterCapture)
474
+ return () => {
475
+ emitter.off('thumbnail:before-capture', hideForCapture)
476
+ emitter.off('thumbnail:after-capture', restoreAfterCapture)
477
+ }
478
+ }, [])
479
+ useEffect(() => () => geometry.dispose(), [geometry])
480
+ useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
481
+ useEffect(() => () => indicatorMaterial.dispose(), [indicatorMaterial])
482
+
483
+ const handleEnter: PointerHandler = (event) => {
484
+ event.stopPropagation()
485
+ onHoverChange(true)
486
+ if (!activeCursor || document.body.style.cursor !== activeCursor) {
487
+ document.body.style.cursor = cursor
488
+ }
489
+ onPointerEnter?.(event)
490
+ }
491
+ const handleLeave: PointerHandler = (event) => {
492
+ event.stopPropagation()
493
+ onHoverChange(false)
494
+ if (document.body.style.cursor === cursor) {
495
+ document.body.style.cursor = ''
496
+ }
497
+ onPointerLeave?.(event)
498
+ }
499
+
500
+ return (
501
+ <group position={placement.position} ref={rootRef} rotation={rotation}>
502
+ {children}
503
+ <group rotation={localRotation}>
504
+ <InvisibleHandleHitArea
505
+ geometry={hitGeometry}
506
+ material={hitMaterial}
507
+ onPointerDown={onPointerDown}
508
+ onPointerEnter={handleEnter}
509
+ onPointerLeave={handleLeave}
510
+ scale={hitScale}
511
+ />
512
+ <mesh
513
+ frustumCulled={false}
514
+ geometry={geometry}
515
+ material={indicatorMaterial}
516
+ raycast={NO_RAYCAST}
517
+ renderOrder={indicatorRenderOrder(visualShape)}
518
+ scale={scale}
519
+ />
520
+ </group>
521
+ </group>
522
+ )
523
+ }