@pascal-app/editor 0.5.1 → 0.7.0

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 (150) hide show
  1. package/package.json +12 -7
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +75 -7
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +29 -0
  6. package/src/components/editor/first-person/build-collider-world.ts +365 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
  8. package/src/components/editor/first-person-controls.tsx +496 -143
  9. package/src/components/editor/floating-action-menu.tsx +281 -83
  10. package/src/components/editor/floating-building-action-menu.tsx +4 -3
  11. package/src/components/editor/floorplan-background-selection.ts +113 -0
  12. package/src/components/editor/floorplan-panel.tsx +10442 -3275
  13. package/src/components/editor/index.tsx +270 -20
  14. package/src/components/editor/node-action-menu.tsx +14 -1
  15. package/src/components/editor/selection-manager.tsx +766 -12
  16. package/src/components/editor/site-edge-labels.tsx +9 -3
  17. package/src/components/editor/thumbnail-generator.tsx +350 -157
  18. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  19. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  20. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  21. package/src/components/editor/wall-measurement-label.tsx +377 -58
  22. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  23. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  24. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  25. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
  26. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  27. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
  28. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  29. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  30. package/src/components/editor-2d/svg-paths.ts +119 -0
  31. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  32. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  33. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  34. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +2 -0
  35. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  36. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  37. package/src/components/tools/column/column-tool.tsx +97 -0
  38. package/src/components/tools/column/move-column-tool.tsx +105 -0
  39. package/src/components/tools/door/door-tool.tsx +19 -0
  40. package/src/components/tools/door/move-door-tool.tsx +38 -8
  41. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  42. package/src/components/tools/fence/fence-drafting.ts +27 -8
  43. package/src/components/tools/fence/fence-tool.tsx +159 -3
  44. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +438 -0
  45. package/src/components/tools/fence/move-fence-tool.tsx +102 -27
  46. package/src/components/tools/item/move-tool.tsx +19 -1
  47. package/src/components/tools/item/placement-math.ts +44 -7
  48. package/src/components/tools/item/placement-strategies.ts +111 -33
  49. package/src/components/tools/item/placement-types.ts +7 -0
  50. package/src/components/tools/item/use-draft-node.ts +2 -0
  51. package/src/components/tools/item/use-placement-coordinator.tsx +701 -61
  52. package/src/components/tools/roof/move-roof-tool.tsx +111 -43
  53. package/src/components/tools/shared/polygon-editor.tsx +244 -29
  54. package/src/components/tools/shared/segment-angle.ts +156 -0
  55. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  56. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  57. package/src/components/tools/slab/slab-hole-editor.tsx +2 -0
  58. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  59. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  60. package/src/components/tools/stair/stair-tool.tsx +11 -3
  61. package/src/components/tools/tool-manager.tsx +30 -3
  62. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +423 -0
  64. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  65. package/src/components/tools/wall/wall-drafting.ts +348 -17
  66. package/src/components/tools/wall/wall-tool.tsx +134 -2
  67. package/src/components/tools/window/move-window-tool.tsx +28 -0
  68. package/src/components/tools/window/window-tool.tsx +17 -0
  69. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  70. package/src/components/ui/action-menu/control-modes.tsx +37 -5
  71. package/src/components/ui/action-menu/index.tsx +91 -1
  72. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  73. package/src/components/ui/action-menu/view-toggles.tsx +424 -35
  74. package/src/components/ui/command-palette/editor-commands.tsx +27 -5
  75. package/src/components/ui/command-palette/index.tsx +0 -1
  76. package/src/components/ui/controls/material-picker.tsx +189 -169
  77. package/src/components/ui/controls/slider-control.tsx +88 -26
  78. package/src/components/ui/floating-level-selector.tsx +286 -55
  79. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  80. package/src/components/ui/item-catalog/catalog-items.tsx +1121 -1219
  81. package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
  82. package/src/components/ui/level-duplicate-dialog.tsx +115 -0
  83. package/src/components/ui/panels/ceiling-panel.tsx +47 -27
  84. package/src/components/ui/panels/column-panel.tsx +715 -0
  85. package/src/components/ui/panels/door-panel.tsx +986 -294
  86. package/src/components/ui/panels/fence-panel.tsx +55 -12
  87. package/src/components/ui/panels/item-panel.tsx +5 -5
  88. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  89. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  90. package/src/components/ui/panels/node-display.ts +39 -0
  91. package/src/components/ui/panels/paint-panel.tsx +138 -0
  92. package/src/components/ui/panels/panel-manager.tsx +241 -30
  93. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  94. package/src/components/ui/panels/reference-panel.tsx +243 -9
  95. package/src/components/ui/panels/roof-panel.tsx +30 -62
  96. package/src/components/ui/panels/roof-segment-panel.tsx +8 -23
  97. package/src/components/ui/panels/slab-panel.tsx +46 -24
  98. package/src/components/ui/panels/spawn-panel.tsx +155 -0
  99. package/src/components/ui/panels/stair-panel.tsx +117 -69
  100. package/src/components/ui/panels/stair-segment-panel.tsx +13 -27
  101. package/src/components/ui/panels/wall-panel.tsx +71 -17
  102. package/src/components/ui/panels/window-panel.tsx +665 -146
  103. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  104. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  105. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +9 -5
  106. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
  107. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  108. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
  109. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
  110. package/src/components/ui/sidebar/panels/site-panel/index.tsx +138 -56
  111. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
  112. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +9 -5
  113. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  114. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
  115. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
  116. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
  117. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
  118. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +12 -6
  119. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
  120. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
  121. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +15 -8
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/viewer-toolbar.tsx +96 -2
  124. package/src/components/viewer-overlay.tsx +25 -19
  125. package/src/hooks/use-auto-frame.ts +45 -0
  126. package/src/hooks/use-contextual-tools.ts +14 -13
  127. package/src/hooks/use-keyboard.ts +67 -9
  128. package/src/hooks/use-mobile.ts +12 -12
  129. package/src/index.tsx +2 -1
  130. package/src/lib/door-interaction.ts +88 -0
  131. package/src/lib/floorplan/geometry.ts +263 -0
  132. package/src/lib/floorplan/index.ts +38 -0
  133. package/src/lib/floorplan/items.ts +179 -0
  134. package/src/lib/floorplan/selection-tool.ts +231 -0
  135. package/src/lib/floorplan/stairs.ts +478 -0
  136. package/src/lib/floorplan/types.ts +57 -0
  137. package/src/lib/floorplan/walls.ts +23 -0
  138. package/src/lib/guide-events.ts +10 -0
  139. package/src/lib/history.ts +20 -0
  140. package/src/lib/level-duplication.test.ts +72 -0
  141. package/src/lib/level-duplication.ts +153 -0
  142. package/src/lib/local-guide-image.ts +42 -0
  143. package/src/lib/material-paint.ts +284 -0
  144. package/src/lib/roof-duplication.ts +214 -0
  145. package/src/lib/scene-bounds.test.ts +183 -0
  146. package/src/lib/scene-bounds.ts +169 -0
  147. package/src/lib/sfx-player.ts +96 -13
  148. package/src/lib/stair-duplication.ts +126 -0
  149. package/src/lib/window-interaction.ts +86 -0
  150. package/src/store/use-editor.tsx +279 -15
