@pascal-app/editor 0.8.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (350) hide show
  1. package/package.json +15 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +168 -0
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +795 -28
  5. package/src/components/editor/editor-layout-mobile.tsx +8 -3
  6. package/src/components/editor/editor-layout-v2.tsx +76 -51
  7. package/src/components/editor/export-manager.tsx +69 -32
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +182 -0
  10. package/src/components/editor/first-person/build-collider-world.ts +228 -83
  11. package/src/components/editor/first-person-controls.tsx +943 -27
  12. package/src/components/editor/floating-action-menu.tsx +676 -208
  13. package/src/components/editor/floating-building-action-menu.tsx +2 -2
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +6916 -12137
  17. package/src/components/editor/grid.tsx +197 -36
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +420 -0
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +396 -0
  24. package/src/components/editor/group-transform-shared.ts +489 -0
  25. package/src/components/editor/handles/handle-arrow.tsx +581 -0
  26. package/src/components/editor/handles/use-handle-drag.ts +246 -0
  27. package/src/components/editor/index.tsx +412 -162
  28. package/src/components/editor/measurement-pill.tsx +115 -0
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +1646 -0
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +1106 -483
  36. package/src/components/editor/site-edge-labels.tsx +45 -21
  37. package/src/components/editor/slab-hole-highlights.tsx +474 -0
  38. package/src/components/editor/snapshot-capture-overlay.tsx +369 -155
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +140 -41
  41. package/src/components/editor/use-floorplan-background-placement.ts +190 -35
  42. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  43. package/src/components/editor/use-floorplan-scene-data.ts +19 -7
  44. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  45. package/src/components/editor/wall-measurement-label.tsx +14 -19
  46. package/src/components/editor/wall-move-side-handles.tsx +870 -0
  47. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  48. package/src/components/editor/wall-snap-beacon-layer.tsx +304 -0
  49. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +15 -6
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  52. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  53. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  54. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  56. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +392 -0
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +857 -0
  59. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  60. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +77 -0
  61. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  62. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  63. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  64. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  65. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  66. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +62 -0
  67. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  68. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +3325 -0
  69. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  70. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  71. package/src/components/editor-2d/svg-paths.ts +10 -2
  72. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +439 -57
  73. package/src/components/systems/ceiling/ceiling-system.tsx +109 -12
  74. package/src/components/systems/roof/roof-edit-system.tsx +1851 -22
  75. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  76. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  77. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  78. package/src/components/systems/zone/zone-system.tsx +42 -13
  79. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  80. package/src/components/tools/elevator/elevator-tool.tsx +375 -0
  81. package/src/components/tools/elevator/move-elevator-tool.tsx +263 -0
  82. package/src/components/tools/fence/fence-drafting.ts +141 -27
  83. package/src/components/tools/item/move-tool.tsx +45 -105
  84. package/src/components/tools/item/placement-math.test.ts +22 -0
  85. package/src/components/tools/item/placement-math.ts +59 -7
  86. package/src/components/tools/item/placement-strategies.ts +432 -16
  87. package/src/components/tools/item/placement-types.ts +22 -1
  88. package/src/components/tools/item/use-draft-node.ts +51 -2
  89. package/src/components/tools/item/use-placement-coordinator.tsx +1399 -256
  90. package/src/components/tools/registry/move-registry-node-tool.tsx +1064 -0
  91. package/src/components/tools/roof/roof-tool.tsx +350 -28
  92. package/src/components/tools/select/box-select-state.ts +88 -0
  93. package/src/components/tools/select/box-select-tool.tsx +442 -520
  94. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  95. package/src/components/tools/select/marquee-geometry.ts +155 -0
  96. package/src/components/tools/select/plane-box-select-tool.tsx +570 -0
  97. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  98. package/src/components/tools/select/select-candidates.test.ts +109 -0
  99. package/src/components/tools/select/select-candidates.ts +85 -0
  100. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  101. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  102. package/src/components/tools/shared/drag-bounding-box.tsx +195 -0
  103. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  104. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  105. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  106. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  107. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  108. package/src/components/tools/shared/placement-box.tsx +305 -0
  109. package/src/components/tools/shared/polygon-editor.tsx +866 -175
  110. package/src/components/tools/shared/segment-angle.ts +56 -0
  111. package/src/components/tools/site/site-boundary-editor.tsx +520 -16
  112. package/src/components/tools/site/site-flag-model.ts +18 -0
  113. package/src/components/tools/stair/stair-defaults.ts +2 -1
  114. package/src/components/tools/stair/stair-tool.tsx +342 -37
  115. package/src/components/tools/tool-manager.tsx +239 -62
  116. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  117. package/src/components/tools/wall/wall-drafting.ts +248 -171
  118. package/src/components/tools/wall/wall-snap-geometry.test.ts +123 -0
  119. package/src/components/tools/wall/wall-snap-geometry.ts +331 -0
  120. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  121. package/src/components/tools/zone/zone-tool.tsx +86 -82
  122. package/src/components/ui/action-menu/action-button.tsx +21 -1
  123. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  124. package/src/components/ui/action-menu/control-modes.tsx +40 -170
  125. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  126. package/src/components/ui/action-menu/index.tsx +3 -117
  127. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  128. package/src/components/ui/action-menu/structure-tools.tsx +27 -93
  129. package/src/components/ui/action-menu/view-toggles.tsx +279 -90
  130. package/src/components/ui/command-palette/editor-commands.tsx +20 -15
  131. package/src/components/ui/command-palette/index.tsx +5 -3
  132. package/src/components/ui/controls/material-paint-panel.tsx +152 -0
  133. package/src/components/ui/controls/material-picker.tsx +87 -177
  134. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  135. package/src/components/ui/controls/metric-control.tsx +142 -46
  136. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  137. package/src/components/ui/controls/slider-control.tsx +88 -38
  138. package/src/components/ui/floating-level-selector.tsx +47 -10
  139. package/src/components/ui/helpers/building-helper.tsx +10 -23
  140. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  141. package/src/components/ui/helpers/helper-manager.tsx +235 -23
  142. package/src/components/ui/helpers/item-helper.tsx +28 -32
  143. package/src/components/ui/helpers/registered-tool-helper.tsx +59 -0
  144. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  145. package/src/components/ui/icon-ref.tsx +47 -0
  146. package/src/components/ui/item-catalog/catalog-items.tsx +40 -1
  147. package/src/components/ui/item-catalog/item-catalog.tsx +16 -36
  148. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  149. package/src/components/ui/panels/node-display.ts +17 -16
  150. package/src/components/ui/panels/panel-manager.tsx +55 -58
  151. package/src/components/ui/panels/panel-wrapper.tsx +238 -12
  152. package/src/components/ui/panels/parametric-inspector.tsx +482 -0
  153. package/src/components/ui/panels/reference-panel.tsx +54 -19
  154. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  155. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  156. package/src/components/ui/primitives/sidebar.tsx +1 -0
  157. package/src/components/ui/scene-loader.tsx +1 -3
  158. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  159. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  160. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  161. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  162. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  163. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +122 -41
  164. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +66 -7
  165. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +267 -0
  166. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  167. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +9 -5
  168. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +86 -0
  169. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  170. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  171. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +92 -0
  172. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  173. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +3 -3
  174. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +86 -0
  175. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -21
  176. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  177. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  178. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +140 -0
  179. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +48 -8
  180. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +132 -0
  181. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -5
  182. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +86 -0
  183. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  184. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  185. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +112 -39
  186. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  187. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  188. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  189. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  190. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  191. package/src/components/ui/sidebar/tab-bar.tsx +75 -1
  192. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  193. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  194. package/src/components/ui/snap-target-badge.tsx +88 -0
  195. package/src/components/viewer-overlay.tsx +253 -61
  196. package/src/components/viewer-zone-system.tsx +7 -2
  197. package/src/hooks/use-auto-save.test.ts +14 -0
  198. package/src/hooks/use-auto-save.ts +64 -10
  199. package/src/hooks/use-ceiling-events.ts +176 -0
  200. package/src/hooks/use-drag-action.ts +127 -0
  201. package/src/hooks/use-keyboard.ts +493 -95
  202. package/src/hooks/use-selection.ts +64 -0
  203. package/src/index.tsx +451 -6
  204. package/src/lib/active-placement-surface.ts +35 -0
  205. package/src/lib/ceiling-plan-snap.ts +24 -0
  206. package/src/lib/constants.ts +6 -2
  207. package/src/lib/contextual-help.test.ts +112 -0
  208. package/src/lib/contextual-help.ts +144 -0
  209. package/src/lib/continuation.ts +64 -0
  210. package/src/lib/direct-manipulation.test.ts +192 -0
  211. package/src/lib/direct-manipulation.ts +109 -0
  212. package/src/lib/editor-api.ts +50 -0
  213. package/src/lib/elevator-support.ts +96 -0
  214. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  215. package/src/lib/floorplan/apply-alignment.ts +117 -0
  216. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  217. package/src/lib/floorplan/geometry.ts +53 -0
  218. package/src/lib/floorplan/index.ts +10 -0
  219. package/src/lib/floorplan/items.ts +5 -2
  220. package/src/lib/floorplan/plan-coords.ts +21 -0
  221. package/src/lib/floorplan/selection-tool.ts +40 -0
  222. package/src/lib/floorplan/stairs.ts +12 -1
  223. package/src/lib/fresh-planar-placement.test.ts +339 -0
  224. package/src/lib/fresh-planar-placement.ts +130 -0
  225. package/src/lib/glb-export.test.ts +441 -0
  226. package/src/lib/glb-export.ts +874 -0
  227. package/src/lib/history.ts +11 -1
  228. package/src/lib/interaction/hot-set.test.ts +133 -0
  229. package/src/lib/interaction/hot-set.ts +67 -0
  230. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  231. package/src/lib/interaction/overlay-policy.ts +59 -0
  232. package/src/lib/interaction/scope.ts +211 -0
  233. package/src/lib/level-duplication.ts +54 -42
  234. package/src/lib/level-selection.ts +2 -2
  235. package/src/lib/material-paint.ts +227 -48
  236. package/src/lib/measurement-kind.test.ts +30 -0
  237. package/src/lib/measurement-kind.ts +19 -0
  238. package/src/lib/measurement-label.test.ts +47 -0
  239. package/src/lib/measurement-label.ts +68 -0
  240. package/src/lib/measurement-parser.ts +116 -0
  241. package/src/lib/measurements.test.ts +183 -0
  242. package/src/lib/measurements.ts +199 -0
  243. package/src/lib/paint-scope.test.ts +454 -0
  244. package/src/lib/paint-scope.ts +461 -0
  245. package/src/lib/placement-drag-release.ts +23 -0
  246. package/src/lib/placement-metadata.ts +29 -0
  247. package/src/lib/planar-cursor-placement.test.ts +100 -0
  248. package/src/lib/planar-cursor-placement.ts +42 -0
  249. package/src/lib/plugin-panels.test.ts +19 -0
  250. package/src/lib/plugin-panels.ts +91 -0
  251. package/src/lib/quick-measurement.test.ts +77 -0
  252. package/src/lib/quick-measurement.ts +109 -0
  253. package/src/lib/roof-duplication.ts +7 -7
  254. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  255. package/src/lib/roof-wall-hit.ts +164 -0
  256. package/src/lib/scene-clipboard.test.ts +196 -0
  257. package/src/lib/scene-clipboard.ts +332 -0
  258. package/src/lib/scene.ts +40 -8
  259. package/src/lib/selection-routing.test.ts +298 -0
  260. package/src/lib/selection-routing.ts +175 -0
  261. package/src/lib/sfx/index.ts +1 -0
  262. package/src/lib/sfx/movement-tick.test.ts +65 -0
  263. package/src/lib/sfx/movement-tick.ts +18 -0
  264. package/src/lib/sfx-bus.ts +69 -10
  265. package/src/lib/sfx-player.test.ts +124 -0
  266. package/src/lib/sfx-player.ts +148 -55
  267. package/src/lib/site-boundary.ts +1 -0
  268. package/src/lib/slab-plan-snap.test.ts +93 -0
  269. package/src/lib/slab-plan-snap.ts +108 -0
  270. package/src/lib/snapping-mode.test.ts +138 -0
  271. package/src/lib/snapping-mode.ts +179 -0
  272. package/src/lib/stair-duplication.ts +4 -4
  273. package/src/lib/stair-levels.test.ts +146 -0
  274. package/src/lib/stair-levels.ts +177 -0
  275. package/src/lib/surface-plan-snap.test.ts +71 -0
  276. package/src/lib/surface-plan-snap.ts +256 -0
  277. package/src/lib/use-linear-display.ts +40 -0
  278. package/src/lib/world-grid-snap.ts +231 -0
  279. package/src/lib/zone-content.ts +130 -0
  280. package/src/store/use-alignment-guides.ts +21 -0
  281. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  282. package/src/store/use-editor.tsx +767 -114
  283. package/src/store/use-facing-pose.ts +44 -0
  284. package/src/store/use-fence-curve-draft.ts +21 -0
  285. package/src/store/use-floorplan-draft-preview.ts +101 -0
  286. package/src/store/use-floorplan-marquee.ts +50 -0
  287. package/src/store/use-interaction-scope.test.ts +186 -0
  288. package/src/store/use-interaction-scope.ts +132 -0
  289. package/src/store/use-measurement-draft.test.ts +530 -0
  290. package/src/store/use-measurement-draft.ts +543 -0
  291. package/src/store/use-opening-guides.ts +41 -0
  292. package/src/store/use-placement-preview.ts +39 -0
  293. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  294. package/src/store/use-quick-measurement-hud.ts +77 -0
  295. package/src/store/use-segment-draft-chain.ts +28 -0
  296. package/src/store/use-stair-build-preview.ts +43 -0
  297. package/src/store/use-wall-move-ghosts.ts +36 -0
  298. package/src/store/use-wall-snap-indicator.ts +34 -0
  299. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  300. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  301. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  302. package/src/components/tools/building/move-building-tool.tsx +0 -157
  303. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  304. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  305. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  306. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  307. package/src/components/tools/column/column-tool.tsx +0 -97
  308. package/src/components/tools/column/move-column-tool.tsx +0 -105
  309. package/src/components/tools/door/door-math.ts +0 -110
  310. package/src/components/tools/door/door-tool.tsx +0 -324
  311. package/src/components/tools/door/move-door-tool.tsx +0 -412
  312. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  313. package/src/components/tools/fence/fence-tool.tsx +0 -346
  314. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  315. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  316. package/src/components/tools/item/item-tool.tsx +0 -26
  317. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  318. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  319. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  320. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  321. package/src/components/tools/slab/slab-tool.tsx +0 -322
  322. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  323. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  324. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  325. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  326. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  327. package/src/components/tools/wall/wall-tool.tsx +0 -332
  328. package/src/components/tools/window/move-window-tool.tsx +0 -447
  329. package/src/components/tools/window/window-math.ts +0 -117
  330. package/src/components/tools/window/window-tool.tsx +0 -332
  331. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  332. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  333. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  334. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  335. package/src/components/ui/panels/column-panel.tsx +0 -759
  336. package/src/components/ui/panels/door-panel.tsx +0 -1299
  337. package/src/components/ui/panels/fence-panel.tsx +0 -222
  338. package/src/components/ui/panels/item-panel.tsx +0 -306
  339. package/src/components/ui/panels/paint-panel.tsx +0 -163
  340. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  341. package/src/components/ui/panels/roof-panel.tsx +0 -280
  342. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  343. package/src/components/ui/panels/slab-panel.tsx +0 -250
  344. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  345. package/src/components/ui/panels/stair-panel.tsx +0 -577
  346. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  347. package/src/components/ui/panels/wall-panel.tsx +0 -273
  348. package/src/components/ui/panels/window-panel.tsx +0 -968
  349. package/src/contexts/presets-context.tsx +0 -121
  350. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -1,11 +1,96 @@
