@pascal-app/editor 0.8.0 → 0.9.2

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