@pascal-app/editor 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (262) hide show
  1. package/package.json +12 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +63 -25
  5. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  6. package/src/components/editor/editor-layout-v2.tsx +18 -0
  7. package/src/components/editor/export-manager.tsx +71 -63
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
  10. package/src/components/editor/first-person/build-collider-world.ts +32 -5
  11. package/src/components/editor/first-person-controls.tsx +118 -7
  12. package/src/components/editor/floating-action-menu.tsx +410 -33
  13. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +1804 -849
  17. package/src/components/editor/grid.tsx +166 -39
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +102 -64
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +192 -1
  24. package/src/components/editor/group-transform-shared.ts +240 -7
  25. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  26. package/src/components/editor/handles/use-handle-drag.ts +37 -4
  27. package/src/components/editor/index.tsx +248 -27
  28. package/src/components/editor/measurement-pill.tsx +62 -22
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +366 -173
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +812 -466
  36. package/src/components/editor/site-edge-labels.tsx +4 -4
  37. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  38. package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +136 -43
  41. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  42. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  43. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  44. package/src/components/editor/wall-measurement-label.tsx +3 -13
  45. package/src/components/editor/wall-move-side-handles.tsx +36 -21
  46. package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
  47. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  48. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
  49. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  50. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  51. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  52. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  53. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  54. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
  55. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
  56. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  57. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  58. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  59. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
  60. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  61. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
  62. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  63. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
  64. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  65. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  66. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  67. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  68. package/src/components/systems/zone/zone-system.tsx +42 -13
  69. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  70. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  71. package/src/components/tools/fence/fence-drafting.ts +80 -8
  72. package/src/components/tools/item/move-tool.tsx +17 -13
  73. package/src/components/tools/item/placement-math.test.ts +13 -1
  74. package/src/components/tools/item/placement-math.ts +28 -2
  75. package/src/components/tools/item/placement-strategies.ts +246 -5
  76. package/src/components/tools/item/placement-types.ts +13 -1
  77. package/src/components/tools/item/use-draft-node.ts +31 -1
  78. package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
  79. package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
  80. package/src/components/tools/roof/roof-tool.tsx +320 -94
  81. package/src/components/tools/select/box-select-state.ts +18 -7
  82. package/src/components/tools/select/box-select-tool.tsx +176 -40
  83. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  84. package/src/components/tools/select/marquee-geometry.ts +155 -0
  85. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  86. package/src/components/tools/select/select-candidates.ts +1 -1
  87. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  88. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  89. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  90. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  91. package/src/components/tools/shared/placement-box.tsx +183 -1
  92. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  93. package/src/components/tools/site/site-boundary-editor.tsx +88 -36
  94. package/src/components/tools/stair/stair-defaults.ts +1 -0
  95. package/src/components/tools/stair/stair-tool.tsx +82 -11
  96. package/src/components/tools/tool-manager.tsx +86 -25
  97. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  98. package/src/components/tools/wall/wall-drafting.ts +180 -103
  99. package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
  100. package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
  101. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  102. package/src/components/tools/zone/zone-tool.tsx +82 -88
  103. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  104. package/src/components/ui/action-menu/control-modes.tsx +40 -93
  105. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  106. package/src/components/ui/action-menu/index.tsx +2 -4
  107. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  108. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  109. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  110. package/src/components/ui/command-palette/editor-commands.tsx +2 -2
  111. package/src/components/ui/command-palette/index.tsx +4 -3
  112. package/src/components/ui/controls/material-paint-panel.tsx +86 -17
  113. package/src/components/ui/controls/material-picker.tsx +83 -152
  114. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  115. package/src/components/ui/controls/metric-control.tsx +118 -44
  116. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  117. package/src/components/ui/controls/slider-control.tsx +88 -38
  118. package/src/components/ui/floating-level-selector.tsx +1 -1
  119. package/src/components/ui/helpers/building-helper.tsx +10 -23
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  121. package/src/components/ui/helpers/helper-manager.tsx +227 -14
  122. package/src/components/ui/helpers/item-helper.tsx +28 -32
  123. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  124. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  125. package/src/components/ui/icon-ref.tsx +47 -0
  126. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  127. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/panels/node-display.ts +17 -17
  130. package/src/components/ui/panels/panel-manager.tsx +22 -13
  131. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  132. package/src/components/ui/panels/parametric-inspector.tsx +15 -2
  133. package/src/components/ui/panels/reference-panel.tsx +54 -19
  134. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  135. package/src/components/ui/primitives/sidebar.tsx +1 -0
  136. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  137. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  138. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  139. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
  140. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
  141. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  142. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  144. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  145. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  146. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  147. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  148. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  149. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  150. package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
  151. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  152. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  153. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  154. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  155. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  156. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  157. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  158. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  159. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  160. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  161. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  162. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  163. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  164. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  165. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  166. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  167. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  168. package/src/components/ui/snap-target-badge.tsx +88 -0
  169. package/src/components/viewer-overlay.tsx +85 -10
  170. package/src/components/viewer-zone-system.tsx +6 -1
  171. package/src/hooks/use-auto-save.test.ts +14 -0
  172. package/src/hooks/use-auto-save.ts +54 -7
  173. package/src/hooks/use-ceiling-events.ts +3 -2
  174. package/src/hooks/use-drag-action.ts +4 -1
  175. package/src/hooks/use-keyboard.ts +385 -40
  176. package/src/index.tsx +219 -6
  177. package/src/lib/active-placement-surface.ts +35 -0
  178. package/src/lib/ceiling-plan-snap.ts +24 -0
  179. package/src/lib/contextual-help.test.ts +112 -0
  180. package/src/lib/contextual-help.ts +144 -0
  181. package/src/lib/continuation.ts +64 -0
  182. package/src/lib/direct-manipulation.test.ts +192 -0
  183. package/src/lib/direct-manipulation.ts +109 -0
  184. package/src/lib/editor-api.ts +23 -35
  185. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  186. package/src/lib/floorplan/apply-alignment.ts +18 -12
  187. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  188. package/src/lib/floorplan/geometry.ts +53 -0
  189. package/src/lib/floorplan/index.ts +3 -0
  190. package/src/lib/floorplan/items.ts +5 -2
  191. package/src/lib/floorplan/plan-coords.ts +21 -0
  192. package/src/lib/fresh-planar-placement.test.ts +240 -3
  193. package/src/lib/fresh-planar-placement.ts +68 -1
  194. package/src/lib/glb-export.test.ts +441 -0
  195. package/src/lib/glb-export.ts +874 -0
  196. package/src/lib/history.ts +9 -0
  197. package/src/lib/interaction/hot-set.test.ts +133 -0
  198. package/src/lib/interaction/hot-set.ts +67 -0
  199. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  200. package/src/lib/interaction/overlay-policy.ts +59 -0
  201. package/src/lib/interaction/scope.ts +211 -0
  202. package/src/lib/level-selection.ts +2 -2
  203. package/src/lib/material-paint.ts +36 -49
  204. package/src/lib/measurement-kind.test.ts +30 -0
  205. package/src/lib/measurement-kind.ts +19 -0
  206. package/src/lib/measurement-label.test.ts +47 -0
  207. package/src/lib/measurement-label.ts +68 -0
  208. package/src/lib/measurement-parser.ts +116 -0
  209. package/src/lib/measurements.test.ts +183 -0
  210. package/src/lib/measurements.ts +199 -0
  211. package/src/lib/paint-scope.test.ts +454 -0
  212. package/src/lib/paint-scope.ts +461 -0
  213. package/src/lib/placement-drag-release.ts +23 -0
  214. package/src/lib/planar-cursor-placement.test.ts +57 -0
  215. package/src/lib/plugin-panels.test.ts +19 -0
  216. package/src/lib/plugin-panels.ts +91 -0
  217. package/src/lib/quick-measurement.test.ts +77 -0
  218. package/src/lib/quick-measurement.ts +109 -0
  219. package/src/lib/roof-duplication.ts +6 -6
  220. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  221. package/src/lib/roof-wall-hit.ts +164 -0
  222. package/src/lib/scene-clipboard.test.ts +196 -0
  223. package/src/lib/scene-clipboard.ts +73 -8
  224. package/src/lib/scene.ts +28 -5
  225. package/src/lib/selection-routing.test.ts +298 -0
  226. package/src/lib/selection-routing.ts +175 -0
  227. package/src/lib/sfx/index.ts +1 -0
  228. package/src/lib/sfx/movement-tick.test.ts +65 -0
  229. package/src/lib/sfx/movement-tick.ts +18 -0
  230. package/src/lib/sfx-bus.ts +50 -13
  231. package/src/lib/sfx-player.test.ts +124 -0
  232. package/src/lib/sfx-player.ts +111 -66
  233. package/src/lib/slab-plan-snap.test.ts +93 -0
  234. package/src/lib/slab-plan-snap.ts +108 -0
  235. package/src/lib/snapping-mode.test.ts +138 -0
  236. package/src/lib/snapping-mode.ts +179 -0
  237. package/src/lib/stair-duplication.ts +4 -4
  238. package/src/lib/surface-plan-snap.test.ts +71 -0
  239. package/src/lib/surface-plan-snap.ts +256 -0
  240. package/src/lib/use-linear-display.ts +40 -0
  241. package/src/lib/world-grid-snap.ts +37 -5
  242. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  243. package/src/store/use-editor.tsx +484 -114
  244. package/src/store/use-facing-pose.ts +44 -0
  245. package/src/store/use-fence-curve-draft.ts +21 -0
  246. package/src/store/use-floorplan-draft-preview.ts +101 -0
  247. package/src/store/use-floorplan-marquee.ts +50 -0
  248. package/src/store/use-interaction-scope.test.ts +186 -0
  249. package/src/store/use-interaction-scope.ts +132 -0
  250. package/src/store/use-measurement-draft.test.ts +530 -0
  251. package/src/store/use-measurement-draft.ts +543 -0
  252. package/src/store/use-opening-guides.ts +41 -0
  253. package/src/store/use-placement-preview.ts +11 -3
  254. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  255. package/src/store/use-quick-measurement-hud.ts +77 -0
  256. package/src/store/use-segment-draft-chain.ts +28 -0
  257. package/src/store/use-stair-build-preview.ts +43 -0
  258. package/src/store/use-wall-snap-indicator.ts +2 -0
  259. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  260. package/src/components/editor/group-move-handle.tsx +0 -316
  261. package/src/components/ui/panels/paint-panel.tsx +0 -163
  262. package/src/lib/level-name.ts +0 -11
