@pascal-app/editor 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/package.json +8 -7
  2. package/src/components/editor/editor-layout-v2.tsx +9 -0
  3. package/src/components/editor/floating-action-menu.tsx +255 -34
  4. package/src/components/editor/floating-building-action-menu.tsx +4 -3
  5. package/src/components/editor/floorplan-panel.tsx +1323 -713
  6. package/src/components/editor/index.tsx +2 -0
  7. package/src/components/editor/node-action-menu.tsx +14 -1
  8. package/src/components/editor/selection-manager.tsx +200 -8
  9. package/src/components/editor/site-edge-labels.tsx +9 -3
  10. package/src/components/editor/thumbnail-generator.tsx +319 -157
  11. package/src/components/editor/wall-measurement-label.tsx +120 -32
  12. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  13. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  14. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  15. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  16. package/src/components/tools/door/door-tool.tsx +12 -0
  17. package/src/components/tools/door/move-door-tool.tsx +10 -0
  18. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  19. package/src/components/tools/fence/fence-drafting.ts +19 -7
  20. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
  21. package/src/components/tools/fence/move-fence-tool.tsx +8 -0
  22. package/src/components/tools/item/move-tool.tsx +9 -0
  23. package/src/components/tools/item/placement-math.ts +14 -6
  24. package/src/components/tools/item/placement-strategies.ts +2 -2
  25. package/src/components/tools/item/use-placement-coordinator.tsx +42 -10
  26. package/src/components/tools/roof/move-roof-tool.tsx +89 -28
  27. package/src/components/tools/shared/polygon-editor.tsx +98 -8
  28. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  29. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  30. package/src/components/tools/stair/stair-tool.tsx +11 -3
  31. package/src/components/tools/tool-manager.tsx +12 -0
  32. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  33. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
  34. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  35. package/src/components/tools/wall/wall-drafting.ts +331 -9
  36. package/src/components/tools/window/move-window-tool.tsx +10 -0
  37. package/src/components/tools/window/window-tool.tsx +12 -0
  38. package/src/components/ui/action-menu/control-modes.tsx +9 -4
  39. package/src/components/ui/command-palette/editor-commands.tsx +9 -4
  40. package/src/components/ui/command-palette/index.tsx +0 -1
  41. package/src/components/ui/controls/material-picker.tsx +127 -94
  42. package/src/components/ui/controls/slider-control.tsx +28 -14
  43. package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
  44. package/src/components/ui/panels/ceiling-panel.tsx +61 -17
  45. package/src/components/ui/panels/door-panel.tsx +5 -5
  46. package/src/components/ui/panels/fence-panel.tsx +97 -12
  47. package/src/components/ui/panels/item-panel.tsx +5 -5
  48. package/src/components/ui/panels/panel-manager.tsx +31 -29
  49. package/src/components/ui/panels/reference-panel.tsx +5 -4
  50. package/src/components/ui/panels/roof-panel.tsx +91 -22
  51. package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
  52. package/src/components/ui/panels/slab-panel.tsx +63 -15
  53. package/src/components/ui/panels/stair-panel.tsx +173 -19
  54. package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
  55. package/src/components/ui/panels/wall-panel.tsx +159 -11
  56. package/src/components/ui/panels/window-panel.tsx +5 -7
  57. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +7 -3
  58. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
  59. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
  60. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
  61. package/src/components/ui/sidebar/panels/site-panel/index.tsx +29 -32
  62. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
  63. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -3
  64. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  65. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
  66. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
  67. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
  68. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +3 -3
  69. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
  70. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
  71. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +7 -3
  72. package/src/components/ui/viewer-toolbar.tsx +55 -2
  73. package/src/components/viewer-overlay.tsx +25 -19
  74. package/src/hooks/use-contextual-tools.ts +14 -13
  75. package/src/hooks/use-keyboard.ts +3 -2
  76. package/src/index.tsx +2 -1
  77. package/src/lib/history.ts +20 -0
  78. package/src/lib/sfx-player.ts +96 -13
  79. package/src/store/use-editor.tsx +118 -10
@@ -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,40 +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
- // 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
217
+ const [lx, lz] = snappedLocal
162
218
  setCursorWorldPos([lx, event.localPosition[1], lz])
163
219
 
164
- const [localX, localZ] = computeLocal(gridX, gridZ, y)
220
+ const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
165
221
 
166
222
  // Directly update the Three.js mesh — no store update during drag
