@pascal-app/editor 1.0.0-beta.4 → 1.0.0

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 (283) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +15 -8
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
  6. package/src/components/editor/bake-exporter.tsx +1 -0
  7. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  8. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  9. package/src/components/editor/capture-camera-rig.tsx +59 -0
  10. package/src/components/editor/custom-camera-controls.tsx +20 -11
  11. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  12. package/src/components/editor/editor-layout-v2.tsx +17 -5
  13. package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
  14. package/src/components/editor/export-manager.tsx +175 -33
  15. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  16. package/src/components/editor/first-person-controls.tsx +182 -57
  17. package/src/components/editor/floating-action-menu.tsx +55 -55
  18. package/src/components/editor/floorplan-background-selection.test.ts +31 -4
  19. package/src/components/editor/floorplan-background-selection.ts +27 -7
  20. package/src/components/editor/floorplan-panel.tsx +81 -57
  21. package/src/components/editor/grid.tsx +12 -7
  22. package/src/components/editor/group-actions.ts +34 -50
  23. package/src/components/editor/group-floating-action-menu.tsx +30 -15
  24. package/src/components/editor/group-move-3d.ts +51 -21
  25. package/src/components/editor/group-transform-shared.test.ts +78 -0
  26. package/src/components/editor/group-transform-shared.ts +74 -7
  27. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  28. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  29. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  30. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  31. package/src/components/editor/handles/resize-snap.ts +10 -1
  32. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  33. package/src/components/editor/index.tsx +252 -46
  34. package/src/components/editor/measurement-pill.tsx +12 -12
  35. package/src/components/editor/node-action-menu.tsx +42 -1
  36. package/src/components/editor/node-arrow-handles.tsx +47 -58
  37. package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
  38. package/src/components/editor/selection-manager.tsx +132 -47
  39. package/src/components/editor/site-edge-labels.tsx +2 -1
  40. package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
  41. package/src/components/editor/snapshot-capture.test.ts +422 -0
  42. package/src/components/editor/snapshot-capture.ts +145 -0
  43. package/src/components/editor/thumbnail-generator.tsx +295 -232
  44. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  45. package/src/components/editor/wall-measurement-label.tsx +3 -2
  46. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  47. package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
  48. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  49. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  50. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  51. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  52. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  53. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  54. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +204 -114
  57. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  59. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  60. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  61. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  62. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  63. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  64. package/src/components/systems/selection-affordance-services.ts +19 -0
  65. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  66. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  67. package/src/components/tools/fence/fence-drafting.ts +6 -25
  68. package/src/components/tools/item/draft-creation.test.ts +12 -0
  69. package/src/components/tools/item/draft-creation.ts +10 -0
  70. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  71. package/src/components/tools/item/face-host-commit.ts +56 -0
  72. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  73. package/src/components/tools/item/face-host-preview.ts +82 -0
  74. package/src/components/tools/item/move-tool.tsx +4 -3
  75. package/src/components/tools/item/placement-math.test.ts +11 -1
  76. package/src/components/tools/item/placement-math.ts +0 -20
  77. package/src/components/tools/item/placement-strategies.test.ts +494 -0
  78. package/src/components/tools/item/placement-strategies.ts +192 -24
  79. package/src/components/tools/item/placement-surface.test.ts +61 -0
  80. package/src/components/tools/item/placement-surface.ts +24 -0
  81. package/src/components/tools/item/placement-types.ts +6 -0
  82. package/src/components/tools/item/test-face-host.ts +121 -0
  83. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  84. package/src/components/tools/item/use-draft-node.ts +26 -4
  85. package/src/components/tools/item/use-placement-coordinator.tsx +447 -180
  86. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  87. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  88. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  89. package/src/components/tools/registry-tool-context.tsx +30 -0
  90. package/src/components/tools/select/box-select-tool.tsx +3 -1
  91. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  92. package/src/components/tools/select/marquee-footprint.ts +29 -0
  93. package/src/components/tools/shared/placement-box.tsx +63 -8
  94. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  95. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  96. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  97. package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
  98. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  99. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  100. package/src/components/tools/stair/stair-tool.tsx +127 -74
  101. package/src/components/tools/tool-manager.tsx +25 -8
  102. package/src/components/tools/wall/wall-drafting.test.ts +527 -4
  103. package/src/components/tools/wall/wall-drafting.ts +55 -462
  104. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  105. package/src/components/ui/action-menu/index.tsx +11 -1
  106. package/src/components/ui/command-palette/editor-commands.tsx +12 -13
  107. package/src/components/ui/command-palette/index.tsx +7 -2
  108. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  109. package/src/components/ui/controls/material-picker.tsx +4 -4
  110. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  111. package/src/components/ui/controls/segmented-control.tsx +8 -1
  112. package/src/components/ui/controls/slider-control.tsx +19 -3
  113. package/src/components/ui/controls/toggle-control.tsx +18 -5
  114. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  115. package/src/components/ui/floating-level-selector.tsx +23 -6
  116. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  117. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  118. package/src/components/ui/helpers/item-helper.tsx +1 -1
  119. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  120. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  121. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  122. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  123. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  124. package/src/components/ui/panels/multi-field-value.ts +163 -0
  125. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  126. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  127. package/src/components/ui/panels/multi-selection-panel.tsx +78 -26
  128. package/src/components/ui/panels/node-display.ts +4 -0
  129. package/src/components/ui/panels/panel-manager.tsx +77 -3
  130. package/src/components/ui/panels/panel-wrapper.tsx +228 -12
  131. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  132. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  133. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  134. package/src/components/ui/panels/reference-panel.tsx +31 -1
  135. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  136. package/src/components/ui/scene-loader.tsx +35 -0
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
  138. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  139. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -23
  140. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  142. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  143. package/src/components/ui/sidebar/panels/site-panel/index.tsx +128 -46
  144. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  145. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  146. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  147. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +22 -7
  148. package/src/components/ui/sidebar/tab-bar.tsx +9 -1
  149. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  150. package/src/components/viewer/floorplan-compass-button.tsx +50 -0
  151. package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
  152. package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
  153. package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
  154. package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
  155. package/src/components/viewer/floorplan-preview.tsx +1069 -0
  156. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
  157. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  158. package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
  159. package/src/components/viewer/viewer-stage-modes.ts +49 -0
  160. package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
  161. package/src/components/viewer/viewer-stage.test.tsx +47 -0
  162. package/src/components/viewer/viewer-stage.tsx +221 -0
  163. package/src/components/viewer-overlay.tsx +13 -30
  164. package/src/components/viewer-zone-system.tsx +6 -2
  165. package/src/components/walkthrough-hud.tsx +19 -10
  166. package/src/hooks/use-auto-save.test.ts +122 -1
  167. package/src/hooks/use-auto-save.ts +119 -13
  168. package/src/hooks/use-ceiling-events.test.ts +122 -0
  169. package/src/hooks/use-drag-action.ts +17 -3
  170. package/src/hooks/use-grid-events.test.ts +240 -0
  171. package/src/hooks/use-grid-events.ts +190 -11
  172. package/src/hooks/use-keyboard.test.ts +244 -0
  173. package/src/hooks/use-keyboard.ts +164 -63
  174. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  175. package/src/hooks/use-save-shortcut.ts +33 -0
  176. package/src/index.tsx +109 -4
  177. package/src/lib/active-placement-surface.test.ts +16 -0
  178. package/src/lib/active-placement-surface.ts +14 -0
  179. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  180. package/src/lib/ceiling-surface-raycast.ts +12 -0
  181. package/src/lib/contextual-help-extension.ts +17 -0
  182. package/src/lib/contextual-help.test.ts +8 -0
  183. package/src/lib/contextual-help.ts +8 -0
  184. package/src/lib/continuation.test.ts +11 -0
  185. package/src/lib/continuation.ts +8 -1
  186. package/src/lib/direct-manipulation.test.ts +132 -0
  187. package/src/lib/direct-manipulation.ts +38 -1
  188. package/src/lib/editor-api.ts +6 -3
  189. package/src/lib/elevation-guides.test.ts +28 -1
  190. package/src/lib/elevation-guides.ts +22 -7
  191. package/src/lib/floating-menu-scale.test.ts +29 -0
  192. package/src/lib/floating-menu-scale.ts +14 -0
  193. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  194. package/src/lib/floorplan/apply-alignment.ts +10 -1
  195. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  196. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  197. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  198. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  199. package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
  200. package/src/lib/floorplan/floorplan-readonly.ts +85 -0
  201. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  202. package/src/lib/floorplan-grid-event-point.ts +13 -0
  203. package/src/lib/glb-export.test.ts +1169 -21
  204. package/src/lib/glb-export.ts +629 -101
  205. package/src/lib/grid-event-presentation.ts +16 -0
  206. package/src/lib/history.test.ts +523 -77
  207. package/src/lib/history.ts +133 -14
  208. package/src/lib/host-tree-children.test.ts +27 -0
  209. package/src/lib/host-tree-children.ts +79 -0
  210. package/src/lib/inspector-card-mode.test.ts +92 -0
  211. package/src/lib/inspector-card-mode.ts +60 -0
  212. package/src/lib/interaction/hot-set.test.ts +10 -1
  213. package/src/lib/interaction/hot-set.ts +3 -1
  214. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  215. package/src/lib/interaction/overlay-policy.ts +16 -0
  216. package/src/lib/interaction/registered-drafting.ts +36 -0
  217. package/src/lib/interaction/scope.ts +31 -0
  218. package/src/lib/level-print-export.test.ts +484 -0
  219. package/src/lib/level-print-export.ts +526 -0
  220. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  221. package/src/lib/local-project-presentation-persistence.ts +335 -0
  222. package/src/lib/material-paint.ts +2 -0
  223. package/src/lib/measurement-parser.ts +3 -0
  224. package/src/lib/measurements.test.ts +4 -0
  225. package/src/lib/model-export.ts +29 -0
  226. package/src/lib/paint-preview-owner.ts +71 -0
  227. package/src/lib/paint-scope.test.ts +58 -2
  228. package/src/lib/paint-scope.ts +8 -2
  229. package/src/lib/planar-cursor-placement.test.ts +178 -1
  230. package/src/lib/planar-cursor-placement.ts +61 -5
  231. package/src/lib/plugin-panels.test.ts +92 -1
  232. package/src/lib/plugin-panels.ts +48 -0
  233. package/src/lib/portable-export.test.ts +366 -0
  234. package/src/lib/portable-export.ts +969 -0
  235. package/src/lib/print-3mf.test.ts +86 -0
  236. package/src/lib/print-3mf.ts +161 -0
  237. package/src/lib/print-content-scope.test.ts +107 -0
  238. package/src/lib/print-content-scope.ts +90 -0
  239. package/src/lib/print-export.test.ts +294 -0
  240. package/src/lib/print-export.ts +735 -0
  241. package/src/lib/print-feature-thickness.test.ts +147 -0
  242. package/src/lib/print-feature-thickness.ts +180 -0
  243. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  244. package/src/lib/print-golden-house.test.ts +249 -0
  245. package/src/lib/print-roof-solids.test.ts +133 -0
  246. package/src/lib/print-roof-solids.ts +630 -0
  247. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  248. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  249. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  250. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  251. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  252. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  253. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  254. package/src/lib/print-shell-compiler.ts +273 -0
  255. package/src/lib/print-wall-solids.test.ts +194 -0
  256. package/src/lib/print-wall-solids.ts +328 -0
  257. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  258. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  259. package/src/lib/scene.ts +22 -8
  260. package/src/lib/selection-routing.test.ts +125 -6
  261. package/src/lib/selection-routing.ts +34 -1
  262. package/src/lib/session-groups.test.ts +88 -0
  263. package/src/lib/session-groups.ts +201 -0
  264. package/src/lib/sfx-bus.ts +4 -0
  265. package/src/lib/sfx-player.ts +8 -0
  266. package/src/lib/snapping-mode.test.ts +32 -4
  267. package/src/lib/snapping-mode.ts +11 -7
  268. package/src/lib/structured-clone-fallback.test.ts +45 -0
  269. package/src/lib/structured-clone-fallback.ts +24 -0
  270. package/src/lib/usdz-export.ts +38 -0
  271. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  272. package/src/store/live-draft-preview-stores.test.ts +3 -0
  273. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  274. package/src/store/tool-mode.test.ts +87 -0
  275. package/src/store/use-camera-hint-focus.ts +48 -0
  276. package/src/store/use-editor.tsx +299 -188
  277. package/src/store/use-floorplan-draft-preview.ts +9 -0
  278. package/src/store/use-interaction-scope.test.ts +23 -1
  279. package/src/store/use-interaction-scope.ts +63 -2
  280. package/src/store/use-placement-preview.ts +56 -3
  281. package/src/store/use-session-groups.ts +103 -0
  282. package/src/components/tools/roof/roof-tool.tsx +0 -656
  283. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -0,0 +1,244 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeId,
