@pascal-app/editor 0.4.0 → 0.6.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 (97) hide show
  1. package/package.json +8 -7
  2. package/src/components/editor/editor-layout-v2.tsx +9 -0
  3. package/src/components/editor/floating-action-menu.tsx +341 -48
  4. package/src/components/editor/floating-building-action-menu.tsx +70 -0
  5. package/src/components/editor/floorplan-panel.tsx +1350 -722
  6. package/src/components/editor/index.tsx +221 -167
  7. package/src/components/editor/node-action-menu.tsx +40 -11
  8. package/src/components/editor/selection-manager.tsx +238 -10
  9. package/src/components/editor/site-edge-labels.tsx +9 -3
  10. package/src/components/editor/thumbnail-generator.tsx +422 -79
  11. package/src/components/editor/wall-measurement-label.tsx +120 -32
  12. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  13. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  14. package/src/components/systems/stair/stair-edit-system.tsx +27 -5
  15. package/src/components/tools/building/move-building-tool.tsx +157 -0
  16. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  17. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  18. package/src/components/tools/door/door-math.ts +1 -1
  19. package/src/components/tools/door/door-tool.tsx +31 -7
  20. package/src/components/tools/door/move-door-tool.tsx +27 -8
  21. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  22. package/src/components/tools/fence/fence-drafting.ts +137 -0
  23. package/src/components/tools/fence/fence-tool.tsx +190 -0
  24. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
  25. package/src/components/tools/fence/move-fence-tool.tsx +231 -0
  26. package/src/components/tools/item/item-tool.tsx +3 -3
  27. package/src/components/tools/item/move-tool.tsx +16 -0
  28. package/src/components/tools/item/placement-math.ts +14 -6
  29. package/src/components/tools/item/placement-strategies.ts +17 -9
  30. package/src/components/tools/item/use-placement-coordinator.tsx +123 -16
  31. package/src/components/tools/roof/move-roof-tool.tsx +90 -26
  32. package/src/components/tools/roof/roof-tool.tsx +6 -6
  33. package/src/components/tools/select/box-select-tool.tsx +2 -2
  34. package/src/components/tools/shared/polygon-editor.tsx +98 -8
  35. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  36. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  37. package/src/components/tools/slab/slab-tool.tsx +4 -4
  38. package/src/components/tools/stair/stair-defaults.ts +10 -0
  39. package/src/components/tools/stair/stair-tool.tsx +39 -8
  40. package/src/components/tools/tool-manager.tsx +54 -14
  41. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  42. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
  43. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  44. package/src/components/tools/wall/wall-drafting.ts +331 -9
  45. package/src/components/tools/wall/wall-tool.tsx +19 -29
  46. package/src/components/tools/window/move-window-tool.tsx +27 -8
  47. package/src/components/tools/window/window-math.ts +1 -1
  48. package/src/components/tools/window/window-tool.tsx +31 -7
  49. package/src/components/tools/zone/zone-tool.tsx +7 -7
  50. package/src/components/ui/action-menu/control-modes.tsx +9 -4
  51. package/src/components/ui/action-menu/structure-tools.tsx +1 -0
  52. package/src/components/ui/command-palette/editor-commands.tsx +9 -4
  53. package/src/components/ui/command-palette/index.tsx +0 -1
  54. package/src/components/ui/controls/material-picker.tsx +127 -94
  55. package/src/components/ui/controls/slider-control.tsx +28 -14
  56. package/src/components/ui/helpers/building-helper.tsx +32 -0
  57. package/src/components/ui/helpers/helper-manager.tsx +2 -0
  58. package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
  59. package/src/components/ui/panels/ceiling-panel.tsx +61 -17
  60. package/src/components/ui/panels/door-panel.tsx +5 -5
  61. package/src/components/ui/panels/fence-panel.tsx +269 -0
  62. package/src/components/ui/panels/item-panel.tsx +5 -5
  63. package/src/components/ui/panels/panel-manager.tsx +32 -27
  64. package/src/components/ui/panels/reference-panel.tsx +5 -4
  65. package/src/components/ui/panels/roof-panel.tsx +91 -22
  66. package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
  67. package/src/components/ui/panels/slab-panel.tsx +63 -15
  68. package/src/components/ui/panels/stair-panel.tsx +377 -50
  69. package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
  70. package/src/components/ui/panels/wall-panel.tsx +159 -11
  71. package/src/components/ui/panels/window-panel.tsx +5 -7
  72. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +28 -17
  73. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +65 -53
  74. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +40 -25
  75. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +69 -0
  76. package/src/components/ui/sidebar/panels/site-panel/index.tsx +88 -72
  77. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
  78. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +64 -53
  79. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +32 -23
  80. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +72 -51
  81. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +40 -37
  82. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +72 -51
  83. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +13 -13
  84. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -17
  85. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +62 -54
  86. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +40 -25
  87. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +27 -28
  88. package/src/components/ui/viewer-toolbar.tsx +55 -2
  89. package/src/components/viewer-overlay.tsx +26 -19
  90. package/src/hooks/use-auto-save.ts +3 -6
  91. package/src/hooks/use-contextual-tools.ts +25 -16
  92. package/src/hooks/use-grid-events.ts +13 -1
  93. package/src/hooks/use-keyboard.ts +7 -2
  94. package/src/index.tsx +2 -1
  95. package/src/lib/history.ts +20 -0
  96. package/src/lib/sfx-player.ts +96 -13
  97. package/src/store/use-editor.tsx +125 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pascal-app/editor",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Pascal building editor component",
