@pascal-app/editor 1.0.0-beta.5 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (297) 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/bake-exporter.tsx +5 -0
  6. package/src/components/editor/bake-thumbnail.tsx +4 -1
  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 +9 -2
  13. package/src/components/editor/export-manager.tsx +175 -33
  14. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  15. package/src/components/editor/first-person-controls.tsx +180 -12
  16. package/src/components/editor/floating-action-menu.tsx +55 -51
  17. package/src/components/editor/floorplan-panel.tsx +99 -15
  18. package/src/components/editor/grid.tsx +12 -7
  19. package/src/components/editor/group-actions.ts +34 -50
  20. package/src/components/editor/group-move-3d.ts +51 -21
  21. package/src/components/editor/group-transform-shared.test.ts +78 -0
  22. package/src/components/editor/group-transform-shared.ts +74 -7
  23. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  24. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  25. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  26. package/src/components/editor/handles/handle-placement.ts +69 -0
  27. package/src/components/editor/handles/linear-resize-drag.ts +78 -0
  28. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  29. package/src/components/editor/handles/resize-snap.ts +10 -1
  30. package/src/components/editor/handles/use-handle-drag.ts +23 -3
  31. package/src/components/editor/index.tsx +151 -24
  32. package/src/components/editor/node-action-menu.tsx +14 -1
  33. package/src/components/editor/node-arrow-handles.tsx +67 -119
  34. package/src/components/editor/selection-manager.tsx +153 -42
  35. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  36. package/src/components/editor/snapshot-capture.test.ts +422 -0
  37. package/src/components/editor/snapshot-capture.ts +145 -0
  38. package/src/components/editor/thumbnail-generator.tsx +295 -239
  39. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  40. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  41. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  42. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  43. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  44. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  45. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  46. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  47. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  48. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  49. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  50. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +226 -35
  51. package/src/components/procedural-items/inspector.tsx +223 -0
  52. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  53. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  54. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  55. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  56. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  57. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  58. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  59. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  60. package/src/components/systems/selection-affordance-services.ts +19 -0
  61. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  62. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  63. package/src/components/tools/fence/fence-drafting.ts +6 -25
  64. package/src/components/tools/item/draft-creation.test.ts +12 -0
  65. package/src/components/tools/item/draft-creation.ts +10 -0
  66. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  67. package/src/components/tools/item/face-host-commit.ts +56 -0
  68. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  69. package/src/components/tools/item/face-host-preview.ts +82 -0
  70. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  71. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  72. package/src/components/tools/item/move-tool.tsx +4 -3
  73. package/src/components/tools/item/placement-math.test.ts +11 -1
  74. package/src/components/tools/item/placement-math.ts +0 -20
  75. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  76. package/src/components/tools/item/placement-strategies.ts +304 -171
  77. package/src/components/tools/item/placement-surface.test.ts +61 -0
  78. package/src/components/tools/item/placement-surface.ts +24 -0
  79. package/src/components/tools/item/placement-types.ts +10 -0
  80. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  81. package/src/components/tools/item/test-face-host.ts +121 -0
  82. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  83. package/src/components/tools/item/use-draft-node.ts +116 -44
  84. package/src/components/tools/item/use-placement-coordinator.tsx +672 -256
  85. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  86. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  87. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  88. package/src/components/tools/registry/item-surface-move.ts +441 -0
  89. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  90. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  91. package/src/components/tools/registry/move-registry-node-tool.tsx +505 -164
  92. package/src/components/tools/registry-tool-context.tsx +30 -0
  93. package/src/components/tools/select/box-select-tool.tsx +3 -1
  94. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  95. package/src/components/tools/select/marquee-footprint.ts +29 -0
  96. package/src/components/tools/shared/placement-box.tsx +57 -7
  97. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  98. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  99. package/src/components/tools/shared/pointer-support-cap.ts +56 -17
  100. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  101. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  102. package/src/components/tools/shared/surface-hit.ts +23 -0
  103. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  104. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  105. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  106. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  107. package/src/components/tools/stair/stair-tool.tsx +127 -74
  108. package/src/components/tools/tool-manager.tsx +25 -8
  109. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  110. package/src/components/tools/wall/wall-drafting.ts +23 -114
  111. package/src/components/tools/zone/zone-tool.tsx +13 -4
  112. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  113. package/src/components/ui/action-menu/index.tsx +11 -1
  114. package/src/components/ui/command-palette/editor-commands.tsx +63 -13
  115. package/src/components/ui/command-palette/index.tsx +7 -2
  116. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  117. package/src/components/ui/controls/material-picker.tsx +4 -4
  118. package/src/components/ui/controls/metric-control.tsx +29 -13
  119. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  120. package/src/components/ui/controls/segmented-control.tsx +8 -1
  121. package/src/components/ui/controls/slider-control.tsx +44 -21
  122. package/src/components/ui/controls/toggle-control.tsx +18 -5
  123. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  124. package/src/components/ui/floating-level-selector.tsx +103 -10
  125. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  126. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  127. package/src/components/ui/helpers/item-helper.tsx +1 -1
  128. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  129. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  130. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  131. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  132. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  133. package/src/components/ui/panels/multi-field-value.ts +163 -0
  134. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  135. package/src/components/ui/panels/multi-parametric-inspector.tsx +266 -0
  136. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  137. package/src/components/ui/panels/node-display.ts +4 -0
  138. package/src/components/ui/panels/panel-manager.tsx +77 -3
  139. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  140. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  141. package/src/components/ui/panels/parametric-inspector.tsx +35 -164
  142. package/src/components/ui/panels/reference-panel.tsx +34 -4
  143. package/src/components/ui/primitives/checkbox.tsx +34 -0
  144. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  145. package/src/components/ui/scene-loader.tsx +35 -0
  146. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +502 -31
  147. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  148. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +167 -0
  149. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +128 -0
  150. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  151. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  152. package/src/components/ui/sidebar/panels/site-panel/index.tsx +222 -50
  153. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  154. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  155. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  156. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  157. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  158. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -7
  159. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  160. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  161. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  162. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  163. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  164. package/src/components/ui/snap-target-badge.tsx +2 -0
  165. package/src/components/viewer/viewer-scene-header.tsx +5 -1
  166. package/src/components/viewer/viewer-stage.tsx +4 -1
  167. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  168. package/src/components/viewer-overlay.tsx +1 -22
  169. package/src/components/viewer-zone-system.tsx +6 -2
  170. package/src/components/walkthrough-hud.tsx +19 -10
  171. package/src/hooks/use-auto-save.test.ts +73 -1
  172. package/src/hooks/use-auto-save.ts +76 -5
  173. package/src/hooks/use-ceiling-events.test.ts +144 -0
  174. package/src/hooks/use-ceiling-events.ts +5 -1
  175. package/src/hooks/use-drag-action.ts +17 -3
  176. package/src/hooks/use-grid-events.test.ts +240 -0
  177. package/src/hooks/use-grid-events.ts +190 -11
  178. package/src/hooks/use-keyboard.test.ts +244 -0
  179. package/src/hooks/use-keyboard.ts +132 -63
  180. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  181. package/src/hooks/use-save-shortcut.ts +33 -0
  182. package/src/index.tsx +87 -4
  183. package/src/lib/active-placement-surface.test.ts +16 -0
  184. package/src/lib/active-placement-surface.ts +14 -0
  185. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  186. package/src/lib/ceiling-surface-raycast.ts +12 -0
  187. package/src/lib/contextual-help-extension.ts +17 -0
  188. package/src/lib/continuation.test.ts +11 -0
  189. package/src/lib/continuation.ts +8 -1
  190. package/src/lib/direct-manipulation.test.ts +132 -0
  191. package/src/lib/direct-manipulation.ts +38 -1
  192. package/src/lib/editor-api.ts +6 -3
  193. package/src/lib/elevation-guides.test.ts +28 -1
  194. package/src/lib/elevation-guides.ts +22 -7
  195. package/src/lib/export-texture-utils.test.ts +167 -0
  196. package/src/lib/export-texture-utils.ts +121 -0
  197. package/src/lib/export-user-data.test.ts +48 -0
  198. package/src/lib/export-user-data.ts +69 -0
  199. package/src/lib/floating-menu-scale.test.ts +29 -0
  200. package/src/lib/floating-menu-scale.ts +14 -0
  201. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  202. package/src/lib/floorplan/apply-alignment.ts +10 -1
  203. package/src/lib/floorplan/floorplan-export.test.ts +536 -2
  204. package/src/lib/floorplan/floorplan-export.tsx +120 -19
  205. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  206. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  207. package/src/lib/floorplan/floorplan-readonly.ts +5 -0
  208. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  209. package/src/lib/floorplan-grid-event-point.ts +13 -0
  210. package/src/lib/fresh-planar-placement.ts +13 -5
  211. package/src/lib/glb-export-serialize.test.ts +280 -0
  212. package/src/lib/glb-export.test.ts +1375 -21
  213. package/src/lib/glb-export.ts +781 -106
  214. package/src/lib/grid-event-presentation.ts +16 -0
  215. package/src/lib/history.test.ts +528 -147
  216. package/src/lib/history.ts +81 -7
  217. package/src/lib/host-tree-children.test.ts +27 -0
  218. package/src/lib/host-tree-children.ts +79 -0
  219. package/src/lib/interaction/hot-set.test.ts +10 -1
  220. package/src/lib/interaction/hot-set.ts +3 -1
  221. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  222. package/src/lib/interaction/overlay-policy.ts +16 -0
  223. package/src/lib/interaction/registered-drafting.ts +36 -0
  224. package/src/lib/interaction/scope.ts +31 -0
  225. package/src/lib/level-duplication.test.ts +73 -0
  226. package/src/lib/level-duplication.ts +32 -2
  227. package/src/lib/level-print-export.test.ts +484 -0
  228. package/src/lib/level-print-export.ts +526 -0
  229. package/src/lib/linear-display.test.ts +59 -0
  230. package/src/lib/linear-display.ts +39 -0
  231. package/src/lib/local-guide-image.test.ts +24 -0
  232. package/src/lib/local-guide-image.ts +34 -1
  233. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  234. package/src/lib/local-project-presentation-persistence.ts +335 -0
  235. package/src/lib/material-paint.ts +2 -0
  236. package/src/lib/model-export.ts +29 -0
  237. package/src/lib/node-action-movement.ts +21 -0
  238. package/src/lib/paint-preview-owner.ts +71 -0
  239. package/src/lib/planar-cursor-placement.test.ts +178 -1
  240. package/src/lib/planar-cursor-placement.ts +61 -5
  241. package/src/lib/plugin-panels.test.ts +92 -1
  242. package/src/lib/plugin-panels.ts +48 -0
  243. package/src/lib/portable-export.test.ts +455 -0
  244. package/src/lib/portable-export.ts +1042 -0
  245. package/src/lib/print-3mf.test.ts +86 -0
  246. package/src/lib/print-3mf.ts +161 -0
  247. package/src/lib/print-content-scope.test.ts +107 -0
  248. package/src/lib/print-content-scope.ts +90 -0
  249. package/src/lib/print-export.test.ts +294 -0
  250. package/src/lib/print-export.ts +735 -0
  251. package/src/lib/print-feature-thickness.test.ts +147 -0
  252. package/src/lib/print-feature-thickness.ts +180 -0
  253. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  254. package/src/lib/print-golden-house.test.ts +249 -0
  255. package/src/lib/print-roof-solids.test.ts +133 -0
  256. package/src/lib/print-roof-solids.ts +630 -0
  257. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  258. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  259. package/src/lib/print-shell-compiler-manifold-core.ts +379 -0
  260. package/src/lib/print-shell-compiler-manifold-worker.ts +200 -0
  261. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  262. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  263. package/src/lib/print-shell-compiler-protocol.ts +38 -0
  264. package/src/lib/print-shell-compiler.ts +273 -0
  265. package/src/lib/print-wall-solids.test.ts +194 -0
  266. package/src/lib/print-wall-solids.ts +328 -0
  267. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  268. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  269. package/src/lib/scene-clipboard.ts +1 -1
  270. package/src/lib/scene.ts +22 -8
  271. package/src/lib/selection-routing.test.ts +105 -1
  272. package/src/lib/selection-routing.ts +19 -0
  273. package/src/lib/send-to-app.test.ts +108 -0
  274. package/src/lib/send-to-app.ts +121 -0
  275. package/src/lib/sfx/movement-tick.ts +37 -0
  276. package/src/lib/sfx-bus.ts +4 -0
  277. package/src/lib/sfx-player.ts +8 -0
  278. package/src/lib/snapping-mode.test.ts +32 -4
  279. package/src/lib/snapping-mode.ts +11 -7
  280. package/src/lib/surface-attachment.ts +77 -0
  281. package/src/lib/units.ts +254 -0
  282. package/src/lib/usdz-export.ts +65 -0
  283. package/src/lib/use-linear-display.ts +8 -32
  284. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  285. package/src/store/live-draft-preview-stores.test.ts +3 -0
  286. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  287. package/src/store/tool-mode.test.ts +87 -0
  288. package/src/store/use-camera-hint-focus.ts +48 -0
  289. package/src/store/use-editor.tsx +299 -188
  290. package/src/store/use-floorplan-draft-preview.ts +9 -0
  291. package/src/store/use-handle-group.ts +4 -0
  292. package/src/store/use-interaction-scope.test.ts +23 -1
  293. package/src/store/use-interaction-scope.ts +63 -2
  294. package/src/store/use-placement-preview.ts +56 -3
  295. package/src/store/use-send-to-app.ts +21 -0
  296. package/src/components/tools/roof/roof-tool.tsx +0 -656
  297. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -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,11 +27,12 @@ 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'
