@pascal-app/editor 0.4.0 → 0.5.1

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 (61) hide show
  1. package/package.json +5 -5
  2. package/src/components/editor/floating-action-menu.tsx +101 -29
  3. package/src/components/editor/floating-building-action-menu.tsx +69 -0
  4. package/src/components/editor/floorplan-panel.tsx +31 -13
  5. package/src/components/editor/index.tsx +219 -167
  6. package/src/components/editor/node-action-menu.tsx +26 -10
  7. package/src/components/editor/selection-manager.tsx +38 -2
  8. package/src/components/editor/thumbnail-generator.tsx +245 -64
  9. package/src/components/systems/stair/stair-edit-system.tsx +27 -5
  10. package/src/components/tools/building/move-building-tool.tsx +157 -0
  11. package/src/components/tools/door/door-math.ts +1 -1
  12. package/src/components/tools/door/door-tool.tsx +19 -7
  13. package/src/components/tools/door/move-door-tool.tsx +17 -8
  14. package/src/components/tools/fence/fence-drafting.ts +125 -0
  15. package/src/components/tools/fence/fence-tool.tsx +190 -0
  16. package/src/components/tools/fence/move-fence-tool.tsx +223 -0
  17. package/src/components/tools/item/item-tool.tsx +3 -3
  18. package/src/components/tools/item/move-tool.tsx +7 -0
  19. package/src/components/tools/item/placement-strategies.ts +15 -7
  20. package/src/components/tools/item/use-placement-coordinator.tsx +89 -14
  21. package/src/components/tools/roof/move-roof-tool.tsx +5 -2
  22. package/src/components/tools/roof/roof-tool.tsx +6 -6
  23. package/src/components/tools/select/box-select-tool.tsx +2 -2
  24. package/src/components/tools/shared/polygon-editor.tsx +2 -2
  25. package/src/components/tools/slab/slab-tool.tsx +4 -4
  26. package/src/components/tools/stair/stair-defaults.ts +10 -0
  27. package/src/components/tools/stair/stair-tool.tsx +29 -6
  28. package/src/components/tools/tool-manager.tsx +42 -14
  29. package/src/components/tools/wall/wall-tool.tsx +19 -29
  30. package/src/components/tools/window/move-window-tool.tsx +17 -8
  31. package/src/components/tools/window/window-math.ts +1 -1
  32. package/src/components/tools/window/window-tool.tsx +19 -7
  33. package/src/components/tools/zone/zone-tool.tsx +7 -7
  34. package/src/components/ui/action-menu/structure-tools.tsx +1 -0
  35. package/src/components/ui/helpers/building-helper.tsx +32 -0
  36. package/src/components/ui/helpers/helper-manager.tsx +2 -0
  37. package/src/components/ui/panels/fence-panel.tsx +184 -0
  38. package/src/components/ui/panels/panel-manager.tsx +3 -0
  39. package/src/components/ui/panels/stair-panel.tsx +206 -33
  40. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +22 -15
  41. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +60 -52
  42. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +35 -24
  43. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +65 -0
  44. package/src/components/ui/sidebar/panels/site-panel/index.tsx +59 -40
  45. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
  46. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +59 -52
  47. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +27 -22
  48. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +66 -49
  49. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +35 -36
  50. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +66 -49
  51. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +11 -11
  52. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +17 -14
  53. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +57 -53
  54. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +35 -24
  55. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +22 -27
  56. package/src/components/viewer-overlay.tsx +1 -0
  57. package/src/hooks/use-auto-save.ts +3 -6
  58. package/src/hooks/use-contextual-tools.ts +10 -2
  59. package/src/hooks/use-grid-events.ts +13 -1
  60. package/src/hooks/use-keyboard.ts +4 -0
  61. package/src/store/use-editor.tsx +7 -0
@@ -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)
@@ -216,6 +233,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
216
233
  // Only update X and Z for cursor - useFrame will handle Y (slab elevation)