5
5
  "type": "module",
6
6
  "exports": {
@@ -11,14 +11,14 @@
11
11
  "check-types": "tsc --noEmit"
12
12
  },
13
13
  "peerDependencies": {
14
- "@pascal-app/core": "*",
15
- "@pascal-app/viewer": "*",
14
+ "@pascal-app/core": "^0.6.0",
15
+ "@pascal-app/viewer": "^0.6.0",
16
16
  "@react-three/drei": "^10",
17
17
  "@react-three/fiber": "^9",
18
18
  "next": ">=15",
19
19
  "react": "^18 || ^19",
20
20
  "react-dom": "^18 || ^19",
21
- "three": "^0.183"
21
+ "three": "^0.184"
22
22
  },
23
23
  "dependencies": {
24
24
  "@iconify/react": "^6.0.2",
@@ -50,13 +50,14 @@
50
50
  "zustand": "^5.0.11"
51
51
  },
52
52
  "devDependencies": {
53
- "@pascal-app/core": "*",
54
- "@pascal-app/viewer": "*",
53
+ "@pascal-app/core": "^0.6.0",
54
+ "@pascal-app/viewer": "^0.6.0",
55
55
  "@pascal/typescript-config": "*",
56
56
  "@types/howler": "^2.2.12",
57
+ "@types/node": "^22.19.12",
57
58
  "@types/react": "19.2.2",
58
59
  "@types/react-dom": "19.2.2",
59
- "@types/three": "^0.183.1",
60
+ "@types/three": "^0.184.0",
60
61
  "typescript": "5.9.3"
61
62
  }
62
63
  }
@@ -39,6 +39,15 @@ function LeftColumn({
39
39
  }
40
40
  }, [tabs, activePanel, setActivePanel])
41
41
 
