@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,43 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { resolvePlanarCursorPosition } from './planar-cursor-placement'
3
+
4
+ const snapHalf = (value: number) => Math.round(value / 0.5) * 0.5
5
+
6
+ describe('resolvePlanarCursorPosition', () => {
7
+ test('absolute mode places the point directly at the snapped cursor', () => {
8
+ const result = resolvePlanarCursorPosition({
9
+ cursor: [1.24, -2.26],
10
+ original: [10, 10],
11
+ anchor: null,
12
+ mode: 'absolute',
13
+ snap: snapHalf,
14
+ })
15
+
16
+ expect(result.point).toEqual([1, -2.5])
17
+ expect(result.anchor).toBeNull()
18
+ })
19
+
20
+ test('relative mode preserves the original grab offset from the first cursor sample', () => {
21
+ const start = resolvePlanarCursorPosition({
22
+ cursor: [4.1, 6.1],
23
+ original: [10, 20],
24
+ anchor: null,
25
+ mode: 'relative',
26
+ snap: snapHalf,
27
+ })
28
+
29
+ expect(start.point).toEqual([10, 20])
30
+ expect(start.anchor).toEqual([4.1, 6.1])
31
+
32
+ const moved = resolvePlanarCursorPosition({
33
+ cursor: [4.9, 5.2],
34
+ original: [10, 20],
35
+ anchor: start.anchor,
36
+ mode: 'relative',
37
+ snap: snapHalf,
38
+ })
39
+
40
+ expect(moved.point).toEqual([11, 19])
41
+ expect(moved.anchor).toEqual([4.1, 6.1])
42
+ })
43
+ })
@@ -0,0 +1,42 @@
1
+ export type PlanarPoint = [number, number]
2
+
3
+ export type PlanarCursorPlacementMode = 'absolute' | 'relative'
4
+
5
+ type ResolvePlanarCursorPositionArgs = {
6
+ cursor: PlanarPoint
7
+ original: PlanarPoint
8
+ anchor: PlanarPoint | null
9
+ mode: PlanarCursorPlacementMode
10
+ snap?: (value: number) => number
11
+ }
12
+
13
+ type ResolvePlanarCursorPositionResult = {
14
+ point: PlanarPoint
15
+ anchor: PlanarPoint | null
16
+ }
17
+
18
+ const identity = (value: number) => value
19
+
20
+ export function resolvePlanarCursorPosition({
21
+ cursor,
22
+ original,
23
+ anchor,
24
+ mode,
25
+ snap = identity,
26
+ }: ResolvePlanarCursorPositionArgs): ResolvePlanarCursorPositionResult {
27
+ if (mode === 'absolute') {
28
+ return {
29
+ point: [snap(cursor[0]), snap(cursor[1])],
30
+ anchor,
31
+ }
32
+ }
33
+
34
+ const resolvedAnchor = anchor ?? cursor
35
+ return {
36
+ point: [
37
+ original[0] + snap(cursor[0] - resolvedAnchor[0]),
38
+ original[1] + snap(cursor[1] - resolvedAnchor[1]),
39
+ ],
40
+ anchor: resolvedAnchor,
41
+ }
42
+ }
@@ -175,7 +175,7 @@ export function duplicateRoofSubtree(
175
175
 
176
176
  export function clearRoofDuplicateMetadata(
177
177
  roofId: AnyNodeId,
178
- updates: Partial<Pick<RoofNode, 'position' | 'rotation' | 'metadata'>> = {},
178
+ updates: Partial<Pick<RoofNode, 'position' | 'rotation' | 'metadata' | 'visible'>> = {},
179
179
  ) {
180
180
  const scene = useScene.getState()
181
181
  const roofNode = scene.nodes[roofId]
@@ -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,9 +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
15
16
  'sfx:snapshot-capture': undefined
17
+ 'sfx:menu-hover': undefined
18
+ 'sfx:menu-click': undefined
16
19
  }
17
20
 
18
21
  /**
@@ -36,9 +39,12 @@ export function initSFXBus() {
36
39
  sfxEmitter.on('sfx:item-pick', () => playSFX('itemPick'))
37
40
  sfxEmitter.on('sfx:item-place', () => playSFX('itemPlace'))
38
41
  sfxEmitter.on('sfx:item-rotate', () => playSFX('itemRotate'))
39
- sfxEmitter.on('sfx:structure-build', () => playSFX('structureBuild'))
42
+ sfxEmitter.on('sfx:structure-build-start', () => playSFX('structureBuildStart'))
43
+ sfxEmitter.on('sfx:structure-build', () => playSFX('structureBuildEnd'))
40
44
  sfxEmitter.on('sfx:structure-delete', () => playSFX('structureDelete'))
41
45
  sfxEmitter.on('sfx:snapshot-capture', () => playSFX('snapshotCapture'))
46
+ sfxEmitter.on('sfx:menu-hover', () => playSFX('menuHover'))
47
+ sfxEmitter.on('sfx:menu-click', () => playSFX('menuClick'))
42
48
  }
43
49
 
44
50
  /**
@@ -49,3 +55,19 @@ export function initSFXBus() {
49
55
  export function triggerSFX(event: keyof SFXEvents) {
50
56
  sfxEmitter.emit(event)
51
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
+ }
@@ -5,7 +5,8 @@ import useAudio from '../store/use-audio'
5
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).
@@ -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
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,26 +108,32 @@ 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
  }
@@ -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'