@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
@@ -1,16 +1,19 @@
1
1
  import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
2
2
  import { createPortal } from '@react-three/fiber'
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
4
- import { BufferGeometry, Float32BufferAttribute, type Line } from 'three'
4
+ import { BufferGeometry, Float32BufferAttribute, type Line, type Object3D } from 'three'
5
5
  import { EDITOR_LAYER } from '../../../lib/constants'
6
6
  import { sfxEmitter } from '../../../lib/sfx-bus'
7
+ import { snapToHalf } from '../item/placement-math'
7
8
 
8
9
  const Y_OFFSET = 0.02
9
10
 
10
11
  type DragState = {
11
12
  isDragging: boolean
12
- vertexIndex: number
13
+ mode: 'vertex' | 'polygon'
14
+ vertexIndex: number | null
13
15
  initialPosition: [number, number]
16
+ initialPolygon: Array<[number, number]>
14
17
  pointerId: number
15
18
  }
16
19
 
@@ -23,6 +26,8 @@ export interface PolygonEditorProps {
23
26
  levelId?: string
24
27
  /** Height of the surface being edited (e.g. slab elevation). Handles adapt to this. */
25
28
  surfaceHeight?: number
29
+ /** Whether to show the center handle that moves the entire polygon. */
30
+ allowPolygonMove?: boolean
26
31
  }
27
32
 
28
33
  /**
@@ -38,9 +43,42 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
38
43
  minVertices = 3,
39
44
  levelId,
40
45
  surfaceHeight = 0,
46
+ allowPolygonMove = false,
41
47
  }) => {
42
- // Get level node from registry if levelId is provided
43
- const levelNode = levelId ? sceneRegistry.nodes.get(levelId) : null
48
+ const [levelNode, setLevelNode] = useState<Object3D | null>(() =>
49
+ levelId ? (sceneRegistry.nodes.get(levelId) ?? null) : null,
50
+ )
51
+
52
+ useEffect(() => {
53
+ if (!levelId) {
54
+ setLevelNode(null)
55
+ return
56
+ }
57
+
58
+ let frameId = 0
59
+
60
+ const resolveLevelNode = () => {
61
+ const nextLevelNode = sceneRegistry.nodes.get(levelId) ?? null
62
+ setLevelNode((currentLevelNode) => {
63
+ if (currentLevelNode === nextLevelNode) {
64
+ return currentLevelNode
65
+ }
66
+ return nextLevelNode
67
+ })
68
+
69
+ if (!nextLevelNode) {
70
+ frameId = window.requestAnimationFrame(resolveLevelNode)
71
+ }
72
+ }
73
+
74
+ resolveLevelNode()
75
+
76
+ return () => {
77
+ if (frameId) {
78
+ window.cancelAnimationFrame(frameId)
79
+ }
80
+ }
81
+ }, [levelId])
44
82
 
45
83
  // When using portal, edit at Y_OFFSET (local to level)
46
84
  // When not using portal, edit at world origin
@@ -75,6 +113,17 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
75
113
  // The polygon to display (preview during drag, or actual polygon)
76
114
  const displayPolygon = previewPolygon ?? polygon
77
115
 
116
+ const polygonCenter = useMemo(() => {
117
+ if (displayPolygon.length === 0) return [0, 0] as [number, number]
118
+ let sumX = 0
119
+ let sumZ = 0
120
+ for (const [x, z] of displayPolygon) {
121
+ sumX += x
122
+ sumZ += z
123
+ }
124
+ return [sumX / displayPolygon.length, sumZ / displayPolygon.length] as [number, number]
125
+ }, [displayPolygon])
126
+
78
127
  // Calculate midpoints for adding new vertices
79
128
  const midpoints = useMemo(() => {
80
129
  if (displayPolygon.length < 2) return []
@@ -139,8 +188,8 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
139
188
  // Listen to grid:move events to track cursor position
140
189
  useEffect(() => {
141
190
  const onGridMove = (event: GridEvent) => {
142
- const gridX = Math.round(event.position[0] * 2) / 2
143
- const gridZ = Math.round(event.position[2] * 2) / 2
191
+ const gridX = snapToHalf(event.localPosition[0])
192
+ const gridZ = snapToHalf(event.localPosition[2])
144
193
  const newPosition: [number, number] = [gridX, gridZ]
145
194
 
146
195
  // Play snap sound when cursor moves to a new grid cell during drag
@@ -158,7 +207,15 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
158
207
 
159
208
  // Update vertex position during drag
160
209
  if (dragState?.isDragging) {
161
- handleVertexDrag(dragState.vertexIndex, newPosition)
210
+ if (dragState.mode === 'vertex' && dragState.vertexIndex !== null) {
211
+ handleVertexDrag(dragState.vertexIndex, newPosition)
212
+ } else if (dragState.mode === 'polygon') {
213
+ const deltaX = newPosition[0] - dragState.initialPosition[0]
214
+ const deltaZ = newPosition[1] - dragState.initialPosition[1]
215
+ setPreviewPolygon(
216
+ dragState.initialPolygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number]),
217
+ )
218
+ }
162
219
  }
163
220
  }
164
221
 
@@ -257,7 +314,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
257
314
  {/* Vertex handles - blue cylinders that match surface height */}
