@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
@@ -1,15 +1,100 @@
1
- import { type AnyNodeId, emitter, nodeRegistry, useScene } from '@pascal-app/core'
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ emitter,
5
+ nodeRegistry,
6
+ pauseSpaceDetection,
7
+ resumeSpaceDetection,
8
+ useScene,
9
+ } from '@pascal-app/core'
2
10
  import { useViewer } from '@pascal-app/viewer'
3
11
  import { useEffect } from 'react'
12
+ import { Vector3 } from 'three'
13
+ import {
14
+ cutSelectionToEditorClipboard,
15
+ deleteSelection,
16
+ pasteSelectionAndPickUp,
17
+ } from '../components/editor/group-actions'
18
+ import {
19
+ classifyParticipant,
20
+ collectParticipants,
21
+ computeGroupBox,
22
+ expandToComponent,
23
+ levelFrame,
24
+ rotateGroupPatches,
25
+ } from '../components/editor/group-transform-shared'
26
+ import { steppedRotation } from '../components/tools/item/placement-math'
27
+ import { resolveDirectManipulationNode } from '../lib/direct-manipulation'
4
28
  import { toggleDoorOpenState } from '../lib/door-interaction'
29
+ import { guideEmitter } from '../lib/guide-events'
5
30
  import { runRedo, runUndo } from '../lib/history'
6
- import {
7
- copySelectedNodesToEditorClipboard,
8
- pasteEditorClipboardToLevel,
9
- } from '../lib/scene-clipboard'
10
- import { emitDeleteSFX, sfxEmitter } from '../lib/sfx-bus'
31
+ import { isActive } from '../lib/interaction/scope'
32
+ import { copySelectedNodesToEditorClipboard } from '../lib/scene-clipboard'
33
+ import { sfxEmitter } from '../lib/sfx-bus'
34
+ import { activeSiteNode, clampBrushRadius } from '../lib/terrain-sculpt'
11
35
  import { toggleWindowOpenState } from '../lib/window-interaction'