@@ -1,15 +1,96 @@
1
- import { type AnyNodeId, emitter, nodeRegistry, useScene } from '@pascal-app/core'
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ emitter,
5
+ nodeRegistry,
6
+ pauseSpaceDetection,
7
+ resumeSpaceDetection,
8
+ useScene,
9
+ } from '@pascal-app/core'
2
10
  import { useViewer } from '@pascal-app/viewer'
3
11
  import { useEffect } from 'react'
12
+ import { Vector3 } from 'three'
13
+ import {
14
+ 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'
4
23
  import { toggleDoorOpenState } from '../lib/door-interaction'
24
+ import { guideEmitter } from '../lib/guide-events'
5
25
  import { runRedo, runUndo } from '../lib/history'
26
+ import { isActive } from '../lib/interaction/scope'
6
27
  import {
7
28
  copySelectedNodesToEditorClipboard,
8
29
  pasteEditorClipboardToLevel,
9
30
  } from '../lib/scene-clipboard'
10
31
  import { emitDeleteSFX, sfxEmitter } from '../lib/sfx-bus'
11
32
  import { toggleWindowOpenState } from '../lib/window-interaction'
12
- import useEditor from '../store/use-editor'
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
+ }
13
94
 
14
95
  // Tools call this in their onCancel handler when they have an active mid-action to cancel,