258
315
  {displayPolygon.map(([x, z], index) => {
259
316
  const isHovered = hoveredVertex === index
260
- const isDragging = dragState?.vertexIndex === index
317
+ const isDragging = dragState?.mode === 'vertex' && dragState.vertexIndex === index
261
318
  const radius = 0.1
262
319
  const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)
263
320
 
@@ -282,8 +339,10 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
282
339
  e.stopPropagation()
283
340
  setDragState({
284
341
  isDragging: true,
342
+ mode: 'vertex',
285
343
  vertexIndex: index,
286
344
  initialPosition: [x!, z!],
345
+ initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
287
346
  pointerId: e.pointerId,
288
347
  })
289
348
  }}
@@ -305,6 +364,37 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
305
364
  )
306
365
  })}
307
366
 
367
+ {allowPolygonMove && (
368
+ <mesh
369
+ castShadow
370
+ layers={EDITOR_LAYER}
371
+ onClick={(e) => {
372
+ if (e.button !== 0) return
373
+ e.stopPropagation()
374
+ }}
375
+ onPointerDown={(e) => {
376
+ if (e.button !== 0) return
377
+ e.stopPropagation()
378
+ setDragState({
379
+ isDragging: true,
380
+ mode: 'polygon',
381
+ vertexIndex: null,
382
+ initialPosition: polygonCenter,
383
+ initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
384
+ pointerId: e.pointerId,
385
+ })
386
+ }}
387
+ position={[
388
+ polygonCenter[0],
389
+ editY + Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02) + 0.08,
390
+ polygonCenter[1],
391
+ ]}
392
+ >
393
+ <sphereGeometry args={[0.09, 20, 20]} />
394
+ <meshStandardMaterial color={dragState?.mode === 'polygon' ? '#22c55e' : '#f59e0b'} />
395
+ </mesh>
396
+ )}
397
+
308
398
  {/* Midpoint handles - smaller green cylinders for adding vertices (hidden while dragging) */}
