@pascal-app/editor 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -7
- package/src/components/editor/editor-layout-v2.tsx +9 -0
- package/src/components/editor/floating-action-menu.tsx +255 -34
- package/src/components/editor/floating-building-action-menu.tsx +4 -3
- package/src/components/editor/floorplan-panel.tsx +1323 -713
- package/src/components/editor/index.tsx +2 -0
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/selection-manager.tsx +200 -8
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +319 -157
- package/src/components/editor/wall-measurement-label.tsx +120 -32
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
- package/src/components/systems/roof/roof-edit-system.tsx +5 -5
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
- package/src/components/tools/door/door-tool.tsx +12 -0
- package/src/components/tools/door/move-door-tool.tsx +10 -0
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +19 -7
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
- package/src/components/tools/fence/move-fence-tool.tsx +8 -0
- package/src/components/tools/item/move-tool.tsx +9 -0
- package/src/components/tools/item/placement-math.ts +14 -6
- package/src/components/tools/item/placement-strategies.ts +2 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +42 -10
- package/src/components/tools/roof/move-roof-tool.tsx +89 -28
- package/src/components/tools/shared/polygon-editor.tsx +98 -8
- package/src/components/tools/slab/move-slab-tool.tsx +182 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
- package/src/components/tools/stair/stair-tool.tsx +11 -3
- package/src/components/tools/tool-manager.tsx +12 -0
- package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
- package/src/components/tools/wall/move-wall-tool.tsx +356 -0
- package/src/components/tools/wall/wall-drafting.ts +331 -9
- package/src/components/tools/window/move-window-tool.tsx +10 -0
- package/src/components/tools/window/window-tool.tsx +12 -0
- package/src/components/ui/action-menu/control-modes.tsx +9 -4
- package/src/components/ui/command-palette/editor-commands.tsx +9 -4
- package/src/components/ui/command-palette/index.tsx +0 -1
- package/src/components/ui/controls/material-picker.tsx +127 -94
- package/src/components/ui/controls/slider-control.tsx +28 -14
- package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
- package/src/components/ui/panels/ceiling-panel.tsx +61 -17
- package/src/components/ui/panels/door-panel.tsx +5 -5
- package/src/components/ui/panels/fence-panel.tsx +97 -12
- package/src/components/ui/panels/item-panel.tsx +5 -5
- package/src/components/ui/panels/panel-manager.tsx +31 -29
- package/src/components/ui/panels/reference-panel.tsx +5 -4
- package/src/components/ui/panels/roof-panel.tsx +91 -22
- package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
- package/src/components/ui/panels/slab-panel.tsx +63 -15
- package/src/components/ui/panels/stair-panel.tsx +173 -19
- package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
- package/src/components/ui/panels/wall-panel.tsx +159 -11
- package/src/components/ui/panels/window-panel.tsx +5 -7
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +29 -32
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +7 -3
- package/src/components/ui/viewer-toolbar.tsx +55 -2
- package/src/components/viewer-overlay.tsx +25 -19
- package/src/hooks/use-contextual-tools.ts +14 -13
- package/src/hooks/use-keyboard.ts +3 -2
- package/src/index.tsx +2 -1
- package/src/lib/history.ts +20 -0
- package/src/lib/sfx-player.ts +96 -13
- package/src/store/use-editor.tsx +118 -10
|
@@ -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
|
+
}
|
|
@@ -167,6 +167,14 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|
|
167
167
|
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
|
|
168
168
|
|
|
169
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
|
+
|
|
170
178
|
useScene.temporal.getState().resume()
|
|
171
179
|
applyNodePreview([
|
|
172
180
|
{ id: nodeId, start: preview.start, end: preview.end },
|
|
@@ -1,21 +1,27 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
BuildingNode,
|
|
3
|
+
CeilingNode,
|
|
3
4
|
DoorNode,
|
|
4
5
|
FenceNode,
|
|
5
6
|
ItemNode,
|
|
6
7
|
RoofNode,
|
|
7
8
|
RoofSegmentNode,
|
|
9
|
+
SlabNode,
|
|
8
10
|
StairNode,
|
|
9
11
|
StairSegmentNode,
|
|
12
|
+
WallNode,
|
|
10
13
|
WindowNode,
|
|
11
14
|
} from '@pascal-app/core'
|
|
12
15
|
import { Vector3 } from 'three'
|
|
13
16
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
14
17
|
import useEditor from '../../../store/use-editor'
|
|
15
18
|
import { MoveBuildingContent } from '../building/move-building-tool'
|
|
19
|
+
import { MoveCeilingTool } from '../ceiling/move-ceiling-tool'
|
|
16
20
|
import { MoveDoorTool } from '../door/move-door-tool'
|
|
17
21
|
import { MoveFenceTool } from '../fence/move-fence-tool'
|
|
18
22
|
import { MoveRoofTool } from '../roof/move-roof-tool'
|
|
23
|
+
import { MoveSlabTool } from '../slab/move-slab-tool'
|
|
24
|
+
import { MoveWallTool } from '../wall/move-wall-tool'
|
|
19
25
|
import { MoveWindowTool } from '../window/move-window-tool'
|
|
20
26
|
import type { PlacementState } from './placement-types'
|
|
21
27
|
import { useDraftNode } from './use-draft-node'
|
|
@@ -89,6 +95,9 @@ export const MoveTool: React.FC = () => {
|
|
|
89
95
|
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
|
|
90
96
|
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
|
|
91
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} />
|
|
92
101
|
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
|
|
93
102
|
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
|
|
94
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
|
/**
|
|
@@ -81,7 +81,7 @@ export const floorStrategy = {
|
|
|
81
81
|
ctx.levelId,
|
|
82
82
|
pos,
|
|
83
83
|
getScaledDimensions(ctx.draftItem),
|
|
84
|
-
|
|
84
|
+
ctx.draftItem.rotation,
|
|
85
85
|
[ctx.draftItem.id],
|
|
86
86
|
).valid
|
|
87
87
|
|
|
@@ -558,7 +558,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
|
|
|
558
558
|
ctx.levelId,
|
|
559
559
|
[ctx.gridPosition.x, 0, ctx.gridPosition.z],
|
|
560
560
|
getScaledDimensions(ctx.draftItem),
|
|
561
|
-
|
|
561
|
+
ctx.draftItem.rotation,
|
|
562
562
|
[ctx.draftItem.id],
|
|
563
563
|
).valid
|
|
564
564
|
}
|
|
@@ -216,6 +216,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
216
216
|
let previousGridPos: [number, number, number] | null = null
|
|
217
217
|
|
|
218
218
|
const onGridMove = (event: GridEvent) => {
|
|
219
|
+
// Lazy draft creation: if no draft yet (e.g. level wasn't ready during init), create now
|
|
220
|
+
if (draftNode.current === null && asset.attachTo === undefined) {
|
|
221
|
+
configRef.current.initDraft(gridPosition.current)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
|
|
219
225
|
const result = floorStrategy.move(getContext(), event)
|
|
220
226
|
if (!result) return
|
|
221
227
|
|
|
@@ -233,7 +239,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
233
239
|
// Only update X and Z for cursor - useFrame will handle Y (slab elevation)
|
|
234
240
|
cursorGroupRef.current.position.x = result.cursorPosition[0]
|
|
235
241
|
cursorGroupRef.current.position.z = result.cursorPosition[2]
|
|
236
|
-
lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
|
|
237
242
|
|
|
238
243
|
const draft = draftNode.current
|
|
239
244
|
if (draft) draft.position = result.gridPosition
|
|
@@ -499,13 +504,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
499
504
|
return
|
|
500
505
|
}
|
|
501
506
|
|
|
507
|
+
lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
|
|
502
508
|
const result = itemSurfaceStrategy.move(ctx, event)
|
|
503
509
|
if (!result) return
|
|
504
510
|
|
|
505
511
|
event.stopPropagation()
|
|
506
512
|
|
|
507
513
|
gridPosition.current.set(...result.gridPosition)
|
|
508
|
-
lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
|
|
509
514
|
const ic = worldToBuildingLocal(...result.cursorPosition)
|
|
510
515
|
cursorGroupRef.current.position.set(ic.x, ic.y, ic.z)
|
|
511
516
|
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
|
@@ -609,6 +614,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
609
614
|
return
|
|
610
615
|
}
|
|
611
616
|
|
|
617
|
+
lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
|
|
612
618
|
const result = ceilingStrategy.move(getContext(), event)
|
|
613
619
|
if (!result) return
|
|
614
620
|
|
|
@@ -625,7 +631,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
625
631
|
}
|
|
626
632
|
|
|
627
633
|
gridPosition.current.set(...result.gridPosition)
|
|
628
|
-
lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
|
|
629
634
|
const cc = worldToBuildingLocal(...result.cursorPosition)
|
|
630
635
|
cursorGroupRef.current.position.set(cc.x, cc.y, cc.z)
|
|
631
636
|
|
|
@@ -701,23 +706,25 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
701
706
|
|
|
702
707
|
const ROTATION_STEP = Math.PI / 2
|
|
703
708
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
704
|
-
// Don't intercept keys when focus is inside a text input
|
|
705
|
-
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
|
706
|
-
return
|
|
707
|
-
}
|
|
708
|
-
|
|
709
709
|
if (event.key === 'Shift') {
|
|
710
710
|
shiftFreeRef.current = true
|
|
711
711
|
revalidate()
|
|
712
712
|
return
|
|
713
713
|
}
|
|
714
714
|
|
|
715
|
+
// Don't intercept keys when focus is inside a text input
|
|
716
|
+
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
|
717
|
+
return
|
|
718
|
+
}
|
|
719
|
+
|
|
715
720
|
const draft = draftNode.current
|
|
716
721
|
if (!draft) return
|
|
717
722
|
|
|
718
723
|
let rotationDelta = 0
|
|
719
|
-
if (event.key === 'r' || event.key === 'R')
|
|
720
|
-
|
|
724
|
+
if ((event.key === 'r' || event.key === 'R') && !event.metaKey && !event.ctrlKey)
|
|
725
|
+
rotationDelta = ROTATION_STEP
|
|
726
|
+
else if ((event.key === 't' || event.key === 'T') && !event.metaKey && !event.ctrlKey)
|
|
727
|
+
rotationDelta = -ROTATION_STEP
|
|
721
728
|
|
|
722
729
|
if (rotationDelta !== 0) {
|
|
723
730
|
event.preventDefault()
|
|
@@ -825,6 +832,29 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
825
832
|
const edgesGeometry = new EdgesGeometry(boxGeometry)
|
|
826
833
|
edgesRef.current.geometry = edgesGeometry
|
|
827
834
|
|
|
835
|
+
// ---- Undo protection ----
|
|
836
|
+
// Undo replaces the entire `nodes` object with a previous snapshot, which doesn't
|
|
837
|
+
// include the draft (created while temporal was paused). Re-insert it so the mesh
|
|
838
|
+
// doesn't disappear mid-placement.
|
|
839
|
+
// We defer via queueMicrotask to avoid nested setState during the undo callback.
|
|
840
|
+
// Temporal is already paused during placement, so createNode won't enter the undo stack.
|
|
841
|
+
let tearingDown = false
|
|
842
|
+
const unsubDraftWatch = useScene.subscribe((state) => {
|
|
843
|
+
if (tearingDown) return
|
|
844
|
+
const draft = draftNode.current
|
|
845
|
+
if (draft === null) return
|
|
846
|
+
if (draft.id in state.nodes) return
|
|
847
|
+
|
|
848
|
+
queueMicrotask(() => {
|
|
849
|
+
if (tearingDown) return
|
|
850
|
+
const draft = draftNode.current
|
|
851
|
+
if (draft === null) return
|
|
852
|
+
if (draft.id in useScene.getState().nodes) return
|
|
853
|
+
// Temporal is paused during placement, createNode won't be tracked
|
|
854
|
+
useScene.getState().createNode(draft, draft.parentId as AnyNodeId)
|
|
855
|
+
})
|
|
856
|
+
})
|
|
857
|
+
|
|
828
858
|
// ---- Subscribe ----
|
|
829
859
|
|
|
830
860
|
emitter.on('grid:move', onGridMove)
|
|
@@ -843,6 +873,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
843
873
|
emitter.on('ceiling:leave', onCeilingLeave)
|
|
844
874
|
|
|
845
875
|
return () => {
|
|
876
|
+
tearingDown = true
|
|
877
|
+
unsubDraftWatch()
|
|
846
878
|
// Clear live transform for any remaining draft
|
|
847
879
|
if (draftNode.current) {
|
|
848
880
|
useLiveTransforms.getState().clear(draftNode.current.id)
|