@pascal-app/editor 0.9.1 → 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 (262) hide show
  1. package/package.json +12 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +63 -25
  5. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  6. package/src/components/editor/editor-layout-v2.tsx +18 -0
  7. package/src/components/editor/export-manager.tsx +71 -63
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
  10. package/src/components/editor/first-person/build-collider-world.ts +32 -5
  11. package/src/components/editor/first-person-controls.tsx +118 -7
  12. package/src/components/editor/floating-action-menu.tsx +410 -33
  13. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  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 +1804 -849
  17. package/src/components/editor/grid.tsx +166 -39
  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 +102 -64
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +192 -1
  24. package/src/components/editor/group-transform-shared.ts +240 -7
  25. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  26. package/src/components/editor/handles/use-handle-drag.ts +37 -4
  27. package/src/components/editor/index.tsx +248 -27
  28. package/src/components/editor/measurement-pill.tsx +62 -22
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +366 -173
  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 +812 -466
  36. package/src/components/editor/site-edge-labels.tsx +4 -4
  37. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  38. package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +136 -43
  41. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  42. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  43. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  44. package/src/components/editor/wall-measurement-label.tsx +3 -13
  45. package/src/components/editor/wall-move-side-handles.tsx +36 -21
  46. package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
  47. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  48. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
  49. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  50. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  51. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  52. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  53. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  54. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
  55. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
  56. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  57. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  58. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  59. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
  60. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  61. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
  62. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  63. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
  64. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  65. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  66. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  67. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  68. package/src/components/systems/zone/zone-system.tsx +42 -13
  69. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  70. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  71. package/src/components/tools/fence/fence-drafting.ts +80 -8
  72. package/src/components/tools/item/move-tool.tsx +17 -13
  73. package/src/components/tools/item/placement-math.test.ts +13 -1
  74. package/src/components/tools/item/placement-math.ts +28 -2
  75. package/src/components/tools/item/placement-strategies.ts +246 -5
  76. package/src/components/tools/item/placement-types.ts +13 -1
  77. package/src/components/tools/item/use-draft-node.ts +31 -1
  78. package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
  79. package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
  80. package/src/components/tools/roof/roof-tool.tsx +320 -94
  81. package/src/components/tools/select/box-select-state.ts +18 -7
  82. package/src/components/tools/select/box-select-tool.tsx +176 -40
  83. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  84. package/src/components/tools/select/marquee-geometry.ts +155 -0
  85. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  86. package/src/components/tools/select/select-candidates.ts +1 -1
  87. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  88. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  89. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  90. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  91. package/src/components/tools/shared/placement-box.tsx +183 -1
  92. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  93. package/src/components/tools/site/site-boundary-editor.tsx +88 -36
  94. package/src/components/tools/stair/stair-defaults.ts +1 -0
  95. package/src/components/tools/stair/stair-tool.tsx +82 -11
  96. package/src/components/tools/tool-manager.tsx +86 -25
  97. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  98. package/src/components/tools/wall/wall-drafting.ts +180 -103
  99. package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
  100. package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
  101. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  102. package/src/components/tools/zone/zone-tool.tsx +82 -88
  103. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  104. package/src/components/ui/action-menu/control-modes.tsx +40 -93
  105. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  106. package/src/components/ui/action-menu/index.tsx +2 -4
  107. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  108. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  109. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  110. package/src/components/ui/command-palette/editor-commands.tsx +2 -2
  111. package/src/components/ui/command-palette/index.tsx +4 -3
  112. package/src/components/ui/controls/material-paint-panel.tsx +86 -17
  113. package/src/components/ui/controls/material-picker.tsx +83 -152
  114. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  115. package/src/components/ui/controls/metric-control.tsx +118 -44
  116. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  117. package/src/components/ui/controls/slider-control.tsx +88 -38
  118. package/src/components/ui/floating-level-selector.tsx +1 -1
  119. package/src/components/ui/helpers/building-helper.tsx +10 -23
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  121. package/src/components/ui/helpers/helper-manager.tsx +227 -14
  122. package/src/components/ui/helpers/item-helper.tsx +28 -32
  123. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  124. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  125. package/src/components/ui/icon-ref.tsx +47 -0
  126. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  127. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/panels/node-display.ts +17 -17
  130. package/src/components/ui/panels/panel-manager.tsx +22 -13
  131. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  132. package/src/components/ui/panels/parametric-inspector.tsx +15 -2
  133. package/src/components/ui/panels/reference-panel.tsx +54 -19
  134. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  135. package/src/components/ui/primitives/sidebar.tsx +1 -0
  136. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  137. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  138. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  139. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
  140. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
  141. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  142. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  144. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  145. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  146. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  147. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  148. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  149. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  150. package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
  151. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  152. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  153. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  154. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  155. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  156. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  157. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  158. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  159. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  160. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  161. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  162. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  163. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  164. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  165. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  166. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  167. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  168. package/src/components/ui/snap-target-badge.tsx +88 -0
  169. package/src/components/viewer-overlay.tsx +85 -10
  170. package/src/components/viewer-zone-system.tsx +6 -1
  171. package/src/hooks/use-auto-save.test.ts +14 -0
  172. package/src/hooks/use-auto-save.ts +54 -7
  173. package/src/hooks/use-ceiling-events.ts +3 -2
  174. package/src/hooks/use-drag-action.ts +4 -1
  175. package/src/hooks/use-keyboard.ts +385 -40
  176. package/src/index.tsx +219 -6
  177. package/src/lib/active-placement-surface.ts +35 -0
  178. package/src/lib/ceiling-plan-snap.ts +24 -0
  179. package/src/lib/contextual-help.test.ts +112 -0
  180. package/src/lib/contextual-help.ts +144 -0
  181. package/src/lib/continuation.ts +64 -0
  182. package/src/lib/direct-manipulation.test.ts +192 -0
  183. package/src/lib/direct-manipulation.ts +109 -0
  184. package/src/lib/editor-api.ts +23 -35
  185. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  186. package/src/lib/floorplan/apply-alignment.ts +18 -12
  187. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  188. package/src/lib/floorplan/geometry.ts +53 -0
  189. package/src/lib/floorplan/index.ts +3 -0
  190. package/src/lib/floorplan/items.ts +5 -2
  191. package/src/lib/floorplan/plan-coords.ts +21 -0
  192. package/src/lib/fresh-planar-placement.test.ts +240 -3
  193. package/src/lib/fresh-planar-placement.ts +68 -1
  194. package/src/lib/glb-export.test.ts +441 -0
  195. package/src/lib/glb-export.ts +874 -0
  196. package/src/lib/history.ts +9 -0
  197. package/src/lib/interaction/hot-set.test.ts +133 -0
  198. package/src/lib/interaction/hot-set.ts +67 -0
  199. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  200. package/src/lib/interaction/overlay-policy.ts +59 -0
  201. package/src/lib/interaction/scope.ts +211 -0
  202. package/src/lib/level-selection.ts +2 -2
  203. package/src/lib/material-paint.ts +36 -49
  204. package/src/lib/measurement-kind.test.ts +30 -0
  205. package/src/lib/measurement-kind.ts +19 -0
  206. package/src/lib/measurement-label.test.ts +47 -0
  207. package/src/lib/measurement-label.ts +68 -0
  208. package/src/lib/measurement-parser.ts +116 -0
  209. package/src/lib/measurements.test.ts +183 -0
  210. package/src/lib/measurements.ts +199 -0
  211. package/src/lib/paint-scope.test.ts +454 -0
  212. package/src/lib/paint-scope.ts +461 -0
  213. package/src/lib/placement-drag-release.ts +23 -0
  214. package/src/lib/planar-cursor-placement.test.ts +57 -0
  215. package/src/lib/plugin-panels.test.ts +19 -0
  216. package/src/lib/plugin-panels.ts +91 -0
  217. package/src/lib/quick-measurement.test.ts +77 -0
  218. package/src/lib/quick-measurement.ts +109 -0
  219. package/src/lib/roof-duplication.ts +6 -6
  220. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  221. package/src/lib/roof-wall-hit.ts +164 -0
  222. package/src/lib/scene-clipboard.test.ts +196 -0
  223. package/src/lib/scene-clipboard.ts +73 -8
  224. package/src/lib/scene.ts +28 -5
  225. package/src/lib/selection-routing.test.ts +298 -0
  226. package/src/lib/selection-routing.ts +175 -0
  227. package/src/lib/sfx/index.ts +1 -0
  228. package/src/lib/sfx/movement-tick.test.ts +65 -0
  229. package/src/lib/sfx/movement-tick.ts +18 -0
  230. package/src/lib/sfx-bus.ts +50 -13
  231. package/src/lib/sfx-player.test.ts +124 -0
  232. package/src/lib/sfx-player.ts +111 -66
  233. package/src/lib/slab-plan-snap.test.ts +93 -0
  234. package/src/lib/slab-plan-snap.ts +108 -0
  235. package/src/lib/snapping-mode.test.ts +138 -0
  236. package/src/lib/snapping-mode.ts +179 -0
  237. package/src/lib/stair-duplication.ts +4 -4
  238. package/src/lib/surface-plan-snap.test.ts +71 -0
  239. package/src/lib/surface-plan-snap.ts +256 -0
  240. package/src/lib/use-linear-display.ts +40 -0
  241. package/src/lib/world-grid-snap.ts +37 -5
  242. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  243. package/src/store/use-editor.tsx +484 -114
  244. package/src/store/use-facing-pose.ts +44 -0
  245. package/src/store/use-fence-curve-draft.ts +21 -0
  246. package/src/store/use-floorplan-draft-preview.ts +101 -0
  247. package/src/store/use-floorplan-marquee.ts +50 -0
  248. package/src/store/use-interaction-scope.test.ts +186 -0
  249. package/src/store/use-interaction-scope.ts +132 -0
  250. package/src/store/use-measurement-draft.test.ts +530 -0
  251. package/src/store/use-measurement-draft.ts +543 -0
  252. package/src/store/use-opening-guides.ts +41 -0
  253. package/src/store/use-placement-preview.ts +11 -3
  254. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  255. package/src/store/use-quick-measurement-hud.ts +77 -0
  256. package/src/store/use-segment-draft-chain.ts +28 -0
  257. package/src/store/use-stair-build-preview.ts +43 -0
  258. package/src/store/use-wall-snap-indicator.ts +2 -0
  259. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  260. package/src/components/editor/group-move-handle.tsx +0 -316
  261. package/src/components/ui/panels/paint-panel.tsx +0 -163
  262. package/src/lib/level-name.ts +0 -11
