@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.
Files changed (244) hide show
  1. package/package.json +12 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +755 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +62 -54
  6. package/src/components/editor/export-manager.tsx +32 -3
  7. package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
  8. package/src/components/editor/first-person/build-collider-world.ts +203 -87
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
  10. package/src/components/editor/first-person-controls.tsx +836 -31
  11. package/src/components/editor/floating-action-menu.tsx +287 -196
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floorplan-panel.tsx +5837 -12006
  14. package/src/components/editor/grid.tsx +51 -17
  15. package/src/components/editor/group-move-handle.tsx +316 -0
  16. package/src/components/editor/group-rotate-handle.tsx +382 -0
  17. package/src/components/editor/group-transform-shared.test.ts +205 -0
  18. package/src/components/editor/group-transform-shared.ts +256 -0
  19. package/src/components/editor/handles/handle-arrow.tsx +523 -0
  20. package/src/components/editor/handles/use-handle-drag.ts +213 -0
  21. package/src/components/editor/index.tsx +234 -190
  22. package/src/components/editor/measurement-pill.tsx +75 -0
  23. package/src/components/editor/node-arrow-handles.tsx +1453 -0
  24. package/src/components/editor/selection-manager.tsx +435 -158
  25. package/src/components/editor/site-edge-labels.tsx +44 -20
  26. package/src/components/editor/slab-hole-highlights.tsx +466 -0
  27. package/src/components/editor/snapshot-capture-overlay.tsx +539 -0
  28. package/src/components/editor/thumbnail-generator.tsx +27 -64
  29. package/src/components/editor/use-floorplan-background-placement.ts +122 -35
  30. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  31. package/src/components/editor/use-floorplan-scene-data.ts +14 -2
  32. package/src/components/editor/wall-measurement-label.tsx +11 -6
  33. package/src/components/editor/wall-move-side-handles.tsx +855 -0
  34. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  35. package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
  36. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
  37. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  38. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  39. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
  41. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  42. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
  43. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  44. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
  45. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  46. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
  47. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  49. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
  50. package/src/components/editor-2d/svg-paths.ts +10 -2
  51. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
  52. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  53. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  54. package/src/components/systems/stair/stair-edit-system.tsx +2 -2
  55. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  56. package/src/components/systems/zone/zone-system.tsx +0 -0
  57. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  58. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  59. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  60. package/src/components/tools/fence/fence-drafting.ts +64 -22
  61. package/src/components/tools/item/move-tool.tsx +40 -107
  62. package/src/components/tools/item/placement-math.test.ts +10 -0
  63. package/src/components/tools/item/placement-math.ts +33 -9
  64. package/src/components/tools/item/placement-strategies.ts +201 -25
  65. package/src/components/tools/item/placement-types.ts +10 -1
  66. package/src/components/tools/item/use-draft-node.ts +20 -2
  67. package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
  68. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  69. package/src/components/tools/roof/roof-tool.tsx +108 -12
  70. package/src/components/tools/select/box-select-state.ts +77 -0
  71. package/src/components/tools/select/box-select-tool.tsx +324 -543
  72. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  73. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  74. package/src/components/tools/select/select-candidates.test.ts +109 -0
  75. package/src/components/tools/select/select-candidates.ts +85 -0
  76. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  77. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  78. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  79. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  80. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  81. package/src/components/tools/shared/placement-box.tsx +123 -0
  82. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  83. package/src/components/tools/shared/segment-angle.ts +57 -1
  84. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  85. package/src/components/tools/site/site-flag-model.ts +18 -0
  86. package/src/components/tools/stair/stair-defaults.ts +1 -1
  87. package/src/components/tools/stair/stair-tool.tsx +269 -35
  88. package/src/components/tools/tool-manager.tsx +172 -56
  89. package/src/components/tools/wall/wall-drafting.ts +98 -98
  90. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  91. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  92. package/src/components/tools/zone/zone-tool.tsx +36 -11
  93. package/src/components/ui/action-menu/action-button.tsx +21 -1
  94. package/src/components/ui/action-menu/camera-actions.tsx +0 -0
  95. package/src/components/ui/action-menu/control-modes.tsx +5 -76
  96. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  97. package/src/components/ui/action-menu/index.tsx +33 -196
  98. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  99. package/src/components/ui/action-menu/view-toggles.tsx +262 -37
  100. package/src/components/ui/command-palette/editor-commands.tsx +24 -17
  101. package/src/components/ui/command-palette/index.tsx +6 -256
  102. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  103. package/src/components/ui/controls/material-picker.tsx +17 -35
  104. package/src/components/ui/controls/metric-control.tsx +33 -11
  105. package/src/components/ui/floating-level-selector.tsx +48 -11
  106. package/src/components/ui/helpers/helper-manager.tsx +21 -17
  107. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  108. package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
  109. package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
  110. package/src/components/ui/level-duplicate-dialog.tsx +5 -6
  111. package/src/components/ui/panels/node-display.ts +1 -0
  112. package/src/components/ui/panels/paint-panel.tsx +66 -41
  113. package/src/components/ui/panels/panel-manager.tsx +33 -74
  114. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  115. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  116. package/src/components/ui/panels/reference-panel.tsx +28 -13
  117. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  118. package/src/components/ui/primitives/number-input.tsx +1 -1
  119. package/src/components/ui/primitives/sidebar.tsx +0 -0
  120. package/src/components/ui/scene-loader.tsx +1 -3
  121. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  122. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  123. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  124. package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  127. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  128. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  129. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  132. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  133. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  134. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  135. package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
  136. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  137. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  138. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  139. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  140. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  141. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  142. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  143. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
  144. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
  145. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
  146. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  147. package/src/components/ui/slider.tsx +1 -1
  148. package/src/components/viewer-overlay.tsx +169 -52
  149. package/src/components/viewer-zone-system.tsx +1 -1
  150. package/src/hooks/use-auto-save.ts +24 -3
  151. package/src/hooks/use-ceiling-events.ts +175 -0
  152. package/src/hooks/use-drag-action.ts +124 -0
  153. package/src/hooks/use-keyboard.ts +125 -62
  154. package/src/hooks/use-selection.ts +64 -0
  155. package/src/index.tsx +243 -4
  156. package/src/lib/constants.ts +6 -2
  157. package/src/lib/editor-api.ts +62 -0
  158. package/src/lib/elevator-support.ts +96 -0
  159. package/src/lib/floorplan/apply-alignment.ts +111 -0
  160. package/src/lib/floorplan/index.ts +7 -0
  161. package/src/lib/floorplan/selection-tool.ts +40 -0
  162. package/src/lib/floorplan/stairs.ts +12 -1
  163. package/src/lib/fresh-planar-placement.test.ts +102 -0
  164. package/src/lib/fresh-planar-placement.ts +63 -0
  165. package/src/lib/history.ts +2 -1
  166. package/src/lib/level-duplication.test.ts +0 -2
  167. package/src/lib/level-duplication.ts +55 -43
  168. package/src/lib/level-name.ts +11 -0
  169. package/src/lib/material-paint.ts +212 -20
  170. package/src/lib/placement-metadata.ts +29 -0
  171. package/src/lib/planar-cursor-placement.test.ts +43 -0
  172. package/src/lib/planar-cursor-placement.ts +42 -0
  173. package/src/lib/roof-duplication.ts +2 -2
  174. package/src/lib/scene-bounds.ts +1 -1
  175. package/src/lib/scene-clipboard.ts +267 -0
  176. package/src/lib/scene.ts +13 -4
  177. package/src/lib/sfx-bus.ts +25 -1
  178. package/src/lib/sfx-player.ts +75 -27
  179. package/src/lib/site-boundary.ts +1 -0
  180. package/src/lib/stair-duplication.ts +2 -2
  181. package/src/lib/stair-levels.test.ts +146 -0
  182. package/src/lib/stair-levels.ts +177 -0
  183. package/src/lib/world-grid-snap.ts +199 -0
  184. package/src/lib/zone-content.ts +130 -0
  185. package/src/store/use-alignment-guides.ts +21 -0
  186. package/src/store/use-editor.tsx +342 -91
  187. package/src/store/use-placement-preview.ts +31 -0
  188. package/src/store/use-wall-move-ghosts.ts +36 -0
  189. package/src/store/use-wall-snap-indicator.ts +32 -0
  190. package/tsconfig.json +2 -1
  191. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  192. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  193. package/src/components/feedback-dialog.tsx +0 -265
  194. package/src/components/pascal-radio.tsx +0 -280
  195. package/src/components/preview-button.tsx +0 -16
  196. package/src/components/tools/building/move-building-tool.tsx +0 -157
  197. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  198. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  199. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  200. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
  201. package/src/components/tools/column/column-tool.tsx +0 -97
  202. package/src/components/tools/column/move-column-tool.tsx +0 -105
  203. package/src/components/tools/door/door-math.ts +0 -110
  204. package/src/components/tools/door/door-tool.tsx +0 -324
  205. package/src/components/tools/door/move-door-tool.tsx +0 -412
  206. package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
  207. package/src/components/tools/fence/fence-tool.tsx +0 -346
  208. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
  209. package/src/components/tools/fence/move-fence-tool.tsx +0 -298
  210. package/src/components/tools/item/item-tool.tsx +0 -26
  211. package/src/components/tools/roof/move-roof-tool.tsx +0 -359
  212. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  213. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  214. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  215. package/src/components/tools/slab/slab-tool.tsx +0 -322
  216. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  217. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  218. package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
  219. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
  220. package/src/components/tools/wall/move-wall-tool.tsx +0 -356
  221. package/src/components/tools/wall/wall-tool.tsx +0 -332
  222. package/src/components/tools/window/move-window-tool.tsx +0 -447
  223. package/src/components/tools/window/window-math.ts +0 -117
  224. package/src/components/tools/window/window-tool.tsx +0 -332
  225. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  226. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  227. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  228. package/src/components/ui/panels/ceiling-panel.tsx +0 -250
  229. package/src/components/ui/panels/column-panel.tsx +0 -715
  230. package/src/components/ui/panels/door-panel.tsx +0 -1292
  231. package/src/components/ui/panels/fence-panel.tsx +0 -227
  232. package/src/components/ui/panels/item-panel.tsx +0 -306
  233. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  234. package/src/components/ui/panels/roof-panel.tsx +0 -230
  235. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  236. package/src/components/ui/panels/slab-panel.tsx +0 -250
  237. package/src/components/ui/panels/spawn-panel.tsx +0 -155
  238. package/src/components/ui/panels/stair-panel.tsx +0 -525
  239. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  240. package/src/components/ui/panels/wall-panel.tsx +0 -177
  241. package/src/components/ui/panels/window-panel.tsx +0 -960
  242. package/src/components/ui/viewer-toolbar.tsx +0 -436
  243. package/src/contexts/presets-context.tsx +0 -121
  244. 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 { closeDoorOpenState, toggleDoorOpenState } from '../lib/door-interaction'