42
+ // Leaving the items tab while furnishing should drop back to select mode
43
+ useEffect(() => {
44
+ if (activePanel === 'items') return
45
+ const { phase, mode, setMode } = useEditor.getState()
46
+ if (phase === 'furnish' && mode === 'build') {
47
+ setMode('select')
48
+ }
49
+ }, [activePanel])
50
+
42
51
  const handleResizerDown = useCallback(
43
52
  (e: React.PointerEvent) => {
44
53
  e.preventDefault()
@@ -3,20 +3,25 @@
3
3
  import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
+ type CeilingNode,
6
7
  DoorNode,
8
+ FenceNode,
7
9
  ItemNode,
8
10
  RoofNode,
9
11
  RoofSegmentNode,
12
+ type SlabNode,
10
13
  StairNode,
11
14
  StairSegmentNode,
12
15
  sceneRegistry,
13
16
  useScene,
17
+ WallNode,
14
18
  WindowNode,
15
19
  } from '@pascal-app/core'
16
20
  import { useViewer } from '@pascal-app/viewer'
17
21
  import { Html } from '@react-three/drei'
18
22
  import { useFrame } from '@react-three/fiber'
19
- import { useCallback, useRef } from 'react'
23
+ import { Move } from 'lucide-react'
24
+ import { useCallback, useEffect, useRef, useState } from 'react'
20
25
  import * as THREE from 'three'
21
26
  import { sfxEmitter } from '../../lib/sfx-bus'
22
27
  import useEditor from '../../store/use-editor'
@@ -31,26 +36,87 @@ const ALLOWED_TYPES = [
31
36
  'stair',
32
37
  'stair-segment',
33
38
  'wall',
39
+ 'fence',
34
40
  'slab',
41
+ 'ceiling',
35
42
  ]
36
- const DELETE_ONLY_TYPES = ['wall', 'slab']
43
+ const DELETE_ONLY_TYPES: string[] = []
44
+ const HOLE_TYPES = ['slab', 'ceiling']
37
45
 
38
46
  export function FloatingActionMenu() {
39
47
  const selectedIds = useViewer((s) => s.selection.selectedIds)
40
- const nodes = useScene((s) => s.nodes)
48
+ const updateNode = useScene((s) => s.updateNode)
41
49
  const mode = useEditor((s) => s.mode)
42
- const setMode = useEditor((s) => s.setMode)
43
50
  const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
51
+ const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
52
+ const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
53
+ const curvingFence = useEditor((s) => s.curvingFence)
44
54
  const setMovingNode = useEditor((s) => s.setMovingNode)
55
+ const setMovingWallEndpoint = useEditor((s) => s.setMovingWallEndpoint)
56
+ const setMovingFenceEndpoint = useEditor((s) => s.setMovingFenceEndpoint)
57
+ const setCurvingWall = useEditor((s) => s.setCurvingWall)
58
+ const setCurvingFence = useEditor((s) => s.setCurvingFence)
45
59
  const setSelection = useViewer((s) => s.setSelection)
60
+ const setEditingHole = useEditor((s) => s.setEditingHole)
46
61
 
47
62
  const groupRef = useRef<THREE.Group>(null)
63
+ const startEndpointGroupRef = useRef<THREE.Group>(null)
64
+ const endEndpointGroupRef = useRef<THREE.Group>(null)
65
+ const [altPressed, setAltPressed] = useState(false)
48
66
 
49
67
  // Only show for single selection of specific types
50
68
  const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
51
- const node = selectedId ? nodes[selectedId as AnyNodeId] : null
69
+
70
+ // Subscribe just to the selected node so unrelated scene updates do not
71
+ // re-render this menu.
72
+ const node = useScene((s) => (selectedId ? (s.nodes[selectedId as AnyNodeId] ?? null) : null))
52
73
  const isValidType = node ? ALLOWED_TYPES.includes(node.type) : false
53
74
 
75
+ // Boolean selector, only re-renders when curving availability actually flips.
76
+ const canCurveSelectedWall = useScene((s) => {
77
+ if (!selectedId) return false
78
+ const selectedNode = s.nodes[selectedId as AnyNodeId]
79
+ if (selectedNode?.type !== 'wall') return false
80
+ return !(selectedNode.children ?? []).some((childId) => {
81
+ const child = s.nodes[childId as AnyNodeId]
82
+ if (!child) return false
83
+ if (child.type === 'door' || child.type === 'window') return true
84
+ if (child.type === 'item') {
85
+ const attachTo = child.asset?.attachTo
86
+ return attachTo === 'wall' || attachTo === 'wall-side'
87
+ }
88
+ return false
89
+ })
90
+ })
91
+
92
+ useEffect(() => {
93
+ const handleKeyDown = (event: KeyboardEvent) => {
94
+ if (event.key === 'Alt') {
95
+ setAltPressed(true)
96
+ }
97
+ }
98
+
99
+ const handleKeyUp = (event: KeyboardEvent) => {
100
+ if (event.key === 'Alt') {
101
+ setAltPressed(false)
102
+ }
103
+ }
104
+
105
+ const handleBlur = () => {
106
+ setAltPressed(false)
107
+ }
108
+
109
+ window.addEventListener('keydown', handleKeyDown)
110
+ window.addEventListener('keyup', handleKeyUp)
111
+ window.addEventListener('blur', handleBlur)
112
+
113
+ return () => {
114
+ window.removeEventListener('keydown', handleKeyDown)
115
+ window.removeEventListener('keyup', handleKeyUp)
116
+ window.removeEventListener('blur', handleBlur)
117
+ }
118
+ }, [])
119
+
54
120
  useFrame(() => {
55
121
  if (!(selectedId && isValidType && groupRef.current)) return
56
122
 
@@ -61,10 +127,47 @@ export function FloatingActionMenu() {
61
127
  if (!box.isEmpty()) {
62
128
  const center = box.getCenter(new THREE.Vector3())
63
129
  // Position above the object, with extra offset for walls/slabs to avoid covering measurement labels
64
- const isDeleteOnly = node && DELETE_ONLY_TYPES.includes(node.type)
65
- const yOffset = isDeleteOnly ? 0.8 : 0.3
130
+ const isStructural = node && [...DELETE_ONLY_TYPES, ...HOLE_TYPES].includes(node.type)
131
+ const yOffset = isStructural ? 0.8 : 0.3
66
132
  groupRef.current.position.set(center.x, box.max.y + yOffset, center.z)
67
133
  }
134
+
135
+ if (node?.type === 'wall' || node?.type === 'fence') {
136
+ const segment = node as WallNode | FenceNode
137
+ const endpointYOffset = 0.35
138
+ const startWorld =
139
+ node.type === 'wall'
140
+ ? obj.localToWorld(new THREE.Vector3(0, 0, 0))
141
+ : obj.localToWorld(new THREE.Vector3(segment.start[0], 0, segment.start[1]))
142
+ const endWorld =
143
+ node.type === 'wall'
144
+ ? obj.localToWorld(
145
+ new THREE.Vector3(
146
+ Math.hypot(
147
+ segment.end[0] - segment.start[0],
148
+ segment.end[1] - segment.start[1],
149
+ ),
150
+ 0,
151
+ 0,
152
+ ),
153
+ )
154
+ : obj.localToWorld(new THREE.Vector3(segment.end[0], 0, segment.end[1]))
155
+
156
+ if (startEndpointGroupRef.current) {
157
+ startEndpointGroupRef.current.position.set(
158
+ startWorld.x,
159
+ startWorld.y + endpointYOffset,
160
+ startWorld.z,
161
+ )
162
+ }
163
+ if (endEndpointGroupRef.current) {
164
+ endEndpointGroupRef.current.position.set(
165
+ endWorld.x,
166
+ endWorld.y + endpointYOffset,
167
+ endWorld.z,
168
+ )
169
+ }
170
+ }
68
171
  }
69
172
  })
70
173
 
@@ -77,6 +180,10 @@ export function FloatingActionMenu() {
77
180
  node.type === 'item' ||
78
181
  node.type === 'window' ||
79
182
  node.type === 'door' ||
183
+ node.type === 'wall' ||
184
+ node.type === 'fence' ||
185
+ node.type === 'slab' ||
186
+ node.type === 'ceiling' ||
80
187
  node.type === 'roof' ||
81
188
  node.type === 'roof-segment' ||
82
189
  node.type === 'stair' ||
@@ -88,6 +195,39 @@ export function FloatingActionMenu() {
88
195
  },
89
196
  [node, setMovingNode, setSelection],
90
197
  )
198
+ const handleCurve = useCallback(
199
+ (e: React.MouseEvent) => {
200
+ e.stopPropagation()
201
+ if (!node) return
202
+ sfxEmitter.emit('sfx:item-pick')
203
+ if (node.type === 'wall') {
204
+ if (!canCurveSelectedWall) return
205
+ setCurvingWall(node)
206
+ } else if (node.type === 'fence') {
207
+ setCurvingFence(node)
208
+ } else {
209
+ return
210
+ }
211
+ setSelection({ selectedIds: [] })
212
+ },
213
+ [canCurveSelectedWall, node, setCurvingFence, setCurvingWall, setSelection],
214
+ )
215
+ const handleEndpointMove = useCallback(
216
+ (endpoint: 'start' | 'end', e: React.MouseEvent) => {
217
+ e.stopPropagation()
218
+ if (!node) return
219
+ sfxEmitter.emit('sfx:item-pick')
220
+ if (node.type === 'wall') {
221
+ setMovingWallEndpoint({ wall: node, endpoint })
222
+ } else if (node.type === 'fence') {
223
+ setMovingFenceEndpoint({ fence: node, endpoint })
224
+ } else {
225
+ return
226
+ }
227
+ setSelection({ selectedIds: [] })
228
+ },
229
+ [node, setMovingFenceEndpoint, setMovingWallEndpoint, setSelection],
230
+ )
91
231
 
92
232
  const handleDuplicate = useCallback(
93
233
  (e: React.MouseEvent) => {
@@ -108,23 +248,43 @@ export function FloatingActionMenu() {
108
248
  duplicate = WindowNode.parse(duplicateInfo)
109
249
  } else if (node.type === 'item') {
110
250
  duplicate = ItemNode.parse(duplicateInfo)
251
+ } else if (node.type === 'wall') {
252
+ duplicate = WallNode.parse(duplicateInfo)
253
+ } else if (node.type === 'fence') {
254
+ duplicate = FenceNode.parse(duplicateInfo)
255
+ duplicate.start = [duplicate.start[0] + 1, duplicate.start[1] + 1]
256
+ duplicate.end = [duplicate.end[0] + 1, duplicate.end[1] + 1]
111
257
  } else if (node.type === 'roof') {
258
+ duplicateInfo.children = []
112
259
  duplicate = RoofNode.parse(duplicateInfo)
113
260
  } else if (node.type === 'roof-segment') {
114
261
  duplicate = RoofSegmentNode.parse(duplicateInfo)
115
262
  } else if (node.type === 'stair') {
263
+ duplicateInfo.children = []
264
+ duplicateInfo.metadata = { ...duplicateInfo.metadata }
265
+ delete duplicateInfo.metadata?.isNew
116
266
  duplicate = StairNode.parse(duplicateInfo)
117
267
  } else if (node.type === 'stair-segment') {
118
268
  duplicate = StairSegmentNode.parse(duplicateInfo)
119
269
  }
120
270
  } catch (error) {
121
271
  console.error('Failed to parse duplicate', error)
272
+ useScene.temporal.getState().resume()
273
+ return
274
+ }
275
+
276
+ if (!duplicate) {
277
+ useScene.temporal.getState().resume()
122
278
  return
123
279
  }
124
280
 
125
281
  if (duplicate) {
126
282
  if (duplicate.type === 'door' || duplicate.type === 'window') {
127
283
  useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
284
+ } else if (duplicate.type === 'wall') {
285
+ useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
286
+ } else if (duplicate.type === 'fence') {
287
+ useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
128
288
  } else if (
129
289
  duplicate.type === 'roof' ||
130
290
  duplicate.type === 'roof-segment' ||
@@ -139,7 +299,35 @@ export function FloatingActionMenu() {
139
299
  duplicate.position[2] + 1,
140
300
  ]
141
301
  }
142
- useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
302
+ if (node.type === 'stair' && duplicate.type === 'stair') {
303
+ const nodesState = useScene.getState().nodes
304
+ const createOps: { node: AnyNode; parentId?: AnyNodeId }[] = [
305
+ { node: duplicate, parentId: duplicate.parentId as AnyNodeId },
306
+ ]
307
+
308
+ for (const childId of node.children ?? []) {
309
+ const childNode = nodesState[childId]
310
+ if (childNode?.type !== 'stair-segment') {
311
+ continue
312
+ }
313
+
314
+ let childDuplicateInfo = structuredClone(childNode) as any
315
+ delete childDuplicateInfo.id
316
+ childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata }
317
+ delete childDuplicateInfo.metadata?.isNew
318
+
319
+ try {
320
+ const childDuplicate = StairSegmentNode.parse(childDuplicateInfo)
321
+ createOps.push({ node: childDuplicate, parentId: duplicate.id as AnyNodeId })
322
+ } catch (e) {
323
+ console.error('Failed to duplicate stair segment', e)
324
+ }
325
+ }
326
+
327
+ useScene.getState().createNodes(createOps)
328
+ } else {
329
+ useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
330
+ }
143
331
 
144
332
  // Duplicate children for roof nodes
145
333
  if (node.type === 'roof' && node.children) {
@@ -161,71 +349,176 @@ export function FloatingActionMenu() {
161
349
  }
162
350
 
163
351
  // Duplicate children for stair nodes
164
- if (node.type === 'stair' && node.children) {
165
- const nodesState = useScene.getState().nodes
166
- for (const childId of node.children) {
167
- const childNode = nodesState[childId]
168
- if (childNode && childNode.type === 'stair-segment') {
169
- let childDuplicateInfo = structuredClone(childNode) as any
170
- delete childDuplicateInfo.id
171
- childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true }
172
- try {
173
- const childDuplicate = StairSegmentNode.parse(childDuplicateInfo)
174
- useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)
175
- } catch (e) {
176
- console.error('Failed to duplicate stair segment', e)
177
- }
178
- }
179
- }
180
- }
181
352
  }
182
353
  if (
183
354
  duplicate.type === 'item' ||
355
+ duplicate.type === 'wall' ||
356
+ duplicate.type === 'fence' ||
184
357
  duplicate.type === 'window' ||
185
358
  duplicate.type === 'door' ||
186
359
  duplicate.type === 'roof' ||
187
360
  duplicate.type === 'roof-segment' ||
188
- duplicate.type === 'stair' ||
189
361
  duplicate.type === 'stair-segment'
190
362
  ) {
191
363
  setMovingNode(duplicate as any)
364
+ } else if (duplicate.type === 'stair') {
365
+ setSelection({ selectedIds: [duplicate.id as AnyNodeId] })
366
+ }
367
+ if (duplicate.type !== 'stair') {
368
+ setSelection({ selectedIds: [] })
192
369
  }
193
- setSelection({ selectedIds: [] })
194
370
  }