217
234
  cursorGroupRef.current.position.x = result.cursorPosition[0]
218
235
  cursorGroupRef.current.position.z = result.cursorPosition[2]
236
+ lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
219
237
 
220
238
  const draft = draftNode.current
221
239
  if (draft) draft.position = result.gridPosition
@@ -334,7 +352,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
334
352
  }
335
353
 
336
354
  gridPosition.current.set(...result.gridPosition)
337
- cursorGroupRef.current.position.set(...result.cursorPosition)
355
+ const wc = worldToBuildingLocal(...result.cursorPosition)
356
+ cursorGroupRef.current.position.set(wc.x, wc.y, wc.z)
338
357
  cursorGroupRef.current.rotation.y = result.cursorRotationY
339
358
 
340
359
  const draft = draftNode.current
@@ -486,7 +505,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
486
505
  event.stopPropagation()
487
506
 
488
507
  gridPosition.current.set(...result.gridPosition)
489
- cursorGroupRef.current.position.set(...result.cursorPosition)
508
+ lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
509
+ const ic = worldToBuildingLocal(...result.cursorPosition)
510
+ cursorGroupRef.current.position.set(ic.x, ic.y, ic.z)
490
511
  cursorGroupRef.current.rotation.y = result.cursorRotationY
491
512
 
492
513
  const draft = draftNode.current
@@ -511,14 +532,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
511
532
 
512
533
  event.stopPropagation()
513
534
 
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
535
+ // Transition back to floor using building-local position
536
+ const wx = Math.round(event.localPosition[0] * 2) / 2
537
+ const wz = Math.round(event.localPosition[2] * 2) / 2
517
538
  const floorPos: [number, number, number] = [wx, 0, wz]
518
539
 
519
540
  Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null })
520
541
  gridPosition.current.set(wx, 0, wz)
521
- cursorGroupRef.current.position.set(wx, event.position[1], wz)
542
+ cursorGroupRef.current.position.x = wx
543
+ cursorGroupRef.current.position.z = wz
522
544
 
523
545
  const draft = draftNode.current
524
546
  if (draft) {
@@ -603,7 +625,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
603
625
  }
604
626
 
605
627
  gridPosition.current.set(...result.gridPosition)
606
- cursorGroupRef.current.position.set(...result.cursorPosition)
628
+ lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
629
+ const cc = worldToBuildingLocal(...result.cursorPosition)
630
+ cursorGroupRef.current.position.set(cc.x, cc.y, cc.z)
607
631
 
608
632
  revalidate()
609
633
 
@@ -677,6 +701,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
677
701
 
678
702
  const ROTATION_STEP = Math.PI / 2
679
703
  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
