@pascal-app/editor 0.9.1 → 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (366) hide show
  1. package/package.json +14 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +49 -0
  4. package/src/components/editor/bake-thumbnail.tsx +95 -0
  5. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  6. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  7. package/src/components/editor/custom-camera-controls.tsx +332 -199
  8. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  9. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  10. package/src/components/editor/editor-layout-v2.tsx +18 -0
  11. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  12. package/src/components/editor/export-manager.tsx +71 -63
  13. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  14. package/src/components/editor/first-person/build-collider-world.test.ts +102 -5
  15. package/src/components/editor/first-person/build-collider-world.ts +52 -5
  16. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  17. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  18. package/src/components/editor/first-person-controls.tsx +410 -136
  19. package/src/components/editor/floating-action-menu.tsx +435 -64
  20. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  21. package/src/components/editor/floorplan-background-selection.test.ts +72 -0
  22. package/src/components/editor/floorplan-background-selection.ts +7 -20
  23. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  24. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  25. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  26. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  27. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  28. package/src/components/editor/floorplan-panel.tsx +2517 -1492
  29. package/src/components/editor/grid.tsx +166 -39
  30. package/src/components/editor/group-actions.ts +597 -0
  31. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  32. package/src/components/editor/group-move-3d.ts +397 -0
  33. package/src/components/editor/group-rotate-handle.tsx +102 -64
  34. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  35. package/src/components/editor/group-transform-shared.test.ts +192 -1
  36. package/src/components/editor/group-transform-shared.ts +240 -7
  37. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  38. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  39. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  40. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  41. package/src/components/editor/handles/preview-overrides.ts +13 -0
  42. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  43. package/src/components/editor/handles/resize-snap.ts +23 -0
  44. package/src/components/editor/handles/use-handle-drag.ts +49 -6
  45. package/src/components/editor/index.tsx +289 -35
  46. package/src/components/editor/measurement-pill.tsx +68 -23
  47. package/src/components/editor/node-action-menu.tsx +14 -1
  48. package/src/components/editor/node-arrow-handles.tsx +409 -180
  49. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  50. package/src/components/editor/quick-measurement-card.tsx +76 -0
  51. package/src/components/editor/quick-measurement-hud.tsx +34 -0
  52. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  53. package/src/components/editor/selection-manager.tsx +814 -466
  54. package/src/components/editor/site-edge-labels.tsx +28 -6
  55. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  56. package/src/components/editor/snapshot-capture-overlay.tsx +258 -115
  57. package/src/components/editor/three-context-bridge.ts +22 -0
  58. package/src/components/editor/thumbnail-generator.tsx +118 -253
  59. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  60. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  61. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  62. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  63. package/src/components/editor/use-mesh-settle-epoch.ts +27 -0
  64. package/src/components/editor/wall-measurement-label.tsx +10 -28
  65. package/src/components/editor/wall-move-side-handles.tsx +229 -39
  66. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  67. package/src/components/editor/wall-snap-beacon-layer.tsx +162 -6
  68. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  69. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +40 -9
  71. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  72. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  73. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  74. package/src/components/editor-2d/floorplan-group-move.tsx +744 -0
  75. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  76. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1672 -0
  77. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  78. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  79. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +219 -20
  80. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +327 -59
  81. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  82. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  83. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  84. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  85. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  86. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  88. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  89. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  90. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +89 -0
  91. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +106 -0
  92. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +94 -26
  93. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +587 -0
  94. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +2280 -578
  95. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  96. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  97. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +425 -61
  98. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  99. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  100. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  101. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  102. package/src/components/systems/zone/zone-system.tsx +42 -13
  103. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  104. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  105. package/src/components/tools/fence/fence-drafting.ts +107 -8
  106. package/src/components/tools/item/move-tool.tsx +17 -13
  107. package/src/components/tools/item/placement-math.test.ts +13 -1
  108. package/src/components/tools/item/placement-math.ts +28 -2
  109. package/src/components/tools/item/placement-strategies.ts +246 -5
  110. package/src/components/tools/item/placement-types.ts +13 -1
  111. package/src/components/tools/item/use-draft-node.ts +146 -57
  112. package/src/components/tools/item/use-placement-coordinator.tsx +813 -125
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +636 -103
  114. package/src/components/tools/roof/roof-tool.tsx +336 -94
  115. package/src/components/tools/select/box-select-state.ts +18 -7
  116. package/src/components/tools/select/box-select-tool.tsx +176 -40
  117. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  118. package/src/components/tools/select/marquee-geometry.ts +155 -0
  119. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  120. package/src/components/tools/select/select-candidates.ts +1 -1
  121. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  122. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  123. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  124. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  125. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  126. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  127. package/src/components/tools/shared/placement-box.tsx +183 -1
  128. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  129. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  130. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  131. package/src/components/tools/site/site-boundary-editor.tsx +101 -42
  132. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  133. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  134. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  135. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  136. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  137. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  138. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  139. package/src/components/tools/stair/stair-defaults.ts +1 -0
  140. package/src/components/tools/stair/stair-tool.tsx +164 -42
  141. package/src/components/tools/tool-manager.tsx +149 -30
  142. package/src/components/tools/wall/wall-drafting.test.ts +447 -0
  143. package/src/components/tools/wall/wall-drafting.ts +304 -108
  144. package/src/components/tools/wall/wall-snap-geometry.test.ts +52 -0
  145. package/src/components/tools/wall/wall-snap-geometry.ts +137 -9
  146. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  147. package/src/components/tools/zone/zone-tool.tsx +98 -88
  148. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  149. package/src/components/ui/action-menu/control-modes.tsx +44 -94
  150. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  151. package/src/components/ui/action-menu/index.tsx +2 -4
  152. package/src/components/ui/action-menu/measurement-control.tsx +267 -0
  153. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  154. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  155. package/src/components/ui/command-palette/editor-commands.tsx +16 -3
  156. package/src/components/ui/command-palette/index.tsx +4 -3
  157. package/src/components/ui/controls/material-paint-panel.tsx +93 -18
  158. package/src/components/ui/controls/material-picker.tsx +162 -152
  159. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  160. package/src/components/ui/controls/metric-control.tsx +118 -44
  161. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  162. package/src/components/ui/controls/slider-control.tsx +88 -38
  163. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  164. package/src/components/ui/floating-level-selector.tsx +74 -20
  165. package/src/components/ui/helpers/building-helper.tsx +10 -23
  166. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  167. package/src/components/ui/helpers/helper-manager.tsx +273 -14
  168. package/src/components/ui/helpers/item-helper.tsx +28 -32
  169. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  170. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  171. package/src/components/ui/icon-ref.tsx +47 -0
  172. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  173. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  174. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  175. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  176. package/src/components/ui/panels/node-display.ts +17 -17
  177. package/src/components/ui/panels/panel-manager.tsx +36 -14
  178. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  179. package/src/components/ui/panels/parametric-inspector.tsx +19 -4
  180. package/src/components/ui/panels/reference-panel.tsx +54 -19
  181. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  182. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  183. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  184. package/src/components/ui/primitives/sidebar.tsx +1 -0
  185. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  186. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  187. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  188. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +82 -41
  189. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +76 -6
  190. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  191. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  192. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  193. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  194. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  195. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  196. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  197. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  198. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  199. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  200. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -17
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  211. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  215. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  216. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  217. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  218. package/src/components/ui/snap-target-badge.tsx +88 -0
  219. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  220. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  221. package/src/components/viewer-overlay.tsx +36 -596
  222. package/src/components/viewer-zone-system.tsx +6 -1
  223. package/src/components/walkthrough-hud.tsx +111 -0
  224. package/src/hooks/use-auto-save.test.ts +14 -0
  225. package/src/hooks/use-auto-save.ts +54 -7
  226. package/src/hooks/use-ceiling-events.ts +3 -2
  227. package/src/hooks/use-drag-action.ts +4 -1
  228. package/src/hooks/use-grid-events.ts +24 -0
  229. package/src/hooks/use-keyboard.ts +438 -70
  230. package/src/index.tsx +355 -9
  231. package/src/lib/active-placement-surface.test.ts +24 -0
  232. package/src/lib/active-placement-surface.ts +42 -0
  233. package/src/lib/camera-pose.test.ts +237 -0
  234. package/src/lib/camera-pose.ts +172 -0
  235. package/src/lib/ceiling-plan-snap.ts +24 -0
  236. package/src/lib/contextual-help.test.ts +112 -0
  237. package/src/lib/contextual-help.ts +144 -0
  238. package/src/lib/continuation.ts +64 -0
  239. package/src/lib/direct-manipulation.test.ts +275 -0
  240. package/src/lib/direct-manipulation.ts +118 -0
  241. package/src/lib/door-interaction.ts +1 -1
  242. package/src/lib/editor-api.ts +23 -35
  243. package/src/lib/elevation-guides.test.ts +107 -0
  244. package/src/lib/elevation-guides.ts +236 -0
  245. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  246. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  247. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  248. package/src/lib/floorplan/apply-alignment.ts +18 -12
  249. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  250. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  251. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  252. package/src/lib/floorplan/floorplan-export.tsx +978 -0
  253. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  254. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  255. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  256. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  257. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  258. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  259. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  260. package/src/lib/floorplan/geometry.ts +52 -0
  261. package/src/lib/floorplan/index.ts +3 -0
  262. package/src/lib/floorplan/items.ts +5 -2
  263. package/src/lib/floorplan/plan-coords.ts +21 -0
  264. package/src/lib/fresh-planar-placement.test.ts +283 -3
  265. package/src/lib/fresh-planar-placement.ts +85 -1
  266. package/src/lib/glb-export.test.ts +556 -0
  267. package/src/lib/glb-export.ts +1071 -0
  268. package/src/lib/ground-surface.ts +95 -0
  269. package/src/lib/history.test.ts +96 -0
  270. package/src/lib/history.ts +31 -0
  271. package/src/lib/interaction/hot-set.test.ts +133 -0
  272. package/src/lib/interaction/hot-set.ts +67 -0
  273. package/src/lib/interaction/overlay-policy.test.ts +52 -0
  274. package/src/lib/interaction/overlay-policy.ts +59 -0
  275. package/src/lib/interaction/scope.ts +243 -0
  276. package/src/lib/level-duplication.test.ts +2 -1
  277. package/src/lib/level-selection.ts +2 -2
  278. package/src/lib/material-paint.ts +36 -49
  279. package/src/lib/measurement-kind.test.ts +30 -0
  280. package/src/lib/measurement-kind.ts +19 -0
  281. package/src/lib/measurement-label.test.ts +47 -0
  282. package/src/lib/measurement-label.ts +68 -0
  283. package/src/lib/measurement-parser.ts +116 -0
  284. package/src/lib/measurements.test.ts +188 -0
  285. package/src/lib/measurements.ts +210 -0
  286. package/src/lib/paint-scope.test.ts +454 -0
  287. package/src/lib/paint-scope.ts +461 -0
  288. package/src/lib/placement-drag-release.ts +23 -0
  289. package/src/lib/planar-cursor-placement.test.ts +57 -0
  290. package/src/lib/plugin-panels.test.ts +19 -0
  291. package/src/lib/plugin-panels.ts +91 -0
  292. package/src/lib/quick-action-feedback.ts +33 -0
  293. package/src/lib/quick-action-nodes.test.ts +94 -0
  294. package/src/lib/quick-action-nodes.ts +47 -0
  295. package/src/lib/quick-measurement.test.ts +77 -0
  296. package/src/lib/quick-measurement.ts +109 -0
  297. package/src/lib/roof-duplication.ts +6 -6
  298. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  299. package/src/lib/roof-wall-hit.ts +164 -0
  300. package/src/lib/scene-clipboard.test.ts +325 -0
  301. package/src/lib/scene-clipboard.ts +310 -21
  302. package/src/lib/scene.ts +28 -5
  303. package/src/lib/selection-routing.test.ts +313 -0
  304. package/src/lib/selection-routing.ts +180 -0
  305. package/src/lib/sfx/index.ts +1 -0
  306. package/src/lib/sfx/movement-tick.test.ts +65 -0
  307. package/src/lib/sfx/movement-tick.ts +18 -0
  308. package/src/lib/sfx-bus.ts +82 -14
  309. package/src/lib/sfx-player.test.ts +266 -0
  310. package/src/lib/sfx-player.ts +262 -69
  311. package/src/lib/site-boundary.test.ts +53 -0
  312. package/src/lib/site-boundary.ts +27 -0
  313. package/src/lib/slab-plan-snap.test.ts +93 -0
  314. package/src/lib/slab-plan-snap.ts +108 -0
  315. package/src/lib/snapping-mode.test.ts +165 -0
  316. package/src/lib/snapping-mode.ts +191 -0
  317. package/src/lib/stair-duplication.test.ts +103 -0
  318. package/src/lib/stair-duplication.ts +58 -86
  319. package/src/lib/stair-levels.ts +2 -0
  320. package/src/lib/surface-plan-snap.test.ts +71 -0
  321. package/src/lib/surface-plan-snap.ts +256 -0
  322. package/src/lib/terrain-sculpt.test.ts +429 -0
  323. package/src/lib/terrain-sculpt.ts +301 -0
  324. package/src/lib/terrain-verb-color.test.ts +51 -0
  325. package/src/lib/terrain-verb-color.ts +58 -0
  326. package/src/lib/touch-gesture-priority.test.ts +45 -0
  327. package/src/lib/touch-gesture-priority.ts +30 -0
  328. package/src/lib/use-linear-display.ts +40 -0
  329. package/src/lib/window-interaction.ts +1 -1
  330. package/src/lib/world-grid-snap.ts +37 -5
  331. package/src/store/camera-pose-store.test.ts +29 -0
  332. package/src/store/camera-pose-store.ts +27 -0
  333. package/src/store/live-draft-preview-stores.test.ts +136 -0
  334. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  335. package/src/store/navigation-sync-pose-store.ts +27 -0
  336. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  337. package/src/store/use-delete-confirmation.ts +27 -0
  338. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  339. package/src/store/use-drawing-view.test.ts +23 -0
  340. package/src/store/use-drawing-view.ts +84 -0
  341. package/src/store/use-editor.tsx +665 -126
  342. package/src/store/use-elevation-guides.ts +40 -0
  343. package/src/store/use-facing-pose.ts +44 -0
  344. package/src/store/use-fence-curve-draft.ts +51 -0
  345. package/src/store/use-first-person-hud.ts +40 -0
  346. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  347. package/src/store/use-floorplan-draft-preview.ts +151 -0
  348. package/src/store/use-floorplan-marquee.ts +50 -0
  349. package/src/store/use-floorplan-mode.ts +110 -0
  350. package/src/store/use-interaction-scope.test.ts +206 -0
  351. package/src/store/use-interaction-scope.ts +140 -0
  352. package/src/store/use-measurement-draft.test.ts +559 -0
  353. package/src/store/use-measurement-draft.ts +570 -0
  354. package/src/store/use-opening-guides.ts +41 -0
  355. package/src/store/use-path-draft-preview.ts +90 -0
  356. package/src/store/use-placement-preview.ts +11 -3
  357. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  358. package/src/store/use-quick-measurement-hud.ts +77 -0
  359. package/src/store/use-segment-draft-chain.ts +28 -0
  360. package/src/store/use-stair-build-preview.ts +53 -0
  361. package/src/store/use-wall-snap-indicator.ts +2 -0
  362. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  363. package/src/components/editor/group-move-handle.tsx +0 -316
  364. package/src/components/ui/panels/paint-panel.tsx +0 -163
  365. package/src/lib/floorplan/selection-tool.ts +0 -271
  366. package/src/lib/level-name.ts +0 -11
