@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.
Files changed (79) 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 +255 -34
  4. package/src/components/editor/floating-building-action-menu.tsx +4 -3
  5. package/src/components/editor/floorplan-panel.tsx +1323 -713
  6. package/src/components/editor/index.tsx +2 -0
  7. package/src/components/editor/node-action-menu.tsx +14 -1
  8. package/src/components/editor/selection-manager.tsx +200 -8
  9. package/src/components/editor/site-edge-labels.tsx +9 -3
  10. package/src/components/editor/thumbnail-generator.tsx +319 -157
  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/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  15. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  16. package/src/components/tools/door/door-tool.tsx +12 -0
  17. package/src/components/tools/door/move-door-tool.tsx +10 -0
  18. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  19. package/src/components/tools/fence/fence-drafting.ts +19 -7
  20. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
  21. package/src/components/tools/fence/move-fence-tool.tsx +8 -0
  22. package/src/components/tools/item/move-tool.tsx +9 -0
  23. package/src/components/tools/item/placement-math.ts +14 -6
  24. package/src/components/tools/item/placement-strategies.ts +2 -2
  25. package/src/components/tools/item/use-placement-coordinator.tsx +42 -10
  26. package/src/components/tools/roof/move-roof-tool.tsx +89 -28
  27. package/src/components/tools/shared/polygon-editor.tsx +98 -8
  28. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  29. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  30. package/src/components/tools/stair/stair-tool.tsx +11 -3
  31. package/src/components/tools/tool-manager.tsx +12 -0
  32. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  33. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
  34. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  35. package/src/components/tools/wall/wall-drafting.ts +331 -9
  36. package/src/components/tools/window/move-window-tool.tsx +10 -0
  37. package/src/components/tools/window/window-tool.tsx +12 -0
  38. package/src/components/ui/action-menu/control-modes.tsx +9 -4
  39. package/src/components/ui/command-palette/editor-commands.tsx +9 -4
  40. package/src/components/ui/command-palette/index.tsx +0 -1
  41. package/src/components/ui/controls/material-picker.tsx +127 -94
  42. package/src/components/ui/controls/slider-control.tsx +28 -14
  43. package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
  44. package/src/components/ui/panels/ceiling-panel.tsx +61 -17
  45. package/src/components/ui/panels/door-panel.tsx +5 -5
  46. package/src/components/ui/panels/fence-panel.tsx +97 -12
  47. package/src/components/ui/panels/item-panel.tsx +5 -5
  48. package/src/components/ui/panels/panel-manager.tsx +31 -29
  49. package/src/components/ui/panels/reference-panel.tsx +5 -4
  50. package/src/components/ui/panels/roof-panel.tsx +91 -22
  51. package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
  52. package/src/components/ui/panels/slab-panel.tsx +63 -15
  53. package/src/components/ui/panels/stair-panel.tsx +173 -19
  54. package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
  55. package/src/components/ui/panels/wall-panel.tsx +159 -11
  56. package/src/components/ui/panels/window-panel.tsx +5 -7
  57. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +7 -3
  58. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
  59. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
  60. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
  61. package/src/components/ui/sidebar/panels/site-panel/index.tsx +29 -32
  62. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
  63. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -3
  64. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  65. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
  66. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
  67. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
  68. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +3 -3
  69. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
  70. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
  71. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +7 -3
  72. package/src/components/ui/viewer-toolbar.tsx +55 -2
  73. package/src/components/viewer-overlay.tsx +25 -19
  74. package/src/hooks/use-contextual-tools.ts +14 -13
  75. package/src/hooks/use-keyboard.ts +3 -2
  76. package/src/index.tsx +2 -1
  77. package/src/lib/history.ts +20 -0
  78. package/src/lib/sfx-player.ts +96 -13
  79. 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 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
  /**
@@ -81,7 +81,7 @@ export const floorStrategy = {
81
81
  ctx.levelId,
82
82
  pos,
83
83
  getScaledDimensions(ctx.draftItem),
84
- [0, 0, 0],
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
- [0, 0, 0],
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') rotationDelta = ROTATION_STEP
720
- else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
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)