34
34
  import { activeSiteNode, clampBrushRadius } from '../lib/terrain-sculpt'
35
+ import { leaveUnitFocus } from '../lib/units'
35
36
  import { toggleWindowOpenState } from '../lib/window-interaction'
36
37
  import useDeleteConfirmation from '../store/use-delete-confirmation'
37
38
  import useEditor, { getActiveContinuationContext, getActiveSnapContext } from '../store/use-editor'
@@ -102,6 +103,9 @@ function rotateGroupSelection(direction: 1 | -1): boolean {
102
103
  let _toolCancelConsumed = false
103
104
  export const markToolCancelConsumed = () => {
104
105
  _toolCancelConsumed = true
106
+ // A consumed cancel means the active gesture reverted — the perf ledger must
107
+ // not measure the restore as a committed action's settle.
108
+ cancelPerfAction()
105
109
  }
106
110
 
107
111
  // Escape's fall-through when no tool consumed the cancel: drop back to the
@@ -117,10 +121,10 @@ const exitToSelectAfterUnconsumedCancel = () => {
117
121
  // From zone mode, return to structure select
118
122
  if (currentPhase === 'structure' && currentStructureLayer === 'zones') {
119
123
  useEditor.getState().setStructureLayer('elements')
120
- useEditor.getState().setMode('select')
124
+ useEditor.getState().armToolMode({ mode: 'select' })
121
125
  } else {
122
126
  // Return to the default select tool while keeping the active building/level context.
123
- useEditor.getState().setMode('select')
127
+ useEditor.getState().armToolMode({ mode: 'select' })
124
128
  }
125
129
 
126
130
  useEditor.getState().setFloorplanSelectionTool('click')
@@ -141,6 +145,9 @@ const cancelInteractionForHistoryShortcut = () => {
141
145
  guideEmitter.emit('guide:cancel-reference-scale')
142
146
  return true
143
147
  }
148
+ const activeScope = useInteractionScope.getState().scope
149
+ if (shouldCancelDraftOnHistoryJump()) return false
150
+ if (activeScope.kind === 'mesh-editing' && activeScope.phase === 'selecting') return false
144
151
  _toolCancelConsumed = false
145
152
  emitter.emit('tool:cancel')
146
153
  if (_toolCancelConsumed) return true
@@ -162,6 +169,56 @@ const cancelInteractionForHistoryShortcut = () => {
162
169
  return false
163
170
  }
164
171
 
172
+ export const runHistoryShortcut = (direction: 'undo' | 'redo') => {
173
+ if (cancelInteractionForHistoryShortcut()) return false
174
+ if (direction === 'redo') runRedo()
175
+ else runUndo()
176
+ return true
177
+ }
178
+
179
+ export const isToolOwnedRotation = () => {
180
+ const editor = useEditor.getState()
181
+ const moving = getMovingNode()
182
+ if (
183
+ moving?.type === 'door' ||
184
+ moving?.type === 'window' ||
185
+ moving?.type === 'item' ||
186
+ moving?.type === 'lean-to-extension'
187
+ )
188
+ return true
189
+ return (
190
+ editor.mode === 'build' &&
191
+ (editor.tool === 'door' ||
192
+ editor.tool === 'window' ||
193
+ editor.tool === 'roof' ||
194
+ // The item tool is mounted for the build mode, but it only owns R/T
195
+ // when a catalog item is actually selected and a placement draft can
196
+ // exist. Without this check, selecting an existing item in the 2D plan
197
+ // while the item tool is armed silently drops the global rotate key.
198
+ (editor.tool === 'item' && editor.selectedItem !== null) ||
199
+ editor.tool === 'lean-to-extension')
200
+ )
201
+ }
202
+
203
+ export const isToolOwnedCanopyForm = () => {
204
+ const editor = useEditor.getState()
205
+ return editor.mode === 'build' && editor.tool === 'lean-to-extension'
206
+ }
207
+
208
+ export const canRunGlobalRotationShortcut = () =>
209
+ useInteractionScope.getState().scope.kind !== 'mesh-editing'
210
+
211
+ export const canCycleSnappingModeShortcut = (hasActiveContext = getActiveSnapContext() != null) =>
212
+ hasActiveContext
213
+
214
+ export function blocksSnappingShortcut(
215
+ target: Pick<HTMLElement, 'tagName' | 'isContentEditable' | 'hasAttribute'> | null,
216
+ ): boolean {
217
+ if (!target) return false
218
+ if (target.tagName === 'INPUT' && target.hasAttribute('data-run-length-input')) return false
219
+ return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable
220
+ }
221
+
165
222
  export const useKeyboard = ({
166
223
  isVersionPreviewMode = false,
167
224
  disabled = false,
@@ -174,32 +231,30 @@ export const useKeyboard = ({
174
231
  return
175
232
  }
176
233
 
177
- // True while a door/window is being placed: either a fresh clone is moving
178
- // (preset / duplicate path) or a door/window build tool is armed. The
179
- // placement tool owns R/T then (flip the draft before commit), so the
180
- // global selection-based R/T handler must stand down to avoid double-firing.
181
- const isPlacingOpening = () => {
182
- const ed = useEditor.getState()
183
- const moving = getMovingNode()
184
- if (moving?.type === 'door' || moving?.type === 'window') return true
185
- return ed.mode === 'build' && (ed.tool === 'door' || ed.tool === 'window')
186
- }
187
-
234
+ // True while an active placement tool owns R/T. Door/window tools flip the
235
+ // draft, item / lean-to placement rotates its draft, and the roof tool turns
236
+ // its draft axes. The global selection handler must stand down to avoid double-firing.
188
237
  // Shift cycles the snapping mode (and a clean-tap Ctrl the grid step)
189
238
  // whenever there's an active snapping context — i.e. exactly when the HUD
190
239
  // shows a snapping chip. That single source covers wall/fence/item drafting,
191
240
  // every node move (including wall-hosted items + door/window openings, which
192
241
  // now declare `snapProfile`), and endpoint/polygon reshaping, so the keys
193
242
  // never silently stop working. Force-place lives on Alt where a tool supports it.
194
- const isSnappingCycleContext = () => getActiveSnapContext() != null
195
243
  // A "clean tap" of Ctrl/Meta (pressed and released with NO other key in
196
244
  // between) cycles the grid step — same context as the Shift snapping-mode
197
245
  // cycle. `ctrlTapClean` starts true the moment Ctrl/Meta goes down alone
198
246
  // and is cleared the instant any other key fires, so chords like Ctrl+Z /
199
247
  // Ctrl+C never cycle.
200
248
  let ctrlTapClean = false
249
+ let shiftTapClean = false
201
250
 
202
251
  const handleKeyDown = (e: KeyboardEvent) => {
252
+ if (e.key === 'Shift') {
253
+ shiftTapClean = !e.repeat && !e.metaKey && !e.ctrlKey && !e.altKey
254
+ } else {
255
+ shiftTapClean = false
256
+ }
257
+
203
258
  if (e.key === 'Control' || e.key === 'Meta') {
204
259
  // Only a fresh, modifier-free press starts a clean-tap candidate;
205
260
  // ignore key-repeat and presses already part of a combo.
@@ -210,6 +265,18 @@ export const useKeyboard = ({
210
265
  ctrlTapClean = false
211
266
  }
212
267
 
268
+ if (
269
+ shouldCancelDraftOnHistoryJump() &&
270
+ isHistoryShortcut(e) &&
271
+ e.target instanceof HTMLInputElement &&
272
+ e.target.hasAttribute('data-run-length-input')
273
+ ) {
274
+ if (isVersionPreviewMode || useDeleteConfirmation.getState().request) return
275
+ e.preventDefault()
276
+ runHistoryShortcut(e.shiftKey ? 'redo' : 'undo')
277
+ return
278
+ }
279
+
213
280
  // Don't handle shortcuts if user is typing in an input
214
281
  if (
215
282
  e.target instanceof HTMLInputElement ||
@@ -261,15 +328,6 @@ export const useKeyboard = ({
261
328
  return
262
329
  }
263
330
 
264
- if (e.key === 'Shift' && !e.repeat && isSnappingCycleContext()) {
265
- // Cycle the global snapping mode (grid → lines → angles → off).
266
- // `'off'` is the snap bypass now, so Shift no longer holds-to-bypass.
267
- e.preventDefault()
268
- useEditor.getState().cycleSnappingMode()
269
- sfxEmitter.emit('sfx:grid-snap')
270
- return
271
- }
272
-
273
331
  if (
274
332
  (e.key === 't' || e.key === 'T') &&
275
333
  !e.repeat &&
@@ -333,37 +391,34 @@ export const useKeyboard = ({
333
391
  // Only switch to select mode if no tool had an active mid-action to cancel.
334
392
  // (e.g. mid-wall draw or mid-slab polygon should only cancel the action, not exit the tool)
335
393
  if (!_toolCancelConsumed) {
336
- exitToSelectAfterUnconsumedCancel()
394
+ if (leaveUnitFocus()) useEditor.getState().armToolMode({ mode: 'select' })
395
+ else exitToSelectAfterUnconsumedCancel()
337
396
  }
338
397
  } else if (e.key === '1' && !e.metaKey && !e.ctrlKey) {
339
398
  e.preventDefault()
340
399
  useEditor.getState().setPhase('site')
341
- useEditor.getState().setMode('select')
400
+ useEditor.getState().armToolMode({ mode: 'select' })
342
401
  } else if (e.key === '2' && !e.metaKey && !e.ctrlKey) {
343
402
  e.preventDefault()
344
403
  useEditor.getState().setPhase('structure')
345
- useEditor.getState().setMode('select')
404
+ useEditor.getState().armToolMode({ mode: 'select' })
346
405
  } else if (e.key === '3' && !e.metaKey && !e.ctrlKey) {
347
406
  e.preventDefault()
348
407
  useEditor.getState().setPhase('furnish')
349
- useEditor.getState().setMode('select')
408
+ useEditor.getState().armToolMode({ mode: 'select' })
350
409
  } else if (e.key === 'f' && !e.metaKey && !e.ctrlKey) {
351
410
  if (isVersionPreviewMode) return
411
+ if (isToolOwnedCanopyForm()) return
352
412
  e.preventDefault()
353
413
  useEditor.getState().setPhase('furnish')
354
- useEditor.getState().setMode('build')
355
- // Set the item tool explicitly so the active tool never inherits a
356
- // stale tool from a prior build session.
357
- useEditor.getState().setTool('item')
414
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'item' })
358
415
  useEditor.getState().setActiveSidebarPanel('items')
359
416
  } else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {
360
417
  if (isVersionPreviewMode) return
361
418
  e.preventDefault()
362
419
  useEditor.getState().setPhase('structure')
363
420
  useEditor.getState().setStructureLayer('zones')
364
- useEditor.getState().setMode('build')
365
- // Set the zone tool explicitly so it never inherits a stale tool.
366
- useEditor.getState().setTool('zone')
421
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'zone' })
367
422
  } else if (e.key === 'm' && !e.metaKey && !e.ctrlKey) {
368
423
  if (isVersionPreviewMode) return
369
424
  e.preventDefault()
@@ -371,39 +426,33 @@ export const useKeyboard = ({
371
426
  editor.setPhase('structure')
372
427
  editor.setStructureLayer('elements')
373
428
  editor.setToolDefaults('measurement', { kind: editor.lastMeasurementKind })
374
- editor.setMode('build')
375
- editor.setTool('measurement')
429
+ editor.armToolMode({ mode: 'build', tool: 'measurement' })
376
430
  }
377
431
  if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
378
432
  e.preventDefault()
379
- useEditor.getState().setMode('select')
433
+ useEditor.getState().armToolMode({ mode: 'select' })
380
434
  useEditor.getState().setFloorplanSelectionTool('click')
381
435
  } else if (e.key === 'b' && !e.metaKey && !e.ctrlKey) {
382
436
  if (isVersionPreviewMode) return
383
437
  e.preventDefault()
384
438
  useEditor.getState().setPhase('structure')
385
439
  useEditor.getState().setStructureLayer('elements')
386
- useEditor.getState().setMode('build')
387
- // Set the wall tool explicitly so B never inherits a stale tool
388
- // (e.g. fence) left over from a prior build session.
389
- useEditor.getState().setTool('wall')
440
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'wall' })
390
441
  } else if (e.key === 'x' && !e.metaKey && !e.ctrlKey) {
391
442
  if (isVersionPreviewMode) return
392
443
  e.preventDefault()
393
- useEditor.getState().setMode('delete')
444
+ useEditor.getState().armToolMode({ mode: 'delete' })
394
445
  } else if (e.key === 'p' && !e.metaKey && !e.ctrlKey) {
395
446
  if (isVersionPreviewMode) return
396
447
  e.preventDefault()
397
- useEditor.getState().primeMaterialPaintFromSelection()
398
448
  useEditor.getState().setPhase('structure')
399
449
  useEditor.getState().setStructureLayer('elements')
400
- useEditor.getState().setMode('material-paint')
450
+ useEditor.getState().armMaterialPaint()
401
451
  } else if (e.key === 'g' && !e.metaKey && !e.ctrlKey) {
402
452
  if (isVersionPreviewMode) return
403
453
  e.preventDefault()
404
- // G for ground. No `setPhase` — `setMode` moves to the site phase itself,
405
- // and doing it here would set the phase twice with a mode reset between.
406
- useEditor.getState().setMode('terrain-sculpt')
454
+ // G for ground. The ToolMode transition moves to the site phase itself.
455
+ useEditor.getState().armToolMode({ mode: 'terrain-sculpt' })
407
456
  } else if (e.key === 'c' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
408
457
  if (isVersionPreviewMode) return
409
458
  e.preventDefault()
@@ -419,13 +468,11 @@ export const useKeyboard = ({
419
468
  } else if (e.key.toLowerCase() === 'z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
420
469
  if (isVersionPreviewMode) return
421
470
  e.preventDefault()
422
- if (cancelInteractionForHistoryShortcut()) return
423
- runRedo()
471
+ runHistoryShortcut('redo')
424
472
  } else if (e.key.toLowerCase() === 'z' && !e.shiftKey && (e.metaKey || e.ctrlKey)) {
425
473
  if (isVersionPreviewMode) return
426
474
  e.preventDefault()
427
- if (cancelInteractionForHistoryShortcut()) return
428
- runUndo()
475
+ runHistoryShortcut('undo')
429
476
  } else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) {
430
477
  e.preventDefault()
431
478
  const { buildingId, levelId } = useViewer.getState().selection
@@ -441,8 +488,10 @@ export const useKeyboard = ({
441
488
  const currentIdx = levelId ? levels.indexOf(levelId as any) : -1
442
489
  const nextIdx = currentIdx < levels.length - 1 ? currentIdx + 1 : currentIdx
443
490
  if (nextIdx !== -1 && nextIdx !== currentIdx) {
491
+ markPerfAction('level-switch', levels[nextIdx] as string)
444
492
  useViewer.getState().setSelection({ levelId: levels[nextIdx] as any })
445
493
  } else if (currentIdx === -1) {
494
+ markPerfAction('level-switch', levels[0] as string)
446
495
  useViewer.getState().setSelection({ levelId: levels[0] as any })
447
496
  }
448
497
  }
@@ -462,8 +511,10 @@ export const useKeyboard = ({
462
511
  const currentIdx = levelId ? levels.indexOf(levelId as any) : -1
463
512
  const prevIdx = currentIdx > 0 ? currentIdx - 1 : currentIdx
464
513
  if (prevIdx !== -1 && prevIdx !== currentIdx) {
514
+ markPerfAction('level-switch', levels[prevIdx] as string)
465
515
  useViewer.getState().setSelection({ levelId: levels[prevIdx] as any })
466
516
  } else if (currentIdx === -1) {
517
+ markPerfAction('level-switch', levels[levels.length - 1] as string)
467
518
  useViewer.getState().setSelection({ levelId: levels[levels.length - 1] as any })
468
519
  }
469
520
  }
@@ -473,7 +524,8 @@ export const useKeyboard = ({
473
524
  !e.metaKey &&
474
525
  !e.ctrlKey &&
475
526
  !isVersionPreviewMode &&
476
- !isPlacingOpening()
527
+ !isToolOwnedRotation() &&
528
+ canRunGlobalRotationShortcut()
477
529
  ) {
478
530
  // `!metaKey && !ctrlKey` lets Cmd/Ctrl+R reach the browser reload instead
479
531
  // of rotating/flipping the selected node.
@@ -482,10 +534,9 @@ export const useKeyboard = ({
482
534
  // open/close toggle lives on E. Windows still use R to toggle
483
535
  // their open/closed state.
484
536
  //
485
- // Skipped entirely while a door/window placement is active
486
- // (`isPlacingOpening`): the placement tool owns R then (flip the draft
487
- // before commit), and the user can have a node selected at the same
488
- // time — without this guard both would fire (double flip + sfx).
537
+ // Skipped while an item, door, window, or roof placement owns rotation.
538
+ // The user can still have a node selected during placement; without this
539
+ // guard both the draft and the selection would rotate.
489
540
  //
490
541
  // References (guide/scan) live in `selectedReferenceId`, not the viewer
491
542
  // selection — check them first, like the Delete arm below.
@@ -565,7 +616,12 @@ export const useKeyboard = ({
565
616
  sfxEmitter.emit('sfx:item-rotate')
566
617
  }
567
618
  }
568
- } else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode && !isPlacingOpening()) {
619
+ } else if (
620
+ (e.key === 't' || e.key === 'T') &&
621
+ !isVersionPreviewMode &&
622
+ !isToolOwnedRotation() &&
623
+ canRunGlobalRotationShortcut()
624
+ ) {
569
625
  // Rotate selected node counter-clockwise
570
626
  // Multi-selection → group rotate, mirroring the R arm above.
571
627
  if (rotateGroupSelection(-1)) {
@@ -683,16 +739,27 @@ export const useKeyboard = ({
683
739
  }
684
740
  }
685
741
  const handleKeyUp = (e: KeyboardEvent) => {
742
+ if (e.key === 'Shift') {
743
+ const wasClean = shiftTapClean
744
+ shiftTapClean = false
745
+ if (!wasClean) return
746
+ if (blocksSnappingShortcut(e.target instanceof HTMLElement ? e.target : null)) {
747
+ return
748
+ }
749
+ if (!canCycleSnappingModeShortcut()) return
750
+ e.preventDefault()
751
+ useEditor.getState().cycleSnappingMode()
752
+ sfxEmitter.emit('sfx:grid-snap')
753
+ return
754
+ }
686
755
  if (e.key === 'Control' || e.key === 'Meta') {
687
756
  const wasClean = ctrlTapClean
688
757
  ctrlTapClean = false
689
758
  if (!wasClean) return
690
- // Same scope as the Shift snapping-mode cycle, and never while typing
691
- // in an input.
692
- if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
759
+ if (blocksSnappingShortcut(e.target instanceof HTMLElement ? e.target : null)) {
693
760
  return
694
761
  }
695
- if (!isSnappingCycleContext()) return
762
+ if (!canCycleSnappingModeShortcut()) return
696
763
  // Cycle the grid / measurement step (0.5 → 0.25 → 0.1 → 0.05).
697
764
  useEditor.getState().cycleGridSnapStep()
698
765
  sfxEmitter.emit('sfx:grid-snap')
@@ -706,6 +773,8 @@ export const useKeyboard = ({
706
773
  // registry move overlay) — safe only because none of them claim Ctrl/Cmd+G.
707
774
  // `e.code` keeps it on the physical G key across keyboard layouts.
708
775
  const handleSessionGroupKeyDown = (e: KeyboardEvent) => {
776
+ if (e.key !== 'Shift') shiftTapClean = false
777
+ if (e.key !== 'Control' && e.key !== 'Meta') ctrlTapClean = false
709
778
  if (
710
779
  e.target instanceof HTMLInputElement ||
711
780
  e.target instanceof HTMLTextAreaElement ||
@@ -0,0 +1,20 @@
1
+ 'use client'
2
+
3
+ import { isNodeKindEnabled, useScene } from '@pascal-app/core'
4
+ import { useEffect } from 'react'
5
+ import useEditor, { type Tool } from '../store/use-editor'
6
+
7
+ export function useRegisteredToolEnabled(tool: Tool | null): boolean {
8
+ const installedPlugins = useScene((state) => state.installedPlugins)
9
+ const enabled = tool === null || isNodeKindEnabled(tool, installedPlugins)
10
+
11
+ useEffect(() => {
12
+ if (enabled || tool === null) return
13
+
14
+ // The render gate has already unmounted the tool, so its own cleanup cancels
15
+ // any draft before clearing the stale selection prevents reinstall remounts.
16
+ if (useEditor.getState().tool === tool) useEditor.getState().setTool(null)
17
+ }, [enabled, tool])
18
+
19
+ return enabled
20
+ }
@@ -0,0 +1,33 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef } from 'react'
4
+
5
+ /**
6
+ * Claims Cmd/Ctrl+S for the app's save.
7
+ *
8
+ * Capture phase and ungated on purpose: the browser's "Save page" dialog must
9
+ * never appear anywhere in the editor — including first-person, studio mode and
10
+ * while focus sits in an input, where people still expect the chord to save.
11
+ * `e.code` keeps it on the physical S key across keyboard layouts.
12
+ */
13
+ export function useSaveShortcut(onSave: () => void) {
14
+ const onSaveRef = useRef(onSave)
15
+
16
+ useEffect(() => {
17
+ onSaveRef.current = onSave
18
+ }, [onSave])
19
+
20
+ useEffect(() => {
21
+ const handleKeyDown = (e: KeyboardEvent) => {
22
+ if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return
23
+ if (e.code !== 'KeyS') return
24
+
25
+ e.preventDefault()
26
+ e.stopPropagation()
27
+ onSaveRef.current()
28
+ }
29
+
30
+ window.addEventListener('keydown', handleKeyDown, true)
31
+ return () => window.removeEventListener('keydown', handleKeyDown, true)
32
+ }, [])
33
+ }