@@ -4,24 +4,30 @@ import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
6
  type CeilingNode,
7
+ ColumnNode,
7
8
  DoorNode,
8
9
  FenceNode,
10
+ generateId,
9
11
  ItemNode,
10
- RoofNode,
11
12
  RoofSegmentNode,
12
13
  type SlabNode,
14
+ SpawnNode,
13
15
  StairNode,
14
16
  StairSegmentNode,
15
17
  sceneRegistry,
16
18
  useScene,
19
+ WallNode,
17
20
  WindowNode,
18
21
  } from '@pascal-app/core'
19
22
  import { useViewer } from '@pascal-app/viewer'
20
23
  import { Html } from '@react-three/drei'
21
24
  import { useFrame } from '@react-three/fiber'
22
- import { useCallback, useRef } from 'react'
25
+ import { Move } from 'lucide-react'
26
+ import { useCallback, useEffect, useRef, useState } from 'react'
23
27
  import * as THREE from 'three'
28
+ import { duplicateRoofSubtree } from '../../lib/roof-duplication'
24
29
  import { sfxEmitter } from '../../lib/sfx-bus'
30
+ import { duplicateStairSubtree } from '../../lib/stair-duplication'
25
31
  import useEditor from '../../store/use-editor'
26
32
  import { NodeActionMenu } from './node-action-menu'
