@pascal-app/editor 0.7.0 → 0.9.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.
- package/package.json +12 -13
- package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
- package/src/components/editor/custom-camera-controls.tsx +755 -25
- package/src/components/editor/editor-layout-mobile.tsx +3 -3
- package/src/components/editor/editor-layout-v2.tsx +62 -54
- package/src/components/editor/export-manager.tsx +32 -3
- package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
- package/src/components/editor/first-person/build-collider-world.ts +203 -87
- package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
- package/src/components/editor/first-person-controls.tsx +836 -31
- package/src/components/editor/floating-action-menu.tsx +287 -196
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-panel.tsx +5837 -12006
- package/src/components/editor/grid.tsx +51 -17
- package/src/components/editor/group-move-handle.tsx +316 -0
- package/src/components/editor/group-rotate-handle.tsx +382 -0
- package/src/components/editor/group-transform-shared.test.ts +205 -0
- package/src/components/editor/group-transform-shared.ts +256 -0
- package/src/components/editor/handles/handle-arrow.tsx +523 -0
- package/src/components/editor/handles/use-handle-drag.ts +213 -0
- package/src/components/editor/index.tsx +234 -190
- package/src/components/editor/measurement-pill.tsx +75 -0
- package/src/components/editor/node-arrow-handles.tsx +1453 -0
- package/src/components/editor/selection-manager.tsx +435 -158
- package/src/components/editor/site-edge-labels.tsx +44 -20
- package/src/components/editor/slab-hole-highlights.tsx +466 -0
- package/src/components/editor/snapshot-capture-overlay.tsx +539 -0
- package/src/components/editor/thumbnail-generator.tsx +27 -64
- package/src/components/editor/use-floorplan-background-placement.ts +122 -35
- package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
- package/src/components/editor/use-floorplan-scene-data.ts +14 -2
- package/src/components/editor/wall-measurement-label.tsx +11 -6
- package/src/components/editor/wall-move-side-handles.tsx +855 -0
- package/src/components/editor/wall-opening-highlights.tsx +181 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
- package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
- package/src/components/editor-2d/svg-paths.ts +10 -2
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
- package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
- package/src/components/systems/roof/roof-edit-system.tsx +86 -12
- package/src/components/systems/stair/stair-edit-system.tsx +2 -2
- package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
- package/src/components/systems/zone/zone-system.tsx +0 -0
- package/src/components/tools/elevator/elevator-defaults.ts +8 -0
- package/src/components/tools/elevator/elevator-tool.tsx +349 -0
- package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
- package/src/components/tools/fence/fence-drafting.ts +64 -22
- package/src/components/tools/item/move-tool.tsx +40 -107
- package/src/components/tools/item/placement-math.test.ts +10 -0
- package/src/components/tools/item/placement-math.ts +33 -9
- package/src/components/tools/item/placement-strategies.ts +201 -25
- package/src/components/tools/item/placement-types.ts +10 -1
- package/src/components/tools/item/use-draft-node.ts +20 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
- package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
- package/src/components/tools/roof/roof-tool.tsx +108 -12
- package/src/components/tools/select/box-select-state.ts +77 -0
- package/src/components/tools/select/box-select-tool.tsx +324 -543
- package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
- package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
- package/src/components/tools/select/select-candidates.test.ts +109 -0
- package/src/components/tools/select/select-candidates.ts +85 -0
- package/src/components/tools/shared/affordance-dispatch.ts +30 -0
- package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
- package/src/components/tools/shared/floor-stack-preview.ts +25 -0
- package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
- package/src/components/tools/shared/placement-box-geometry.ts +123 -0
- package/src/components/tools/shared/placement-box.tsx +123 -0
- package/src/components/tools/shared/polygon-editor.tsx +617 -167
- package/src/components/tools/shared/segment-angle.ts +57 -1
- package/src/components/tools/site/site-boundary-editor.tsx +464 -12
- package/src/components/tools/site/site-flag-model.ts +18 -0
- package/src/components/tools/stair/stair-defaults.ts +1 -1
- package/src/components/tools/stair/stair-tool.tsx +269 -35
- package/src/components/tools/tool-manager.tsx +172 -56
- package/src/components/tools/wall/wall-drafting.ts +98 -98
- package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
- package/src/components/tools/zone/zone-tool.tsx +36 -11
- package/src/components/ui/action-menu/action-button.tsx +21 -1
- package/src/components/ui/action-menu/camera-actions.tsx +0 -0
- package/src/components/ui/action-menu/control-modes.tsx +5 -76
- package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
- package/src/components/ui/action-menu/index.tsx +33 -196
- package/src/components/ui/action-menu/structure-tools.tsx +13 -88
- package/src/components/ui/action-menu/view-toggles.tsx +262 -37
- package/src/components/ui/command-palette/editor-commands.tsx +24 -17
- package/src/components/ui/command-palette/index.tsx +6 -256
- package/src/components/ui/controls/material-paint-panel.tsx +83 -0
- package/src/components/ui/controls/material-picker.tsx +17 -35
- package/src/components/ui/controls/metric-control.tsx +33 -11
- package/src/components/ui/floating-level-selector.tsx +48 -11
- package/src/components/ui/helpers/helper-manager.tsx +21 -17
- package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
- package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
- package/src/components/ui/level-duplicate-dialog.tsx +5 -6
- package/src/components/ui/panels/node-display.ts +1 -0
- package/src/components/ui/panels/paint-panel.tsx +66 -41
- package/src/components/ui/panels/panel-manager.tsx +33 -74
- package/src/components/ui/panels/panel-wrapper.tsx +217 -12
- package/src/components/ui/panels/parametric-inspector.tsx +469 -0
- package/src/components/ui/panels/reference-panel.tsx +28 -13
- package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
- package/src/components/ui/primitives/number-input.tsx +1 -1
- package/src/components/ui/primitives/sidebar.tsx +0 -0
- package/src/components/ui/scene-loader.tsx +1 -3
- package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
- package/src/components/ui/sidebar/icon-rail.tsx +0 -0
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
- package/src/components/ui/sidebar/tab-bar.tsx +61 -1
- package/src/components/ui/slider.tsx +1 -1
- package/src/components/viewer-overlay.tsx +169 -52
- package/src/components/viewer-zone-system.tsx +1 -1
- package/src/hooks/use-auto-save.ts +24 -3
- package/src/hooks/use-ceiling-events.ts +175 -0
- package/src/hooks/use-drag-action.ts +124 -0
- package/src/hooks/use-keyboard.ts +125 -62
- package/src/hooks/use-selection.ts +64 -0
- package/src/index.tsx +243 -4
- package/src/lib/constants.ts +6 -2
- package/src/lib/editor-api.ts +62 -0
- package/src/lib/elevator-support.ts +96 -0
- package/src/lib/floorplan/apply-alignment.ts +111 -0
- package/src/lib/floorplan/index.ts +7 -0
- package/src/lib/floorplan/selection-tool.ts +40 -0
- package/src/lib/floorplan/stairs.ts +12 -1
- package/src/lib/fresh-planar-placement.test.ts +102 -0
- package/src/lib/fresh-planar-placement.ts +63 -0
- package/src/lib/history.ts +2 -1
- package/src/lib/level-duplication.test.ts +0 -2
- package/src/lib/level-duplication.ts +55 -43
- package/src/lib/level-name.ts +11 -0
- package/src/lib/material-paint.ts +212 -20
- package/src/lib/placement-metadata.ts +29 -0
- package/src/lib/planar-cursor-placement.test.ts +43 -0
- package/src/lib/planar-cursor-placement.ts +42 -0
- package/src/lib/roof-duplication.ts +2 -2
- package/src/lib/scene-bounds.ts +1 -1
- package/src/lib/scene-clipboard.ts +267 -0
- package/src/lib/scene.ts +13 -4
- package/src/lib/sfx-bus.ts +25 -1
- package/src/lib/sfx-player.ts +75 -27
- package/src/lib/site-boundary.ts +1 -0
- package/src/lib/stair-duplication.ts +2 -2
- package/src/lib/stair-levels.test.ts +146 -0
- package/src/lib/stair-levels.ts +177 -0
- package/src/lib/world-grid-snap.ts +199 -0
- package/src/lib/zone-content.ts +130 -0
- package/src/store/use-alignment-guides.ts +21 -0
- package/src/store/use-editor.tsx +342 -91
- package/src/store/use-placement-preview.ts +31 -0
- package/src/store/use-wall-move-ghosts.ts +36 -0
- package/src/store/use-wall-snap-indicator.ts +32 -0
- package/tsconfig.json +2 -1
- package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
- package/src/components/feedback-dialog.tsx +0 -265
- package/src/components/pascal-radio.tsx +0 -280
- package/src/components/preview-button.tsx +0 -16
- package/src/components/tools/building/move-building-tool.tsx +0 -157
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
- package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
- package/src/components/tools/column/column-tool.tsx +0 -97
- package/src/components/tools/column/move-column-tool.tsx +0 -105
- package/src/components/tools/door/door-math.ts +0 -110
- package/src/components/tools/door/door-tool.tsx +0 -324
- package/src/components/tools/door/move-door-tool.tsx +0 -412
- package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
- package/src/components/tools/fence/fence-tool.tsx +0 -346
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
- package/src/components/tools/fence/move-fence-tool.tsx +0 -298
- package/src/components/tools/item/item-tool.tsx +0 -26
- package/src/components/tools/roof/move-roof-tool.tsx +0 -359
- package/src/components/tools/slab/move-slab-tool.tsx +0 -182
- package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
- package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
- package/src/components/tools/slab/slab-tool.tsx +0 -322
- package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
- package/src/components/tools/spawn/spawn-tool.tsx +0 -130
- package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
- package/src/components/tools/wall/move-wall-tool.tsx +0 -356
- package/src/components/tools/wall/wall-tool.tsx +0 -332
- package/src/components/tools/window/move-window-tool.tsx +0 -447
- package/src/components/tools/window/window-math.ts +0 -117
- package/src/components/tools/window/window-tool.tsx +0 -332
- package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
- package/src/components/ui/helpers/slab-helper.tsx +0 -20
- package/src/components/ui/helpers/wall-helper.tsx +0 -20
- package/src/components/ui/panels/ceiling-panel.tsx +0 -250
- package/src/components/ui/panels/column-panel.tsx +0 -715
- package/src/components/ui/panels/door-panel.tsx +0 -1292
- package/src/components/ui/panels/fence-panel.tsx +0 -227
- package/src/components/ui/panels/item-panel.tsx +0 -306
- package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
- package/src/components/ui/panels/roof-panel.tsx +0 -230
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
- package/src/components/ui/panels/slab-panel.tsx +0 -250
- package/src/components/ui/panels/spawn-panel.tsx +0 -155
- package/src/components/ui/panels/stair-panel.tsx +0 -525
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
- package/src/components/ui/panels/wall-panel.tsx +0 -177
- package/src/components/ui/panels/window-panel.tsx +0 -960
- package/src/components/ui/viewer-toolbar.tsx +0 -436
- package/src/contexts/presets-context.tsx +0 -121
- package/src/hooks/use-contextual-tools.ts +0 -61
|
@@ -9,7 +9,7 @@ const AUTOSAVE_DEBOUNCE_MS = 1000
|
|
|
9
9
|
export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'paused' | 'error'
|
|
10
10
|
|
|
11
11
|
interface UseAutoSaveOptions {
|
|
12
|
-
onSave?: (scene: SceneGraph) => Promise<void>
|
|
12
|
+
onSave?: (scene: SceneGraph, options?: { keepalive?: boolean }) => Promise<void>
|
|
13
13
|
onDirty?: () => void
|
|
14
14
|
onSaveStatusChange?: (status: SaveStatus) => void
|
|
15
15
|
isVersionPreviewMode?: boolean
|
|
@@ -60,6 +60,7 @@ export function useAutoSave({
|
|
|
60
60
|
// Stable subscription to scene changes
|
|
61
61
|
useEffect(() => {
|
|
62
62
|
let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes)
|
|
63
|
+
let lastNodeCount = Object.keys(useScene.getState().nodes).length
|
|
63
64
|
|
|
64
65
|
async function executeSave() {
|
|
65
66
|
if (isLoadingSceneRef.current || isVersionPreviewModeRef.current) {
|
|
@@ -71,6 +72,19 @@ export function useAutoSave({
|
|
|
71
72
|
const { nodes, rootNodeIds } = useScene.getState()
|
|
72
73
|
const sceneGraph = { nodes, rootNodeIds } as SceneGraph
|
|
73
74
|
|
|
75
|
+
// Guard: refuse to autosave if the scene went from populated to nearly empty.
|
|
76
|
+
// This catches accidental full deletions before they're persisted.
|
|
77
|
+
const currentNodeCount = Object.keys(nodes).length
|
|
78
|
+
const STRUCTURAL_NODE_COUNT = 4 // site + building + levels (empty scene skeleton)
|
|
79
|
+
if (lastNodeCount > STRUCTURAL_NODE_COUNT && currentNodeCount <= STRUCTURAL_NODE_COUNT) {
|
|
80
|
+
console.warn(
|
|
81
|
+
`[autosave] Blocked: scene dropped from ${lastNodeCount} to ${currentNodeCount} nodes. Likely accidental deletion.`,
|
|
82
|
+
)
|
|
83
|
+
setSaveStatus('error')
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
lastNodeCount = currentNodeCount
|
|
87
|
+
|
|
74
88
|
isSavingRef.current = true
|
|
75
89
|
pendingSaveRef.current = false
|
|
76
90
|
setSaveStatus('saving')
|
|
@@ -134,23 +148,30 @@ export function useAutoSave({
|
|
|
134
148
|
}, AUTOSAVE_DEBOUNCE_MS)
|
|
135
149
|
})
|
|
136
150
|
|
|
151
|
+
// Flush any unsaved change while the page is going away. The network
|
|
152
|
+
// save MUST set `keepalive` — a normal fetch is cancelled by the browser
|
|
153
|
+
// the moment the page unloads, so a quick refresh right after an edit
|
|
154
|
+
// would otherwise drop the change entirely. `pagehide` fires in cases
|
|
155
|
+
// (mobile Safari, bfcache) where `beforeunload` does not.
|
|
137
156
|
function flushOnExit() {
|
|
138
157
|
if (!hasDirtyChangesRef.current) return
|
|
158
|
+
hasDirtyChangesRef.current = false
|
|
139
159
|
const { nodes, rootNodeIds } = useScene.getState()
|
|
140
160
|
const sceneGraph = { nodes, rootNodeIds } as SceneGraph
|
|
141
161
|
if (onSaveRef.current) {
|
|
142
|
-
onSaveRef.current(sceneGraph).catch(() => {})
|
|
162
|
+
onSaveRef.current(sceneGraph, { keepalive: true }).catch(() => {})
|
|
143
163
|
} else {
|
|
144
164
|
saveSceneToLocalStorage(sceneGraph)
|
|
145
165
|
}
|
|
146
|
-
hasDirtyChangesRef.current = false
|
|
147
166
|
}
|
|
148
167
|
|
|
149
168
|
window.addEventListener('beforeunload', flushOnExit)
|
|
169
|
+
window.addEventListener('pagehide', flushOnExit)
|
|
150
170
|
|
|
151
171
|
return () => {
|
|
152
172
|
executeSaveRef.current = null
|
|
153
173
|
window.removeEventListener('beforeunload', flushOnExit)
|
|
174
|
+
window.removeEventListener('pagehide', flushOnExit)
|
|
154
175
|
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
|
|
155
176
|
flushOnExit()
|
|
156
177
|
unsubscribe()
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNodeId,
|
|
3
|
+
type CeilingEvent,
|
|
4
|
+
type CeilingNode,
|
|
5
|
+
emitter,
|
|
6
|
+
resolveLevelId,
|
|
7
|
+
sceneRegistry,
|
|
8
|
+
useScene,
|
|
9
|
+
} from '@pascal-app/core'
|
|
10
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
11
|
+
import { useThree } from '@react-three/fiber'
|
|
12
|
+
import { useEffect, useRef } from 'react'
|
|
13
|
+
import { type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
|
|
14
|
+
import useEditor from '../store/use-editor'
|
|
15
|
+
|
|
16
|
+
const UP = new Vector3(0, 1, 0)
|
|
17
|
+
|
|
18
|
+
/** Ray-casting point-in-polygon on ceiling-local [x, z] tuples. */
|
|
19
|
+
function pointInPolygon(x: number, z: number, polygon: Array<[number, number]>): boolean {
|
|
20
|
+
let inside = false
|
|
21
|
+
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
22
|
+
const a = polygon[i]!
|
|
23
|
+
const b = polygon[j]!
|
|
24
|
+
const intersects =
|
|
25
|
+
a[1] > z !== b[1] > z && x < ((b[0] - a[0]) * (z - a[1])) / (b[1] - a[1]) + a[0]
|
|
26
|
+
if (intersects) inside = !inside
|
|
27
|
+
}
|
|
28
|
+
return inside
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Reliable pointer events for placing ceiling-attached items.
|
|
33
|
+
*
|
|
34
|
+
* Mirrors {@link useGridEvents} (the floor path): rather than relying on the
|
|
35
|
+
* thin, single-sided `ceiling-grid` overlay mesh to be hit by R3F's raycaster —
|
|
36
|
+
* which misses from the "wrong" camera side, near polygon edges/holes, or while
|
|
37
|
+
* the overlay is mid-reveal, dropping the commit click even though the green box
|
|
38
|
+
* still shows — it intersects a math plane at each ceiling's height (double-sided,
|
|
39
|
+
* so it hits from above or below) and point-in-polygon tests the hit. Emits
|
|
40
|
+
* `ceiling:enter/move/leave/click` so both the placement coordinator and the 2D
|
|
41
|
+
* floor-plan item preview keep working.
|
|
42
|
+
*
|
|
43
|
+
* Active only while a ceiling-attached item is being placed or moved. The
|
|
44
|
+
* `ceiling-grid` mesh no longer emits these events (see `CeilingRenderer`), so
|
|
45
|
+
* this is the single, reliable source.
|
|
46
|
+
*/
|
|
47
|
+
export function useCeilingEvents() {
|
|
48
|
+
const { camera, gl } = useThree()
|
|
49
|
+
const raycaster = useRef(new Raycaster())
|
|
50
|
+
const pointer = useRef(new Vector2())
|
|
51
|
+
const plane = useRef(new Plane(UP.clone(), 0))
|
|
52
|
+
const worldHit = useRef(new Vector3())
|
|
53
|
+
const meshWorld = useRef(new Vector3())
|
|
54
|
+
const hoveredRef = useRef<string | null>(null)
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
const canvas = gl.domElement
|
|
58
|
+
|
|
59
|
+
const isActive = (): boolean => {
|
|
60
|
+
const ed = useEditor.getState()
|
|
61
|
+
if (ed.selectedItem?.attachTo === 'ceiling') return true
|
|
62
|
+
const moving = ed.movingNode
|
|
63
|
+
return moving?.type === 'item' && moving.asset?.attachTo === 'ceiling'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type Hit = { node: CeilingNode; mesh: Object3D; world: Vector3; local: Vector3 }
|
|
67
|
+
|
|
68
|
+
// The ceiling on the active level whose plane the cursor ray crosses inside
|
|
69
|
+
// its polygon, nearest the camera.
|
|
70
|
+
const pick = (nativeEvent: PointerEvent | MouseEvent): Hit | null => {
|
|
71
|
+
const activeLevelId = useViewer.getState().selection.levelId
|
|
72
|
+
if (!activeLevelId) return null
|
|
73
|
+
|
|
74
|
+
const rect = canvas.getBoundingClientRect()
|
|
75
|
+
pointer.current.x = ((nativeEvent.clientX - rect.left) / rect.width) * 2 - 1
|
|
76
|
+
pointer.current.y = -((nativeEvent.clientY - rect.top) / rect.height) * 2 + 1
|
|
77
|
+
raycaster.current.setFromCamera(pointer.current, camera)
|
|
78
|
+
|
|
79
|
+
const nodes = useScene.getState().nodes
|
|
80
|
+
const ceilingIds = sceneRegistry.byType.ceiling
|
|
81
|
+
if (!ceilingIds) return null
|
|
82
|
+
|
|
83
|
+
let best: Hit | null = null
|
|
84
|
+
let bestDist = Number.POSITIVE_INFINITY
|
|
85
|
+
|
|
86
|
+
for (const id of ceilingIds) {
|
|
87
|
+
const node = nodes[id as AnyNodeId] as CeilingNode | undefined
|
|
88
|
+
if (!node || node.type !== 'ceiling' || node.polygon.length < 3) continue
|
|
89
|
+
if (resolveLevelId(node, nodes) !== activeLevelId) continue
|
|
90
|
+
const mesh = sceneRegistry.nodes.get(id)
|
|
91
|
+
if (!mesh) continue
|
|
92
|
+
|
|
93
|
+
mesh.getWorldPosition(meshWorld.current)
|
|
94
|
+
plane.current.set(UP, -meshWorld.current.y)
|
|
95
|
+
if (!raycaster.current.ray.intersectPlane(plane.current, worldHit.current)) continue
|
|
96
|
+
|
|
97
|
+
const local = mesh.worldToLocal(worldHit.current.clone())
|
|
98
|
+
if (!pointInPolygon(local.x, local.z, node.polygon)) continue
|
|
99
|
+
|
|
100
|
+
const dist = raycaster.current.ray.origin.distanceToSquared(worldHit.current)
|
|
101
|
+
if (dist < bestDist) {
|
|
102
|
+
bestDist = dist
|
|
103
|
+
best = { node, mesh, world: worldHit.current.clone(), local }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return best
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const buildEvent = (hit: Hit, nativeEvent: PointerEvent | MouseEvent): CeilingEvent => ({
|
|
110
|
+
node: hit.node,
|
|
111
|
+
position: [hit.world.x, hit.world.y, hit.world.z],
|
|
112
|
+
localPosition: [hit.local.x, hit.local.y, hit.local.z],
|
|
113
|
+
normal: [0, 1, 0],
|
|
114
|
+
object: hit.mesh,
|
|
115
|
+
stopPropagation: () => {},
|
|
116
|
+
nativeEvent: nativeEvent as never,
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const emitLeave = (nativeEvent: PointerEvent | MouseEvent) => {
|
|
120
|
+
const id = hoveredRef.current
|
|
121
|
+
if (!id) return
|
|
122
|
+
hoveredRef.current = null
|
|
123
|
+
const node = useScene.getState().nodes[id as AnyNodeId] as CeilingNode | undefined
|
|
124
|
+
const mesh = sceneRegistry.nodes.get(id)
|
|
125
|
+
if (!node || !mesh) return
|
|
126
|
+
emitter.emit(
|
|
127
|
+
'ceiling:leave',
|
|
128
|
+
buildEvent(
|
|
129
|
+
{ node, mesh, world: meshWorld.current.clone(), local: new Vector3() },
|
|
130
|
+
nativeEvent,
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const onMove = (e: PointerEvent) => {
|
|
136
|
+
if (!isActive()) {
|
|
137
|
+
emitLeave(e)
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
const hit = pick(e)
|
|
141
|
+
if (!hit) {
|
|
142
|
+
emitLeave(e)
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
const ev = buildEvent(hit, e)
|
|
146
|
+
if (hoveredRef.current !== hit.node.id) {
|
|
147
|
+
if (hoveredRef.current) emitLeave(e)
|
|
148
|
+
hoveredRef.current = hit.node.id
|
|
149
|
+
emitter.emit('ceiling:enter', ev)
|
|
150
|
+
}
|
|
151
|
+
emitter.emit('ceiling:move', ev)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const onClick = (e: MouseEvent) => {
|
|
155
|
+
if (useViewer.getState().cameraDragging) return
|
|
156
|
+
if (e.button !== 0) return
|
|
157
|
+
if (!isActive()) return
|
|
158
|
+
const hit = pick(e)
|
|
159
|
+
if (!hit) return
|
|
160
|
+
emitter.emit('ceiling:click', buildEvent(hit, e))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const onPointerLeave = (e: PointerEvent) => emitLeave(e)
|
|
164
|
+
|
|
165
|
+
canvas.addEventListener('pointermove', onMove)
|
|
166
|
+
canvas.addEventListener('click', onClick)
|
|
167
|
+
canvas.addEventListener('pointerleave', onPointerLeave)
|
|
168
|
+
|
|
169
|
+
return () => {
|
|
170
|
+
canvas.removeEventListener('pointermove', onMove)
|
|
171
|
+
canvas.removeEventListener('click', onClick)
|
|
172
|
+
canvas.removeEventListener('pointerleave', onPointerLeave)
|
|
173
|
+
}
|
|
174
|
+
}, [camera, gl])
|
|
175
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyNode,
|
|
5
|
+
type AnyNodeId,
|
|
6
|
+
type ChildQuery,
|
|
7
|
+
createDragSession,
|
|
8
|
+
createSceneApi,
|
|
9
|
+
type DragAction,
|
|
10
|
+
type DragSessionInput,
|
|
11
|
+
emitter,
|
|
12
|
+
type GridEvent,
|
|
13
|
+
type Modifiers,
|
|
14
|
+
type SpatialQuery,
|
|
15
|
+
useScene,
|
|
16
|
+
} from '@pascal-app/core'
|
|
17
|
+
import { useEffect, useRef } from 'react'
|
|
18
|
+
|
|
19
|
+
const sceneApi = createSceneApi(useScene)
|
|
20
|
+
|
|
21
|
+
function modifiersFromGridEvent(event: GridEvent): Modifiers {
|
|
22
|
+
const ne = event.nativeEvent?.nativeEvent as Partial<KeyboardEvent> | undefined
|
|
23
|
+
return {
|
|
24
|
+
shift: ne?.shiftKey ?? false,
|
|
25
|
+
alt: ne?.altKey ?? false,
|
|
26
|
+
ctrl: ne?.ctrlKey ?? false,
|
|
27
|
+
meta: ne?.metaKey ?? false,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type UseDragActionArgs<Ctx, Draft> = {
|
|
32
|
+
/** When true the session is live: subscribes to grid events + Esc.
|
|
33
|
+
* Flipping to false (or unmount) cancels and cleans up. */
|
|
34
|
+
active: boolean
|
|
35
|
+
action: DragAction<Ctx, Draft>
|
|
36
|
+
/** Captured once at the moment `active` flips to true. */
|
|
37
|
+
initial: DragSessionInput
|
|
38
|
+
/** Relations cascade plumbing. */
|
|
39
|
+
spatialQuery?: SpatialQuery
|
|
40
|
+
childQuery?: ChildQuery
|
|
41
|
+
/** Fires once after `action.commit` returns true. */
|
|
42
|
+
onCommit?: () => void
|
|
43
|
+
/** Fires once after `action.cancel` (Esc, unmount, or commit-returns-false). */
|
|
44
|
+
onCancel?: () => void
|
|
45
|
+
/**
|
|
46
|
+
* Milliseconds after activation during which `grid:click` is swallowed.
|
|
47
|
+
* Stops the very click that mounted this tool (a DOM button or 3D
|
|
48
|
+
* handle elsewhere) from cascading into the grid and immediately
|
|
49
|
+
* committing the drag. Defaults to 150ms — matches the legacy guard
|
|
50
|
+
* used by every kind-owned tool entered via a click.
|
|
51
|
+
*/
|
|
52
|
+
activationGraceMs?: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* React hook wrapping the pure `createDragSession` orchestrator with the
|
|
57
|
+
* editor's grid event emitter and an Esc-to-cancel keyboard binding.
|
|
58
|
+
*
|
|
59
|
+
* - Pauses scene history when active → resumes on commit/cancel/unmount
|
|
60
|
+
* - Per `grid:move` runs preview + snap + apply and cascades dirty marks
|
|
61
|
+
* - `grid:click` triggers commit; Escape triggers cancel
|
|
62
|
+
*
|
|
63
|
+
* For tests of the underlying behavior, drive `createDragSession` directly
|
|
64
|
+
* (no React needed). This hook is the thin glue.
|
|
65
|
+
*/
|
|
66
|
+
export function useDragAction<Ctx, Draft>(args: UseDragActionArgs<Ctx, Draft>) {
|
|
67
|
+
// Stable refs so handlers don't re-bind when callbacks change.
|
|
68
|
+
const argsRef = useRef(args)
|
|
69
|
+
argsRef.current = args
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!args.active) return
|
|
73
|
+
|
|
74
|
+
const session = createDragSession<Ctx, Draft>(argsRef.current.action, sceneApi, {
|
|
75
|
+
spatialQuery: argsRef.current.spatialQuery,
|
|
76
|
+
childQuery: argsRef.current.childQuery,
|
|
77
|
+
onCommit: () => argsRef.current.onCommit?.(),
|
|
78
|
+
onCancel: () => argsRef.current.onCancel?.(),
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
session.start(argsRef.current.initial)
|
|
82
|
+
|
|
83
|
+
const activatedAt = Date.now()
|
|
84
|
+
const graceMs = argsRef.current.activationGraceMs ?? 150
|
|
85
|
+
|
|
86
|
+
const onMove = (event: GridEvent) => {
|
|
87
|
+
const point: readonly [number, number] = [event.localPosition[0], event.localPosition[2]]
|
|
88
|
+
session.move(point, modifiersFromGridEvent(event))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const onClick = (event: GridEvent) => {
|
|
92
|
+
// Swallow the click that mounted this tool — otherwise the very
|
|
93
|
+
// first grid:click cascades into commit() before any move().
|
|
94
|
+
if (Date.now() - activatedAt < graceMs) {
|
|
95
|
+
event.nativeEvent?.stopPropagation?.()
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
session.commit()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
102
|
+
if (e.key === 'Escape') session.cancel()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
emitter.on('grid:move', onMove)
|
|
106
|
+
emitter.on('grid:click', onClick)
|
|
107
|
+
if (typeof window !== 'undefined') {
|
|
108
|
+
window.addEventListener('keydown', onKeyDown)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return () => {
|
|
112
|
+
emitter.off('grid:move', onMove)
|
|
113
|
+
emitter.off('grid:click', onClick)
|
|
114
|
+
if (typeof window !== 'undefined') {
|
|
115
|
+
window.removeEventListener('keydown', onKeyDown)
|
|
116
|
+
}
|
|
117
|
+
// If the parent flipped `active` to false (or unmounted) while we were
|
|
118
|
+
// still mid-drag, treat it as a cancel — no dangling history pause.
|
|
119
|
+
session.dispose()
|
|
120
|
+
}
|
|
121
|
+
}, [args.active])
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export type { AnyNode, AnyNodeId, DragAction, Modifiers }
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
|
|
1
|
+
import { type AnyNodeId, emitter, nodeRegistry, useScene } from '@pascal-app/core'
|
|
2
2
|
import { useViewer } from '@pascal-app/viewer'
|
|
3
3
|
import { useEffect } from 'react'
|
|
4
|
-
import {
|
|
4
|
+
import { toggleDoorOpenState } from '../lib/door-interaction'
|
|
5
5
|
import { runRedo, runUndo } from '../lib/history'
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
copySelectedNodesToEditorClipboard,
|
|
8
|
+
pasteEditorClipboardToLevel,
|
|
9
|
+
} from '../lib/scene-clipboard'
|
|
10
|
+
import { emitDeleteSFX, sfxEmitter } from '../lib/sfx-bus'
|
|
11
|
+
import { toggleWindowOpenState } from '../lib/window-interaction'
|
|
8
12
|
import useEditor from '../store/use-editor'
|
|
9
13
|
|
|
10
14
|
// Tools call this in their onCancel handler when they have an active mid-action to cancel,
|
|
@@ -77,6 +81,7 @@ export const useKeyboard = ({
|
|
|
77
81
|
e.preventDefault()
|
|
78
82
|
useEditor.getState().setPhase('furnish')
|
|
79
83
|
useEditor.getState().setMode('build')
|
|
84
|
+
useEditor.getState().setActiveSidebarPanel('items')
|
|
80
85
|
} else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {
|
|
81
86
|
if (isVersionPreviewMode) return
|
|
82
87
|
e.preventDefault()
|
|
@@ -94,7 +99,7 @@ export const useKeyboard = ({
|
|
|
94
99
|
useEditor.getState().setPhase('structure')
|
|
95
100
|
useEditor.getState().setStructureLayer('elements')
|
|
96
101
|
useEditor.getState().setMode('build')
|
|
97
|
-
} else if (e.key === '
|
|
102
|
+
} else if (e.key === 'x' && !e.metaKey && !e.ctrlKey) {
|
|
98
103
|
if (isVersionPreviewMode) return
|
|
99
104
|
e.preventDefault()
|
|
100
105
|
useEditor.getState().setMode('delete')
|
|
@@ -105,6 +110,17 @@ export const useKeyboard = ({
|
|
|
105
110
|
useEditor.getState().setPhase('structure')
|
|
106
111
|
useEditor.getState().setStructureLayer('elements')
|
|
107
112
|
useEditor.getState().setMode('material-paint')
|
|
113
|
+
} else if (e.key === 'c' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
|
|
114
|
+
if (isVersionPreviewMode) return
|
|
115
|
+
e.preventDefault()
|
|
116
|
+
copySelectedNodesToEditorClipboard()
|
|
117
|
+
} else if (e.key === 'v' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
|
|
118
|
+
if (isVersionPreviewMode) return
|
|
119
|
+
e.preventDefault()
|
|
120
|
+
const result = pasteEditorClipboardToLevel()
|
|
121
|
+
if (result?.pastedIds.length) {
|
|
122
|
+
sfxEmitter.emit('sfx:item-place')
|
|
123
|
+
}
|
|
108
124
|
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
|
|
109
125
|
if (isVersionPreviewMode) return
|
|
110
126
|
e.preventDefault()
|
|
@@ -118,13 +134,19 @@ export const useKeyboard = ({
|
|
|
118
134
|
const { buildingId, levelId } = useViewer.getState().selection
|
|
119
135
|
if (buildingId) {
|
|
120
136
|
const building = useScene.getState().nodes[buildingId]
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
137
|
+
const levels =
|
|
138
|
+
building?.type === 'building'
|
|
139
|
+
? building.children.filter(
|
|
140
|
+
(childId) => useScene.getState().nodes[childId as AnyNodeId]?.type === 'level',
|
|
141
|
+
)
|
|
142
|
+
: []
|
|
143
|
+
if (levels.length > 0) {
|
|
144
|
+
const currentIdx = levelId ? levels.indexOf(levelId as any) : -1
|
|
145
|
+
const nextIdx = currentIdx < levels.length - 1 ? currentIdx + 1 : currentIdx
|
|
124
146
|
if (nextIdx !== -1 && nextIdx !== currentIdx) {
|
|
125
|
-
useViewer.getState().setSelection({ levelId:
|
|
147
|
+
useViewer.getState().setSelection({ levelId: levels[nextIdx] as any })
|
|
126
148
|
} else if (currentIdx === -1) {
|
|
127
|
-
useViewer.getState().setSelection({ levelId:
|
|
149
|
+
useViewer.getState().setSelection({ levelId: levels[0] as any })
|
|
128
150
|
}
|
|
129
151
|
}
|
|
130
152
|
}
|
|
@@ -133,43 +155,60 @@ export const useKeyboard = ({
|
|
|
133
155
|
const { buildingId, levelId } = useViewer.getState().selection
|
|
134
156
|
if (buildingId) {
|
|
135
157
|
const building = useScene.getState().nodes[buildingId]
|
|
136
|
-
|
|
137
|
-
|
|
158
|
+
const levels =
|
|
159
|
+
building?.type === 'building'
|
|
160
|
+
? building.children.filter(
|
|
161
|
+
(childId) => useScene.getState().nodes[childId as AnyNodeId]?.type === 'level',
|
|
162
|
+
)
|
|
163
|
+
: []
|
|
164
|
+
if (levels.length > 0) {
|
|
165
|
+
const currentIdx = levelId ? levels.indexOf(levelId as any) : -1
|
|
138
166
|
const prevIdx = currentIdx > 0 ? currentIdx - 1 : currentIdx
|
|
139
167
|
if (prevIdx !== -1 && prevIdx !== currentIdx) {
|
|
140
|
-
useViewer.getState().setSelection({ levelId:
|
|
168
|
+
useViewer.getState().setSelection({ levelId: levels[prevIdx] as any })
|
|
141
169
|
} else if (currentIdx === -1) {
|
|
142
|
-
useViewer
|
|
143
|
-
.getState()
|
|
144
|
-
.setSelection({ levelId: building.children[building.children.length - 1] as any })
|
|
170
|
+
useViewer.getState().setSelection({ levelId: levels[levels.length - 1] as any })
|
|
145
171
|
}
|
|
146
172
|
}
|
|
147
173
|
}
|
|
148
174
|
} else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode) {
|
|
149
175
|
// Rotate selected node clockwise if it supports rotation (items, roofs, etc.)
|
|
150
|
-
//
|
|
176
|
+
// Doors use R to flip side (front ↔ back, rotation += π); their
|
|
177
|
+
// open/close toggle lives on E. Windows still use R to toggle
|
|
178
|
+
// their open/closed state.
|
|
151
179
|
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
|
152
180
|
if (selectedNodeIds.length === 1) {
|
|
153
181
|
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
|
154
182
|
if (node?.type === 'door') {
|
|
155
183
|
e.preventDefault()
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
184
|
+
useScene.getState().updateNode(node.id, {
|
|
185
|
+
side: node.side === 'front' ? 'back' : 'front',
|
|
186
|
+
rotation: [node.rotation[0], node.rotation[1] + Math.PI, node.rotation[2]],
|
|
187
|
+
})
|
|
188
|
+
if (node.parentId) {
|
|
189
|
+
useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
|
159
190
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
node.windowType === 'casement' ||
|
|
165
|
-
node.windowType === 'awning' ||
|
|
166
|
-
node.windowType === 'hopper' ||
|
|
167
|
-
node.windowType === 'single-hung' ||
|
|
168
|
-
node.windowType === 'double-hung' ||
|
|
169
|
-
node.windowType === 'louvered')
|
|
170
|
-
) {
|
|
191
|
+
sfxEmitter.emit('sfx:item-rotate')
|
|
192
|
+
} else if (node?.type === 'window') {
|
|
193
|
+
// Windows: R flips side (front ↔ back, rotation += π). Open/
|
|
194
|
+
// close toggle for operable windows lives on E.
|
|
171
195
|
e.preventDefault()
|
|
172
|
-
|
|
196
|
+
useScene.getState().updateNode(node.id, {
|
|
197
|
+
side: node.side === 'front' ? 'back' : 'front',
|
|
198
|
+
rotation: [node.rotation[0], node.rotation[1] + Math.PI, node.rotation[2]],
|
|
199
|
+
})
|
|
200
|
+
if (node.parentId) {
|
|
201
|
+
useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
|
202
|
+
}
|
|
203
|
+
sfxEmitter.emit('sfx:item-rotate')
|
|
204
|
+
} else if (node && nodeRegistry.get(node.type)?.keyboardActions?.r?.appliesTo(node)) {
|
|
205
|
+
// Registry-driven R action. Skylight uses this for open/
|
|
206
|
+
// close toggling; future kinds with custom R behaviour
|
|
207
|
+
// declare it on their `def.keyboardActions` without
|
|
208
|
+
// touching this hook. Door / window still use the legacy
|
|
209
|
+
// direct calls above (follow-up to migrate).
|
|
210
|
+
e.preventDefault()
|
|
211
|
+
nodeRegistry.get(node.type)?.keyboardActions?.r?.run(node)
|
|
173
212
|
sfxEmitter.emit('sfx:item-rotate')
|
|
174
213
|
} else if (node && 'rotation' in node) {
|
|
175
214
|
e.preventDefault()
|
|
@@ -192,24 +231,17 @@ export const useKeyboard = ({
|
|
|
192
231
|
if (selectedNodeIds.length === 1) {
|
|
193
232
|
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
|
194
233
|
if (node?.type === 'door') {
|
|
234
|
+
// Door's open/close moved to E; T is a no-op for doors so
|
|
235
|
+
// it doesn't free-rotate a wall-bound node by π/4.
|
|
195
236
|
e.preventDefault()
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
-
} else if (
|
|
201
|
-
node?.type === 'window' &&
|
|
202
|
-
node.openingKind !== 'opening' &&
|
|
203
|
-
(node.windowType === 'sliding' ||
|
|
204
|
-
node.windowType === 'casement' ||
|
|
205
|
-
node.windowType === 'awning' ||
|
|
206
|
-
node.windowType === 'hopper' ||
|
|
207
|
-
node.windowType === 'single-hung' ||
|
|
208
|
-
node.windowType === 'double-hung' ||
|
|
209
|
-
node.windowType === 'louvered')
|
|
210
|
-
) {
|
|
237
|
+
} else if (node?.type === 'window') {
|
|
238
|
+
// Window's open/close moved to E; T is a no-op so it doesn't
|
|
239
|
+
// free-rotate a wall-bound node by π/4.
|
|
211
240
|
e.preventDefault()
|
|
212
|
-
|
|
241
|
+
} else if (node && nodeRegistry.get(node.type)?.keyboardActions?.t?.appliesTo(node)) {
|
|
242
|
+
// Registry-driven T action. Same shape as the R arm above.
|
|
243
|
+
e.preventDefault()
|
|
244
|
+
nodeRegistry.get(node.type)?.keyboardActions?.t?.run(node)
|
|
213
245
|
sfxEmitter.emit('sfx:item-rotate')
|
|
214
246
|
} else if (node && 'rotation' in node) {
|
|
215
247
|
e.preventDefault()
|
|
@@ -225,6 +257,32 @@ export const useKeyboard = ({
|
|
|
225
257
|
sfxEmitter.emit('sfx:item-rotate')
|
|
226
258
|
}
|
|
227
259
|
}
|
|
260
|
+
} else if ((e.key === 'e' || e.key === 'E') && !isVersionPreviewMode) {
|
|
261
|
+
// Toggle door / operable-window open/closed state. Moved off R,
|
|
262
|
+
// which now flips the opening (side + π rotation).
|
|
263
|
+
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
|
264
|
+
if (selectedNodeIds.length === 1) {
|
|
265
|
+
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
|
266
|
+
if (node?.type === 'door' && node.openingKind !== 'opening') {
|
|
267
|
+
e.preventDefault()
|
|
268
|
+
toggleDoorOpenState(node.id)
|
|
269
|
+
sfxEmitter.emit('sfx:item-rotate')
|
|
270
|
+
} else if (
|
|
271
|
+
node?.type === 'window' &&
|
|
272
|
+
node.openingKind !== 'opening' &&
|
|
273
|
+
(node.windowType === 'sliding' ||
|
|
274
|
+
node.windowType === 'casement' ||
|
|
275
|
+
node.windowType === 'awning' ||
|
|
276
|
+
node.windowType === 'hopper' ||
|
|
277
|
+
node.windowType === 'single-hung' ||
|
|
278
|
+
node.windowType === 'double-hung' ||
|
|
279
|
+
node.windowType === 'louvered')
|
|
280
|
+
) {
|
|
281
|
+
e.preventDefault()
|
|
282
|
+
toggleWindowOpenState(node.id)
|
|
283
|
+
sfxEmitter.emit('sfx:item-rotate')
|
|
284
|
+
}
|
|
285
|
+
}
|
|
228
286
|
} else if ((e.key === 'Delete' || e.key === 'Backspace') && !isVersionPreviewMode) {
|
|
229
287
|
e.preventDefault()
|
|
230
288
|
|
|
@@ -240,31 +298,36 @@ export const useKeyboard = ({
|
|
|
240
298
|
}
|
|
241
299
|
}
|
|
242
300
|
|
|
243
|
-
// Delete selected zone
|
|
244
|
-
const selectedZoneId = useViewer.getState().selection.zoneId
|
|
245
|
-
if (selectedZoneId) {
|
|
246
|
-
sfxEmitter.emit('sfx:structure-delete')
|
|
247
|
-
useScene.getState().deleteNode(selectedZoneId as AnyNodeId)
|
|
248
|
-
useViewer.getState().setSelection({ zoneId: null })
|
|
249
|
-
return
|
|
250
|
-
}
|
|
251
|
-
|
|
252
301
|
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
|
253
302
|
|
|
254
303
|
if (selectedNodeIds.length > 0) {
|
|
304
|
+
// Guard against accidental bulk deletion (e.g. box-select all + Delete)
|
|
305
|
+
const BULK_DELETE_THRESHOLD = 10
|
|
306
|
+
if (selectedNodeIds.length >= BULK_DELETE_THRESHOLD) {
|
|
307
|
+
const confirmed = window.confirm(
|
|
308
|
+
`Delete ${selectedNodeIds.length} selected elements? This cannot be undone if the undo history is exhausted.`,
|
|
309
|
+
)
|
|
310
|
+
if (!confirmed) return
|
|
311
|
+
}
|
|
312
|
+
|
|
255
313
|
// Play appropriate SFX based on what's being deleted
|
|
256
314
|
if (selectedNodeIds.length === 1) {
|
|
257
315
|
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
|
258
|
-
|
|
259
|
-
sfxEmitter.emit('sfx:item-delete')
|
|
260
|
-
} else {
|
|
261
|
-
sfxEmitter.emit('sfx:structure-delete')
|
|
262
|
-
}
|
|
316
|
+
emitDeleteSFX(node?.type)
|
|
263
317
|
} else {
|
|
264
318
|
sfxEmitter.emit('sfx:structure-delete')
|
|
265
319
|
}
|
|
266
320
|
|
|
267
321
|
useScene.getState().deleteNodes(selectedNodeIds)
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Delete selected zone when no explicit element selection is active.
|
|
326
|
+
const selectedZoneId = useViewer.getState().selection.zoneId
|
|
327
|
+
if (selectedZoneId) {
|
|
328
|
+
sfxEmitter.emit('sfx:structure-delete')
|
|
329
|
+
useScene.getState().deleteNode(selectedZoneId as AnyNodeId)
|
|
330
|
+
useViewer.getState().setSelection({ zoneId: null })
|
|
268
331
|
}
|
|
269
332
|
}
|
|
270
333
|
}
|