@pascal-app/editor 1.0.0 → 1.0.3
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/package.json +12 -6
- package/src/components/editor/bake-exporter.tsx +4 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- package/src/components/editor/custom-camera-controls.tsx +22 -74
- package/src/components/editor/editor-layout-mobile.tsx +1 -0
- package/src/components/editor/floating-action-menu.tsx +14 -1
- package/src/components/editor/floorplan-panel.tsx +233 -166
- package/src/components/editor/grid.tsx +31 -7
- package/src/components/editor/group-actions.ts +5 -9
- package/src/components/editor/group-floating-action-menu.tsx +19 -9
- package/src/components/editor/group-move-3d.ts +4 -6
- package/src/components/editor/group-rotate-handle.tsx +81 -35
- package/src/components/editor/group-selection-box-3d.tsx +25 -17
- package/src/components/editor/group-transform-shared.test.ts +126 -1
- package/src/components/editor/group-transform-shared.ts +79 -73
- package/src/components/editor/handles/handle-arrow.tsx +72 -2
- package/src/components/editor/handles/handle-placement.test.ts +111 -0
- package/src/components/editor/handles/handle-placement.ts +69 -0
- package/src/components/editor/handles/linear-resize-drag.ts +13 -2
- package/src/components/editor/handles/use-handle-drag.ts +80 -8
- package/src/components/editor/index.tsx +36 -13
- package/src/components/editor/node-action-menu.tsx +2 -0
- package/src/components/editor/node-arrow-handles.tsx +162 -69
- package/src/components/editor/registry-action-contributions.tsx +53 -0
- package/src/components/editor/selection-manager.tsx +49 -0
- package/src/components/editor/thumbnail-generator.tsx +11 -2
- package/src/components/editor/wall-move-side-handles.tsx +71 -21
- package/src/components/editor/wall-opening-highlights.tsx +2 -3
- package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
- package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +113 -24
- package/src/components/procedural-items/inspector.tsx +223 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
- package/src/components/systems/zone/zone-system.tsx +8 -4
- package/src/components/tools/__fixtures__/hosting.ts +10 -0
- package/src/components/tools/item/item-surface-placement.test.ts +169 -0
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +204 -0
- package/src/components/tools/item/placement-strategies.ts +212 -200
- package/src/components/tools/item/placement-types.ts +4 -0
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
- package/src/components/tools/item/use-draft-node.ts +136 -47
- package/src/components/tools/item/use-placement-coordinator.tsx +391 -157
- package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
- package/src/components/tools/registry/item-surface-move.test.ts +509 -0
- package/src/components/tools/registry/item-surface-move.ts +463 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +237 -78
- package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
- package/src/components/tools/shared/affordance-dispatch.ts +13 -0
- package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
- package/src/components/tools/shared/pointer-support-cap.ts +27 -13
- package/src/components/tools/shared/shelf-stickiness.ts +54 -0
- package/src/components/tools/shared/surface-hit.test.ts +56 -0
- package/src/components/tools/shared/surface-hit.ts +23 -0
- package/src/components/tools/shared/surface-rejection.test.tsx +107 -0
- package/src/components/tools/shared/surface-rejection.tsx +43 -0
- package/src/components/tools/tool-manager.tsx +36 -3
- package/src/components/tools/zone/zone-tool.tsx +13 -4
- package/src/components/ui/command-palette/editor-commands.tsx +54 -1
- package/src/components/ui/controls/material-paint-panel.tsx +6 -67
- package/src/components/ui/controls/material-picker.tsx +5 -67
- package/src/components/ui/controls/metric-control.tsx +29 -13
- package/src/components/ui/controls/slider-control.tsx +87 -35
- package/src/components/ui/floating-level-selector.tsx +81 -5
- package/src/components/ui/helpers/helper-manager.tsx +24 -6
- package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
- package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
- package/src/components/ui/panels/multi-height-mode.tsx +27 -21
- package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
- package/src/components/ui/panels/parametric-field-control.tsx +1 -1
- package/src/components/ui/panels/parametric-inspector.tsx +9 -17
- package/src/components/ui/panels/reference-panel.tsx +3 -3
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- package/src/components/ui/primitives/shortcut-token.tsx +4 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +669 -336
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +30 -11
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
- package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
- package/src/components/ui/snap-target-badge.tsx +2 -0
- package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
- package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
- package/src/components/viewer/floorplan-preview.tsx +1 -12
- package/src/components/viewer/viewer-scene-header.tsx +3 -0
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- package/src/components/viewer-zone-system.tsx +7 -2
- package/src/hooks/use-auto-save.integration.test.tsx +197 -0
- package/src/hooks/use-auto-save.ts +21 -6
- package/src/hooks/use-ceiling-events.test.ts +25 -2
- package/src/hooks/use-ceiling-events.ts +7 -1
- package/src/hooks/use-grid-events.test.ts +2 -0
- package/src/hooks/use-grid-events.ts +16 -11
- package/src/hooks/use-keyboard.test.ts +7 -0
- package/src/hooks/use-keyboard.ts +43 -28
- package/src/index.tsx +68 -3
- package/src/lib/catalog-panel-model.ts +39 -0
- package/src/lib/catalog-selection.test.ts +27 -0
- package/src/lib/catalog-selection.ts +8 -0
- package/src/lib/contextual-help.test.ts +27 -1
- package/src/lib/contextual-help.ts +16 -9
- package/src/lib/export-texture-utils.test.ts +167 -0
- package/src/lib/export-texture-utils.ts +121 -0
- package/src/lib/export-user-data.test.ts +48 -0
- package/src/lib/export-user-data.ts +69 -0
- package/src/lib/floorplan/floorplan-export.test.ts +142 -2
- package/src/lib/floorplan/floorplan-export.tsx +9 -3
- package/src/lib/floorplan/floorplan-extension.ts +16 -0
- package/src/lib/floorplan/floorplan-readonly.ts +4 -0
- package/src/lib/fresh-planar-placement.ts +173 -33
- package/src/lib/glb-export-serialize.test.ts +280 -0
- package/src/lib/glb-export.test.ts +265 -16
- package/src/lib/glb-export.ts +197 -37
- package/src/lib/grid-frame.test.ts +19 -0
- package/src/lib/grid-frame.ts +18 -0
- package/src/lib/interaction/scope.ts +9 -1
- package/src/lib/keyboard-pan.test.ts +62 -0
- package/src/lib/keyboard-pan.ts +89 -0
- package/src/lib/level-duplication.test.ts +73 -0
- package/src/lib/level-duplication.ts +32 -2
- package/src/lib/linear-display.test.ts +59 -0
- package/src/lib/linear-display.ts +39 -0
- package/src/lib/local-guide-image.test.ts +24 -0
- package/src/lib/local-guide-image.ts +34 -1
- package/src/lib/material-catalog-model.ts +72 -0
- package/src/lib/material-paint-panel-model.ts +76 -0
- package/src/lib/node-action-movement.ts +21 -0
- package/src/lib/overlay-transform.test.ts +28 -0
- package/src/lib/overlay-transform.ts +12 -0
- package/src/lib/panel-rows.ts +75 -0
- package/src/lib/panel-tool-options.test.ts +34 -0
- package/src/lib/panel-tool-options.ts +46 -0
- package/src/lib/parametric-node-update.ts +37 -0
- package/src/lib/portable-export.test.ts +89 -0
- package/src/lib/portable-export.ts +84 -11
- package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
- package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
- package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
- package/src/lib/print-shell-compiler-protocol.ts +8 -0
- package/src/lib/scene-clipboard.ts +1 -1
- package/src/lib/scene.test.ts +99 -0
- package/src/lib/scene.ts +21 -3
- package/src/lib/send-to-app.test.ts +108 -0
- package/src/lib/send-to-app.ts +121 -0
- package/src/lib/sfx/movement-tick.ts +37 -0
- package/src/lib/snapping-mode.test.ts +13 -2
- package/src/lib/snapping-mode.ts +11 -6
- package/src/lib/spatial-drag-plane.test.ts +53 -0
- package/src/lib/spatial-drag-plane.ts +19 -0
- package/src/lib/spatial-pointer-input.test.ts +72 -0
- package/src/lib/spatial-pointer-input.ts +62 -0
- package/src/lib/spatial-snap.test.ts +47 -0
- package/src/lib/surface-attachment.ts +77 -0
- package/src/lib/terrain-panel-model.ts +117 -0
- package/src/lib/units.ts +254 -0
- package/src/lib/usdz-export.ts +43 -16
- package/src/lib/use-linear-display.ts +8 -32
- package/src/lib/world-grid-snap.test.ts +48 -0
- package/src/lib/world-grid-snap.ts +9 -5
- package/src/store/rotation-snapping.test.ts +53 -0
- package/src/store/use-editor.tsx +10 -2
- package/src/store/use-floorplan-draft-preview.ts +10 -1
- package/src/store/use-handle-group.ts +4 -0
- package/src/store/use-interaction-scope.ts +155 -6
- package/src/store/use-send-to-app.ts +21 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { afterEach, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
BuildingNode,
|
|
4
|
+
ColumnNode,
|
|
5
|
+
clearSceneHistory,
|
|
6
|
+
LevelNode,
|
|
7
|
+
SiteNode,
|
|
8
|
+
useScene,
|
|
9
|
+
} from '@pascal-app/core'
|
|
10
|
+
import { create } from '@react-three/test-renderer'
|
|
11
|
+
import { type SaveStatus, useAutoSave } from './use-auto-save'
|
|
12
|
+
|
|
13
|
+
const originalWindow = globalThis.window
|
|
14
|
+
const previousScene = useScene.getState()
|
|
15
|
+
let renderer: Awaited<ReturnType<typeof create>> | undefined
|
|
16
|
+
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
await renderer?.unmount()
|
|
19
|
+
renderer = undefined
|
|
20
|
+
globalThis.window = originalWindow
|
|
21
|
+
useScene.setState(previousScene, true)
|
|
22
|
+
clearSceneHistory()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
function graph(count: number) {
|
|
26
|
+
if (count === 0) return { nodes: {}, rootNodeIds: [] }
|
|
27
|
+
const columns = Array.from({ length: count - 3 }, (_, index) =>
|
|
28
|
+
ColumnNode.parse({ id: `column_autosave_${index}`, parentId: 'level_autosave' }),
|
|
29
|
+
)
|
|
30
|
+
const level = LevelNode.parse({
|
|
31
|
+
id: 'level_autosave',
|
|
32
|
+
parentId: 'building_autosave',
|
|
33
|
+
children: columns.map((node) => node.id),
|
|
34
|
+
})
|
|
35
|
+
const building = BuildingNode.parse({
|
|
36
|
+
id: 'building_autosave',
|
|
37
|
+
parentId: 'site_autosave',
|
|
38
|
+
children: [level.id],
|
|
39
|
+
})
|
|
40
|
+
const site = SiteNode.parse({ id: 'site_autosave', children: [building.id] })
|
|
41
|
+
const nodes = [site, building, level, ...columns]
|
|
42
|
+
return { nodes: Object.fromEntries(nodes.map((node) => [node.id, node])), rootNodeIds: [site.id] }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function mount(
|
|
46
|
+
loadedCount: number,
|
|
47
|
+
guardAgainstSceneWipe?: boolean,
|
|
48
|
+
save?: () => Promise<void>,
|
|
49
|
+
) {
|
|
50
|
+
globalThis.window = new EventTarget() as unknown as Window & typeof globalThis
|
|
51
|
+
useScene.setState({ ...graph(0), readOnly: false })
|
|
52
|
+
clearSceneHistory()
|
|
53
|
+
const writes: number[] = []
|
|
54
|
+
const statuses: SaveStatus[] = []
|
|
55
|
+
let controls: ReturnType<typeof useAutoSave> | undefined
|
|
56
|
+
function Host(props: { guardAgainstSceneWipe?: boolean; isVersionPreviewMode?: boolean }) {
|
|
57
|
+
controls = useAutoSave({
|
|
58
|
+
...props,
|
|
59
|
+
onSave: async (scene) => {
|
|
60
|
+
writes.push(Object.keys(scene.nodes).length)
|
|
61
|
+
await save?.()
|
|
62
|
+
},
|
|
63
|
+
onSaveStatusChange: (status) => statuses.push(status),
|
|
64
|
+
})
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
renderer = await create(<Host guardAgainstSceneWipe={guardAgainstSceneWipe} />)
|
|
68
|
+
useScene.setState(graph(loadedCount))
|
|
69
|
+
clearSceneHistory()
|
|
70
|
+
controls!.isLoadingSceneRef.current = false
|
|
71
|
+
statuses.length = 0
|
|
72
|
+
return {
|
|
73
|
+
writes,
|
|
74
|
+
statuses,
|
|
75
|
+
controls: controls!,
|
|
76
|
+
update: (props: { guardAgainstSceneWipe?: boolean; isVersionPreviewMode?: boolean }) =>
|
|
77
|
+
renderer!.update(<Host {...props} />),
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
test.each([
|
|
82
|
+
'undo',
|
|
83
|
+
'redo',
|
|
84
|
+
'delete',
|
|
85
|
+
] as const)('autosave permits a 5 to 4 single-node %s', async (action) => {
|
|
86
|
+
const { writes, statuses } = await mount(5)
|
|
87
|
+
if (action === 'undo') {
|
|
88
|
+
useScene.setState(graph(4))
|
|
89
|
+
clearSceneHistory()
|
|
90
|
+
useScene.setState(graph(5))
|
|
91
|
+
useScene.temporal.getState().undo()
|
|
92
|
+
} else if (action === 'redo') {
|
|
93
|
+
useScene.getState().deleteNode('column_autosave_1')
|
|
94
|
+
useScene.temporal.getState().undo()
|
|
95
|
+
useScene.temporal.getState().redo()
|
|
96
|
+
} else {
|
|
97
|
+
useScene.getState().deleteNode('column_autosave_1')
|
|
98
|
+
}
|
|
99
|
+
expect(Object.keys(useScene.getState().nodes)).toHaveLength(4)
|
|
100
|
+
await Bun.sleep(1100)
|
|
101
|
+
expect(statuses).not.toContain('error')
|
|
102
|
+
expect(writes).toEqual([4])
|
|
103
|
+
expect(statuses.at(-1)).toBe('saved')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('autosave permits a single-node deletion on exit', async () => {
|
|
107
|
+
const { writes, statuses } = await mount(5)
|
|
108
|
+
useScene.getState().deleteNode('column_autosave_1')
|
|
109
|
+
window.dispatchEvent(new Event('pagehide'))
|
|
110
|
+
expect(statuses).not.toContain('error')
|
|
111
|
+
expect(writes).toEqual([4])
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('autosave blocks a full wipe and resumes after an allowed edit', async () => {
|
|
115
|
+
const { writes, statuses, controls } = await mount(78)
|
|
116
|
+
useScene.setState(graph(4))
|
|
117
|
+
controls.saveNow()
|
|
118
|
+
window.dispatchEvent(new Event('pagehide'))
|
|
119
|
+
expect(statuses).toContain('error')
|
|
120
|
+
expect(writes).toEqual([])
|
|
121
|
+
useScene.setState(graph(5))
|
|
122
|
+
controls.saveNow()
|
|
123
|
+
await Bun.sleep(0)
|
|
124
|
+
expect(writes).toEqual([5])
|
|
125
|
+
expect(statuses.at(-1)).toBe('saved')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('collaboration scaffold deletion waits for persistence and reports saved', async () => {
|
|
129
|
+
const idle = Promise.withResolvers<void>()
|
|
130
|
+
const { writes, statuses } = await mount(78, false, () => idle.promise)
|
|
131
|
+
useScene.setState(graph(4))
|
|
132
|
+
await Bun.sleep(1100)
|
|
133
|
+
expect(statuses).not.toContain('error')
|
|
134
|
+
expect(writes).toEqual([4])
|
|
135
|
+
expect(statuses).toEqual(['pending', 'saving'])
|
|
136
|
+
idle.resolve()
|
|
137
|
+
await Bun.sleep(0)
|
|
138
|
+
expect(statuses).toEqual(['pending', 'saving', 'saved'])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test.each([
|
|
142
|
+
'pagehide',
|
|
143
|
+
'unmount',
|
|
144
|
+
])('collaboration scaffold deletion still flushes on %s', async (event) => {
|
|
145
|
+
const { writes, statuses } = await mount(78, false)
|
|
146
|
+
useScene.setState(graph(4))
|
|
147
|
+
if (event === 'pagehide') window.dispatchEvent(new Event(event))
|
|
148
|
+
else {
|
|
149
|
+
await renderer!.unmount()
|
|
150
|
+
renderer = undefined
|
|
151
|
+
}
|
|
152
|
+
expect(statuses).not.toContain('error')
|
|
153
|
+
expect(writes).toEqual([4])
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('collaboration guard exemption preserves preview pause and resume', async () => {
|
|
157
|
+
const { writes, statuses, update } = await mount(78, false)
|
|
158
|
+
useScene.setState(graph(4))
|
|
159
|
+
await update({ guardAgainstSceneWipe: false, isVersionPreviewMode: true })
|
|
160
|
+
expect(statuses.at(-1)).toBe('paused')
|
|
161
|
+
await Bun.sleep(1100)
|
|
162
|
+
expect(writes).toEqual([])
|
|
163
|
+
await update({ guardAgainstSceneWipe: false, isVersionPreviewMode: false })
|
|
164
|
+
await Bun.sleep(1100)
|
|
165
|
+
expect(writes).toEqual([4])
|
|
166
|
+
expect(statuses.at(-1)).toBe('saved')
|
|
167
|
+
expect(statuses).not.toContain('error')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('changing guard ownership recovers an error and keeps the new stored baseline', async () => {
|
|
171
|
+
const { writes, statuses, controls, update } = await mount(78)
|
|
172
|
+
useScene.setState(graph(4))
|
|
173
|
+
controls.saveNow()
|
|
174
|
+
expect(statuses.at(-1)).toBe('error')
|
|
175
|
+
await update({ guardAgainstSceneWipe: false })
|
|
176
|
+
controls.saveNow()
|
|
177
|
+
await Bun.sleep(0)
|
|
178
|
+
expect(writes).toEqual([4])
|
|
179
|
+
expect(statuses.at(-1)).toBe('saved')
|
|
180
|
+
await update({ guardAgainstSceneWipe: true })
|
|
181
|
+
useScene.setState(graph(3))
|
|
182
|
+
controls.saveNow()
|
|
183
|
+
await Bun.sleep(0)
|
|
184
|
+
expect(writes).toEqual([4, 3])
|
|
185
|
+
expect(statuses.at(-1)).toBe('saved')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('collaboration callback failures still report error', async () => {
|
|
189
|
+
const { writes, statuses, controls } = await mount(78, false, async () => {
|
|
190
|
+
throw new Error('Collaboration failed')
|
|
191
|
+
})
|
|
192
|
+
useScene.setState(graph(4))
|
|
193
|
+
controls.saveNow()
|
|
194
|
+
await Bun.sleep(0)
|
|
195
|
+
expect(writes).toEqual([4])
|
|
196
|
+
expect(statuses).toEqual(['pending', 'saving', 'error'])
|
|
197
|
+
})
|
|
@@ -8,7 +8,11 @@ const AUTOSAVE_DEBOUNCE_MS = 1000
|
|
|
8
8
|
const STRUCTURAL_NODE_COUNT = 4
|
|
9
9
|
|
|
10
10
|
export function isSuspiciousNodeDrop(previousNodeCount: number, currentNodeCount: number) {
|
|
11
|
-
return
|
|
11
|
+
return (
|
|
12
|
+
previousNodeCount > STRUCTURAL_NODE_COUNT &&
|
|
13
|
+
currentNodeCount <= STRUCTURAL_NODE_COUNT &&
|
|
14
|
+
previousNodeCount - currentNodeCount > 1
|
|
15
|
+
)
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
/**
|
|
@@ -37,8 +41,8 @@ export function createStoredNodeCountTracker(initialNodeCount: number) {
|
|
|
37
41
|
* which is an accidental full deletion far more often than an intent. The
|
|
38
42
|
* caller reports the block; on `true` the write becomes the new baseline.
|
|
39
43
|
*/
|
|
40
|
-
allowWrite(nodeCount: number) {
|
|
41
|
-
if (isSuspiciousNodeDrop(count, nodeCount)) return false
|
|
44
|
+
allowWrite(nodeCount: number, guardAgainstSceneWipe = true) {
|
|
45
|
+
if (guardAgainstSceneWipe && isSuspiciousNodeDrop(count, nodeCount)) return false
|
|
42
46
|
count = nodeCount
|
|
43
47
|
return true
|
|
44
48
|
},
|
|
@@ -65,10 +69,14 @@ export function decideExitFlush(opts: {
|
|
|
65
69
|
hasDirtyChanges: boolean
|
|
66
70
|
storedNodeCount: number
|
|
67
71
|
currentNodeCount: number
|
|
72
|
+
guardAgainstSceneWipe?: boolean
|
|
68
73
|
}): ExitFlushDecision {
|
|
69
74
|
if (!opts.hasDirtyChanges) return 'skip-clean'
|
|
70
75
|
if (opts.isLoadingScene) return 'skip-loading'
|
|
71
|
-
if (
|
|
76
|
+
if (
|
|
77
|
+
opts.guardAgainstSceneWipe !== false &&
|
|
78
|
+
isSuspiciousNodeDrop(opts.storedNodeCount, opts.currentNodeCount)
|
|
79
|
+
) {
|
|
72
80
|
return 'blocked-suspicious'
|
|
73
81
|
}
|
|
74
82
|
return 'flush'
|
|
@@ -77,6 +85,7 @@ export function decideExitFlush(opts: {
|
|
|
77
85
|
export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'paused' | 'error'
|
|
78
86
|
|
|
79
87
|
interface UseAutoSaveOptions {
|
|
88
|
+
guardAgainstSceneWipe?: boolean
|
|
80
89
|
onSave?: (scene: SceneGraph, options?: { keepalive?: boolean }) => Promise<void>
|
|
81
90
|
onDirty?: () => void
|
|
82
91
|
onSaveStatusChange?: (status: SaveStatus) => void
|
|
@@ -90,6 +99,7 @@ interface UseAutoSaveOptions {
|
|
|
90
99
|
* ⚠️ Mount in exactly ONE component (the Editor).
|
|
91
100
|
*/
|
|
92
101
|
export function useAutoSave({
|
|
102
|
+
guardAgainstSceneWipe = true,
|
|
93
103
|
onSave,
|
|
94
104
|
onDirty,
|
|
95
105
|
onSaveStatusChange,
|
|
@@ -116,6 +126,7 @@ export function useAutoSave({
|
|
|
116
126
|
const onDirtyRef = useRef(onDirty)
|
|
117
127
|
const onSaveStatusChangeRef = useRef(onSaveStatusChange)
|
|
118
128
|
const isVersionPreviewModeRef = useRef(isVersionPreviewMode)
|
|
129
|
+
const guardAgainstSceneWipeRef = useRef(guardAgainstSceneWipe)
|
|
119
130
|
|
|
120
131
|
useEffect(() => {
|
|
121
132
|
onSaveRef.current = onSave
|
|
@@ -129,6 +140,9 @@ export function useAutoSave({
|
|
|
129
140
|
useEffect(() => {
|
|
130
141
|
isVersionPreviewModeRef.current = isVersionPreviewMode
|
|
131
142
|
}, [isVersionPreviewMode])
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
guardAgainstSceneWipeRef.current = guardAgainstSceneWipe
|
|
145
|
+
}, [guardAgainstSceneWipe])
|
|
132
146
|
|
|
133
147
|
const setSaveStatus = useCallback((status: SaveStatus) => {
|
|
134
148
|
onSaveStatusChangeRef.current?.(status)
|
|
@@ -166,7 +180,7 @@ export function useAutoSave({
|
|
|
166
180
|
|
|
167
181
|
const currentNodeCount = Object.keys(nodes).length
|
|
168
182
|
const previousNodeCount = storedNodeCount.count
|
|
169
|
-
if (!storedNodeCount.allowWrite(currentNodeCount)) {
|
|
183
|
+
if (!storedNodeCount.allowWrite(currentNodeCount, guardAgainstSceneWipeRef.current)) {
|
|
170
184
|
console.warn(
|
|
171
185
|
`[autosave] Blocked: scene dropped from ${previousNodeCount} to ${currentNodeCount} nodes. Likely accidental deletion.`,
|
|
172
186
|
)
|
|
@@ -266,6 +280,7 @@ export function useAutoSave({
|
|
|
266
280
|
hasDirtyChanges: hasDirtyChangesRef.current,
|
|
267
281
|
storedNodeCount: previousNodeCount,
|
|
268
282
|
currentNodeCount,
|
|
283
|
+
guardAgainstSceneWipe: guardAgainstSceneWipeRef.current,
|
|
269
284
|
})
|
|
270
285
|
if (decision === 'skip-clean') return
|
|
271
286
|
if (decision === 'skip-loading') {
|
|
@@ -282,7 +297,7 @@ export function useAutoSave({
|
|
|
282
297
|
return
|
|
283
298
|
}
|
|
284
299
|
// 'flush' — adopt the write as the new stored baseline.
|
|
285
|
-
storedNodeCount.allowWrite(currentNodeCount)
|
|
300
|
+
storedNodeCount.allowWrite(currentNodeCount, guardAgainstSceneWipeRef.current)
|
|
286
301
|
|
|
287
302
|
hasDirtyChangesRef.current = false
|
|
288
303
|
const sceneGraph = {
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
sceneRegistry,
|
|
8
8
|
useScene,
|
|
9
9
|
} from '@pascal-app/core'
|
|
10
|
+
import { ProceduralItemNode, shelfRecipe } from '@pascal-app/core/procedural-items'
|
|
10
11
|
import { hideFromScene, showInScene, useViewer } from '@pascal-app/viewer'
|
|
11
12
|
import { _roots, act, createRoot } from '@react-three/fiber'
|
|
12
13
|
import { createElement } from 'react'
|
|
@@ -18,11 +19,16 @@ import {
|
|
|
18
19
|
type WebGLRenderer,
|
|
19
20
|
} from 'three'
|
|
20
21
|
import useEditor from '../store/use-editor'
|
|
22
|
+
import useInteractionScope from '../store/use-interaction-scope'
|
|
21
23
|
import { useCeilingEvents } from './use-ceiling-events'
|
|
22
24
|
|
|
23
|
-
test(
|
|
25
|
+
test.each([
|
|
26
|
+
'item',
|
|
27
|
+
'procedural-item',
|
|
28
|
+
])('%s ceiling placement keeps move and commit hits while an unhovered ceiling is batched', async (kind) => {
|
|
24
29
|
const previousViewer = useViewer.getState()
|
|
25
30
|
const previousEditor = useEditor.getState()
|
|
31
|
+
const previousInteraction = useInteractionScope.getState()
|
|
26
32
|
const previousScene = useScene.getState()
|
|
27
33
|
const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
28
34
|
const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT
|
|
@@ -73,13 +79,29 @@ test('ceiling-item placement keeps move and commit hits while an unhovered ceili
|
|
|
73
79
|
cameraDragging: false,
|
|
74
80
|
selection: { buildingId: null, levelId: level.id, zoneId: null, selectedIds: [] },
|
|
75
81
|
})
|
|
76
|
-
useEditor.setState({ selectedItem: { attachTo: 'ceiling' } as never })
|
|
82
|
+
if (kind === 'item') useEditor.setState({ selectedItem: { attachTo: 'ceiling' } as never })
|
|
83
|
+
else {
|
|
84
|
+
useEditor.setState({ selectedItem: null })
|
|
85
|
+
useEditor.getState().setMovingNode(
|
|
86
|
+
ProceduralItemNode.parse({
|
|
87
|
+
recipe: {
|
|
88
|
+
...shelfRecipe,
|
|
89
|
+
mounting: { attachTo: 'ceiling', reference: 'top' },
|
|
90
|
+
surfaces: [
|
|
91
|
+
{ id: 'top', label: 'Top', position: [0, 'height', 0], size: ['width', 'depth'] },
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
parentId: ceiling.id,
|
|
95
|
+
}) as never,
|
|
96
|
+
)
|
|
97
|
+
}
|
|
77
98
|
await root.configure({
|
|
78
99
|
gl: {
|
|
79
100
|
domElement: canvas,
|
|
80
101
|
render() {},
|
|
81
102
|
setSize() {},
|
|
82
103
|
setPixelRatio() {},
|
|
104
|
+
xr: { isPresenting: false },
|
|
83
105
|
} as unknown as WebGLRenderer,
|
|
84
106
|
camera,
|
|
85
107
|
frameloop: 'never',
|
|
@@ -116,6 +138,7 @@ test('ceiling-item placement keeps move and commit hits while an unhovered ceili
|
|
|
116
138
|
surface.material.dispose()
|
|
117
139
|
useScene.setState(previousScene)
|
|
118
140
|
useEditor.setState(previousEditor)
|
|
141
|
+
useInteractionScope.setState(previousInteraction)
|
|
119
142
|
useViewer.setState(previousViewer)
|
|
120
143
|
actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct
|
|
121
144
|
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
sceneRegistry,
|
|
8
8
|
useScene,
|
|
9
9
|
} from '@pascal-app/core'
|
|
10
|
+
import { isProceduralItem } from '@pascal-app/core/procedural-items'
|
|
10
11
|
import { useViewer } from '@pascal-app/viewer'
|
|
11
12
|
import { useThree } from '@react-three/fiber'
|
|
12
13
|
import { useEffect, useRef } from 'react'
|
|
@@ -55,13 +56,18 @@ export function useCeilingEvents() {
|
|
|
55
56
|
const hoveredRef = useRef<string | null>(null)
|
|
56
57
|
|
|
57
58
|
useEffect(() => {
|
|
59
|
+
// XR placement is driven by the controller-ray bridge, never screen coordinates.
|
|
60
|
+
if (gl.xr.isPresenting) return
|
|
58
61
|
const canvas = gl.domElement
|
|
59
62
|
|
|
60
63
|
const isActive = (): boolean => {
|
|
61
64
|
const ed = useEditor.getState()
|
|
62
65
|
if (ed.selectedItem?.attachTo === 'ceiling') return true
|
|
63
66
|
const moving = getMovingNode()
|
|
64
|
-
return
|
|
67
|
+
return (
|
|
68
|
+
(moving?.type === 'item' && moving.asset?.attachTo === 'ceiling') ||
|
|
69
|
+
(isProceduralItem(moving) && moving.recipe.mounting?.attachTo === 'ceiling')
|
|
70
|
+
)
|
|
65
71
|
}
|
|
66
72
|
|
|
67
73
|
type Hit = { node: CeilingNode; mesh: Object3D; world: Vector3; local: Vector3 }
|
|
@@ -70,6 +70,7 @@ test('grid moves throttle camera drags at 100 ms and resume immediately during t
|
|
|
70
70
|
render() {},
|
|
71
71
|
setSize() {},
|
|
72
72
|
setPixelRatio() {},
|
|
73
|
+
xr: { isPresenting: false },
|
|
73
74
|
} as unknown as WebGLRenderer,
|
|
74
75
|
camera,
|
|
75
76
|
frameloop: 'never',
|
|
@@ -199,6 +200,7 @@ test.each([
|
|
|
199
200
|
render() {},
|
|
200
201
|
setSize() {},
|
|
201
202
|
setPixelRatio() {},
|
|
203
|
+
xr: { isPresenting: false },
|
|
202
204
|
} as unknown as WebGLRenderer,
|
|
203
205
|
camera,
|
|
204
206
|
frameloop: 'never',
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
type EventSuffix,
|
|
4
4
|
emitter,
|
|
5
5
|
type GridEvent,
|
|
6
|
+
hiddenWallPointerEventsHeld,
|
|
6
7
|
nodeRegistry,
|
|
7
8
|
sceneRegistry,
|
|
8
9
|
useScene,
|
|
@@ -58,6 +59,8 @@ export function useGridEvents(gridY: number) {
|
|
|
58
59
|
}, [gridY])
|
|
59
60
|
|
|
60
61
|
useEffect(() => {
|
|
62
|
+
// XR placement is driven by the controller-ray bridge, never screen coordinates.
|
|
63
|
+
if (gl.xr.isPresenting) return
|
|
61
64
|
const canvas = gl.domElement
|
|
62
65
|
setSurfaceRaycastLayers(raycaster.current.layers)
|
|
63
66
|
|
|
@@ -132,7 +135,10 @@ export function useGridEvents(gridY: number) {
|
|
|
132
135
|
// Architectural meshes are expensive to raycast and are meaningful only
|
|
133
136
|
// to an active placement/drafting interaction. Floor tools retain the
|
|
134
137
|
// ordinary terrain/grid intersection without scanning every wall.
|
|
135
|
-
const surfaceHit =
|
|
138
|
+
const surfaceHit =
|
|
139
|
+
semanticSurfaceQueryRef.current || hiddenWallPointerEventsHeld()
|
|
140
|
+
? getSurfaceIntersection()
|
|
141
|
+
: null
|
|
136
142
|
|
|
137
143
|
// A semantic architectural hit is the authoritative cursor position.
|
|
138
144
|
// Do not replace it with the terrain/grid intersection below: that would
|
|
@@ -244,16 +250,15 @@ export function useGridEvents(gridY: number) {
|
|
|
244
250
|
: undefined,
|
|
245
251
|
surfaceNormal: localNormal ? [localNormal.x, localNormal.y, localNormal.z] : undefined,
|
|
246
252
|
surfaceObject: point.surface?.object,
|
|
247
|
-
surfaceHit:
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
: undefined,
|
|
253
|
+
surfaceHit: point.surface
|
|
254
|
+
? {
|
|
255
|
+
kind: point.surface.descriptor.kind,
|
|
256
|
+
hostId: point.surface.hostId,
|
|
257
|
+
face: classifiedFace?.face ?? 'unknown',
|
|
258
|
+
levelId: useViewer.getState().selection.levelId ?? undefined,
|
|
259
|
+
side: classifiedFace?.side,
|
|
260
|
+
}
|
|
261
|
+
: undefined,
|
|
257
262
|
nativeEvent: nativeEvent as any, // Type compatibility with ThreeEvent
|
|
258
263
|
}
|
|
259
264
|
|
|
@@ -106,6 +106,13 @@ describe('rotation shortcut ownership', () => {
|
|
|
106
106
|
expect(isToolOwnedRotation()).toBe(true)
|
|
107
107
|
})
|
|
108
108
|
|
|
109
|
+
test('leaves R to the wall tool for its line / rectangle toggle, but not T', () => {
|
|
110
|
+
useEditor.getState().armToolMode({ mode: 'build', tool: 'wall' })
|
|
111
|
+
|
|
112
|
+
expect(isToolOwnedRotation('r')).toBe(true)
|
|
113
|
+
expect(isToolOwnedRotation('t')).toBe(false)
|
|
114
|
+
})
|
|
115
|
+
|
|
109
116
|
test('leaves R and T to a moving lean-to extension', () => {
|
|
110
117
|
const leanTo = { id: 'lean_to_moving', type: 'lean-to-extension' } as unknown as AnyNode
|
|
111
118
|
useInteractionScope.getState().begin({
|
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
} from '@pascal-app/core'
|
|
10
10
|
import { cancelPerfAction, markPerfAction, useViewer } from '@pascal-app/viewer'
|
|
11
11
|
import { useEffect } from 'react'
|
|
12
|
-
import { Vector3 } from 'three'
|
|
13
12
|
import {
|
|
14
13
|
cutSelectionToEditorClipboard,
|
|
15
14
|
deleteSelection,
|
|
@@ -18,9 +17,9 @@ import {
|
|
|
18
17
|
import {
|
|
19
18
|
classifyParticipant,
|
|
20
19
|
collectParticipants,
|
|
21
|
-
|
|
22
|
-
expandToComponent,
|
|
20
|
+
groupPlanBounds,
|
|
23
21
|
levelFrame,
|
|
22
|
+
planBoundsCenter,
|
|
24
23
|
rotateGroupPatches,
|
|
25
24
|
} from '../components/editor/group-transform-shared'
|
|
26
25
|
import { steppedRotation } from '../components/tools/item/placement-math'
|
|
@@ -32,10 +31,14 @@ import { isActive } from '../lib/interaction/scope'
|
|
|
32
31
|
import { copySelectedNodesToEditorClipboard } from '../lib/scene-clipboard'
|
|
33
32
|
import { sfxEmitter } from '../lib/sfx-bus'
|
|
34
33
|
import { activeSiteNode, clampBrushRadius } from '../lib/terrain-sculpt'
|
|
34
|
+
import { leaveUnitFocus } from '../lib/units'
|
|
35
35
|
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
|
-
import useInteractionScope, {
|
|
38
|
+
import useInteractionScope, {
|
|
39
|
+
getMovingNode,
|
|
40
|
+
isInteractionSubtreeDraft,
|
|
41
|
+
} from '../store/use-interaction-scope'
|
|
39
42
|
import { groupCurrentSelection, ungroupCurrentSelection } from '../store/use-session-groups'
|
|
40
43
|
|
|
41
44
|
// References (guide/scan) are selected via `useEditor.selectedReferenceId`, not
|
|
@@ -65,27 +68,20 @@ function rotateGroupSelection(direction: 1 | -1): boolean {
|
|
|
65
68
|
(id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
|
|
66
69
|
)
|
|
67
70
|
if (participantIds.length === 0) return false
|
|
68
|
-
const
|
|
69
|
-
const { starts, links } = collectParticipants(fullIds, nodes, levelId)
|
|
71
|
+
const { starts, links } = collectParticipants(participantIds, nodes, levelId)
|
|
70
72
|
if (starts.length === 0) return false
|
|
71
73
|
|
|
72
|
-
// Same pivot as the
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const worldCenter = new Vector3(
|
|
78
|
-
(box.min.x + box.max.x) / 2,
|
|
79
|
-
box.min.y,
|
|
80
|
-
(box.min.z + box.max.z) / 2,
|
|
81
|
-
)
|
|
82
|
-
const localCenter = worldCenter.applyMatrix4(levelFrame(levelId).inverse)
|
|
74
|
+
// Same pivot as the dashed boxes and the rotate gizmo: the selection's box
|
|
75
|
+
// centre in the level frame.
|
|
76
|
+
const bounds = groupPlanBounds(starts, levelFrame(levelId).inverse)
|
|
77
|
+
if (!bounds) return false
|
|
78
|
+
const [pivotX, pivotZ] = planBoundsCenter(bounds)
|
|
83
79
|
|
|
84
80
|
// R (+45° yaw) orbits by -45° in the atan2 x→z sense: yaw = rotation - delta
|
|
85
81
|
// (see rotateGroupPatches), so keyboard direction matches the single-node
|
|
86
82
|
// steppedRotation sense.
|
|
87
83
|
const delta = -direction * (Math.PI / 4)
|
|
88
|
-
const patches = rotateGroupPatches(starts, links, { x:
|
|
84
|
+
const patches = rotateGroupPatches(starts, links, { x: pivotX, z: pivotZ }, delta)
|
|
89
85
|
// Space detection stays out: a rigid rotation of existing walls must not
|
|
90
86
|
// re-create the room's auto floors/ceilings at the new bearing.
|
|
91
87
|
pauseSpaceDetection()
|
|
@@ -115,7 +111,13 @@ const exitToSelectAfterUnconsumedCancel = () => {
|
|
|
115
111
|
const currentPhase = useEditor.getState().phase
|
|
116
112
|
const currentStructureLayer = useEditor.getState().structureLayer
|
|
117
113
|
|
|
118
|
-
useInteractionScope
|
|
114
|
+
useInteractionScope
|
|
115
|
+
.getState()
|
|
116
|
+
.endIf(
|
|
117
|
+
(sc) =>
|
|
118
|
+
(sc.kind === 'placing' && isInteractionSubtreeDraft()) ||
|
|
119
|
+
(sc.kind === 'reshaping' && sc.reshape === 'hole'),
|
|
120
|
+
)
|
|
119
121
|
|
|
120
122
|
// From zone mode, return to structure select
|
|
121
123
|
if (currentPhase === 'structure' && currentStructureLayer === 'zones') {
|
|
@@ -133,6 +135,20 @@ const exitToSelectAfterUnconsumedCancel = () => {
|
|
|
133
135
|
useEditor.getState().setSelectedReferenceId(null)
|
|
134
136
|
}
|
|
135
137
|
|
|
138
|
+
// Cancel the active editor action with the same consume-or-exit semantics as
|
|
139
|
+
// Escape. Spatial inputs use this instead of unconditionally selecting the
|
|
140
|
+
// Select tool, so multi-step tools can keep their tool active after clearing
|
|
141
|
+
// the current draft.
|
|
142
|
+
export const cancelActiveTool = () => {
|
|
143
|
+
_toolCancelConsumed = false
|
|
144
|
+
emitter.emit('tool:cancel')
|
|
145
|
+
if (!_toolCancelConsumed) {
|
|
146
|
+
if (leaveUnitFocus()) useEditor.getState().armToolMode({ mode: 'select' })
|
|
147
|
+
else exitToSelectAfterUnconsumedCancel()
|
|
148
|
+
}
|
|
149
|
+
return _toolCancelConsumed
|
|
150
|
+
}
|
|
151
|
+
|
|
136
152
|
// ⌘Z pressed mid-interaction (moving a node, drawing a wall, mid-placement…)
|
|
137
153
|
// reads as "abort this action", not history undo — behave exactly like Escape
|
|
138
154
|
// and report whether anything was in flight so the undo/redo arms know to
|
|
@@ -175,7 +191,8 @@ export const runHistoryShortcut = (direction: 'undo' | 'redo') => {
|
|
|
175
191
|
return true
|
|
176
192
|
}
|
|
177
193
|
|
|
178
|
-
|
|
194
|
+
/** Whether an armed tool owns the rotation key (`R` or `T`) instead of the selection. */
|
|
195
|
+
export const isToolOwnedRotation = (key: 'r' | 't' = 'r') => {
|
|
179
196
|
const editor = useEditor.getState()
|
|
180
197
|
const moving = getMovingNode()
|
|
181
198
|
if (
|
|
@@ -195,7 +212,10 @@ export const isToolOwnedRotation = () => {
|
|
|
195
212
|
// exist. Without this check, selecting an existing item in the 2D plan
|
|
196
213
|
// while the item tool is armed silently drops the global rotate key.
|
|
197
214
|
(editor.tool === 'item' && editor.selectedItem !== null) ||
|
|
198
|
-
editor.tool === 'lean-to-extension'
|
|
215
|
+
editor.tool === 'lean-to-extension' ||
|
|
216
|
+
// R toggles the wall tool between line and rectangle drawing; T stays
|
|
217
|
+
// the selection's.
|
|
218
|
+
(editor.tool === 'wall' && key === 'r'))
|
|
199
219
|
)
|
|
200
220
|
}
|
|
201
221
|
|
|
@@ -384,14 +404,9 @@ export const useKeyboard = ({
|
|
|
384
404
|
return
|
|
385
405
|
}
|
|
386
406
|
|
|
387
|
-
_toolCancelConsumed = false
|
|
388
|
-
emitter.emit('tool:cancel')
|
|
389
|
-
|
|
390
407
|
// Only switch to select mode if no tool had an active mid-action to cancel.
|
|
391
408
|
// (e.g. mid-wall draw or mid-slab polygon should only cancel the action, not exit the tool)
|
|
392
|
-
|
|
393
|
-
exitToSelectAfterUnconsumedCancel()
|
|
394
|
-
}
|
|
409
|
+
cancelActiveTool()
|
|
395
410
|
} else if (e.key === '1' && !e.metaKey && !e.ctrlKey) {
|
|
396
411
|
e.preventDefault()
|
|
397
412
|
useEditor.getState().setPhase('site')
|
|
@@ -617,7 +632,7 @@ export const useKeyboard = ({
|
|
|
617
632
|
} else if (
|
|
618
633
|
(e.key === 't' || e.key === 'T') &&
|
|
619
634
|
!isVersionPreviewMode &&
|
|
620
|
-
!isToolOwnedRotation() &&
|
|
635
|
+
!isToolOwnedRotation('t') &&
|
|
621
636
|
canRunGlobalRotationShortcut()
|
|
622
637
|
) {
|
|
623
638
|
// Rotate selected node counter-clockwise
|