27
33
 
@@ -35,29 +41,88 @@ const ALLOWED_TYPES = [
35
41
  'stair-segment',
36
42
  'wall',
37
43
  'fence',
44
+ 'column',
38
45
  'slab',
39
46
  'ceiling',
47
+ 'spawn',
40
48
  ]
41
- const DELETE_ONLY_TYPES = ['wall']
49
+ const DELETE_ONLY_TYPES: string[] = []
42
50
  const HOLE_TYPES = ['slab', 'ceiling']
43
51
 
44
52
  export function FloatingActionMenu() {
45
53
  const selectedIds = useViewer((s) => s.selection.selectedIds)
46
- const nodes = useScene((s) => s.nodes)
47
54
  const updateNode = useScene((s) => s.updateNode)
48
55
  const mode = useEditor((s) => s.mode)
49
56
  const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
57
+ const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
58
+ const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
59
+ const curvingFence = useEditor((s) => s.curvingFence)
50
60
  const setMovingNode = useEditor((s) => s.setMovingNode)
61
+ const setMovingWallEndpoint = useEditor((s) => s.setMovingWallEndpoint)
62
+ const setMovingFenceEndpoint = useEditor((s) => s.setMovingFenceEndpoint)
63
+ const setCurvingWall = useEditor((s) => s.setCurvingWall)
64
+ const setCurvingFence = useEditor((s) => s.setCurvingFence)
51
65
  const setSelection = useViewer((s) => s.setSelection)
52
66
  const setEditingHole = useEditor((s) => s.setEditingHole)
53
67
 
54
68
  const groupRef = useRef<THREE.Group>(null)
69
+ const startEndpointGroupRef = useRef<THREE.Group>(null)
70
+ const endEndpointGroupRef = useRef<THREE.Group>(null)
71
+ const [altPressed, setAltPressed] = useState(false)
55
72
 
56
73
  // Only show for single selection of specific types
57
74
  const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
58
- const node = selectedId ? nodes[selectedId as AnyNodeId] : null
75
+
76
+ // Subscribe just to the selected node so unrelated scene updates do not
77
+ // re-render this menu.
78
+ const node = useScene((s) => (selectedId ? (s.nodes[selectedId as AnyNodeId] ?? null) : null))
59
79
  const isValidType = node ? ALLOWED_TYPES.includes(node.type) : false
60
80
 
81
+ // Boolean selector, only re-renders when curving availability actually flips.
82
+ const canCurveSelectedWall = useScene((s) => {
83
+ if (!selectedId) return false
84
+ const selectedNode = s.nodes[selectedId as AnyNodeId]
85
+ if (selectedNode?.type !== 'wall') return false
86
+ return !(selectedNode.children ?? []).some((childId) => {
87
+ const child = s.nodes[childId as AnyNodeId]
88
+ if (!child) return false
89
+ if (child.type === 'door' || child.type === 'window') return true
90
+ if (child.type === 'item') {
91
+ const attachTo = child.asset?.attachTo
92
+ return attachTo === 'wall' || attachTo === 'wall-side'
93
+ }
94
+ return false
95
+ })
96
+ })
97
+
98
+ useEffect(() => {
99
+ const handleKeyDown = (event: KeyboardEvent) => {
100
+ if (event.key === 'Alt') {
101
+ setAltPressed(true)
102
+ }
103
+ }
104
+
105
+ const handleKeyUp = (event: KeyboardEvent) => {
106
+ if (event.key === 'Alt') {
107
+ setAltPressed(false)
108
+ }
109
+ }
110
+
111
+ const handleBlur = () => {
112
+ setAltPressed(false)
113
+ }
114
+
115
+ window.addEventListener('keydown', handleKeyDown)
116
+ window.addEventListener('keyup', handleKeyUp)
117
+ window.addEventListener('blur', handleBlur)
118
+
119
+ return () => {
120
+ window.removeEventListener('keydown', handleKeyDown)
121
+ window.removeEventListener('keyup', handleKeyUp)
122
+ window.removeEventListener('blur', handleBlur)
123
+ }
124
+ }, [])
125
+
61
126
  useFrame(() => {
62
127
  if (!(selectedId && isValidType && groupRef.current)) return
63
128
 
@@ -72,6 +137,40 @@ export function FloatingActionMenu() {
72
137
  const yOffset = isStructural ? 0.8 : 0.3
73
138
  groupRef.current.position.set(center.x, box.max.y + yOffset, center.z)
74
139
  }
140
+
141
+ if (node?.type === 'wall' || node?.type === 'fence') {
142
+ const segment = node as WallNode | FenceNode
143
+ const endpointYOffset = 0.35
144
+ const startWorld =
145
+ node.type === 'wall'
146
+ ? obj.localToWorld(new THREE.Vector3(0, 0, 0))
147
+ : obj.localToWorld(new THREE.Vector3(segment.start[0], 0, segment.start[1]))
148
+ const endWorld =
149
+ node.type === 'wall'
150
+ ? obj.localToWorld(
151
+ new THREE.Vector3(
152
+ Math.hypot(segment.end[0] - segment.start[0], segment.end[1] - segment.start[1]),
153
+ 0,
154
+ 0,
155
+ ),
156
+ )
157
+ : obj.localToWorld(new THREE.Vector3(segment.end[0], 0, segment.end[1]))
158
+
159
+ if (startEndpointGroupRef.current) {
160
+ startEndpointGroupRef.current.position.set(
161
+ startWorld.x,
162
+ startWorld.y + endpointYOffset,
163
+ startWorld.z,
164
+ )
165
+ }
166
+ if (endEndpointGroupRef.current) {
167
+ endEndpointGroupRef.current.position.set(
168
+ endWorld.x,
169
+ endWorld.y + endpointYOffset,
170
+ endWorld.z,
171
+ )
172
+ }
173
+ }
75
174
  }
76
175
  })