4
+ import { toggleDoorOpenState } from '../lib/door-interaction'
5
5
  import { runRedo, runUndo } from '../lib/history'
6
- import { sfxEmitter } from '../lib/sfx-bus'
7
- import { closeWindowOpenState, toggleWindowOpenState } from '../lib/window-interaction'
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 === 'd' && !e.metaKey && !e.ctrlKey) {
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
- if (building && building.type === 'building' && building.children.length > 0) {
122
- const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1
123
- const nextIdx = currentIdx < building.children.length - 1 ? currentIdx + 1 : currentIdx
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: building.children[nextIdx] as any })
147
+ useViewer.getState().setSelection({ levelId: levels[nextIdx] as any })
126
148
  } else if (currentIdx === -1) {
127
- useViewer.getState().setSelection({ levelId: building.children[0] as any })
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
- if (building && building.type === 'building' && building.children.length > 0) {
137
- const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1
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: building.children[prevIdx] as any })
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
- // Operable doors/windows use R to toggle their open/closed state.
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
- if (node.openingKind !== 'opening') {
157
- toggleDoorOpenState(node.id)
158
- sfxEmitter.emit('sfx:item-rotate')
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
- } else if (
161
- node?.type === 'window' &&
162
- node.openingKind !== 'opening' &&
163
- (node.windowType === 'sliding' ||
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
- toggleWindowOpenState(node.id)
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
- if (node.openingKind !== 'opening') {
197
- closeDoorOpenState(node.id)
198
- sfxEmitter.emit('sfx:item-rotate')
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
- closeWindowOpenState(node.id)
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
- if (node?.type === 'item') {
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
  }