@@ -6,15 +6,16 @@ import {
6
6
  type ArcResizeHandle,
7
7
  type Cursor,
8
8
  createSceneApi,
9
+ DEFAULT_ANGLE_STEP,
9
10
  type HandleDescriptor,
10
11
  type HandlePortal,
12
+ type LatchHandle,
11
13
  type LinearResizeHandle,
12
14
  nodeRegistry,
13
15
  type RadialResizeHandle,
14
16
  sceneRegistry,
15
17
  snapScalar,
16
18
  type TapActionHandle,
17
- type TranslateHandle,
18
19
  useLiveNodeOverrides,
19
20
  useScene,
20
21
  } from '@pascal-app/core'
@@ -34,15 +35,26 @@ import {
34
35
  OrthographicCamera,
35
36
  Plane,
36
37
  Quaternion,
38
+ Ray,
37
39
  RingGeometry,
38
40
  Vector3,
39
41
  } from 'three'
40
42
  import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
43
+
41
44
  import { MeshBasicNodeMaterial } from 'three/webgpu'
42
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'
43
48
  import { createEditorApi } from '../../lib/editor-api'
44
49
  import { sfxEmitter } from '../../lib/sfx-bus'
50
+ import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
45
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'
46
58
  import { formatAngleRadians } from '../tools/shared/segment-angle'