77
176
 
@@ -84,7 +183,12 @@ export function FloatingActionMenu() {
84
183
  node.type === 'item' ||
85
184
  node.type === 'window' ||
86
185
  node.type === 'door' ||
186
+ node.type === 'wall' ||
87
187
  node.type === 'fence' ||
188
+ node.type === 'column' ||
189
+ node.type === 'slab' ||
190
+ node.type === 'ceiling' ||
191
+ node.type === 'spawn' ||
88
192
  node.type === 'roof' ||
89
193
  node.type === 'roof-segment' ||
90
194
  node.type === 'stair' ||
@@ -96,12 +200,55 @@ export function FloatingActionMenu() {
96
200
  },
97
201
  [node, setMovingNode, setSelection],
98
202
  )
203
+ const handleCurve = useCallback(
204
+ (e: React.MouseEvent) => {
205
+ e.stopPropagation()
206
+ if (!node) return
207
+ sfxEmitter.emit('sfx:item-pick')
208
+ if (node.type === 'wall') {
209
+ if (!canCurveSelectedWall) return
210
+ setCurvingWall(node)
211
+ } else if (node.type === 'fence') {
212
+ setCurvingFence(node)
213
+ } else {
214
+ return
215
+ }
216
+ setSelection({ selectedIds: [] })
217
+ },
218
+ [canCurveSelectedWall, node, setCurvingFence, setCurvingWall, setSelection],
219
+ )
220
+ const handleEndpointMove = useCallback(
221
+ (endpoint: 'start' | 'end', e: React.MouseEvent) => {
222
+ e.stopPropagation()
223
+ if (!node) return
224
+ sfxEmitter.emit('sfx:item-pick')
225
+ if (node.type === 'wall') {
226
+ setMovingWallEndpoint({ wall: node, endpoint })
227
+ } else if (node.type === 'fence') {
228
+ setMovingFenceEndpoint({ fence: node, endpoint })
229
+ } else {
230
+ return
231
+ }
232
+ setSelection({ selectedIds: [] })
233
+ },
234
+ [node, setMovingFenceEndpoint, setMovingWallEndpoint, setSelection],
235
+ )
99
236
 