5
+ BlockNode,
6
+ clearSceneHistory,
7
+ DuctFittingNode,
8
+ DuctSegmentNode,
9
+ emitter,
10
+ nodeRegistry,
11
+ PipeFittingNode,
12
+ PipeSegmentNode,
13
+ useScene,
14
+ } from '@pascal-app/core'
15
+ import { runRedo, runUndo } from '../lib/history'
16
+ import { meshEditScope } from '../lib/interaction/scope'
17
+ import useEditor from '../store/use-editor'
18
+ import useInteractionScope from '../store/use-interaction-scope'
19
+ import {
20
+ blocksSnappingShortcut,
21
+ canCycleSnappingModeShortcut,
22
+ canRunGlobalRotationShortcut,
23
+ isToolOwnedCanopyForm,
24
+ isToolOwnedRotation,
25
+ markToolCancelConsumed,
26
+ runHistoryShortcut,
27
+ } from './use-keyboard'
28
+
29
+ type RafFn = (callback: (time: number) => void) => number
30
+ ;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (
31
+ callback,
32
+ ) => {
33
+ callback(0)
34
+ return 0
35
+ }
36
+ ;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??=
37
+ () => {}
38
+
39
+ const NODE_ID = 'block_history' as AnyNodeId
40
+
41
+ describe('snapping shortcuts while entering a run length', () => {
42
+ test('allows snap mode and spacing shortcuts in the duct and pipe length field', () => {
43
+ expect(
44
+ blocksSnappingShortcut({
45
+ tagName: 'INPUT',
46
+ isContentEditable: false,
47
+ hasAttribute: (name) => name === 'data-run-length-input',
48
+ }),
49
+ ).toBe(false)
50
+ })
51
+
52
+ test('continues to protect ordinary text fields and editable content', () => {
53
+ for (const tagName of ['INPUT', 'TEXTAREA', 'DIV']) {
54
+ expect(
55
+ blocksSnappingShortcut({
56
+ tagName,
57
+ isContentEditable: tagName === 'DIV',
58
+ hasAttribute: () => false,
59
+ }),
60
+ ).toBe(true)
61
+ }
62
+ expect(blocksSnappingShortcut(null)).toBe(false)
63
+ })
64
+ })
65
+
66
+ beforeEach(() => {
67
+ const node = BlockNode.parse({ id: NODE_ID, position: [0, 0, 0] })
68
+ useScene.setState({
69
+ nodes: { [NODE_ID]: node },
70
+ rootNodeIds: [NODE_ID],
71
+ dirtyNodes: new Set<AnyNodeId>(),
72
+ collections: {},
73
+ materials: {},
74
+ readOnly: false,
75
+ } as never)
76
+ clearSceneHistory()
77
+ useScene.getState().updateNode(NODE_ID, { position: [1, 2, 3] } as Partial<AnyNode>)
78
+ })
79
+
80
+ afterEach(() => {
81
+ useInteractionScope.getState().end()
82
+ useEditor.getState().armToolMode({ mode: 'select' })
83
+ useEditor.setState({ selectedItem: null })
84
+ clearSceneHistory()
85
+ })
86
+
87
+ describe('rotation shortcut ownership', () => {
88
+ test('does not reserve R and T for an armed item tool without a placement item', () => {
89
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'item' })
90
+
91
+ expect(isToolOwnedRotation()).toBe(false)
92
+ })
93
+
94
+ test('leaves R and T to the active item placement tool', () => {
95
+ useEditor.getState().setSelectedItem({
96
+ asset: { id: 'asset:test' },
97
+ } as never)
98
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'item' })
99
+
100
+ expect(isToolOwnedRotation()).toBe(true)
101
+ })
102
+
103
+ test('leaves R and T to the active lean-to placement tool', () => {
104
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'lean-to-extension' })
105
+
106
+ expect(isToolOwnedRotation()).toBe(true)
107
+ })
108
+
109
+ test('leaves R and T to a moving lean-to extension', () => {
110
+ const leanTo = { id: 'lean_to_moving', type: 'lean-to-extension' } as unknown as AnyNode
111
+ useInteractionScope.getState().begin({
112
+ kind: 'moving',
113
+ node: leanTo,
114
+ nodeId: leanTo.id,
115
+ nodeType: leanTo.type,
116
+ view: '3d',
117
+ })
118
+
119
+ expect(isToolOwnedRotation()).toBe(true)
120
+ })
121
+
122
+ test('leaves F to the active lean-to placement tool', () => {
123
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'lean-to-extension' })
124
+
125
+ expect(isToolOwnedCanopyForm()).toBe(true)
126
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'wall' })
127
+ expect(isToolOwnedCanopyForm()).toBe(false)
128
+ })
129
+ })
130
+
131
+ describe('history shortcuts during block editing', () => {
132
+ test('reserves global rotation shortcuts for the active mesh editor', () => {
133
+ expect(canRunGlobalRotationShortcut()).toBe(true)
134
+ useInteractionScope.getState().begin(meshEditScope(NODE_ID))
135
+ expect(canRunGlobalRotationShortcut()).toBe(false)
136
+ })
137
+
138
+ test('keeps Shift available to cycle snapping while a mesh operation is active', () => {
139
+ useInteractionScope.getState().begin(meshEditScope(NODE_ID))
140
+ expect(canCycleSnappingModeShortcut(true)).toBe(true)
141
+
142
+ useInteractionScope.getState().begin(meshEditScope(NODE_ID, 'operating', 'translate'))
143
+ expect(canCycleSnappingModeShortcut(true)).toBe(true)
144
+ })
145
+
146
+ test('undoes and redoes mesh changes without leaving component selection mode', () => {
147
+ useInteractionScope.getState().begin(meshEditScope(NODE_ID))
148
+
149
+ expect(runHistoryShortcut('undo')).toBe(true)
150
+ expect((useScene.getState().nodes[NODE_ID] as BlockNode).position).toEqual([0, 0, 0])
151
+ expect(useInteractionScope.getState().scope).toEqual({
152
+ kind: 'mesh-editing',
153
+ nodeId: NODE_ID,
154
+ phase: 'selecting',
155
+ })
156
+
157
+ expect(runHistoryShortcut('redo')).toBe(true)
158
+ expect((useScene.getState().nodes[NODE_ID] as BlockNode).position).toEqual([1, 2, 3])
159
+ expect(useInteractionScope.getState().scope).toEqual({
160
+ kind: 'mesh-editing',
161
+ nodeId: NODE_ID,
162
+ phase: 'selecting',
163
+ })
164
+ })
165
+ })
166
+
167
+ describe('history while drawing distribution runs', () => {
168
+ let restoreRegistry = () => {}
169
+
170
+ beforeEach(() => {
171
+ restoreRegistry = nodeRegistry._snapshot()
172
+ nodeRegistry._reset()
173
+ for (const kind of ['duct-segment', 'pipe-segment']) {
174
+ nodeRegistry._register({
175
+ kind,
176
+ schemaVersion: 1,
177
+ drafting: { cancelOnHistoryJump: true },
178
+ } as never)
179
+ }
180
+ })
181
+
182
+ afterEach(() => {
183
+ restoreRegistry()
184
+ restoreRegistry = () => {}
185
+ })
186
+
187
+ for (const [kind, schema, fittingSchema] of [
188
+ ['duct', DuctSegmentNode, DuctFittingNode],
189
+ ['pipe', PipeSegmentNode, PipeFittingNode],
190
+ ] as const) {
191
+ test(`undoes and redoes individual ${kind} commits while drafting`, () => {
192
+ clearSceneHistory()
193
+ const first = schema.parse({
194
+ path: [
195
+ [0, 1, 0],
196
+ [1, 1, 0],
197
+ ],
198
+ })
199
+ const second = schema.parse({
200
+ path: [
201
+ [1, 1, 0],
202
+ [2, 1, 0],
203
+ ],
204
+ })
205
+ useScene.getState().applyNodeChanges({ create: [{ node: first }] })
206
+ const fitting = fittingSchema.parse({ position: [1, 1, 0] })
207
+ const trimmedPath: [number, number, number][] = [
208
+ [0, 1, 0],
209
+ [0.8, 1, 0],
210
+ ]
211
+ useScene.getState().applyNodeChanges({
212
+ create: [{ node: second }, { node: fitting }],
213
+ update: [{ id: first.id, data: { path: trimmedPath } }],
214
+ })
215
+ expect(useScene.temporal.getState().pastStates).toHaveLength(2)
216
+ useInteractionScope.getState().begin({ kind: 'drafting', tool: first.type })
217
+ let cancellations = 0
218
+ const cancel = () => {
219
+ cancellations += 1
220
+ markToolCancelConsumed()
221
+ }
222
+ emitter.on('tool:cancel', cancel)
223
+ try {
224
+ expect(runHistoryShortcut('undo')).toBe(true)
225
+ expect(useScene.getState().nodes[second.id]).toBeUndefined()
226
+ expect(useScene.getState().nodes[fitting.id]).toBeUndefined()
227
+ expect(useScene.getState().nodes[first.id]).toMatchObject({ path: first.path })
228
+ expect(runUndo().kind).toBe('applied')
229
+ expect(useScene.getState().nodes[first.id]).toBeUndefined()
230
+ expect(runRedo().kind).toBe('applied')
231
+ expect(useScene.getState().nodes[first.id]).toBeDefined()
232
+ expect(useScene.getState().nodes[second.id]).toBeUndefined()
233
+ expect(runHistoryShortcut('redo')).toBe(true)
234
+ expect(useScene.getState().nodes[second.id]).toBeDefined()
235
+ expect(useScene.getState().nodes[fitting.id]).toBeDefined()
236
+ expect(useScene.getState().nodes[first.id]).toMatchObject({ path: trimmedPath })
237
+ expect(cancellations).toBe(4)
238
+ expect(useInteractionScope.getState().scope).toEqual({ kind: 'drafting', tool: first.type })
239
+ } finally {
240
+ emitter.off('tool:cancel', cancel)
241
+ }
242
+ })
243
+ }
244
+ })
@@ -7,7 +7,7 @@ import {
7
7
  resumeSpaceDetection,
8
8
  useScene,
9
9
  } from '@pascal-app/core'