15
96
  // so that the global Escape handler knows not to also switch to select mode.
@@ -18,6 +99,64 @@ export const markToolCancelConsumed = () => {
18
99
  _toolCancelConsumed = true
19
100
  }
20
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
+
21
160
  export const useKeyboard = ({
22
161
  isVersionPreviewMode = false,
23
162
  disabled = false,
@@ -30,39 +169,130 @@ export const useKeyboard = ({
30
169
  return
31
170
  }
32
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
+
33
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
+
34
208
  // Don't handle shortcuts if user is typing in an input
35
209
  if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
36
210
  return
37
211
  }
38
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
+
39
279
  if (e.key === 'Escape') {
40
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
+
41
289
  _toolCancelConsumed = false
42
290
  emitter.emit('tool:cancel')
43
291
 
44
292
  // Only switch to select mode if no tool had an active mid-action to cancel.
45
293
  // (e.g. mid-wall draw or mid-slab polygon should only cancel the action, not exit the tool)
46
294
  if (!_toolCancelConsumed) {
47
- const currentPhase = useEditor.getState().phase
48
- const currentStructureLayer = useEditor.getState().structureLayer
49
-
50
- useEditor.getState().setEditingHole(null)
51
-
52
- // From zone mode, return to structure select
53
- if (currentPhase === 'structure' && currentStructureLayer === 'zones') {
54
- useEditor.getState().setStructureLayer('elements')
55
- useEditor.getState().setMode('select')
56
- } else {
57
- // Return to the default select tool while keeping the active building/level context.
58
- useEditor.getState().setMode('select')
59
- }
60
-
61
- useEditor.getState().setFloorplanSelectionTool('click')
62
-
63
- // Clear selections to close UI panels, but KEEP the active building and level context.
64
- useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
65
- useEditor.getState().setSelectedReferenceId(null)
295
+ exitToSelectAfterUnconsumedCancel()
66
296
  }
67
297
  } else if (e.key === '1' && !e.metaKey && !e.ctrlKey) {
68
298
  e.preventDefault()
@@ -81,6 +311,9 @@ export const useKeyboard = ({
81
311
  e.preventDefault()
82
312
  useEditor.getState().setPhase('furnish')
83
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')
84
317
  useEditor.getState().setActiveSidebarPanel('items')
85
318
  } else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {
86
319
  if (isVersionPreviewMode) return
@@ -88,6 +321,17 @@ export const useKeyboard = ({
88
321
  useEditor.getState().setPhase('structure')
89
322
  useEditor.getState().setStructureLayer('zones')
90
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')
91
335
  }
92
336
  if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
93
337
  e.preventDefault()
@@ -99,6 +343,9 @@ export const useKeyboard = ({
99
343
  useEditor.getState().setPhase('structure')
100
344
  useEditor.getState().setStructureLayer('elements')
101
345
  useEditor.getState().setMode('build')
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')
102
349
  } else if (e.key === 'x' && !e.metaKey && !e.ctrlKey) {
103
350
  if (isVersionPreviewMode) return
104
351
  e.preventDefault()
@@ -121,14 +368,16 @@ export const useKeyboard = ({
121
368
  if (result?.pastedIds.length) {
122
369
  sfxEmitter.emit('sfx:item-place')
123
370
  }
124
- } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
371
+ } else if (e.key.toLowerCase() === 'z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
125
372
  if (isVersionPreviewMode) return
126
373
  e.preventDefault()
127
- runUndo()
128
- } else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
374
+ if (cancelInteractionForHistoryShortcut()) return
375
+ runRedo()
376
+ } else if (e.key.toLowerCase() === 'z' && !e.shiftKey && (e.metaKey || e.ctrlKey)) {
129
377
  if (isVersionPreviewMode) return
130
378
  e.preventDefault()
131
- runRedo()
379
+ if (cancelInteractionForHistoryShortcut()) return
380
+ runUndo()
132
381
  } else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) {
133
382
  e.preventDefault()
134
383
  const { buildingId, levelId } = useViewer.getState().selection
@@ -171,14 +420,53 @@ export const useKeyboard = ({
171
420
  }
172
421
  }
173
422
  }
174
- } 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.
175
432
  // Rotate selected node clockwise if it supports rotation (items, roofs, etc.)
176
433
  // Doors use R to flip side (front ↔ back, rotation += π); their
177
434
  // open/close toggle lives on E. Windows still use R to toggle
178
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
+ }
179
465
  const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
180
466
  if (selectedNodeIds.length === 1) {
181
- 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
182
470
  if (node?.type === 'door') {
183
471
  e.preventDefault()
184
472
  useScene.getState().updateNode(node.id, {
@@ -212,24 +500,48 @@ export const useKeyboard = ({
212
500
  sfxEmitter.emit('sfx:item-rotate')
213
501
  } else if (node && 'rotation' in node) {
214
502
  e.preventDefault()
215
- const ROTATION_STEP = Math.PI / 4
216
-
217
- // 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°).
218
504
  if (typeof node.rotation === 'number') {
219
- useScene.getState().updateNode(node.id, { rotation: node.rotation + ROTATION_STEP })
505
+ useScene
506
+ .getState()
507
+ .updateNode(node.id, { rotation: steppedRotation(node.rotation, 1) })
220
508
  } else if (Array.isArray(node.rotation)) {
221
509
  useScene.getState().updateNode(node.id, {
222
- 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
+ ],
223
515
  })
224
516
  }
225
517
  sfxEmitter.emit('sfx:item-rotate')
226
518
  }
227
519
  }
228
- } else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode) {
520
+ } else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode && !isPlacingOpening()) {
229
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
+ }
230
540
  const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
231
541
  if (selectedNodeIds.length === 1) {
232
- 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
233
545
  if (node?.type === 'door') {
234
546
  // Door's open/close moved to E; T is a no-op for doors so
235
547
  // it doesn't free-rotate a wall-bound node by π/4.
@@ -245,13 +557,18 @@ export const useKeyboard = ({
245
557
  sfxEmitter.emit('sfx:item-rotate')
246
558
  } else if (node && 'rotation' in node) {
247
559
  e.preventDefault()
248
- const ROTATION_STEP = Math.PI / 4
249
-
560
+ // Round to the nearest 45° then step one increment back.
250
561
  if (typeof node.rotation === 'number') {
251
- useScene.getState().updateNode(node.id, { rotation: node.rotation - ROTATION_STEP })
562
+ useScene
563
+ .getState()
564
+ .updateNode(node.id, { rotation: steppedRotation(node.rotation, -1) })
252
565
  } else if (Array.isArray(node.rotation)) {
253
566
  useScene.getState().updateNode(node.id, {
254
- rotation: [node.rotation[0], node.rotation[1] - ROTATION_STEP, node.rotation[2]],
567
+ rotation: [
568
+ node.rotation[0],
569
+ steppedRotation(node.rotation[1], -1),
570
+ node.rotation[2],
571
+ ],
255
572
  })
256
573
  }
257
574
  sfxEmitter.emit('sfx:item-rotate')
@@ -263,7 +580,13 @@ export const useKeyboard = ({
263
580
  const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
264
581
  if (selectedNodeIds.length === 1) {
265
582
  const node = useScene.getState().nodes[selectedNodeIds[0]!]
266
- if (node?.type === 'door' && node.openingKind !== 'opening') {
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') {
267
590
  e.preventDefault()
268
591
  toggleDoorOpenState(node.id)
269
592
  sfxEmitter.emit('sfx:item-rotate')
@@ -331,8 +654,30 @@ export const useKeyboard = ({
331
654
  }
332
655
  }
333
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
672
+ }
673
+ }
674
+
334
675
  window.addEventListener('keydown', handleKeyDown)
335
- return () => window.removeEventListener('keydown', handleKeyDown)
676
+ window.addEventListener('keyup', handleKeyUp)
677
+ return () => {
678
+ window.removeEventListener('keydown', handleKeyDown)
679
+ window.removeEventListener('keyup', handleKeyUp)
680
+ }
336
681
  }, [disabled, isVersionPreviewMode])
337
682
 
338
683
  return null