@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
@@ -47,12 +47,16 @@ export const floorStrategy = {
47
47
  ? getScaledDimensions(ctx.draftItem)
48
48
  : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
49
49
  const [dimX, , dimZ] = dims
50
- const x = snapToGrid(event.position[0], dimX)
51
- const z = snapToGrid(event.position[2], dimZ)
50
+ const rotY = ctx.draftItem?.rotation?.[1] ?? 0
51
+ const swapDims = Math.abs(Math.sin(rotY)) > 0.9
52
+ // event.localPosition is building-local; the coordinator cursor group is inside the
53
+ // building-local ToolManager group, so local coords are correct for both data and visuals.
54
+ const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
55
+ const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
52
56
 
53
57
  return {
54
58
  gridPosition: [x, 0, z],
55
- cursorPosition: [x, event.position[1], z],
59
+ cursorPosition: [x, event.localPosition[1], z],
56
60
  cursorRotationY: 0,
57
61
  nodeUpdate: { position: [x, 0, z] },
58
62
  stopPropagation: false,
@@ -77,7 +81,7 @@ export const floorStrategy = {
77
81
  ctx.levelId,
78
82
  pos,
79
83
  getScaledDimensions(ctx.draftItem),
80
- [0, 0, 0],
84
+ ctx.draftItem.rotation,
81
85
  [ctx.draftItem.id],
82
86
  ).valid
83
87
 
@@ -302,9 +306,11 @@ export const ceilingStrategy = {
302
306
  : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
303
307
  const [dimX, , dimZ] = dims
304
308
  const itemHeight = dims[1]
309
+ const rotY = ctx.draftItem?.rotation?.[1] ?? 0
310
+ const swapDims = Math.abs(Math.sin(rotY)) > 0.9
305
311
 
306
- const x = snapToGrid(event.position[0], dimX)
307
- const z = snapToGrid(event.position[2], dimZ)
312
+ const x = snapToGrid(event.position[0], swapDims ? dimZ : dimX)
313
+ const z = snapToGrid(event.position[2], swapDims ? dimX : dimZ)
308
314
 
309
315
  return {
310
316
  stateUpdate: { surface: 'ceiling', ceilingId: event.node.id },
@@ -329,9 +335,11 @@ export const ceilingStrategy = {
329
335
  const dims = getScaledDimensions(ctx.draftItem)
330
336
  const [dimX, , dimZ] = dims
331
337
  const itemHeight = dims[1]
338
+ const rotY = ctx.draftItem.rotation?.[1] ?? 0
339
+ const swapDims = Math.abs(Math.sin(rotY)) > 0.9
332
340
 
333
- const x = snapToGrid(event.position[0], dimX)
334
- const z = snapToGrid(event.position[2], dimZ)
341
+ const x = snapToGrid(event.position[0], swapDims ? dimZ : dimX)
342
+ const z = snapToGrid(event.position[2], swapDims ? dimX : dimZ)
335
343
 
336
344
  return {
337
345
  gridPosition: [x, -itemHeight, z],
@@ -550,7 +558,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
550
558
  ctx.levelId,
551
559
  [ctx.gridPosition.x, 0, ctx.gridPosition.z],
552
560
  getScaledDimensions(ctx.draftItem),
553
- [0, 0, 0],
561
+ ctx.draftItem.rotation,
554
562
  [ctx.draftItem.id],
555
563
  ).valid
556
564
  }
@@ -33,6 +33,7 @@ import { distance, smoothstep, uv, vec2 } from 'three/tsl'
33
33
  import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
34
34
  import { EDITOR_LAYER } from '../../../lib/constants'
35
35
  import { sfxEmitter } from '../../../lib/sfx-bus'
36
+ import { snapToGrid } from './placement-math'
36
37
  import {
37
38
  ceilingStrategy,
38
39
  checkCanPlace,
@@ -68,7 +69,7 @@ const radialOpacity = smoothstep(0, 0.7, dist).mul(0.6)
68
69
  basePlaneMaterial.opacityNode = radialOpacity
69
70
 
70
71
  export interface PlacementCoordinatorConfig {
71
- asset: AssetInput
72
+ asset: AssetInput | null
72
73
  draftNode: DraftNodeHandle
73
74
  initDraft: (gridPosition: Vector3) => void
74
75
  onCommitted: () => boolean
@@ -83,6 +84,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
83
84
  const edgesRef = useRef<LineSegments>(null!)
84
85
  const basePlaneRef = useRef<Mesh>(null!)
85
86
  const gridPosition = useRef(new Vector3(0, 0, 0))
87
+ const lastRawPos = useRef(new Vector3(0, 0, 0))
86
88
  const placementState = useRef<PlacementState>(
87
89
  config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null },
88
90
  )
@@ -96,6 +98,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
96
98
  const { asset, draftNode } = config
97
99
 
98
100
  useEffect(() => {
101
+ if (!asset) return
99
102
  useScene.temporal.getState().pause()
100
103
 
101
104
  const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
@@ -135,11 +138,21 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
135
138
  return placeable
136
139
  }
137
140
 
141
+ // Tool visuals are rendered inside the building-local ToolManager group, so all cursor
142
+ // positions must be in building-local space. Wall/ceiling/item-surface strategies return
143
+ // world-space cursor positions (from their event.position); convert them here.
144
+ const worldToBuildingLocal = (x: number, y: number, z: number): Vector3 => {
145
+ const buildingId = useViewer.getState().selection.buildingId
146
+ const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
147
+ return buildingMesh ? buildingMesh.worldToLocal(new Vector3(x, y, z)) : new Vector3(x, y, z)
148
+ }
149
+
138
150
  const applyTransition = (result: TransitionResult) => {
139
151
  Object.assign(placementState.current, result.stateUpdate)
140
152
  gridPosition.current.set(...result.gridPosition)
141
153
 
142
- cursorGroupRef.current.position.set(...result.cursorPosition)
154
+ const c = worldToBuildingLocal(...result.cursorPosition)
155
+ cursorGroupRef.current.position.set(c.x, c.y, c.z)
143
156
  cursorGroupRef.current.rotation.y = result.cursorRotationY
144
157
 
145
158
  const draft = draftNode.current
@@ -152,7 +165,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
152
165
 
153
166
  const ensureDraft = (result: TransitionResult) => {
154
167
  gridPosition.current.set(...result.gridPosition)
155
- cursorGroupRef.current.position.set(...result.cursorPosition)
168
+ const c = worldToBuildingLocal(...result.cursorPosition)
169
+ cursorGroupRef.current.position.set(c.x, c.y, c.z)
156
170
  cursorGroupRef.current.rotation.y = result.cursorRotationY
157
171
 
158
172
  draftNode.create(
@@ -181,7 +195,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
181
195
  if (draftNode.current) {
182
196
  const mesh = sceneRegistry.nodes.get(draftNode.current.id)
183
197
  if (mesh) {
184
- mesh.getWorldPosition(cursorGroupRef.current.position)
198
+ const worldPos = new Vector3()
199
+ mesh.getWorldPosition(worldPos)
200
+ const localPos = worldToBuildingLocal(worldPos.x, worldPos.y, worldPos.z)
201
+ cursorGroupRef.current.position.copy(localPos)
185
202
  // Extract world Y rotation (handles wall-parented items correctly)
186
203
  const q = new Quaternion()
187
204
  mesh.getWorldQuaternion(q)
@@ -199,6 +216,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
199
216
  let previousGridPos: [number, number, number] | null = null
200
217
 
201
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])
202
225
  const result = floorStrategy.move(getContext(), event)
203
226
  if (!result) return
204
227
 
@@ -334,7 +357,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
334
357
  }
335
358
 
336
359
  gridPosition.current.set(...result.gridPosition)
337
- cursorGroupRef.current.position.set(...result.cursorPosition)
360
+ const wc = worldToBuildingLocal(...result.cursorPosition)
361
+ cursorGroupRef.current.position.set(wc.x, wc.y, wc.z)
338
362
  cursorGroupRef.current.rotation.y = result.cursorRotationY
339
363
 
340
364
  const draft = draftNode.current
@@ -480,13 +504,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
480
504
  return
481
505
  }
482
506
 
507
+ lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
483
508
  const result = itemSurfaceStrategy.move(ctx, event)
484
509
  if (!result) return
485
510
 
486
511
  event.stopPropagation()
487
512
 
488
513
  gridPosition.current.set(...result.gridPosition)
489
- cursorGroupRef.current.position.set(...result.cursorPosition)
514
+ const ic = worldToBuildingLocal(...result.cursorPosition)
515
+ cursorGroupRef.current.position.set(ic.x, ic.y, ic.z)
490
516
  cursorGroupRef.current.rotation.y = result.cursorRotationY
491
517
 
492
518
  const draft = draftNode.current
@@ -511,14 +537,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
511
537
 
512
538
  event.stopPropagation()
513
539
 
514
- // Transition back to floor using event world position
515
- const wx = Math.round(event.position[0] * 2) / 2
516
- const wz = Math.round(event.position[2] * 2) / 2
540
+ // Transition back to floor using building-local position
541
+ const wx = Math.round(event.localPosition[0] * 2) / 2
542
+ const wz = Math.round(event.localPosition[2] * 2) / 2
517
543
  const floorPos: [number, number, number] = [wx, 0, wz]
518
544
 
519
545
  Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null })
520
546
  gridPosition.current.set(wx, 0, wz)
521
- cursorGroupRef.current.position.set(wx, event.position[1], wz)
547
+ cursorGroupRef.current.position.x = wx
548
+ cursorGroupRef.current.position.z = wz
522
549
 
523
550
  const draft = draftNode.current
524
551
  if (draft) {
@@ -587,6 +614,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
587
614
  return
588
615
  }
589
616
 
617
+ lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
590
618
  const result = ceilingStrategy.move(getContext(), event)
591
619
  if (!result) return
592
620
 
@@ -603,7 +631,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
603
631
  }
604
632
 
605
633
  gridPosition.current.set(...result.gridPosition)
606
- cursorGroupRef.current.position.set(...result.cursorPosition)
634
+ const cc = worldToBuildingLocal(...result.cursorPosition)
635
+ cursorGroupRef.current.position.set(cc.x, cc.y, cc.z)
607
636
 
608
637
  revalidate()
609
638
 
@@ -683,12 +712,19 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
683
712
  return
684
713
  }
685
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
+
686
720
  const draft = draftNode.current
687
721
  if (!draft) return
688
722
 
689
723
  let rotationDelta = 0
690
- if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
691
- 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
692
728
 
693
729
  if (rotationDelta !== 0) {
694
730
  event.preventDefault()
@@ -702,11 +738,55 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
702
738
  const mesh = sceneRegistry.nodes.get(draft.id)
703
739
  if (mesh) mesh.rotation.y = newRotationY
704
740
 
705
- // Update live transform rotation for 2D floorplan
741
+ // Re-snap position immediately with updated rotation (dimX/dimZ may swap at 90°)
742
+ const surface = placementState.current.surface
743
+ if (surface === 'floor' || surface === 'ceiling') {
744
+ const dims = getScaledDimensions(draft)
745
+ const [dimX, , dimZ] = dims
746
+ const swapDims = Math.abs(Math.sin(newRotationY)) > 0.9
747
+ const x = snapToGrid(lastRawPos.current.x, swapDims ? dimZ : dimX)
748
+ const z = snapToGrid(lastRawPos.current.z, swapDims ? dimX : dimZ)
749
+ gridPosition.current.set(x, gridPosition.current.y, z)
750
+ draft.position = [x, gridPosition.current.y, z]
751
+ cursorGroupRef.current.position.x = x
752
+ cursorGroupRef.current.position.z = z
753
+ if (mesh) {
754
+ mesh.position.x = x
755
+ mesh.position.z = z
756
+ }
757
+ } else if (surface === 'item-surface' && placementState.current.surfaceItemId) {
758
+ const surfaceMesh = sceneRegistry.nodes.get(placementState.current.surfaceItemId)
759
+ if (surfaceMesh) {
760
+ const localPos = surfaceMesh.worldToLocal(lastRawPos.current.clone())
761
+ const dims = getScaledDimensions(draft)
762
+ const [dimX, , dimZ] = dims
763
+ const swapDims = Math.abs(Math.sin(newRotationY)) > 0.9
764
+ const x = snapToGrid(localPos.x, swapDims ? dimZ : dimX)
765
+ const z = snapToGrid(localPos.z, swapDims ? dimX : dimZ)
766
+ const y = gridPosition.current.y
767
+ gridPosition.current.set(x, y, z)
768
+ draft.position = [x, y, z]
769
+ const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
770
+ const localSnapped = worldToBuildingLocal(
771
+ worldSnapped.x,
772
+ worldSnapped.y,
773
+ worldSnapped.z,
774
+ )
775
+ cursorGroupRef.current.position.set(localSnapped.x, localSnapped.y, localSnapped.z)
776
+ if (mesh) mesh.position.set(x, y, z)
777
+ }
778
+ }
779
+
780
+ // Update live transform for 2D floorplan with post-snap position
706
781
  const currentLive = useLiveTransforms.getState().get(draft.id)
707
782
  if (currentLive) {
708
783
  useLiveTransforms.getState().set(draft.id, {
709
784
  ...currentLive,
785
+ position: [
786
+ cursorGroupRef.current.position.x,
787
+ cursorGroupRef.current.position.y,
788
+ cursorGroupRef.current.position.z,
789
+ ] as [number, number, number],
710
790
  rotation: newRotationY,
711
791
  })
712
792
  }
@@ -752,6 +832,29 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
752
832
  const edgesGeometry = new EdgesGeometry(boxGeometry)
753
833
  edgesRef.current.geometry = edgesGeometry
754
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
+
755
858
  // ---- Subscribe ----
756
859
 
757
860
  emitter.on('grid:move', onGridMove)
@@ -770,6 +873,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
770
873
  emitter.on('ceiling:leave', onCeilingLeave)
771
874
 
772
875
  return () => {
876
+ tearingDown = true
877
+ unsubDraftWatch()
773
878
  // Clear live transform for any remaining draft
774
879
  if (draftNode.current) {
775
880
  useLiveTransforms.getState().clear(draftNode.current.id)
@@ -801,6 +906,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
801
906
  // Wall/ceiling items are managed by their own surface entry events (ensureDraft / reparent).
802
907
  const viewerLevelId = useViewer((s) => s.selection.levelId)
803
908
  useEffect(() => {
909
+ if (!asset) return
804
910
  const draft = draftNode.current
805
911
  if (!(draft && viewerLevelId) || asset.attachTo) return
806
912
  if (draft.parentId === viewerLevelId) return
@@ -809,6 +915,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
809
915
  }, [viewerLevelId, draftNode, asset])
810
916
 
811
917
  useFrame((_, delta) => {
918
+ if (!asset) return
812
919
  if (!draftNode.current) return
813
920
  const mesh = sceneRegistry.nodes.get(draftNode.current.id)
814
921
  if (!mesh) return
@@ -850,9 +957,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
850
957
  const initialDraft = draftNode.current
851
958
  const dims = initialDraft
852
959
  ? getScaledDimensions(initialDraft)
853
- : (config.asset.dimensions ?? DEFAULT_DIMENSIONS)
960
+ : (config.asset?.dimensions ?? DEFAULT_DIMENSIONS)
854
961
  const initialBoxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
855
- const wallSideZOffset = config.asset.attachTo === 'wall-side' ? -dims[2] / 2 : 0
962
+ const wallSideZOffset = config.asset?.attachTo === 'wall-side' ? -dims[2] / 2 : 0
856
963
  initialBoxGeometry.translate(0, dims[1] / 2, wallSideZOffset)
857
964
 
858
965
  // Base plane geometry (colored rectangle on the ground)
@@ -1,7 +1,9 @@
1
1
  import {
2
2
  type AnyNodeId,
3
3
  emitter,
4
+ type FenceNode,
4
5
  type GridEvent,
6
+ type LevelNode,
5
7
  type RoofNode,
6
8
  type RoofSegmentNode,
7
9
  type StairNode,
@@ -9,13 +11,16 @@ import {
9
11
  sceneRegistry,
10
12
  useLiveTransforms,
11
13
  useScene,
14
+ type WallNode,
12
15
  } from '@pascal-app/core'
13
16
  import { useViewer } from '@pascal-app/viewer'
14
17
  import { useCallback, useEffect, useRef, useState } from 'react'
15
18
  import * as THREE from 'three'
16
19
  import { sfxEmitter } from '../../../lib/sfx-bus'
17
20
  import useEditor from '../../../store/use-editor'
21
+ import { snapFenceDraftPoint } from '../fence/fence-drafting'
18
22
  import { CursorSphere } from '../shared/cursor-sphere'
23
+ import type { WallPlanPoint } from '../wall/wall-drafting'
19
24
 
20
25
  export const MoveRoofTool: React.FC<{
21
26
  node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode
@@ -29,9 +34,13 @@ export const MoveRoofTool: React.FC<{
29
34
  const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => {
30
35
  const obj = sceneRegistry.nodes.get(movingNode.id)
31
36
  if (obj) {
32
- const pos = new THREE.Vector3()
33
- obj.getWorldPosition(pos)
34
- return [pos.x, pos.y, pos.z]
37
+ const worldPos = obj.getWorldPosition(new THREE.Vector3())
38
+ // Cursor renders inside the building-local ToolManager group, so convert
39
+ // world building-local to honor any building rotation.
40
+ const buildingId = useViewer.getState().selection.buildingId
41
+ const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
42
+ if (buildingObj) buildingObj.worldToLocal(worldPos)
43
+ return [worldPos.x, worldPos.y, worldPos.z]
35
44
  }
36
45
  // Fallback if not registered (e.g. newly created duplicate without mesh yet)
37
46
  if (
@@ -114,10 +123,55 @@ export const MoveRoofTool: React.FC<{
114
123
  }
115
124
  }
116
125
 
117
- const computeLocal = (gridX: number, gridZ: number, y: number): [number, number] => {
118
- let localX = gridX
119
- let localZ = gridZ
126
+ const resolveLevelId = () => {
127
+ if (movingNode.type === 'roof' || movingNode.type === 'stair') {
128
+ return movingNode.parentId ?? null
129
+ }
120
130
 
131
+ if (
132
+ (movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') &&
133
+ movingNode.parentId
134
+ ) {
135
+ const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId]
136
+ return parentNode && 'parentId' in parentNode ? (parentNode.parentId ?? null) : null
137
+ }
138
+
139
+ return null
140
+ }
141
+
142
+ const levelId = resolveLevelId()
143
+ const levelNode =
144
+ levelId && useScene.getState().nodes[levelId as AnyNodeId]?.type === 'level'
145
+ ? (useScene.getState().nodes[levelId as AnyNodeId] as LevelNode)
146
+ : null
147
+ const levelChildren = levelNode?.children ?? []
148
+ const levelWalls = levelChildren
149
+ .map((childId) => useScene.getState().nodes[childId as AnyNodeId])
150
+ .filter((node): node is WallNode => node?.type === 'wall')
151
+ const levelFences = levelChildren
152
+ .map((childId) => useScene.getState().nodes[childId as AnyNodeId])
153
+ .filter((node): node is FenceNode => node?.type === 'fence')
154
+ const buildingId = useViewer.getState().selection.buildingId
155
+ const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
156
+
157
+ const localToWorldPoint = (localPoint: WallPlanPoint, y: number): [number, number, number] => {
158
+ if (buildingObj) {
159
+ const worldPoint = buildingObj.localToWorld(new THREE.Vector3(localPoint[0], y, localPoint[1]))
160
+ return [worldPoint.x, worldPoint.y, worldPoint.z]
161
+ }
162
+
163
+ return [localPoint[0], y, localPoint[1]]
164
+ }
165
+
166
+ const computeLocal = (
167
+ gridX: number,
168
+ gridZ: number,
169
+ y: number,
170
+ buildingLocalX: number,
171
+ buildingLocalZ: number,
172
+ ): [number, number] => {
173
+ // Segments have a transformed parent (stair/roof). Convert world → parent-local
174
+ // via Three.js hierarchy so the segment's stored position stays parent-relative.
121
175
  if (
122
176
  (movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') &&
123
177
  movingNode.parentId
@@ -128,37 +182,42 @@ export const MoveRoofTool: React.FC<{
128
182
  if (parentObj) {
129
183
  const worldVec = new THREE.Vector3(gridX, y, gridZ)
130
184
  parentObj.worldToLocal(worldVec)
131
- localX = worldVec.x
132
- localZ = worldVec.z
133
- } else {
134
- const dx = gridX - (parentNode.position[0] as number)
135
- const dz = gridZ - (parentNode.position[2] as number)
136
- const angle = -(parentNode.rotation as number)
137
- localX = dx * Math.cos(angle) - dz * Math.sin(angle)
138
- localZ = dx * Math.sin(angle) + dz * Math.cos(angle)
185
+ return [worldVec.x, worldVec.z]
139
186
  }
187
+ const dx = gridX - (parentNode.position[0] as number)
188
+ const dz = gridZ - (parentNode.position[2] as number)
189
+ const angle = -(parentNode.rotation as number)
190
+ return [
191
+ dx * Math.cos(angle) - dz * Math.sin(angle),
192
+ dx * Math.sin(angle) + dz * Math.cos(angle),
193
+ ]
140
194
  }
141
195
  }
142
196
 
143
- return [localX, localZ]
197
+ // Stair/roof live directly in the level — their stored position is building-local.
198
+ // event.localPosition is already building-local, so using it handles building rotation.
199
+ return [buildingLocalX, buildingLocalZ]
144
200
  }
145
201
 
146
202
  const onGridMove = (event: GridEvent) => {
147
- const gridX = Math.round(event.position[0] * 2) / 2
148
- const gridZ = Math.round(event.position[2] * 2) / 2
149
203
  const y = event.position[1]
150
204
 
151
- if (
152
- previousGridPosRef.current &&
153
- (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
154
- ) {
205
+ const snappedLocal = snapFenceDraftPoint({
206
+ point: [event.localPosition[0], event.localPosition[2]],
207
+ walls: levelWalls,
208
+ fences: levelFences,
209
+ })
210
+ const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y)
211
+
212
+ if (previousGridPosRef.current && (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])) {
155
213
  sfxEmitter.emit('sfx:grid-snap')
156
214
  }
157
215
 
158
216
  previousGridPosRef.current = [gridX, gridZ]
159
- setCursorWorldPos([gridX, y, gridZ])
217
+ const [lx, lz] = snappedLocal
218
+ setCursorWorldPos([lx, event.localPosition[1], lz])
160
219
 
161
- const [localX, localZ] = computeLocal(gridX, gridZ, y)
220
+ const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
162
221
 
163
222
  // Directly update the Three.js mesh — no store update during drag
164
223
  const mesh = sceneRegistry.nodes.get(movingNode.id)
@@ -175,11 +234,16 @@ export const MoveRoofTool: React.FC<{
175
234
  }
176
235
 
177
236
  const onGridClick = (event: GridEvent) => {
178
- const gridX = Math.round(event.position[0] * 2) / 2
179
- const gridZ = Math.round(event.position[2] * 2) / 2
180
237
  const y = event.position[1]
238
+ const snappedLocal = snapFenceDraftPoint({
239
+ point: [event.localPosition[0], event.localPosition[2]],
240
+ walls: levelWalls,
241
+ fences: levelFences,
242
+ })
243
+ const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y)
244
+ const [lx, lz] = snappedLocal
181
245
 
182
- const [localX, localZ] = computeLocal(gridX, gridZ, y)
246
+ const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
183
247
 
184
248
  wasCommitted = true
185
249
 
@@ -173,9 +173,9 @@ export const RoofTool: React.FC = () => {
173
173
  const onGridMove = (event: GridEvent) => {
174
174
  if (!cursorRef.current) return
175
175
 
176
- const gridX = Math.round(event.position[0] * 2) / 2
177
- const gridZ = Math.round(event.position[2] * 2) / 2
178
- const y = event.position[1]
176
+ const gridX = Math.round(event.localPosition[0] * 2) / 2
177
+ const gridZ = Math.round(event.localPosition[2] * 2) / 2
178
+ const y = event.localPosition[1]
179
179
 
180
180
  const cursorPosition: [number, number, number] = [gridX, y, gridZ]
181
181
  const gridY = y + GRID_OFFSET
@@ -206,9 +206,9 @@ export const RoofTool: React.FC = () => {
206
206
  const onGridClick = (event: GridEvent) => {
207
207
  if (!currentLevelId) return
208
208
 
209
- const gridX = Math.round(event.position[0] * 2) / 2
210
- const gridZ = Math.round(event.position[2] * 2) / 2
211
- const y = event.position[1]
209
+ const gridX = Math.round(event.localPosition[0] * 2) / 2
210
+ const gridZ = Math.round(event.localPosition[2] * 2) / 2
211
+ const y = event.localPosition[1]
212
212
 
213
213
  if (corner1Ref.current) {
214
214
  const roofId = commitRoofPlacement(
@@ -197,7 +197,7 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
197
197
  const node = nodes[childId as AnyNodeId]
198
198
  if (!node) continue
199
199
 
200
- if (node.type === 'wall') {
200
+ if (node.type === 'wall' || node.type === 'fence') {
201
201
  const wall = node as WallNode
202
202
  if (
203
203
  segmentIntersectsBounds(wall.start[0], wall.start[1], wall.end[0], wall.end[1], bounds)
@@ -205,7 +205,7 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
205
205
  result.push(wall.id)
206
206
  }
207
207
  // Check wall children (doors/windows)
208
- for (const itemId of wall.children) {
208
+ for (const itemId of Array.isArray(wall.children) ? wall.children : []) {
209
209
  const child = nodes[itemId as AnyNodeId]
210
210
  if (!child) continue
211
211
  if (