195
371
  },
196
372
  [node, setMovingNode, setSelection],
197
373
  )
198
374
 
375
+ const handleAddHole = useCallback(
376
+ (e: React.MouseEvent) => {
377
+ e.stopPropagation()
378
+ if (!(node && selectedId && (node.type === 'slab' || node.type === 'ceiling'))) return
379
+
380
+ const polygon = (node as SlabNode | CeilingNode).polygon
381
+ let cx = 0
382
+ let cz = 0
383
+ for (const [x, z] of polygon) {
384
+ cx += x
385
+ cz += z
386
+ }
387
+ cx /= polygon.length
388
+ cz /= polygon.length
389
+
390
+ const holeSize = 0.5
391
+ const newHole: Array<[number, number]> = [
392
+ [cx - holeSize, cz - holeSize],
393
+ [cx + holeSize, cz - holeSize],
394
+ [cx + holeSize, cz + holeSize],
395
+ [cx - holeSize, cz + holeSize],
396
+ ]
397
+ const surfaceNode = node as SlabNode | CeilingNode
398
+ const currentHoles = surfaceNode.holes || []
399
+ const currentMetadata = currentHoles.map(
400
+ (_, index) => surfaceNode.holeMetadata?.[index] ?? { source: 'manual' as const },
401
+ )
402
+ updateNode(selectedId as AnyNodeId, {
403
+ holes: [...currentHoles, newHole],
404
+ holeMetadata: [...currentMetadata, { source: 'manual' }],
405
+ })
406
+ setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
407
+ // Re-assert selection so the node stays selected
408
+ setSelection({ selectedIds: [selectedId] })
409
+ },
410
+ [node, selectedId, updateNode, setEditingHole, setSelection],
411
+ )
412
+
199
413
  const handleDelete = useCallback(
200
414
  (e: React.MouseEvent) => {
201
415
  e.stopPropagation()
202
- // Activate delete mode (sledgehammer tool) instead of deleting directly
416
+ if (!selectedId) return
417
+ if (node?.type === 'item') {
418
+ sfxEmitter.emit('sfx:item-delete')
419
+ } else {
420
+ sfxEmitter.emit('sfx:structure-delete')
421
+ }
203
422
  setSelection({ selectedIds: [] })
204
- setMode('delete')
423
+ useScene.getState().deleteNode(selectedId as AnyNodeId)
205
424
  },
206
- [setSelection, setMode],
425
+ [node?.type, selectedId, setSelection],
207
426
  )
