@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.
- package/package.json +12 -7
- package/src/components/editor/bottom-sheet.tsx +149 -0
- package/src/components/editor/custom-camera-controls.tsx +75 -7
- package/src/components/editor/editor-layout-mobile.tsx +264 -0
- package/src/components/editor/editor-layout-v2.tsx +29 -0
- package/src/components/editor/first-person/build-collider-world.ts +365 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
- package/src/components/editor/first-person-controls.tsx +496 -143
- package/src/components/editor/floating-action-menu.tsx +281 -83
- package/src/components/editor/floating-building-action-menu.tsx +4 -3
- package/src/components/editor/floorplan-background-selection.ts +113 -0
- package/src/components/editor/floorplan-panel.tsx +10442 -3275
- package/src/components/editor/index.tsx +270 -20
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/selection-manager.tsx +766 -12
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +350 -157
- package/src/components/editor/use-floorplan-background-placement.ts +257 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
- package/src/components/editor/use-floorplan-scene-data.ts +189 -0
- package/src/components/editor/wall-measurement-label.tsx +377 -58
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
- package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
- package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
- package/src/components/editor-2d/svg-paths.ts +119 -0
- 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-boundary-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +2 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
- package/src/components/tools/column/column-tool.tsx +97 -0
- package/src/components/tools/column/move-column-tool.tsx +105 -0
- package/src/components/tools/door/door-tool.tsx +19 -0
- package/src/components/tools/door/move-door-tool.tsx +38 -8
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +27 -8
- package/src/components/tools/fence/fence-tool.tsx +159 -3
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +438 -0
- package/src/components/tools/fence/move-fence-tool.tsx +102 -27
- package/src/components/tools/item/move-tool.tsx +19 -1
- package/src/components/tools/item/placement-math.ts +44 -7
- package/src/components/tools/item/placement-strategies.ts +111 -33
- package/src/components/tools/item/placement-types.ts +7 -0
- package/src/components/tools/item/use-draft-node.ts +2 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +701 -61
- package/src/components/tools/roof/move-roof-tool.tsx +111 -43
- package/src/components/tools/shared/polygon-editor.tsx +244 -29
- package/src/components/tools/shared/segment-angle.ts +156 -0
- package/src/components/tools/slab/move-slab-tool.tsx +182 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +2 -0
- package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
- package/src/components/tools/spawn/spawn-tool.tsx +130 -0
- package/src/components/tools/stair/stair-tool.tsx +11 -3
- package/src/components/tools/tool-manager.tsx +30 -3
- package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +423 -0
- package/src/components/tools/wall/move-wall-tool.tsx +356 -0
- package/src/components/tools/wall/wall-drafting.ts +348 -17
- package/src/components/tools/wall/wall-tool.tsx +134 -2
- package/src/components/tools/window/move-window-tool.tsx +28 -0
- package/src/components/tools/window/window-tool.tsx +17 -0
- package/src/components/ui/action-menu/camera-actions.tsx +37 -33
- package/src/components/ui/action-menu/control-modes.tsx +37 -5
- package/src/components/ui/action-menu/index.tsx +91 -1
- package/src/components/ui/action-menu/structure-tools.tsx +2 -0
- package/src/components/ui/action-menu/view-toggles.tsx +424 -35
- package/src/components/ui/command-palette/editor-commands.tsx +27 -5
- package/src/components/ui/command-palette/index.tsx +0 -1
- package/src/components/ui/controls/material-picker.tsx +189 -169
- package/src/components/ui/controls/slider-control.tsx +88 -26
- package/src/components/ui/floating-level-selector.tsx +286 -55
- package/src/components/ui/helpers/helper-manager.tsx +5 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1121 -1219
- package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
- package/src/components/ui/level-duplicate-dialog.tsx +115 -0
- package/src/components/ui/panels/ceiling-panel.tsx +47 -27
- package/src/components/ui/panels/column-panel.tsx +715 -0
- package/src/components/ui/panels/door-panel.tsx +986 -294
- package/src/components/ui/panels/fence-panel.tsx +55 -12
- package/src/components/ui/panels/item-panel.tsx +5 -5
- package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
- package/src/components/ui/panels/node-display.ts +39 -0
- package/src/components/ui/panels/paint-panel.tsx +138 -0
- package/src/components/ui/panels/panel-manager.tsx +241 -30
- package/src/components/ui/panels/panel-wrapper.tsx +48 -39
- package/src/components/ui/panels/reference-panel.tsx +243 -9
- package/src/components/ui/panels/roof-panel.tsx +30 -62
- package/src/components/ui/panels/roof-segment-panel.tsx +8 -23
- package/src/components/ui/panels/slab-panel.tsx +46 -24
- package/src/components/ui/panels/spawn-panel.tsx +155 -0
- package/src/components/ui/panels/stair-panel.tsx +117 -69
- package/src/components/ui/panels/stair-segment-panel.tsx +13 -27
- package/src/components/ui/panels/wall-panel.tsx +71 -17
- package/src/components/ui/panels/window-panel.tsx +665 -146
- package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
- 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 +138 -56
- 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 +9 -5
- 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/spawn-tree-node.tsx +82 -0
- 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 +12 -6
- 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 +15 -8
- package/src/components/ui/sidebar/tab-bar.tsx +3 -0
- package/src/components/ui/viewer-toolbar.tsx +96 -2
- package/src/components/viewer-overlay.tsx +25 -19
- package/src/hooks/use-auto-frame.ts +45 -0
- package/src/hooks/use-contextual-tools.ts +14 -13
- package/src/hooks/use-keyboard.ts +67 -9
- package/src/hooks/use-mobile.ts +12 -12
- package/src/index.tsx +2 -1
- package/src/lib/door-interaction.ts +88 -0
- package/src/lib/floorplan/geometry.ts +263 -0
- package/src/lib/floorplan/index.ts +38 -0
- package/src/lib/floorplan/items.ts +179 -0
- package/src/lib/floorplan/selection-tool.ts +231 -0
- package/src/lib/floorplan/stairs.ts +478 -0
- package/src/lib/floorplan/types.ts +57 -0
- package/src/lib/floorplan/walls.ts +23 -0
- package/src/lib/guide-events.ts +10 -0
- package/src/lib/history.ts +20 -0
- package/src/lib/level-duplication.test.ts +72 -0
- package/src/lib/level-duplication.ts +153 -0
- package/src/lib/local-guide-image.ts +42 -0
- package/src/lib/material-paint.ts +284 -0
- package/src/lib/roof-duplication.ts +214 -0
- package/src/lib/scene-bounds.test.ts +183 -0
- package/src/lib/scene-bounds.ts +169 -0
- package/src/lib/sfx-player.ts +96 -13
- package/src/lib/stair-duplication.ts +126 -0
- package/src/lib/window-interaction.ts +86 -0
- 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 {
|
|
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 = [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
254
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
+
}
|