@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
@@ -9,24 +9,28 @@ interface MetricControlProps {
9
9
  label: React.ReactNode
10
10
  value: number
11
11
  onChange: (value: number) => void
12
+ onCommit?: (value: number) => void
12
13
  min?: number
13
14
  max?: number
14
15
  precision?: number
15
16
  step?: number
16
17
  className?: string
17
18
  unit?: string
19
+ restoreOnCommit?: boolean
18
20
  }
19
21
 
20
22
  export function MetricControl({
21
23
  label,
22
24
  value,
23
25
  onChange,
26
+ onCommit,
24
27
  min = Number.NEGATIVE_INFINITY,
25
28
  max = Number.POSITIVE_INFINITY,
26
29
  precision = 2,
27
30
  step = 1,
28
31
  className,
29
32
  unit = '',
33
+ restoreOnCommit = true,
30
34
  }: MetricControlProps) {
31
35
  const viewerUnit = useViewer((state) => state.unit)
32
36
  const isImperial = viewerUnit === 'imperial' && unit === 'm'
@@ -53,6 +57,17 @@ export function MetricControl({
53
57
  [min, max],
54
58
  )
55
59
 
60
+ const applyCommittedValue = useCallback(
61
+ (nextValue: number) => {
62
+ if (onCommit) {
63
+ onCommit(nextValue)
64
+ } else {
65
+ onChange(nextValue)
66
+ }
67
+ },
68
+ [onChange, onCommit],
69
+ )
70
+
56
71
  useEffect(() => {
57
72
  if (!isEditing) {
58
73
  setInputValue(displayValue.toFixed(precision))
@@ -77,13 +92,13 @@ export function MetricControl({
77
92
  const finalValue = Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier
78
93
 
79
94
  if (Math.abs(finalValue - valueRef.current) > 1e-6) {
80
- onChange(finalValue)
95
+ applyCommittedValue(finalValue)
81
96
  }
82
97
  }
83
98
 
84
99
  container.addEventListener('wheel', handleWheel, { passive: false })
85
100
  return () => container.removeEventListener('wheel', handleWheel)
86
- }, [isEditing, step, clamp, onChange, precision, multiplier])
101
+ }, [isEditing, step, clamp, applyCommittedValue, precision, multiplier])
87
102
 
88
103
  useEffect(() => {
89
104
  if (!isHovered || isEditing) return
@@ -104,14 +119,14 @@ export function MetricControl({
104
119
  Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier
105
120
 
106
121
  if (Math.abs(finalValue - valueRef.current) > 1e-6) {
107
- onChange(finalValue)
122
+ applyCommittedValue(finalValue)
108
123
  }
109
124
  }
110
125
  }
111
126
 
112
127
  window.addEventListener('keydown', handleKeyDown)
113
128
  return () => window.removeEventListener('keydown', handleKeyDown)
114
- }, [isHovered, isEditing, step, clamp, onChange, precision, multiplier])
129
+ }, [isHovered, isEditing, step, clamp, applyCommittedValue, precision, multiplier])
115
130
 
