@pascal-app/editor 0.5.1 → 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.
- package/package.json +8 -7
- package/src/components/editor/editor-layout-v2.tsx +9 -0
- package/src/components/editor/floating-action-menu.tsx +255 -34
- package/src/components/editor/floating-building-action-menu.tsx +4 -3
- package/src/components/editor/floorplan-panel.tsx +1323 -713
- package/src/components/editor/index.tsx +2 -0
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/selection-manager.tsx +200 -8
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +319 -157
- package/src/components/editor/wall-measurement-label.tsx +120 -32
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
- package/src/components/systems/roof/roof-edit-system.tsx +5 -5
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
- package/src/components/tools/door/door-tool.tsx +12 -0
- package/src/components/tools/door/move-door-tool.tsx +10 -0
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +19 -7
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
- package/src/components/tools/fence/move-fence-tool.tsx +8 -0
- package/src/components/tools/item/move-tool.tsx +9 -0
- package/src/components/tools/item/placement-math.ts +14 -6
- package/src/components/tools/item/placement-strategies.ts +2 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +42 -10
- package/src/components/tools/roof/move-roof-tool.tsx +89 -28
- package/src/components/tools/shared/polygon-editor.tsx +98 -8
- package/src/components/tools/slab/move-slab-tool.tsx +182 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
- package/src/components/tools/stair/stair-tool.tsx +11 -3
- package/src/components/tools/tool-manager.tsx +12 -0
- package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
- package/src/components/tools/wall/move-wall-tool.tsx +356 -0
- package/src/components/tools/wall/wall-drafting.ts +331 -9
- package/src/components/tools/window/move-window-tool.tsx +10 -0
- package/src/components/tools/window/window-tool.tsx +12 -0
- package/src/components/ui/action-menu/control-modes.tsx +9 -4
- package/src/components/ui/command-palette/editor-commands.tsx +9 -4
- package/src/components/ui/command-palette/index.tsx +0 -1
- package/src/components/ui/controls/material-picker.tsx +127 -94
- package/src/components/ui/controls/slider-control.tsx +28 -14
- package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
- package/src/components/ui/panels/ceiling-panel.tsx +61 -17
- package/src/components/ui/panels/door-panel.tsx +5 -5
- package/src/components/ui/panels/fence-panel.tsx +97 -12
- package/src/components/ui/panels/item-panel.tsx +5 -5
- package/src/components/ui/panels/panel-manager.tsx +31 -29
- package/src/components/ui/panels/reference-panel.tsx +5 -4
- package/src/components/ui/panels/roof-panel.tsx +91 -22
- package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
- package/src/components/ui/panels/slab-panel.tsx +63 -15
- package/src/components/ui/panels/stair-panel.tsx +173 -19
- package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
- package/src/components/ui/panels/wall-panel.tsx +159 -11
- package/src/components/ui/panels/window-panel.tsx +5 -7
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +29 -32
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +7 -3
- package/src/components/ui/viewer-toolbar.tsx +55 -2
- package/src/components/viewer-overlay.tsx +25 -19
- package/src/hooks/use-contextual-tools.ts +14 -13
- package/src/hooks/use-keyboard.ts +3 -2
- package/src/index.tsx +2 -1
- package/src/lib/history.ts +20 -0
- package/src/lib/sfx-player.ts +96 -13
- package/src/store/use-editor.tsx +118 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pascal-app/editor",
|
|
3
|
-
"version": "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": "^0.
|
|
15
|
-
"@pascal-app/viewer": "^0.
|
|
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.
|
|
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": "^0.
|
|
54
|
-
"@pascal-app/viewer": "^0.
|
|
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.
|
|
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()
|
|
@@ -14,12 +14,14 @@ import {
|
|
|
14
14
|
StairSegmentNode,
|
|
15
15
|
sceneRegistry,
|
|
16
16
|
useScene,
|
|
17
|
+
WallNode,
|
|
17
18
|
WindowNode,
|
|
18
19
|
} from '@pascal-app/core'
|
|
19
20
|
import { useViewer } from '@pascal-app/viewer'
|
|
20
21
|
import { Html } from '@react-three/drei'
|
|
21
22
|
import { useFrame } from '@react-three/fiber'
|
|
22
|
-
import {
|
|
23
|
+
import { Move } from 'lucide-react'
|
|
24
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
23
25
|
import * as THREE from 'three'
|
|
24
26
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
|
25
27
|
import useEditor from '../../store/use-editor'
|
|
@@ -38,26 +40,83 @@ const ALLOWED_TYPES = [
|
|
|
38
40
|
'slab',
|
|
39
41
|
'ceiling',
|
|
40
42
|
]
|
|
41
|
-
const DELETE_ONLY_TYPES = [
|
|
43
|
+
const DELETE_ONLY_TYPES: string[] = []
|
|
42
44
|
const HOLE_TYPES = ['slab', 'ceiling']
|
|
43
45
|
|
|
44
46
|
export function FloatingActionMenu() {
|
|
45
47
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
46
|
-
const nodes = useScene((s) => s.nodes)
|
|
47
48
|
const updateNode = useScene((s) => s.updateNode)
|
|
48
49
|
const mode = useEditor((s) => s.mode)
|
|
49
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)
|
|
50
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)
|
|
51
59
|
const setSelection = useViewer((s) => s.setSelection)
|
|
52
60
|
const setEditingHole = useEditor((s) => s.setEditingHole)
|
|
53
61
|
|
|
54
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)
|
|
55
66
|
|
|
56
67
|
// Only show for single selection of specific types
|
|
57
68
|
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
|
|
58
|
-
|
|
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))
|
|
59
73
|
const isValidType = node ? ALLOWED_TYPES.includes(node.type) : false
|
|
60
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
|
+
|
|
61
120
|
useFrame(() => {
|
|
62
121
|
if (!(selectedId && isValidType && groupRef.current)) return
|
|
63
122
|
|
|
@@ -72,6 +131,43 @@ export function FloatingActionMenu() {
|
|
|
72
131
|
const yOffset = isStructural ? 0.8 : 0.3
|
|
73
132
|
groupRef.current.position.set(center.x, box.max.y + yOffset, center.z)
|
|
74
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
|
+
}
|
|
75
171
|
}
|
|
76
172
|
})
|
|
77
173
|
|
|
@@ -84,7 +180,10 @@ export function FloatingActionMenu() {
|
|
|
84
180
|
node.type === 'item' ||
|
|
85
181
|
node.type === 'window' ||
|
|
86
182
|
node.type === 'door' ||
|
|
183
|
+
node.type === 'wall' ||
|
|
87
184
|
node.type === 'fence' ||
|
|
185
|
+
node.type === 'slab' ||
|
|
186
|
+
node.type === 'ceiling' ||
|
|
88
187
|
node.type === 'roof' ||
|
|
89
188
|
node.type === 'roof-segment' ||
|
|
90
189
|
node.type === 'stair' ||
|
|
@@ -96,6 +195,39 @@ export function FloatingActionMenu() {
|
|
|
96
195
|
},
|
|
97
196
|
[node, setMovingNode, setSelection],
|
|
98
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
|
+
)
|
|
99
231
|
|
|
100
232
|
const handleDuplicate = useCallback(
|
|
101
233
|
(e: React.MouseEvent) => {
|
|
@@ -116,11 +248,14 @@ export function FloatingActionMenu() {
|
|
|
116
248
|
duplicate = WindowNode.parse(duplicateInfo)
|
|
117
249
|
} else if (node.type === 'item') {
|
|
118
250
|
duplicate = ItemNode.parse(duplicateInfo)
|
|
251
|
+
} else if (node.type === 'wall') {
|
|
252
|
+
duplicate = WallNode.parse(duplicateInfo)
|
|
119
253
|
} else if (node.type === 'fence') {
|
|
120
254
|
duplicate = FenceNode.parse(duplicateInfo)
|
|
121
255
|
duplicate.start = [duplicate.start[0] + 1, duplicate.start[1] + 1]
|
|
122
256
|
duplicate.end = [duplicate.end[0] + 1, duplicate.end[1] + 1]
|
|
123
257
|
} else if (node.type === 'roof') {
|
|
258
|
+
duplicateInfo.children = []
|
|
124
259
|
duplicate = RoofNode.parse(duplicateInfo)
|
|
125
260
|
} else if (node.type === 'roof-segment') {
|
|
126
261
|
duplicate = RoofSegmentNode.parse(duplicateInfo)
|
|
@@ -134,12 +269,20 @@ export function FloatingActionMenu() {
|
|
|
134
269
|
}
|
|
135
270
|
} catch (error) {
|
|
136
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()
|
|
137
278
|
return
|
|
138
279
|
}
|
|
139
280
|
|
|
140
281
|
if (duplicate) {
|
|
141
282
|
if (duplicate.type === 'door' || duplicate.type === 'window') {
|
|
142
283
|
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
|
284
|
+
} else if (duplicate.type === 'wall') {
|
|
285
|
+
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
|
143
286
|
} else if (duplicate.type === 'fence') {
|
|
144
287
|
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
|
145
288
|
} else if (
|
|
@@ -209,6 +352,7 @@ export function FloatingActionMenu() {
|
|
|
209
352
|
}
|
|
210
353
|
if (
|
|
211
354
|
duplicate.type === 'item' ||
|
|
355
|
+
duplicate.type === 'wall' ||
|
|
212
356
|
duplicate.type === 'fence' ||
|
|
213
357
|
duplicate.type === 'window' ||
|
|
214
358
|
duplicate.type === 'door' ||
|
|
@@ -250,8 +394,15 @@ export function FloatingActionMenu() {
|
|
|
250
394
|
[cx + holeSize, cz + holeSize],
|
|
251
395
|
[cx - holeSize, cz + holeSize],
|
|
252
396
|
]
|
|
253
|
-
const
|
|
254
|
-
|
|
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
|
+
})
|
|
255
406
|
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
|
|
256
407
|
// Re-assert selection so the node stays selected
|
|
257
408
|
setSelection({ selectedIds: [selectedId] })
|
|
@@ -263,41 +414,111 @@ export function FloatingActionMenu() {
|
|
|
263
414
|
(e: React.MouseEvent) => {
|
|
264
415
|
e.stopPropagation()
|
|
265
416
|
if (!selectedId) return
|
|
417
|
+
if (node?.type === 'item') {
|
|
418
|
+
sfxEmitter.emit('sfx:item-delete')
|
|
419
|
+
} else {
|
|
420
|
+
sfxEmitter.emit('sfx:structure-delete')
|
|
421
|
+
}
|
|
266
422
|
setSelection({ selectedIds: [] })
|
|
267
423
|
useScene.getState().deleteNode(selectedId as AnyNodeId)
|
|
268
424
|
},
|
|
269
|
-
[selectedId, setSelection],
|
|
425
|
+
[node?.type, selectedId, setSelection],
|
|
270
426
|
)
|
|
271
427
|
|
|
272
|
-
if (
|
|
428
|
+
if (
|
|
429
|
+
!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
|
|
430
|
+
movingWallEndpoint ||
|
|
431
|
+
movingFenceEndpoint ||
|
|
432
|
+
curvingFence
|
|
433
|
+
)
|
|
434
|
+
return null
|
|
273
435
|
|
|
274
436
|
return (
|
|
275
|
-
<group
|
|
276
|
-
<
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
+
)}
|
|
301
522
|
</group>
|
|
302
523
|
)
|
|
303
524
|
}
|
|
@@ -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
|
-
|
|
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,
|
|
46
|
+
[buildingId, setMovingNode, setSelection],
|
|
46
47
|
)
|
|
47
48
|
|
|
48
49
|
// Only show when a building is selected without a level
|