100
237
  const handleDuplicate = useCallback(
101
238
  (e: React.MouseEvent) => {
102
239
  e.stopPropagation()
103
240
  if (!node?.parentId) return
104
241
  sfxEmitter.emit('sfx:item-pick')
242
+
243
+ if (node.type === 'roof') {
244
+ try {
245
+ duplicateRoofSubtree(node.id as AnyNodeId, { mode: 'move' })
246
+ } catch (error) {
247
+ console.error('Failed to duplicate roof', error)
248
+ }
249
+ return
250
+ }
251
+
105
252
  useScene.temporal.getState().pause()
106
253
 
107
254
  let duplicateInfo = structuredClone(node) as any
@@ -116,13 +263,16 @@ export function FloatingActionMenu() {
116
263
  duplicate = WindowNode.parse(duplicateInfo)
117
264
  } else if (node.type === 'item') {
118
265
  duplicate = ItemNode.parse(duplicateInfo)
266
+ } else if (node.type === 'column') {
267
+ duplicate = ColumnNode.parse(duplicateInfo)
268
+ } else if (node.type === 'wall') {
269
+ duplicate = WallNode.parse(duplicateInfo)
119
270
  } else if (node.type === 'fence') {
120
271
  duplicate = FenceNode.parse(duplicateInfo)
121
272
  duplicate.start = [duplicate.start[0] + 1, duplicate.start[1] + 1]
122
273
  duplicate.end = [duplicate.end[0] + 1, duplicate.end[1] + 1]
123
- } else if (node.type === 'roof') {
124
- duplicate = RoofNode.parse(duplicateInfo)
125
274
  } else if (node.type === 'roof-segment') {
275
+ duplicateInfo.id = generateId('rseg')
126
276
  duplicate = RoofSegmentNode.parse(duplicateInfo)
127
277
  } else if (node.type === 'stair') {
128
278
  duplicateInfo.children = []
@@ -131,19 +281,28 @@ export function FloatingActionMenu() {
131
281
  duplicate = StairNode.parse(duplicateInfo)
132
282
  } else if (node.type === 'stair-segment') {
133
283
  duplicate = StairSegmentNode.parse(duplicateInfo)
284
+ } else if (node.type === 'spawn') {
285
+ duplicate = SpawnNode.parse(duplicateInfo)
134
286
  }
135
287
  } catch (error) {
136
288
  console.error('Failed to parse duplicate', error)
289
+ useScene.temporal.getState().resume()
290
+ return
291
+ }
292
+
293
+ if (!duplicate) {
294
+ useScene.temporal.getState().resume()
137
295
  return
138
296
  }
139
297
 
140
298
  if (duplicate) {
141
299
  if (duplicate.type === 'door' || duplicate.type === 'window') {
142
300
  useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
301
+ } else if (duplicate.type === 'wall') {
302
+ useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
143
303
  } else if (duplicate.type === 'fence') {
144
304
  useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
145
305
  } else if (
146
- duplicate.type === 'roof' ||
147
306
  duplicate.type === 'roof-segment' ||
148
307
  duplicate.type === 'stair' ||
149
308
  duplicate.type === 'stair-segment'
@@ -157,63 +316,22 @@ export function FloatingActionMenu() {
157
316
  ]
158
317
  }
159
318
  if (node.type === 'stair' && duplicate.type === 'stair') {
160
- const nodesState = useScene.getState().nodes
161
- const createOps: { node: AnyNode; parentId?: AnyNodeId }[] = [
162
- { node: duplicate, parentId: duplicate.parentId as AnyNodeId },
163
- ]
164
-
165
- for (const childId of node.children ?? []) {
166
- const childNode = nodesState[childId]
167
- if (childNode?.type !== 'stair-segment') {
168
- continue
169
- }
170
-
171
- let childDuplicateInfo = structuredClone(childNode) as any
172
- delete childDuplicateInfo.id
173
- childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata }
174
- delete childDuplicateInfo.metadata?.isNew
175
-
176
- try {
177
- const childDuplicate = StairSegmentNode.parse(childDuplicateInfo)
178
- createOps.push({ node: childDuplicate, parentId: duplicate.id as AnyNodeId })
179
- } catch (e) {
180
- console.error('Failed to duplicate stair segment', e)
181
- }
182
- }
183
-
184
- useScene.getState().createNodes(createOps)
319
+ duplicateStairSubtree(node.id as AnyNodeId, { mode: 'move' })
185
320
  } else {
186
321
  useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
187
322
  }
188
323
 
189
- // Duplicate children for roof nodes
190
- if (node.type === 'roof' && node.children) {
191
- const nodesState = useScene.getState().nodes
192
- for (const childId of node.children) {
193
- const childNode = nodesState[childId]
194
- if (childNode && childNode.type === 'roof-segment') {
195
- let childDuplicateInfo = structuredClone(childNode) as any
196
- delete childDuplicateInfo.id
197
- childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true }
198
- try {
199
- const childDuplicate = RoofSegmentNode.parse(childDuplicateInfo)
200
- useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)
201
- } catch (e) {
202
- console.error('Failed to duplicate roof segment', e)
203
- }
204
- }
205
- }
206
- }
207
-
208
324
  // Duplicate children for stair nodes