12
- import useEditor from '../store/use-editor'
36
+ import useDeleteConfirmation from '../store/use-delete-confirmation'
37
+ import useEditor, { getActiveContinuationContext, getActiveSnapContext } from '../store/use-editor'
38
+ import useInteractionScope, { getMovingNode } from '../store/use-interaction-scope'
39
+
40
+ // References (guide/scan) are selected via `useEditor.selectedReferenceId`, not
41
+ // the viewer selection, so selection-based key arms (R/T rotate) need this
42
+ // separate lookup. Locked guides don't rotate, matching direct manipulation.
43
+ function getRotatableSelectedReference() {
44
+ const refId = useEditor.getState().selectedReferenceId
45
+ if (!refId) return null
46
+ const node = useScene.getState().nodes[refId as AnyNodeId]
47
+ if (!node || (node.type !== 'guide' && node.type !== 'scan')) return null
48
+ if (useEditor.getState().guideUi[refId]?.locked === true) return null
49
+ return node
50
+ }
51
+
52
+ // Group rotate: R/T on a multi-selection spins the whole selection rigidly
53
+ // ±45° around its bbox center — the keyboard sibling of the 3D group-rotate
54
+ // gizmo, sharing its participant snapshot + rigid-rotation math (welded
55
+ // wall/fence junctions, connected-component expansion). One batched
56
+ // `updateNodes` call = one undo step. Returns false when the selection holds
57
+ // no transformable participants so the caller can fall through to the
58
+ // single-selection arms.
59
+ function rotateGroupSelection(direction: 1 | -1): boolean {
60
+ const { selectedIds, levelId } = useViewer.getState().selection
61
+ if (selectedIds.length <= 1) return false
62
+ const nodes = useScene.getState().nodes
63
+ const participantIds = selectedIds.filter(
64
+ (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
65
+ )
66
+ if (participantIds.length === 0) return false
67
+ const fullIds = expandToComponent(participantIds, nodes, levelId)
68
+ const { starts, links } = collectParticipants(fullIds, nodes, levelId)
69
+ if (starts.length === 0) return false
70
+
71
+ // Same pivot as the 3D gizmo: the selection's world bbox center, converted
72
+ // into the level frame before orbiting placements (a rotated building would
73
+ // otherwise displace the centre).
74
+ const box = computeGroupBox(fullIds)
75
+ if (!box) return false
76
+ const worldCenter = new Vector3(
77
+ (box.min.x + box.max.x) / 2,
78
+ box.min.y,
79
+ (box.min.z + box.max.z) / 2,
80
+ )
81
+ const localCenter = worldCenter.applyMatrix4(levelFrame(levelId).inverse)
82
+
83
+ // R (+45° yaw) orbits by -45° in the atan2 x→z sense: yaw = rotation - delta
84
+ // (see rotateGroupPatches), so keyboard direction matches the single-node
85
+ // steppedRotation sense.
86
+ const delta = -direction * (Math.PI / 4)
87
+ const patches = rotateGroupPatches(starts, links, { x: localCenter.x, z: localCenter.z }, delta)
88
+ // Space detection stays out: a rigid rotation of existing walls must not
89
+ // re-create the room's auto floors/ceilings at the new bearing.
90
+ pauseSpaceDetection()
91
+ useScene
92
+ .getState()
93
+ .updateNodes(patches.map(([id, data]) => ({ id, data: data as Partial<AnyNode> })))
94
+ resumeSpaceDetection()
95
+ sfxEmitter.emit('sfx:item-rotate')
96
+ return true
97
+ }
13
98
 
14
99
  // Tools call this in their onCancel handler when they have an active mid-action to cancel,
15
100
  // so that the global Escape handler knows not to also switch to select mode.
@@ -18,6 +103,64 @@ export const markToolCancelConsumed = () => {
18
103
  _toolCancelConsumed = true
19
104
  }
20
105
 
106
+ // Escape's fall-through when no tool consumed the cancel: drop back to the
107
+ // select tool (keeping building/level context) and close panels. Tools like
108
+ // preset/item placement rely on this — they pass no coordinator onCancel, and
109
+ // it is the mode switch unmounting them that destroys the draft.
110
+ const exitToSelectAfterUnconsumedCancel = () => {
111
+ const currentPhase = useEditor.getState().phase
112
+ const currentStructureLayer = useEditor.getState().structureLayer
113
+
114
+ useInteractionScope.getState().endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
115
+
116
+ // From zone mode, return to structure select
117
+ if (currentPhase === 'structure' && currentStructureLayer === 'zones') {
118
+ useEditor.getState().setStructureLayer('elements')
119
+ useEditor.getState().setMode('select')
120
+ } else {
121
+ // Return to the default select tool while keeping the active building/level context.
122
+ useEditor.getState().setMode('select')
123
+ }
124
+
125
+ useEditor.getState().setFloorplanSelectionTool('click')
126
+
127
+ // Clear selections to close UI panels, but KEEP the active building and level context.
128
+ useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
129
+ useEditor.getState().setSelectedReferenceId(null)
130
+ }
131
+
132
+ // ⌘Z pressed mid-interaction (moving a node, drawing a wall, mid-placement…)
133
+ // reads as "abort this action", not history undo — behave exactly like Escape
134
+ // and report whether anything was in flight so the undo/redo arms know to
135
+ // skip the history jump. Pointer drags that only listen for their own
136
+ // capture-phase keydown never reach here — they stopPropagation first (see
137
+ // isHistoryShortcut call sites).
138
+ const cancelInteractionForHistoryShortcut = () => {
139
+ if (useEditor.getState().referenceScaleActiveGuideId) {
140
+ guideEmitter.emit('guide:cancel-reference-scale')
141
+ return true
142
+ }
143
+ _toolCancelConsumed = false
144
+ emitter.emit('tool:cancel')
145
+ if (_toolCancelConsumed) return true
146
+ if (
147
+ isActive(useInteractionScope.getState().scope) ||
148
+ useViewer.getState().inputDragging ||
149
+ // Paused history means a gesture session is live (draft placement, adopted
150
+ // move, …) even when no scope/drag flag is set — the preset/item draft
151
+ // cycle keeps temporal paused for the whole session, and a history jump
152
+ // against a paused store would land on a stale baseline anyway.
153
+ !useScene.temporal.getState().isTracking
154
+ ) {
155
+ // A gesture is live but nothing consumed the cancel: finish it the way
156
+ // Escape does — the mode switch is what actually cancels tools that hook
157
+ // their teardown to unmount (preset/item placement).
158
+ exitToSelectAfterUnconsumedCancel()
159
+ return true
160
+ }
161
+ return false
162
+ }
163
+
21
164
  export const useKeyboard = ({
22
165
  isVersionPreviewMode = false,
23
166
  disabled = false,
@@ -30,39 +173,162 @@ export const useKeyboard = ({
30
173
  return
31
174
  }
32
175
 
176
+ // True while a door/window is being placed: either a fresh clone is moving
177
+ // (preset / duplicate path) or a door/window build tool is armed. The
178
+ // placement tool owns R/T then (flip the draft before commit), so the
179
+ // global selection-based R/T handler must stand down to avoid double-firing.
180
+ const isPlacingOpening = () => {
181
+ const ed = useEditor.getState()
182
+ const moving = getMovingNode()
183
+ if (moving?.type === 'door' || moving?.type === 'window') return true
184
+ return ed.mode === 'build' && (ed.tool === 'door' || ed.tool === 'window')
185
+ }
186
+
187
+ // Shift cycles the snapping mode (and a clean-tap Ctrl the grid step)
188
+ // whenever there's an active snapping context — i.e. exactly when the HUD
189
+ // shows a snapping chip. That single source covers wall/fence/item drafting,
190
+ // every node move (including wall-hosted items + door/window openings, which
191
+ // now declare `snapProfile`), and endpoint/polygon reshaping, so the keys
192
+ // never silently stop working. Force-place lives on Alt where a tool supports it.
193
+ const isSnappingCycleContext = () => getActiveSnapContext() != null
194
+ // A "clean tap" of Ctrl/Meta (pressed and released with NO other key in
195
+ // between) cycles the grid step — same context as the Shift snapping-mode
196
+ // cycle. `ctrlTapClean` starts true the moment Ctrl/Meta goes down alone
197
+ // and is cleared the instant any other key fires, so chords like Ctrl+Z /
198
+ // Ctrl+C never cycle.
199
+ let ctrlTapClean = false
200
+
33
201
  const handleKeyDown = (e: KeyboardEvent) => {
202
+ if (e.key === 'Control' || e.key === 'Meta') {
203
+ // Only a fresh, modifier-free press starts a clean-tap candidate;
204
+ // ignore key-repeat and presses already part of a combo.
205
+ ctrlTapClean = !e.repeat && !e.shiftKey && !e.altKey
206
+ } else {
207
+ // Any non-modifier key (or a modifier combined with Ctrl/Meta) breaks
208
+ // the clean tap.
209
+ ctrlTapClean = false
210
+ }
211
+
34
212
  // Don't handle shortcuts if user is typing in an input
35
213
  if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
36
214
  return
37
215
  }
38
216
 
217
+ if (useDeleteConfirmation.getState().request) {
218
+ return
219
+ }
220
+
221
+ if (e.key === 'Shift' && !e.repeat && useEditor.getState().mode === 'material-paint') {
222
+ // In paint mode Shift cycles the application scope (this surface →
223
+ // whole item / all matching / room) — the paint-mode analogue of the
224
+ // snapping-mode cycle below. The scope chip mirrors this key.
225
+ e.preventDefault()
226
+ useEditor.getState().cyclePaintScope()
227
+ sfxEmitter.emit('sfx:grid-snap')
228
+ return
229
+ }
230
+
231
+ // Brush size, on the keys every sculpting tool in the industry uses. Gated
232
+ // on sculpt mode so `[`/`]` stay free everywhere else. Key-repeat is
233
+ // allowed (unlike the cycles above) because holding to resize is the
234
+ // expected feel, and the step is multiplicative so one press is a
235
+ // proportional change at both the floor and 20 m rather than 40× coarser at
236
+ // the bottom of the range. The range comes from `brushRadiusRange` so this
237
+ // and the panel's slider cannot disagree about it — and so the low end
238
+ // tracks the field's sample spacing, below which a dab paints nothing.
239
+ if (
240
+ (e.key === '[' || e.key === ']') &&
241
+ !e.metaKey &&
242
+ !e.ctrlKey &&
243
+ useEditor.getState().mode === 'terrain-sculpt'
244
+ ) {
245
+ e.preventDefault()
246
+ const { terrainBrush, setTerrainBrush } = useEditor.getState()
247
+ const factor = e.key === ']' ? 1.25 : 1 / 1.25
248
+ const radius = clampBrushRadius(
249
+ activeSiteNode(),
250
+ Math.round(terrainBrush.radius * factor * 10) / 10,
251
+ )
252
+ if (radius !== terrainBrush.radius) {
253
+ setTerrainBrush({ radius })
254
+ sfxEmitter.emit('sfx:grid-snap')
255
+ }
256
+ return
257
+ }
258
+
259
+ if (e.key === 'Shift' && !e.repeat && isSnappingCycleContext()) {
260
+ // Cycle the global snapping mode (grid → lines → angles → off).
261
+ // `'off'` is the snap bypass now, so Shift no longer holds-to-bypass.
262
+ e.preventDefault()
263
+ useEditor.getState().cycleSnappingMode()
264
+ sfxEmitter.emit('sfx:grid-snap')
265
+ return
266
+ }
267
+
268
+ if (
269
+ (e.key === 't' || e.key === 'T') &&
270
+ !e.repeat &&
271
+ !e.metaKey &&
272
+ !e.ctrlKey &&
273
+ !e.shiftKey &&
274
+ !e.altKey
275
+ ) {
276
+ const context = getActiveContinuationContext()
277
+ if (context === 'fence') {
278
+ e.preventDefault()
279
+ const current = useEditor.getState().getContinuation('fence')
280
+ useEditor
281
+ .getState()
282
+ .setContinuation('fence', current === 'curved' ? 'continuous' : 'curved')
283
+ sfxEmitter.emit('sfx:grid-snap')
284
+ return
285
+ }
286
+ }
287
+
288
+ if (
289
+ (e.key === 'c' || e.key === 'C') &&
290
+ !e.repeat &&
291
+ !e.metaKey &&
292
+ !e.ctrlKey &&
293
+ !e.shiftKey &&
294
+ !e.altKey
295
+ ) {
296
+ const context = getActiveContinuationContext()
297
+ if (context) {
298
+ e.preventDefault()
299
+ if (context === 'fence') {
300
+ const current = useEditor.getState().getContinuation('fence')
301
+ if (current !== 'curved') {
302
+ useEditor
303
+ .getState()
304
+ .setContinuation('fence', current === 'single' ? 'continuous' : 'single')
305
+ sfxEmitter.emit('sfx:grid-snap')
306
+ }
307
+ return
308
+ }
309
+ useEditor.getState().cycleContinuation(context)
310
+ sfxEmitter.emit('sfx:grid-snap')
311
+ return
312
+ }
313
+ }
314
+
39
315
  if (e.key === 'Escape') {
40
316
  e.preventDefault()
317
+
318
+ // An in-flight reference-scale measurement swallows Escape whole:
319
+ // cancel the flow but keep the reference selected and its panel open.
320
+ if (useEditor.getState().referenceScaleActiveGuideId) {
321
+ guideEmitter.emit('guide:cancel-reference-scale')
322
+ return
323
+ }
324
+
41
325
  _toolCancelConsumed = false
42
326
  emitter.emit('tool:cancel')
43
327
 
44
328
  // Only switch to select mode if no tool had an active mid-action to cancel.
45
329
  // (e.g. mid-wall draw or mid-slab polygon should only cancel the action, not exit the tool)
46
330
  if (!_toolCancelConsumed) {
47
- const currentPhase = useEditor.getState().phase
48
- const currentStructureLayer = useEditor.getState().structureLayer
49
-
50
- useEditor.getState().setEditingHole(null)
51
-
52
- // From zone mode, return to structure select
53
- if (currentPhase === 'structure' && currentStructureLayer === 'zones') {
54
- useEditor.getState().setStructureLayer('elements')
55
- useEditor.getState().setMode('select')
56
- } else {
57
- // Return to the default select tool while keeping the active building/level context.
58
- useEditor.getState().setMode('select')
59
- }
60
-
61
- useEditor.getState().setFloorplanSelectionTool('click')
62
-
63
- // Clear selections to close UI panels, but KEEP the active building and level context.
64
- useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
65
- useEditor.getState().setSelectedReferenceId(null)
331
+ exitToSelectAfterUnconsumedCancel()
66
332
  }
67
333
  } else if (e.key === '1' && !e.metaKey && !e.ctrlKey) {
68
334
  e.preventDefault()
@@ -81,6 +347,9 @@ export const useKeyboard = ({
81
347
  e.preventDefault()
82
348
  useEditor.getState().setPhase('furnish')
83
349
  useEditor.getState().setMode('build')
350
+ // Set the item tool explicitly so the active tool never inherits a
351
+ // stale tool from a prior build session.
352
+ useEditor.getState().setTool('item')
84
353
  useEditor.getState().setActiveSidebarPanel('items')
85
354
  } else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {
86
355
  if (isVersionPreviewMode) return
@@ -88,6 +357,17 @@ export const useKeyboard = ({
88
357
  useEditor.getState().setPhase('structure')
89
358
  useEditor.getState().setStructureLayer('zones')
90
359
  useEditor.getState().setMode('build')
360
+ // Set the zone tool explicitly so it never inherits a stale tool.
361
+ useEditor.getState().setTool('zone')
362
+ } else if (e.key === 'm' && !e.metaKey && !e.ctrlKey) {
363
+ if (isVersionPreviewMode) return
364
+ e.preventDefault()
365
+ const editor = useEditor.getState()
366
+ editor.setPhase('structure')
367
+ editor.setStructureLayer('elements')
368
+ editor.setToolDefaults('measurement', { kind: editor.lastMeasurementKind })
369
+ editor.setMode('build')
370
+ editor.setTool('measurement')
91
371
  }
92
372
  if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
93
373
  e.preventDefault()
@@ -99,6 +379,9 @@ export const useKeyboard = ({
99
379
  useEditor.getState().setPhase('structure')
100
380
  useEditor.getState().setStructureLayer('elements')
101
381
  useEditor.getState().setMode('build')
382
+ // Set the wall tool explicitly so B never inherits a stale tool
383
+ // (e.g. fence) left over from a prior build session.
384
+ useEditor.getState().setTool('wall')
102
385
  } else if (e.key === 'x' && !e.metaKey && !e.ctrlKey) {
103
386
  if (isVersionPreviewMode) return
104
387
  e.preventDefault()
@@ -110,25 +393,34 @@ export const useKeyboard = ({
110
393
  useEditor.getState().setPhase('structure')
111
394
  useEditor.getState().setStructureLayer('elements')
112
395
  useEditor.getState().setMode('material-paint')
396
+ } else if (e.key === 'g' && !e.metaKey && !e.ctrlKey) {
397
+ if (isVersionPreviewMode) return
398
+ e.preventDefault()
399
+ // G for ground. No `setPhase` — `setMode` moves to the site phase itself,
400
+ // and doing it here would set the phase twice with a mode reset between.
401
+ useEditor.getState().setMode('terrain-sculpt')
113
402
  } else if (e.key === 'c' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
114
403
  if (isVersionPreviewMode) return
115
404
  e.preventDefault()
116
405
  copySelectedNodesToEditorClipboard()
117
- } else if (e.key === 'v' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
406
+ } else if (e.key === 'x' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
118
407
  if (isVersionPreviewMode) return
119
408
  e.preventDefault()
120
- const result = pasteEditorClipboardToLevel()
121
- if (result?.pastedIds.length) {
122
- sfxEmitter.emit('sfx:item-place')
123
- }
124
- } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
409
+ cutSelectionToEditorClipboard()
410
+ } else if (e.key === 'v' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
125
411
  if (isVersionPreviewMode) return
126
412
  e.preventDefault()
127
- runUndo()
128
- } else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
413
+ void pasteSelectionAndPickUp()
414
+ } else if (e.key.toLowerCase() === 'z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
129
415
  if (isVersionPreviewMode) return
130
416
  e.preventDefault()
417
+ if (cancelInteractionForHistoryShortcut()) return
131
418
  runRedo()
419
+ } else if (e.key.toLowerCase() === 'z' && !e.shiftKey && (e.metaKey || e.ctrlKey)) {
420
+ if (isVersionPreviewMode) return
421
+ e.preventDefault()
422
+ if (cancelInteractionForHistoryShortcut()) return
423
+ runUndo()
132
424
  } else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) {
133
425
  e.preventDefault()
134
426
  const { buildingId, levelId } = useViewer.getState().selection
@@ -171,14 +463,53 @@ export const useKeyboard = ({
171
463
  }
172
464
  }
173
465
  }
174
- } else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode) {
466
+ } else if (
467
+ (e.key === 'r' || e.key === 'R') &&
468
+ !e.metaKey &&
469
+ !e.ctrlKey &&
470
+ !isVersionPreviewMode &&
471
+ !isPlacingOpening()
472
+ ) {
473
+ // `!metaKey && !ctrlKey` lets Cmd/Ctrl+R reach the browser reload instead
474
+ // of rotating/flipping the selected node.
175
475
  // Rotate selected node clockwise if it supports rotation (items, roofs, etc.)
176
476
  // Doors use R to flip side (front ↔ back, rotation += π); their
177
477
  // open/close toggle lives on E. Windows still use R to toggle
178
478
  // their open/closed state.
479
+ //
480
+ // Skipped entirely while a door/window placement is active
481
+ // (`isPlacingOpening`): the placement tool owns R then (flip the draft
482
+ // before commit), and the user can have a node selected at the same
483
+ // time — without this guard both would fire (double flip + sfx).
484
+ //
485
+ // References (guide/scan) live in `selectedReferenceId`, not the viewer
486
+ // selection — check them first, like the Delete arm below.
487
+ //
488
+ // Multi-selection branches to the group rotate before any of the
489
+ // single-selection arms (reference, door/window flip, registry
490
+ // keyboardActions, plain rotate) — those stay single-selection-only.
491
+ if (rotateGroupSelection(1)) {
492
+ e.preventDefault()
493
+ return
494
+ }
495
+ const rotatableReference = getRotatableSelectedReference()
496
+ if (rotatableReference) {
497
+ e.preventDefault()
498
+ useScene.getState().updateNode(rotatableReference.id, {
499
+ rotation: [
500
+ rotatableReference.rotation[0],
501
+ steppedRotation(rotatableReference.rotation[1], 1),
502
+ rotatableReference.rotation[2],
503
+ ],
504
+ })
505
+ sfxEmitter.emit('sfx:item-rotate')
506
+ return
507
+ }
179
508
  const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
180
509
  if (selectedNodeIds.length === 1) {
181
- const node = useScene.getState().nodes[selectedNodeIds[0]!]
510
+ const sceneNodes = useScene.getState().nodes
511
+ const selectedNode = sceneNodes[selectedNodeIds[0]!]
512
+ const node = selectedNode ? resolveDirectManipulationNode(selectedNode, sceneNodes) : null
182
513
  if (node?.type === 'door') {
183
514
  e.preventDefault()
184
515
  useScene.getState().updateNode(node.id, {
@@ -212,24 +543,48 @@ export const useKeyboard = ({
212
543
  sfxEmitter.emit('sfx:item-rotate')
213
544
  } else if (node && 'rotation' in node) {
214
545
  e.preventDefault()
215
- const ROTATION_STEP = Math.PI / 4
216
-
217
- // Handle different rotation types (number for roof, array for items/windows/doors)
546
+ // Round to the nearest 45° then step one increment (not a blind +45°).
218
547
  if (typeof node.rotation === 'number') {
219
- useScene.getState().updateNode(node.id, { rotation: node.rotation + ROTATION_STEP })
548
+ useScene
549
+ .getState()
550
+ .updateNode(node.id, { rotation: steppedRotation(node.rotation, 1) })
220
551
  } else if (Array.isArray(node.rotation)) {
221
552
  useScene.getState().updateNode(node.id, {
222
- rotation: [node.rotation[0], node.rotation[1] + ROTATION_STEP, node.rotation[2]],
553
+ rotation: [
554
+ node.rotation[0],
555
+ steppedRotation(node.rotation[1], 1),
556
+ node.rotation[2],
557
+ ],
223
558
  })
224
559
  }
225
560
  sfxEmitter.emit('sfx:item-rotate')
226
561
  }
227
562
  }
228
- } else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode) {
563
+ } else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode && !isPlacingOpening()) {
229
564
  // Rotate selected node counter-clockwise
565
+ // Multi-selection → group rotate, mirroring the R arm above.
566
+ if (rotateGroupSelection(-1)) {
567
+ e.preventDefault()
568
+ return
569
+ }
570
+ const rotatableReference = getRotatableSelectedReference()
571
+ if (rotatableReference) {
572
+ e.preventDefault()
573
+ useScene.getState().updateNode(rotatableReference.id, {
574
+ rotation: [
575
+ rotatableReference.rotation[0],
576
+ steppedRotation(rotatableReference.rotation[1], -1),
577
+ rotatableReference.rotation[2],
578
+ ],
579
+ })
580
+ sfxEmitter.emit('sfx:item-rotate')
581
+ return
582
+ }
230
583
  const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
231
584
  if (selectedNodeIds.length === 1) {
232
- const node = useScene.getState().nodes[selectedNodeIds[0]!]
585
+ const sceneNodes = useScene.getState().nodes
586
+ const selectedNode = sceneNodes[selectedNodeIds[0]!]
587
+ const node = selectedNode ? resolveDirectManipulationNode(selectedNode, sceneNodes) : null
233
588
  if (node?.type === 'door') {
234
589
  // Door's open/close moved to E; T is a no-op for doors so
235
590
  // it doesn't free-rotate a wall-bound node by π/4.
@@ -245,13 +600,18 @@ export const useKeyboard = ({
245
600
  sfxEmitter.emit('sfx:item-rotate')
246
601
  } else if (node && 'rotation' in node) {
247
602
  e.preventDefault()
248
- const ROTATION_STEP = Math.PI / 4
249
-
603
+ // Round to the nearest 45° then step one increment back.
250
604
  if (typeof node.rotation === 'number') {
251
- useScene.getState().updateNode(node.id, { rotation: node.rotation - ROTATION_STEP })
605
+ useScene
606
+ .getState()
607
+ .updateNode(node.id, { rotation: steppedRotation(node.rotation, -1) })
252
608
  } else if (Array.isArray(node.rotation)) {
253
609
  useScene.getState().updateNode(node.id, {
254
- rotation: [node.rotation[0], node.rotation[1] - ROTATION_STEP, node.rotation[2]],
610
+ rotation: [
611
+ node.rotation[0],
612
+ steppedRotation(node.rotation[1], -1),
613
+ node.rotation[2],
614
+ ],
255
615
  })
256
616
  }
257
617
  sfxEmitter.emit('sfx:item-rotate')
@@ -263,7 +623,13 @@ export const useKeyboard = ({
263
623
  const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
264
624
  if (selectedNodeIds.length === 1) {
265
625
  const node = useScene.getState().nodes[selectedNodeIds[0]!]
266
- if (node?.type === 'door' && node.openingKind !== 'opening') {
626
+ const registryE = node && nodeRegistry.get(node.type)?.keyboardActions?.e
627
+ if (node && registryE?.appliesTo(node)) {
628
+ // Registry-driven E interaction. Same shape as the R/T arms.
629
+ e.preventDefault()
630
+ registryE.run(node)
631
+ sfxEmitter.emit('sfx:item-rotate')
632
+ } else if (node?.type === 'door' && node.openingKind !== 'opening') {
267
633
  e.preventDefault()
268
634
  toggleDoorOpenState(node.id)
269
635
  sfxEmitter.emit('sfx:item-rotate')
@@ -298,27 +664,7 @@ export const useKeyboard = ({
298
664
  }
299
665
  }
300
666
 
301
- const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
302
-
303
- if (selectedNodeIds.length > 0) {
304
- // Guard against accidental bulk deletion (e.g. box-select all + Delete)
305
- const BULK_DELETE_THRESHOLD = 10
306
- if (selectedNodeIds.length >= BULK_DELETE_THRESHOLD) {
307
- const confirmed = window.confirm(
308
- `Delete ${selectedNodeIds.length} selected elements? This cannot be undone if the undo history is exhausted.`,
309
- )
310
- if (!confirmed) return
311
- }
312
-
313
- // Play appropriate SFX based on what's being deleted
314
- if (selectedNodeIds.length === 1) {
315
- const node = useScene.getState().nodes[selectedNodeIds[0]!]
316
- emitDeleteSFX(node?.type)
317
- } else {
318
- sfxEmitter.emit('sfx:structure-delete')
319
- }
320
-
321
- useScene.getState().deleteNodes(selectedNodeIds)
667
+ if (deleteSelection()) {
322
668
  return
323
669
  }
324
670
 
@@ -331,8 +677,30 @@ export const useKeyboard = ({
331
677
  }
332
678
  }
333
679
  }
680
+ const handleKeyUp = (e: KeyboardEvent) => {
681
+ if (e.key === 'Control' || e.key === 'Meta') {
682
+ const wasClean = ctrlTapClean
683
+ ctrlTapClean = false
684
+ if (!wasClean) return
685
+ // Same scope as the Shift snapping-mode cycle, and never while typing
686
+ // in an input.
687
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
688
+ return
689
+ }
690
+ if (!isSnappingCycleContext()) return
691
+ // Cycle the grid / measurement step (0.5 → 0.25 → 0.1 → 0.05).
692
+ useEditor.getState().cycleGridSnapStep()
693
+ sfxEmitter.emit('sfx:grid-snap')
694
+ return
695
+ }
696
+ }
697
+
334
698
  window.addEventListener('keydown', handleKeyDown)
335
- return () => window.removeEventListener('keydown', handleKeyDown)
699
+ window.addEventListener('keyup', handleKeyUp)
700
+ return () => {
701
+ window.removeEventListener('keydown', handleKeyDown)
702
+ window.removeEventListener('keyup', handleKeyUp)
703
+ }
336
704
  }, [disabled, isVersionPreviewMode])
337
705
 
338
706
  return null