47
59
  import {
48
60
  ARROW_COLOR,
@@ -54,15 +66,66 @@ import {
54
66
  } from './handles/handle-arrow'
55
67
  import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag'
56
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
+
57
115
  export {
58
116
  ARROW_COLOR,
59
117
  ARROW_HOVER_COLOR,
60
118
  ARROW_SCALE,
119
+ createArrowHandleGeometry,
61
120
  createArrowHitAreaGeometry,
62
121
  createEndpointHitAreaGeometry,
63
122
  createMoveCrossHandleGeometry,
64
123
  createRotateArrowHandleGeometry,
65
124
  createRotateArrowHitAreaGeometry,
125
+ HandleArrow,
126
+ type HandleArrowInputShape,
127
+ type HandleArrowPlacement,
128
+ type HandleArrowProps,
66
129
  HIT_AREA_MARGIN,
67
130
  InvisibleHandleHitArea,
68
131
  NO_RAYCAST,
@@ -76,6 +139,20 @@ export { swallowNextClick } from './handles/use-handle-drag'
76
139
  // its end). Upward trackers — wall / chimney height — stop at the cube.
77
140
  const TRACKER_THROUGH = 0.12
78
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
+
79
156
  // Mirrors the formatter used by wall / fence measurement labels so all
80
157
  // in-world dimension chips read consistently.
81
158
  function formatDimension(value: number, unit: 'metric' | 'imperial'): string {
@@ -122,23 +199,23 @@ function DimensionLabel({
122
199
 
123
200
  export function NodeArrowHandles() {
124
201
  const selectedIds = useViewer((state) => state.selection.selectedIds)
202
+ const activeRotateNodeId = useDirectManipulationFeedback((state) => state.activeRotateNodeId)
125
203
  const mode = useEditor((state) => state.mode)
126
204
  const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
127
- const movingNode = useEditor((state) => state.movingNode)
128
- const placementDragMode = useEditor((state) => state.placementDragMode)
205
+ const movingNode = useMovingNode()
129
206
  // Endpoint / curve drags reshape the selected wall or fence; hide its
130
207
  // resize arrows for the duration so they don't clutter (or get blocked
131
208
  // by) the drag's own cursor + dimension overlays. Mirrors the same guard
132
209
  // 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
- )
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
+ })
142
219
 
143
220
  // Merge any live drag override so the arrows themselves (positions,
144
221
  // ring decorations) track the in-flight drag instead of freezing at
@@ -151,26 +228,37 @@ export function NodeArrowHandles() {
151
228
  () => (rawNode && liveOverride ? ({ ...rawNode, ...liveOverride } as AnyNode) : rawNode),
152
229
  [rawNode, liveOverride],
153
230
  )
154
- const isOwnPressDragMove =
155
- placementDragMode && movingNode !== null && selectedId !== null && movingNode.id === selectedId
156
-
157
231
  const def = node ? nodeRegistry.get(node.type) : null
232
+ const descriptorSceneApi = useMemo(() => createSceneApi(useScene), [])
158
233
  const descriptors = useMemo(() => {
159
234
  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])
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])
164
250
 
165
251
  const shouldRender =
166
252
  Boolean(node && descriptors?.length) &&
167
253
  !isFloorplanHovered &&
168
254
  mode !== 'delete' &&
169
- (!movingNode || isOwnPressDragMove) &&
170
- !movingWallEndpoint &&
171
- !movingFenceEndpoint &&
172
- !curvingWall &&
173
- !curvingFence
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
174
262
 
175
263
  if (!shouldRender || !node || !descriptors) return null
176
264
  // Key by the selected node id so switching selection REMOUNTS the rig.
@@ -275,14 +363,32 @@ function NodeArrowHandlesForNode({
275
363
  // exclusion the wall arrow also goes without.
276
364
 
277
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
+ }
278
388
  if (outerRef.current && outerRide) {
279
389
  outerRef.current.position.copy(outerRide.position)
280
390
  outerRef.current.quaternion.copy(outerRide.quaternion)
281
391
  }
282
- if (innerRef.current && innerRide) {
283
- innerRef.current.position.copy(innerRide.position)
284
- innerRef.current.quaternion.copy(innerRide.quaternion)
285
- }
286
392
  })