209
325
  }
210
326
  if (
211
327
  duplicate.type === 'item' ||
328
+ duplicate.type === 'column' ||
329
+ duplicate.type === 'wall' ||
212
330
  duplicate.type === 'fence' ||
213
331
  duplicate.type === 'window' ||
214
332
  duplicate.type === 'door' ||
215
- duplicate.type === 'roof' ||
216
333
  duplicate.type === 'roof-segment' ||
334
+ duplicate.type === 'spawn' ||
217
335
  duplicate.type === 'stair-segment'
218
336
  ) {
219
337
  setMovingNode(duplicate as any)
@@ -250,8 +368,15 @@ export function FloatingActionMenu() {
250
368
  [cx + holeSize, cz + holeSize],
251
369
  [cx - holeSize, cz + holeSize],
252
370
  ]
253
- const currentHoles = (node as SlabNode | CeilingNode).holes || []
254
- updateNode(selectedId as AnyNodeId, { holes: [...currentHoles, newHole] })
371
+ const surfaceNode = node as SlabNode | CeilingNode
372
+ const currentHoles = surfaceNode.holes || []
373
+ const currentMetadata = currentHoles.map(
374
+ (_, index) => surfaceNode.holeMetadata?.[index] ?? { source: 'manual' as const },
375
+ )
376
+ updateNode(selectedId as AnyNodeId, {
377
+ holes: [...currentHoles, newHole],
378
+ holeMetadata: [...currentMetadata, { source: 'manual' }],
379
+ })
255
380
  setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
256
381
  // Re-assert selection so the node stays selected
257
382
  setSelection({ selectedIds: [selectedId] })
@@ -263,41 +388,114 @@ export function FloatingActionMenu() {
263
388
  (e: React.MouseEvent) => {
264
389
  e.stopPropagation()
265
390
  if (!selectedId) return
391
+ if (node?.type === 'item') {
392
+ sfxEmitter.emit('sfx:item-delete')
393
+ } else {
394
+ sfxEmitter.emit('sfx:structure-delete')
395
+ }
266
396
  setSelection({ selectedIds: [] })
267
397
  useScene.getState().deleteNode(selectedId as AnyNodeId)
268
398
  },
269
- [selectedId, setSelection],
399
+ [node?.type, selectedId, setSelection],
270
400
  )
271
401
 
272
- if (!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete')) return null
402
+ if (
403
+ !(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
404
+ movingWallEndpoint ||
405
+ movingFenceEndpoint ||
406
+ curvingFence
407
+ )
408
+ return null
273
409
 
274
410
  return (
275
- <group ref={groupRef}>
276
- <Html
277
- center
278
- style={{
279
- pointerEvents: 'auto',
280
- touchAction: 'none',
281
- }}
282
- zIndexRange={[100, 0]}
283
- >
284
- <NodeActionMenu
285
- onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
286
- onDelete={handleDelete}
287
- onDuplicate={
288
- node && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type)
289
- ? handleDuplicate
290
- : undefined
291
- }
292
- onMove={
293
- node && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type)
294
- ? handleMove
295
- : undefined
296
- }
297
- onPointerDown={(e) => e.stopPropagation()}
298
- onPointerUp={(e) => e.stopPropagation()}
299
- />
300
- </Html>
411
+ <group>
412
+ <group ref={groupRef}>
413
+ <Html
414
+ center
415
+ style={{
416
+ pointerEvents: 'auto',
417
+ touchAction: 'none',
418
+ }}
419
+ zIndexRange={[100, 0]}
420
+ >
421
+ <NodeActionMenu
422
+ onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
423
+ onCurve={
424
+ node?.type === 'fence' || (node?.type === 'wall' && canCurveSelectedWall)
425
+ ? handleCurve
426
+ : undefined
427
+ }
428
+ onDelete={handleDelete}
429
+ onDuplicate={
430
+ node &&
431
+ node.type !== 'spawn' &&
432
+ !DELETE_ONLY_TYPES.includes(node.type) &&
433
+ !HOLE_TYPES.includes(node.type)
434
+ ? handleDuplicate
435
+ : undefined
436
+ }
437
+ onMove={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleMove : undefined}
438
+ onPointerDown={(e) => e.stopPropagation()}
439
+ onPointerUp={(e) => e.stopPropagation()}
440
+ />
441
+ </Html>
442
+ </group>
443
+ {(node?.type === 'wall' || node?.type === 'fence') && (
444
+ <>
445
+ <group ref={startEndpointGroupRef}>
446
+ <Html
447
+ center
448
+ style={{ pointerEvents: 'auto', touchAction: 'none' }}
449
+ zIndexRange={[100, 0]}
450
+ >
451
+ <button
452
+ aria-label={node.type === 'wall' ? 'Move wall start' : 'Move fence start'}
453
+ className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${
454
+ altPressed
455
+ ? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white'
456
+ : 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'
457
+ }`}
458
+ onClick={(e) => handleEndpointMove('start', e)}
459
+ onPointerDown={(e) => e.stopPropagation()}
460
+ title={
461
+ node.type === 'wall'
462
+ ? 'Move wall start (Alt to detach)'
463
+ : 'Move fence start (Alt to detach)'
464
+ }
465
+ type="button"
466
+ >
467
+ <Move className="h-4 w-4" />
468
+ </button>
469
+ </Html>
470
+ </group>
471
+ <group ref={endEndpointGroupRef}>
472
+ <Html
473
+ center
474
+ style={{ pointerEvents: 'auto', touchAction: 'none' }}
475
+ zIndexRange={[100, 0]}
476
+ >
477
+ <button
478
+ aria-label={node.type === 'wall' ? 'Move wall end' : 'Move fence end'}
479
+ className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${
480
+ altPressed
481
+ ? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white'
482
+ : 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'
483
+ }`}
484
+ onClick={(e) => handleEndpointMove('end', e)}
485
+ onPointerDown={(e) => e.stopPropagation()}
486
+ title={
487
+ node.type === 'wall'
488
+ ? 'Move wall end (Alt to detach)'
489
+ : 'Move fence end (Alt to detach)'
490
+ }
491
+ type="button"
492
+ >
493
+ <Move className="h-4 w-4" />
494
+ </button>
495
+ </Html>
496
+ </group>
497
+ </>
498
+ )}
301
499
  </group>
302
500
  )
