@pascal-app/editor 0.8.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 (223) hide show
  1. package/package.json +8 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +754 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +58 -51
  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 +201 -83
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +8 -8
  10. package/src/components/editor/first-person-controls.tsx +825 -20
  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 +5829 -12005
  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 +178 -149
  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 +433 -156
  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 +136 -62
  28. package/src/components/editor/thumbnail-generator.tsx +9 -3
  29. package/src/components/editor/use-floorplan-background-placement.ts +119 -32
  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-geometry-renderer.tsx +266 -0
  45. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  46. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  47. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  48. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  49. package/src/components/editor-2d/svg-paths.ts +10 -2
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +26 -3
  51. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  52. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  53. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  54. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  55. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  56. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  57. package/src/components/tools/fence/fence-drafting.ts +64 -22
  58. package/src/components/tools/item/move-tool.tsx +41 -105
  59. package/src/components/tools/item/placement-math.test.ts +10 -0
  60. package/src/components/tools/item/placement-math.ts +31 -5
  61. package/src/components/tools/item/placement-strategies.ts +190 -15
  62. package/src/components/tools/item/placement-types.ts +10 -1
  63. package/src/components/tools/item/use-draft-node.ts +20 -1
  64. package/src/components/tools/item/use-placement-coordinator.tsx +694 -191
  65. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  66. package/src/components/tools/roof/roof-tool.tsx +108 -12
  67. package/src/components/tools/select/box-select-state.ts +77 -0
  68. package/src/components/tools/select/box-select-tool.tsx +324 -538
  69. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  70. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  71. package/src/components/tools/select/select-candidates.test.ts +109 -0
  72. package/src/components/tools/select/select-candidates.ts +85 -0
  73. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  74. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  75. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  76. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  77. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  78. package/src/components/tools/shared/placement-box.tsx +123 -0
  79. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  80. package/src/components/tools/shared/segment-angle.ts +56 -0
  81. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  82. package/src/components/tools/site/site-flag-model.ts +18 -0
  83. package/src/components/tools/stair/stair-defaults.ts +1 -1
  84. package/src/components/tools/stair/stair-tool.tsx +269 -35
  85. package/src/components/tools/tool-manager.tsx +172 -56
  86. package/src/components/tools/wall/wall-drafting.ts +98 -98
  87. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  88. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  89. package/src/components/tools/zone/zone-tool.tsx +16 -6
  90. package/src/components/ui/action-menu/action-button.tsx +21 -1
  91. package/src/components/ui/action-menu/control-modes.tsx +5 -82
  92. package/src/components/ui/action-menu/index.tsx +1 -113
  93. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  94. package/src/components/ui/action-menu/view-toggles.tsx +243 -6
  95. package/src/components/ui/command-palette/editor-commands.tsx +18 -13
  96. package/src/components/ui/command-palette/index.tsx +2 -1
  97. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  98. package/src/components/ui/controls/material-picker.tsx +11 -32
  99. package/src/components/ui/controls/metric-control.tsx +33 -11
  100. package/src/components/ui/floating-level-selector.tsx +47 -10
  101. package/src/components/ui/helpers/helper-manager.tsx +16 -17
  102. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  103. package/src/components/ui/item-catalog/catalog-items.tsx +1 -0
  104. package/src/components/ui/item-catalog/item-catalog.tsx +3 -0
  105. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  106. package/src/components/ui/panels/node-display.ts +1 -0
  107. package/src/components/ui/panels/panel-manager.tsx +35 -47
  108. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  109. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  110. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  111. package/src/components/ui/scene-loader.tsx +1 -3
  112. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  113. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  114. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  115. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  116. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  117. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  118. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  119. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  120. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  121. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  122. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  123. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
  125. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  126. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  127. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  128. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -36
  133. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  134. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  135. package/src/components/viewer-overlay.tsx +169 -52
  136. package/src/components/viewer-zone-system.tsx +1 -1
  137. package/src/hooks/use-auto-save.ts +10 -3
  138. package/src/hooks/use-ceiling-events.ts +175 -0
  139. package/src/hooks/use-drag-action.ts +124 -0
  140. package/src/hooks/use-keyboard.ts +115 -62
  141. package/src/hooks/use-selection.ts +64 -0
  142. package/src/index.tsx +237 -5
  143. package/src/lib/constants.ts +6 -2
  144. package/src/lib/editor-api.ts +62 -0
  145. package/src/lib/elevator-support.ts +96 -0
  146. package/src/lib/floorplan/apply-alignment.ts +111 -0
  147. package/src/lib/floorplan/index.ts +7 -0
  148. package/src/lib/floorplan/selection-tool.ts +40 -0
  149. package/src/lib/floorplan/stairs.ts +12 -1
  150. package/src/lib/fresh-planar-placement.test.ts +102 -0
  151. package/src/lib/fresh-planar-placement.ts +63 -0
  152. package/src/lib/history.ts +2 -1
  153. package/src/lib/level-duplication.ts +54 -42
  154. package/src/lib/level-name.ts +11 -0
  155. package/src/lib/material-paint.ts +211 -19
  156. package/src/lib/placement-metadata.ts +29 -0
  157. package/src/lib/planar-cursor-placement.test.ts +43 -0
  158. package/src/lib/planar-cursor-placement.ts +42 -0
  159. package/src/lib/roof-duplication.ts +1 -1
  160. package/src/lib/scene-clipboard.ts +267 -0
  161. package/src/lib/scene.ts +13 -4
  162. package/src/lib/sfx-bus.ts +23 -1
  163. package/src/lib/sfx-player.ts +70 -22
  164. package/src/lib/site-boundary.ts +1 -0
  165. package/src/lib/stair-levels.test.ts +146 -0
  166. package/src/lib/stair-levels.ts +177 -0
  167. package/src/lib/world-grid-snap.ts +199 -0
  168. package/src/lib/zone-content.ts +130 -0
  169. package/src/store/use-alignment-guides.ts +21 -0
  170. package/src/store/use-editor.tsx +320 -37
  171. package/src/store/use-placement-preview.ts +31 -0
  172. package/src/store/use-wall-move-ghosts.ts +36 -0
  173. package/src/store/use-wall-snap-indicator.ts +32 -0
  174. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  175. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  176. package/src/components/tools/building/move-building-tool.tsx +0 -157
  177. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  178. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  179. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  180. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  181. package/src/components/tools/column/column-tool.tsx +0 -97
  182. package/src/components/tools/column/move-column-tool.tsx +0 -105
  183. package/src/components/tools/door/door-math.ts +0 -110
  184. package/src/components/tools/door/door-tool.tsx +0 -324
  185. package/src/components/tools/door/move-door-tool.tsx +0 -412
  186. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  187. package/src/components/tools/fence/fence-tool.tsx +0 -346
  188. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  189. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  190. package/src/components/tools/item/item-tool.tsx +0 -26
  191. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  192. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  193. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  194. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  195. package/src/components/tools/slab/slab-tool.tsx +0 -322
  196. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  197. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  198. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  199. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  200. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  201. package/src/components/tools/wall/wall-tool.tsx +0 -332
  202. package/src/components/tools/window/move-window-tool.tsx +0 -447
  203. package/src/components/tools/window/window-math.ts +0 -117
  204. package/src/components/tools/window/window-tool.tsx +0 -332
  205. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  206. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  207. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  208. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  209. package/src/components/ui/panels/column-panel.tsx +0 -759
  210. package/src/components/ui/panels/door-panel.tsx +0 -1299
  211. package/src/components/ui/panels/fence-panel.tsx +0 -222
  212. package/src/components/ui/panels/item-panel.tsx +0 -306
  213. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  214. package/src/components/ui/panels/roof-panel.tsx +0 -280
  215. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  216. package/src/components/ui/panels/slab-panel.tsx +0 -250
  217. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  218. package/src/components/ui/panels/stair-panel.tsx +0 -577
  219. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  220. package/src/components/ui/panels/wall-panel.tsx +0 -273
  221. package/src/components/ui/panels/window-panel.tsx +0 -968
  222. package/src/contexts/presets-context.tsx +0 -121
  223. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -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,