@@ -6,15 +6,15 @@ 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
- snapScalar,
16
17
  type TapActionHandle,
17
- type TranslateHandle,
18
18
  useLiveNodeOverrides,
19
19
  useScene,
20
20
  } from '@pascal-app/core'
@@ -34,15 +34,25 @@ import {
34
34
  OrthographicCamera,
35
35
  Plane,
36
36
  Quaternion,
37
+ Ray,
37
38
  RingGeometry,
38
39
  Vector3,
39
40
  } from 'three'
40
41
  import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
42
+
41
43
  import { MeshBasicNodeMaterial } from 'three/webgpu'
42
44
  import { EDITOR_LAYER } from '../../lib/constants'
45
+ import { RESIZE_HANDLE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from '../../lib/contextual-help'
43
46
  import { createEditorApi } from '../../lib/editor-api'
44
47
  import { sfxEmitter } from '../../lib/sfx-bus'
45
- import useEditor from '../../store/use-editor'
48
+ import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
49
+ import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../store/use-editor'
50
+ import useInteractionScope, {
51
+ useEndpointReshape,
52
+ useIsCurveReshape,
53
+ useMovingNode,
54
+ } from '../../store/use-interaction-scope'
55
+ import useOpeningGuides from '../../store/use-opening-guides'
46
56
  import { formatAngleRadians } from '../tools/shared/segment-angle'
47
57
  import {
48
58
  ARROW_COLOR,
@@ -52,17 +62,70 @@ import {
52
62
  HandleArrow,
53
63
  NO_RAYCAST,
54
64
  } from './handles/handle-arrow'
65
+ import { replacePreviewOverrideIds } from './handles/preview-overrides'
66
+ import { resolveResizeSnapValue } from './handles/resize-snap'
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,22 @@ 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
+ return state.nodes[selectedId as AnyNodeId] ?? null
217
+ })
142
218
 