10
- import { useViewer } from '@pascal-app/viewer'
10
+ import { cancelPerfAction, markPerfAction, useViewer } from '@pascal-app/viewer'
11
11
  import { useEffect } from 'react'
12
12
  import { Vector3 } from 'three'
13
13
  import {
@@ -27,7 +27,7 @@ import { steppedRotation } from '../components/tools/item/placement-math'
27
27
  import { resolveDirectManipulationNode } from '../lib/direct-manipulation'
28
28
  import { toggleDoorOpenState } from '../lib/door-interaction'
29
29
  import { guideEmitter } from '../lib/guide-events'
30
- import { runRedo, runUndo } from '../lib/history'
30
+ import { isHistoryShortcut, runRedo, runUndo, shouldCancelDraftOnHistoryJump } from '../lib/history'
31
31
  import { isActive } from '../lib/interaction/scope'
32
32
  import { copySelectedNodesToEditorClipboard } from '../lib/scene-clipboard'
33
33
  import { sfxEmitter } from '../lib/sfx-bus'
@@ -36,6 +36,7 @@ import { toggleWindowOpenState } from '../lib/window-interaction'
36
36
  import useDeleteConfirmation from '../store/use-delete-confirmation'
37
37
  import useEditor, { getActiveContinuationContext, getActiveSnapContext } from '../store/use-editor'
38
38
  import useInteractionScope, { getMovingNode } from '../store/use-interaction-scope'
39
+ import { groupCurrentSelection, ungroupCurrentSelection } from '../store/use-session-groups'
39
40
 
40
41
  // References (guide/scan) are selected via `useEditor.selectedReferenceId`, not
41
42
  // the viewer selection, so selection-based key arms (R/T rotate) need this
@@ -101,6 +102,9 @@ function rotateGroupSelection(direction: 1 | -1): boolean {
101
102
  let _toolCancelConsumed = false
102
103
  export const markToolCancelConsumed = () => {
103
104
  _toolCancelConsumed = true
105
+ // A consumed cancel means the active gesture reverted — the perf ledger must
106
+ // not measure the restore as a committed action's settle.
107
+ cancelPerfAction()
104
108
  }
105
109
 
106
110
  // Escape's fall-through when no tool consumed the cancel: drop back to the
@@ -116,10 +120,10 @@ const exitToSelectAfterUnconsumedCancel = () => {
116
120
  // From zone mode, return to structure select
117
121
  if (currentPhase === 'structure' && currentStructureLayer === 'zones') {
118
122
  useEditor.getState().setStructureLayer('elements')
119
- useEditor.getState().setMode('select')
123
+ useEditor.getState().armToolMode({ mode: 'select' })
120
124
  } else {
121
125
  // Return to the default select tool while keeping the active building/level context.
122
- useEditor.getState().setMode('select')
126
+ useEditor.getState().armToolMode({ mode: 'select' })
123
127
  }
124
128
 
125
129
  useEditor.getState().setFloorplanSelectionTool('click')
@@ -140,6 +144,9 @@ const cancelInteractionForHistoryShortcut = () => {
140
144
  guideEmitter.emit('guide:cancel-reference-scale')
141
145
  return true
142
146
  }
147
+ const activeScope = useInteractionScope.getState().scope
148
+ if (shouldCancelDraftOnHistoryJump()) return false
149
+ if (activeScope.kind === 'mesh-editing' && activeScope.phase === 'selecting') return false
143
150
  _toolCancelConsumed = false
144
151
  emitter.emit('tool:cancel')
145
152
  if (_toolCancelConsumed) return true
@@ -161,6 +168,56 @@ const cancelInteractionForHistoryShortcut = () => {
161
168
  return false
162
169
  }
163
170
 
171
+ export const runHistoryShortcut = (direction: 'undo' | 'redo') => {
172
+ if (cancelInteractionForHistoryShortcut()) return false
173
+ if (direction === 'redo') runRedo()
174
+ else runUndo()
175
+ return true
176
+ }
177
+
178
+ export const isToolOwnedRotation = () => {
179
+ const editor = useEditor.getState()
180
+ const moving = getMovingNode()
181
+ if (
182
+ moving?.type === 'door' ||
183
+ moving?.type === 'window' ||
184
+ moving?.type === 'item' ||
185
+ moving?.type === 'lean-to-extension'
186
+ )
187
+ return true
188
+ return (
189
+ editor.mode === 'build' &&
190
+ (editor.tool === 'door' ||
191
+ editor.tool === 'window' ||
192
+ editor.tool === 'roof' ||
193
+ // The item tool is mounted for the build mode, but it only owns R/T
194
+ // when a catalog item is actually selected and a placement draft can
195
+ // exist. Without this check, selecting an existing item in the 2D plan
196
+ // while the item tool is armed silently drops the global rotate key.
197
+ (editor.tool === 'item' && editor.selectedItem !== null) ||
198
+ editor.tool === 'lean-to-extension')
199
+ )
200
+ }
201
+
202
+ export const isToolOwnedCanopyForm = () => {
203
+ const editor = useEditor.getState()
204
+ return editor.mode === 'build' && editor.tool === 'lean-to-extension'
205
+ }
206
+
207
+ export const canRunGlobalRotationShortcut = () =>
208
+ useInteractionScope.getState().scope.kind !== 'mesh-editing'
209
+
210
+ export const canCycleSnappingModeShortcut = (hasActiveContext = getActiveSnapContext() != null) =>
211
+ hasActiveContext
212
+
213
+ export function blocksSnappingShortcut(
214
+ target: Pick<HTMLElement, 'tagName' | 'isContentEditable' | 'hasAttribute'> | null,
215
+ ): boolean {
216
+ if (!target) return false
217
+ if (target.tagName === 'INPUT' && target.hasAttribute('data-run-length-input')) return false
218
+ return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable
219
+ }
220
+
164
221
  export const useKeyboard = ({
165
222
  isVersionPreviewMode = false,
166
223
  disabled = false,
@@ -173,32 +230,30 @@ export const useKeyboard = ({
173
230
  return
174
231
  }
175
232
 
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
-
233
+ // True while an active placement tool owns R/T. Door/window tools flip the
234
+ // draft, item / lean-to placement rotates its draft, and the roof tool turns
235
+ // its draft axes. The global selection handler must stand down to avoid double-firing.
187
236
  // Shift cycles the snapping mode (and a clean-tap Ctrl the grid step)
188
237
  // whenever there's an active snapping context — i.e. exactly when the HUD
189
238
  // shows a snapping chip. That single source covers wall/fence/item drafting,
190
239
  // every node move (including wall-hosted items + door/window openings, which
191
240
  // now declare `snapProfile`), and endpoint/polygon reshaping, so the keys
192
241
  // never silently stop working. Force-place lives on Alt where a tool supports it.
193
- const isSnappingCycleContext = () => getActiveSnapContext() != null
194
242
  // A "clean tap" of Ctrl/Meta (pressed and released with NO other key in
195
243
  // between) cycles the grid step — same context as the Shift snapping-mode
196
244
  // cycle. `ctrlTapClean` starts true the moment Ctrl/Meta goes down alone
197
245
  // and is cleared the instant any other key fires, so chords like Ctrl+Z /
198
246
  // Ctrl+C never cycle.
199
247
  let ctrlTapClean = false
248
+ let shiftTapClean = false
200
249
 
201
250
  const handleKeyDown = (e: KeyboardEvent) => {
251
+ if (e.key === 'Shift') {
252
+ shiftTapClean = !e.repeat && !e.metaKey && !e.ctrlKey && !e.altKey
253
+ } else {
254
+ shiftTapClean = false
255
+ }
256
+
202
257
  if (e.key === 'Control' || e.key === 'Meta') {
203
258
  // Only a fresh, modifier-free press starts a clean-tap candidate;
204
259
  // ignore key-repeat and presses already part of a combo.
@@ -209,8 +264,24 @@ export const useKeyboard = ({
209
264
  ctrlTapClean = false
210
265
  }
211
266
 
267
+ if (
268
+ shouldCancelDraftOnHistoryJump() &&
269
+ isHistoryShortcut(e) &&
270
+ e.target instanceof HTMLInputElement &&
271
+ e.target.hasAttribute('data-run-length-input')
272
+ ) {
273
+ if (isVersionPreviewMode || useDeleteConfirmation.getState().request) return
274
+ e.preventDefault()
275
+ runHistoryShortcut(e.shiftKey ? 'redo' : 'undo')
276
+ return
277
+ }
278
+
212
279
  // Don't handle shortcuts if user is typing in an input
213
- if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
280
+ if (
281
+ e.target instanceof HTMLInputElement ||
282
+ e.target instanceof HTMLTextAreaElement ||
283
+ (e.target instanceof HTMLElement && e.target.isContentEditable)
284
+ ) {
214
285
  return
215
286
  }
216
287
 
@@ -256,15 +327,6 @@ export const useKeyboard = ({
256
327
  return
257
328
  }
258
329
 
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
330
  if (
269
331
  (e.key === 't' || e.key === 'T') &&
270
332
  !e.repeat &&
@@ -333,32 +395,28 @@ export const useKeyboard = ({
333
395
  } else if (e.key === '1' && !e.metaKey && !e.ctrlKey) {
334
396
  e.preventDefault()
335
397
  useEditor.getState().setPhase('site')
336
- useEditor.getState().setMode('select')
398
+ useEditor.getState().armToolMode({ mode: 'select' })
337
399
  } else if (e.key === '2' && !e.metaKey && !e.ctrlKey) {
338
400
  e.preventDefault()
339
401
  useEditor.getState().setPhase('structure')
340
- useEditor.getState().setMode('select')
402
+ useEditor.getState().armToolMode({ mode: 'select' })
341
403
  } else if (e.key === '3' && !e.metaKey && !e.ctrlKey) {
342
404
  e.preventDefault()
343
405
  useEditor.getState().setPhase('furnish')
344
- useEditor.getState().setMode('select')
406
+ useEditor.getState().armToolMode({ mode: 'select' })
345
407
  } else if (e.key === 'f' && !e.metaKey && !e.ctrlKey) {
346
408
  if (isVersionPreviewMode) return
409
+ if (isToolOwnedCanopyForm()) return
347
410
  e.preventDefault()
348
411
  useEditor.getState().setPhase('furnish')
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')
412
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'item' })
353
413
  useEditor.getState().setActiveSidebarPanel('items')
354
414
  } else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {
355
415
  if (isVersionPreviewMode) return
356
416
  e.preventDefault()
357
417
  useEditor.getState().setPhase('structure')
358
418
  useEditor.getState().setStructureLayer('zones')
359
- useEditor.getState().setMode('build')
360
- // Set the zone tool explicitly so it never inherits a stale tool.
361
- useEditor.getState().setTool('zone')
419
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'zone' })
362
420
  } else if (e.key === 'm' && !e.metaKey && !e.ctrlKey) {
363
421
  if (isVersionPreviewMode) return
364
422
  e.preventDefault()
@@ -366,39 +424,33 @@ export const useKeyboard = ({
366
424
  editor.setPhase('structure')
367
425
  editor.setStructureLayer('elements')
368
426
  editor.setToolDefaults('measurement', { kind: editor.lastMeasurementKind })
369
- editor.setMode('build')
370
- editor.setTool('measurement')
427
+ editor.armToolMode({ mode: 'build', tool: 'measurement' })
371
428
  }
372
429
  if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
373
430
  e.preventDefault()
374
- useEditor.getState().setMode('select')
431
+ useEditor.getState().armToolMode({ mode: 'select' })
375
432
  useEditor.getState().setFloorplanSelectionTool('click')
376
433
  } else if (e.key === 'b' && !e.metaKey && !e.ctrlKey) {
377
434
  if (isVersionPreviewMode) return
378
435
  e.preventDefault()
379
436
  useEditor.getState().setPhase('structure')
380
437
  useEditor.getState().setStructureLayer('elements')
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')
438
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'wall' })
385
439
  } else if (e.key === 'x' && !e.metaKey && !e.ctrlKey) {
386
440
  if (isVersionPreviewMode) return
387
441
  e.preventDefault()
388
- useEditor.getState().setMode('delete')
442
+ useEditor.getState().armToolMode({ mode: 'delete' })
389
443
  } else if (e.key === 'p' && !e.metaKey && !e.ctrlKey) {
390
444
  if (isVersionPreviewMode) return
391
445
  e.preventDefault()
392
- useEditor.getState().primeMaterialPaintFromSelection()
393
446
  useEditor.getState().setPhase('structure')
394
447
  useEditor.getState().setStructureLayer('elements')
395
- useEditor.getState().setMode('material-paint')
448
+ useEditor.getState().armMaterialPaint()
396
449
  } else if (e.key === 'g' && !e.metaKey && !e.ctrlKey) {
397
450
  if (isVersionPreviewMode) return
398
451
  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')
452
+ // G for ground. The ToolMode transition moves to the site phase itself.
453
+ useEditor.getState().armToolMode({ mode: 'terrain-sculpt' })
402
454
  } else if (e.key === 'c' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
403
455
  if (isVersionPreviewMode) return
404
456
  e.preventDefault()
@@ -414,13 +466,11 @@ export const useKeyboard = ({
414
466
  } else if (e.key.toLowerCase() === 'z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
415
467
  if (isVersionPreviewMode) return
416
468
  e.preventDefault()
417
- if (cancelInteractionForHistoryShortcut()) return
418
- runRedo()
469
+ runHistoryShortcut('redo')
419
470
  } else if (e.key.toLowerCase() === 'z' && !e.shiftKey && (e.metaKey || e.ctrlKey)) {
420
471
  if (isVersionPreviewMode) return
421
472
  e.preventDefault()
422
- if (cancelInteractionForHistoryShortcut()) return
423
- runUndo()
473
+ runHistoryShortcut('undo')
424
474
  } else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) {
425
475
  e.preventDefault()
426
476
  const { buildingId, levelId } = useViewer.getState().selection
@@ -436,8 +486,10 @@ export const useKeyboard = ({
436
486
  const currentIdx = levelId ? levels.indexOf(levelId as any) : -1
437
487
  const nextIdx = currentIdx < levels.length - 1 ? currentIdx + 1 : currentIdx
438
488
  if (nextIdx !== -1 && nextIdx !== currentIdx) {
489
+ markPerfAction('level-switch', levels[nextIdx] as string)
439
490
  useViewer.getState().setSelection({ levelId: levels[nextIdx] as any })
440
491
  } else if (currentIdx === -1) {
492
+ markPerfAction('level-switch', levels[0] as string)
441
493
  useViewer.getState().setSelection({ levelId: levels[0] as any })
442
494
  }
443
495
  }
@@ -457,8 +509,10 @@ export const useKeyboard = ({
457
509
  const currentIdx = levelId ? levels.indexOf(levelId as any) : -1
458
510
  const prevIdx = currentIdx > 0 ? currentIdx - 1 : currentIdx
459
511
  if (prevIdx !== -1 && prevIdx !== currentIdx) {
512
+ markPerfAction('level-switch', levels[prevIdx] as string)
460
513
  useViewer.getState().setSelection({ levelId: levels[prevIdx] as any })
461
514
  } else if (currentIdx === -1) {
515
+ markPerfAction('level-switch', levels[levels.length - 1] as string)
462
516
  useViewer.getState().setSelection({ levelId: levels[levels.length - 1] as any })
463
517
  }
464
518
  }
@@ -468,7 +522,8 @@ export const useKeyboard = ({
468
522
  !e.metaKey &&
469
523
  !e.ctrlKey &&
470
524
  !isVersionPreviewMode &&
471
- !isPlacingOpening()
525
+ !isToolOwnedRotation() &&
526
+ canRunGlobalRotationShortcut()
472
527
  ) {
473
528
  // `!metaKey && !ctrlKey` lets Cmd/Ctrl+R reach the browser reload instead
474
529
  // of rotating/flipping the selected node.
@@ -477,10 +532,9 @@ export const useKeyboard = ({
477
532
  // open/close toggle lives on E. Windows still use R to toggle
478
533
  // their open/closed state.
479
534
  //
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).
535
+ // Skipped while an item, door, window, or roof placement owns rotation.
536
+ // The user can still have a node selected during placement; without this
537
+ // guard both the draft and the selection would rotate.
484
538
  //
485
539
  // References (guide/scan) live in `selectedReferenceId`, not the viewer
486
540
  // selection — check them first, like the Delete arm below.
@@ -560,7 +614,12 @@ export const useKeyboard = ({
560
614
  sfxEmitter.emit('sfx:item-rotate')
561
615
  }
562
616
  }
563
- } else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode && !isPlacingOpening()) {
617
+ } else if (
618
+ (e.key === 't' || e.key === 'T') &&
619
+ !isVersionPreviewMode &&
620
+ !isToolOwnedRotation() &&
621
+ canRunGlobalRotationShortcut()
622
+ ) {
564
623
  // Rotate selected node counter-clockwise
565
624
  // Multi-selection → group rotate, mirroring the R arm above.
566
625
  if (rotateGroupSelection(-1)) {
@@ -678,16 +737,27 @@ export const useKeyboard = ({
678
737
  }
679
738
  }
680
739
  const handleKeyUp = (e: KeyboardEvent) => {
740
+ if (e.key === 'Shift') {
741
+ const wasClean = shiftTapClean
742
+ shiftTapClean = false
743
+ if (!wasClean) return
744
+ if (blocksSnappingShortcut(e.target instanceof HTMLElement ? e.target : null)) {
745
+ return
746
+ }
747
+ if (!canCycleSnappingModeShortcut()) return
748
+ e.preventDefault()
749
+ useEditor.getState().cycleSnappingMode()
750
+ sfxEmitter.emit('sfx:grid-snap')
751
+ return
752
+ }
681
753
  if (e.key === 'Control' || e.key === 'Meta') {
682
754
  const wasClean = ctrlTapClean
683
755
  ctrlTapClean = false
684
756
  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) {
757
+ if (blocksSnappingShortcut(e.target instanceof HTMLElement ? e.target : null)) {
688
758
  return
689
759
  }
690
- if (!isSnappingCycleContext()) return
760
+ if (!canCycleSnappingModeShortcut()) return
691
761
  // Cycle the grid / measurement step (0.5 → 0.25 → 0.1 → 0.05).
692
762
  useEditor.getState().cycleGridSnapStep()
693
763
  sfxEmitter.emit('sfx:grid-snap')
@@ -695,9 +765,40 @@ export const useKeyboard = ({
695
765
  }
696
766
  }
697
767
 
768
+ // Capture-phase Ctrl/Cmd+G so browser "Find next" cannot steal the shortcut
769
+ // before the editor bubble listener runs. `stopPropagation` here silences the
770
+ // chord for every other capture listener (floorplan hotkeys, group move,
771
+ // registry move overlay) — safe only because none of them claim Ctrl/Cmd+G.
772
+ // `e.code` keeps it on the physical G key across keyboard layouts.
773
+ const handleSessionGroupKeyDown = (e: KeyboardEvent) => {
774
+ if (e.key !== 'Shift') shiftTapClean = false
775
+ if (e.key !== 'Control' && e.key !== 'Meta') ctrlTapClean = false
776
+ if (
777
+ e.target instanceof HTMLInputElement ||
778
+ e.target instanceof HTMLTextAreaElement ||
779
+ (e.target instanceof HTMLElement && e.target.isContentEditable)
780
+ ) {
781
+ return
782
+ }
783
+ if (useDeleteConfirmation.getState().request) return
784
+ if (isVersionPreviewMode) return
785
+ if (!(e.metaKey || e.ctrlKey) || e.altKey) return
786
+ if (e.code !== 'KeyG') return
787
+
788
+ e.preventDefault()
789
+ e.stopPropagation()
790
+ if (e.shiftKey) {
791
+ ungroupCurrentSelection()
792
+ } else {
793
+ groupCurrentSelection()
794
+ }
795
+ }
796
+
797
+ window.addEventListener('keydown', handleSessionGroupKeyDown, true)
698
798
  window.addEventListener('keydown', handleKeyDown)
699
799
  window.addEventListener('keyup', handleKeyUp)
700
800
  return () => {
801
+ window.removeEventListener('keydown', handleSessionGroupKeyDown, true)
701
802
  window.removeEventListener('keydown', handleKeyDown)
702
803
  window.removeEventListener('keyup', handleKeyUp)
703
804
  }