@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.
- package/LICENSE +21 -0
- package/bunfig.toml +4 -0
- package/package.json +15 -8
- package/scripts/generate-print-golden-house.ts +89 -0
- package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
- package/src/components/editor/bake-exporter.tsx +1 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
- package/src/components/editor/capture-camera-rig.tsx +59 -0
- package/src/components/editor/custom-camera-controls.tsx +20 -11
- package/src/components/editor/editor-layout-mobile.tsx +6 -6
- package/src/components/editor/editor-layout-v2.tsx +17 -5
- package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
- package/src/components/editor/export-manager.tsx +175 -33
- package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
- package/src/components/editor/first-person-controls.tsx +182 -57
- package/src/components/editor/floating-action-menu.tsx +55 -55
- package/src/components/editor/floorplan-background-selection.test.ts +31 -4
- package/src/components/editor/floorplan-background-selection.ts +27 -7
- package/src/components/editor/floorplan-panel.tsx +81 -57
- package/src/components/editor/grid.tsx +12 -7
- package/src/components/editor/group-actions.ts +34 -50
- package/src/components/editor/group-floating-action-menu.tsx +30 -15
- package/src/components/editor/group-move-3d.ts +51 -21
- package/src/components/editor/group-transform-shared.test.ts +78 -0
- package/src/components/editor/group-transform-shared.ts +74 -7
- package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
- package/src/components/editor/handles/handle-arrow.tsx +109 -48
- package/src/components/editor/handles/linear-resize-drag.ts +67 -0
- package/src/components/editor/handles/resize-snap.test.ts +46 -0
- package/src/components/editor/handles/resize-snap.ts +10 -1
- package/src/components/editor/handles/use-handle-drag.ts +18 -1
- package/src/components/editor/index.tsx +252 -46
- package/src/components/editor/measurement-pill.tsx +12 -12
- package/src/components/editor/node-action-menu.tsx +42 -1
- package/src/components/editor/node-arrow-handles.tsx +47 -58
- package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
- package/src/components/editor/selection-manager.tsx +132 -47
- package/src/components/editor/site-edge-labels.tsx +2 -1
- package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
- package/src/components/editor/snapshot-capture.test.ts +422 -0
- package/src/components/editor/snapshot-capture.ts +145 -0
- package/src/components/editor/thumbnail-generator.tsx +295 -232
- package/src/components/editor/use-floorplan-background-placement.ts +53 -5
- package/src/components/editor/wall-measurement-label.tsx +3 -2
- package/src/components/editor/wall-move-side-handles.tsx +299 -7
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
- package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +204 -114
- package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
- package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
- package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
- package/src/components/systems/roof/roof-edit-system.tsx +10 -19
- package/src/components/systems/selection-affordance-manager.tsx +59 -9
- package/src/components/systems/selection-affordance-services.ts +19 -0
- package/src/components/systems/stair/stair-edit-system.tsx +5 -2
- package/src/components/tools/fence/fence-drafting.test.ts +100 -0
- package/src/components/tools/fence/fence-drafting.ts +6 -25
- package/src/components/tools/item/draft-creation.test.ts +12 -0
- package/src/components/tools/item/draft-creation.ts +10 -0
- package/src/components/tools/item/face-host-commit.test.ts +65 -0
- package/src/components/tools/item/face-host-commit.ts +56 -0
- package/src/components/tools/item/face-host-preview.test.ts +77 -0
- package/src/components/tools/item/face-host-preview.ts +82 -0
- package/src/components/tools/item/move-tool.tsx +4 -3
- package/src/components/tools/item/placement-math.test.ts +11 -1
- package/src/components/tools/item/placement-math.ts +0 -20
- package/src/components/tools/item/placement-strategies.test.ts +494 -0
- package/src/components/tools/item/placement-strategies.ts +192 -24
- package/src/components/tools/item/placement-surface.test.ts +61 -0
- package/src/components/tools/item/placement-surface.ts +24 -0
- package/src/components/tools/item/placement-types.ts +6 -0
- package/src/components/tools/item/test-face-host.ts +121 -0
- package/src/components/tools/item/use-draft-node.test.tsx +336 -0
- package/src/components/tools/item/use-draft-node.ts +26 -4
- package/src/components/tools/item/use-placement-coordinator.tsx +447 -180
- package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
- package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
- package/src/components/tools/registry-tool-context.tsx +30 -0
- package/src/components/tools/select/box-select-tool.tsx +3 -1
- package/src/components/tools/select/marquee-footprint.test.ts +53 -0
- package/src/components/tools/select/marquee-footprint.ts +29 -0
- package/src/components/tools/shared/placement-box.tsx +63 -8
- package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
- package/src/components/tools/shared/pointer-support-cap.ts +45 -8
- package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
- package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
- package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
- package/src/components/tools/stair/stair-tool.tsx +127 -74
- package/src/components/tools/tool-manager.tsx +25 -8
- package/src/components/tools/wall/wall-drafting.test.ts +527 -4
- package/src/components/tools/wall/wall-drafting.ts +55 -462
- package/src/components/ui/action-menu/control-modes.tsx +9 -6
- package/src/components/ui/action-menu/index.tsx +11 -1
- package/src/components/ui/command-palette/editor-commands.tsx +12 -13
- package/src/components/ui/command-palette/index.tsx +7 -2
- package/src/components/ui/controls/material-paint-panel.tsx +6 -4
- package/src/components/ui/controls/material-picker.tsx +4 -4
- package/src/components/ui/controls/scene-material-list.tsx +6 -5
- package/src/components/ui/controls/segmented-control.tsx +8 -1
- package/src/components/ui/controls/slider-control.tsx +19 -3
- package/src/components/ui/controls/toggle-control.tsx +18 -5
- package/src/components/ui/controls/tool-options-panel.tsx +126 -0
- package/src/components/ui/floating-level-selector.tsx +23 -6
- package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
- package/src/components/ui/helpers/helper-manager.tsx +47 -11
- package/src/components/ui/helpers/item-helper.tsx +1 -1
- package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
- package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
- package/src/components/ui/panels/homogeneous-selection.ts +47 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
- package/src/components/ui/panels/multi-field-value.test.ts +209 -0
- package/src/components/ui/panels/multi-field-value.ts +163 -0
- package/src/components/ui/panels/multi-height-mode.tsx +185 -0
- package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +78 -26
- package/src/components/ui/panels/node-display.ts +4 -0
- package/src/components/ui/panels/panel-manager.tsx +77 -3
- package/src/components/ui/panels/panel-wrapper.tsx +228 -12
- package/src/components/ui/panels/parametric-field-control.tsx +173 -0
- package/src/components/ui/panels/parametric-field-utils.ts +18 -0
- package/src/components/ui/panels/parametric-inspector.tsx +29 -162
- package/src/components/ui/panels/reference-panel.tsx +31 -1
- package/src/components/ui/primitives/shortcut-token.tsx +23 -1
- package/src/components/ui/scene-loader.tsx +35 -0
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -23
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +128 -46
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
- package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +22 -7
- package/src/components/ui/sidebar/tab-bar.tsx +9 -1
- package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
- package/src/components/viewer/floorplan-compass-button.tsx +50 -0
- package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
- package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
- package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
- package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
- package/src/components/viewer/floorplan-preview.tsx +1069 -0
- package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
- package/src/components/viewer/viewer-scene-header.tsx +2 -1
- package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
- package/src/components/viewer/viewer-stage-modes.ts +49 -0
- package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
- package/src/components/viewer/viewer-stage.test.tsx +47 -0
- package/src/components/viewer/viewer-stage.tsx +221 -0
- package/src/components/viewer-overlay.tsx +13 -30
- package/src/components/viewer-zone-system.tsx +6 -2
- package/src/components/walkthrough-hud.tsx +19 -10
- package/src/hooks/use-auto-save.test.ts +122 -1
- package/src/hooks/use-auto-save.ts +119 -13
- package/src/hooks/use-ceiling-events.test.ts +122 -0
- package/src/hooks/use-drag-action.ts +17 -3
- package/src/hooks/use-grid-events.test.ts +240 -0
- package/src/hooks/use-grid-events.ts +190 -11
- package/src/hooks/use-keyboard.test.ts +244 -0
- package/src/hooks/use-keyboard.ts +164 -63
- package/src/hooks/use-registered-tool-enabled.ts +20 -0
- package/src/hooks/use-save-shortcut.ts +33 -0
- package/src/index.tsx +109 -4
- package/src/lib/active-placement-surface.test.ts +16 -0
- package/src/lib/active-placement-surface.ts +14 -0
- package/src/lib/ceiling-surface-raycast.test.ts +64 -0
- package/src/lib/ceiling-surface-raycast.ts +12 -0
- package/src/lib/contextual-help-extension.ts +17 -0
- package/src/lib/contextual-help.test.ts +8 -0
- package/src/lib/contextual-help.ts +8 -0
- package/src/lib/continuation.test.ts +11 -0
- package/src/lib/continuation.ts +8 -1
- package/src/lib/direct-manipulation.test.ts +132 -0
- package/src/lib/direct-manipulation.ts +38 -1
- package/src/lib/editor-api.ts +6 -3
- package/src/lib/elevation-guides.test.ts +28 -1
- package/src/lib/elevation-guides.ts +22 -7
- package/src/lib/floating-menu-scale.test.ts +29 -0
- package/src/lib/floating-menu-scale.ts +14 -0
- package/src/lib/floorplan/apply-alignment.test.ts +47 -2
- package/src/lib/floorplan/apply-alignment.ts +10 -1
- package/src/lib/floorplan/floorplan-export.test.ts +397 -1
- package/src/lib/floorplan/floorplan-export.tsx +111 -16
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
- package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
- package/src/lib/floorplan/floorplan-readonly.ts +85 -0
- package/src/lib/floorplan-grid-event-point.test.ts +29 -0
- package/src/lib/floorplan-grid-event-point.ts +13 -0
- package/src/lib/glb-export.test.ts +1169 -21
- package/src/lib/glb-export.ts +629 -101
- package/src/lib/grid-event-presentation.ts +16 -0
- package/src/lib/history.test.ts +523 -77
- package/src/lib/history.ts +133 -14
- package/src/lib/host-tree-children.test.ts +27 -0
- package/src/lib/host-tree-children.ts +79 -0
- package/src/lib/inspector-card-mode.test.ts +92 -0
- package/src/lib/inspector-card-mode.ts +60 -0
- package/src/lib/interaction/hot-set.test.ts +10 -1
- package/src/lib/interaction/hot-set.ts +3 -1
- package/src/lib/interaction/overlay-policy.test.ts +33 -1
- package/src/lib/interaction/overlay-policy.ts +16 -0
- package/src/lib/interaction/registered-drafting.ts +36 -0
- package/src/lib/interaction/scope.ts +31 -0
- package/src/lib/level-print-export.test.ts +484 -0
- package/src/lib/level-print-export.ts +526 -0
- package/src/lib/local-project-presentation-persistence.test.ts +302 -0
- package/src/lib/local-project-presentation-persistence.ts +335 -0
- package/src/lib/material-paint.ts +2 -0
- package/src/lib/measurement-parser.ts +3 -0
- package/src/lib/measurements.test.ts +4 -0
- package/src/lib/model-export.ts +29 -0
- package/src/lib/paint-preview-owner.ts +71 -0
- package/src/lib/paint-scope.test.ts +58 -2
- package/src/lib/paint-scope.ts +8 -2
- package/src/lib/planar-cursor-placement.test.ts +178 -1
- package/src/lib/planar-cursor-placement.ts +61 -5
- package/src/lib/plugin-panels.test.ts +92 -1
- package/src/lib/plugin-panels.ts +48 -0
- package/src/lib/portable-export.test.ts +366 -0
- package/src/lib/portable-export.ts +969 -0
- package/src/lib/print-3mf.test.ts +86 -0
- package/src/lib/print-3mf.ts +161 -0
- package/src/lib/print-content-scope.test.ts +107 -0
- package/src/lib/print-content-scope.ts +90 -0
- package/src/lib/print-export.test.ts +294 -0
- package/src/lib/print-export.ts +735 -0
- package/src/lib/print-feature-thickness.test.ts +147 -0
- package/src/lib/print-feature-thickness.ts +180 -0
- package/src/lib/print-golden-house.test-fixture.ts +352 -0
- package/src/lib/print-golden-house.test.ts +249 -0
- package/src/lib/print-roof-solids.test.ts +133 -0
- package/src/lib/print-roof-solids.ts +630 -0
- package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
- package/src/lib/print-shell-compiler-baseline.ts +225 -0
- package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
- package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
- package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
- package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
- package/src/lib/print-shell-compiler-protocol.ts +30 -0
- package/src/lib/print-shell-compiler.ts +273 -0
- package/src/lib/print-wall-solids.test.ts +194 -0
- package/src/lib/print-wall-solids.ts +328 -0
- package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
- package/src/lib/rigid-plan-svg-transform.ts +24 -0
- package/src/lib/scene.ts +22 -8
- package/src/lib/selection-routing.test.ts +125 -6
- package/src/lib/selection-routing.ts +34 -1
- package/src/lib/session-groups.test.ts +88 -0
- package/src/lib/session-groups.ts +201 -0
- package/src/lib/sfx-bus.ts +4 -0
- package/src/lib/sfx-player.ts +8 -0
- package/src/lib/snapping-mode.test.ts +32 -4
- package/src/lib/snapping-mode.ts +11 -7
- package/src/lib/structured-clone-fallback.test.ts +45 -0
- package/src/lib/structured-clone-fallback.ts +24 -0
- package/src/lib/usdz-export.ts +38 -0
- package/src/lib/walkthrough-pointer-lock.ts +47 -0
- package/src/store/live-draft-preview-stores.test.ts +3 -0
- package/src/store/terrain-sculpt-mode.test.ts +3 -6
- package/src/store/tool-mode.test.ts +87 -0
- package/src/store/use-camera-hint-focus.ts +48 -0
- package/src/store/use-editor.tsx +299 -188
- package/src/store/use-floorplan-draft-preview.ts +9 -0
- package/src/store/use-interaction-scope.test.ts +23 -1
- package/src/store/use-interaction-scope.ts +63 -2
- package/src/store/use-placement-preview.ts +56 -3
- package/src/store/use-session-groups.ts +103 -0
- package/src/components/tools/roof/roof-tool.tsx +0 -656
- 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().
|
|
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().
|
|
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
|
|
177
|
-
//
|
|
178
|
-
//
|
|
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 (
|
|
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().
|
|
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().
|
|
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().
|
|
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().
|
|
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().
|
|
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.
|
|
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().
|
|
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().
|
|
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().
|
|
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().
|
|
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.
|
|
400
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
!
|
|
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
|
|
481
|
-
//
|
|
482
|
-
//
|
|
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 (
|
|
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
|
-
|
|
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 (!
|
|
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
|
}
|