@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
@@ -0,0 +1,267 @@
1
+ import {
2
+ AnyNode,
3
+ type AnyNodeId,
4
+ generateId,
5
+ type LevelNode,
6
+ type StairNode,
7
+ useScene,
8
+ } from '@pascal-app/core'
9
+ import { useViewer } from '@pascal-app/viewer'
10
+
11
+ type ClipboardPayload = {
12
+ copiedAt: number
13
+ nodes: AnyNode[]
14
+ rootIds: AnyNodeId[]
15
+ }
16
+
17
+ type PasteResult = {
18
+ pastedIds: AnyNodeId[]
19
+ skippedIds: AnyNodeId[]
20
+ }
21
+
22
+ const COPYABLE_ROOT_TYPES = new Set<AnyNode['type']>([
23
+ 'wall',
24
+ 'fence',
25
+ 'column',
26
+ 'item',
27
+ 'slab',
28
+ 'ceiling',
29
+ 'roof',
30
+ 'stair',
31
+ 'spawn',
32
+ 'zone',
33
+ ])
34
+
35
+ let clipboardPayload: ClipboardPayload | null = null
36
+ const subscribers = new Set<() => void>()
37
+
38
+ function notifySubscribers() {
39
+ for (const subscriber of subscribers) {
40
+ subscriber()
41
+ }
42
+ }
43
+
44
+ export function subscribeEditorClipboard(subscriber: () => void) {
45
+ subscribers.add(subscriber)
46
+ return () => {
47
+ subscribers.delete(subscriber)
48
+ }
49
+ }
50
+
51
+ export function getEditorClipboardSnapshot() {
52
+ return clipboardPayload
53
+ }
54
+
55
+ export function hasEditorClipboard() {
56
+ return !!clipboardPayload && clipboardPayload.rootIds.length > 0
57
+ }
58
+
59
+ function extractIdPrefix(id: string) {
60
+ const underscoreIndex = id.indexOf('_')
61
+ return underscoreIndex === -1 ? 'node' : id.slice(0, underscoreIndex)
62
+ }
63
+
64
+ function collectSubtreeIds(
65
+ nodes: Record<AnyNodeId, AnyNode>,
66
+ rootId: AnyNodeId,
67
+ ids: Set<AnyNodeId>,
68
+ ) {
69
+ if (ids.has(rootId)) return
70
+ const node = nodes[rootId]
71
+ if (!node) return
72
+ ids.add(rootId)
73
+
74
+ if ('children' in node && Array.isArray(node.children)) {
75
+ for (const childId of node.children as AnyNodeId[]) {
76
+ collectSubtreeIds(nodes, childId, ids)
77
+ }
78
+ }
79
+ }
80
+
81
+ function hasSelectedAncestor(
82
+ nodes: Record<AnyNodeId, AnyNode>,
83
+ id: AnyNodeId,
84
+ selectedIds: Set<AnyNodeId>,
85
+ ) {
86
+ let parentId = nodes[id]?.parentId as AnyNodeId | null
87
+
88
+ while (parentId) {
89
+ if (selectedIds.has(parentId)) return true
90
+ parentId = nodes[parentId]?.parentId as AnyNodeId | null
91
+ }
92
+
93
+ return false
94
+ }
95
+
96
+ function isLevelChildRoot(nodes: Record<AnyNodeId, AnyNode>, node: AnyNode) {
97
+ const parentId = node.parentId as AnyNodeId | null
98
+ if (!parentId) return true
99
+ return nodes[parentId]?.type === 'level'
100
+ }
101
+
102
+ function getPasteTargetLevel(targetLevelId?: AnyNodeId) {
103
+ const scene = useScene.getState()
104
+ const resolvedLevelId =
105
+ targetLevelId ?? (useViewer.getState().selection.levelId as AnyNodeId | null)
106
+ if (!resolvedLevelId) return null
107
+
108
+ const level = scene.nodes[resolvedLevelId]
109
+ return level?.type === 'level' ? level : null
110
+ }
111
+
112
+ function getNextLevelId(level: LevelNode, nodes: Record<AnyNodeId, AnyNode>) {
113
+ const parentId = level.parentId as AnyNodeId | null
114
+ if (!parentId) return null
115
+
116
+ const building = nodes[parentId]
117
+ if (!building || building.type !== 'building') return null
118
+
119
+ const siblingLevels = building.children
120
+ .map((childId) => nodes[childId as AnyNodeId])
121
+ .filter((node): node is LevelNode => node?.type === 'level')
122
+
123
+ return (
124
+ siblingLevels
125
+ .filter((candidate) => candidate.level > level.level)
126
+ .sort((a, b) => a.level - b.level)[0]?.id ?? null
127
+ )
128
+ }
129
+
130
+ function remapNodeReferences(
131
+ node: AnyNode,
132
+ oldId: AnyNodeId,
133
+ targetLevel: LevelNode,
134
+ idMap: Map<AnyNodeId, AnyNodeId>,
135
+ rootIds: Set<AnyNodeId>,
136
+ nodes: Record<AnyNodeId, AnyNode>,
137
+ ) {
138
+ const clone = JSON.parse(JSON.stringify(node)) as AnyNode
139
+ ;(clone as Record<string, unknown>).id = idMap.get(oldId)
140
+
141
+ if (rootIds.has(oldId)) {
142
+ clone.parentId = targetLevel.id
143
+ } else if (clone.parentId && typeof clone.parentId === 'string') {
144
+ clone.parentId = idMap.get(clone.parentId as AnyNodeId) ?? clone.parentId
145
+ }
146
+
147
+ if ('children' in clone && Array.isArray(clone.children)) {
148
+ ;(clone as Record<string, unknown>).children = (clone.children as AnyNodeId[])
149
+ .map((childId) => idMap.get(childId))
150
+ .filter((childId): childId is AnyNodeId => !!childId)
151
+ }
152
+
153
+ if ('wallId' in clone && typeof clone.wallId === 'string') {
154
+ const nextWallId = idMap.get(clone.wallId as AnyNodeId)
155
+ if (nextWallId) {
156
+ ;(clone as Record<string, unknown>).wallId = nextWallId
157
+ } else {
158
+ delete (clone as Record<string, unknown>).wallId
159
+ }
160
+ }
161
+
162
+ if (clone.type === 'stair') {
163
+ const nextLevelId = getNextLevelId(targetLevel, nodes)
164
+ ;(clone as StairNode).fromLevelId = targetLevel.id
165
+ ;(clone as StairNode).toLevelId = nextLevelId
166
+ }
167
+
168
+ const metadata =
169
+ clone.metadata && typeof clone.metadata === 'object' && !Array.isArray(clone.metadata)
170
+ ? { ...(clone.metadata as Record<string, unknown>) }
171
+ : {}
172
+ delete metadata.isNew
173
+ delete metadata.isTransient
174
+ ;(clone as Record<string, unknown>).metadata = metadata
175
+
176
+ return AnyNode.parse(clone)
177
+ }
178
+
179
+ export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) {
180
+ const scene = useScene.getState()
181
+ const ids = selectedIds ?? (useViewer.getState().selection.selectedIds as AnyNodeId[])
182
+ const selectedIdSet = new Set(ids)
183
+ const rootIds = ids.filter((id) => {
184
+ const node = scene.nodes[id]
185
+ return (
186
+ node &&
187
+ COPYABLE_ROOT_TYPES.has(node.type) &&
188
+ isLevelChildRoot(scene.nodes, node) &&
189
+ !hasSelectedAncestor(scene.nodes, id, selectedIdSet)
190
+ )
191
+ })
192
+
193
+ if (rootIds.length === 0) {
194
+ return false
195
+ }
196
+
197
+ const subtreeIds = new Set<AnyNodeId>()
198
+ for (const rootId of rootIds) {
199
+ collectSubtreeIds(scene.nodes, rootId, subtreeIds)
200
+ }
201
+
202
+ clipboardPayload = {
203
+ copiedAt: Date.now(),
204
+ nodes: [...subtreeIds]
205
+ .map((id) => scene.nodes[id])
206
+ .filter((node): node is AnyNode => !!node)
207
+ .map((node) => JSON.parse(JSON.stringify(node)) as AnyNode),
208
+ rootIds,
209
+ }
210
+ notifySubscribers()
211
+
212
+ return true
213
+ }
214
+
215
+ export function pasteEditorClipboardToLevel(targetLevelId?: AnyNodeId): PasteResult | null {
216
+ const payload = clipboardPayload
217
+ const targetLevel = getPasteTargetLevel(targetLevelId)
218
+ if (!payload || !targetLevel) return null
219
+
220
+ const scene = useScene.getState()
221
+ const idMap = new Map<AnyNodeId, AnyNodeId>()
222
+
223
+ for (const node of payload.nodes) {
224
+ idMap.set(node.id as AnyNodeId, generateId(extractIdPrefix(node.id)) as AnyNodeId)
225
+ }
226
+
227
+ const rootIdSet = new Set(payload.rootIds)
228
+ const pastedNodes: AnyNode[] = []
229
+ const skippedIds: AnyNodeId[] = []
230
+
231
+ for (const node of payload.nodes) {
232
+ try {
233
+ pastedNodes.push(
234
+ remapNodeReferences(node, node.id as AnyNodeId, targetLevel, idMap, rootIdSet, scene.nodes),
235
+ )
236
+ } catch (error) {
237
+ console.error('Failed to paste copied node', node.id, error)
238
+ skippedIds.push(node.id as AnyNodeId)
239
+ }
240
+ }
241
+
242
+ if (pastedNodes.length === 0) {
243
+ return { pastedIds: [], skippedIds }
244
+ }
245
+
246
+ scene.createNodes(
247
+ pastedNodes.map((node) => ({
248
+ node,
249
+ parentId: (node.parentId as AnyNodeId | null) ?? undefined,
250
+ })),
251
+ )
252
+
253
+ const pastedNodeIds = new Set(pastedNodes.map((node) => node.id as AnyNodeId))
254
+ const pastedRootIds = payload.rootIds
255
+ .map((rootId) => idMap.get(rootId))
256
+ .filter((id): id is AnyNodeId => !!id && pastedNodeIds.has(id))
257
+
258
+ useViewer.getState().setSelection({
259
+ levelId: targetLevel.id,
260
+ selectedIds: pastedRootIds,
261
+ })
262
+
263
+ return {
264
+ pastedIds: pastedRootIds,
265
+ skippedIds,
266
+ }
267
+ }
package/src/lib/scene.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { resolveLevelId, sceneRegistry, useScene } from '@pascal-app/core'
3
+ import { nodeRegistry, resolveLevelId, sceneRegistry, useScene } from '@pascal-app/core'
4
4
  import { useViewer } from '@pascal-app/viewer'