208
427
 
209
- if (!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete')) return null
428
+ if (
429
+ !(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
430
+ movingWallEndpoint ||
431
+ movingFenceEndpoint ||
432
+ curvingFence
433
+ )
434
+ return null
210
435
 
211
436
  return (
212
- <group ref={groupRef}>
213
- <Html
214
- center
215
- style={{
216
- pointerEvents: 'auto',
217
- touchAction: 'none',
218
- }}
219
- zIndexRange={[100, 0]}
220
- >
221
- <NodeActionMenu
222
- onDelete={handleDelete}
223
- onDuplicate={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleDuplicate : undefined}
224
- onMove={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleMove : undefined}
225
- onPointerDown={(e) => e.stopPropagation()}
226
- onPointerUp={(e) => e.stopPropagation()}
227
- />
228
- </Html>
437
+ <group>
438
+ <group ref={groupRef}>
439
+ <Html
440
+ center
441
+ style={{
442
+ pointerEvents: 'auto',
443
+ touchAction: 'none',
444
+ }}
445
+ zIndexRange={[100, 0]}
446
+ >
447
+ <NodeActionMenu
448
+ onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
449
+ onCurve={
450
+ node?.type === 'fence' || (node?.type === 'wall' && canCurveSelectedWall)
451
+ ? handleCurve
452
+ : undefined
453
+ }
454
+ onDelete={handleDelete}
455
+ onDuplicate={
456
+ node && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type)
457
+ ? handleDuplicate
458
+ : undefined
459
+ }
460
+ onMove={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleMove : undefined}
461
+ onPointerDown={(e) => e.stopPropagation()}
462
+ onPointerUp={(e) => e.stopPropagation()}
463
+ />
464
+ </Html>
465
+ </group>
466
+ {(node?.type === 'wall' || node?.type === 'fence') && (
467
+ <>
468
+ <group ref={startEndpointGroupRef}>
469
+ <Html
470
+ center
471
+ style={{ pointerEvents: 'auto', touchAction: 'none' }}
472
+ zIndexRange={[100, 0]}
473
+ >
474
+ <button
475
+ aria-label={node.type === 'wall' ? 'Move wall start' : 'Move fence start'}
476
+ 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 ${
477
+ altPressed
478
+ ? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white'
479
+ : 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'
480
+ }`}
481
+ onClick={(e) => handleEndpointMove('start', e)}
482
+ onPointerDown={(e) => e.stopPropagation()}
483
+ title={
484
+ node.type === 'wall'
485
+ ? 'Move wall start (Alt to detach)'
486
+ : 'Move fence start (Alt to detach)'
487
+ }
488
+ type="button"
489
+ >
490
+ <Move className="h-4 w-4" />
491
+ </button>
492
+ </Html>
493
+ </group>
494
+ <group ref={endEndpointGroupRef}>
495
+ <Html
496
+ center
497
+ style={{ pointerEvents: 'auto', touchAction: 'none' }}
498
+ zIndexRange={[100, 0]}
499
+ >
500
+ <button
501
+ aria-label={node.type === 'wall' ? 'Move wall end' : 'Move fence end'}
502
+ 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 ${
503
+ altPressed
504
+ ? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white'
505
+ : 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'
506
+ }`}
507
+ onClick={(e) => handleEndpointMove('end', e)}
508
+ onPointerDown={(e) => e.stopPropagation()}
509
+ title={
510
+ node.type === 'wall'
511
+ ? 'Move wall end (Alt to detach)'
512
+ : 'Move fence end (Alt to detach)'
513
+ }
514
+ type="button"
515
+ >
516
+ <Move className="h-4 w-4" />
517
+ </button>
518
+ </Html>
519
+ </group>
520
+ </>
521
+ )}
229
522
  </group>
230
523
  )