143
219
  // Merge any live drag override so the arrows themselves (positions,
144
220
  // ring decorations) track the in-flight drag instead of freezing at
@@ -151,26 +227,37 @@ export function NodeArrowHandles() {
151
227
  () => (rawNode && liveOverride ? ({ ...rawNode, ...liveOverride } as AnyNode) : rawNode),
152
228
  [rawNode, liveOverride],
153
229
  )
154
- const isOwnPressDragMove =
155
- placementDragMode && movingNode !== null && selectedId !== null && movingNode.id === selectedId
156
-
157
230
  const def = node ? nodeRegistry.get(node.type) : null
231
+ const descriptorSceneApi = useMemo(() => createSceneApi(useScene), [])
158
232
  const descriptors = useMemo(() => {
159
233
  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])
234
+ const all =
235
+ typeof def.handles === 'function'
236
+ ? def.handles(node as never, descriptorSceneApi)
237
+ : (def.handles as HandleDescriptor[])
238
+ // The whole-node move-cross gizmo is gone: moving is now click-to-move on
239
+ // the selected node body (see selection-manager). Drop both flavours — the
240
+ // `translate` ground cross (column/roof/shelf/spawn) and the `tap-action`
241
+ // `move-cross` (item/door/window/elevator/stair) — keep rotate/resize.
242
+ return all.filter(
243
+ (d) =>
244
+ d.kind !== 'translate' &&
245
+ !('shape' in d && d.shape === 'move-cross') &&
246
+ (d.kind !== 'linear-resize' || d.visible?.(node as never, descriptorSceneApi) !== false),
247
+ )
248
+ }, [node, def, descriptorSceneApi])
164
249
 
