@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,1453 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ type ArcResizeHandle,
7
+ type Cursor,
8
+ createSceneApi,
9
+ type HandleDescriptor,
10
+ type HandlePortal,
11
+ type LinearResizeHandle,
12
+ nodeRegistry,
13
+ type RadialResizeHandle,
14
+ sceneRegistry,
15
+ snapScalar,
16
+ type TapActionHandle,
17
+ type TranslateHandle,
18
+ useLiveNodeOverrides,
19
+ useScene,
20
+ } from '@pascal-app/core'
21
+ import { useViewer } from '@pascal-app/viewer'
22
+ import { Html } from '@react-three/drei'
23
+ import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber'
24
+ import { useEffect, useMemo, useRef, useState } from 'react'
25
+ import {
26
+ BufferGeometry,
27
+ Color,
28
+ CylinderGeometry,
29
+ DoubleSide,
30
+ Float32BufferAttribute,
31
+ type Group,
32
+ Matrix4,
33
+ type Object3D,
34
+ OrthographicCamera,
35
+ Plane,
36
+ Quaternion,
37
+ RingGeometry,
38
+ Vector3,
39
+ } from 'three'
40
+ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
41
+ import { MeshBasicNodeMaterial } from 'three/webgpu'
42
+ import { EDITOR_LAYER } from '../../lib/constants'
43
+ import { createEditorApi } from '../../lib/editor-api'
44
+ import { sfxEmitter } from '../../lib/sfx-bus'
45
+ import useEditor from '../../store/use-editor'
46
+ import { formatAngleRadians } from '../tools/shared/segment-angle'
47
+ import {
48
+ ARROW_COLOR,
49
+ ARROW_HOVER_COLOR,
50
+ ARROW_SCALE,
51
+ CORNER_HEX_RADIUS,
52
+ HandleArrow,
53
+ NO_RAYCAST,
54
+ } from './handles/handle-arrow'
55
+ import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag'
56
+
57
+ export {
58
+ ARROW_COLOR,
59
+ ARROW_HOVER_COLOR,
60
+ ARROW_SCALE,
61
+ createArrowHitAreaGeometry,
62
+ createEndpointHitAreaGeometry,
63
+ createMoveCrossHandleGeometry,
64
+ createRotateArrowHandleGeometry,
65
+ createRotateArrowHitAreaGeometry,
66
+ HIT_AREA_MARGIN,
67
+ InvisibleHandleHitArea,
68
+ NO_RAYCAST,
69
+ useArrowMaterial,
70
+ useInvisibleHitAreaMaterial,
71
+ } from './handles/handle-arrow'
72
+ export { swallowNextClick } from './handles/use-handle-drag'
73
+
74
+ // How far a DOWNWARD tracker's dashed leader pokes past its cube so the
75
+ // dashes visibly thread through it (the cube sits ON the line, not at
76
+ // its end). Upward trackers — wall / chimney height — stop at the cube.
77
+ const TRACKER_THROUGH = 0.12
78
+
79
+ // Mirrors the formatter used by wall / fence measurement labels so all
80
+ // in-world dimension chips read consistently.
81
+ function formatDimension(value: number, unit: 'metric' | 'imperial'): string {
82
+ if (unit === 'imperial') {
83
+ const feet = value * 3.280_84
84
+ const wholeFeet = Math.floor(feet)
85
+ const inches = Math.round((feet - wholeFeet) * 12)
86
+ if (inches === 12) return `${wholeFeet + 1}'0"`
87
+ return `${wholeFeet}'${inches}"`
88
+ }
89
+ return `${Number.parseFloat(value.toFixed(2))}m`
90
+ }
91
+
92
+ // In-world dimension chip rendered next to a resize arrow during hover
93
+ // or drag. Uses the same screen-space `<Html>` recipe + text-shadow
94
+ // halo as the wall measurement label so it reads at every camera angle.
95
+ function DimensionLabel({
96
+ position,
97
+ text,
98
+ }: {
99
+ position: readonly [number, number, number]
100
+ text: string
101
+ }) {
102
+ return (
103
+ <Html
104
+ center
105
+ position={position as unknown as [number, number, number]}
106
+ style={{ pointerEvents: 'none', userSelect: 'none' }}
107
+ zIndexRange={[25, 0]}
108
+ >
109
+ <div
110
+ className="whitespace-nowrap font-bold font-mono text-[13px]"
111
+ style={{
112
+ color: '#fafafa',
113
+ textShadow:
114
+ '-1.5px -1.5px 0 #0b0b0b, 1.5px -1.5px 0 #0b0b0b, -1.5px 1.5px 0 #0b0b0b, 1.5px 1.5px 0 #0b0b0b, 0 0 4px #0b0b0b',
115
+ }}
116
+ >
117
+ {text}
118
+ </div>
119
+ </Html>
120
+ )
121
+ }
122
+
123
+ export function NodeArrowHandles() {
124
+ const selectedIds = useViewer((state) => state.selection.selectedIds)
125
+ const mode = useEditor((state) => state.mode)
126
+ const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
127
+ const movingNode = useEditor((state) => state.movingNode)
128
+ const placementDragMode = useEditor((state) => state.placementDragMode)
129
+ // Endpoint / curve drags reshape the selected wall or fence; hide its
130
+ // resize arrows for the duration so they don't clutter (or get blocked
131
+ // by) the drag's own cursor + dimension overlays. Mirrors the same guard
132
+ // on the legacy wall handles (`WallMoveSideHandles`).
133
+ const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
134
+ const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
135
+ const curvingWall = useEditor((state) => state.curvingWall)
136
+ const curvingFence = useEditor((state) => state.curvingFence)
137
+
138
+ const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
139
+ const rawNode = useScene((state) =>
140
+ selectedId ? (state.nodes[selectedId as AnyNodeId] ?? null) : null,
141
+ )
142
+
143
+ // Merge any live drag override so the arrows themselves (positions,
144
+ // ring decorations) track the in-flight drag instead of freezing at
145
+ // pre-drag values. Subscribe to just this node's entry so unrelated
146
+ // override writes don't re-render the handle stack.
147
+ const liveOverride = useLiveNodeOverrides((s) =>
148
+ rawNode ? s.overrides.get(rawNode.id) : undefined,
149
+ )
150
+ const node = useMemo<AnyNode | null>(
151
+ () => (rawNode && liveOverride ? ({ ...rawNode, ...liveOverride } as AnyNode) : rawNode),
152
+ [rawNode, liveOverride],
153
+ )
154
+ const isOwnPressDragMove =
155
+ placementDragMode && movingNode !== null && selectedId !== null && movingNode.id === selectedId
156
+
157
+ const def = node ? nodeRegistry.get(node.type) : null
158
+ const descriptors = useMemo(() => {
159
+ if (!(node && def?.handles)) return null
160
+ return typeof def.handles === 'function'
161
+ ? def.handles(node as never)
162
+ : (def.handles as HandleDescriptor[])
163
+ }, [node, def])
164
+
165
+ const shouldRender =
166
+ Boolean(node && descriptors?.length) &&
167
+ !isFloorplanHovered &&
168
+ mode !== 'delete' &&
169
+ (!movingNode || isOwnPressDragMove) &&
170
+ !movingWallEndpoint &&
171
+ !movingFenceEndpoint &&
172
+ !curvingWall &&
173
+ !curvingFence
174
+
175
+ if (!shouldRender || !node || !descriptors) return null
176
+ // Key by the selected node id so switching selection REMOUNTS the rig.
177
+ // The portal target + ride-mesh refs are seeded from the scene registry
178
+ // in `useState` initializers; without a remount they'd persist from the
179
+ // previous selection and the arrows would ride the old node's world pose
180
+ // (right local placements, wrong frame) until the resolve effect happened
181
+ // to catch up. Remounting re-resolves both refs synchronously for the new
182
+ // node, so the arrows land in the right place the instant it's selected.
183
+ return <NodeArrowHandlesForNode descriptors={descriptors} key={node.id} node={node} />
184
+ }
185
+
186
+ // Resolves the portal target + ride mesh chain. Descriptor-level `portal`
187
+ // toggles between two layout patterns; descriptor placement is *always* in
188
+ // the selected node's local frame regardless of mode.
189
+ //
190
+ // - 'parent' (default): mount inside the selected node's parent mesh.
191
+ // The wrapper mirrors the node's own local pose, so handles live in
192
+ // node-local coords directly. No inner group. Used by columns / walls
193
+ // / anything where the node IS the thing the user selected and whose
194
+ // rotation should drive the handle frame.
195
+ // - 'grandparent': mount inside the grandparent mesh (to escape the
196
+ // parent's selection-outline traversal). The wrapper mirrors the
197
+ // parent mesh's local pose; a nested inner group mirrors the node's
198
+ // own local pose. Handles end up in node-local coords. Used by doors /
199
+ // windows — handles need to ride the wall's rotation but not be
200
+ // children of the wall mesh.
201
+ function NodeArrowHandlesForNode({
202
+ node,
203
+ descriptors,
204
+ }: {
205
+ node: AnyNode
206
+ descriptors: HandleDescriptor[]
207
+ }) {
208
+ const parentId = node.parentId ?? null
209
+ const grandparentId = useScene((state) => {
210
+ if (!parentId) return null
211
+ const parent = state.nodes[parentId as AnyNodeId]
212
+ return parent?.parentId ?? null
213
+ })
214
+
215
+ const portalMode: HandlePortal = descriptors.some((d) => d.portal === 'grandparent')
216
+ ? 'grandparent'
217
+ : 'parent'
218
+
219
+ // Portal target: the mesh we createPortal into.
220
+ const portalTargetId = portalMode === 'grandparent' ? grandparentId : parentId
221
+ // Outer wrapper mirrors this mesh's local pose. For 'parent' mode the
222
+ // outer IS the node (so handles + drag math both live in node-local).
223
+ // For 'grandparent' the outer rides the parent and an inner group adds
224
+ // the node's own pose.
225
+ const outerRideId = portalMode === 'grandparent' ? parentId : (node.id as AnyNodeId)
226
+ const innerRideId = portalMode === 'grandparent' ? (node.id as AnyNodeId) : null
227
+
228
+ const [portalObject, setPortalObject] = useState<Object3D | null>(() =>
229
+ portalTargetId ? (sceneRegistry.nodes.get(portalTargetId as AnyNodeId) ?? null) : null,
230
+ )
231
+ const [outerRide, setOuterRide] = useState<Object3D | null>(() =>
232
+ outerRideId ? (sceneRegistry.nodes.get(outerRideId as AnyNodeId) ?? null) : null,
233
+ )
234
+ const [innerRide, setInnerRide] = useState<Object3D | null>(() =>
235
+ innerRideId ? (sceneRegistry.nodes.get(innerRideId as AnyNodeId) ?? null) : null,
236
+ )
237
+
238
+ useEffect(() => {
239
+ let frameId = 0
240
+ const resolve = () => {
241
+ const nextPortal = portalTargetId
242
+ ? (sceneRegistry.nodes.get(portalTargetId as AnyNodeId) ?? null)
243
+ : null
244
+ const nextOuter = outerRideId
245
+ ? (sceneRegistry.nodes.get(outerRideId as AnyNodeId) ?? null)
246
+ : null
247
+ const nextInner = innerRideId
248
+ ? (sceneRegistry.nodes.get(innerRideId as AnyNodeId) ?? null)
249
+ : null
250
+ setPortalObject((cur) => (cur === nextPortal ? cur : nextPortal))
251
+ setOuterRide((cur) => (cur === nextOuter ? cur : nextOuter))
252
+ setInnerRide((cur) => (cur === nextInner ? cur : nextInner))
253
+ // Inner ride is optional ('parent' mode skips it).
254
+ const needInner = innerRideId !== null
255
+ if (!nextPortal || !nextOuter || (needInner && !nextInner)) {
256
+ frameId = window.requestAnimationFrame(resolve)
257
+ }
258
+ }
259
+ resolve()
260
+ return () => {
261
+ if (frameId) window.cancelAnimationFrame(frameId)
262
+ }
263
+ }, [portalTargetId, outerRideId, innerRideId])
264
+
265
+ const outerRef = useRef<Group>(null)
266
+ const innerRef = useRef<Group>(null)
267
+
268
+ // Keep arrow objects on SCENE_LAYER so the post-processing scenePass
269
+ // captures them in the depth/normal MRT — that's what feeds the ink-edge
270
+ // shader, and it's the reason the wall height arrow (which also stays on
271
+ // SCENE_LAYER) reads as a proper 3D plate with outlined edges. Putting
272
+ // them on EDITOR_LAYER hides them from scenePass and the chevron renders
273
+ // flat. Arrows are only mounted while a node is selected, so thumbnail
274
+ // captures (which never have selection) don't need the layer-based
275
+ // exclusion the wall arrow also goes without.
276
+
277
+ useFrame(() => {
278
+ if (outerRef.current && outerRide) {
279
+ outerRef.current.position.copy(outerRide.position)
280
+ outerRef.current.quaternion.copy(outerRide.quaternion)
281
+ }
282
+ if (innerRef.current && innerRide) {
283
+ innerRef.current.position.copy(innerRide.position)
284
+ innerRef.current.quaternion.copy(innerRide.quaternion)
285
+ }
286
+ })
287
+
288
+ // Active-drag tracking. When a handle starts dragging, it claims its
289
+ // descriptor index here and snapshots the store node at drag-start.
290
+ // Non-active arrows re-render against the snapshot + a freeze offset
291
+ // that undoes the mesh's `position` drift in node-local frame — so
292
+ // asymmetric resize (width L/R, length L/R) doesn't visually slide the
293
+ // depth / height / rotate chevrons. They stay anchored at their
294
+ // pre-drag world positions for the duration of the drag.
295
+ //
296
+ // Hooks must sit ABOVE the early-return guard below — the registry-
297
+ // resolve `useEffect` flips `portalObject` from null → object after
298
+ // the first frame, so a guard between two hooks would change the
299
+ // hook count between renders and trip React's rules-of-hooks check.
300
+ const [activeIndex, setActiveIndex] = useState<number | null>(null)
301
+ const [preDragNode, setPreDragNode] = useState<AnyNode | null>(null)
302
+ const dragControls = useMemo<HandleDragControls>(
303
+ () => ({
304
+ onStart: (index: number, snapshot: AnyNode) => {
305
+ setActiveIndex(index)
306
+ setPreDragNode(snapshot)
307
+ },
308
+ onEnd: () => {
309
+ setActiveIndex(null)
310
+ setPreDragNode(null)
311
+ },
312
+ }),
313
+ [],
314
+ )
315
+
316
+ if (!portalObject || !outerRide || (innerRideId !== null && !innerRide)) return null
317
+
318
+ // `arrowFrame` is the Object3D used as the spatial reference for the
319
+ // per-arrow drag math — its world matrix maps node-local coords to
320
+ // world. In 'parent' mode that's the outer ride (= the node mesh
321
+ // itself). In 'grandparent' mode it's the inner ride (= the node mesh)
322
+ // because the inner group mirrors the node's local pose under the
323
+ // wall-riding outer wrapper.
324
+ const arrowFrame = innerRide ?? outerRide
325
+
326
+ // A translate drag moves `position`, so the whole handle rig should travel
327
+ // with the mesh — the freeze-at-pre-drag mechanism (built for asymmetric
328
+ // resize that re-centres the mesh) must NOT fire for the non-active arrows
329
+ // here, or they'd lag behind the moving item.
330
+ const activeIsTranslate = activeIndex !== null && descriptors[activeIndex]?.kind === 'translate'
331
+
332
+ const arrows = descriptors.map((descriptor, index) => (
333
+ <ArrowHandle
334
+ activeIndex={activeIndex}
335
+ descriptor={descriptor}
336
+ dragControls={dragControls}
337
+ handleIndex={index}
338
+ // Descriptors come from a per-node-kind static list, so index is a
339
+ // stable identity within this node's selection cycle.
340
+ key={index}
341
+ liveNode={node}
342
+ preDragNode={preDragNode}
343
+ rideObject={arrowFrame}
344
+ suppressFreeze={activeIsTranslate}
345
+ />
346
+ ))
347
+
348
+ return createPortal(
349
+ <group ref={outerRef}>
350
+ {innerRideId !== null ? <group ref={innerRef}>{arrows}</group> : arrows}
351
+ </group>,
352
+ portalObject,
353
+ )
354
+ }
355
+
356
+ // Offset, in node-local frame, that compensates for `position` drift on
357
+ // the mesh during an asymmetric resize. Width/length L+R recompute
358
+ // `position` so the anchored edge stays world-fixed — the renderer
359
+ // follows that override, the ride object moves, and every arrow under
360
+ // it would drift along with the mesh center. Subtracting this offset
361
+ // from a non-active arrow's local placement undoes that drift so it
362
+ // stays at its pre-drag world position.
363
+ //
364
+ // Rotation drags don't change `position`, so the offset collapses to
365
+ // zero and non-active arrows naturally rotate with the mesh — which is
366
+ // the desired behaviour (the whole rig rotates as a unit).
367
+ function computeFreezeOffset(liveNode: AnyNode, preDragNode: AnyNode): [number, number, number] {
368
+ // Not every node in the union carries a `position` field (sites are the
369
+ // notable holdout — they don't have handles anyway, but TypeScript still
370
+ // requires us to discriminate). Guarded access keeps the freeze logic
371
+ // safe for the few node kinds that lack the field.
372
+ const liveP = (liveNode as { position?: readonly [number, number, number] }).position ?? [0, 0, 0]
373
+ const preP = (preDragNode as { position?: readonly [number, number, number] }).position ?? [
374
+ 0, 0, 0,
375
+ ]
376
+ const deltaWorldX = liveP[0] - preP[0]
377
+ const deltaWorldY = liveP[1] - preP[1]
378
+ const deltaWorldZ = liveP[2] - preP[2]
379
+ const rotY = (preDragNode as { rotation?: number }).rotation ?? 0
380
+ // World → node-local for Y-axis rotation by rotY (THREE.Object3D
381
+ // rotation-y convention): inverse is rotation by -rotY around +Y.
382
+ const cosR = Math.cos(rotY)
383
+ const sinR = Math.sin(rotY)
384
+ const deltaLocalX = cosR * deltaWorldX - sinR * deltaWorldZ
385
+ const deltaLocalZ = sinR * deltaWorldX + cosR * deltaWorldZ
386
+ return [deltaLocalX, deltaWorldY, deltaLocalZ]
387
+ }
388
+
389
+ function ArrowHandle({
390
+ descriptor,
391
+ liveNode,
392
+ preDragNode,
393
+ activeIndex,
394
+ handleIndex,
395
+ dragControls,
396
+ rideObject,
397
+ suppressFreeze,
398
+ }: {
399
+ descriptor: HandleDescriptor
400
+ liveNode: AnyNode
401
+ preDragNode: AnyNode | null
402
+ activeIndex: number | null
403
+ handleIndex: number
404
+ dragControls: HandleDragControls
405
+ rideObject: Object3D
406
+ /** When the active drag is a translate, non-active arrows ride the moving
407
+ * mesh instead of freezing at their pre-drag world position. */
408
+ suppressFreeze?: boolean
409
+ }) {
410
+ // During a drag, non-active arrows render against the pre-drag store
411
+ // snapshot. The active arrow always uses the live (override-merged)
412
+ // node so it tracks the cursor.
413
+ const isOtherActive = activeIndex !== null && activeIndex !== handleIndex && preDragNode !== null
414
+ const placementNode = isOtherActive ? (preDragNode as AnyNode) : liveNode
415
+ const freezeOffset =
416
+ isOtherActive && preDragNode && !suppressFreeze
417
+ ? computeFreezeOffset(liveNode, preDragNode)
418
+ : null
419
+
420
+ if (descriptor.kind === 'linear-resize' || descriptor.kind === 'radial-resize') {
421
+ return (
422
+ <LinearArrow
423
+ descriptor={descriptor}
424
+ dragControls={dragControls}
425
+ freezeOffset={freezeOffset}
426
+ handleIndex={handleIndex}
427
+ liveNode={liveNode}
428
+ node={placementNode}
429
+ rideObject={rideObject}
430
+ />
431
+ )
432
+ }
433
+ if (descriptor.kind === 'arc-resize') {
434
+ return (
435
+ <ArcArrow
436
+ descriptor={descriptor}
437
+ dragControls={dragControls}
438
+ freezeOffset={freezeOffset}
439
+ handleIndex={handleIndex}
440
+ liveNode={liveNode}
441
+ node={placementNode}
442
+ rideObject={rideObject}
443
+ />
444
+ )
445
+ }
446
+ if (descriptor.kind === 'translate') {
447
+ return (
448
+ <TranslateArrow
449
+ descriptor={descriptor}
450
+ dragControls={dragControls}
451
+ handleIndex={handleIndex}
452
+ node={placementNode}
453
+ rideObject={rideObject}
454
+ />
455
+ )
456
+ }
457
+ if (descriptor.kind === 'tap-action') {
458
+ // Tap-action handles (fence side-move arrows, corner pickers) aren't
459
+ // resize handles, so the freeze-at-pre-drag mechanism — which only
460
+ // exists to stop arrows sliding when an asymmetric width/length resize
461
+ // re-centers the mesh — doesn't apply to them. Track the live node so
462
+ // their height-dependent placement (side arrows ride the top, corner
463
+ // leaders span the full height) follows a height drag in real time.
464
+ return <TapActionArrow descriptor={descriptor} node={liveNode} />
465
+ }
466
+ // endpoint-move not yet implemented.
467
+ return null
468
+ }
469
+
470
+ function pickCursor(descriptor: LinearResizeHandle<AnyNode> | RadialResizeHandle<AnyNode>): Cursor {
471
+ if (descriptor.kind === 'linear-resize' && descriptor.cursor) return descriptor.cursor
472
+ return descriptor.axis === 'y' ? 'ns-resize' : 'ew-resize'
473
+ }
474
+
475
+ function resolveBound(
476
+ bound:
477
+ | number
478
+ | ((node: AnyNode, sceneApi: ReturnType<typeof createSceneApi>) => number)
479
+ | undefined,
480
+ fallback: number,
481
+ node: AnyNode,
482
+ sceneApi: ReturnType<typeof createSceneApi>,
483
+ ): number {
484
+ if (bound === undefined) return fallback
485
+ return typeof bound === 'function' ? bound(node, sceneApi) : bound
486
+ }
487
+
488
+ function LinearArrow({
489
+ descriptor,
490
+ node,
491
+ liveNode,
492
+ freezeOffset,
493
+ handleIndex,
494
+ dragControls,
495
+ rideObject,
496
+ }: {
497
+ descriptor: LinearResizeHandle<AnyNode> | RadialResizeHandle<AnyNode>
498
+ /** Effective node for placement (preDrag snapshot when another arrow is active). */
499
+ node: AnyNode
500
+ /** Always the live (override-merged) node — used inside drag handlers. */
501
+ liveNode: AnyNode
502
+ /** Node-local offset that undoes the mesh's `position` drift; null when not frozen. */
503
+ freezeOffset: [number, number, number] | null
504
+ handleIndex: number
505
+ dragControls: HandleDragControls
506
+ rideObject: Object3D
507
+ }) {
508
+ const [isHovered, setIsHovered] = useState(false)
509
+ const [isDragging, setIsDragging] = useState(false)
510
+ const { camera } = useThree()
511
+ const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
512
+ const baseScale = zoom * ARROW_SCALE
513
+ const unit = useViewer((s) => s.unit)
514
+
515
+ // Suppress "declared but unused" for `liveNode` — LinearArrow's apply
516
+ // operates on `initialNode` (snapshot inside activate) and reads value
517
+ // updates back via `useLiveNodeOverrides`. The prop is required for
518
+ // uniformity with ArrowHandle's variant dispatch but isn't consumed in
519
+ // this variant's render path.
520
+ void liveNode
521
+
522
+ const cursor = pickCursor(descriptor)
523
+ // When a handle declares `measureLabel`, its readout is routed to the
524
+ // floating dimension pill (via `activeHandleDrag`) and its own in-world
525
+ // chip is suppressed — matches the wall height handle.
526
+ const measureLabel = descriptor.kind === 'linear-resize' ? descriptor.measureLabel : undefined
527
+ const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
528
+ const basePosition = descriptor.placement.position(node, placementSceneApi)
529
+ // `freezeOffset` (in node-local frame) cancels the mesh's `position`
530
+ // drift while another arrow is being dragged — `basePosition` is
531
+ // computed against the pre-drag snapshot, then we subtract the offset
532
+ // so the arrow's WORLD location matches its pre-drag world location.
533
+ // Active arrows + idle state have `freezeOffset === null`, so the
534
+ // position passes through unchanged.
535
+ const position: [number, number, number] = freezeOffset
536
+ ? [
537
+ basePosition[0] - freezeOffset[0],
538
+ basePosition[1] - freezeOffset[1],
539
+ basePosition[2] - freezeOffset[2],
540
+ ]
541
+ : [basePosition[0], basePosition[1], basePosition[2]]
542
+ const baseRotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0
543
+ // Default chevron points +X. Rotate around Y to face the chosen axis.
544
+ const axisRotationY = descriptor.axis === 'z' ? -Math.PI / 2 : 0
545
+ // For axis === 'y' we orient the chevron up. Z-rotation by π/2 then
546
+ // Y-rotation chains via the parent <group> below.
547
+ const rotationY = baseRotationY + axisRotationY
548
+
549
+ const activate = useHandleDrag({
550
+ kind: 'drag',
551
+ cursor,
552
+ dragControls,
553
+ handleIndex,
554
+ node,
555
+ rideObject,
556
+ setIsDragging,
557
+ onStart: ({
558
+ camera: dragCamera,
559
+ event,
560
+ initialNode,
561
+ intersectPlane,
562
+ nodeId,
563
+ rideObject: dragRideObject,
564
+ sceneApi,
565
+ }) => {
566
+ const initialFrameInverse = new Matrix4().copy(dragRideObject.matrixWorld).invert()
567
+ const worldOrigin = new Vector3(...position).applyMatrix4(dragRideObject.matrixWorld)
568
+ const planeNormal = new Vector3().subVectors(dragCamera.position, worldOrigin).setY(0)
569
+ if (planeNormal.lengthSq() === 0) return null
570
+ planeNormal.normalize()
571
+ const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, worldOrigin)
572
+
573
+ const hitWorld = new Vector3()
574
+ if (!intersectPlane(event.nativeEvent.clientX, event.nativeEvent.clientY, plane, hitWorld)) {
575
+ return null
576
+ }
577
+ const hitLocal = hitWorld.clone().applyMatrix4(initialFrameInverse)
578
+
579
+ const overrideId =
580
+ (descriptor.kind === 'linear-resize'
581
+ ? descriptor.overrideTarget?.(initialNode as never, sceneApi)
582
+ : undefined) ?? nodeId
583
+ const initialValue = descriptor.currentValue(initialNode)
584
+ const initialPointer =
585
+ descriptor.axis === 'x' ? hitLocal.x : descriptor.axis === 'y' ? hitLocal.y : hitLocal.z
586
+ const minBound = resolveBound(descriptor.min, Number.NEGATIVE_INFINITY, initialNode, sceneApi)
587
+ const maxBound = resolveBound(descriptor.max, Number.POSITIVE_INFINITY, initialNode, sceneApi)
588
+ const gridSnapStep =
589
+ descriptor.kind === 'linear-resize' && descriptor.gridSnap
590
+ ? useEditor.getState().gridSnapStep
591
+ : null
592
+ const factor =
593
+ descriptor.kind === 'radial-resize'
594
+ ? 1
595
+ : descriptor.anchor === 'center'
596
+ ? 2
597
+ : descriptor.anchor === 'min'
598
+ ? 1
599
+ : -1
600
+
601
+ return {
602
+ overrideId,
603
+ onBegin: () => {
604
+ if (measureLabel) {
605
+ useEditor.getState().setActiveHandleDrag({ nodeId, label: measureLabel })
606
+ }
607
+ },
608
+ onEnd: () => {
609
+ if (measureLabel) {
610
+ useEditor.getState().setActiveHandleDrag(null)
611
+ }
612
+ },
613
+ move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => {
614
+ const intersection = new Vector3()
615
+ if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, intersection)) {
616
+ return null
617
+ }
618
+ const intersectionLocal = intersection.clone().applyMatrix4(initialFrameInverse)
619
+ const currentPointer =
620
+ descriptor.axis === 'x'
621
+ ? intersectionLocal.x
622
+ : descriptor.axis === 'y'
623
+ ? intersectionLocal.y
624
+ : intersectionLocal.z
625
+ const delta = currentPointer - initialPointer
626
+ const rawNext = initialValue + delta * factor
627
+ const snappedNext =
628
+ gridSnapStep && gridSnapStep > 0 ? snapScalar(rawNext, gridSnapStep) : rawNext
629
+ const next = Math.min(maxBound, Math.max(minBound, snappedNext))
630
+ return descriptor.apply(initialNode as never, next, sceneApi) as Partial<AnyNode>
631
+ },
632
+ }
633
+ },
634
+ })
635
+
636
+ // For axis === 'y' (vertical handles), tilt the chevron up via local
637
+ // X+Z rotation chain matching DoorHeightArrowHandle. When the handle
638
+ // sits below the node (placement Y < 0, e.g. window bottom arrow),
639
+ // flip the Z rotation so the chevron points outward (downward).
640
+ const innerRotation: [number, number, number] =
641
+ descriptor.axis === 'y'
642
+ ? [0, Math.PI / 2, position[1] < 0 ? -Math.PI / 2 : Math.PI / 2]
643
+ : [0, 0, 0]
644
+
645
+ // Optional guide decoration — linear handles use it for curved-stair
646
+ // width / inner-radius rings; radial handles use it for the column's
647
+ // round footprint ring.
648
+ const decoration = descriptor.decoration
649
+ const showDecoration = Boolean(decoration) && (isHovered || isDragging)
650
+
651
+ // Dimension chip — shows the live value the drag is steering. `node`
652
+ // is already the effective (override-merged) node, so currentValue
653
+ // returns the in-flight value during a drag and the label tracks
654
+ // smoothly without an extra subscription.
655
+ // `measureLabel` handles route their readout to the floating dimension
656
+ // pill, so suppress the inline chip here to avoid showing it twice.
657
+ const showLabel = (isHovered || isDragging) && !measureLabel
658
+ const labelText = showLabel ? formatDimension(descriptor.currentValue(node), unit) : ''
659
+
660
+ // `tracker` shape on a linear-resize handle: render a dashed vertical
661
+ // leader from the floor up to a small cube at `placement.position`. The
662
+ // cube is the drag target and reuses the same `activate` pointer handler
663
+ // as the chevron path, so all the override/commit plumbing is unchanged.
664
+ // Only valid for axis='y' resize handles — the leader is rendered at
665
+ // (0,0,0)→(0,position.y,0) in the same group as the cube, so for x/z
666
+ // axes the leader would still climb vertically and look wrong.
667
+ const shape =
668
+ descriptor.kind === 'linear-resize' && descriptor.shape === 'tracker' ? 'tracker' : 'arrow'
669
+
670
+ if (shape === 'tracker') {
671
+ // Descriptors can pin the leader's bottom Y above the floor — e.g.
672
+ // chimney body height starts at the deck plane, not at y=0, so the
673
+ // dashed leader spans only the body's visible extent.
674
+ const trackerDescriptor = descriptor as LinearResizeHandle<AnyNode>
675
+ const baseY = trackerDescriptor.trackerBaseY?.(node as never, placementSceneApi) ?? 0
676
+ // Leader spans base ↔ cube either direction. Upward (cube above base:
677
+ // wall / chimney height) it stops at the cube as before. Downward
678
+ // (cube below base: a downspout's length cube under the gutter
679
+ // outlet) it pokes `TRACKER_THROUGH` past the cube so the dashes
680
+ // thread through it instead of the leader collapsing to nothing.
681
+ const cubeY = position[1]
682
+ const cubeBelowBase = cubeY < baseY
683
+ const leaderBottomY = Math.min(baseY, cubeY) - (cubeBelowBase ? TRACKER_THROUGH : 0)
684
+ const leaderHeight = Math.max(Math.max(baseY, cubeY) - leaderBottomY, 0)
685
+ return (
686
+ <>
687
+ {showDecoration && decoration ? (
688
+ <GuideRing
689
+ radius={decoration.radius(node as never)}
690
+ y={decoration.y?.(node as never) ?? 0}
691
+ />
692
+ ) : null}
693
+ {showLabel ? (
694
+ <DimensionLabel
695
+ position={[position[0], position[1] + 0.22, position[2]]}
696
+ text={labelText}
697
+ />
698
+ ) : null}
699
+ <TrackerShape
700
+ basePosition={[position[0], leaderBottomY, position[2]]}
701
+ baseScale={zoom}
702
+ cubePosition={position}
703
+ cursor={cursor}
704
+ hover={isHovered}
705
+ leaderHeight={leaderHeight}
706
+ onHoverChange={setIsHovered}
707
+ onPointerDown={activate}
708
+ />
709
+ </>
710
+ )
711
+ }
712
+
713
+ return (
714
+ <>
715
+ {showDecoration && decoration ? (
716
+ <GuideRing
717
+ radius={decoration.radius(node as never)}
718
+ y={decoration.y?.(node as never) ?? 0}
719
+ />
720
+ ) : null}
721
+ <HandleArrow
722
+ cursor={cursor}
723
+ hover={isHovered}
724
+ indicatorRotation={innerRotation}
725
+ onHoverChange={setIsHovered}
726
+ onPointerDown={activate}
727
+ placement={{ position, rotation: [0, rotationY, 0], baseScale }}
728
+ shape="chevron"
729
+ >
730
+ {showLabel ? <DimensionLabel position={[0, 0.22, 0]} text={labelText} /> : null}
731
+ </HandleArrow>
732
+ </>
733
+ )
734
+ }
735
+
736
+ // Thin horizontal ring used as a visual guide alongside a resize arrow —
737
+ // e.g. the curved-stair width arrow traces the outer rim, the inner-radius
738
+ // arrow traces the central pillar. Floats at node-local `y`, lies in the
739
+ // XZ plane.
740
+ export function GuideRing({ radius, y }: { radius: number; y: number }) {
741
+ const safeRadius = Math.max(radius, 0.01)
742
+ const ringGeometry = useMemo(() => {
743
+ const inner = Math.max(safeRadius - 0.015, 0.001)
744
+ const outer = safeRadius + 0.015
745
+ return new RingGeometry(inner, outer, 96)
746
+ }, [safeRadius])
747
+ const ringMaterial = useMemo(
748
+ () =>
749
+ new MeshBasicNodeMaterial({
750
+ color: new Color(ARROW_COLOR),
751
+ side: DoubleSide,
752
+ transparent: true,
753
+ opacity: 0.95,
754
+ depthTest: false,
755
+ depthWrite: false,
756
+ }),
757
+ [],
758
+ )
759
+ useEffect(() => () => ringGeometry.dispose(), [ringGeometry])
760
+ useEffect(() => () => ringMaterial.dispose(), [ringMaterial])
761
+
762
+ return (
763
+ <mesh
764
+ frustumCulled={false}
765
+ geometry={ringGeometry}
766
+ material={ringMaterial}
767
+ position={[0, y, 0]}
768
+ renderOrder={1009}
769
+ rotation={[-Math.PI / 2, 0, 0]}
770
+ />
771
+ )
772
+ }
773
+
774
+ const ROTATION_GUIDE_COLOR = ARROW_COLOR
775
+ const ROTATION_GUIDE_SEGMENTS = 48
776
+
777
+ // Live rotation readout shown while a whole-node rotate gizmo is dragged.
778
+ // Mirrors the wall-draft angle arc: a filled wedge + outline swept from the
779
+ // pointer's bearing at grab (`startAngle`) to its current bearing
780
+ // (`endAngle`) around the rotation pivot, plus a degree chip at the wedge's
781
+ // midpoint. All coordinates are world-space — the guide is portalled to the
782
+ // scene root so it stays fixed while the node mesh rotates underneath it.
783
+ export type RotationGuideData = {
784
+ center: [number, number, number]
785
+ startAngle: number
786
+ endAngle: number
787
+ radius: number
788
+ labelPos: [number, number, number]
789
+ /** Swept magnitude in radians, for the degree chip. */
790
+ sweep: number
791
+ }
792
+
793
+ export function RotationGuide({ data }: { data: RotationGuideData }) {
794
+ const { center, startAngle, endAngle, radius, labelPos, sweep } = data
795
+ const { outline, fill } = useMemo(() => {
796
+ const span = endAngle - startAngle
797
+ const count = Math.max(8, Math.ceil((Math.abs(span) / Math.PI) * ROTATION_GUIDE_SEGMENTS))
798
+ const arc = Array.from({ length: count + 1 }, (_, index) => {
799
+ const angle = startAngle + (span * index) / count
800
+ return new Vector3(
801
+ center[0] + Math.cos(angle) * radius,
802
+ center[1],
803
+ center[2] + Math.sin(angle) * radius,
804
+ )
805
+ })
806
+ const centerV = new Vector3(center[0], center[1], center[2])
807
+ const outlineGeo = new BufferGeometry().setFromPoints([centerV, ...arc, centerV])
808
+ const positions: number[] = []
809
+ for (let i = 0; i < arc.length - 1; i++) {
810
+ const a = arc[i]
811
+ const b = arc[i + 1]
812
+ if (!a || !b) continue
813
+ positions.push(centerV.x, centerV.y, centerV.z, a.x, a.y, a.z, b.x, b.y, b.z)
814
+ }
815
+ const fillGeo = new BufferGeometry()
816
+ fillGeo.setAttribute('position', new Float32BufferAttribute(positions, 3))
817
+ return { outline: outlineGeo, fill: fillGeo }
818
+ }, [center, startAngle, endAngle, radius])
819
+ useEffect(() => () => outline.dispose(), [outline])
820
+ useEffect(() => () => fill.dispose(), [fill])
821
+
822
+ return (
823
+ <>
824
+ <mesh
825
+ frustumCulled={false}
826
+ geometry={fill}
827
+ layers={EDITOR_LAYER}
828
+ raycast={NO_RAYCAST}
829
+ renderOrder={1008}
830
+ >
831
+ <meshBasicMaterial
832
+ color={ROTATION_GUIDE_COLOR}
833
+ depthTest={false}
834
+ depthWrite={false}
835
+ opacity={0.18}
836
+ side={DoubleSide}
837
+ transparent
838
+ />
839
+ </mesh>
840
+ <RotationGuideOutline geometry={outline} />
841
+ <DimensionLabel position={labelPos} text={formatAngleRadians(sweep)} />
842
+ </>
843
+ )
844
+ }
845
+
846
+ function RotationGuideOutline({ geometry }: { geometry: BufferGeometry }) {
847
+ return (
848
+ // @ts-expect-error - R3F accepts Three line primitives, matching the wall draft arc.
849
+ <line frustumCulled={false} geometry={geometry} layers={EDITOR_LAYER} renderOrder={1009}>
850
+ <lineBasicNodeMaterial
851
+ color={ROTATION_GUIDE_COLOR}
852
+ depthTest={false}
853
+ depthWrite={false}
854
+ linewidth={2}
855
+ opacity={0.95}
856
+ transparent
857
+ />
858
+ </line>
859
+ )
860
+ }
861
+
862
+ // Live whole-node rotation readout for a single-node rotate gizmo, rendered as
863
+ // a CHILD of the node frame (sibling of its guide ring). Because it lives in
864
+ // the node's own frame, it is automatically concentric and coplanar with the
865
+ // ring — on flat ground the frame's XZ is world-horizontal, on a pitched roof
866
+ // it follows the slope, with no world-space basis math.
867
+ //
868
+ // The wedge fills the node-local XZ plane at the ring's height `y`. Its leading
869
+ // edge sits at the rotate handle's bearing `handleAngle` (which is fixed in the
870
+ // node frame, so it tracks the orbiting handle), and it opens BACKWARD by the
871
+ // swept `delta` — so the trailing edge stays pinned to the grab direction in
872
+ // world while the node spins. `orbitRadius` is the handle's in-plane distance
873
+ // from the pivot; the fill is pulled inside it so it reads as the handle
874
+ // swinging around rather than overlapping the icon.
875
+ function RotationWedge({
876
+ delta,
877
+ handleAngle,
878
+ orbitRadius,
879
+ y,
880
+ }: {
881
+ delta: number
882
+ handleAngle: number
883
+ orbitRadius: number
884
+ y: number
885
+ }) {
886
+ const radius = Math.min(Math.max(orbitRadius * 0.72, 0.3), 1.6)
887
+ const { outline, fill } = useMemo(() => {
888
+ const start = handleAngle - delta
889
+ const span = delta
890
+ const count = Math.max(8, Math.ceil((Math.abs(span) / Math.PI) * ROTATION_GUIDE_SEGMENTS))
891
+ const arc = Array.from({ length: count + 1 }, (_, index) => {
892
+ const angle = start + (span * index) / count
893
+ return new Vector3(Math.cos(angle) * radius, 0, Math.sin(angle) * radius)
894
+ })
895
+ const centerV = new Vector3(0, 0, 0)
896
+ const outlineGeo = new BufferGeometry().setFromPoints([centerV, ...arc, centerV])
897
+ const positions: number[] = []
898
+ for (let i = 0; i < arc.length - 1; i++) {
899
+ const a = arc[i]
900
+ const b = arc[i + 1]
901
+ if (!a || !b) continue
902
+ positions.push(0, 0, 0, a.x, a.y, a.z, b.x, b.y, b.z)
903
+ }
904
+ const fillGeo = new BufferGeometry()
905
+ fillGeo.setAttribute('position', new Float32BufferAttribute(positions, 3))
906
+ return { outline: outlineGeo, fill: fillGeo }
907
+ }, [delta, handleAngle, radius])
908
+ useEffect(() => () => outline.dispose(), [outline])
909
+ useEffect(() => () => fill.dispose(), [fill])
910
+
911
+ const labelRadius = radius + 0.22
912
+ const midAngle = handleAngle - delta / 2
913
+ const labelPos: [number, number, number] = [
914
+ Math.cos(midAngle) * labelRadius,
915
+ 0,
916
+ Math.sin(midAngle) * labelRadius,
917
+ ]
918
+
919
+ return (
920
+ <group position={[0, y, 0]}>
921
+ <mesh
922
+ frustumCulled={false}
923
+ geometry={fill}
924
+ layers={EDITOR_LAYER}
925
+ raycast={NO_RAYCAST}
926
+ renderOrder={1008}
927
+ >
928
+ <meshBasicMaterial
929
+ color={ROTATION_GUIDE_COLOR}
930
+ depthTest={false}
931
+ depthWrite={false}
932
+ opacity={0.18}
933
+ side={DoubleSide}
934
+ transparent
935
+ />
936
+ </mesh>
937
+ <RotationGuideOutline geometry={outline} />
938
+ <DimensionLabel position={labelPos} text={formatAngleRadians(Math.abs(delta))} />
939
+ </group>
940
+ )
941
+ }
942
+
943
+ // Angular drag: project pointer to a horizontal plane at the arrow's Y
944
+ // and measure the signed angle around the node's local origin (in world
945
+ // XZ). Pass the normalised delta to `apply` — the descriptor owns the
946
+ // per-field math (sweep handles write `sweepAngle` AND `rotation` from
947
+ // the same delta to keep the opposite edge world-fixed).
948
+ function ArcArrow({
949
+ descriptor,
950
+ node,
951
+ liveNode,
952
+ freezeOffset,
953
+ handleIndex,
954
+ dragControls,
955
+ rideObject,
956
+ }: {
957
+ descriptor: ArcResizeHandle<AnyNode>
958
+ /** Effective node for placement (preDrag snapshot when another arrow is active). */
959
+ node: AnyNode
960
+ /** Always the live (override-merged) node — used inside drag handlers. */
961
+ liveNode: AnyNode
962
+ /** Node-local offset that undoes the mesh's `position` drift; null when not frozen. */
963
+ freezeOffset: [number, number, number] | null
964
+ handleIndex: number
965
+ dragControls: HandleDragControls
966
+ rideObject: Object3D
967
+ }) {
968
+ const [isHovered, setIsHovered] = useState(false)
969
+ const [isDragging, setIsDragging] = useState(false)
970
+ // 'rotate' descriptors (whole-node rotation handles like the elevator
971
+ // corner) render a two-headed curved arrow; everything else (stair
972
+ // sweep, etc.) keeps the chevron.
973
+ const isRotateShape = descriptor.shape === 'rotate'
974
+ // 'node-normal' spins the node about its local +Z (a wall item flat against
975
+ // its wall) instead of yaw about world-Y. The drag plane and the icon both
976
+ // tilt into that plane, and the horizontal-only wedge/ring readout is
977
+ // suppressed.
978
+ const isNodeNormalRot = descriptor.rotationPlane === 'node-normal'
979
+ const { camera } = useThree()
980
+ const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
981
+ // The rotate icon is denser than the chevron; pump scale a touch so the
982
+ // ribbon reads at the same on-screen size as the other handles.
983
+ const arrowScale = isRotateShape ? ARROW_SCALE * 1.05 : ARROW_SCALE
984
+ const baseScale = zoom * arrowScale
985
+ // Live rotation amount (radians swept since grab) — non-null only while a
986
+ // `shape: 'rotate'` gizmo is mid-drag. Drives the in-frame wedge readout.
987
+ const [rotationDelta, setRotationDelta] = useState<number | null>(null)
988
+
989
+ const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
990
+ const basePosition = descriptor.placement.position(node, placementSceneApi)
991
+ // See the LinearArrow note on freezeOffset — for rotation drags the
992
+ // delta collapses to zero (position doesn't change), so the rotate
993
+ // gizmo naturally rotates with the mesh while another arrow is being
994
+ // dragged. The offset only kicks in for asymmetric resize drags that
995
+ // recompute `position` to anchor the opposite edge.
996
+ const position: [number, number, number] = freezeOffset
997
+ ? [
998
+ basePosition[0] - freezeOffset[0],
999
+ basePosition[1] - freezeOffset[1],
1000
+ basePosition[2] - freezeOffset[2],
1001
+ ]
1002
+ : [basePosition[0], basePosition[1], basePosition[2]]
1003
+ const rotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0
1004
+ // Rotation gizmo: hover signals "grabbable", active drag signals
1005
+ // "grabbed". `ew-resize` was wrong — it implies linear width drag.
1006
+ const hoverCursor: Cursor = 'grab'
1007
+ const dragCursor: Cursor = 'grabbing'
1008
+
1009
+ // Optional guide ring (elevator rotation circle) shown while the arc
1010
+ // arrow is hovered or dragging. Same recipe as the linear / radial
1011
+ // decoration path.
1012
+ const decoration = descriptor.decoration
1013
+ const showDecoration = Boolean(decoration) && (isHovered || isDragging)
1014
+
1015
+ const activate = useHandleDrag({
1016
+ kind: 'drag',
1017
+ cursor: dragCursor,
1018
+ dragControls,
1019
+ handleIndex,
1020
+ node,
1021
+ rideObject,
1022
+ setIsDragging,
1023
+ onStart: ({ event, initialNode, intersectPlane, rideObject: dragRideObject, sceneApi }) => {
1024
+ const centerWorld =
1025
+ descriptor.rotationCenter !== undefined
1026
+ ? new Vector3(...descriptor.rotationCenter(node as never, sceneApi)).applyMatrix4(
1027
+ dragRideObject.matrixWorld,
1028
+ )
1029
+ : new Vector3().setFromMatrixPosition(dragRideObject.matrixWorld)
1030
+ const arrowWorld = new Vector3(...position).applyMatrix4(dragRideObject.matrixWorld)
1031
+ const planeY = arrowWorld.y
1032
+ const axis = isNodeNormalRot
1033
+ ? new Vector3().setFromMatrixColumn(dragRideObject.matrixWorld, 2).normalize()
1034
+ : new Vector3(0, 1, 0)
1035
+ const plane = isNodeNormalRot
1036
+ ? new Plane().setFromNormalAndCoplanarPoint(axis, centerWorld)
1037
+ : new Plane(new Vector3(0, 1, 0), -planeY)
1038
+
1039
+ let basisU: Vector3
1040
+ if (isNodeNormalRot) {
1041
+ const up = new Vector3(0, 1, 0)
1042
+ basisU = up.clone().addScaledVector(axis, -up.dot(axis))
1043
+ if (basisU.lengthSq() < 1e-6) {
1044
+ const x = new Vector3(1, 0, 0)
1045
+ basisU = x.addScaledVector(axis, -x.dot(axis))
1046
+ }
1047
+ basisU.normalize()
1048
+ } else {
1049
+ basisU = new Vector3(1, 0, 0)
1050
+ }
1051
+ const basisV = isNodeNormalRot
1052
+ ? new Vector3().crossVectors(axis, basisU).normalize()
1053
+ : new Vector3(0, 0, 1)
1054
+ const angleOf = (p: Vector3) => {
1055
+ const d = new Vector3().subVectors(p, centerWorld)
1056
+ return Math.atan2(d.dot(basisV), d.dot(basisU))
1057
+ }
1058
+
1059
+ const hitWorld = new Vector3()
1060
+ if (!intersectPlane(event.nativeEvent.clientX, event.nativeEvent.clientY, plane, hitWorld)) {
1061
+ return null
1062
+ }
1063
+ const initialAngle = angleOf(hitWorld)
1064
+
1065
+ return {
1066
+ onEnd: () => setRotationDelta(null),
1067
+ move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => {
1068
+ const hit = new Vector3()
1069
+ if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, hit)) return null
1070
+ const currentAngle = angleOf(hit)
1071
+ let delta = currentAngle - initialAngle
1072
+ while (delta > Math.PI) delta -= 2 * Math.PI
1073
+ while (delta < -Math.PI) delta += 2 * Math.PI
1074
+
1075
+ if (moveEvent.shiftKey && descriptor.shape === 'rotate') {
1076
+ const step = Math.PI / 12
1077
+ delta = Math.round(delta / step) * step
1078
+ }
1079
+
1080
+ if (isRotateShape && !isNodeNormalRot) {
1081
+ setRotationDelta(Math.abs(delta) < 0.0087 ? null : delta)
1082
+ }
1083
+ return descriptor.apply(initialNode as never, delta, sceneApi) as Partial<AnyNode>
1084
+ },
1085
+ }
1086
+ },
1087
+ })
1088
+
1089
+ // Suppress "declared but unused" for `liveNode` — ArcArrow's apply
1090
+ // operates entirely on `initialNode` (snapshot taken inside activate)
1091
+ // and `delta` (live cursor angle), so the live store node doesn't
1092
+ // appear in the rotation pipeline. The prop is still required because
1093
+ // ArrowHandle passes it uniformly to every variant.
1094
+ void liveNode
1095
+
1096
+ return (
1097
+ <>
1098
+ {showDecoration && decoration ? (
1099
+ <GuideRing
1100
+ radius={decoration.radius(node as never)}
1101
+ y={decoration.y?.(node as never) ?? 0}
1102
+ />
1103
+ ) : null}
1104
+ {/* Live rotation readout. Rendered HERE (a child of the node frame, the
1105
+ same frame the guide ring lives in) rather than portalled to world
1106
+ space, so the wedge is automatically concentric and coplanar with the
1107
+ ring on any surface — flat ground or a pitched roof. */}
1108
+ {rotationDelta !== null ? (
1109
+ <RotationWedge
1110
+ delta={rotationDelta}
1111
+ handleAngle={Math.atan2(position[2], position[0])}
1112
+ orbitRadius={Math.hypot(position[0], position[2])}
1113
+ y={decoration?.y?.(node as never) ?? 0}
1114
+ />
1115
+ ) : null}
1116
+ <HandleArrow
1117
+ activeCursor={dragCursor}
1118
+ cursor={hoverCursor}
1119
+ hover={isHovered}
1120
+ onHoverChange={setIsHovered}
1121
+ onPointerDown={activate}
1122
+ placement={{
1123
+ position,
1124
+ // The curved arrow is built flat in XZ. For a wall-normal spin, tilt
1125
+ // it up about X so it lies in the item-local XY plane (the wall face).
1126
+ rotation: isNodeNormalRot ? [Math.PI / 2, 0, rotationY] : [0, rotationY, 0],
1127
+ baseScale,
1128
+ }}
1129
+ shape={isRotateShape ? 'curved-arrow' : 'chevron'}
1130
+ />
1131
+ </>
1132
+ )
1133
+ }
1134
+
1135
+ // Free ground-plane move gizmo (the 4-way cross). Press-drag-release: raycast
1136
+ // the horizontal plane at the node's base, convert the hit into the node's
1137
+ // parent-local frame, add the delta to the node's drag-start position, grid-
1138
+ // snap via the descriptor's `snapExtents`, and publish to `useLiveNodeOverrides`
1139
+ // each move — committing one write to the store on release. The override stays
1140
+ // at base Y; `<FloorElevationSystem>` reads that effective node and owns the
1141
+ // presentation-only slab lift so the handle path shares the menu-move stacking
1142
+ // contract without storing lifted positions.
1143
+ function TranslateArrow({
1144
+ descriptor,
1145
+ node,
1146
+ }: {
1147
+ descriptor: TranslateHandle<AnyNode>
1148
+ node: AnyNode
1149
+ handleIndex: number
1150
+ dragControls: HandleDragControls
1151
+ rideObject: Object3D
1152
+ }) {
1153
+ const [isHovered, setIsHovered] = useState(false)
1154
+ const { camera } = useThree()
1155
+ const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
1156
+ const baseScale = zoom * ARROW_SCALE
1157
+
1158
+ const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
1159
+ const position = descriptor.placement.position(node, placementSceneApi)
1160
+ const cursor: Cursor = 'move'
1161
+ // 'node-normal' constrains the drag to the wall face (plane ⟂ the node's
1162
+ // local +Z). Its cross icon stands up into that plane (tilt about X).
1163
+ const isWallPlane = descriptor.plane === 'node-normal'
1164
+
1165
+ // Same function as the floating action menu's Move button
1166
+ // (`floating-action-menu.tsx` → `handleMove`): arm the registry move tool,
1167
+ // which owns the cursor follow, grid + alignment snap, green guide overlay,
1168
+ // and click-to-commit. Routes both entry points through one path so the
1169
+ // 3D translate gizmo and the floating Move button behave identically.
1170
+ const activate = (event: ThreeEvent<PointerEvent>) => {
1171
+ event.stopPropagation()
1172
+ sfxEmitter.emit('sfx:item-pick')
1173
+ useEditor.getState().setMovingNode(node as never)
1174
+ useViewer.getState().setSelection({ selectedIds: [] })
1175
+ }
1176
+
1177
+ // The cross is built flat in the XZ plane. On a wall, tilt it up about X so
1178
+ // it lies in the item-local XY plane (= the wall face).
1179
+ const iconRotation: [number, number, number] = isWallPlane ? [Math.PI / 2, 0, 0] : [0, 0, 0]
1180
+
1181
+ return (
1182
+ <HandleArrow
1183
+ cursor={cursor}
1184
+ hover={isHovered}
1185
+ onHoverChange={setIsHovered}
1186
+ onPointerDown={activate}
1187
+ placement={{ position, rotation: iconRotation, baseScale }}
1188
+ shape="cross"
1189
+ />
1190
+ )
1191
+ }
1192
+
1193
+ // Click-to-engage affordance — no drag plumbing, just a click target. The
1194
+ // descriptor's `onActivate` receives sceneApi + editorApi so it can engage
1195
+ // move tools, endpoint drags, or any other editor-state transition without
1196
+ // importing editor internals from the node-def layer.
1197
+ function TapActionArrow({
1198
+ descriptor,
1199
+ node,
1200
+ }: {
1201
+ descriptor: TapActionHandle<AnyNode>
1202
+ node: AnyNode
1203
+ }) {
1204
+ const [isHovered, setIsHovered] = useState(false)
1205
+ const { camera } = useThree()
1206
+ const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
1207
+
1208
+ const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
1209
+ const position = descriptor.placement.position(node, placementSceneApi)
1210
+ const rotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0
1211
+ const shape = descriptor.shape ?? 'arrow'
1212
+ const cursor: Cursor = descriptor.cursor ?? (shape === 'corner-picker' ? 'move' : 'ew-resize')
1213
+
1214
+ const onActivate = useHandleDrag({
1215
+ kind: 'tap',
1216
+ onTap: () => {
1217
+ setIsHovered(false)
1218
+ descriptor.onActivate(node as never, createSceneApi(useScene), createEditorApi())
1219
+ },
1220
+ })
1221
+
1222
+ if (shape === 'corner-picker') {
1223
+ const height = descriptor.nodeHeight?.(node) ?? 1
1224
+ return (
1225
+ <CornerPickerShape
1226
+ baseScale={zoom}
1227
+ cursor={cursor}
1228
+ height={height}
1229
+ hover={isHovered}
1230
+ onHoverChange={setIsHovered}
1231
+ onPointerDown={onActivate}
1232
+ position={position}
1233
+ />
1234
+ )
1235
+ }
1236
+
1237
+ // Default 'arrow' shape — the standard chevron.
1238
+ const baseScale = zoom * ARROW_SCALE
1239
+ return (
1240
+ <HandleArrow
1241
+ cursor={cursor}
1242
+ hover={isHovered}
1243
+ onHoverChange={setIsHovered}
1244
+ onPointerDown={onActivate}
1245
+ placement={{ position, rotation: [0, rotationY, 0], baseScale }}
1246
+ shape="chevron"
1247
+ />
1248
+ )
1249
+ }
1250
+
1251
+ // Wall corner-picker visual: dashed vertical leader from floor up to
1252
+ // `height` + billboarded hex disc (the click target) + outer ring. The
1253
+ // hex disc is the only mesh with a pointer-down handler; the dashes and
1254
+ // ring are decorative.
1255
+ const CORNER_DASH_SIZE = 0.1
1256
+ const CORNER_GAP_SIZE = 0.07
1257
+ const CORNER_DASH_THICKNESS = 0.006
1258
+ const CORNER_FLOOR_OFFSET = 0.01
1259
+
1260
+ function buildDashedVerticalGeometry(height: number) {
1261
+ const dashes: BufferGeometry[] = []
1262
+ let y = 0
1263
+ while (y < height) {
1264
+ const end = Math.min(y + CORNER_DASH_SIZE, height)
1265
+ const length = end - y
1266
+ const cylinder = new CylinderGeometry(CORNER_DASH_THICKNESS, CORNER_DASH_THICKNESS, length, 8)
1267
+ cylinder.translate(0, y + length / 2, 0)
1268
+ dashes.push(cylinder)
1269
+ y = end + CORNER_GAP_SIZE
1270
+ }
1271
+ const merged = mergeGeometries(dashes, false) ?? dashes[0]
1272
+ for (const dash of dashes) dash.dispose()
1273
+ return merged
1274
+ }
1275
+
1276
+ function CornerPickerShape({
1277
+ position,
1278
+ height,
1279
+ baseScale,
1280
+ cursor,
1281
+ hover,
1282
+ onHoverChange,
1283
+ onPointerDown,
1284
+ }: {
1285
+ position: readonly [number, number, number]
1286
+ height: number
1287
+ baseScale: number
1288
+ cursor: Cursor
1289
+ hover: boolean
1290
+ onHoverChange: (hovered: boolean) => void
1291
+ onPointerDown: (event: ThreeEvent<PointerEvent>) => void
1292
+ }) {
1293
+ const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(height), [height])
1294
+ useEffect(() => () => dashedGeometry.dispose(), [dashedGeometry])
1295
+
1296
+ const dashMaterial = useMemo(
1297
+ () =>
1298
+ new MeshBasicNodeMaterial({
1299
+ color: new Color(ARROW_COLOR),
1300
+ transparent: true,
1301
+ opacity: 0.85,
1302
+ depthTest: false,
1303
+ depthWrite: false,
1304
+ }),
1305
+ [],
1306
+ )
1307
+ const ringMaterial = useMemo(
1308
+ () =>
1309
+ new MeshBasicNodeMaterial({
1310
+ color: new Color(ARROW_COLOR),
1311
+ side: DoubleSide,
1312
+ transparent: true,
1313
+ opacity: 1,
1314
+ depthTest: false,
1315
+ depthWrite: false,
1316
+ }),
1317
+ [],
1318
+ )
1319
+ useEffect(() => {
1320
+ const next = hover ? ARROW_HOVER_COLOR : ARROW_COLOR
1321
+ dashMaterial.color.set(next)
1322
+ ringMaterial.color.set(next)
1323
+ }, [dashMaterial, ringMaterial, hover])
1324
+ useEffect(() => () => dashMaterial.dispose(), [dashMaterial])
1325
+ useEffect(() => () => ringMaterial.dispose(), [ringMaterial])
1326
+
1327
+ const billboardRef = useRef<Group>(null)
1328
+ const { camera } = useThree()
1329
+ // Billboard the disc to the camera so the picker remains readable at any
1330
+ // viewing angle. The disc lives under the building-rotated tool group
1331
+ // (and possibly a rotated level), so copying `camera.quaternion` onto
1332
+ // the local quaternion no longer yields camera-aligned WORLD rotation
1333
+ // when the parent has a rotation of its own — compute the local
1334
+ // quaternion that, composed with the parent's world rotation, equals
1335
+ // the camera's world rotation.
1336
+ const parentWorldQuat = useMemo(() => new Quaternion(), [])
1337
+ const invParentWorldQuat = useMemo(() => new Quaternion(), [])
1338
+ useFrame(() => {
1339
+ const group = billboardRef.current
1340
+ if (!group) return
1341
+ if (group.parent) {
1342
+ group.parent.getWorldQuaternion(parentWorldQuat)
1343
+ invParentWorldQuat.copy(parentWorldQuat).invert()
1344
+ group.quaternion.copy(invParentWorldQuat).multiply(camera.quaternion)
1345
+ } else {
1346
+ group.quaternion.copy(camera.quaternion)
1347
+ }
1348
+ })
1349
+
1350
+ const scale = (hover ? 1.25 : 1) * baseScale
1351
+
1352
+ return (
1353
+ <>
1354
+ <mesh
1355
+ frustumCulled={false}
1356
+ geometry={dashedGeometry}
1357
+ material={dashMaterial}
1358
+ position={position}
1359
+ renderOrder={1001}
1360
+ />
1361
+ <group position={[position[0], CORNER_FLOOR_OFFSET, position[2]]} ref={billboardRef}>
1362
+ <HandleArrow
1363
+ cursor={cursor}
1364
+ hover={hover}
1365
+ hoverScale={1.25}
1366
+ onHoverChange={onHoverChange}
1367
+ onPointerDown={onPointerDown}
1368
+ placement={{ position: [0, 0, 0], baseScale }}
1369
+ shape="corner-picker"
1370
+ />
1371
+ <mesh material={ringMaterial} renderOrder={1002} scale={scale}>
1372
+ <ringGeometry args={[CORNER_HEX_RADIUS, CORNER_HEX_RADIUS * 1.18, 6]} />
1373
+ </mesh>
1374
+ </group>
1375
+ </>
1376
+ )
1377
+ }
1378
+
1379
+ // Tracker visual for the `linear-resize` handle's `shape: 'tracker'` option.
1380
+ // Mirrors the corner picker (dashed vertical leader from the floor) but caps
1381
+ // the leader with a small draggable cube instead of a hex disc, and the cube
1382
+ // sits at the TOP of the leader rather than the floor — the visual reads as
1383
+ // "this cube is the wall top; drag it to raise/lower." All interactivity
1384
+ // (pointer-down → linear-resize drag) is wired by the parent `LinearArrow`.
1385
+ function TrackerShape({
1386
+ basePosition,
1387
+ baseScale,
1388
+ cubePosition,
1389
+ cursor,
1390
+ hover,
1391
+ leaderHeight,
1392
+ onHoverChange,
1393
+ onPointerDown,
1394
+ }: {
1395
+ basePosition: readonly [number, number, number]
1396
+ baseScale: number
1397
+ cubePosition: readonly [number, number, number]
1398
+ cursor: Cursor
1399
+ hover: boolean
1400
+ leaderHeight: number
1401
+ onHoverChange: (hovered: boolean) => void
1402
+ onPointerDown: (event: ThreeEvent<PointerEvent>) => void
1403
+ }) {
1404
+ // `leaderHeight === 0` (wallHeight collapsed to floor) would make the
1405
+ // dashed builder return an empty geometry — skip the mesh entirely in
1406
+ // that case so the cube still renders by itself.
1407
+ const hasLeader = leaderHeight > 0.0001
1408
+ const dashedGeometry = useMemo(
1409
+ () => (hasLeader ? buildDashedVerticalGeometry(leaderHeight) : null),
1410
+ [hasLeader, leaderHeight],
1411
+ )
1412
+ useEffect(() => () => dashedGeometry?.dispose(), [dashedGeometry])
1413
+
1414
+ const dashMaterial = useMemo(
1415
+ () =>
1416
+ new MeshBasicNodeMaterial({
1417
+ color: new Color(ARROW_COLOR),
1418
+ transparent: true,
1419
+ opacity: 0.85,
1420
+ depthTest: false,
1421
+ depthWrite: false,
1422
+ }),
1423
+ [],
1424
+ )
1425
+ useEffect(() => {
1426
+ const next = hover ? ARROW_HOVER_COLOR : ARROW_COLOR
1427
+ dashMaterial.color.set(next)
1428
+ }, [dashMaterial, hover])
1429
+ useEffect(() => () => dashMaterial.dispose(), [dashMaterial])
1430
+
1431
+ return (
1432
+ <>
1433
+ {dashedGeometry ? (
1434
+ <mesh
1435
+ frustumCulled={false}
1436
+ geometry={dashedGeometry}
1437
+ material={dashMaterial}
1438
+ position={basePosition}
1439
+ renderOrder={1001}
1440
+ />
1441
+ ) : null}
1442
+ <HandleArrow
1443
+ cursor={cursor}
1444
+ hover={hover}
1445
+ hoverScale={1.25}
1446
+ onHoverChange={onHoverChange}
1447
+ onPointerDown={onPointerDown}
1448
+ placement={{ position: cubePosition, baseScale }}
1449
+ shape="tracker"
1450
+ />
1451
+ </>
1452
+ )
1453
+ }