@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.
- package/package.json +8 -7
- package/src/components/editor/editor-layout-v2.tsx +9 -0
- package/src/components/editor/floating-action-menu.tsx +341 -48
- package/src/components/editor/floating-building-action-menu.tsx +70 -0
- package/src/components/editor/floorplan-panel.tsx +1350 -722
- package/src/components/editor/index.tsx +221 -167
- package/src/components/editor/node-action-menu.tsx +40 -11
- package/src/components/editor/selection-manager.tsx +238 -10
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +422 -79
- 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/systems/stair/stair-edit-system.tsx +27 -5
- package/src/components/tools/building/move-building-tool.tsx +157 -0
- 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-math.ts +1 -1
- package/src/components/tools/door/door-tool.tsx +31 -7
- package/src/components/tools/door/move-door-tool.tsx +27 -8
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +137 -0
- package/src/components/tools/fence/fence-tool.tsx +190 -0
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
- package/src/components/tools/fence/move-fence-tool.tsx +231 -0
- package/src/components/tools/item/item-tool.tsx +3 -3
- package/src/components/tools/item/move-tool.tsx +16 -0
- package/src/components/tools/item/placement-math.ts +14 -6
- package/src/components/tools/item/placement-strategies.ts +17 -9
- package/src/components/tools/item/use-placement-coordinator.tsx +123 -16
- package/src/components/tools/roof/move-roof-tool.tsx +90 -26
- package/src/components/tools/roof/roof-tool.tsx +6 -6
- package/src/components/tools/select/box-select-tool.tsx +2 -2
- 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/slab/slab-tool.tsx +4 -4
- package/src/components/tools/stair/stair-defaults.ts +10 -0
- package/src/components/tools/stair/stair-tool.tsx +39 -8
- package/src/components/tools/tool-manager.tsx +54 -14
- 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/wall/wall-tool.tsx +19 -29
- package/src/components/tools/window/move-window-tool.tsx +27 -8
- package/src/components/tools/window/window-math.ts +1 -1
- package/src/components/tools/window/window-tool.tsx +31 -7
- package/src/components/tools/zone/zone-tool.tsx +7 -7
- package/src/components/ui/action-menu/control-modes.tsx +9 -4
- package/src/components/ui/action-menu/structure-tools.tsx +1 -0
- 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/helpers/building-helper.tsx +32 -0
- package/src/components/ui/helpers/helper-manager.tsx +2 -0
- 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 +269 -0
- package/src/components/ui/panels/item-panel.tsx +5 -5
- package/src/components/ui/panels/panel-manager.tsx +32 -27
- 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 +377 -50
- 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 +28 -17
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +65 -53
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +40 -25
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +69 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +88 -72
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +64 -53
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +32 -23
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +72 -51
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +40 -37
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +72 -51
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +13 -13
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -17
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +62 -54
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +40 -25
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +27 -28
- package/src/components/ui/viewer-toolbar.tsx +55 -2
- package/src/components/viewer-overlay.tsx +26 -19
- package/src/hooks/use-auto-save.ts +3 -6
- package/src/hooks/use-contextual-tools.ts +25 -16
- package/src/hooks/use-grid-events.ts +13 -1
- package/src/hooks/use-keyboard.ts +7 -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 +125 -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": "
|
|
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.
|
|
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.
|
|
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 {
|
|
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 = [
|
|
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
|
|
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
|
-
|
|
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
|
|
65
|
-
const yOffset =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
423
|
+
useScene.getState().deleteNode(selectedId as AnyNodeId)
|
|
205
424
|
},
|
|
206
|
-
[
|
|
425
|
+
[node?.type, selectedId, setSelection],
|
|
207
426
|
)
|
|
208
427
|
|
|
209
|
-
if (
|
|
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
|
|
213
|
-
<
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
+
}
|