165
250
  const shouldRender =
166
251
  Boolean(node && descriptors?.length) &&
167
252
  !isFloorplanHovered &&
168
253
  mode !== 'delete' &&
169
- (!movingNode || isOwnPressDragMove) &&
170
- !movingWallEndpoint &&
171
- !movingFenceEndpoint &&
172
- !curvingWall &&
173
- !curvingFence
254
+ // Any whole-node move (placement or press-drag) hides the rig: the item is
255
+ // following the cursor, so its rotate/resize handles would only clutter and
256
+ // draw stray selection rays. The active handle-drag scope (resize/rotate)
257
+ // sets `activeHandleDrag`, not `movingNode`, so those are unaffected.
258
+ !movingNode &&
259
+ !endpointReshape &&
260
+ !isCurveReshape
174
261
 
175
262
  if (!shouldRender || !node || !descriptors) return null
176
263
  // Key by the selected node id so switching selection REMOUNTS the rig.
@@ -275,14 +362,32 @@ function NodeArrowHandlesForNode({
275
362
  // exclusion the wall arrow also goes without.
276
363
 
277
364
  useFrame(() => {
365
+ if (innerRef.current && innerRide && portalObject) {
366
+ // Grandparent mode: pose the rig by mapping the node's WORLD pose
367
+ // into the portal target's frame. Copying the parent + node
368
+ // registry poses (the previous approach) assumed the node mesh is
369
+ // a DIRECT child of the parent's registered object — roof-hosted
370
+ // openings break that with an intermediate face-frame group, which
371
+ // the world-relative mapping absorbs for free. For wall children
372
+ // the result is identical (portal⁻¹ ∘ node = wall.local ∘ node.local).
373
+ if (outerRef.current) {
374
+ outerRef.current.position.set(0, 0, 0)
375
+ outerRef.current.quaternion.identity()
376
+ }
377
+ portalObject.updateWorldMatrix(true, false)
378
+ innerRide.updateWorldMatrix(true, false)
379
+ _rigRelative.copy(portalObject.matrixWorld).invert().multiply(innerRide.matrixWorld)
380
+ _rigRelative.decompose(
381
+ innerRef.current.position,
382
+ innerRef.current.quaternion,
383
+ _rigScratchScale,
384
+ )
385
+ return
386
+ }
278
387
  if (outerRef.current && outerRide) {
279
388
  outerRef.current.position.copy(outerRide.position)
280
389
  outerRef.current.quaternion.copy(outerRide.quaternion)
281
390
  }
282
- if (innerRef.current && innerRide) {
283
- innerRef.current.position.copy(innerRide.position)
284
- innerRef.current.quaternion.copy(innerRide.quaternion)
285
- }
286
391
  })
287
392
 
288
393
  // Active-drag tracking. When a handle starts dragging, it claims its
@@ -299,6 +404,21 @@ function NodeArrowHandlesForNode({
299
404
  // hook count between renders and trip React's rules-of-hooks check.
300
405
  const [activeIndex, setActiveIndex] = useState<number | null>(null)
301
406
  const [preDragNode, setPreDragNode] = useState<AnyNode | null>(null)
407
+ // Latch groups currently toggled open. A `latch` cube descriptor flips its
408
+ // group here on click; arrows tagged with a `latchGroup` only render while
409
+ // their group is in this set. Local to this mount, so it resets on deselect
410
+ // (the rig remounts per selection — see the `key` on NodeArrowHandlesForNode).
411
+ const [openLatchGroups, setOpenLatchGroups] = useState<ReadonlySet<string>>(() => new Set())
412
+ const toggleLatchGroup = useMemo(
413
+ () => (group: string) =>
414
+ setOpenLatchGroups((prev) => {
415
+ const next = new Set(prev)
416
+ if (next.has(group)) next.delete(group)
417
+ else next.add(group)
418
+ return next
419
+ }),
420
+ [],
421
+ )
302
422
  const dragControls = useMemo<HandleDragControls>(
303
423
  () => ({
304
424
  onStart: (index: number, snapshot: AnyNode) => {
@@ -328,25 +448,48 @@ function NodeArrowHandlesForNode({
328
448
  // resize that re-centres the mesh) must NOT fire for the non-active arrows
329
449
  // here, or they'd lag behind the moving item.
330
450
  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
- ))
451
+ // While a rotate gizmo is mid-drag, drop the opposite-side move cross: you
452
+ // can't move and rotate at once, so it only clutters the rotation.
453
+ const activeDescriptor = activeIndex !== null ? descriptors[activeIndex] : undefined
454
+ const activeIsRotate =
455
+ !!activeDescriptor && 'shape' in activeDescriptor && activeDescriptor.shape === 'rotate'
456
+
457
+ const arrows = descriptors.map((descriptor, index) => {
458
+ if (activeIsRotate && 'shape' in descriptor && descriptor.shape === 'move-cross') return null
459
+ // A `latch` cube toggles its group's visibility; render it always.
460
+ if (descriptor.kind === 'latch') {
461
+ return (
462
+ <LatchCube
463
+ descriptor={descriptor}
464
+ key={index}
465
+ node={node}
466
+ onToggle={toggleLatchGroup}
467
+ open={openLatchGroups.has(descriptor.group)}
468
+ />
469
+ )
470
+ }
471
+ // Arrows tagged with a latch group stay hidden until that group is open.
472
+ const latchGroup = descriptor.kind === 'linear-resize' ? descriptor.latchGroup : undefined
473
+ if (latchGroup && !openLatchGroups.has(latchGroup)) return null
474
+ return (
475
+ <ArrowHandle
476
+ activeIndex={activeIndex}
477
+ descriptor={descriptor}
478
+ dragControls={dragControls}
479
+ handleIndex={index}
480
+ // Descriptors come from a per-node-kind static list, so index is a
481
+ // stable identity within this node's selection cycle.
482
+ key={index}
483
+ liveNode={node}
484
+ preDragNode={preDragNode}
485
+ rideObject={arrowFrame}
486
+ suppressFreeze={activeIsTranslate}
487
+ />
488
+ )
489
+ })
347
490
 
348
491
  return createPortal(
349
- <group ref={outerRef}>
492
+ <group ref={outerRef} userData={MEASUREMENT_SURFACE_EXCLUDE_USER_DATA}>
350
493
  {innerRideId !== null ? <group ref={innerRef}>{arrows}</group> : arrows}
351
494
  </group>,
352
495
  portalObject,
@@ -443,17 +586,6 @@ function ArrowHandle({
443
586
  />
444
587
  )
445
588
  }
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
589
  if (descriptor.kind === 'tap-action') {
458
590
  // Tap-action handles (fence side-move arrows, corner pickers) aren't
459
591
  // resize handles, so the freeze-at-pre-drag mechanism — which only
@@ -524,6 +656,9 @@ function LinearArrow({
524
656
  // floating dimension pill (via `activeHandleDrag`) and its own in-world
525
657
  // chip is suppressed — matches the wall height handle.
526
658
  const measureLabel = descriptor.kind === 'linear-resize' ? descriptor.measureLabel : undefined
659
+ // Optional per-tick feedback hook (doors/windows publish proximity/sill guides
660
+ // for the edge being resized); cleared when the drag ends.
661
+ const onDrag = descriptor.kind === 'linear-resize' ? descriptor.onDrag : undefined
527
662
  const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
528
663
  const basePosition = descriptor.placement.position(node, placementSceneApi)
529
664
  // `freezeOffset` (in node-local frame) cancels the mesh's `position`
@@ -555,40 +690,33 @@ function LinearArrow({
555
690
  rideObject,
556
691
  setIsDragging,
557
692
  onStart: ({
558
- camera: dragCamera,
559
693
  event,
694
+ getPointerRay,
560
695
  initialNode,
561
- intersectPlane,
562
696
  nodeId,
563
697
  rideObject: dragRideObject,
564
698
  sceneApi,
565
699
  }) => {
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)
700
+ dragRideObject.matrixWorld.decompose(_resizePositionW, _resizeQuaternion, _resizeScale)
701
+ _resizeOriginW.set(...position).applyMatrix4(dragRideObject.matrixWorld)
702
+ axisVector(descriptor.axis, _resizeAxisW).applyQuaternion(_resizeQuaternion).normalize()
703
+ const localToWorldScale = axisScale(descriptor.axis, _resizeScale)
704
+ if (Math.abs(localToWorldScale) < 1e-6 || _resizeAxisW.lengthSq() === 0) return null
572
705
 
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)
706
+ const initialPointer =
707
+ closestAxisParameterToRay(
708
+ _resizeOriginW,
709
+ _resizeAxisW,
710
+ getPointerRay(event.nativeEvent.clientX, event.nativeEvent.clientY, _resizeRay),
711
+ ) / localToWorldScale
578
712
 
579
713
  const overrideId =
580
714
  (descriptor.kind === 'linear-resize'
581
715
  ? descriptor.overrideTarget?.(initialNode as never, sceneApi)
582
716
  : undefined) ?? nodeId
583
717
  const initialValue = descriptor.currentValue(initialNode)
584
- const initialPointer =
585
- descriptor.axis === 'x' ? hitLocal.x : descriptor.axis === 'y' ? hitLocal.y : hitLocal.z
586
718
  const minBound = resolveBound(descriptor.min, Number.NEGATIVE_INFINITY, initialNode, sceneApi)
587
719
  const maxBound = resolveBound(descriptor.max, Number.POSITIVE_INFINITY, initialNode, sceneApi)
588
- const gridSnapStep =
589
- descriptor.kind === 'linear-resize' && descriptor.gridSnap
590
- ? useEditor.getState().gridSnapStep
591
- : null
592
720
  const factor =
593
721
  descriptor.kind === 'radial-resize'
594
722
  ? 1
@@ -598,36 +726,92 @@ function LinearArrow({
598
726
  ? 1
599
727
  : -1
600
728
 
729
+ // Last value an emitted resize tick fired at — a new tick fires only
730
+ // when the (snapped + clamped) value actually changes, so the cue
731
+ // tracks real size steps instead of every sub-pixel pointer jitter.
732
+ let lastTickValue = initialValue
733
+ let previewOverrideIds = new Set<AnyNodeId>()
734
+
601
735
  return {
602
736
  overrideId,
737
+ commit:
738
+ descriptor.kind === 'linear-resize' && descriptor.commit
739
+ ? (patch) => descriptor.commit?.(initialNode, patch, sceneApi)
740
+ : undefined,
603
741
  onBegin: () => {
604
- if (measureLabel) {
605
- useEditor.getState().setActiveHandleDrag({ nodeId, label: measureLabel })
606
- }
742
+ // Always claim the handle-drag scope so the HUD knows a resize is the
743
+ // active interaction (keeps the idle select hints off-screen). The
744
+ // dimension-pill handles carry their `measureLabel`; plain resize
745
+ // arrows use the generic label.
746
+ useInteractionScope.getState().begin({
747
+ kind: 'handle-drag',
748
+ nodeId,
749
+ handle: measureLabel ?? RESIZE_HANDLE_DRAG_LABEL,
750
+ })
607
751
  },
608
752
  onEnd: () => {
609
- if (measureLabel) {
610
- useEditor.getState().setActiveHandleDrag(null)
753
+ useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
754
+ if (descriptor.kind === 'linear-resize') {
755
+ descriptor.onDragEnd?.(initialNode as never, sceneApi)
611
756
  }
612
- },
613
- move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => {
614
- const intersection = new Vector3()
615
- if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, intersection)) {
616
- return null
757
+ if (onDrag) useOpeningGuides.getState().clear()
758
+ for (const previewId of previewOverrideIds) {
759
+ useLiveNodeOverrides.getState().clear(previewId)
760
+ useScene.getState().markDirty(previewId)
617
761
  }
618
- const intersectionLocal = intersection.clone().applyMatrix4(initialFrameInverse)
762
+ },
763
+ move: ({ event: moveEvent, getPointerRay: getMovePointerRay }) => {
619
764
  const currentPointer =
620
- descriptor.axis === 'x'
621
- ? intersectionLocal.x
622
- : descriptor.axis === 'y'
623
- ? intersectionLocal.y
624
- : intersectionLocal.z
765
+ closestAxisParameterToRay(
766
+ _resizeOriginW,
767
+ _resizeAxisW,
768
+ getMovePointerRay(moveEvent.clientX, moveEvent.clientY, _resizeRay),
769
+ ) / localToWorldScale
625
770
  const delta = currentPointer - initialPointer
626
771
  const rawNext = initialValue + delta * factor
627
- const snappedNext =
628
- gridSnapStep && gridSnapStep > 0 ? snapScalar(rawNext, gridSnapStep) : rawNext
772
+ const linearDescriptor = descriptor.kind === 'linear-resize' ? descriptor : null
773
+ const snappedNext = resolveResizeSnapValue({
774
+ rawValue: rawNext,
775
+ gridSnapEnabled: linearDescriptor?.gridSnap === true,
776
+ gridSnapActive: isGridSnapActive(),
777
+ gridSnapStep: useEditor.getState().gridSnapStep,
778
+ magneticSnapActive: isMagneticSnapActive(),
779
+ magneticSnap: linearDescriptor?.magneticSnap
780
+ ? (value) => linearDescriptor.magneticSnap?.(initialNode, value, sceneApi) ?? value
781
+ : undefined,
782
+ })
629
783
  const next = Math.min(maxBound, Math.max(minBound, snappedNext))
630
- return descriptor.apply(initialNode as never, next, sceneApi) as Partial<AnyNode>
784
+ if (next !== lastTickValue) {
785
+ lastTickValue = next
786
+ sfxEmitter.emit('sfx:resize')
787
+ }
788
+ const patch = descriptor.apply(initialNode as never, next, sceneApi) as Partial<AnyNode>
789
+ if (descriptor.kind === 'linear-resize' && descriptor.previewOverrides) {
790
+ const previewEntries = descriptor.previewOverrides(initialNode as never, next, sceneApi)
791
+ const nextPreviewOverrideIds = replacePreviewOverrideIds(
792
+ previewOverrideIds,
793
+ previewEntries,
794
+ (previewId) => {
795
+ useLiveNodeOverrides.getState().clear(previewId)
796
+ useScene.getState().markDirty(previewId)
797
+ },
798
+ )
799
+ useLiveNodeOverrides
800
+ .getState()
801
+ .setMany(
802
+ previewEntries.map(([id, previewPatch]) => [
803
+ id,
804
+ previewPatch as Record<string, unknown>,
805
+ ]),
806
+ )
807
+ for (const [previewId] of previewEntries) {
808
+ useScene.getState().markDirty(previewId)
809
+ }
810
+ previewOverrideIds = nextPreviewOverrideIds
811
+ }
812
+ // Let the kind publish live guides for the edge being resized.
813
+ onDrag?.({ ...(initialNode as object), ...patch } as AnyNode, sceneApi)
814
+ return patch
631
815
  },
632
816
  }
633
817
  },
@@ -637,10 +821,19 @@ function LinearArrow({
637
821
  // X+Z rotation chain matching DoorHeightArrowHandle. When the handle
638
822
  // sits below the node (placement Y < 0, e.g. window bottom arrow),
639
823
  // flip the Z rotation so the chevron points outward (downward).
824
+ //
825
+ // For axis === 'x' with `faceNormal` (wall-mounted opening width arrows),
826
+ // roll the blade 90° about its own pointing (X) axis so it stands up from
827
+ // the horizontal XZ plane into the node's facing plane (XY = the wall
828
+ // face) — otherwise the blade is seen edge-on from the front.
829
+ const faceNormalX =
830
+ descriptor.kind === 'linear-resize' && descriptor.axis === 'x' && descriptor.faceNormal === true
640
831
  const innerRotation: [number, number, number] =
641
832
  descriptor.axis === 'y'
642
833
  ? [0, Math.PI / 2, position[1] < 0 ? -Math.PI / 2 : Math.PI / 2]
643
- : [0, 0, 0]
834
+ : faceNormalX
835
+ ? [Math.PI / 2, 0, 0]
836
+ : [0, 0, 0]
644
837
 
645
838
  // Optional guide decoration — linear handles use it for curved-stair
646
839
  // width / inner-radius rings; radial handles use it for the column's
@@ -686,7 +879,8 @@ function LinearArrow({
686
879
  <>
687
880
  {showDecoration && decoration ? (
688
881
  <GuideRing
689
- radius={decoration.radius(node as never)}
882
+ center={guideDecorationCenter(decoration, node, placementSceneApi)}
883
+ radius={guideDecorationRadius(decoration, node, placementSceneApi)}
690
884
  y={decoration.y?.(node as never) ?? 0}
691
885
  />
692
886
  ) : null}
@@ -714,7 +908,8 @@ function LinearArrow({
714
908
  <>
715
909
  {showDecoration && decoration ? (
716
910
  <GuideRing
717
- radius={decoration.radius(node as never)}
911
+ center={guideDecorationCenter(decoration, node, placementSceneApi)}
912
+ radius={guideDecorationRadius(decoration, node, placementSceneApi)}
718
913
  y={decoration.y?.(node as never) ?? 0}
719
914
  />
720
915
  ) : null}
@@ -726,6 +921,7 @@ function LinearArrow({
726
921
  onPointerDown={activate}
727
922
  placement={{ position, rotation: [0, rotationY, 0], baseScale }}
728
923
  shape="chevron"
924
+ thin
729
925
  >
730
926
  {showLabel ? <DimensionLabel position={[0, 0.22, 0]} text={labelText} /> : null}
731
927
  </HandleArrow>
@@ -737,7 +933,15 @@ function LinearArrow({
737
933
  // e.g. the curved-stair width arrow traces the outer rim, the inner-radius
738
934
  // arrow traces the central pillar. Floats at node-local `y`, lies in the
739
935
  // XZ plane.
740
- export function GuideRing({ radius, y }: { radius: number; y: number }) {
936
+ export function GuideRing({
937
+ center,
938
+ radius,
939
+ y,
940
+ }: {
941
+ center?: readonly [number, number, number]
942
+ radius: number
943
+ y: number
944
+ }) {
741
945
  const safeRadius = Math.max(radius, 0.01)
742
946
  const ringGeometry = useMemo(() => {
743
947
  const inner = Math.max(safeRadius - 0.015, 0.001)
@@ -764,7 +968,7 @@ export function GuideRing({ radius, y }: { radius: number; y: number }) {
764
968
  frustumCulled={false}
765
969
  geometry={ringGeometry}
766
970
  material={ringMaterial}
767
- position={[0, y, 0]}
971
+ position={center ? [center[0], y, center[2]] : [0, y, 0]}
768
972
  renderOrder={1009}
769
973
  rotation={[-Math.PI / 2, 0, 0]}
770
974
  />
@@ -873,11 +1077,13 @@ function RotationGuideOutline({ geometry }: { geometry: BufferGeometry }) {
873
1077
  // from the pivot; the fill is pulled inside it so it reads as the handle
874
1078
  // swinging around rather than overlapping the icon.
875
1079
  function RotationWedge({
1080
+ center,
876
1081
  delta,
877
1082
  handleAngle,
878
1083
  orbitRadius,
879
1084
  y,
880
1085
  }: {
1086
+ center?: readonly [number, number, number]
881
1087
  delta: number
882
1088
  handleAngle: number
883
1089
  orbitRadius: number
@@ -917,7 +1123,7 @@ function RotationWedge({
917
1123
  ]
918
1124
 
919
1125
  return (
920
- <group position={[0, y, 0]}>
1126
+ <group position={center ? [center[0], y, center[2]] : [0, y, 0]}>
921
1127
  <mesh
922
1128
  frustumCulled={false}
923
1129
  geometry={fill}
@@ -971,6 +1177,8 @@ function ArcArrow({
971
1177
  // corner) render a two-headed curved arrow; everything else (stair
972
1178
  // sweep, etc.) keeps the chevron.
973
1179
  const isRotateShape = descriptor.shape === 'rotate'
1180
+ const activeRotateNodeId = useDirectManipulationFeedback((state) => state.activeRotateNodeId)
1181
+ const isDirectRotating = isRotateShape && activeRotateNodeId === liveNode.id
974
1182
  // 'node-normal' spins the node about its local +Z (a wall item flat against
975
1183
  // its wall) instead of yaw about world-Y. The drag plane and the icon both
976
1184
  // tilt into that plane, and the horizontal-only wedge/ring readout is
@@ -1010,7 +1218,8 @@ function ArcArrow({
1010
1218
  // arrow is hovered or dragging. Same recipe as the linear / radial
1011
1219
  // decoration path.
1012
1220
  const decoration = descriptor.decoration
1013
- const showDecoration = Boolean(decoration) && (isHovered || isDragging)
1221
+ const decorationCenter = guideDecorationCenter(decoration, node, placementSceneApi)
1222
+ const showDecoration = Boolean(decoration) && (isHovered || isDragging || isDirectRotating)
1014
1223
 
1015
1224
  const activate = useHandleDrag({
1016
1225
  kind: 'drag',
@@ -1062,8 +1271,23 @@ function ArcArrow({
1062
1271
  }
1063
1272
  const initialAngle = angleOf(hitWorld)
1064
1273
 
1274
+ // Advertise the rotate interaction so the contextual HUD can surface the
1275
+ // Shift = free-rotation toggle (the angle-step bypass below). Resize
1276
+ // handles route a measurement label here; rotate gets a sentinel label so
1277
+ // the HUD shows the rotate hint, not a dimension pill.
1278
+ if (isRotateShape) {
1279
+ useInteractionScope
1280
+ .getState()
1281
+ .begin({ kind: 'handle-drag', nodeId: node.id, handle: ROTATE_HANDLE_DRAG_LABEL })
1282
+ }
1283
+
1065
1284
  return {
1066
- onEnd: () => setRotationDelta(null),
1285
+ onEnd: () => {
1286
+ setRotationDelta(null)
1287
+ if (isRotateShape) {
1288
+ useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
1289
+ }
1290
+ },
1067
1291
  move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => {
1068
1292
  const hit = new Vector3()
1069
1293
  if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, hit)) return null
@@ -1072,9 +1296,8 @@ function ArcArrow({
1072
1296
  while (delta > Math.PI) delta -= 2 * Math.PI
1073
1297
  while (delta < -Math.PI) delta += 2 * Math.PI
1074
1298
 
1075
- if (moveEvent.shiftKey && descriptor.shape === 'rotate') {
1076
- const step = Math.PI / 12
1077
- delta = Math.round(delta / step) * step
1299
+ if (!moveEvent.shiftKey && descriptor.shape === 'rotate') {
1300
+ delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
1078
1301
  }
1079
1302
 
1080
1303
  if (isRotateShape && !isNodeNormalRot) {
@@ -1086,18 +1309,12 @@ function ArcArrow({
1086
1309
  },
1087
1310
  })
1088
1311
 
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
1312
  return (
1097
1313
  <>
1098
1314
  {showDecoration && decoration ? (
1099
1315
  <GuideRing
1100
- radius={decoration.radius(node as never)}
1316
+ center={guideDecorationCenter(decoration, node, placementSceneApi)}
1317
+ radius={guideDecorationRadius(decoration, node, placementSceneApi)}
1101
1318
  y={decoration.y?.(node as never) ?? 0}
1102
1319
  />
1103
1320
  ) : null}
@@ -1107,16 +1324,23 @@ function ArcArrow({
1107
1324
  ring on any surface — flat ground or a pitched roof. */}
1108
1325
  {rotationDelta !== null ? (
1109
1326
  <RotationWedge
1327
+ center={decorationCenter}
1110
1328
  delta={rotationDelta}
1111
- handleAngle={Math.atan2(position[2], position[0])}
1112
- orbitRadius={Math.hypot(position[0], position[2])}
1329
+ handleAngle={Math.atan2(
1330
+ position[2] - (decorationCenter?.[2] ?? 0),
1331
+ position[0] - (decorationCenter?.[0] ?? 0),
1332
+ )}
1333
+ orbitRadius={Math.hypot(
1334
+ position[0] - (decorationCenter?.[0] ?? 0),
1335
+ position[2] - (decorationCenter?.[2] ?? 0),
1336
+ )}
1113
1337
  y={decoration?.y?.(node as never) ?? 0}
1114
1338
  />
1115
1339
  ) : null}
1116
1340
  <HandleArrow
1117
1341
  activeCursor={dragCursor}
1118
1342
  cursor={hoverCursor}
1119
- hover={isHovered}
1343
+ hover={isHovered || isDirectRotating}
1120
1344
  onHoverChange={setIsHovered}
1121
1345
  onPointerDown={activate}
1122
1346
  placement={{
@@ -1127,69 +1351,12 @@ function ArcArrow({
1127
1351
  baseScale,
1128
1352
  }}
1129
1353
  shape={isRotateShape ? 'curved-arrow' : 'chevron'}
1354
+ thin
1130
1355
  />
1131
1356
  </>
1132
1357
  )
1133
1358
  }
1134
1359
 
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
1360
  // Click-to-engage affordance — no drag plumbing, just a click target. The
1194
1361
  // descriptor's `onActivate` receives sceneApi + editorApi so it can engage
1195
1362
  // move tools, endpoint drags, or any other editor-state transition without
@@ -1210,6 +1377,7 @@ function TapActionArrow({
1210
1377
  const rotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0
1211
1378
  const shape = descriptor.shape ?? 'arrow'
1212
1379
  const cursor: Cursor = descriptor.cursor ?? (shape === 'corner-picker' ? 'move' : 'ew-resize')
1380
+ const round = descriptor.round ?? false
1213
1381
 
1214
1382
  const onActivate = useHandleDrag({
1215
1383
  kind: 'tap',
@@ -1230,20 +1398,75 @@ function TapActionArrow({
1230
1398
  onHoverChange={setIsHovered}
1231
1399
  onPointerDown={onActivate}
1232
1400
  position={position}
1401
+ round={round}
1233
1402
  />
1234
1403
  )
1235
1404
  }
1236
1405
 
1237
- // Default 'arrow' shape — the standard chevron.
1238
1406
  const baseScale = zoom * ARROW_SCALE
1407
+ // A `move-cross` with `plane: 'node-normal'` stands up into the node's facing
1408
+ // plane (a wall face) like the door / window / wall-item move grips; other
1409
+ // tap-actions keep their in-plane `rotationY`.
1410
+ const rotation: [number, number, number] =
1411
+ descriptor.plane === 'node-normal' ? NODE_NORMAL_TILT : [0, rotationY, 0]
1239
1412
  return (
1240
1413
  <HandleArrow
1241
1414
  cursor={cursor}
1242
1415
  hover={isHovered}
1243
1416
  onHoverChange={setIsHovered}
1244
1417
  onPointerDown={onActivate}
1418
+ placement={{ position, rotation, baseScale }}
1419
+ shape={shape === 'move-cross' ? 'move-cross' : 'chevron'}
1420
+ thin
1421
+ />
1422
+ )
1423
+ }
1424
+
1425
+ // Click-to-latch cube. A persistent grip (the `tracker` cube) that toggles
1426
+ // the visibility of every arrow tagged with its `latchGroup` on click. Sized
1427
+ // to match the duct selection cube (`baseScale = zoom`, full TRACKER_CUBE_SIZE)
1428
+ // so every latch grip reads the same across the app. Stays highlighted while
1429
+ // its group is open so the user can tell it's engaged.
1430
+ function LatchCube({
1431
+ descriptor,
1432
+ node,
1433
+ open,
1434
+ onToggle,
1435
+ }: {
1436
+ descriptor: LatchHandle<AnyNode>
1437
+ node: AnyNode
1438
+ open: boolean
1439
+ onToggle: (group: string) => void
1440
+ }) {
1441
+ const [isHovered, setIsHovered] = useState(false)
1442
+ const { camera } = useThree()
1443
+ const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
1444
+ const baseScale = zoom
1445
+
1446
+ const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
1447
+ const position = descriptor.placement.position(node, placementSceneApi)
1448
+ const rotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0
1449
+
1450
+ // Route through the shared tap path so the cube click is swallowed before it
1451
+ // reaches the select tool — stops R3F propagation, suppresses box-select, and
1452
+ // eats the trailing DOM click that would otherwise select the host node.
1453
+ const onPointerDown = useHandleDrag({
1454
+ kind: 'tap',
1455
+ onTap: () => {
1456
+ setIsHovered(false)
1457
+ onToggle(descriptor.group)
1458
+ },
1459
+ })
1460
+
1461
+ return (
1462
+ <HandleArrow
1463
+ cursor="grab"
1464
+ hover={isHovered || open}
1465
+ hoverScale={1.15}
1466
+ onHoverChange={setIsHovered}
1467
+ onPointerDown={onPointerDown}
1245
1468
  placement={{ position, rotation: [0, rotationY, 0], baseScale }}
1246
- shape="chevron"
1469
+ shape="tracker"
1247
1470
  />
1248
1471
  )
1249
1472
  }
@@ -1281,6 +1504,7 @@ function CornerPickerShape({
1281
1504
  hover,
1282
1505
  onHoverChange,
1283
1506
  onPointerDown,
1507
+ round = false,
1284
1508
  }: {
1285
1509
  position: readonly [number, number, number]
1286
1510
  height: number
@@ -1289,6 +1513,7 @@ function CornerPickerShape({
1289
1513
  hover: boolean
1290
1514
  onHoverChange: (hovered: boolean) => void
1291
1515
  onPointerDown: (event: ThreeEvent<PointerEvent>) => void
1516
+ round?: boolean
1292
1517
  }) {
1293
1518
  const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(height), [height])
1294
1519
  useEffect(() => () => dashedGeometry.dispose(), [dashedGeometry])
@@ -1358,7 +1583,10 @@ function CornerPickerShape({
1358
1583
  position={position}
1359
1584
  renderOrder={1001}
1360
1585
  />
1361
- <group position={[position[0], CORNER_FLOOR_OFFSET, position[2]]} ref={billboardRef}>
1586
+ <group
1587
+ position={[position[0], position[1] + CORNER_FLOOR_OFFSET, position[2]]}
1588
+ ref={billboardRef}
1589
+ >
1362
1590
  <HandleArrow
1363
1591
  cursor={cursor}
1364
1592
  hover={hover}
@@ -1366,10 +1594,11 @@ function CornerPickerShape({
1366
1594
  onHoverChange={onHoverChange}
1367
1595
  onPointerDown={onPointerDown}
1368
1596
  placement={{ position: [0, 0, 0], baseScale }}
1597
+ round={round}
1369
1598
  shape="corner-picker"
1370
1599
  />
1371
1600
  <mesh material={ringMaterial} renderOrder={1002} scale={scale}>
1372
- <ringGeometry args={[CORNER_HEX_RADIUS, CORNER_HEX_RADIUS * 1.18, 6]} />
1601
+ <ringGeometry args={[CORNER_HEX_RADIUS, CORNER_HEX_RADIUS * 1.18, round ? 32 : 6]} />
1373
1602
  </mesh>
1374
1603
  </group>
1375
1604
  </>