231
524
  }
@@ -0,0 +1,70 @@
1
+ 'use client'
2
+
3
+ import { type BuildingNode, sceneRegistry, useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { Html } from '@react-three/drei'
6
+ import { useFrame } from '@react-three/fiber'
7
+ import { useCallback, useRef } from 'react'
8
+ import * as THREE from 'three'
9
+ import { sfxEmitter } from '../../lib/sfx-bus'
10
+ import useEditor from '../../store/use-editor'
11
+ import { NodeActionMenu } from './node-action-menu'
12
+
13
+ export function FloatingBuildingActionMenu() {
14
+ const buildingId = useViewer((s) => s.selection.buildingId)
15
+ const levelId = useViewer((s) => s.selection.levelId)
16
+ const setMovingNode = useEditor((s) => s.setMovingNode)
17
+ const setSelection = useViewer((s) => s.setSelection)
18
+
19
+ const groupRef = useRef<THREE.Group>(null)
20
+
21
+ useFrame(() => {
22
+ if (!(buildingId && !levelId && groupRef.current)) return
23
+
24
+ const obj = sceneRegistry.nodes.get(buildingId)
25
+ if (obj) {
26
+ const box = new THREE.Box3().setFromObject(obj)
27
+ if (!box.isEmpty()) {
28
+ const center = box.getCenter(new THREE.Vector3())
29
+ groupRef.current.position.set(center.x, 1.5, center.z)
30
+ }
31
+ }
32
+ })
33
+
34
+ const handleMove = useCallback(
35
+ (e: React.MouseEvent) => {
36
+ e.stopPropagation()
37
+ if (!buildingId) return
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]
41
+ if (!node || node.type !== 'building') return
42
+ sfxEmitter.emit('sfx:item-pick')
43
+ setMovingNode(node as BuildingNode)
44
+ setSelection({ buildingId: null })
45
+ },
46
+ [buildingId, setMovingNode, setSelection],
47
+ )
48
+
49
+ // Only show when a building is selected without a level
50
+ if (!buildingId || levelId) return null
51
+
52
+ return (
53
+ <group ref={groupRef}>
54
+ <Html
55
+ center
56
+ style={{
57
+ pointerEvents: 'auto',
58
+ touchAction: 'none',
59
+ }}
60
+ zIndexRange={[100, 0]}
61
+ >
62
+ <NodeActionMenu
63
+ onMove={handleMove}
64
+ onPointerDown={(e) => e.stopPropagation()}
65
+ onPointerUp={(e) => e.stopPropagation()}
66
+ />
67
+ </Html>
68
+ </group>
69
+ )
70
+ }