309
399
  {!dragState &&
310
400
  midpoints.map(([x, z], index) => {
@@ -0,0 +1,182 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNodeId,
5
+ emitter,
6
+ type FenceNode,
7
+ type GridEvent,
8
+ type LevelNode,
9
+ type SlabNode,
10
+ useScene,
11
+ type WallNode,
12
+ } from '@pascal-app/core'
13
+ import { useViewer } from '@pascal-app/viewer'
14
+ import { useCallback, useEffect, useRef, useState } from 'react'
15
+ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
16
+ import { sfxEmitter } from '../../../lib/sfx-bus'
17
+ import useEditor from '../../../store/use-editor'
18
+ import { snapFenceDraftPoint } from '../fence/fence-drafting'
19
+ import { CursorSphere } from '../shared/cursor-sphere'
20
+
21
+ function translatePolygon(
22
+ polygon: Array<[number, number]>,
23
+ deltaX: number,
24
+ deltaZ: number,
25
+ ): Array<[number, number]> {
26
+ return polygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number])
27
+ }
28
+
29
+ function getPolygonCenter(polygon: Array<[number, number]>): [number, number] {
30
+ if (polygon.length === 0) return [0, 0]
31
+ let sumX = 0
32
+ let sumZ = 0
33
+ for (const [x, z] of polygon) {
34
+ sumX += x
35
+ sumZ += z
36
+ }
37
+ return [sumX / polygon.length, sumZ / polygon.length]
38
+ }
39
+
40
+ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
41
+ const activatedAtRef = useRef<number>(Date.now())
42
+ const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number]))
43
+ const originalHolesRef = useRef(
44
+ (node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
45
+ )
46
+ const dragAnchorRef = useRef<[number, number] | null>(null)
47
+ const previousGridPosRef = useRef<[number, number] | null>(null)
48
+ const previewRef = useRef<{
49
+ polygon: Array<[number, number]>
50
+ holes: Array<Array<[number, number]>>
51
+ } | null>(null)
52
+
53
+ const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
54
+ const center = getPolygonCenter(node.polygon)
55
+ return [center[0], 0, center[1]]
56
+ })
57
+
58
+ const exitMoveMode = useCallback(() => {
59
+ useEditor.getState().setMovingNode(null)
60
+ }, [])
61
+
62
+ useEffect(() => {
63
+ const originalPolygon = originalPolygonRef.current
64
+ const originalHoles = originalHolesRef.current
65
+ const levelNode =
66
+ node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level'
67
+ ? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode)
68
+ : null
69
+ const levelChildren = levelNode?.children ?? []
70
+ const levelWalls = levelChildren
71
+ .map((childId) => useScene.getState().nodes[childId as AnyNodeId])
72
+ .filter((child): child is WallNode => child?.type === 'wall')
73
+ const levelFences = levelChildren
74
+ .map((childId) => useScene.getState().nodes[childId as AnyNodeId])
75
+ .filter((child): child is FenceNode => child?.type === 'fence')
76
+
77
+ useScene.temporal.getState().pause()
78
+ let wasCommitted = false
79
+
80
+ const applyPreview = (
81
+ polygon: Array<[number, number]>,
82
+ holes: Array<Array<[number, number]>>,
83
+ ) => {
84
+ previewRef.current = { polygon, holes }
85
+ const center = getPolygonCenter(polygon)
86
+ setCursorLocalPos([center[0], 0, center[1]])
87
+ useScene.getState().updateNode(node.id, { polygon, holes })
88
+ useScene.getState().markDirty(node.id as AnyNodeId)
89
+ }
90
+
91
+ const restoreOriginal = () => {
92
+ useScene.getState().updateNode(node.id, {
93
+ holes: originalHoles,
94
+ polygon: originalPolygon,
95
+ })
96
+ useScene.getState().markDirty(node.id as AnyNodeId)
97
+ }
98
+
99
+ const onGridMove = (event: GridEvent) => {
100
+ const [localX, localZ] = snapFenceDraftPoint({
101
+ point: [event.localPosition[0], event.localPosition[2]],
102
+ walls: levelWalls,
103
+ fences: levelFences,
104
+ })
105
+
106
+ if (
107
+ previousGridPosRef.current &&
108
+ (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
109
+ ) {
110
+ sfxEmitter.emit('sfx:grid-snap')
111
+ }
112
+ previousGridPosRef.current = [localX, localZ]
113
+
114
+ const anchor = dragAnchorRef.current ?? [localX, localZ]
115
+ dragAnchorRef.current = anchor
116
+
117
+ const deltaX = localX - anchor[0]
118
+ const deltaZ = localZ - anchor[1]
119
+
120
+ applyPreview(
121
+ translatePolygon(originalPolygon, deltaX, deltaZ),
122
+ originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)),
123
+ )
124
+ }
125
+
126
+ const onGridClick = (event: GridEvent) => {
127
+ if (Date.now() - activatedAtRef.current < 150) {
128
+ event.nativeEvent?.stopPropagation?.()
129
+ return
130
+ }
131
+
132
+ const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles }
133
+
134
+ wasCommitted = true
135
+
136
+ // Restore original baseline while paused so the next resume+update
137
+ // registers as a single tracked change (undo reverts to original).
138
+ useScene.getState().updateNode(node.id, {
139
+ polygon: originalPolygon,
140
+ holes: originalHoles,
141
+ })
142
+
143
+ useScene.temporal.getState().resume()
144
+ useScene.getState().updateNode(node.id, preview)
145
+ useScene.getState().markDirty(node.id as AnyNodeId)
146
+ useScene.temporal.getState().pause()
147
+
148
+ sfxEmitter.emit('sfx:item-place')
149
+ useViewer.getState().setSelection({ selectedIds: [node.id] })
150
+ exitMoveMode()
151
+ event.nativeEvent?.stopPropagation?.()
152
+ }
153
+
154
+ const onCancel = () => {
155
+ restoreOriginal()
156
+ useViewer.getState().setSelection({ selectedIds: [node.id] })
157
+ useScene.temporal.getState().resume()
158
+ markToolCancelConsumed()
159
+ exitMoveMode()
160
+ }
161
+
162
+ emitter.on('grid:move', onGridMove)
163
+ emitter.on('grid:click', onGridClick)
164
+ emitter.on('tool:cancel', onCancel)
165
+
166
+ return () => {
167
+ if (!wasCommitted) {
168
+ restoreOriginal()
169
+ }
170
+ useScene.temporal.getState().resume()
171
+ emitter.off('grid:move', onGridMove)
172
+ emitter.off('grid:click', onGridClick)
173
+ emitter.off('tool:cancel', onCancel)
174
+ }
175
+ }, [exitMoveMode, node.id])
176
+
177
+ return (
178
+ <group>
179
+ <CursorSphere position={cursorLocalPos} showTooltip={false} />
180
+ </group>
181
+ )
182
+ }
@@ -36,6 +36,7 @@ export const SlabHoleEditor: React.FC<SlabHoleEditorProps> = ({ slabId, holeInde
36
36
 
37
37
  return (
38
38
  <PolygonEditor
39
+ allowPolygonMove
39
40
  color="#ef4444"
40
41
  levelId={resolveLevelId(slab, useScene.getState().nodes)} // red for holes
41
42
  minVertices={3}
@@ -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
 
@@ -84,10 +93,34 @@ function commitStairPlacement(
84
93
  position: [0, 0, 0],
85
94
  })
86
95
 
96
+ const sortedLevels = Object.values(nodes)
97
+ .filter((node): node is LevelNode => node.type === 'level')
98
+ .sort((left, right) => left.level - right.level)
99
+ const currentLevelIndex = sortedLevels.findIndex((level) => level.id === levelId)
100
+ const nextLevelId = sortedLevels[currentLevelIndex + 1]?.id ?? levelId
101
+
87
102
  const stair = StairNode.parse({
88
103
  name,
89
104
  position,
90
105
  rotation,
106
+ stairType: DEFAULT_STAIR_TYPE,
107
+ fromLevelId: levelId,
108
+ toLevelId: nextLevelId,
109
+ slabOpeningMode: 'destination',
110
+ openingOffset: 0.08,
111
+ width: DEFAULT_STAIR_WIDTH,
112
+ totalRise: DEFAULT_STAIR_HEIGHT,
113
+ stepCount: DEFAULT_STAIR_STEP_COUNT,
114
+ thickness: DEFAULT_STAIR_THICKNESS,
115
+ fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR,
116
+ innerRadius: DEFAULT_CURVED_STAIR_INNER_RADIUS,
117
+ sweepAngle: DEFAULT_CURVED_STAIR_SWEEP_ANGLE,
118
+ topLandingMode: DEFAULT_SPIRAL_TOP_LANDING_MODE,
119
+ topLandingDepth: DEFAULT_SPIRAL_TOP_LANDING_DEPTH,
120
+ showCenterColumn: DEFAULT_SPIRAL_SHOW_CENTER_COLUMN,
121
+ showStepSupports: DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS,
122
+ railingHeight: DEFAULT_STAIR_RAILING_HEIGHT,
123
+ railingMode: DEFAULT_STAIR_RAILING_MODE,
91
124
  children: [segment.id],
92
125
  })
93
126
 
@@ -116,9 +149,9 @@ export const StairTool: React.FC = () => {
116
149
  if (previewRef.current) previewRef.current.rotation.y = 0
117
150
 
118
151
  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]
152
+ const gridX = Math.round(event.localPosition[0] * 2) / 2
153
+ const gridZ = Math.round(event.localPosition[2] * 2) / 2
154
+ const y = event.localPosition[1]
122
155
 
123
156
  if (cursorRef.current) {
124
157
  cursorRef.current.position.set(gridX, y + GRID_OFFSET, gridZ)
@@ -141,11 +174,9 @@ export const StairTool: React.FC = () => {
141
174
  const onGridClick = (event: GridEvent) => {
142
175
  if (!currentLevelId) return
143
176
 
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]
147
-
148
- commitStairPlacement(currentLevelId, [gridX, y, gridZ], rotationRef.current)
177
+ const gridX = Math.round(event.localPosition[0] * 2) / 2
178
+ const gridZ = Math.round(event.localPosition[2] * 2) / 2
179
+ commitStairPlacement(currentLevelId, [gridX, 0, gridZ], rotationRef.current)
149
180
  }
150
181
 
151
182
  const onKeyDown = (event: KeyboardEvent) => {
@@ -1,10 +1,19 @@
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 { CurveFenceTool } from './fence/curve-fence-tool'
15
+ import { FenceTool } from './fence/fence-tool'
16
+ import { MoveFenceEndpointTool } from './fence/move-fence-endpoint-tool'
8
17
  import { ItemTool } from './item/item-tool'
9
18
  import { MoveTool } from './item/move-tool'
10
19
  import { RoofTool } from './roof/roof-tool'
@@ -13,6 +22,8 @@ import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
13
22
  import { SlabHoleEditor } from './slab/slab-hole-editor'
14
23
  import { SlabTool } from './slab/slab-tool'
15
24
  import { StairTool } from './stair/stair-tool'
25
+ import { CurveWallTool } from './wall/curve-wall-tool'
26
+ import { MoveWallEndpointTool } from './wall/move-wall-endpoint-tool'
16
27
  import { WallTool } from './wall/wall-tool'
17
28
  import { WindowTool } from './window/window-tool'
18
29
  import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
@@ -24,6 +35,7 @@ const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
24
35
  },
25
36
  structure: {
26
37
  wall: WallTool,
38
+ fence: FenceTool,
27
39
  slab: SlabTool,
28
40
  ceiling: CeilingTool,
29
41
  roof: RoofTool,
@@ -43,11 +55,24 @@ export const ToolManager: React.FC = () => {
43
55
  const mode = useEditor((state) => state.mode)
44
56
  const tool = useEditor((state) => state.tool)
45
57
  const movingNode = useEditor((state) => state.movingNode)
58
+ const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
59
+ const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
60
+ const curvingWall = useEditor((state) => state.curvingWall)
61
+ const curvingFence = useEditor((state) => state.curvingFence)
46
62
  const editingHole = useEditor((state) => state.editingHole)
47
63
  const selectedZoneId = useViewer((state) => state.selection.zoneId)
64
+ const buildingId = useViewer((state) => state.selection.buildingId)
48
65
  const selectedIds = useViewer((state) => state.selection.selectedIds)
49
66
  const nodes = useScene((state) => state.nodes)
50
67
 
68
+ // Building transform for the local group — all building-relative tools live inside this group
69
+ // so their cursor positions and committed data are naturally in building-local space.
70
+ const building = buildingId
71
+ ? (nodes[buildingId as AnyNodeId] as BuildingNode | undefined)
72
+ : undefined
73
+ const buildingPosition = building?.position ?? [0, 0, 0]
74
+ const buildingRotation = building?.rotation ?? [0, 0, 0]
75
+
51
76
  // Check if a slab is selected
52
77
  const selectedSlabId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'slab') as
53
78
  | SlabNode['id']
@@ -102,19 +127,34 @@ export const ToolManager: React.FC = () => {
102
127
  return (
103
128
  <>
104
129
  {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 />}
130
+ {/* World-space tools: site boundary and building movement operate in world coordinates */}
131
+ {movingNode?.type === 'building' && <MoveTool />}
132
+
133
+ {/* Building-local group: all other tools are relative to the selected building.
134
+ Cursor visuals set positions in building-local space; this group applies the
135
+ building's world transform so they render at the correct world position. */}
136
+ <group
137
+ position={buildingPosition as [number, number, number]}
138
+ rotation={buildingRotation as [number, number, number]}
139
+ >
140
+ {showZoneBoundaryEditor && selectedZoneId && <ZoneBoundaryEditor zoneId={selectedZoneId} />}
141
+ {showSlabBoundaryEditor && selectedSlabId && <SlabBoundaryEditor slabId={selectedSlabId} />}
142
+ {showSlabHoleEditor && selectedSlabId && editingHole && (
143
+ <SlabHoleEditor holeIndex={editingHole.holeIndex} slabId={selectedSlabId} />
144
+ )}
145
+ {showCeilingBoundaryEditor && selectedCeilingId && (
146
+ <CeilingBoundaryEditor ceilingId={selectedCeilingId} />
147
+ )}
148
+ {showCeilingHoleEditor && selectedCeilingId && editingHole && (
149
+ <CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
150
+ )}
151
+ {movingWallEndpoint && <MoveWallEndpointTool target={movingWallEndpoint} />}
152
+ {movingFenceEndpoint && <MoveFenceEndpointTool target={movingFenceEndpoint} />}
153
+ {curvingWall && <CurveWallTool node={curvingWall} />}
154
+ {curvingFence && <CurveFenceTool node={curvingFence} />}
155
+ {movingNode && movingNode.type !== 'building' && <MoveTool />}
156
+ {!movingNode && BuildToolComponent && <BuildToolComponent />}
157
+ </group>
118
158
  </>
119
159
  )
120
160
  }