167
223
  const mesh = sceneRegistry.nodes.get(movingNode.id)
@@ -178,11 +234,16 @@ export const MoveRoofTool: React.FC<{
178
234
  }
179
235
 
180
236
  const onGridClick = (event: GridEvent) => {
181
- const gridX = Math.round(event.position[0] * 2) / 2 // world, for computeLocal
182
- const gridZ = Math.round(event.position[2] * 2) / 2
183
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
184
245
 
185
- const [localX, localZ] = computeLocal(gridX, gridZ, y)
246
+ const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
186
247
 
187
248
  wasCommitted = true
188
249
 
@@ -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.localPosition[0] * 2) / 2
143
- const gridZ = Math.round(event.localPosition[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}
@@ -93,11 +93,21 @@ function commitStairPlacement(
93
93
  position: [0, 0, 0],
94
94
  })
95
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
+
96
102
  const stair = StairNode.parse({
97
103
  name,
98
104
  position,
99
105
  rotation,
100
106
  stairType: DEFAULT_STAIR_TYPE,
107
+ fromLevelId: levelId,
108
+ toLevelId: nextLevelId,
109
+ slabOpeningMode: 'destination',
110
+ openingOffset: 0.08,
101
111
  width: DEFAULT_STAIR_WIDTH,
102
112
  totalRise: DEFAULT_STAIR_HEIGHT,
103
113
  stepCount: DEFAULT_STAIR_STEP_COUNT,
@@ -166,9 +176,7 @@ export const StairTool: React.FC = () => {
166
176
 
167
177
  const gridX = Math.round(event.localPosition[0] * 2) / 2
168
178
  const gridZ = Math.round(event.localPosition[2] * 2) / 2
169
- const y = event.localPosition[1]
170
-
171
- commitStairPlacement(currentLevelId, [gridX, y, gridZ], rotationRef.current)
179
+ commitStairPlacement(currentLevelId, [gridX, 0, gridZ], rotationRef.current)
172
180
  }
173
181
 
174
182
  const onKeyDown = (event: KeyboardEvent) => {
@@ -11,7 +11,9 @@ import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
11
11
  import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
12
12
  import { CeilingTool } from './ceiling/ceiling-tool'
13
13
  import { DoorTool } from './door/door-tool'
14
+ import { CurveFenceTool } from './fence/curve-fence-tool'
14
15
  import { FenceTool } from './fence/fence-tool'
16
+ import { MoveFenceEndpointTool } from './fence/move-fence-endpoint-tool'
15
17
  import { ItemTool } from './item/item-tool'
16
18
  import { MoveTool } from './item/move-tool'
17
19
  import { RoofTool } from './roof/roof-tool'
@@ -20,6 +22,8 @@ import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
20
22
  import { SlabHoleEditor } from './slab/slab-hole-editor'
21
23
  import { SlabTool } from './slab/slab-tool'
22
24
  import { StairTool } from './stair/stair-tool'
25
+ import { CurveWallTool } from './wall/curve-wall-tool'
26
+ import { MoveWallEndpointTool } from './wall/move-wall-endpoint-tool'
23
27
  import { WallTool } from './wall/wall-tool'
24
28
  import { WindowTool } from './window/window-tool'
25
29
  import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
@@ -51,6 +55,10 @@ export const ToolManager: React.FC = () => {
51
55
  const mode = useEditor((state) => state.mode)
52
56
  const tool = useEditor((state) => state.tool)
53
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)
54
62
  const editingHole = useEditor((state) => state.editingHole)
55
63
  const selectedZoneId = useViewer((state) => state.selection.zoneId)
56
64
  const buildingId = useViewer((state) => state.selection.buildingId)
@@ -140,6 +148,10 @@ export const ToolManager: React.FC = () => {
140
148
  {showCeilingHoleEditor && selectedCeilingId && editingHole && (
141
149
  <CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
142
150
  )}
151
+ {movingWallEndpoint && <MoveWallEndpointTool target={movingWallEndpoint} />}
152
+ {movingFenceEndpoint && <MoveFenceEndpointTool target={movingFenceEndpoint} />}
153
+ {curvingWall && <CurveWallTool node={curvingWall} />}
154
+ {curvingFence && <CurveFenceTool node={curvingFence} />}
143
155
  {movingNode && movingNode.type !== 'building' && <MoveTool />}
144
156
  {!movingNode && BuildToolComponent && <BuildToolComponent />}
145
157
  </group>