303
501
  }
@@ -15,7 +15,6 @@ export function FloatingBuildingActionMenu() {
15
15
  const levelId = useViewer((s) => s.selection.levelId)
16
16
  const setMovingNode = useEditor((s) => s.setMovingNode)
17
17
  const setSelection = useViewer((s) => s.setSelection)
18
- const nodes = useScene((s) => s.nodes)
19
18
 
20
19
  const groupRef = useRef<THREE.Group>(null)
21
20
 
@@ -36,13 +35,15 @@ export function FloatingBuildingActionMenu() {
36
35
  (e: React.MouseEvent) => {
37
36
  e.stopPropagation()
38
37
  if (!buildingId) return
39
- const node = nodes[buildingId]
38
+ // Read lazily at click time — no need to subscribe to nodes for a
39
+ // one-shot action.
40
+ const node = useScene.getState().nodes[buildingId]
40
41
  if (!node || node.type !== 'building') return
41
42
  sfxEmitter.emit('sfx:item-pick')
42
43
  setMovingNode(node as BuildingNode)
43
44
  setSelection({ buildingId: null })
44
45
  },
45
- [buildingId, nodes, setMovingNode, setSelection],
46
+ [buildingId, setMovingNode, setSelection],
46
47
  )
47
48
 
48
49
  // Only show when a building is selected without a level
@@ -0,0 +1,113 @@
1
+ 'use client'
2
+
3
+ import type { Point2D, ZoneNode as ZoneNodeType } from '@pascal-app/core'
4
+ import { isPointInsidePolygon } from '../../lib/floorplan'
5
+ import type { WallPlanPoint } from '../tools/wall/wall-drafting'
6
+
7
+ type ModifierKeys = {
8
+ meta: boolean
9
+ ctrl: boolean
10
+ }
11
+
12
+ type ZoneHitEntry = {
13
+ zone: {
14
+ id: ZoneNodeType['id']
15
+ }
16
+ polygon: Point2D[]
17
+ }
18
+
19
+ type ResolveFloorplanBackgroundSelectionArgs = {
20
+ canSelectElementFloorplanGeometry: boolean
21
+ canSelectFloorplanZones: boolean
22
+ currentSelectedIds: string[]
23
+ getFloorplanHitIdAtPoint: (planPoint: WallPlanPoint) => string | null
24
+ isWallBuildActive: boolean
25
+ modifierKeys: ModifierKeys
26
+ planPoint: WallPlanPoint
27
+ structureLayer: string
28
+ toPoint2D: (point: WallPlanPoint) => Point2D
29
+ visibleZonePolygons: ZoneHitEntry[]
30
+ }
31
+
32
+ export type FloorplanBackgroundSelectionResult =
33
+ | {
34
+ handled: true
35
+ kind: 'select-zone'
36
+ zoneId: ZoneNodeType['id']
37
+ }
38
+ | {
39
+ handled: true
40
+ kind: 'select-elements'
41
+ selectedIds: string[]
42
+ }
43
+ | {
44
+ handled: true
45
+ kind: 'clear-zones'
46
+ }
47
+ | {
48
+ handled: true
49
+ kind: 'clear-elements'
50
+ preserveSelection: boolean
51
+ }
52
+ | {
53
+ handled: false
54
+ }
55
+
56
+ export function resolveFloorplanBackgroundSelection({
57
+ canSelectElementFloorplanGeometry,
58
+ canSelectFloorplanZones,
59
+ currentSelectedIds,
60
+ getFloorplanHitIdAtPoint,
61
+ isWallBuildActive,
62
+ modifierKeys,
63
+ planPoint,
64
+ structureLayer,
65
+ toPoint2D,
66
+ visibleZonePolygons,
67
+ }: ResolveFloorplanBackgroundSelectionArgs): FloorplanBackgroundSelectionResult {
68
+ if (canSelectFloorplanZones) {
69
+ const zoneHit = visibleZonePolygons.find(({ polygon }) =>
70
+ isPointInsidePolygon(toPoint2D(planPoint), polygon),
71
+ )
72
+ if (zoneHit) {
73
+ return {
74
+ handled: true,
75
+ kind: 'select-zone',
76
+ zoneId: zoneHit.zone.id,
77
+ }
78
+ }
79
+ }
80
+
81
+ if (canSelectElementFloorplanGeometry) {
82
+ const hitId = getFloorplanHitIdAtPoint(planPoint)
83
+ if (hitId) {
84
+ return {
85
+ handled: true,
86
+ kind: 'select-elements',
87
+ selectedIds:
88
+ modifierKeys.meta || modifierKeys.ctrl
89
+ ? currentSelectedIds.includes(hitId)
90
+ ? currentSelectedIds.filter((selectedId) => selectedId !== hitId)
91
+ : [...currentSelectedIds, hitId]
92
+ : [hitId],
93
+ }
94
+ }
95
+ }
96
+
97
+ if (!isWallBuildActive) {
98
+ if (structureLayer === 'zones') {
99
+ return {
100
+ handled: true,
101
+ kind: 'clear-zones',
102
+ }
103
+ }
104
+
105
+ return {
106
+ handled: true,
107
+ kind: 'clear-elements',
108
+ preserveSelection: modifierKeys.meta || modifierKeys.ctrl,
109
+ }
110
+ }
111
+
112
+ return { handled: false }
113
+ }