@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
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyNodeId,
|
|
5
|
+
type FenceNode,
|
|
6
|
+
type WallNode,
|
|
7
|
+
emitter,
|
|
8
|
+
type GridEvent,
|
|
9
|
+
pauseSceneHistory,
|
|
10
|
+
resumeSceneHistory,
|
|
11
|
+
useScene,
|
|
12
|
+
} from '@pascal-app/core'
|
|
13
|
+
import { Html } from '@react-three/drei'
|
|
14
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
15
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
16
|
+
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|
17
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
18
|
+
import useEditor, { type MovingFenceEndpoint } from '../../../store/use-editor'
|
|
19
|
+
import { CursorSphere } from '../shared/cursor-sphere'
|
|
20
|
+
import { snapFenceDraftPoint, type FencePlanPoint } from './fence-drafting'
|
|
21
|
+
import { isWallLongEnough } from '../wall/wall-drafting'
|
|
22
|
+
|
|
23
|
+
function samePoint(a: FencePlanPoint, b: FencePlanPoint) {
|
|
24
|
+
return a[0] === b[0] && a[1] === b[1]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type LinkedFenceSnapshot = {
|
|
28
|
+
id: FenceNode['id']
|
|
29
|
+
start: FencePlanPoint
|
|
30
|
+
end: FencePlanPoint
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getLinkedFenceSnapshots(args: {
|
|
34
|
+
fenceId: FenceNode['id']
|
|
35
|
+
fenceParentId: string | null
|
|
36
|
+
originalStart: FencePlanPoint
|
|
37
|
+
originalEnd: FencePlanPoint
|
|
38
|
+
}) {
|
|
39
|
+
const { fenceId, fenceParentId, originalStart, originalEnd } = args
|
|
40
|
+
const { nodes } = useScene.getState()
|
|
41
|
+
const snapshots: LinkedFenceSnapshot[] = []
|
|
42
|
+
|
|
43
|
+
for (const node of Object.values(nodes)) {
|
|
44
|
+
if (!(node?.type === 'fence' && node.id !== fenceId)) {
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if ((node.parentId ?? null) !== fenceParentId) {
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (
|
|
53
|
+
!samePoint(node.start, originalStart) &&
|
|
54
|
+
!samePoint(node.start, originalEnd) &&
|
|
55
|
+
!samePoint(node.end, originalStart) &&
|
|
56
|
+
!samePoint(node.end, originalEnd)
|
|
57
|
+
) {
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
snapshots.push({
|
|
62
|
+
id: node.id,
|
|
63
|
+
start: [...node.start] as FencePlanPoint,
|
|
64
|
+
end: [...node.end] as FencePlanPoint,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return snapshots
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function getLinkedFenceUpdates(
|
|
72
|
+
linkedFences: LinkedFenceSnapshot[],
|
|
73
|
+
originalStart: FencePlanPoint,
|
|
74
|
+
originalEnd: FencePlanPoint,
|
|
75
|
+
nextStart: FencePlanPoint,
|
|
76
|
+
nextEnd: FencePlanPoint,
|
|
77
|
+
) {
|
|
78
|
+
return linkedFences.map((fence) => ({
|
|
79
|
+
id: fence.id,
|
|
80
|
+
start: samePoint(fence.start, originalStart)
|
|
81
|
+
? nextStart
|
|
82
|
+
: samePoint(fence.start, originalEnd)
|
|
83
|
+
? nextEnd
|
|
84
|
+
: fence.start,
|
|
85
|
+
end: samePoint(fence.end, originalStart)
|
|
86
|
+
? nextStart
|
|
87
|
+
: samePoint(fence.end, originalEnd)
|
|
88
|
+
? nextEnd
|
|
89
|
+
: fence.end,
|
|
90
|
+
}))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = ({ target }) => {
|
|
94
|
+
const activatedAtRef = useRef<number>(Date.now())
|
|
95
|
+
const previousGridPosRef = useRef<FencePlanPoint | null>(null)
|
|
96
|
+
const shiftPressedRef = useRef(false)
|
|
97
|
+
const altPressedRef = useRef(false)
|
|
98
|
+
const nodeIdRef = useRef(target.fence.id)
|
|
99
|
+
const originalStartRef = useRef<FencePlanPoint>([...target.fence.start] as FencePlanPoint)
|
|
100
|
+
const originalEndRef = useRef<FencePlanPoint>([...target.fence.end] as FencePlanPoint)
|
|
101
|
+
const fixedPointRef = useRef<FencePlanPoint>(
|
|
102
|
+
target.endpoint === 'start'
|
|
103
|
+
? ([...target.fence.end] as FencePlanPoint)
|
|
104
|
+
: ([...target.fence.start] as FencePlanPoint),
|
|
105
|
+
)
|
|
106
|
+
const linkedOriginalsRef = useRef(
|
|
107
|
+
getLinkedFenceSnapshots({
|
|
108
|
+
fenceId: target.fence.id,
|
|
109
|
+
fenceParentId: target.fence.parentId ?? null,
|
|
110
|
+
originalStart: target.fence.start,
|
|
111
|
+
originalEnd: target.fence.end,
|
|
112
|
+
}),
|
|
113
|
+
)
|
|
114
|
+
const previewRef = useRef<{ start: FencePlanPoint; end: FencePlanPoint } | null>(null)
|
|
115
|
+
|
|
116
|
+
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
|
|
117
|
+
const point = target.endpoint === 'start' ? target.fence.start : target.fence.end
|
|
118
|
+
return [point[0], 0, point[1]]
|
|
119
|
+
})
|
|
120
|
+
const [altPressed, setAltPressed] = useState(false)
|
|
121
|
+
|
|
122
|
+
const exitMoveMode = useCallback(() => {
|
|
123
|
+
useEditor.getState().setMovingFenceEndpoint(null)
|
|
124
|
+
}, [])
|
|
125
|
+
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
const nodeId = nodeIdRef.current
|
|
128
|
+
const originalStart = originalStartRef.current
|
|
129
|
+
const originalEnd = originalEndRef.current
|
|
130
|
+
const fixedPoint = fixedPointRef.current
|
|
131
|
+
const siblings = Object.values(useScene.getState().nodes)
|
|
132
|
+
const levelWalls = siblings.filter(
|
|
133
|
+
(node): node is WallNode =>
|
|
134
|
+
node?.type === 'wall' && (node.parentId ?? null) === (target.fence.parentId ?? null),
|
|
135
|
+
)
|
|
136
|
+
const levelFences = siblings.filter(
|
|
137
|
+
(node): node is FenceNode =>
|
|
138
|
+
node?.type === 'fence' && (node.parentId ?? null) === (target.fence.parentId ?? null),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
pauseSceneHistory(useScene)
|
|
142
|
+
let wasCommitted = false
|
|
143
|
+
|
|
144
|
+
const applyNodePreview = (
|
|
145
|
+
updates: Array<{ id: FenceNode['id']; start: FencePlanPoint; end: FencePlanPoint }>,
|
|
146
|
+
) => {
|
|
147
|
+
useScene.getState().updateNodes(
|
|
148
|
+
updates.map((entry) => ({
|
|
149
|
+
id: entry.id as AnyNodeId,
|
|
150
|
+
data: { start: entry.start, end: entry.end },
|
|
151
|
+
})),
|
|
152
|
+
)
|
|
153
|
+
for (const entry of updates) {
|
|
154
|
+
useScene.getState().markDirty(entry.id as AnyNodeId)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const applyPreview = (movingPoint: FencePlanPoint, detachLinkedFences = false) => {
|
|
159
|
+
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
|
|
160
|
+
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
|
|
161
|
+
previewRef.current = { start: nextStart, end: nextEnd }
|
|
162
|
+
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
|
|
163
|
+
applyNodePreview([
|
|
164
|
+
{ id: nodeId, start: nextStart, end: nextEnd },
|
|
165
|
+
...(detachLinkedFences
|
|
166
|
+
? []
|
|
167
|
+
: getLinkedFenceUpdates(
|
|
168
|
+
linkedOriginalsRef.current,
|
|
169
|
+
originalStart,
|
|
170
|
+
originalEnd,
|
|
171
|
+
nextStart,
|
|
172
|
+
nextEnd,
|
|
173
|
+
)),
|
|
174
|
+
])
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const restoreOriginal = () => {
|
|
178
|
+
applyNodePreview([
|
|
179
|
+
{ id: nodeId, start: originalStart, end: originalEnd },
|
|
180
|
+
...linkedOriginalsRef.current,
|
|
181
|
+
])
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const onGridMove = (event: GridEvent) => {
|
|
185
|
+
const planPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
|
|
186
|
+
const snappedPoint = snapFenceDraftPoint({
|
|
187
|
+
point: planPoint,
|
|
188
|
+
walls: levelWalls,
|
|
189
|
+
fences: levelFences,
|
|
190
|
+
start: fixedPoint,
|
|
191
|
+
angleSnap: !shiftPressedRef.current,
|
|
192
|
+
ignoreFenceIds: [nodeId],
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
if (
|
|
196
|
+
previousGridPosRef.current &&
|
|
197
|
+
(snappedPoint[0] !== previousGridPosRef.current[0] ||
|
|
198
|
+
snappedPoint[1] !== previousGridPosRef.current[1])
|
|
199
|
+
) {
|
|
200
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
201
|
+
}
|
|
202
|
+
previousGridPosRef.current = snappedPoint
|
|
203
|
+
|
|
204
|
+
applyPreview(snappedPoint, event.nativeEvent.altKey)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const onGridClick = (event: GridEvent) => {
|
|
208
|
+
if (Date.now() - activatedAtRef.current < 150) {
|
|
209
|
+
event.nativeEvent?.stopPropagation?.()
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
|
|
214
|
+
const hasChanged =
|
|
215
|
+
!samePoint(preview.start, originalStart) || !samePoint(preview.end, originalEnd)
|
|
216
|
+
|
|
217
|
+
if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
|
|
218
|
+
wasCommitted = true
|
|
219
|
+
|
|
220
|
+
applyNodePreview([
|
|
221
|
+
{ id: nodeId, start: originalStart, end: originalEnd },
|
|
222
|
+
...linkedOriginalsRef.current,
|
|
223
|
+
])
|
|
224
|
+
|
|
225
|
+
resumeSceneHistory(useScene)
|
|
226
|
+
applyNodePreview([
|
|
227
|
+
{ id: nodeId, start: preview.start, end: preview.end },
|
|
228
|
+
...(altPressedRef.current
|
|
229
|
+
? []
|
|
230
|
+
: getLinkedFenceUpdates(
|
|
231
|
+
linkedOriginalsRef.current,
|
|
232
|
+
originalStart,
|
|
233
|
+
originalEnd,
|
|
234
|
+
preview.start,
|
|
235
|
+
preview.end,
|
|
236
|
+
)),
|
|
237
|
+
])
|
|
238
|
+
pauseSceneHistory(useScene)
|
|
239
|
+
sfxEmitter.emit('sfx:item-place')
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
|
243
|
+
exitMoveMode()
|
|
244
|
+
event.nativeEvent?.stopPropagation?.()
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const onCancel = () => {
|
|
248
|
+
restoreOriginal()
|
|
249
|
+
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
|
250
|
+
resumeSceneHistory(useScene)
|
|
251
|
+
markToolCancelConsumed()
|
|
252
|
+
exitMoveMode()
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
256
|
+
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
if (event.key === 'Shift') {
|
|
260
|
+
shiftPressedRef.current = true
|
|
261
|
+
}
|
|
262
|
+
if (event.key === 'Alt') {
|
|
263
|
+
altPressedRef.current = true
|
|
264
|
+
setAltPressed(true)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const onKeyUp = (event: KeyboardEvent) => {
|
|
269
|
+
if (event.key === 'Shift') {
|
|
270
|
+
shiftPressedRef.current = false
|
|
271
|
+
}
|
|
272
|
+
if (event.key === 'Alt') {
|
|
273
|
+
altPressedRef.current = false
|
|
274
|
+
setAltPressed(false)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const onWindowBlur = () => {
|
|
279
|
+
shiftPressedRef.current = false
|
|
280
|
+
altPressedRef.current = false
|
|
281
|
+
setAltPressed(false)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
emitter.on('grid:move', onGridMove)
|
|
285
|
+
emitter.on('grid:click', onGridClick)
|
|
286
|
+
emitter.on('tool:cancel', onCancel)
|
|
287
|
+
window.addEventListener('keydown', onKeyDown)
|
|
288
|
+
window.addEventListener('keyup', onKeyUp)
|
|
289
|
+
window.addEventListener('blur', onWindowBlur)
|
|
290
|
+
|
|
291
|
+
return () => {
|
|
292
|
+
if (!wasCommitted) {
|
|
293
|
+
restoreOriginal()
|
|
294
|
+
}
|
|
295
|
+
resumeSceneHistory(useScene)
|
|
296
|
+
emitter.off('grid:move', onGridMove)
|
|
297
|
+
emitter.off('grid:click', onGridClick)
|
|
298
|
+
emitter.off('tool:cancel', onCancel)
|
|
299
|
+
window.removeEventListener('keydown', onKeyDown)
|
|
300
|
+
window.removeEventListener('keyup', onKeyUp)
|
|
301
|
+
window.removeEventListener('blur', onWindowBlur)
|
|
302
|
+
}
|
|
303
|
+
}, [exitMoveMode, target])
|
|
304
|
+
|
|
305
|
+
return (
|
|
306
|
+
<group>
|
|
307
|
+
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
|
308
|
+
<Html
|
|
309
|
+
position={[cursorLocalPos[0], 0, cursorLocalPos[2]]}
|
|
310
|
+
style={{ pointerEvents: 'none', touchAction: 'none' }}
|
|
311
|
+
zIndexRange={[100, 0]}
|
|
312
|
+
>
|
|
313
|
+
<div className="translate-y-10">
|
|
314
|
+
<div
|
|
315
|
+
className={`whitespace-nowrap rounded-full border px-2 py-1 text-[11px] font-medium shadow-lg backdrop-blur-md transition-colors ${
|
|
316
|
+
altPressed
|
|
317
|
+
? 'border-amber-500/70 bg-amber-500/15 text-amber-100'
|
|
318
|
+
: 'border-border/70 bg-background/90 text-foreground/80'
|
|
319
|
+
}`}
|
|
320
|
+
>
|
|
321
|
+
{altPressed ? 'Detach endpoint' : 'Drag endpoint'}
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
</Html>
|
|
325
|
+
</group>
|
|
326
|
+
)
|
|
327
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type AnyNodeId, type FenceNode, emitter, type GridEvent, useScene } from '@pascal-app/core'
|
|
4
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
5
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
6
|
+
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|
7
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
8
|
+
import useEditor from '../../../store/use-editor'
|
|
9
|
+
import { CursorSphere } from '../shared/cursor-sphere'
|
|
10
|
+
|
|
11
|
+
function snap(value: number) {
|
|
12
|
+
return Math.round(value * 2) / 2
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function samePoint(a: [number, number], b: [number, number]) {
|
|
16
|
+
return a[0] === b[0] && a[1] === b[1]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type LinkedFenceSnapshot = {
|
|
20
|
+
id: FenceNode['id']
|
|
21
|
+
start: [number, number]
|
|
22
|
+
end: [number, number]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getLinkedFenceSnapshots(args: {
|
|
26
|
+
fenceId: FenceNode['id']
|
|
27
|
+
originalStart: [number, number]
|
|
28
|
+
originalEnd: [number, number]
|
|
29
|
+
}) {
|
|
30
|
+
const { fenceId, originalStart, originalEnd } = args
|
|
31
|
+
const { nodes } = useScene.getState()
|
|
32
|
+
const snapshots: LinkedFenceSnapshot[] = []
|
|
33
|
+
|
|
34
|
+
for (const node of Object.values(nodes)) {
|
|
35
|
+
if (!(node?.type === 'fence' && node.id !== fenceId)) {
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (
|
|
40
|
+
!samePoint(node.start, originalStart) &&
|
|
41
|
+
!samePoint(node.start, originalEnd) &&
|
|
42
|
+
!samePoint(node.end, originalStart) &&
|
|
43
|
+
!samePoint(node.end, originalEnd)
|
|
44
|
+
) {
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
snapshots.push({
|
|
49
|
+
id: node.id,
|
|
50
|
+
start: [...node.start] as [number, number],
|
|
51
|
+
end: [...node.end] as [number, number],
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return snapshots
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getLinkedFenceUpdates(
|
|
59
|
+
linkedFences: LinkedFenceSnapshot[],
|
|
60
|
+
originalStart: [number, number],
|
|
61
|
+
originalEnd: [number, number],
|
|
62
|
+
nextStart: [number, number],
|
|
63
|
+
nextEnd: [number, number],
|
|
64
|
+
) {
|
|
65
|
+
return linkedFences.map((fence) => ({
|
|
66
|
+
id: fence.id,
|
|
67
|
+
start: samePoint(fence.start, originalStart)
|
|
68
|
+
? nextStart
|
|
69
|
+
: samePoint(fence.start, originalEnd)
|
|
70
|
+
? nextEnd
|
|
71
|
+
: fence.start,
|
|
72
|
+
end: samePoint(fence.end, originalStart)
|
|
73
|
+
? nextStart
|
|
74
|
+
: samePoint(fence.end, originalEnd)
|
|
75
|
+
? nextEnd
|
|
76
|
+
: fence.end,
|
|
77
|
+
}))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|
81
|
+
const previousGridPosRef = useRef<[number, number] | null>(null)
|
|
82
|
+
const originalStartRef = useRef<[number, number]>([...node.start] as [number, number])
|
|
83
|
+
const originalEndRef = useRef<[number, number]>([...node.end] as [number, number])
|
|
84
|
+
const linkedOriginalsRef = useRef(
|
|
85
|
+
getLinkedFenceSnapshots({
|
|
86
|
+
fenceId: node.id,
|
|
87
|
+
originalStart: node.start,
|
|
88
|
+
originalEnd: node.end,
|
|
89
|
+
}),
|
|
90
|
+
)
|
|
91
|
+
const dragAnchorRef = useRef<[number, number] | null>(null)
|
|
92
|
+
const nodeIdRef = useRef(node.id)
|
|
93
|
+
const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null)
|
|
94
|
+
|
|
95
|
+
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
|
|
96
|
+
const centerX = (node.start[0] + node.end[0]) / 2
|
|
97
|
+
const centerZ = (node.start[1] + node.end[1]) / 2
|
|
98
|
+
return [centerX, 0, centerZ]
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
const exitMoveMode = useCallback(() => {
|
|
102
|
+
useEditor.getState().setMovingNode(null)
|
|
103
|
+
}, [])
|
|
104
|
+
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
const nodeId = nodeIdRef.current
|
|
107
|
+
const originalStart = originalStartRef.current
|
|
108
|
+
const originalEnd = originalEndRef.current
|
|
109
|
+
|
|
110
|
+
useScene.temporal.getState().pause()
|
|
111
|
+
let wasCommitted = false
|
|
112
|
+
|
|
113
|
+
const applyNodePreview = (updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>) => {
|
|
114
|
+
useScene.getState().updateNodes(
|
|
115
|
+
updates.map((entry) => ({
|
|
116
|
+
id: entry.id as AnyNodeId,
|
|
117
|
+
data: { start: entry.start, end: entry.end },
|
|
118
|
+
})),
|
|
119
|
+
)
|
|
120
|
+
for (const entry of updates) {
|
|
121
|
+
useScene.getState().markDirty(entry.id as AnyNodeId)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => {
|
|
126
|
+
previewRef.current = { start: nextStart, end: nextEnd }
|
|
127
|
+
const centerX = (nextStart[0] + nextEnd[0]) / 2
|
|
128
|
+
const centerZ = (nextStart[1] + nextEnd[1]) / 2
|
|
129
|
+
setCursorLocalPos([centerX, 0, centerZ])
|
|
130
|
+
applyNodePreview([
|
|
131
|
+
{ id: nodeId, start: nextStart, end: nextEnd },
|
|
132
|
+
...getLinkedFenceUpdates(
|
|
133
|
+
linkedOriginalsRef.current,
|
|
134
|
+
originalStart,
|
|
135
|
+
originalEnd,
|
|
136
|
+
nextStart,
|
|
137
|
+
nextEnd,
|
|
138
|
+
),
|
|
139
|
+
])
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const onGridMove = (event: GridEvent) => {
|
|
143
|
+
const localX = snap(event.localPosition[0])
|
|
144
|
+
const localZ = snap(event.localPosition[2])
|
|
145
|
+
|
|
146
|
+
if (
|
|
147
|
+
previousGridPosRef.current &&
|
|
148
|
+
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
|
|
149
|
+
) {
|
|
150
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
151
|
+
}
|
|
152
|
+
previousGridPosRef.current = [localX, localZ]
|
|
153
|
+
|
|
154
|
+
const anchor = dragAnchorRef.current ?? [localX, localZ]
|
|
155
|
+
dragAnchorRef.current = anchor
|
|
156
|
+
|
|
157
|
+
const deltaX = localX - anchor[0]
|
|
158
|
+
const deltaZ = localZ - anchor[1]
|
|
159
|
+
|
|
160
|
+
const nextStart: [number, number] = [originalStart[0] + deltaX, originalStart[1] + deltaZ]
|
|
161
|
+
const nextEnd: [number, number] = [originalEnd[0] + deltaX, originalEnd[1] + deltaZ]
|
|
162
|
+
|
|
163
|
+
applyPreview(nextStart, nextEnd)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const onGridClick = (event: GridEvent) => {
|
|
167
|
+
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
|
|
168
|
+
|
|
169
|
+
wasCommitted = true
|
|
170
|
+
|
|
171
|
+
// Restore original baseline while paused so the next resume+update
|
|
172
|
+
// registers as a single tracked change (undo reverts to original).
|
|
173
|
+
applyNodePreview([
|
|
174
|
+
{ id: nodeId, start: originalStart, end: originalEnd },
|
|
175
|
+
...linkedOriginalsRef.current,
|
|
176
|
+
])
|
|
177
|
+
|
|
178
|
+
useScene.temporal.getState().resume()
|
|
179
|
+
applyNodePreview([
|
|
180
|
+
{ id: nodeId, start: preview.start, end: preview.end },
|
|
181
|
+
...getLinkedFenceUpdates(
|
|
182
|
+
linkedOriginalsRef.current,
|
|
183
|
+
originalStart,
|
|
184
|
+
originalEnd,
|
|
185
|
+
preview.start,
|
|
186
|
+
preview.end,
|
|
187
|
+
),
|
|
188
|
+
])
|
|
189
|
+
useScene.temporal.getState().pause()
|
|
190
|
+
|
|
191
|
+
sfxEmitter.emit('sfx:item-place')
|
|
192
|
+
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
|
193
|
+
exitMoveMode()
|
|
194
|
+
event.nativeEvent?.stopPropagation?.()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const onCancel = () => {
|
|
198
|
+
applyNodePreview([
|
|
199
|
+
{ id: nodeId, start: originalStart, end: originalEnd },
|
|
200
|
+
...linkedOriginalsRef.current,
|
|
201
|
+
])
|
|
202
|
+
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
|
203
|
+
useScene.temporal.getState().resume()
|
|
204
|
+
markToolCancelConsumed()
|
|
205
|
+
exitMoveMode()
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
emitter.on('grid:move', onGridMove)
|
|
209
|
+
emitter.on('grid:click', onGridClick)
|
|
210
|
+
emitter.on('tool:cancel', onCancel)
|
|
211
|
+
|
|
212
|
+
return () => {
|
|
213
|
+
if (!wasCommitted) {
|
|
214
|
+
applyNodePreview([
|
|
215
|
+
{ id: nodeId, start: originalStart, end: originalEnd },
|
|
216
|
+
...linkedOriginalsRef.current,
|
|
217
|
+
])
|
|
218
|
+
}
|
|
219
|
+
useScene.temporal.getState().resume()
|
|
220
|
+
emitter.off('grid:move', onGridMove)
|
|
221
|
+
emitter.off('grid:click', onGridClick)
|
|
222
|
+
emitter.off('tool:cancel', onCancel)
|
|
223
|
+
}
|
|
224
|
+
}, [exitMoveMode])
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<group>
|
|
228
|
+
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
|
229
|
+
</group>
|
|
230
|
+
)
|
|
231
|
+
}
|
|
@@ -8,11 +8,11 @@ export const ItemTool: React.FC = () => {
|
|
|
8
8
|
const draftNode = useDraftNode()
|
|
9
9
|
|
|
10
10
|
const cursor = usePlacementCoordinator({
|
|
11
|
-
asset: selectedItem
|
|
11
|
+
asset: selectedItem,
|
|
12
12
|
draftNode,
|
|
13
13
|
initDraft: (gridPosition) => {
|
|
14
|
-
if (!selectedItem
|
|
15
|
-
draftNode.create(gridPosition, selectedItem
|
|
14
|
+
if (selectedItem && !selectedItem.attachTo) {
|
|
15
|
+
draftNode.create(gridPosition, selectedItem)
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
18
|
onCommitted: () => {
|
|
@@ -1,17 +1,27 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
BuildingNode,
|
|
3
|
+
CeilingNode,
|
|
2
4
|
DoorNode,
|
|
5
|
+
FenceNode,
|
|
3
6
|
ItemNode,
|
|
4
7
|
RoofNode,
|
|
5
8
|
RoofSegmentNode,
|
|
9
|
+
SlabNode,
|
|
6
10
|
StairNode,
|
|
7
11
|
StairSegmentNode,
|
|
12
|
+
WallNode,
|
|
8
13
|
WindowNode,
|
|
9
14
|
} from '@pascal-app/core'
|
|
10
15
|
import { Vector3 } from 'three'
|
|
11
16
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
12
17
|
import useEditor from '../../../store/use-editor'
|
|
18
|
+
import { MoveBuildingContent } from '../building/move-building-tool'
|
|
19
|
+
import { MoveCeilingTool } from '../ceiling/move-ceiling-tool'
|
|
13
20
|
import { MoveDoorTool } from '../door/move-door-tool'
|
|
21
|
+
import { MoveFenceTool } from '../fence/move-fence-tool'
|
|
14
22
|
import { MoveRoofTool } from '../roof/move-roof-tool'
|
|
23
|
+
import { MoveSlabTool } from '../slab/move-slab-tool'
|
|
24
|
+
import { MoveWallTool } from '../wall/move-wall-tool'
|
|
15
25
|
import { MoveWindowTool } from '../window/move-window-tool'
|
|
16
26
|
import type { PlacementState } from './placement-types'
|
|
17
27
|
import { useDraftNode } from './use-draft-node'
|
|
@@ -80,8 +90,14 @@ export const MoveTool: React.FC = () => {
|
|
|
80
90
|
const movingNode = useEditor((state) => state.movingNode)
|
|
81
91
|
|
|
82
92
|
if (!movingNode) return null
|
|
93
|
+
if (movingNode.type === 'building')
|
|
94
|
+
return <MoveBuildingContent node={movingNode as BuildingNode} />
|
|
83
95
|
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
|
|
84
96
|
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
|
|
97
|
+
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
|
|
98
|
+
if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} />
|
|
99
|
+
if (movingNode.type === 'slab') return <MoveSlabTool node={movingNode as SlabNode} />
|
|
100
|
+
if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} />
|
|
85
101
|
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
|
|
86
102
|
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
|
|
87
103
|
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
|
|
@@ -1,22 +1,30 @@
|
|
|
1
1
|
import { isObject } from '@pascal-app/core'
|
|
2
|
+
import useEditor from '../../../store/use-editor'
|
|
3
|
+
|
|
4
|
+
function getGridSnapStep(): number {
|
|
5
|
+
return useEditor.getState().gridSnapStep
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function positiveModulo(value: number, divisor: number): number {
|
|
9
|
+
return ((value % divisor) + divisor) % divisor
|
|
10
|
+
}
|
|
2
11
|
|
|
3
12
|
/**
|
|
4
13
|
* Snaps a position to 0.5 grid, with an offset to align item edges to grid lines.
|
|
5
14
|
* For items with dimensions like 2.5, the center would be at 1.25 from the edge,
|
|
6
15
|
* which doesn't align with 0.5 grid. This adds an offset so edges align instead.
|
|
7
16
|
*/
|
|
8
|
-
export function snapToGrid(position: number, dimension: number): number {
|
|
17
|
+
export function snapToGrid(position: number, dimension: number, step = getGridSnapStep()): number {
|
|
9
18
|
const halfDim = dimension / 2
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
return Math.round((position - offset) * 2) / 2 + offset
|
|
19
|
+
const offset = positiveModulo(halfDim, step)
|
|
20
|
+
return Math.round((position - offset) / step) * step + offset
|
|
13
21
|
}
|
|
14
22
|
|
|
15
23
|
/**
|
|
16
24
|
* Snap a value to 0.5 increments (used for wall-local positions).
|
|
17
25
|
*/
|
|
18
|
-
export function snapToHalf(value: number): number {
|
|
19
|
-
return Math.round(value
|
|
26
|
+
export function snapToHalf(value: number, step = getGridSnapStep()): number {
|
|
27
|
+
return Math.round(value / step) * step
|
|
20
28
|
}
|
|
21
29
|
|
|
22
30
|
/**
|