287
393
 
288
394
  // Active-drag tracking. When a handle starts dragging, it claims its
@@ -299,6 +405,21 @@ function NodeArrowHandlesForNode({
299
405
  // hook count between renders and trip React's rules-of-hooks check.
300
406
  const [activeIndex, setActiveIndex] = useState<number | null>(null)
301
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
+ )
302
423
  const dragControls = useMemo<HandleDragControls>(
303
424
  () => ({
304
425
  onStart: (index: number, snapshot: AnyNode) => {
@@ -328,25 +449,48 @@ function NodeArrowHandlesForNode({
328
449
  // resize that re-centres the mesh) must NOT fire for the non-active arrows
329
450
  // here, or they'd lag behind the moving item.
330
451
  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
- ))
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
+ })
347
491
 
348
492
  return createPortal(
349
- <group ref={outerRef}>
493
+ <group ref={outerRef} userData={MEASUREMENT_SURFACE_EXCLUDE_USER_DATA}>
350
494
  {innerRideId !== null ? <group ref={innerRef}>{arrows}</group> : arrows}
351
495
  </group>,
352
496
  portalObject,
@@ -443,17 +587,6 @@ function ArrowHandle({
443
587
  />
444
588
  )
445
589
  }
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
590
  if (descriptor.kind === 'tap-action') {
458
591
  // Tap-action handles (fence side-move arrows, corner pickers) aren't
459
592
  // resize handles, so the freeze-at-pre-drag mechanism — which only
@@ -524,6 +657,9 @@ function LinearArrow({
524
657
  // floating dimension pill (via `activeHandleDrag`) and its own in-world
525
658
  // chip is suppressed — matches the wall height handle.
526
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
527
663
  const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
528
664
  const basePosition = descriptor.placement.position(node, placementSceneApi)
529
665
  // `freezeOffset` (in node-local frame) cancels the mesh's `position`
@@ -555,34 +691,31 @@ function LinearArrow({
555
691
  rideObject,
556
692
  setIsDragging,
557
693
  onStart: ({
558
- camera: dragCamera,
559
694
  event,
695
+ getPointerRay,
560
696
  initialNode,
561
- intersectPlane,
562
697
  nodeId,
563
698
  rideObject: dragRideObject,
564
699
  sceneApi,
565
700
  }) => {
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)
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
572
706
 
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)
707
+ const initialPointer =
708
+ closestAxisParameterToRay(
709
+ _resizeOriginW,
710
+ _resizeAxisW,
711
+ getPointerRay(event.nativeEvent.clientX, event.nativeEvent.clientY, _resizeRay),
712
+ ) / localToWorldScale
578
713
 
579
714
  const overrideId =
580
715
  (descriptor.kind === 'linear-resize'
581
716
  ? descriptor.overrideTarget?.(initialNode as never, sceneApi)
582
717
  : undefined) ?? nodeId
583
718
  const initialValue = descriptor.currentValue(initialNode)
584
- const initialPointer =
585
- descriptor.axis === 'x' ? hitLocal.x : descriptor.axis === 'y' ? hitLocal.y : hitLocal.z
586
719
  const minBound = resolveBound(descriptor.min, Number.NEGATIVE_INFINITY, initialNode, sceneApi)
587
720
  const maxBound = resolveBound(descriptor.max, Number.POSITIVE_INFINITY, initialNode, sceneApi)
588
721
  const gridSnapStep =
@@ -598,36 +731,54 @@ function LinearArrow({
598
731
  ? 1
599
732
  : -1
600
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
+
601
739
  return {
602
740
  overrideId,
741
+ commit:
742
+ descriptor.kind === 'linear-resize' && descriptor.commit
743
+ ? (patch) => descriptor.commit?.(initialNode, patch, sceneApi)
744
+ : undefined,
603
745
  onBegin: () => {
604
- if (measureLabel) {
605
- useEditor.getState().setActiveHandleDrag({ nodeId, label: measureLabel })
606
- }
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
+ })
607
755
  },
608
756
  onEnd: () => {
609
- if (measureLabel) {
610
- useEditor.getState().setActiveHandleDrag(null)
611
- }
757
+ useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
758
+ if (onDrag) useOpeningGuides.getState().clear()
612
759
  },
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)
760
+ move: ({ event: moveEvent, getPointerRay: getMovePointerRay }) => {
619
761
  const currentPointer =
620
- descriptor.axis === 'x'
621
- ? intersectionLocal.x
622
- : descriptor.axis === 'y'
623
- ? intersectionLocal.y
624
- : intersectionLocal.z
762
+ closestAxisParameterToRay(
763
+ _resizeOriginW,
764
+ _resizeAxisW,
765
+ getMovePointerRay(moveEvent.clientX, moveEvent.clientY, _resizeRay),
766
+ ) / localToWorldScale
625
767
  const delta = currentPointer - initialPointer
626
768
  const rawNext = initialValue + delta * factor
627
769
  const snappedNext =
628
- gridSnapStep && gridSnapStep > 0 ? snapScalar(rawNext, gridSnapStep) : rawNext
770
+ !moveEvent.shiftKey && gridSnapStep && gridSnapStep > 0
771
+ ? snapScalar(rawNext, gridSnapStep)
772
+ : rawNext
629
773
  const next = Math.min(maxBound, Math.max(minBound, snappedNext))
630
- return descriptor.apply(initialNode as never, next, sceneApi) as Partial<AnyNode>
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
631
782
  },
632
783
  }
633
784
  },
@@ -637,10 +788,19 @@ function LinearArrow({
637
788
  // X+Z rotation chain matching DoorHeightArrowHandle. When the handle
638
789
  // sits below the node (placement Y < 0, e.g. window bottom arrow),
639
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
640
798
  const innerRotation: [number, number, number] =
641
799
  descriptor.axis === 'y'
642
800
  ? [0, Math.PI / 2, position[1] < 0 ? -Math.PI / 2 : Math.PI / 2]
643
- : [0, 0, 0]
801
+ : faceNormalX
802
+ ? [Math.PI / 2, 0, 0]
803
+ : [0, 0, 0]
644
804
 
645
805
  // Optional guide decoration — linear handles use it for curved-stair
646
806
  // width / inner-radius rings; radial handles use it for the column's
@@ -686,7 +846,8 @@ function LinearArrow({
686
846
  <>
687
847
  {showDecoration && decoration ? (
688
848
  <GuideRing
689
- radius={decoration.radius(node as never)}
849
+ center={guideDecorationCenter(decoration, node, placementSceneApi)}
850
+ radius={guideDecorationRadius(decoration, node, placementSceneApi)}
690
851
  y={decoration.y?.(node as never) ?? 0}
691
852
  />
692
853
  ) : null}
@@ -714,7 +875,8 @@ function LinearArrow({
714
875
  <>
715
876
  {showDecoration && decoration ? (
716
877
  <GuideRing
717
- radius={decoration.radius(node as never)}
878
+ center={guideDecorationCenter(decoration, node, placementSceneApi)}
879
+ radius={guideDecorationRadius(decoration, node, placementSceneApi)}
718
880
  y={decoration.y?.(node as never) ?? 0}
719
881
  />
720
882
  ) : null}
@@ -726,6 +888,7 @@ function LinearArrow({
726
888
  onPointerDown={activate}
727
889
  placement={{ position, rotation: [0, rotationY, 0], baseScale }}
728
890
  shape="chevron"
891
+ thin
729
892
  >
730
893
  {showLabel ? <DimensionLabel position={[0, 0.22, 0]} text={labelText} /> : null}
731
894
  </HandleArrow>
@@ -737,7 +900,15 @@ function LinearArrow({
737
900
  // e.g. the curved-stair width arrow traces the outer rim, the inner-radius
738
901
  // arrow traces the central pillar. Floats at node-local `y`, lies in the
739
902
  // XZ plane.
740
- export function GuideRing({ radius, y }: { radius: number; y: number }) {
903
+ export function GuideRing({
904
+ center,
905
+ radius,
906
+ y,
907
+ }: {
908
+ center?: readonly [number, number, number]
909
+ radius: number
910
+ y: number
911
+ }) {
741
912
  const safeRadius = Math.max(radius, 0.01)
742
913
  const ringGeometry = useMemo(() => {
743
914
  const inner = Math.max(safeRadius - 0.015, 0.001)
@@ -764,7 +935,7 @@ export function GuideRing({ radius, y }: { radius: number; y: number }) {
764
935
  frustumCulled={false}
765
936
  geometry={ringGeometry}
766
937
  material={ringMaterial}
767
- position={[0, y, 0]}
938
+ position={center ? [center[0], y, center[2]] : [0, y, 0]}
768
939
  renderOrder={1009}
769
940
  rotation={[-Math.PI / 2, 0, 0]}
770
941
  />
@@ -873,11 +1044,13 @@ function RotationGuideOutline({ geometry }: { geometry: BufferGeometry }) {
873
1044
  // from the pivot; the fill is pulled inside it so it reads as the handle
874
1045
  // swinging around rather than overlapping the icon.
875
1046
  function RotationWedge({
1047
+ center,
876
1048
  delta,
877
1049
  handleAngle,
878
1050
  orbitRadius,
879
1051
  y,
880
1052
  }: {
1053
+ center?: readonly [number, number, number]
881
1054
  delta: number
882
1055
  handleAngle: number
883
1056
  orbitRadius: number
@@ -917,7 +1090,7 @@ function RotationWedge({
917
1090
  ]
918
1091
 
919
1092
  return (
920
- <group position={[0, y, 0]}>
1093
+ <group position={center ? [center[0], y, center[2]] : [0, y, 0]}>
921
1094
  <mesh
922
1095
  frustumCulled={false}
923
1096
  geometry={fill}
@@ -971,6 +1144,8 @@ function ArcArrow({
971
1144
  // corner) render a two-headed curved arrow; everything else (stair
972
1145
  // sweep, etc.) keeps the chevron.
973
1146
  const isRotateShape = descriptor.shape === 'rotate'
1147
+ const activeRotateNodeId = useDirectManipulationFeedback((state) => state.activeRotateNodeId)
1148
+ const isDirectRotating = isRotateShape && activeRotateNodeId === liveNode.id
974
1149
  // 'node-normal' spins the node about its local +Z (a wall item flat against
975
1150
  // its wall) instead of yaw about world-Y. The drag plane and the icon both
976
1151
  // tilt into that plane, and the horizontal-only wedge/ring readout is
@@ -1010,7 +1185,8 @@ function ArcArrow({
1010
1185
  // arrow is hovered or dragging. Same recipe as the linear / radial
1011
1186
  // decoration path.
1012
1187
  const decoration = descriptor.decoration
1013
- const showDecoration = Boolean(decoration) && (isHovered || isDragging)
1188
+ const decorationCenter = guideDecorationCenter(decoration, node, placementSceneApi)
1189
+ const showDecoration = Boolean(decoration) && (isHovered || isDragging || isDirectRotating)
1014
1190
 
1015
1191
  const activate = useHandleDrag({
1016
1192
  kind: 'drag',
@@ -1062,8 +1238,23 @@ function ArcArrow({
1062
1238
  }
1063
1239
  const initialAngle = angleOf(hitWorld)
1064
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
+
1065
1251
  return {
1066
- onEnd: () => setRotationDelta(null),
1252
+ onEnd: () => {
1253
+ setRotationDelta(null)
1254
+ if (isRotateShape) {
1255
+ useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
1256
+ }
1257
+ },
1067
1258
  move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => {
1068
1259
  const hit = new Vector3()
1069
1260
  if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, hit)) return null
@@ -1072,9 +1263,8 @@ function ArcArrow({
1072
1263
  while (delta > Math.PI) delta -= 2 * Math.PI
1073
1264
  while (delta < -Math.PI) delta += 2 * Math.PI
1074
1265
 
1075
- if (moveEvent.shiftKey && descriptor.shape === 'rotate') {
1076
- const step = Math.PI / 12
1077
- delta = Math.round(delta / step) * step
1266
+ if (!moveEvent.shiftKey && descriptor.shape === 'rotate') {
1267
+ delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
1078
1268
  }
1079
1269
 
1080
1270
  if (isRotateShape && !isNodeNormalRot) {
@@ -1086,18 +1276,12 @@ function ArcArrow({
1086
1276
  },
1087
1277
  })
1088
1278
 
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
1279
  return (
1097
1280
  <>
1098
1281
  {showDecoration && decoration ? (
1099
1282
  <GuideRing
1100
- radius={decoration.radius(node as never)}
1283
+ center={guideDecorationCenter(decoration, node, placementSceneApi)}
1284
+ radius={guideDecorationRadius(decoration, node, placementSceneApi)}
1101
1285
  y={decoration.y?.(node as never) ?? 0}
1102
1286
  />
1103
1287
  ) : null}
@@ -1107,16 +1291,23 @@ function ArcArrow({
1107
1291
  ring on any surface — flat ground or a pitched roof. */}
1108
1292
  {rotationDelta !== null ? (
1109
1293
  <RotationWedge
1294
+ center={decorationCenter}
1110
1295
  delta={rotationDelta}
1111
- handleAngle={Math.atan2(position[2], position[0])}
1112
- orbitRadius={Math.hypot(position[0], position[2])}
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
+ )}
1113
1304
  y={decoration?.y?.(node as never) ?? 0}
1114
1305
  />
1115
1306
  ) : null}
1116
1307
  <HandleArrow
1117
1308
  activeCursor={dragCursor}
1118
1309
  cursor={hoverCursor}
1119
- hover={isHovered}
1310
+ hover={isHovered || isDirectRotating}
1120
1311
  onHoverChange={setIsHovered}
1121
1312
  onPointerDown={activate}
1122
1313
  placement={{
@@ -1127,69 +1318,12 @@ function ArcArrow({
1127
1318
  baseScale,
1128
1319
  }}
1129
1320
  shape={isRotateShape ? 'curved-arrow' : 'chevron'}
1321
+ thin
1130
1322
  />
1131
1323
  </>
1132
1324
  )
1133
1325
  }
1134
1326
 
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
1327
  // Click-to-engage affordance — no drag plumbing, just a click target. The
1194
1328
  // descriptor's `onActivate` receives sceneApi + editorApi so it can engage
1195
1329
  // move tools, endpoint drags, or any other editor-state transition without
@@ -1210,6 +1344,7 @@ function TapActionArrow({
1210
1344
  const rotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0
1211
1345
  const shape = descriptor.shape ?? 'arrow'
1212
1346
  const cursor: Cursor = descriptor.cursor ?? (shape === 'corner-picker' ? 'move' : 'ew-resize')
1347
+ const round = descriptor.round ?? false
1213
1348
 
1214
1349
  const onActivate = useHandleDrag({
1215
1350
  kind: 'tap',
@@ -1230,20 +1365,75 @@ function TapActionArrow({
1230
1365
  onHoverChange={setIsHovered}
1231
1366
  onPointerDown={onActivate}
1232
1367
  position={position}
1368
+ round={round}
1233
1369
  />
1234
1370
  )
1235
1371
  }
1236
1372
 
1237
- // Default 'arrow' shape — the standard chevron.
1238
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]
1239
1379
  return (
1240
1380
  <HandleArrow
1241
1381
  cursor={cursor}
1242
1382
  hover={isHovered}
1243
1383
  onHoverChange={setIsHovered}
1244
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}
1245
1435
  placement={{ position, rotation: [0, rotationY, 0], baseScale }}
1246
- shape="chevron"
1436
+ shape="tracker"
1247
1437
  />
1248
1438
  )
1249
1439
  }
@@ -1281,6 +1471,7 @@ function CornerPickerShape({
1281
1471
  hover,
1282
1472
  onHoverChange,
1283
1473
  onPointerDown,
1474
+ round = false,
1284
1475
  }: {
1285
1476
  position: readonly [number, number, number]
1286
1477
  height: number
@@ -1289,6 +1480,7 @@ function CornerPickerShape({
1289
1480
  hover: boolean
1290
1481
  onHoverChange: (hovered: boolean) => void
1291
1482
  onPointerDown: (event: ThreeEvent<PointerEvent>) => void
1483
+ round?: boolean
1292
1484
  }) {
1293
1485
  const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(height), [height])
1294
1486
  useEffect(() => () => dashedGeometry.dispose(), [dashedGeometry])
@@ -1366,10 +1558,11 @@ function CornerPickerShape({
1366
1558
  onHoverChange={onHoverChange}
1367
1559
  onPointerDown={onPointerDown}
1368
1560
  placement={{ position: [0, 0, 0], baseScale }}
1561
+ round={round}
1369
1562
  shape="corner-picker"
1370
1563
  />
1371
1564
  <mesh material={ringMaterial} renderOrder={1002} scale={scale}>
1372
- <ringGeometry args={[CORNER_HEX_RADIUS, CORNER_HEX_RADIUS * 1.18, 6]} />
1565
+ <ringGeometry args={[CORNER_HEX_RADIUS, CORNER_HEX_RADIUS * 1.18, round ? 32 : 6]} />
1373
1566
  </mesh>
1374
1567
  </group>
1375
1568
  </>