116
131
  const handlePointerDown = useCallback(
117
132
  (e: React.PointerEvent) => {
@@ -148,7 +163,14 @@ export function MetricControl({
148
163
  document.removeEventListener('pointermove', handlePointerMove)
149
164
  document.removeEventListener('pointerup', handlePointerUp)
150
165
 
151
- if (Math.abs(finalValue - startValueRef.current) > 1e-6) {
166
+ const changed = Math.abs(finalValue - startValueRef.current) > 1e-6
167
+ if (onCommit) {
168
+ if (changed && restoreOnCommit) {
169
+ onChange(startValueRef.current)
170
+ }
171
+ useScene.temporal.getState().resume()
172
+ onCommit(finalValue)
173
+ } else if (changed) {
152
174
  onChange(startValueRef.current)
153
175
  useScene.temporal.getState().resume()
154
176
  onChange(finalValue)
@@ -160,7 +182,7 @@ export function MetricControl({
160
182
  document.addEventListener('pointermove', handlePointerMove)
161
183
  document.addEventListener('pointerup', handlePointerUp)
162
184
  },
163
- [isEditing, value, onChange, clamp, precision, step, multiplier],
185
+ [isEditing, value, onChange, onCommit, restoreOnCommit, clamp, precision, step, multiplier],
164
186
  )
165
187
 
166
188
  const handleValueClick = useCallback(() => {
@@ -177,10 +199,10 @@ export function MetricControl({
177
199
  if (Number.isNaN(numValue)) {
178
200
  setInputValue((value * multiplier).toFixed(precision))
179
201
  } else {
180
- onChange(clamp(numValue / multiplier))
202
+ applyCommittedValue(clamp(numValue / multiplier))
181
203
  }
182
204
  setIsEditing(false)
183
- }, [inputValue, onChange, clamp, multiplier, value, precision])
205
+ }, [inputValue, applyCommittedValue, clamp, multiplier, value, precision])
184
206
 
185
207
  const handleInputBlur = useCallback(() => {
186
208
  submitValue()
@@ -196,16 +218,16 @@ export function MetricControl({
196
218
  } else if (e.key === 'ArrowUp') {
197
219
  e.preventDefault()
198
220
  const newV = clamp(value + step / multiplier)
199
- onChange(newV)
221
+ applyCommittedValue(newV)
200
222
  setInputValue((newV * multiplier).toFixed(precision))
201
223
  } else if (e.key === 'ArrowDown') {
202
224
  e.preventDefault()
203
225
  const newV = clamp(value - step / multiplier)
204
- onChange(newV)
226
+ applyCommittedValue(newV)
205
227
  setInputValue((newV * multiplier).toFixed(precision))
206
228
  }
207
229
  },
208
- [submitValue, value, multiplier, precision, step, clamp, onChange],
230
+ [submitValue, value, multiplier, precision, step, clamp, applyCommittedValue],
209
231
  )
210
232
 
211
233
  return (
@@ -26,7 +26,7 @@ import {
26
26
  useScene,
27
27
  } from '@pascal-app/core'
28
28
  import { useViewer } from '@pascal-app/viewer'
29
- import { Copy, GripVertical, MoreVertical, Plus, Trash2 } from 'lucide-react'
29
+ import { ClipboardPaste, Copy, GripVertical, MoreVertical, Plus, Trash2 } from 'lucide-react'
30
30
  import {
31
31
  type ButtonHTMLAttributes,
32
32
  type CSSProperties,
@@ -34,13 +34,21 @@ import {
34
34
  useEffect,
35
35
  useRef,
36
36
  useState,
37
+ useSyncExternalStore,
37
38
  } from 'react'
38
39
  import { useShallow } from 'zustand/react/shallow'
39
40
  import {
40
41
  buildLevelDuplicateCreateOps,
41
42
  type LevelDuplicatePreset,
42
43
  } from '../../lib/level-duplication'
44
+ import { getDefaultLevelName, getLevelDisplayName } from '../../lib/level-name'
43
45
  import { deleteLevelWithFallbackSelection } from '../../lib/level-selection'
46
+ import {
47
+ getEditorClipboardSnapshot,
48
+ pasteEditorClipboardToLevel,
49
+ subscribeEditorClipboard,
50
+ } from '../../lib/scene-clipboard'
51
+ import { sfxEmitter } from '../../lib/sfx-bus'
44
52
  import { cn } from '../../lib/utils'
45
53
  import { LevelDuplicateDialog } from './level-duplicate-dialog'
46
54
  import {
@@ -53,10 +61,6 @@ import {
53
61
  } from './primitives/dialog'
54
62
  import { Popover, PopoverContent, PopoverTrigger } from './primitives/popover'
55
63
 
56
- function getLevelDisplayLabel(level: LevelNode) {
57
- return level.name || `Level ${level.level}`
58
- }
59
-
60
64
  // ── Inline rename input for a level row ─────────────────────────────────────
61
65
 
62
66
  function LevelInlineRename({
@@ -69,7 +73,7 @@ function LevelInlineRename({
69
73
  onStopEditing: () => void
70
74
  }) {
71
75
  const updateNode = useScene((s) => s.updateNode)
72
- const defaultName = `Level ${level.level}`
76
+ const defaultName = getDefaultLevelName(level.level)
73
77
  const [value, setValue] = useState(level.name || '')
74
78
  const inputRef = useRef<HTMLInputElement>(null)
75
79
 
@@ -126,6 +130,7 @@ function LevelRow({
126
130
  dragHandleRef,
127
131
  onSelect,
128
132
  onDuplicate,
133
+ onPaste,
129
134
  onRequestDelete,
130
135
  }: {
131
136
  level: LevelNode
@@ -135,6 +140,7 @@ function LevelRow({
135
140
  dragHandleRef?: (element: HTMLButtonElement | null) => void
136
141
  onSelect: () => void
137
142
  onDuplicate: (preset?: LevelDuplicatePreset) => void
143
+ onPaste?: () => void
138
144
  onRequestDelete: () => void
139
145
  }) {
140
146
  const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false)
@@ -160,7 +166,7 @@ function LevelRow({
160
166
  >
161
167
  <button
162
168
  {...dragHandleProps}
163
- aria-label={`Reorder ${getLevelDisplayLabel(level)}`}
169
+ aria-label={`Reorder ${getLevelDisplayName(level)}`}
164
170
  className={cn(
165
171
  'ml-0.5 flex h-6 w-4 shrink-0 cursor-grab touch-none items-center justify-center rounded-md text-muted-foreground/35 opacity-0 transition-colors hover:bg-white/5 hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 group-hover/level:opacity-100',
166
172
  isDragging && 'cursor-grabbing opacity-100',
@@ -183,10 +189,10 @@ function LevelRow({
183
189
  e.stopPropagation()
184
190
  setIsEditing(true)
185
191
  }}
186
- title={getLevelDisplayLabel(level)}
192
+ title={getLevelDisplayName(level)}
187
193
  type="button"
188
194
  >
189
- <span className="truncate">{getLevelDisplayLabel(level)}</span>
195
+ <span className="truncate">{getLevelDisplayName(level)}</span>
190
196
  </button>
191
197
 
192
198
  {/* Vertical three-dot menu — inside the pill */}
@@ -223,6 +229,19 @@ function LevelRow({
223
229
  <Copy className="h-3 w-3" />
224
230
  Duplicate with options...
225
231
  </button>
232
+ {onPaste && (
233
+ <button
234
+ className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors hover:bg-white/10 hover:text-foreground"
235
+ onClick={(e) => {
236
+ e.stopPropagation()
237
+ onPaste()
238
+ }}
239
+ type="button"
240
+ >
241
+ <ClipboardPaste className="h-3 w-3" />
242
+ Paste copied selection
243
+ </button>
244
+ )}
226
245
  <button
227
246
  className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors hover:bg-white/10 hover:text-red-400"
228
247
  onClick={(e) => {
@@ -256,12 +275,14 @@ function SortableLevelRow({
256
275
  isSelected,
257
276
  onSelect,
258
277
  onDuplicate,
278
+ onPaste,
259
279
  onRequestDelete,
260
280
  }: {
261
281
  level: LevelNode
262
282
  isSelected: boolean
263
283
  onSelect: () => void
264
284
  onDuplicate: (preset?: LevelDuplicatePreset) => void
285
+ onPaste?: () => void
265
286
  onRequestDelete: () => void
266
287
  }) {
267
288
  const {
@@ -291,6 +312,7 @@ function SortableLevelRow({
291
312
  isSelected={isSelected}
292
313
  level={level}
293
314
  onDuplicate={onDuplicate}
315
+ onPaste={onPaste}
294
316
  onRequestDelete={onRequestDelete}
295
317
  onSelect={onSelect}
296
318
  />
@@ -310,6 +332,11 @@ export function FloatingLevelSelector() {
310
332
 
311
333
  const [deletingLevel, setDeletingLevel] = useState<LevelNode | null>(null)
312
334
  const [draggingLevelId, setDraggingLevelId] = useState<string | null>(null)
335
+ const clipboardSnapshot = useSyncExternalStore(
336
+ subscribeEditorClipboard,
337
+ getEditorClipboardSnapshot,
338
+ getEditorClipboardSnapshot,
339
+ )
313
340
  const sensors = useSensors(
314
341
  useSensor(PointerSensor, {
315
342
  activationConstraint: { distance: 4 },
@@ -424,6 +451,13 @@ export function FloatingLevelSelector() {
424
451
  [createNodes, levels, resolvedBuildingId, setSelection, updateNodes],
425
452
  )
426
453
 
454
+ const handlePasteToLevel = useCallback((level: LevelNode) => {
455
+ const result = pasteEditorClipboardToLevel(level.id)
456
+ if (result?.pastedIds.length) {
457
+ sfxEmitter.emit('sfx:item-place')
458
+ }
459
+ }, [])
460
+
427
461
  const handleDragStart = useCallback((event: DragStartEvent) => {
428
462
  setDraggingLevelId(String(event.active.id))
429
463
  }, [])
@@ -523,6 +557,9 @@ export function FloatingLevelSelector() {
523
557
  isSelected={isSelected}
524
558
  level={level}
525
559
  onDuplicate={(preset) => handleDuplicateLevel(level, preset)}
560
+ onPaste={
561
+ clipboardSnapshot ? () => handlePasteToLevel(level) : undefined
562
+ }
526
563
  onRequestDelete={() => setDeletingLevel(level)}
527
564
  onSelect={() =>
528
565
  setSelection(
@@ -559,7 +596,7 @@ export function FloatingLevelSelector() {
559
596
  <DialogTitle>Delete level</DialogTitle>
560
597
  <DialogDescription>
561
598
  Are you sure you want to delete{' '}
562
- <strong>{deletingLevel ? getLevelDisplayLabel(deletingLevel) : ''}</strong>? All
599
+ <strong>{deletingLevel ? getLevelDisplayName(deletingLevel) : ''}</strong>? All
563
600
  walls, floors, and objects on this level will be permanently removed.
564
601
  </DialogDescription>
565
602
  </DialogHeader>
@@ -1,13 +1,12 @@
1
1
  'use client'
2
2
 
3
+ import { nodeRegistry } from '@pascal-app/core'
3
4
  import { useIsMobile } from '../../../hooks/use-mobile'
4
5
  import useEditor from '../../../store/use-editor'
5
6
  import { BuildingHelper } from './building-helper'
6
- import { CeilingHelper } from './ceiling-helper'
7
7
  import { ItemHelper } from './item-helper'
8
+ import { RegisteredToolHelper } from './registered-tool-helper'
8
9
  import { RoofHelper } from './roof-helper'
9
- import { SlabHelper } from './slab-helper'
10
- import { WallHelper } from './wall-helper'
11
10
 
12
11
  export function HelperManager() {
13
12
  const mode = useEditor((s) => s.mode)
@@ -27,19 +26,19 @@ export function HelperManager() {
27
26
  return null
28
27
  }
29
28
 
30
- // Show appropriate helper based on current tool
31
- switch (tool) {
32
- case 'wall':
33
- return <WallHelper />
34
- case 'item':
35
- return <ItemHelper />
36
- case 'slab':
37
- return <SlabHelper />
38
- case 'ceiling':
39
- return <CeilingHelper />
40
- case 'roof':
41
- return <RoofHelper />
42
- default:
43
- return null
29
+ // Registry-first: kinds with `def.toolHints` render through the generic
30
+ // `RegisteredToolHelper`. Today that covers ceiling / door / fence /
31
+ // item / shelf / slab / spawn / wall / window.
32
+ if (tool) {
33
+ const def = nodeRegistry.get(tool)
34
+ if (def?.toolHints && def.toolHints.length > 0) {
35
+ return <RegisteredToolHelper hints={def.toolHints} />
36
+ }
44
37
  }
38
+
39
+ // Legacy fallback — only `roof` remains because it hasn't migrated to
40
+ // `def.tool` / `def.toolHints` yet (no Stage D port). When roof
41
+ // migrates, this switch deletes outright.
42
+ if (tool === 'roof') return <RoofHelper />
43
+ return null
45
44
  }
@@ -0,0 +1,25 @@
1
+ import type { ToolHint } from '@pascal-app/core'
2
+ import { ShortcutToken } from '../primitives/shortcut-token'
3
+
4
+ /**
5
+ * Generic helper panel rendered from `def.toolHints` data. Matches the
6
+ * visual styling of the hand-written `<WallHelper>` / `<ItemHelper>` /
7
+ * etc. so registry-driven kinds get a consistent look without each kind
8
+ * writing its own component.
9
+ *
10
+ * Drops the need for per-kind helper files entirely — kinds declare
11
+ * their hints as static data in their `NodeDefinition`.
12
+ */
13
+ export function RegisteredToolHelper({ hints }: { hints: ToolHint[] }) {
14
+ if (hints.length === 0) return null
15
+ return (
16
+ <div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
17
+ {hints.map((hint) => (
18
+ <div className="flex items-center gap-2 text-sm" key={`${hint.key}:${hint.label}`}>
19
+ <ShortcutToken value={hint.key} />
20
+ <span className="text-muted-foreground">{hint.label}</span>
21
+ </div>
22
+ ))}
23
+ </div>
24
+ )
25
+ }
@@ -628,6 +628,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
628
628
  rotation: [0, 0, 0],
629
629
  scale: [1, 1, 1],
630
630
  attachTo: 'ceiling',
631
+ recessed: true,
631
632
  interactive: {
632
633
  controls: [
633
634
  { kind: 'toggle' },
@@ -9,6 +9,7 @@ import {
9
9
  TooltipContent,
10
10
  TooltipTrigger,
11
11
  } from './../../../components/ui/primitives/tooltip'
12
+ import { triggerSFX } from './../../../lib/sfx-bus'
12
13
  import { cn } from './../../../lib/utils'
13
14
  import useEditor, { type CatalogCategory } from './../../../store/use-editor'
14
15
  import { CATALOG_ITEMS } from './catalog-items'
@@ -94,10 +95,12 @@ export function ItemCatalog({
94
95
  )}
95
96
  key={index}
96
97
  onClick={() => {
98
+ triggerSFX('sfx:menu-click')
97
99
  setSelectedItem(item)
98
100
  setTool('item')
99
101
  setMode('build')
100
102
  }}
103
+ onMouseEnter={() => triggerSFX('sfx:menu-hover')}
101
104
  type="button"
102
105
  >
103
106
  <div className="relative aspect-square w-full overflow-hidden rounded-lg">
@@ -3,6 +3,7 @@
3
3
  import type { LevelNode } from '@pascal-app/core'
4
4
  import { useEffect, useState } from 'react'
5
5
  import type { LevelDuplicatePreset } from '../../lib/level-duplication'
6
+ import { getLevelDisplayName } from '../../lib/level-name'
6
7
  import { cn } from '../../lib/utils'
7
8
  import {
8
9
  Dialog,
@@ -42,7 +43,7 @@ const DUPLICATE_PRESETS: Array<{
42
43
 
43
44
  function getLevelLabel(level: LevelNode | null) {
44
45
  if (!level) return 'this level'
45
- return level.name || `Level ${level.level}`
46
+ return getLevelDisplayName(level)
46
47
  }
47
48
 
48
49
  export function LevelDuplicateDialog({
@@ -13,6 +13,7 @@ const TYPE_DEFAULTS: Record<string, NodeDisplay> = {
13
13
  slab: { icon: '/icons/floor.png', label: 'Slab' },
14
14
  ceiling: { icon: '/icons/ceiling.png', label: 'Ceiling' },
15
15
  column: { icon: '/icons/column.png', label: 'Column' },
16
+ elevator: { icon: '/icons/elevator.png', label: 'Elevator' },
16
17
  fence: { icon: '/icons/fence.png', label: 'Fence' },
17
18
  roof: { icon: '/icons/roof.png', label: 'Roof' },
18
19
  'roof-segment': { icon: '/icons/roof.png', label: 'Roof segment' },
@@ -5,8 +5,11 @@ import {
5
5
  type AnyNodeId,
6
6
  type BuildingNode,
7
7
  type CeilingNode,
8
+ type ChimneyNode,
8
9
  type ColumnNode,
9
10
  type DoorNode,
11
+ type DormerNode,
12
+ type ElevatorNode,
10
13
  type FenceNode,
11
14
  type ItemNode,
12
15
  type RoofNode,
@@ -23,31 +26,22 @@ import { useCallback, useEffect, useState } from 'react'
23
26
  import { useIsMobile } from '../../../hooks/use-mobile'
24
27
  import { sfxEmitter } from '../../../lib/sfx-bus'
25
28
  import useEditor from '../../../store/use-editor'
26
- import { CeilingPanel } from './ceiling-panel'
27
- import { ColumnPanel } from './column-panel'
28
- import { DoorPanel } from './door-panel'
29
- import { FencePanel } from './fence-panel'
30
- import { ItemPanel } from './item-panel'
31
29
  import { MobilePanelSheet } from './mobile-panel-sheet'
32
30
  import { MobileSelectionBar } from './mobile-selection-bar'
33
31
  import { getNodeDisplay } from './node-display'
34
32
  import { PaintPanel } from './paint-panel'
33
+ import { ParametricInspector } from './parametric-inspector'
35
34
  import { ReferencePanel } from './reference-panel'
36
- import { RoofPanel } from './roof-panel'
37
- import { RoofSegmentPanel } from './roof-segment-panel'
38
- import { SlabPanel } from './slab-panel'
39
- import { SpawnPanel } from './spawn-panel'
40
- import { StairPanel } from './stair-panel'
41
- import { StairSegmentPanel } from './stair-segment-panel'
42
- import { WallPanel } from './wall-panel'
43
- import { WindowPanel } from './window-panel'
44
35
 
45
36
  type MovableNode =
46
37
  | ItemNode
47
38
  | WindowNode
48
39
  | DoorNode
40
+ | ElevatorNode
49
41
  | CeilingNode
42
+ | ChimneyNode
50
43
  | ColumnNode
44
+ | DormerNode
51
45
  | SlabNode
52
46
  | WallNode
53
47
  | FenceNode
@@ -61,8 +55,11 @@ const MOVABLE_TYPES = new Set<string>([
61
55
  'item',
62
56
  'window',
63
57
  'door',
58
+ 'elevator',
64
59
  'ceiling',
60
+ 'chimney',
65
61
  'column',
62
+ 'dormer',
66
63
  'slab',
67
64
  'wall',
68
65
  'fence',
@@ -77,38 +74,17 @@ function isMovableNode(node: AnyNode | null): node is MovableNode {
77
74
  return !!node && MOVABLE_TYPES.has(node.type)
78
75
  }
79
76
 
80
- function panelForType(type: string | null) {
77
+ function panelForType(type: string | null, footer?: React.ReactNode) {
81
78
  if (!type) return null
82
- switch (type) {
83
- case 'item':
84
- return <ItemPanel />
85
- case 'roof':
86
- return <RoofPanel />
87
- case 'roof-segment':
88
- return <RoofSegmentPanel />
89
- case 'stair':
90
- return <StairPanel />
91
- case 'stair-segment':
92
- return <StairSegmentPanel />
93
- case 'slab':
94
- return <SlabPanel />
95
- case 'spawn':
96
- return <SpawnPanel />
97
- case 'ceiling':
98
- return <CeilingPanel />
99
- case 'column':
100
- return <ColumnPanel />
101
- case 'wall':
102
- return <WallPanel />
103
- case 'fence':
104
- return <FencePanel />
105
- case 'door':
106
- return <DoorPanel />
107
- case 'window':
108
- return <WindowPanel />
109
- default:
110
- return null
111
- }
79
+ // Every kind now renders through `<ParametricInspector>`, which either
80
+ // composes auto-derived editors from `parametrics.groups` or lazy-
81
+ // loads the kind-owned panel via `parametrics.customPanel`. The
82
+ // hardcoded switch is gone — all per-kind panel layout lives in
83
+ // `nodes/src/<kind>/panel.tsx`. The `type` arg is preserved for
84
+ // future cases where we might want a non-registry fallback (e.g.
85
+ // reference scale, paint mode); leave the function shape intact.
86
+ void type
87
+ return <ParametricInspector footer={footer} />
112
88
  }
113
89
 
114
90
  function MobilePanelLayer({
@@ -192,9 +168,11 @@ function MobilePanelLayer({
192
168
  )
193
169
  }
194
170
 
195
- export function PanelManager() {
171
+ export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.ReactNode }) {
196
172
  const isMobile = useIsMobile()
197
173
  const selectedIds = useViewer((s) => s.selection.selectedIds)
174
+ const selectedZoneId = useViewer((s) => s.selection.zoneId)
175
+ const setSelection = useViewer((s) => s.setSelection)
198
176
  const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
199
177
  const isPaintPanelOpen = useEditor((s) => s.isPaintPanelOpen)
200
178
  const mode = useEditor((s) => s.mode)
@@ -239,6 +217,16 @@ export function PanelManager() {
239
217
  return <PaintPanel />
240
218
  }
241
219
 
242
- // Show appropriate panel based on selected node type
243
- return panelForType(selectedNodeType)
220
+ if (selectedZoneId && selectedIds.length === 0) {
221
+ return (
222
+ <ParametricInspector
223
+ footer={inspectorFooter}
224
+ key={selectedZoneId}
225
+ nodeId={selectedZoneId as AnyNodeId}
226
+ onClose={() => setSelection({ zoneId: null })}
227
+ />
228
+ )
229
+ }
230
+
231
+ return panelForType(selectedNodeType, inspectorFooter)
244
232
  }