5
5
  import useEditor, {
6
6
  hasCustomPersistedEditorUiState,
@@ -220,7 +220,11 @@ function getValidatedSelectionForScene(
220
220
 
221
221
  const selectedIds = selection.selectedIds.filter((id) => {
222
222
  const node = sceneNodes[id]
223
- return Boolean(node) && resolveLevelId(node, sceneNodes) === levelId
223
+ if (!node) return false
224
+ if (resolveLevelId(node, sceneNodes) === levelId) return true
225
+
226
+ const def = nodeRegistry.get(node.type)
227
+ return def?.floorplanScope === 'building' && node.parentId === buildingId
224
228
  })
225
229
 
226
230
  return {
@@ -283,7 +287,11 @@ export function syncEditorSelectionFromCurrentScene() {
283
287
 
284
288
  if (shouldRestoreEditorUiState) {
285
289
  if (restoredSelection) {
286
- useViewer.getState().setSelection(restoredSelection)
290
+ // PersistedSelectionPath carries plain `string` ids (read from
291
+ // localStorage, no branded-template-literal guarantee). The viewer's
292
+ // SelectionPath expects branded ids. The runtime values match the
293
+ // brand; the cast bridges the static gap.
294
+ useViewer.getState().setSelection(restoredSelection as never)
287
295
  useEditor.setState(
288
296
  restoredEditorUiState.phase === 'site'
289
297
  ? (selectionDrivenEditorUiState ?? restoredEditorUiState)
@@ -305,7 +313,7 @@ export function syncEditorSelectionFromCurrentScene() {
305
313
  }
306
314
 
307
315
  if (restoredSelection) {
308
- useViewer.getState().setSelection(restoredSelection)
316
+ useViewer.getState().setSelection(restoredSelection as never)
309
317
  if (selectionDrivenEditorUiState) {
310
318
  useEditor.setState(selectionDrivenEditorUiState)
311
319
  }
@@ -351,6 +359,7 @@ function resetEditorInteractionState() {
351
359
  selectedReferenceId: null,
352
360
  spaces: {},
353
361
  editingHole: null,
362
+ hoveredHole: null,
354
363
  isPreviewMode: false,
355
364
  })
356
365
  }
@@ -10,8 +10,12 @@ type SFXEvents = {
10
10
  'sfx:item-pick': undefined
11
11
  'sfx:item-place': undefined
12
12
  'sfx:item-rotate': undefined
13
+ 'sfx:structure-build-start': undefined
13
14
  'sfx:structure-build': undefined
14
15
  'sfx:structure-delete': undefined
16
+ 'sfx:snapshot-capture': undefined
17
+ 'sfx:menu-hover': undefined
18
+ 'sfx:menu-click': undefined
15
19
  }
16
20
 
17
21
  /**
@@ -35,8 +39,12 @@ export function initSFXBus() {
35
39
  sfxEmitter.on('sfx:item-pick', () => playSFX('itemPick'))
36
40
  sfxEmitter.on('sfx:item-place', () => playSFX('itemPlace'))
37
41
  sfxEmitter.on('sfx:item-rotate', () => playSFX('itemRotate'))
38
- sfxEmitter.on('sfx:structure-build', () => playSFX('structureBuild'))
42
+ sfxEmitter.on('sfx:structure-build-start', () => playSFX('structureBuildStart'))
43
+ sfxEmitter.on('sfx:structure-build', () => playSFX('structureBuildEnd'))
39
44
  sfxEmitter.on('sfx:structure-delete', () => playSFX('structureDelete'))
45
+ sfxEmitter.on('sfx:snapshot-capture', () => playSFX('snapshotCapture'))
46
+ sfxEmitter.on('sfx:menu-hover', () => playSFX('menuHover'))
47
+ sfxEmitter.on('sfx:menu-click', () => playSFX('menuClick'))
40
48
  }
41
49
 
42
50
  /**
@@ -47,3 +55,19 @@ export function initSFXBus() {
47
55
  export function triggerSFX(event: keyof SFXEvents) {
48
56
  sfxEmitter.emit(event)
49
57
  }
58
+
59
+ /**
60
+ * Node types whose deletion should use the lighter item-delete cue rather
61
+ * than the heavier structure-delete one. Shelves are furniture-like placeable
62
+ * objects, so they sound like items being removed, not structures demolished.
63
+ */
64
+ const ITEM_DELETE_NODE_TYPES = new Set(['item', 'shelf'])
65
+
66
+ /**
67
+ * Emit the delete SFX appropriate for a deleted node's type.
68
+ */
69
+ export function emitDeleteSFX(nodeType: string | undefined) {
70
+ sfxEmitter.emit(
71
+ nodeType && ITEM_DELETE_NODE_TYPES.has(nodeType) ? 'sfx:item-delete' : 'sfx:structure-delete',
72
+ )
73
+ }
@@ -2,10 +2,11 @@ import { Howl } from 'howler'
2
2
  import useAudio from '../store/use-audio'
3
3
 
4
4
  // Per-sound variation config. Playback rate also shifts pitch (one semitone ≈ 1.0595×),
5
- // so a rate range of ~0.88–1.12 reads as a subtle ±2 semitones, enough to kill the
5
+ // so a rate range of ~0.88–1.12 reads as a subtle ±2 semitones enough to kill the
6
6
  // machine-gun feeling when the same SFX fires in rapid succession.
7
7
  type SFXConfig = {
8
- src: string
8
+ // One file, or several pre-rendered variations cycled round-robin per play.
9
+ src: string | string[]
9
10
  // Random playback-rate range applied per play (1 = unchanged).
10
11
  rateRange?: [number, number]
11
12
  // Random volume multiplier range applied per play (1 = unchanged).
@@ -13,8 +14,8 @@ type SFXConfig = {
13
14
  // Minimum gap between two plays of this SFX. Triggers within this window
14
15
  // are silently dropped so bursty sequences don't phase-stack into noise.
15
16
  minIntervalMs?: number
16
- // Random stereo pan per play, max absolute offset (0 = center, 1 = hard
17
- // right). A small value like 0.15 keeps things centered but adds just enough
17
+ // Random stereo pan per play max absolute offset (0 = center, 1 = hard
18
+ // right). A small value like 0.15 keeps things centred but adds just enough
18
19
  // spread to stop repeats from stacking on the same point in the field.
19
20
  panJitter?: number
20
21
  }
@@ -24,26 +25,31 @@ const DEFAULT_MIN_INTERVAL_MS = 30
24
25
  // SFX sound definitions
25
26
  export const SFX: Record<string, SFXConfig> = {
26
27
  gridSnap: {
27
- src: '/audios/sfx/grid_snap.mp3',
28
- rateRange: [0.94, 1.06],
29
- volumeRange: [0.92, 1.0],
28
+ src: [
29
+ '/audios/sfx/grid_snap_0.mp3',
30
+ '/audios/sfx/grid_snap_1.mp3',
31
+ '/audios/sfx/grid_snap_2.mp3',
32
+ ],
33
+ rateRange: [0.98, 1.02],
34
+ volumeRange: [0.5, 0.6],
30
35
  panJitter: 0.15,
36
+ minIntervalMs: 50,
31
37
  },
32
38
  itemDelete: {
33
39
  src: '/audios/sfx/item_delete.mp3',
34
40
  rateRange: [0.9, 1.1],
35
41
  volumeRange: [0.9, 1.0],
36
- panJitter: 0.15,
42
+ panJitter: 0.05,
37
43
  },
38
44
  itemPick: {
39
45
  src: '/audios/sfx/item_pick.mp3',
40
- rateRange: [0.92, 1.08],
46
+ rateRange: [0.95, 1.05],
41
47
  volumeRange: [0.92, 1.0],
42
48
  panJitter: 0.15,
43
49
  },
44
50
  itemPlace: {
45
51
  src: '/audios/sfx/item_place.mp3',
46
- rateRange: [0.98, 1.06],
52
+ rateRange: [0.98, 1.02],
47
53
  volumeRange: [0.9, 1.0],
48
54
  panJitter: 0.15,
49
55
  },
@@ -53,8 +59,16 @@ export const SFX: Record<string, SFXConfig> = {
53
59
  volumeRange: [0.92, 1.0],
54
60
  panJitter: 0.15,
55
61
  },
56
- structureBuild: {
57
- src: '/audios/sfx/structure_build.mp3',
62
+ // Fired when a structure draft begins (first click of a wall/slab/etc).
63
+ structureBuildStart: {
64
+ src: '/audios/sfx/structure_build_start.mp3',
65
+ rateRange: [0.95, 1.05],
66
+ volumeRange: [0.88, 1.0],
67
+ panJitter: 0.15,
68
+ },
69
+ // Fired when a structure is committed (segment placed / polygon closed).
70
+ structureBuildEnd: {
71
+ src: '/audios/sfx/structure_build_end.mp3',
58
72
  rateRange: [0.95, 1.05],
59
73
  volumeRange: [0.88, 1.0],
60
74
  panJitter: 0.15,
@@ -63,12 +77,29 @@ export const SFX: Record<string, SFXConfig> = {
63
77
  src: '/audios/sfx/structure_delete.mp3',
64
78
  rateRange: [0.9, 1.1],
65
79
  volumeRange: [0.9, 1.0],
66
- panJitter: 0.15,
80
+ panJitter: 0.08,
67
81
  },
68
82
  snapshotCapture: {
69
- // Shutter should sound consistent, no variation.
83
+ // Shutter should sound consistent no variation.
70
84
  src: '/audios/sfx/snapshot_capture.mp3',
71
85
  },
86
+ // Soft tick when hovering a main category in the Build / Items panels.
87
+ // Kept quiet and rate-locked so sweeping across the grid reads as texture,
88
+ // not a melody.
89
+ menuHover: {
90
+ src: '/audios/sfx/menu_hover.mp3',
91
+ rateRange: [0.98, 1.02],
92
+ volumeRange: [0.2, 0.3],
93
+ panJitter: 0.1,
94
+ minIntervalMs: 0,
95
+ },
96
+ // Fired when a main category in the Build / Items panels is clicked.
97
+ menuClick: {
98
+ src: '/audios/sfx/menu_click.mp3',
99
+ rateRange: [0.98, 1.02],
100
+ volumeRange: [0.5, 0.6],
101
+ panJitter: 0.1,
102
+ },
72
103
  } as const
73
104
 
74
105
  export type SFXName = keyof typeof SFX
@@ -77,32 +108,38 @@ function randomInRange([min, max]: [number, number]): number {
77
108
  return min + Math.random() * (max - min)
78
109
  }
79
110
 
80
- // Preload all SFX sounds
81
- const sfxCache = new Map<SFXName, Howl>()
111
+ // Preload all SFX sounds. Each variation gets its own Howl so they can overlap
112
+ // and be cycled round-robin.
113
+ const sfxCache = new Map<SFXName, Howl[]>()
82
114
  const lastPlayedAt = new Map<SFXName, number>()
115
+ const lastVariation = new Map<SFXName, number>()
83
116
 
84
117
  // Initialize all sounds
85
118
  Object.entries(SFX).forEach(([name, config]) => {
86
- const sound = new Howl({
87
- src: [config.src],
88
- preload: true,
89
- volume: 0.5, // Will be adjusted by the bus
90
- })
91
- sfxCache.set(name as SFXName, sound)
119
+ const sources = Array.isArray(config.src) ? config.src : [config.src]
120
+ const sounds = sources.map(
121
+ (src) =>
122
+ new Howl({
123
+ src: [src],
124
+ preload: true,
125
+ volume: 0.5, // Will be adjusted by the bus
126
+ }),
127
+ )
128
+ sfxCache.set(name as SFXName, sounds)
92
129
  })
93
130
 
94
131
  /**
95
132
  * Play a sound effect with volume based on audio settings
96
133
  */
97
134
  export function playSFX(name: SFXName) {
98
- const sound = sfxCache.get(name)
99
- if (!sound) {
135
+ const sounds = sfxCache.get(name)
136
+ if (!sounds || sounds.length === 0) {
100
137
  console.warn(`SFX not found: ${name}`)
101
138
  return
102
139
  }
103
140
  const config = SFX[name]!
104
141
 
105
- // Drop rapid repeats, two plays of the same SFX within minIntervalMs just
142
+ // Drop rapid repeats two plays of the same SFX within minIntervalMs just
106
143
  // smear into noise, they don't add useful information.
107
144
  const now = performance.now()
108
145
  const minInterval = config.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS
@@ -110,6 +147,15 @@ export function playSFX(name: SFXName) {
110
147
  if (last !== undefined && now - last < minInterval) return
111
148
  lastPlayedAt.set(name, now)
112
149
 
150
+ // Pick a random variation, avoiding an immediate repeat of the last one so
151
+ // consecutive plays don't land on the same file.
152
+ let index = Math.floor(Math.random() * sounds.length)
153
+ if (sounds.length > 1 && index === lastVariation.get(name)) {
154
+ index = (index + 1) % sounds.length
155
+ }
156
+ lastVariation.set(name, index)
157
+ const sound = sounds[index]!
158
+
113
159
  const { masterVolume, sfxVolume, muted } = useAudio.getState()
114
160
 
115
161
  if (muted) return
@@ -137,7 +183,9 @@ export function updateSFXVolumes() {
137
183
  const { masterVolume, sfxVolume } = useAudio.getState()
138
184
  const finalVolume = (masterVolume / 100) * (sfxVolume / 100)
139
185
 
140
- sfxCache.forEach((sound) => {
141
- sound.volume(finalVolume)
186
+ sfxCache.forEach((sounds) => {
187
+ sounds.forEach((sound) => {
188
+ sound.volume(finalVolume)
189
+ })
142
190
  })
143
191
  }
@@ -0,0 +1 @@
1
+ export const SITE_BOUNDARY_DRAG_LABEL = 'site-boundary'
@@ -1,11 +1,11 @@
1
1
  import {
2
- generateId,
3
2
  type AnyNodeId,
4
- sceneRegistry,
3
+ generateId,
5
4
  type StairNode,
6
5
  StairNode as StairNodeSchema,
7
6
  type StairSegmentNode,
8
7
  StairSegmentNode as StairSegmentNodeSchema,
8
+ sceneRegistry,
9
9
  useScene,
10
10
  } from '@pascal-app/core'
11
11
  import { useViewer } from '@pascal-app/viewer'