1
- import { type AnyNodeId, emitter, 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'
4
- import { closeDoorOpenState, toggleDoorOpenState } from '../lib/door-interaction'
12
+ import { Vector3 } from 'three'
13
+ import {
14
+ classifyParticipant,
15
+ collectParticipants,
16
+ computeGroupBox,
17
+ expandToComponent,
18
+ levelFrame,
19
+ rotateGroupPatches,
20
+ } from '../components/editor/group-transform-shared'
21
+ import { steppedRotation } from '../components/tools/item/placement-math'
22
+ import { resolveDirectManipulationNode } from '../lib/direct-manipulation'
23
+ import { toggleDoorOpenState } from '../lib/door-interaction'
24
+ import { guideEmitter } from '../lib/guide-events'
5
25
  import { runRedo, runUndo } from '../lib/history'
6
- import { sfxEmitter } from '../lib/sfx-bus'
7
- import { closeWindowOpenState, toggleWindowOpenState } from '../lib/window-interaction'
8
- import useEditor from '../store/use-editor'
26
+ import { isActive } from '../lib/interaction/scope'
27
+ import {
28
+ copySelectedNodesToEditorClipboard,
29
+ pasteEditorClipboardToLevel,
30
+ } from '../lib/scene-clipboard'
31
+ import { emitDeleteSFX, sfxEmitter } from '../lib/sfx-bus'
32
+ import { toggleWindowOpenState } from '../lib/window-interaction'
33
+ import useEditor, { getActiveContinuationContext, getActiveSnapContext } from '../store/use-editor'
34
+ import useInteractionScope, { getMovingNode } from '../store/use-interaction-scope'
35
+
36
+ // References (guide/scan) are selected via `useEditor.selectedReferenceId`, not
37
+ // the viewer selection, so selection-based key arms (R/T rotate) need this
38
+ // separate lookup. Locked guides don't rotate, matching direct manipulation.
39
+ function getRotatableSelectedReference() {
40
+ const refId = useEditor.getState().selectedReferenceId
41
+ if (!refId) return null
42
+ const node = useScene.getState().nodes[refId as AnyNodeId]
43
+ if (!node || (node.type !== 'guide' && node.type !== 'scan')) return null
44
+ if (useEditor.getState().guideUi[refId]?.locked === true) return null
45
+ return node
46
+ }
47
+
48
+ // Group rotate: R/T on a multi-selection spins the whole selection rigidly
49
+ // ±45° around its bbox center — the keyboard sibling of the 3D group-rotate
50
+ // gizmo, sharing its participant snapshot + rigid-rotation math (welded
51
+ // wall/fence junctions, connected-component expansion). One batched
52
+ // `updateNodes` call = one undo step. Returns false when the selection holds
53
+ // no transformable participants so the caller can fall through to the
54
+ // single-selection arms.
55
+ function rotateGroupSelection(direction: 1 | -1): boolean {
56
+ const { selectedIds, levelId } = useViewer.getState().selection
57
+ if (selectedIds.length <= 1) return false
58
+ const nodes = useScene.getState().nodes
59
+ const participantIds = selectedIds.filter(
60
+ (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
61
+ )
62
+ if (participantIds.length === 0) return false
63
+ const fullIds = expandToComponent(participantIds, nodes, levelId)
64
+ const { starts, links } = collectParticipants(fullIds, nodes, levelId)
65
+ if (starts.length === 0) return false
66
+
67
+ // Same pivot as the 3D gizmo: the selection's world bbox center, converted
68
+ // into the level frame before orbiting placements (a rotated building would
69
+ // otherwise displace the centre).
70
+ const box = computeGroupBox(fullIds)
71
+ if (!box) return false
72
+ const worldCenter = new Vector3(
73
+ (box.min.x + box.max.x) / 2,
74
+ box.min.y,
75
+ (box.min.z + box.max.z) / 2,
76
+ )
77
+ const localCenter = worldCenter.applyMatrix4(levelFrame(levelId).inverse)
78
+
79
+ // R (+45° yaw) orbits by -45° in the atan2 x→z sense: yaw = rotation - delta
80
+ // (see rotateGroupPatches), so keyboard direction matches the single-node
81
+ // steppedRotation sense.
82
+ const delta = -direction * (Math.PI / 4)
83
+ const patches = rotateGroupPatches(starts, links, { x: localCenter.x, z: localCenter.z }, delta)
84
+ // Space detection stays out: a rigid rotation of existing walls must not
85
+ // re-create the room's auto floors/ceilings at the new bearing.
86
+ pauseSpaceDetection()
87
+ useScene
88
+ .getState()
89
+ .updateNodes(patches.map(([id, data]) => ({ id, data: data as Partial<AnyNode> })))
90
+ resumeSpaceDetection()
91
+ sfxEmitter.emit('sfx:item-rotate')
92
+ return true
93
+ }
9
94
 
10
95
  // Tools call this in their onCancel handler when they have an active mid-action to cancel,
11
96
  // so that the global Escape handler knows not to also switch to select mode.
@@ -14,6 +99,64 @@ export const markToolCancelConsumed = () => {
14
99
  _toolCancelConsumed = true
15
100
  }
16
101
 
102
+ // Escape's fall-through when no tool consumed the cancel: drop back to the
103
+ // select tool (keeping building/level context) and close panels. Tools like
104
+ // preset/item placement rely on this — they pass no coordinator onCancel, and
105
+ // it is the mode switch unmounting them that destroys the draft.
106
+ const exitToSelectAfterUnconsumedCancel = () => {
107
+ const currentPhase = useEditor.getState().phase
108
+ const currentStructureLayer = useEditor.getState().structureLayer
109
+
110
+ useInteractionScope.getState().endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
111
+
112
+ // From zone mode, return to structure select
113
+ if (currentPhase === 'structure' && currentStructureLayer === 'zones') {
114
+ useEditor.getState().setStructureLayer('elements')
115
+ useEditor.getState().setMode('select')
116
+ } else {
117
+ // Return to the default select tool while keeping the active building/level context.
118
+ useEditor.getState().setMode('select')
119
+ }
120
+
121
+ useEditor.getState().setFloorplanSelectionTool('click')
122
+
123
+ // Clear selections to close UI panels, but KEEP the active building and level context.
124
+ useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
125
+ useEditor.getState().setSelectedReferenceId(null)
126
+ }
127
+
128
+ // ⌘Z pressed mid-interaction (moving a node, drawing a wall, mid-placement…)
129
+ // reads as "abort this action", not history undo — behave exactly like Escape
130
+ // and report whether anything was in flight so the undo/redo arms know to
131
+ // skip the history jump. Pointer drags that only listen for their own
132
+ // capture-phase keydown never reach here — they stopPropagation first (see
133
+ // isHistoryShortcut call sites).
134
+ const cancelInteractionForHistoryShortcut = () => {
135
+ if (useEditor.getState().referenceScaleActiveGuideId) {
136
+ guideEmitter.emit('guide:cancel-reference-scale')
137
+ return true
138
+ }
139
+ _toolCancelConsumed = false
140
+ emitter.emit('tool:cancel')
141
+ if (_toolCancelConsumed) return true
142
+ if (
143
+ isActive(useInteractionScope.getState().scope) ||
144
+ useViewer.getState().inputDragging ||
145
+ // Paused history means a gesture session is live (draft placement, adopted
146
+ // move, …) even when no scope/drag flag is set — the preset/item draft
147
+ // cycle keeps temporal paused for the whole session, and a history jump
148
+ // against a paused store would land on a stale baseline anyway.
149
+ !useScene.temporal.getState().isTracking
150
+ ) {
151
+ // A gesture is live but nothing consumed the cancel: finish it the way
152
+ // Escape does — the mode switch is what actually cancels tools that hook
153
+ // their teardown to unmount (preset/item placement).
154
+ exitToSelectAfterUnconsumedCancel()
155
+ return true
156
+ }
157
+ return false
158
+ }
159
+
17
160
  export const useKeyboard = ({
18
161
  isVersionPreviewMode = false,
19
162
  disabled = false,
@@ -26,39 +169,130 @@ export const useKeyboard = ({
26
169
  return
27
170
  }
28
171
 
172
+ // True while a door/window is being placed: either a fresh clone is moving
173
+ // (preset / duplicate path) or a door/window build tool is armed. The
174
+ // placement tool owns R/T then (flip the draft before commit), so the
175
+ // global selection-based R/T handler must stand down to avoid double-firing.
176
+ const isPlacingOpening = () => {
177
+ const ed = useEditor.getState()
178
+ const moving = getMovingNode()
179
+ if (moving?.type === 'door' || moving?.type === 'window') return true
180
+ return ed.mode === 'build' && (ed.tool === 'door' || ed.tool === 'window')
181
+ }
182
+
183
+ // Shift cycles the snapping mode (and a clean-tap Ctrl the grid step)
184
+ // whenever there's an active snapping context — i.e. exactly when the HUD
185
+ // shows a snapping chip. That single source covers wall/fence/item drafting,
186
+ // every node move (including wall-hosted items + door/window openings, which
187
+ // now declare `snapProfile`), and endpoint/polygon reshaping, so the keys
188
+ // never silently stop working. Force-place lives on Alt where a tool supports it.
189
+ const isSnappingCycleContext = () => getActiveSnapContext() != null
190
+ // A "clean tap" of Ctrl/Meta (pressed and released with NO other key in
191
+ // between) cycles the grid step — same context as the Shift snapping-mode
192
+ // cycle. `ctrlTapClean` starts true the moment Ctrl/Meta goes down alone
193
+ // and is cleared the instant any other key fires, so chords like Ctrl+Z /
194
+ // Ctrl+C never cycle.
195
+ let ctrlTapClean = false
196
+
29
197
  const handleKeyDown = (e: KeyboardEvent) => {
198
+ if (e.key === 'Control' || e.key === 'Meta') {
199
+ // Only a fresh, modifier-free press starts a clean-tap candidate;
200
+ // ignore key-repeat and presses already part of a combo.
201
+ ctrlTapClean = !e.repeat && !e.shiftKey && !e.altKey
202
+ } else {
203
+ // Any non-modifier key (or a modifier combined with Ctrl/Meta) breaks
204
+ // the clean tap.
205
+ ctrlTapClean = false
206
+ }
207
+
30
208
  // Don't handle shortcuts if user is typing in an input
31
209
  if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
32
210
  return
33
211
  }
34
212
 
213
+ if (e.key === 'Shift' && !e.repeat && useEditor.getState().mode === 'material-paint') {
214
+ // In paint mode Shift cycles the application scope (this surface →
215
+ // whole item / all matching / room) — the paint-mode analogue of the
216
+ // snapping-mode cycle below. The scope chip mirrors this key.
217
+ e.preventDefault()
218
+ useEditor.getState().cyclePaintScope()
219
+ sfxEmitter.emit('sfx:grid-snap')
220
+ return
221
+ }
222
+
223
+ if (e.key === 'Shift' && !e.repeat && isSnappingCycleContext()) {
224
+ // Cycle the global snapping mode (grid → lines → angles → off).
225
+ // `'off'` is the snap bypass now, so Shift no longer holds-to-bypass.
226
+ e.preventDefault()
227
+ useEditor.getState().cycleSnappingMode()
228
+ sfxEmitter.emit('sfx:grid-snap')
229
+ return
230
+ }
231
+
232
+ if (
233
+ (e.key === 't' || e.key === 'T') &&
234
+ !e.repeat &&
235
+ !e.metaKey &&
236
+ !e.ctrlKey &&
237
+ !e.shiftKey &&
238
+ !e.altKey
239
+ ) {
240
+ const context = getActiveContinuationContext()
241
+ if (context === 'fence') {
242
+ e.preventDefault()
243
+ const current = useEditor.getState().getContinuation('fence')
244
+ useEditor
245
+ .getState()
246
+ .setContinuation('fence', current === 'curved' ? 'continuous' : 'curved')
247
+ sfxEmitter.emit('sfx:grid-snap')
248
+ return
249
+ }
250
+ }
251
+
252
+ if (
253
+ (e.key === 'c' || e.key === 'C') &&
254
+ !e.repeat &&
255
+ !e.metaKey &&
256
+ !e.ctrlKey &&
257
+ !e.shiftKey &&
258
+ !e.altKey
259
+ ) {
260
+ const context = getActiveContinuationContext()
261
+ if (context) {
262
+ e.preventDefault()
263
+ if (context === 'fence') {
264
+ const current = useEditor.getState().getContinuation('fence')
265
+ if (current !== 'curved') {
266
+ useEditor
267
+ .getState()
268
+ .setContinuation('fence', current === 'single' ? 'continuous' : 'single')
269
+ sfxEmitter.emit('sfx:grid-snap')
270
+ }
271
+ return
272
+ }
273
+ useEditor.getState().cycleContinuation(context)
274
+ sfxEmitter.emit('sfx:grid-snap')
275
+ return
276
+ }
277
+ }
278
+
35
279
  if (e.key === 'Escape') {
36
280
  e.preventDefault()
281
+
282
+ // An in-flight reference-scale measurement swallows Escape whole:
283
+ // cancel the flow but keep the reference selected and its panel open.
284
+ if (useEditor.getState().referenceScaleActiveGuideId) {
285
+ guideEmitter.emit('guide:cancel-reference-scale')
286
+ return
287
+ }
288
+
37
289
  _toolCancelConsumed = false
38
290
  emitter.emit('tool:cancel')
39
291
 
40
292
  // Only switch to select mode if no tool had an active mid-action to cancel.
41
293
  // (e.g. mid-wall draw or mid-slab polygon should only cancel the action, not exit the tool)
42
294
  if (!_toolCancelConsumed) {
43
- const currentPhase = useEditor.getState().phase
44
- const currentStructureLayer = useEditor.getState().structureLayer
45
-
46
- useEditor.getState().setEditingHole(null)
47
-
48
- // From zone mode, return to structure select
49
- if (currentPhase === 'structure' && currentStructureLayer === 'zones') {
50
- useEditor.getState().setStructureLayer('elements')
51
- useEditor.getState().setMode('select')
52
- } else {
53
- // Return to the default select tool while keeping the active building/level context.
54
- useEditor.getState().setMode('select')
55
- }
56
-
57
- useEditor.getState().setFloorplanSelectionTool('click')
58
-
59
- // Clear selections to close UI panels, but KEEP the active building and level context.
60
- useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
61
- useEditor.getState().setSelectedReferenceId(null)
295
+ exitToSelectAfterUnconsumedCancel()
62
296
  }
63
297
  } else if (e.key === '1' && !e.metaKey && !e.ctrlKey) {
64
298
  e.preventDefault()
@@ -77,6 +311,9 @@ export const useKeyboard = ({
77
311
  e.preventDefault()
78
312
  useEditor.getState().setPhase('furnish')
79
313
  useEditor.getState().setMode('build')
314
+ // Set the item tool explicitly so the active tool never inherits a
315
+ // stale tool from a prior build session.
316
+ useEditor.getState().setTool('item')
80
317
  useEditor.getState().setActiveSidebarPanel('items')
81
318
  } else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {
82
319
  if (isVersionPreviewMode) return
@@ -84,6 +321,17 @@ export const useKeyboard = ({
84
321
  useEditor.getState().setPhase('structure')
85
322
  useEditor.getState().setStructureLayer('zones')
86
323
  useEditor.getState().setMode('build')
324
+ // Set the zone tool explicitly so it never inherits a stale tool.
325
+ useEditor.getState().setTool('zone')
326
+ } else if (e.key === 'm' && !e.metaKey && !e.ctrlKey) {
327
+ if (isVersionPreviewMode) return
328
+ e.preventDefault()
329
+ const editor = useEditor.getState()
330
+ editor.setPhase('structure')
331
+ editor.setStructureLayer('elements')
332
+ editor.setToolDefaults('measurement', { kind: editor.lastMeasurementKind })
333
+ editor.setMode('build')
334
+ editor.setTool('measurement')
87
335
  }
88
336
  if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
89
337
  e.preventDefault()
@@ -95,7 +343,10 @@ export const useKeyboard = ({
95
343
  useEditor.getState().setPhase('structure')
96
344
  useEditor.getState().setStructureLayer('elements')
97
345
  useEditor.getState().setMode('build')
98
- } else if (e.key === 'd' && !e.metaKey && !e.ctrlKey) {
346
+ // Set the wall tool explicitly so B never inherits a stale tool
347
+ // (e.g. fence) left over from a prior build session.
348
+ useEditor.getState().setTool('wall')
349
+ } else if (e.key === 'x' && !e.metaKey && !e.ctrlKey) {
99
350
  if (isVersionPreviewMode) return
100
351
  e.preventDefault()
101
352
  useEditor.getState().setMode('delete')
@@ -106,26 +357,45 @@ export const useKeyboard = ({
106
357
  useEditor.getState().setPhase('structure')
107
358
  useEditor.getState().setStructureLayer('elements')
108
359
  useEditor.getState().setMode('material-paint')
109
- } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
360
+ } else if (e.key === 'c' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
110
361
  if (isVersionPreviewMode) return
111
362
  e.preventDefault()
112
- runUndo()
113
- } else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
363
+ copySelectedNodesToEditorClipboard()
364
+ } else if (e.key === 'v' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
114
365
  if (isVersionPreviewMode) return
115
366
  e.preventDefault()
367
+ const result = pasteEditorClipboardToLevel()
368
+ if (result?.pastedIds.length) {
369
+ sfxEmitter.emit('sfx:item-place')
370
+ }
371
+ } else if (e.key.toLowerCase() === 'z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
372
+ if (isVersionPreviewMode) return
373
+ e.preventDefault()
374
+ if (cancelInteractionForHistoryShortcut()) return
116
375
  runRedo()
376
+ } else if (e.key.toLowerCase() === 'z' && !e.shiftKey && (e.metaKey || e.ctrlKey)) {
377
+ if (isVersionPreviewMode) return
378
+ e.preventDefault()
379
+ if (cancelInteractionForHistoryShortcut()) return
380
+ runUndo()
117
381
  } else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) {
118
382
  e.preventDefault()
119
383
  const { buildingId, levelId } = useViewer.getState().selection
120
384
  if (buildingId) {
121
385
  const building = useScene.getState().nodes[buildingId]
122
- if (building && building.type === 'building' && building.children.length > 0) {
123
- const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1
124
- const nextIdx = currentIdx < building.children.length - 1 ? currentIdx + 1 : currentIdx
386
+ const levels =
387
+ building?.type === 'building'
388
+ ? building.children.filter(
389
+ (childId) => useScene.getState().nodes[childId as AnyNodeId]?.type === 'level',
390
+ )
391
+ : []
392
+ if (levels.length > 0) {
393
+ const currentIdx = levelId ? levels.indexOf(levelId as any) : -1
394
+ const nextIdx = currentIdx < levels.length - 1 ? currentIdx + 1 : currentIdx
125
395
  if (nextIdx !== -1 && nextIdx !== currentIdx) {
126
- useViewer.getState().setSelection({ levelId: building.children[nextIdx] as any })
396
+ useViewer.getState().setSelection({ levelId: levels[nextIdx] as any })
127
397
  } else if (currentIdx === -1) {
128
- useViewer.getState().setSelection({ levelId: building.children[0] as any })
398
+ useViewer.getState().setSelection({ levelId: levels[0] as any })
129
399
  }
130
400
  }
131
401
  }
@@ -134,70 +404,192 @@ export const useKeyboard = ({
134
404
  const { buildingId, levelId } = useViewer.getState().selection
135
405
  if (buildingId) {
136
406
  const building = useScene.getState().nodes[buildingId]
137
- if (building && building.type === 'building' && building.children.length > 0) {
138
- const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1
407
+ const levels =
408
+ building?.type === 'building'
409
+ ? building.children.filter(
410
+ (childId) => useScene.getState().nodes[childId as AnyNodeId]?.type === 'level',
411
+ )
412
+ : []
413
+ if (levels.length > 0) {
414
+ const currentIdx = levelId ? levels.indexOf(levelId as any) : -1
139
415
  const prevIdx = currentIdx > 0 ? currentIdx - 1 : currentIdx
140
416
  if (prevIdx !== -1 && prevIdx !== currentIdx) {
141
- useViewer.getState().setSelection({ levelId: building.children[prevIdx] as any })
417
+ useViewer.getState().setSelection({ levelId: levels[prevIdx] as any })
142
418
  } else if (currentIdx === -1) {
143
- useViewer
144
- .getState()
145
- .setSelection({ levelId: building.children[building.children.length - 1] as any })
419
+ useViewer.getState().setSelection({ levelId: levels[levels.length - 1] as any })
146
420
  }
147
421
  }
148
422
  }
149
- } else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode) {
423
+ } else if (
424
+ (e.key === 'r' || e.key === 'R') &&
425
+ !e.metaKey &&
426
+ !e.ctrlKey &&
427
+ !isVersionPreviewMode &&
428
+ !isPlacingOpening()
429
+ ) {
430
+ // `!metaKey && !ctrlKey` lets Cmd/Ctrl+R reach the browser reload instead
431
+ // of rotating/flipping the selected node.
150
432
  // Rotate selected node clockwise if it supports rotation (items, roofs, etc.)
151
- // Operable doors/windows use R to toggle their open/closed state.
433
+ // Doors use R to flip side (front ↔ back, rotation += π); their
434
+ // open/close toggle lives on E. Windows still use R to toggle
435
+ // their open/closed state.
436
+ //
437
+ // Skipped entirely while a door/window placement is active
438
+ // (`isPlacingOpening`): the placement tool owns R then (flip the draft
439
+ // before commit), and the user can have a node selected at the same
440
+ // time — without this guard both would fire (double flip + sfx).
441
+ //
442
+ // References (guide/scan) live in `selectedReferenceId`, not the viewer
443
+ // selection — check them first, like the Delete arm below.
444
+ //
445
+ // Multi-selection branches to the group rotate before any of the
446
+ // single-selection arms (reference, door/window flip, registry
447
+ // keyboardActions, plain rotate) — those stay single-selection-only.
448
+ if (rotateGroupSelection(1)) {
449
+ e.preventDefault()
450
+ return
451
+ }
452
+ const rotatableReference = getRotatableSelectedReference()
453
+ if (rotatableReference) {
454
+ e.preventDefault()
455
+ useScene.getState().updateNode(rotatableReference.id, {
456
+ rotation: [
457
+ rotatableReference.rotation[0],
458
+ steppedRotation(rotatableReference.rotation[1], 1),
459
+ rotatableReference.rotation[2],
460
+ ],
461
+ })
462
+ sfxEmitter.emit('sfx:item-rotate')
463
+ return
464
+ }
152
465
  const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
153
466
  if (selectedNodeIds.length === 1) {
154
- const node = useScene.getState().nodes[selectedNodeIds[0]!]
467
+ const sceneNodes = useScene.getState().nodes
468
+ const selectedNode = sceneNodes[selectedNodeIds[0]!]
469
+ const node = selectedNode ? resolveDirectManipulationNode(selectedNode, sceneNodes) : null
155
470
  if (node?.type === 'door') {
156
471
  e.preventDefault()
157
- if (node.openingKind !== 'opening') {
158
- toggleDoorOpenState(node.id)
159
- sfxEmitter.emit('sfx:item-rotate')
472
+ useScene.getState().updateNode(node.id, {
473
+ side: node.side === 'front' ? 'back' : 'front',
474
+ rotation: [node.rotation[0], node.rotation[1] + Math.PI, node.rotation[2]],
475
+ })
476
+ if (node.parentId) {
477
+ useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
160
478
  }
161
- } else if (
162
- node?.type === 'window' &&
163
- node.openingKind !== 'opening' &&
164
- (node.windowType === 'sliding' ||
165
- node.windowType === 'casement' ||
166
- node.windowType === 'awning' ||
167
- node.windowType === 'hopper' ||
168
- node.windowType === 'single-hung' ||
169
- node.windowType === 'double-hung' ||
170
- node.windowType === 'louvered')
171
- ) {
479
+ sfxEmitter.emit('sfx:item-rotate')
480
+ } else if (node?.type === 'window') {
481
+ // Windows: R flips side (front ↔ back, rotation += π). Open/
482
+ // close toggle for operable windows lives on E.
172
483
  e.preventDefault()
173
- toggleWindowOpenState(node.id)
484
+ useScene.getState().updateNode(node.id, {
485
+ side: node.side === 'front' ? 'back' : 'front',
486
+ rotation: [node.rotation[0], node.rotation[1] + Math.PI, node.rotation[2]],
487
+ })
488
+ if (node.parentId) {
489
+ useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
490
+ }
491
+ sfxEmitter.emit('sfx:item-rotate')
492
+ } else if (node && nodeRegistry.get(node.type)?.keyboardActions?.r?.appliesTo(node)) {
493
+ // Registry-driven R action. Skylight uses this for open/
494
+ // close toggling; future kinds with custom R behaviour
495
+ // declare it on their `def.keyboardActions` without
496
+ // touching this hook. Door / window still use the legacy
497
+ // direct calls above (follow-up to migrate).
498
+ e.preventDefault()
499
+ nodeRegistry.get(node.type)?.keyboardActions?.r?.run(node)
174
500
  sfxEmitter.emit('sfx:item-rotate')
175
501
  } else if (node && 'rotation' in node) {
176
502
  e.preventDefault()
177
- const ROTATION_STEP = Math.PI / 4
178
-
179
- // Handle different rotation types (number for roof, array for items/windows/doors)
503
+ // Round to the nearest 45° then step one increment (not a blind +45°).
180
504
  if (typeof node.rotation === 'number') {
181
- useScene.getState().updateNode(node.id, { rotation: node.rotation + ROTATION_STEP })
505
+ useScene
506
+ .getState()
507
+ .updateNode(node.id, { rotation: steppedRotation(node.rotation, 1) })
182
508
  } else if (Array.isArray(node.rotation)) {
183
509
  useScene.getState().updateNode(node.id, {
184
- rotation: [node.rotation[0], node.rotation[1] + ROTATION_STEP, node.rotation[2]],
510
+ rotation: [
511
+ node.rotation[0],
512
+ steppedRotation(node.rotation[1], 1),
513
+ node.rotation[2],
514
+ ],
185
515
  })
186
516
  }
187
517
  sfxEmitter.emit('sfx:item-rotate')
188
518
  }
189
519
  }
190
- } else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode) {
520
+ } else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode && !isPlacingOpening()) {
191
521
  // Rotate selected node counter-clockwise
522
+ // Multi-selection → group rotate, mirroring the R arm above.
523
+ if (rotateGroupSelection(-1)) {
524
+ e.preventDefault()
525
+ return
526
+ }
527
+ const rotatableReference = getRotatableSelectedReference()
528
+ if (rotatableReference) {
529
+ e.preventDefault()
530
+ useScene.getState().updateNode(rotatableReference.id, {
531
+ rotation: [
532
+ rotatableReference.rotation[0],
533
+ steppedRotation(rotatableReference.rotation[1], -1),
534
+ rotatableReference.rotation[2],
535
+ ],
536
+ })
537
+ sfxEmitter.emit('sfx:item-rotate')
538
+ return
539
+ }
192
540
  const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
193
541
  if (selectedNodeIds.length === 1) {
194
- const node = useScene.getState().nodes[selectedNodeIds[0]!]
542
+ const sceneNodes = useScene.getState().nodes
543
+ const selectedNode = sceneNodes[selectedNodeIds[0]!]
544
+ const node = selectedNode ? resolveDirectManipulationNode(selectedNode, sceneNodes) : null
195
545
  if (node?.type === 'door') {
546
+ // Door's open/close moved to E; T is a no-op for doors so
547
+ // it doesn't free-rotate a wall-bound node by π/4.
548
+ e.preventDefault()
549
+ } else if (node?.type === 'window') {
550
+ // Window's open/close moved to E; T is a no-op so it doesn't
551
+ // free-rotate a wall-bound node by π/4.
552
+ e.preventDefault()
553
+ } else if (node && nodeRegistry.get(node.type)?.keyboardActions?.t?.appliesTo(node)) {
554
+ // Registry-driven T action. Same shape as the R arm above.
196
555
  e.preventDefault()
197
- if (node.openingKind !== 'opening') {
198
- closeDoorOpenState(node.id)
199
- sfxEmitter.emit('sfx:item-rotate')
556
+ nodeRegistry.get(node.type)?.keyboardActions?.t?.run(node)
557
+ sfxEmitter.emit('sfx:item-rotate')
558
+ } else if (node && 'rotation' in node) {
559
+ e.preventDefault()
560
+ // Round to the nearest 45° then step one increment back.
561
+ if (typeof node.rotation === 'number') {
562
+ useScene
563
+ .getState()
564
+ .updateNode(node.id, { rotation: steppedRotation(node.rotation, -1) })
565
+ } else if (Array.isArray(node.rotation)) {
566
+ useScene.getState().updateNode(node.id, {
567
+ rotation: [
568
+ node.rotation[0],
569
+ steppedRotation(node.rotation[1], -1),
570
+ node.rotation[2],
571
+ ],
572
+ })
200
573
  }
574
+ sfxEmitter.emit('sfx:item-rotate')
575
+ }
576
+ }
577
+ } else if ((e.key === 'e' || e.key === 'E') && !isVersionPreviewMode) {
578
+ // Toggle door / operable-window open/closed state. Moved off R,
579
+ // which now flips the opening (side + π rotation).
580
+ const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
581
+ if (selectedNodeIds.length === 1) {
582
+ const node = useScene.getState().nodes[selectedNodeIds[0]!]
583
+ const registryE = node && nodeRegistry.get(node.type)?.keyboardActions?.e
584
+ if (node && registryE?.appliesTo(node)) {
585
+ // Registry-driven E interaction. Same shape as the R/T arms.
586
+ e.preventDefault()
587
+ registryE.run(node)
588
+ sfxEmitter.emit('sfx:item-rotate')
589
+ } else if (node?.type === 'door' && node.openingKind !== 'opening') {
590
+ e.preventDefault()
591
+ toggleDoorOpenState(node.id)
592
+ sfxEmitter.emit('sfx:item-rotate')
201
593
  } else if (
202
594
  node?.type === 'window' &&
203
595
  node.openingKind !== 'opening' &&
@@ -210,19 +602,7 @@ export const useKeyboard = ({
210
602
  node.windowType === 'louvered')
211
603
  ) {
212
604
  e.preventDefault()
213
- closeWindowOpenState(node.id)
214
- sfxEmitter.emit('sfx:item-rotate')
215
- } else if (node && 'rotation' in node) {
216
- e.preventDefault()
217
- const ROTATION_STEP = Math.PI / 4
218
-
219
- if (typeof node.rotation === 'number') {
220
- useScene.getState().updateNode(node.id, { rotation: node.rotation - ROTATION_STEP })
221
- } else if (Array.isArray(node.rotation)) {
222
- useScene.getState().updateNode(node.id, {
223
- rotation: [node.rotation[0], node.rotation[1] - ROTATION_STEP, node.rotation[2]],
224
- })
225
- }
605
+ toggleWindowOpenState(node.id)
226
606
  sfxEmitter.emit('sfx:item-rotate')
227
607
  }
228
608
  }
@@ -241,15 +621,6 @@ export const useKeyboard = ({
241
621
  }
242
622
  }
243
623
 
244
- // Delete selected zone
245
- const selectedZoneId = useViewer.getState().selection.zoneId
246
- if (selectedZoneId) {
247
- sfxEmitter.emit('sfx:structure-delete')
248
- useScene.getState().deleteNode(selectedZoneId as AnyNodeId)
249
- useViewer.getState().setSelection({ zoneId: null })
250
- return
251
- }
252
-
253
624
  const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
254
625
 
255
626
  if (selectedNodeIds.length > 0) {
@@ -265,21 +636,48 @@ export const useKeyboard = ({
265
636
  // Play appropriate SFX based on what's being deleted
266
637
  if (selectedNodeIds.length === 1) {
267
638
  const node = useScene.getState().nodes[selectedNodeIds[0]!]
268
- if (node?.type === 'item') {
269
- sfxEmitter.emit('sfx:item-delete')
270
- } else {
271
- sfxEmitter.emit('sfx:structure-delete')
272
- }
639
+ emitDeleteSFX(node?.type)
273
640
  } else {
274
641
  sfxEmitter.emit('sfx:structure-delete')
275
642
  }
276
643
 
277
644
  useScene.getState().deleteNodes(selectedNodeIds)
645
+ return
278
646
  }
647
+
648
+ // Delete selected zone when no explicit element selection is active.
649
+ const selectedZoneId = useViewer.getState().selection.zoneId
650
+ if (selectedZoneId) {
651
+ sfxEmitter.emit('sfx:structure-delete')
652
+ useScene.getState().deleteNode(selectedZoneId as AnyNodeId)
653
+ useViewer.getState().setSelection({ zoneId: null })
654
+ }
655
+ }
656
+ }
657
+ const handleKeyUp = (e: KeyboardEvent) => {
658
+ if (e.key === 'Control' || e.key === 'Meta') {
659
+ const wasClean = ctrlTapClean
660
+ ctrlTapClean = false
661
+ if (!wasClean) return
662
+ // Same scope as the Shift snapping-mode cycle, and never while typing
663
+ // in an input.
664
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
665
+ return
666
+ }
667
+ if (!isSnappingCycleContext()) return
668
+ // Cycle the grid / measurement step (0.5 → 0.25 → 0.1 → 0.05).
669
+ useEditor.getState().cycleGridSnapStep()
670
+ sfxEmitter.emit('sfx:grid-snap')
671
+ return
279
672
  }
280
673
  }
674
+
281
675
  window.addEventListener('keydown', handleKeyDown)
282
- return () => window.removeEventListener('keydown', handleKeyDown)
676
+ window.addEventListener('keyup', handleKeyUp)
677
+ return () => {
678
+ window.removeEventListener('keydown', handleKeyDown)
679
+ window.removeEventListener('keyup', handleKeyUp)
680
+ }
283
681
  }, [disabled, isVersionPreviewMode])
284
682
 
285
683
  return null