+
680
709
  if (event.key === 'Shift') {
681
710
  shiftFreeRef.current = true
682
711
  revalidate()
@@ -702,11 +731,55 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
702
731
  const mesh = sceneRegistry.nodes.get(draft.id)
703
732
  if (mesh) mesh.rotation.y = newRotationY
704
733
 
705
- // Update live transform rotation for 2D floorplan
734
+ // Re-snap position immediately with updated rotation (dimX/dimZ may swap at 90°)
735
+ const surface = placementState.current.surface
736
+ if (surface === 'floor' || surface === 'ceiling') {
737
+ const dims = getScaledDimensions(draft)
738
+ const [dimX, , dimZ] = dims
739
+ const swapDims = Math.abs(Math.sin(newRotationY)) > 0.9
740
+ const x = snapToGrid(lastRawPos.current.x, swapDims ? dimZ : dimX)
741
+ const z = snapToGrid(lastRawPos.current.z, swapDims ? dimX : dimZ)
742
+ gridPosition.current.set(x, gridPosition.current.y, z)
743
+ draft.position = [x, gridPosition.current.y, z]
744
+ cursorGroupRef.current.position.x = x
745
+ cursorGroupRef.current.position.z = z
746
+ if (mesh) {
747
+ mesh.position.x = x
748
+ mesh.position.z = z
749
+ }
750
+ } else if (surface === 'item-surface' && placementState.current.surfaceItemId) {
751
+ const surfaceMesh = sceneRegistry.nodes.get(placementState.current.surfaceItemId)
752
+ if (surfaceMesh) {
753
+ const localPos = surfaceMesh.worldToLocal(lastRawPos.current.clone())
754
+ const dims = getScaledDimensions(draft)
755
+ const [dimX, , dimZ] = dims
756
+ const swapDims = Math.abs(Math.sin(newRotationY)) > 0.9
757
+ const x = snapToGrid(localPos.x, swapDims ? dimZ : dimX)
758
+ const z = snapToGrid(localPos.z, swapDims ? dimX : dimZ)
759
+ const y = gridPosition.current.y
760
+ gridPosition.current.set(x, y, z)
761
+ draft.position = [x, y, z]
762
+ const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
763
+ const localSnapped = worldToBuildingLocal(
764
+ worldSnapped.x,
765
+ worldSnapped.y,
766
+ worldSnapped.z,
767
+ )
768
+ cursorGroupRef.current.position.set(localSnapped.x, localSnapped.y, localSnapped.z)
769
+ if (mesh) mesh.position.set(x, y, z)
770
+ }
771
+ }
772
+
773
+ // Update live transform for 2D floorplan with post-snap position
706
774
  const currentLive = useLiveTransforms.getState().get(draft.id)
707
775
  if (currentLive) {
708
776
  useLiveTransforms.getState().set(draft.id, {
709
777
  ...currentLive,
778
+ position: [
779
+ cursorGroupRef.current.position.x,
780
+ cursorGroupRef.current.position.y,
781
+ cursorGroupRef.current.position.z,
782
+ ] as [number, number, number],
710
783
  rotation: newRotationY,
711
784
  })
712
785
  }
@@ -801,6 +874,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
801
874
  // Wall/ceiling items are managed by their own surface entry events (ensureDraft / reparent).
802
875
  const viewerLevelId = useViewer((s) => s.selection.levelId)
803
876
  useEffect(() => {
877
+ if (!asset) return
804
878
  const draft = draftNode.current
805
879
  if (!(draft && viewerLevelId) || asset.attachTo) return
806
880
  if (draft.parentId === viewerLevelId) return
@@ -809,6 +883,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
809
883
  }, [viewerLevelId, draftNode, asset])
810
884
 
811
885
  useFrame((_, delta) => {
886
+ if (!asset) return
812
887
  if (!draftNode.current) return
813
888
  const mesh = sceneRegistry.nodes.get(draftNode.current.id)
814
889
  if (!mesh) return
@@ -850,9 +925,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
850
925
  const initialDraft = draftNode.current
851
926
  const dims = initialDraft
852
927
  ? getScaledDimensions(initialDraft)
853
- : (config.asset.dimensions ?? DEFAULT_DIMENSIONS)
928
+ : (config.asset?.dimensions ?? DEFAULT_DIMENSIONS)
854
929
  const initialBoxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
855
- const wallSideZOffset = config.asset.attachTo === 'wall-side' ? -dims[2] / 2 : 0
930
+ const wallSideZOffset = config.asset?.attachTo === 'wall-side' ? -dims[2] / 2 : 0
856
931
  initialBoxGeometry.translate(0, dims[1] / 2, wallSideZOffset)
857
932
 
858
933
  // Base plane geometry (colored rectangle on the ground)
@@ -156,7 +156,10 @@ export const MoveRoofTool: React.FC<{
156
156
  }
157
157
 
158
158
  previousGridPosRef.current = [gridX, gridZ]
159
- setCursorWorldPos([gridX, y, gridZ])
159
+ // Cursor is inside the building-local ToolManager group — use local position
160
+ const lx = Math.round(event.localPosition[0] * 2) / 2
161
+ const lz = Math.round(event.localPosition[2] * 2) / 2
162
+ setCursorWorldPos([lx, event.localPosition[1], lz])
160
163
 
161
164
  const [localX, localZ] = computeLocal(gridX, gridZ, y)
162
165
 
@@ -175,7 +178,7 @@ export const MoveRoofTool: React.FC<{
175
178
  }
176
179
 
177
180
  const onGridClick = (event: GridEvent) => {
178
- const gridX = Math.round(event.position[0] * 2) / 2
181
+ const gridX = Math.round(event.position[0] * 2) / 2 // world, for computeLocal
179
182
  const gridZ = Math.round(event.position[2] * 2) / 2
180
183
  const y = event.position[1]
181
184
 
@@ -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 (
@@ -139,8 +139,8 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
139
139
  // Listen to grid:move events to track cursor position
140
140
  useEffect(() => {
141
141
  const onGridMove = (event: GridEvent) => {
142
- const gridX = Math.round(event.position[0] * 2) / 2
143
- const gridZ = Math.round(event.position[2] * 2) / 2
142
+ const gridX = Math.round(event.localPosition[0] * 2) / 2
143
+ const gridZ = Math.round(event.localPosition[2] * 2) / 2
144
144
  const newPosition: [number, number] = [gridX, gridZ]
145
145
 
146
146
  // Play snap sound when cursor moves to a new grid cell during drag
@@ -86,12 +86,12 @@ export const SlabTool: React.FC = () => {
86
86
  const onGridMove = (event: GridEvent) => {
87
87
  if (!cursorRef.current) return
88
88
 
89
- const gridX = Math.round(event.position[0] * 2) / 2
90
- const gridZ = Math.round(event.position[2] * 2) / 2
89
+ const gridX = Math.round(event.localPosition[0] * 2) / 2
90
+ const gridZ = Math.round(event.localPosition[2] * 2) / 2
91
91
  const gridPosition: [number, number] = [gridX, gridZ]
92
92
 
93
93
  setCursorPosition(gridPosition)
94
- setLevelY(event.position[1])
94
+ setLevelY(event.localPosition[1])
95
95
 
96
96
  // Calculate snapped display position (bypass snap when Shift is held)
97
97
  const lastPoint = points[points.length - 1]
@@ -112,7 +112,7 @@ export const SlabTool: React.FC = () => {
112
112
  }
113
113
 
114
114
  previousSnappedPointRef.current = displayPoint
115
- cursorRef.current.position.set(displayPoint[0], event.position[1], displayPoint[1])
115
+ cursorRef.current.position.set(displayPoint[0], event.localPosition[1], displayPoint[1])
116
116
  }
117
117
 
118
118
  const onGridClick = (_event: GridEvent) => {
@@ -1,3 +1,4 @@
1
+ export const DEFAULT_STAIR_TYPE = 'straight' as const
1
2
  export const DEFAULT_STAIR_WIDTH = 1.0
2
3
  export const DEFAULT_STAIR_LENGTH = 3.0
3
4
  export const DEFAULT_STAIR_HEIGHT = 2.5
@@ -5,3 +6,12 @@ export const DEFAULT_STAIR_STEP_COUNT = 10
5
6
  export const DEFAULT_STAIR_ATTACHMENT_SIDE = 'front' as const
6
7
  export const DEFAULT_STAIR_FILL_TO_FLOOR = true
7
8
  export const DEFAULT_STAIR_THICKNESS = 0.25
9
+ export const DEFAULT_CURVED_STAIR_INNER_RADIUS = 0.9
10
+ export const DEFAULT_CURVED_STAIR_SWEEP_ANGLE = Math.PI / 2
11
+ export const DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE = (400 * Math.PI) / 180
12
+ export const DEFAULT_SPIRAL_TOP_LANDING_MODE = 'none' as const
13
+ export const DEFAULT_SPIRAL_TOP_LANDING_DEPTH = 0.9
14
+ export const DEFAULT_SPIRAL_SHOW_CENTER_COLUMN = true
15
+ export const DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS = true
16
+ export const DEFAULT_STAIR_RAILING_MODE = 'right' as const
17
+ export const DEFAULT_STAIR_RAILING_HEIGHT = 0.92
@@ -13,12 +13,21 @@ import * as THREE from 'three'
13
13
  import { sfxEmitter } from '../../../lib/sfx-bus'
14
14
  import { CursorSphere } from '../shared/cursor-sphere'
15
15
  import {
16
+ DEFAULT_CURVED_STAIR_INNER_RADIUS,
17
+ DEFAULT_CURVED_STAIR_SWEEP_ANGLE,
18
+ DEFAULT_SPIRAL_SHOW_CENTER_COLUMN,
19
+ DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS,
20
+ DEFAULT_SPIRAL_TOP_LANDING_DEPTH,
21
+ DEFAULT_SPIRAL_TOP_LANDING_MODE,
16
22
  DEFAULT_STAIR_ATTACHMENT_SIDE,
17
23
  DEFAULT_STAIR_FILL_TO_FLOOR,
18
24
  DEFAULT_STAIR_HEIGHT,
19
25
  DEFAULT_STAIR_LENGTH,
26
+ DEFAULT_STAIR_RAILING_HEIGHT,
27
+ DEFAULT_STAIR_RAILING_MODE,
20
28
  DEFAULT_STAIR_STEP_COUNT,
21
29
  DEFAULT_STAIR_THICKNESS,
30
+ DEFAULT_STAIR_TYPE,
22
31
  DEFAULT_STAIR_WIDTH,
23
32
  } from './stair-defaults'
24
33
 
@@ -88,6 +97,20 @@ function commitStairPlacement(
88
97
  name,
89
98
  position,
90
99
  rotation,
100
+ stairType: DEFAULT_STAIR_TYPE,
101
+ width: DEFAULT_STAIR_WIDTH,
102
+ totalRise: DEFAULT_STAIR_HEIGHT,
103
+ stepCount: DEFAULT_STAIR_STEP_COUNT,
104
+ thickness: DEFAULT_STAIR_THICKNESS,
105
+ fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR,
106
+ innerRadius: DEFAULT_CURVED_STAIR_INNER_RADIUS,
107
+ sweepAngle: DEFAULT_CURVED_STAIR_SWEEP_ANGLE,
108
+ topLandingMode: DEFAULT_SPIRAL_TOP_LANDING_MODE,
109
+ topLandingDepth: DEFAULT_SPIRAL_TOP_LANDING_DEPTH,
110
+ showCenterColumn: DEFAULT_SPIRAL_SHOW_CENTER_COLUMN,
111
+ showStepSupports: DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS,
112
+ railingHeight: DEFAULT_STAIR_RAILING_HEIGHT,
113
+ railingMode: DEFAULT_STAIR_RAILING_MODE,
91
114
  children: [segment.id],
92
115
  })
93
116
 
@@ -116,9 +139,9 @@ export const StairTool: React.FC = () => {
116
139
  if (previewRef.current) previewRef.current.rotation.y = 0
117
140
 
118
141
  const onGridMove = (event: GridEvent) => {
119
- const gridX = Math.round(event.position[0] * 2) / 2
120
- const gridZ = Math.round(event.position[2] * 2) / 2
121
- const y = event.position[1]
142
+ const gridX = Math.round(event.localPosition[0] * 2) / 2
143
+ const gridZ = Math.round(event.localPosition[2] * 2) / 2
144
+ const y = event.localPosition[1]
122
145
 
123
146
  if (cursorRef.current) {
124
147
  cursorRef.current.position.set(gridX, y + GRID_OFFSET, gridZ)
@@ -141,9 +164,9 @@ export const StairTool: React.FC = () => {
141
164
  const onGridClick = (event: GridEvent) => {
142
165
  if (!currentLevelId) return
143
166
 
144
- const gridX = Math.round(event.position[0] * 2) / 2
145
- const gridZ = Math.round(event.position[2] * 2) / 2
146
- const y = event.position[1]
167
+ const gridX = Math.round(event.localPosition[0] * 2) / 2
168
+ const gridZ = Math.round(event.localPosition[2] * 2) / 2
169
+ const y = event.localPosition[1]
147
170
 
148
171
  commitStairPlacement(currentLevelId, [gridX, y, gridZ], rotationRef.current)
149
172
  }
@@ -1,10 +1,17 @@
1
- import { type AnyNodeId, type CeilingNode, type SlabNode, useScene } from '@pascal-app/core'
1
+ import {
2
+ type AnyNodeId,
3
+ type BuildingNode,
4
+ type CeilingNode,
5
+ type SlabNode,
6
+ useScene,
7
+ } from '@pascal-app/core'
2
8
  import { useViewer } from '@pascal-app/viewer'
3
9
  import useEditor, { type Phase, type Tool } from '../../store/use-editor'
4
10
  import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
5
11
  import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
6
12
  import { CeilingTool } from './ceiling/ceiling-tool'
7
13
  import { DoorTool } from './door/door-tool'
14
+ import { FenceTool } from './fence/fence-tool'
8
15
  import { ItemTool } from './item/item-tool'
9
16
  import { MoveTool } from './item/move-tool'
10
17
  import { RoofTool } from './roof/roof-tool'
@@ -24,6 +31,7 @@ const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
24
31
  },
25
32
  structure: {
26
33
  wall: WallTool,
34
+ fence: FenceTool,
27
35
  slab: SlabTool,
28
36
  ceiling: CeilingTool,
29
37
  roof: RoofTool,
@@ -45,9 +53,18 @@ export const ToolManager: React.FC = () => {
45
53
  const movingNode = useEditor((state) => state.movingNode)
46
54
  const editingHole = useEditor((state) => state.editingHole)
47
55
  const selectedZoneId = useViewer((state) => state.selection.zoneId)
56
+ const buildingId = useViewer((state) => state.selection.buildingId)
48
57
  const selectedIds = useViewer((state) => state.selection.selectedIds)
49
58
  const nodes = useScene((state) => state.nodes)
50
59
 
60
+ // Building transform for the local group — all building-relative tools live inside this group
61
+ // so their cursor positions and committed data are naturally in building-local space.
62
+ const building = buildingId
63
+ ? (nodes[buildingId as AnyNodeId] as BuildingNode | undefined)
64
+ : undefined
65
+ const buildingPosition = building?.position ?? [0, 0, 0]
66
+ const buildingRotation = building?.rotation ?? [0, 0, 0]
67
+
51
68
  // Check if a slab is selected
52
69
  const selectedSlabId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'slab') as
53
70
  | SlabNode['id']
@@ -102,19 +119,30 @@ export const ToolManager: React.FC = () => {
102
119
  return (
103
120
  <>
104
121
  {showSiteBoundaryEditor && <SiteBoundaryEditor />}
105
- {showZoneBoundaryEditor && selectedZoneId && <ZoneBoundaryEditor zoneId={selectedZoneId} />}
106
- {showSlabBoundaryEditor && selectedSlabId && <SlabBoundaryEditor slabId={selectedSlabId} />}
107
- {showSlabHoleEditor && selectedSlabId && editingHole && (
108
- <SlabHoleEditor holeIndex={editingHole.holeIndex} slabId={selectedSlabId} />
109
- )}
110
- {showCeilingBoundaryEditor && selectedCeilingId && (
111
- <CeilingBoundaryEditor ceilingId={selectedCeilingId} />
112
- )}
113
- {showCeilingHoleEditor && selectedCeilingId && editingHole && (
114
- <CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
115
- )}
116
- {movingNode && <MoveTool />}
117
- {!movingNode && BuildToolComponent && <BuildToolComponent />}
122
+ {/* World-space tools: site boundary and building movement operate in world coordinates */}
123
+ {movingNode?.type === 'building' && <MoveTool />}
124
+
125
+ {/* Building-local group: all other tools are relative to the selected building.
126
+ Cursor visuals set positions in building-local space; this group applies the
127
+ building's world transform so they render at the correct world position. */}
128
+ <group
129
+ position={buildingPosition as [number, number, number]}
130
+ rotation={buildingRotation as [number, number, number]}
131
+ >
132
+ {showZoneBoundaryEditor && selectedZoneId && <ZoneBoundaryEditor zoneId={selectedZoneId} />}
133
+ {showSlabBoundaryEditor && selectedSlabId && <SlabBoundaryEditor slabId={selectedSlabId} />}
134
+ {showSlabHoleEditor && selectedSlabId && editingHole && (
135
+ <SlabHoleEditor holeIndex={editingHole.holeIndex} slabId={selectedSlabId} />
136
+ )}
137
+ {showCeilingBoundaryEditor && selectedCeilingId && (
138
+ <CeilingBoundaryEditor ceilingId={selectedCeilingId} />
139
+ )}
140
+ {showCeilingHoleEditor && selectedCeilingId && editingHole && (
141
+ <CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
142
+ )}
143
+ {movingNode && movingNode.type !== 'building' && <MoveTool />}
144
+ {!movingNode && BuildToolComponent && <BuildToolComponent />}
145
+ </group>
118
146
  </>
119
147
  )
120
148
  }
@@ -78,31 +78,28 @@ export const WallTool: React.FC = () => {
78
78
  let gridPosition: WallPlanPoint = [0, 0]
79
79
  let previousWallEnd: [number, number] | null = null
80
80
 
81
+ // All positions are building-local: this tool is inside the ToolManager building group,
82
+ // so local coords are used for both data and visual positioning.
81
83
  const onGridMove = (event: GridEvent) => {
82
84
  if (!(cursorRef.current && wallPreviewRef.current)) return
83
85
 
84
86
  const walls = getCurrentLevelWalls()
85
- const cursorPoint: WallPlanPoint = [event.position[0], event.position[2]]
86
- gridPosition = snapWallDraftPoint({
87
- point: cursorPoint,
88
- walls,
89
- })
87
+ // event.localPosition is building-local consistent with stored wall start/end
88
+ const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
89
+ gridPosition = snapWallDraftPoint({ point: localPoint, walls })
90
90
 
91
91
  if (buildingState.current === 1) {
92
- const snappedPoint = snapWallDraftPoint({
93
- point: cursorPoint,
92
+ const snappedLocal = snapWallDraftPoint({
93
+ point: localPoint,
94
94
  walls,
95
95
  start: [startingPoint.current.x, startingPoint.current.z],
96
96
  angleSnap: !shiftPressed.current,
97
97
  })
98
- const snapped = new Vector3(snappedPoint[0], event.position[1], snappedPoint[1])
99
- endingPoint.current.copy(snapped)
100
-
101
- // Position the cursor at the end of the wall being drawn
102
- cursorRef.current.position.set(snapped.x, snapped.y, snapped.z)
98
+ endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
99
+ cursorRef.current.position.copy(endingPoint.current)
103
100
 
104
101
  // Play snap sound only when the actual wall end position changes
105
- const currentWallEnd: [number, number] = [endingPoint.current.x, endingPoint.current.z]
102
+ const currentWallEnd: [number, number] = [snappedLocal[0], snappedLocal[1]]
106
103
  if (
107
104
  previousWallEnd &&
108
105
  (currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1])
@@ -111,43 +108,36 @@ export const WallTool: React.FC = () => {
111
108
  }
112
109
  previousWallEnd = currentWallEnd
113
110
 
114
- // Update wall preview geometry
115
111
  updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current)
116
112
  } else {
117
113
  // Not drawing a wall yet, show the snapped anchor point.
118
- cursorRef.current.position.set(gridPosition[0], event.position[1], gridPosition[1])
114
+ cursorRef.current.position.set(gridPosition[0], event.localPosition[1], gridPosition[1])
119
115
  }
120
116
  }
121
117
 
122
118
  const onGridClick = (event: GridEvent) => {
123
119
  const walls = getCurrentLevelWalls()
124
- const clickPoint: WallPlanPoint = [event.position[0], event.position[2]]
120
+ const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
125
121
 
126
122
  if (buildingState.current === 0) {
127
- const snappedStart = snapWallDraftPoint({
128
- point: clickPoint,
129
- walls,
130
- })
123
+ const snappedStart = snapWallDraftPoint({ point: localClick, walls })
131
124
  gridPosition = snappedStart
132
- startingPoint.current.set(snappedStart[0], event.position[1], snappedStart[1])
125
+ startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
133
126
  endingPoint.current.copy(startingPoint.current)
134
127
  buildingState.current = 1
135
128
  wallPreviewRef.current.visible = true
136
129
  } else if (buildingState.current === 1) {
137
130
  const snappedEnd = snapWallDraftPoint({
138
- point: clickPoint,
131
+ point: localClick,
139
132
  walls,
140
133
  start: [startingPoint.current.x, startingPoint.current.z],
141
134
  angleSnap: !shiftPressed.current,
142
135
  })
143
- endingPoint.current.set(snappedEnd[0], event.position[1], snappedEnd[1])
144
- const dx = endingPoint.current.x - startingPoint.current.x
145
- const dz = endingPoint.current.z - startingPoint.current.z
136
+ const dx = snappedEnd[0] - startingPoint.current.x
137
+ const dz = snappedEnd[1] - startingPoint.current.z
146
138
  if (dx * dx + dz * dz < 0.01 * 0.01) return
147
- createWallOnCurrentLevel(
148
- [startingPoint.current.x, startingPoint.current.z],
149
- [endingPoint.current.x, endingPoint.current.z],
150
- )
139
+ // Both start and end are building-local ✓
140
+ createWallOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd)
151
141
  wallPreviewRef.current.visible = false
152
142
  buildingState.current = 0
153
143
  }
@@ -185,17 +185,26 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
185
185
  movingWindowNode.height,
186
186
  )
187
187
 
188
- useScene.getState().updateNode(movingWindowNode.id, {
189
- position: [clampedX, clampedY, 0],
190
- rotation: [0, itemRotation, 0],
191
- side,
192
- parentId: event.node.id,
193
- wallId: event.node.id,
194
- })
195
-
196
188
  if (currentWallId !== event.node.id) {
189
+ // Wall changed mid-move: must updateNode to reparent
190
+ useScene.getState().updateNode(movingWindowNode.id, {
191
+ position: [clampedX, clampedY, 0],
192
+ rotation: [0, itemRotation, 0],
193
+ side,
194
+ parentId: event.node.id,
195
+ wallId: event.node.id,
196
+ })
197
197
  markWallDirty(currentWallId)
198
198
  currentWallId = event.node.id
199
+ } else {
200
+ // Same wall: update Three.js mesh directly to avoid store churn
201
+ // collectCutoutBrushes reads cutoutMesh.matrixWorld, not scene store positions
202
+ const windowMesh = sceneRegistry.nodes.get(movingWindowNode.id as AnyNodeId)
203
+ if (windowMesh) {
204
+ windowMesh.position.set(clampedX, clampedY, 0)
205
+ windowMesh.rotation.set(0, itemRotation, 0)
206
+ windowMesh.updateMatrixWorld(true)
207
+ }
199
208
  }
200
209
  markWallDirty(event.node.id)
201
210
 
@@ -77,7 +77,7 @@ export function hasWallChildOverlap(
77
77
  const newLeft = clampedX - halfW
78
78
  const newRight = clampedX + halfW
79
79
 
80
- for (const childId of wallNode.children) {
80
+ for (const childId of Array.isArray(wallNode.children) ? wallNode.children : []) {
81
81
  if (childId === ignoreId) continue
82
82
  const child = nodes[childId as AnyNodeId]
83
83
  if (!child) continue