@@ -95,7 +99,7 @@ export const useKeyboard = ({
95
99
  useEditor.getState().setPhase('structure')
96
100
  useEditor.getState().setStructureLayer('elements')
97
101
  useEditor.getState().setMode('build')
98
- } else if (e.key === 'd' && !e.metaKey && !e.ctrlKey) {
102
+ } else if (e.key === 'x' && !e.metaKey && !e.ctrlKey) {
99
103
  if (isVersionPreviewMode) return
100
104
  e.preventDefault()
101
105
  useEditor.getState().setMode('delete')
@@ -106,6 +110,17 @@ export const useKeyboard = ({
106
110
  useEditor.getState().setPhase('structure')
107
111
  useEditor.getState().setStructureLayer('elements')
108
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
+ }
109
124
  } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
110
125
  if (isVersionPreviewMode) return
111
126
  e.preventDefault()
@@ -119,13 +134,19 @@ export const useKeyboard = ({
119
134
  const { buildingId, levelId } = useViewer.getState().selection
120
135
  if (buildingId) {
121
136
  const building = useScene.getState().nodes[buildingId]
122
- if (building && building.type === 'building' && building.children.length > 0) {
123
- const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1
124
- 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
125
146
  if (nextIdx !== -1 && nextIdx !== currentIdx) {
126
- useViewer.getState().setSelection({ levelId: building.children[nextIdx] as any })
147
+ useViewer.getState().setSelection({ levelId: levels[nextIdx] as any })
127
148
  } else if (currentIdx === -1) {
128
- useViewer.getState().setSelection({ levelId: building.children[0] as any })
149
+ useViewer.getState().setSelection({ levelId: levels[0] as any })
129
150
  }
130
151
  }
131
152
  }
@@ -134,43 +155,60 @@ export const useKeyboard = ({
134
155
  const { buildingId, levelId } = useViewer.getState().selection
135
156
  if (buildingId) {
136
157
  const building = useScene.getState().nodes[buildingId]
137
- if (building && building.type === 'building' && building.children.length > 0) {
138
- 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
139
166
  const prevIdx = currentIdx > 0 ? currentIdx - 1 : currentIdx
140
167
  if (prevIdx !== -1 && prevIdx !== currentIdx) {
141
- useViewer.getState().setSelection({ levelId: building.children[prevIdx] as any })
168
+ useViewer.getState().setSelection({ levelId: levels[prevIdx] as any })
142
169
  } else if (currentIdx === -1) {
143
- useViewer
144
- .getState()
145
- .setSelection({ levelId: building.children[building.children.length - 1] as any })
170
+ useViewer.getState().setSelection({ levelId: levels[levels.length - 1] as any })
146
171
  }
147
172
  }
148
173
  }
149
174
  } else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode) {
150
175
  // Rotate selected node clockwise if it supports rotation (items, roofs, etc.)
151
- // 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.
152
179
  const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
153
180
  if (selectedNodeIds.length === 1) {
154
181
  const node = useScene.getState().nodes[selectedNodeIds[0]!]
155
182
  if (node?.type === 'door') {
156
183
  e.preventDefault()
157
- if (node.openingKind !== 'opening') {
158
- toggleDoorOpenState(node.id)
159
- 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)
160
190
  }
161
- } else if (
162
- node?.type === 'window' &&
163
- node.openingKind !== 'opening' &&
164
- (node.windowType === 'sliding' ||
165
- node.windowType === 'casement' ||
166
- node.windowType === 'awning' ||
167
- node.windowType === 'hopper' ||
168
- node.windowType === 'single-hung' ||
169
- node.windowType === 'double-hung' ||
170
- node.windowType === 'louvered')
171
- ) {
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.
172
195
  e.preventDefault()
173
- 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)
174
212
  sfxEmitter.emit('sfx:item-rotate')
175
213
  } else if (node && 'rotation' in node) {
176
214
  e.preventDefault()
@@ -193,24 +231,17 @@ export const useKeyboard = ({
193
231
  if (selectedNodeIds.length === 1) {
194
232
  const node = useScene.getState().nodes[selectedNodeIds[0]!]
195
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.
196
236
  e.preventDefault()
197
- if (node.openingKind !== 'opening') {
198
- closeDoorOpenState(node.id)
199
- sfxEmitter.emit('sfx:item-rotate')
200
- }
201
- } else if (
202
- node?.type === 'window' &&
203
- node.openingKind !== 'opening' &&
204
- (node.windowType === 'sliding' ||
205
- node.windowType === 'casement' ||
206
- node.windowType === 'awning' ||
207
- node.windowType === 'hopper' ||
208
- node.windowType === 'single-hung' ||
209
- node.windowType === 'double-hung' ||
210
- node.windowType === 'louvered')
211
- ) {
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.
240
+ e.preventDefault()
241
+ } else if (node && nodeRegistry.get(node.type)?.keyboardActions?.t?.appliesTo(node)) {
242
+ // Registry-driven T action. Same shape as the R arm above.
212
243
  e.preventDefault()
213
- closeWindowOpenState(node.id)
244
+ nodeRegistry.get(node.type)?.keyboardActions?.t?.run(node)
214
245
  sfxEmitter.emit('sfx:item-rotate')
215
246
  } else if (node && 'rotation' in node) {
216
247
  e.preventDefault()
@@ -226,6 +257,32 @@ export const useKeyboard = ({
226
257
  sfxEmitter.emit('sfx:item-rotate')
227
258
  }
228
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
+ }
229
286
  } else if ((e.key === 'Delete' || e.key === 'Backspace') && !isVersionPreviewMode) {
230
287
  e.preventDefault()
231
288
 
@@ -241,15 +298,6 @@ export const useKeyboard = ({
241
298
  }
242
299
  }
243
300
 
244
- // Delete selected zone
245
- const selectedZoneId = useViewer.getState().selection.zoneId
246
- if (selectedZoneId) {
247
- sfxEmitter.emit('sfx:structure-delete')
248
- useScene.getState().deleteNode(selectedZoneId as AnyNodeId)
249
- useViewer.getState().setSelection({ zoneId: null })
250
- return
251
- }
252
-
253
301
  const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
254
302
 
255
303
  if (selectedNodeIds.length > 0) {
@@ -265,16 +313,21 @@ export const useKeyboard = ({
265
313
  // Play appropriate SFX based on what's being deleted
266
314
  if (selectedNodeIds.length === 1) {
267
315
  const node = useScene.getState().nodes[selectedNodeIds[0]!]
268
- if (node?.type === 'item') {
269
- sfxEmitter.emit('sfx:item-delete')
270
- } else {
271
- sfxEmitter.emit('sfx:structure-delete')
272
- }
316
+ emitDeleteSFX(node?.type)
273
317
  } else {
274
318
  sfxEmitter.emit('sfx:structure-delete')
275
319
  }
276
320
 
277
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 })
278
331
  }
279
332
  }
280
333
  }
@@ -0,0 +1,64 @@
1
+ 'use client'
2
+
3
+ import type { AnyNode, AnyNodeId } from '@pascal-app/core'
4
+ import { useScene } from '@pascal-app/core'
5
+ import { useViewer } from '@pascal-app/viewer'
6
+
7
+ /**
8
+ * Resolved current selection — selected node IDs plus a convenience
9
+ * lookup into the live scene store. Returned shape is intentionally
10
+ * narrow: hosts that need richer per-node state can compose this with
11
+ * `useScene()` themselves.
12
+ */
13
+ export type Selection = {
14
+ /** Multi-select node IDs (drives the rest of the editor's UI). */
15
+ selectedIds: AnyNodeId[]
16
+ /** Currently active building context — surfaces / palettes filter on this. */
17
+ buildingId: AnyNodeId | null
18
+ /** Currently active level context. */
19
+ levelId: AnyNodeId | null
20
+ /** Currently active zone context. */
21
+ zoneId: AnyNodeId | null
22
+ /**
23
+ * Resolved nodes for `selectedIds`. Missing entries (deleted nodes) are
24
+ * filtered out, so the array length may be ≤ `selectedIds.length`.
25
+ */
26
+ selectedNodes: AnyNode[]
27
+ /**
28
+ * The single selected node, or `null` when zero or multiple nodes are
29
+ * selected. Useful for "save as preset" / inspector gating where the
30
+ * UI only makes sense for a unique selection.
31
+ */
32
+ selectedNode: AnyNode | null
33
+ }
34
+
35
+ /**
36
+ * Subscribe to the current selection. Equivalent to reading from
37
+ * `useViewer().selection` plus a live `useScene()` lookup, packaged as
38
+ * a single hook so consumers building their own shells (community,
39
+ * standalone editor app, embedders) don't have to learn the two
40
+ * separate stores.
41
+ *
42
+ * Selection state intentionally lives in `useViewer` (it tracks the
43
+ * camera / visibility hierarchy: building → level → zone → nodes), not
44
+ * `useScene` — see `wiki/architecture/scene-registry.md`.
45
+ */
46
+ export function useSelection(): Selection {
47
+ const selection = useViewer((s) => s.selection)
48
+ const nodes = useScene((s) => s.nodes)
49
+
50
+ const selectedIds = selection.selectedIds as AnyNodeId[]
51
+ const selectedNodes = selectedIds
52
+ .map((id) => nodes[id])
53
+ .filter((n): n is AnyNode => n !== undefined)
54
+ const selectedNode = selectedNodes.length === 1 ? (selectedNodes[0] ?? null) : null
55
+
56
+ return {
57
+ selectedIds,
58
+ buildingId: (selection.buildingId ?? null) as AnyNodeId | null,
59
+ levelId: (selection.levelId ?? null) as AnyNodeId | null,
60
+ zoneId: (selection.zoneId ?? null) as AnyNodeId | null,
61
+ selectedNodes,
62
+ selectedNode,
63
+ }
64
+ }