@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.
Files changed (97) hide show
  1. package/package.json +8 -7
  2. package/src/components/editor/editor-layout-v2.tsx +9 -0
  3. package/src/components/editor/floating-action-menu.tsx +341 -48
  4. package/src/components/editor/floating-building-action-menu.tsx +70 -0
  5. package/src/components/editor/floorplan-panel.tsx +1350 -722
  6. package/src/components/editor/index.tsx +221 -167
  7. package/src/components/editor/node-action-menu.tsx +40 -11
  8. package/src/components/editor/selection-manager.tsx +238 -10
  9. package/src/components/editor/site-edge-labels.tsx +9 -3
  10. package/src/components/editor/thumbnail-generator.tsx +422 -79
  11. package/src/components/editor/wall-measurement-label.tsx +120 -32
  12. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  13. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  14. package/src/components/systems/stair/stair-edit-system.tsx +27 -5
  15. package/src/components/tools/building/move-building-tool.tsx +157 -0
  16. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  17. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  18. package/src/components/tools/door/door-math.ts +1 -1
  19. package/src/components/tools/door/door-tool.tsx +31 -7
  20. package/src/components/tools/door/move-door-tool.tsx +27 -8
  21. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  22. package/src/components/tools/fence/fence-drafting.ts +137 -0
  23. package/src/components/tools/fence/fence-tool.tsx +190 -0
  24. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
  25. package/src/components/tools/fence/move-fence-tool.tsx +231 -0
  26. package/src/components/tools/item/item-tool.tsx +3 -3
  27. package/src/components/tools/item/move-tool.tsx +16 -0
  28. package/src/components/tools/item/placement-math.ts +14 -6
  29. package/src/components/tools/item/placement-strategies.ts +17 -9
  30. package/src/components/tools/item/use-placement-coordinator.tsx +123 -16
  31. package/src/components/tools/roof/move-roof-tool.tsx +90 -26
  32. package/src/components/tools/roof/roof-tool.tsx +6 -6
  33. package/src/components/tools/select/box-select-tool.tsx +2 -2
  34. package/src/components/tools/shared/polygon-editor.tsx +98 -8
  35. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  36. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  37. package/src/components/tools/slab/slab-tool.tsx +4 -4
  38. package/src/components/tools/stair/stair-defaults.ts +10 -0
  39. package/src/components/tools/stair/stair-tool.tsx +39 -8
  40. package/src/components/tools/tool-manager.tsx +54 -14
  41. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  42. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
  43. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  44. package/src/components/tools/wall/wall-drafting.ts +331 -9
  45. package/src/components/tools/wall/wall-tool.tsx +19 -29
  46. package/src/components/tools/window/move-window-tool.tsx +27 -8
  47. package/src/components/tools/window/window-math.ts +1 -1
  48. package/src/components/tools/window/window-tool.tsx +31 -7
  49. package/src/components/tools/zone/zone-tool.tsx +7 -7
  50. package/src/components/ui/action-menu/control-modes.tsx +9 -4
  51. package/src/components/ui/action-menu/structure-tools.tsx +1 -0
  52. package/src/components/ui/command-palette/editor-commands.tsx +9 -4
  53. package/src/components/ui/command-palette/index.tsx +0 -1
  54. package/src/components/ui/controls/material-picker.tsx +127 -94
  55. package/src/components/ui/controls/slider-control.tsx +28 -14
  56. package/src/components/ui/helpers/building-helper.tsx +32 -0
  57. package/src/components/ui/helpers/helper-manager.tsx +2 -0
  58. package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
  59. package/src/components/ui/panels/ceiling-panel.tsx +61 -17
  60. package/src/components/ui/panels/door-panel.tsx +5 -5
  61. package/src/components/ui/panels/fence-panel.tsx +269 -0
  62. package/src/components/ui/panels/item-panel.tsx +5 -5
  63. package/src/components/ui/panels/panel-manager.tsx +32 -27
  64. package/src/components/ui/panels/reference-panel.tsx +5 -4
  65. package/src/components/ui/panels/roof-panel.tsx +91 -22
  66. package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
  67. package/src/components/ui/panels/slab-panel.tsx +63 -15
  68. package/src/components/ui/panels/stair-panel.tsx +377 -50
  69. package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
  70. package/src/components/ui/panels/wall-panel.tsx +159 -11
  71. package/src/components/ui/panels/window-panel.tsx +5 -7
  72. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +28 -17
  73. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +65 -53
  74. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +40 -25
  75. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +69 -0
  76. package/src/components/ui/sidebar/panels/site-panel/index.tsx +88 -72
  77. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
  78. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +64 -53
  79. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +32 -23
  80. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +72 -51
  81. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +40 -37
  82. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +72 -51
  83. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +13 -13
  84. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -17
  85. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +62 -54
  86. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +40 -25
  87. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +27 -28
  88. package/src/components/ui/viewer-toolbar.tsx +55 -2
  89. package/src/components/viewer-overlay.tsx +26 -19
  90. package/src/hooks/use-auto-save.ts +3 -6
  91. package/src/hooks/use-contextual-tools.ts +25 -16
  92. package/src/hooks/use-grid-events.ts +13 -1
  93. package/src/hooks/use-keyboard.ts +7 -2
  94. package/src/index.tsx +2 -1
  95. package/src/lib/history.ts +20 -0
  96. package/src/lib/sfx-player.ts +96 -13
  97. 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?.attachTo) {
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 needsOffset = Math.abs(((halfDim * 2) % 1) - 0.5) < 0.01
11
- const offset = needsOffset ? 0.25 : 0
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 * 2) / 2
26
+ export function snapToHalf(value: number, step = getGridSnapStep()): number {
27
+ return Math.round(value / step) * step
20
28
  }
21
29
 
22
30
  /**