@pascal-app/editor 0.5.1 → 0.7.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 (150) hide show
  1. package/package.json +12 -7
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +75 -7
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +29 -0
  6. package/src/components/editor/first-person/build-collider-world.ts +365 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
  8. package/src/components/editor/first-person-controls.tsx +496 -143
  9. package/src/components/editor/floating-action-menu.tsx +281 -83
  10. package/src/components/editor/floating-building-action-menu.tsx +4 -3
  11. package/src/components/editor/floorplan-background-selection.ts +113 -0
  12. package/src/components/editor/floorplan-panel.tsx +10442 -3275
  13. package/src/components/editor/index.tsx +270 -20
  14. package/src/components/editor/node-action-menu.tsx +14 -1
  15. package/src/components/editor/selection-manager.tsx +766 -12
  16. package/src/components/editor/site-edge-labels.tsx +9 -3
  17. package/src/components/editor/thumbnail-generator.tsx +350 -157
  18. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  19. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  20. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  21. package/src/components/editor/wall-measurement-label.tsx +377 -58
  22. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  23. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  24. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  25. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
  26. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  27. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
  28. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  29. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  30. package/src/components/editor-2d/svg-paths.ts +119 -0
  31. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  32. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  33. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  34. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +2 -0
  35. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  36. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  37. package/src/components/tools/column/column-tool.tsx +97 -0
  38. package/src/components/tools/column/move-column-tool.tsx +105 -0
  39. package/src/components/tools/door/door-tool.tsx +19 -0
  40. package/src/components/tools/door/move-door-tool.tsx +38 -8
  41. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  42. package/src/components/tools/fence/fence-drafting.ts +27 -8
  43. package/src/components/tools/fence/fence-tool.tsx +159 -3
  44. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +438 -0
  45. package/src/components/tools/fence/move-fence-tool.tsx +102 -27
  46. package/src/components/tools/item/move-tool.tsx +19 -1
  47. package/src/components/tools/item/placement-math.ts +44 -7
  48. package/src/components/tools/item/placement-strategies.ts +111 -33
  49. package/src/components/tools/item/placement-types.ts +7 -0
  50. package/src/components/tools/item/use-draft-node.ts +2 -0
  51. package/src/components/tools/item/use-placement-coordinator.tsx +701 -61
  52. package/src/components/tools/roof/move-roof-tool.tsx +111 -43
  53. package/src/components/tools/shared/polygon-editor.tsx +244 -29
  54. package/src/components/tools/shared/segment-angle.ts +156 -0
  55. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  56. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  57. package/src/components/tools/slab/slab-hole-editor.tsx +2 -0
  58. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  59. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  60. package/src/components/tools/stair/stair-tool.tsx +11 -3
  61. package/src/components/tools/tool-manager.tsx +30 -3
  62. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +423 -0
  64. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  65. package/src/components/tools/wall/wall-drafting.ts +348 -17
  66. package/src/components/tools/wall/wall-tool.tsx +134 -2
  67. package/src/components/tools/window/move-window-tool.tsx +28 -0
  68. package/src/components/tools/window/window-tool.tsx +17 -0
  69. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  70. package/src/components/ui/action-menu/control-modes.tsx +37 -5
  71. package/src/components/ui/action-menu/index.tsx +91 -1
  72. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  73. package/src/components/ui/action-menu/view-toggles.tsx +424 -35
  74. package/src/components/ui/command-palette/editor-commands.tsx +27 -5
  75. package/src/components/ui/command-palette/index.tsx +0 -1
  76. package/src/components/ui/controls/material-picker.tsx +189 -169
  77. package/src/components/ui/controls/slider-control.tsx +88 -26
  78. package/src/components/ui/floating-level-selector.tsx +286 -55
  79. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  80. package/src/components/ui/item-catalog/catalog-items.tsx +1121 -1219
  81. package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
  82. package/src/components/ui/level-duplicate-dialog.tsx +115 -0
  83. package/src/components/ui/panels/ceiling-panel.tsx +47 -27
  84. package/src/components/ui/panels/column-panel.tsx +715 -0
  85. package/src/components/ui/panels/door-panel.tsx +986 -294
  86. package/src/components/ui/panels/fence-panel.tsx +55 -12
  87. package/src/components/ui/panels/item-panel.tsx +5 -5
  88. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  89. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  90. package/src/components/ui/panels/node-display.ts +39 -0
  91. package/src/components/ui/panels/paint-panel.tsx +138 -0
  92. package/src/components/ui/panels/panel-manager.tsx +241 -30
  93. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  94. package/src/components/ui/panels/reference-panel.tsx +243 -9
  95. package/src/components/ui/panels/roof-panel.tsx +30 -62
  96. package/src/components/ui/panels/roof-segment-panel.tsx +8 -23
  97. package/src/components/ui/panels/slab-panel.tsx +46 -24
  98. package/src/components/ui/panels/spawn-panel.tsx +155 -0
  99. package/src/components/ui/panels/stair-panel.tsx +117 -69
  100. package/src/components/ui/panels/stair-segment-panel.tsx +13 -27
  101. package/src/components/ui/panels/wall-panel.tsx +71 -17
  102. package/src/components/ui/panels/window-panel.tsx +665 -146
  103. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  104. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  105. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +9 -5
  106. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
  107. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  108. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
  109. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
  110. package/src/components/ui/sidebar/panels/site-panel/index.tsx +138 -56
  111. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
  112. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +9 -5
  113. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  114. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
  115. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
  116. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
  117. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
  118. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +12 -6
  119. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
  120. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
  121. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +15 -8
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/viewer-toolbar.tsx +96 -2
  124. package/src/components/viewer-overlay.tsx +25 -19
  125. package/src/hooks/use-auto-frame.ts +45 -0
  126. package/src/hooks/use-contextual-tools.ts +14 -13
  127. package/src/hooks/use-keyboard.ts +67 -9
  128. package/src/hooks/use-mobile.ts +12 -12
  129. package/src/index.tsx +2 -1
  130. package/src/lib/door-interaction.ts +88 -0
  131. package/src/lib/floorplan/geometry.ts +263 -0
  132. package/src/lib/floorplan/index.ts +38 -0
  133. package/src/lib/floorplan/items.ts +179 -0
  134. package/src/lib/floorplan/selection-tool.ts +231 -0
  135. package/src/lib/floorplan/stairs.ts +478 -0
  136. package/src/lib/floorplan/types.ts +57 -0
  137. package/src/lib/floorplan/walls.ts +23 -0
  138. package/src/lib/guide-events.ts +10 -0
  139. package/src/lib/history.ts +20 -0
  140. package/src/lib/level-duplication.test.ts +72 -0
  141. package/src/lib/level-duplication.ts +153 -0
  142. package/src/lib/local-guide-image.ts +42 -0
  143. package/src/lib/material-paint.ts +284 -0
  144. package/src/lib/roof-duplication.ts +214 -0
  145. package/src/lib/scene-bounds.test.ts +183 -0
  146. package/src/lib/scene-bounds.ts +169 -0
  147. package/src/lib/sfx-player.ts +96 -13
  148. package/src/lib/stair-duplication.ts +126 -0
  149. package/src/lib/window-interaction.ts +86 -0
  150. package/src/store/use-editor.tsx +279 -15
@@ -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,17 @@ 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'
19
+ import { clearRoofDuplicateMetadata } from '../../../lib/roof-duplication'
16
20
  import { sfxEmitter } from '../../../lib/sfx-bus'
17
21
  import useEditor from '../../../store/use-editor'
22
+ import { snapFenceDraftPoint } from '../fence/fence-drafting'
18
23
  import { CursorSphere } from '../shared/cursor-sphere'
24
+ import type { WallPlanPoint } from '../wall/wall-drafting'
19
25
 
20
26
  export const MoveRoofTool: React.FC<{
21
27
  node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode
@@ -29,9 +35,13 @@ export const MoveRoofTool: React.FC<{
29
35
  const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => {
30
36
  const obj = sceneRegistry.nodes.get(movingNode.id)
31
37
  if (obj) {
32
- const pos = new THREE.Vector3()
33
- obj.getWorldPosition(pos)
34
- return [pos.x, pos.y, pos.z]
38
+ const worldPos = obj.getWorldPosition(new THREE.Vector3())
39
+ // Cursor renders inside the building-local ToolManager group, so convert
40
+ // world building-local to honor any building rotation.
41
+ const buildingId = useViewer.getState().selection.buildingId
42
+ const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
43
+ if (buildingObj) buildingObj.worldToLocal(worldPos)
44
+ return [worldPos.x, worldPos.y, worldPos.z]
35
45
  }
36
46
  // Fallback if not registered (e.g. newly created duplicate without mesh yet)
37
47
  if (
@@ -91,6 +101,7 @@ export const MoveRoofTool: React.FC<{
91
101
  // resetting the mesh position (it resets on dirty) and from triggering
92
102
  // expensive merged-mesh CSG rebuilds on every frame.
93
103
  let wasCommitted = false
104
+ let wasCancelled = false
94
105
 
95
106
  // Track pending rotation — no store updates during drag
96
107
  let pendingRotation: number = movingNode.rotation as number
@@ -114,10 +125,55 @@ export const MoveRoofTool: React.FC<{
114
125
  }
115
126
  }
116
127
 
117
- const computeLocal = (gridX: number, gridZ: number, y: number): [number, number] => {
118
- let localX = gridX
119
- let localZ = gridZ
128
+ const resolveLevelId = () => {
129
+ if (movingNode.type === 'roof' || movingNode.type === 'stair') {
130
+ return movingNode.parentId ?? null
131
+ }
120
132
 
133
+ if (
134
+ (movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') &&
135
+ movingNode.parentId
136
+ ) {
137
+ const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId]
138
+ return parentNode && 'parentId' in parentNode ? (parentNode.parentId ?? null) : null
139
+ }
140
+
141
+ return null
142
+ }
143
+
144
+ const levelId = resolveLevelId()
145
+ const levelNode =
146
+ levelId && useScene.getState().nodes[levelId as AnyNodeId]?.type === 'level'
147
+ ? (useScene.getState().nodes[levelId as AnyNodeId] as LevelNode)
148
+ : null
149
+ const levelChildren = levelNode?.children ?? []
150
+ const levelWalls = levelChildren
151
+ .map((childId) => useScene.getState().nodes[childId as AnyNodeId])
152
+ .filter((node): node is WallNode => node?.type === 'wall')
153
+ const levelFences = levelChildren
154
+ .map((childId) => useScene.getState().nodes[childId as AnyNodeId])
155
+ .filter((node): node is FenceNode => node?.type === 'fence')
156
+ const buildingId = useViewer.getState().selection.buildingId
157
+ const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
158
+
159
+ const localToWorldPoint = (localPoint: WallPlanPoint, y: number): [number, number, number] => {
160
+ if (buildingObj) {
161
+ const worldPoint = buildingObj.localToWorld(new THREE.Vector3(localPoint[0], y, localPoint[1]))
162
+ return [worldPoint.x, worldPoint.y, worldPoint.z]
163
+ }
164
+
165
+ return [localPoint[0], y, localPoint[1]]
166
+ }
167
+
168
+ const computeLocal = (
169
+ gridX: number,
170
+ gridZ: number,
171
+ y: number,
172
+ buildingLocalX: number,
173
+ buildingLocalZ: number,
174
+ ): [number, number] => {
175
+ // Segments have a transformed parent (stair/roof). Convert world → parent-local
176
+ // via Three.js hierarchy so the segment's stored position stays parent-relative.
121
177
  if (
122
178
  (movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') &&
123
179
  movingNode.parentId
@@ -128,40 +184,42 @@ export const MoveRoofTool: React.FC<{
128
184
  if (parentObj) {
129
185
  const worldVec = new THREE.Vector3(gridX, y, gridZ)
130
186
  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)
187
+ return [worldVec.x, worldVec.z]
139
188
  }
189
+ const dx = gridX - (parentNode.position[0] as number)
190
+ const dz = gridZ - (parentNode.position[2] as number)
191
+ const angle = -(parentNode.rotation as number)
192
+ return [
193
+ dx * Math.cos(angle) - dz * Math.sin(angle),
194
+ dx * Math.sin(angle) + dz * Math.cos(angle),
195
+ ]
140
196
  }
141
197
  }
142
198
 
143
- return [localX, localZ]
199
+ // Stair/roof live directly in the level — their stored position is building-local.
200
+ // event.localPosition is already building-local, so using it handles building rotation.
201
+ return [buildingLocalX, buildingLocalZ]
144
202
  }
145
203
 
146
204
  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
205
  const y = event.position[1]
150
206
 
151
- if (
152
- previousGridPosRef.current &&
153
- (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
154
- ) {
207
+ const snappedLocal = snapFenceDraftPoint({
208
+ point: [event.localPosition[0], event.localPosition[2]],
209
+ walls: levelWalls,
210
+ fences: levelFences,
211
+ })
212
+ const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y)
213
+
214
+ if (previousGridPosRef.current && (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])) {
155
215
  sfxEmitter.emit('sfx:grid-snap')
156
216
  }
157
217
 
158
218
  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
219
+ const [lx, lz] = snappedLocal
162
220
  setCursorWorldPos([lx, event.localPosition[1], lz])
163
221
 
164
- const [localX, localZ] = computeLocal(gridX, gridZ, y)
222
+ const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
165
223
 
166
224
  // Directly update the Three.js mesh — no store update during drag
167
225
  const mesh = sceneRegistry.nodes.get(movingNode.id)
@@ -178,11 +236,16 @@ export const MoveRoofTool: React.FC<{
178
236
  }
179
237
 
180
238
  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
239
  const y = event.position[1]
240
+ const snappedLocal = snapFenceDraftPoint({
241
+ point: [event.localPosition[0], event.localPosition[2]],
242
+ walls: levelWalls,
243
+ fences: levelFences,
244
+ })
245
+ const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y)
246
+ const [lx, lz] = snappedLocal
184
247
 
185
- const [localX, localZ] = computeLocal(gridX, gridZ, y)
248
+ const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
186
249
 
187
250
  wasCommitted = true
188
251
 
@@ -190,11 +253,19 @@ export const MoveRoofTool: React.FC<{
190
253
  // Resume temporal and apply the final state as a single undoable step.
191
254
  useScene.temporal.getState().resume()
192
255
 
193
- useScene.getState().updateNode(movingNode.id, {
194
- position: [localX, movingNode.position[1], localZ],
195
- rotation: pendingRotation,
196
- metadata: committedMeta,
197
- })
256
+ if (isNew && movingNode.type === 'roof') {
257
+ clearRoofDuplicateMetadata(movingNode.id as AnyNodeId, {
258
+ position: [localX, movingNode.position[1], localZ],
259
+ rotation: pendingRotation,
260
+ metadata: committedMeta,
261
+ })
262
+ } else {
263
+ useScene.getState().updateNode(movingNode.id, {
264
+ position: [localX, movingNode.position[1], localZ],
265
+ rotation: pendingRotation,
266
+ metadata: committedMeta,
267
+ })
268
+ }
198
269
 
199
270
  useScene.temporal.getState().pause()
200
271
 
@@ -206,6 +277,7 @@ export const MoveRoofTool: React.FC<{
206
277
  }
207
278
 
208
279
  const onCancel = () => {
280
+ wasCancelled = true
209
281
  useLiveTransforms.getState().clear(movingNode.id)
210
282
  if (isNew) {
211
283
  useScene.getState().deleteNode(movingNode.id)
@@ -264,16 +336,12 @@ export const MoveRoofTool: React.FC<{
264
336
  // Clear ephemeral live transform
265
337
  useLiveTransforms.getState().clear(movingNode.id)
266
338
 
267
- if (!wasCommitted) {
268
- if (isNew) {
269
- useScene.getState().deleteNode(movingNode.id)
270
- } else {
271
- useScene.getState().updateNode(movingNode.id, {
272
- position: original.position,
273
- rotation: original.rotation,
274
- metadata: original.metadata,
275
- })
276
- }
339
+ if (!(wasCommitted || wasCancelled || isNew)) {
340
+ useScene.getState().updateNode(movingNode.id, {
341
+ position: original.position,
342
+ rotation: original.rotation,
343
+ metadata: original.metadata,
344
+ })
277
345
  }
278
346
  useScene.temporal.getState().resume()
279
347
  emitter.off('grid:move', onGridMove)
@@ -1,16 +1,21 @@
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' | 'edge'
14
+ vertexIndex: number | null
15
+ edgeIndex?: number
16
+ edgeNormal?: [number, number]
13
17
  initialPosition: [number, number]
18
+ initialPolygon: Array<[number, number]>
14
19
  pointerId: number
15
20
  }
16
21
 
@@ -23,6 +28,10 @@ export interface PolygonEditorProps {
23
28
  levelId?: string
24
29
  /** Height of the surface being edited (e.g. slab elevation). Handles adapt to this. */
25
30
  surfaceHeight?: number
31
+ /** Whether to show the center handle that moves the entire polygon. */
32
+ allowPolygonMove?: boolean
33
+ /** Whether polygon edges can be dragged along their perpendicular normal. */
34
+ allowEdgeMove?: boolean
26
35
  }
27
36
 
28
37
  /**
@@ -30,6 +39,17 @@ export interface PolygonEditorProps {
30
39
  * Used by zone and site boundary editors
31
40
  */
32
41
  const MIN_HANDLE_HEIGHT = 0.15
42
+ const EDGE_HANDLE_HEIGHT = 0.06
43
+ const EDGE_HANDLE_THICKNESS = 0.12
44
+
45
+ function getEdgeNormal(start: [number, number], end: [number, number]): [number, number] | null {
46
+ const dx = end[0] - start[0]
47
+ const dz = end[1] - start[1]
48
+ const length = Math.hypot(dx, dz)
49
+ if (length < 1e-6) return null
50
+
51
+ return [-dz / length, dx / length]
52
+ }
33
53
 
34
54
  export const PolygonEditor: React.FC<PolygonEditorProps> = ({
35
55
  polygon,
@@ -38,9 +58,43 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
38
58
  minVertices = 3,
39
59
  levelId,
40
60
  surfaceHeight = 0,
61
+ allowPolygonMove = false,
62
+ allowEdgeMove = false,
41
63
  }) => {
42
- // Get level node from registry if levelId is provided
43
- const levelNode = levelId ? sceneRegistry.nodes.get(levelId) : null
64
+ const [levelNode, setLevelNode] = useState<Object3D | null>(() =>
65
+ levelId ? (sceneRegistry.nodes.get(levelId) ?? null) : null,
66
+ )
67
+
68
+ useEffect(() => {
69
+ if (!levelId) {
70
+ setLevelNode(null)
71
+ return
72
+ }
73
+
74
+ let frameId = 0
75
+
76
+ const resolveLevelNode = () => {
77
+ const nextLevelNode = sceneRegistry.nodes.get(levelId) ?? null
78
+ setLevelNode((currentLevelNode) => {
79
+ if (currentLevelNode === nextLevelNode) {
80
+ return currentLevelNode
81
+ }
82
+ return nextLevelNode
83
+ })
84
+
85
+ if (!nextLevelNode) {
86
+ frameId = window.requestAnimationFrame(resolveLevelNode)
87
+ }
88
+ }
89
+
90
+ resolveLevelNode()
91
+
92
+ return () => {
93
+ if (frameId) {
94
+ window.cancelAnimationFrame(frameId)
95
+ }
96
+ }
97
+ }, [levelId])
44
98
 
45
99
  // When using portal, edit at Y_OFFSET (local to level)
46
100
  // When not using portal, edit at world origin
@@ -51,6 +105,11 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
51
105
  const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
52
106
  const previewPolygonRef = useRef<Array<[number, number]> | null>(null)
53
107
 
108
+ const updatePreviewPolygon = useCallback((nextPolygon: Array<[number, number]> | null) => {
109
+ previewPolygonRef.current = nextPolygon
110
+ setPreviewPolygon(nextPolygon)
111
+ }, [])
112
+
54
113
  // Keep ref in sync
55
114
  useEffect(() => {
56
115
  previewPolygonRef.current = previewPolygon
@@ -58,6 +117,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
58
117
 
59
118
  const [hoveredVertex, setHoveredVertex] = useState<number | null>(null)
60
119
  const [hoveredMidpoint, setHoveredMidpoint] = useState<number | null>(null)
120
+ const [hoveredEdge, setHoveredEdge] = useState<number | null>(null)
61
121
  const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
62
122
 
63
123
  const lineRef = useRef<Line>(null!)
@@ -68,13 +128,24 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
68
128
  if (polygon !== lastPolygonRef.current) {
69
129
  lastPolygonRef.current = polygon
70
130
  // External change (e.g. undo/redo) — clear any stale preview/drag state
71
- if (previewPolygon) setPreviewPolygon(null)
131
+ if (previewPolygon) updatePreviewPolygon(null)
72
132
  if (dragState) setDragState(null)
73
133
  }
74
134
 
75
135
  // The polygon to display (preview during drag, or actual polygon)
76
136
  const displayPolygon = previewPolygon ?? polygon
77
137
 
138
+ const polygonCenter = useMemo(() => {
139
+ if (displayPolygon.length === 0) return [0, 0] as [number, number]
140
+ let sumX = 0
141
+ let sumZ = 0
142
+ for (const [x, z] of displayPolygon) {
143
+ sumX += x
144
+ sumZ += z
145
+ }
146
+ return [sumX / displayPolygon.length, sumZ / displayPolygon.length] as [number, number]
147
+ }, [displayPolygon])
148
+
78
149
  // Calculate midpoints for adding new vertices
79
150
  const midpoints = useMemo(() => {
80
151
  if (displayPolygon.length < 2) return []
@@ -85,17 +156,37 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
85
156
  })
86
157
  }, [displayPolygon])
87
158
 
159
+ const edgeHandles = useMemo(() => {
160
+ if (displayPolygon.length < 2) return []
161
+
162
+ return displayPolygon.flatMap(([x1, z1], index) => {
163
+ const nextIndex = (index + 1) % displayPolygon.length
164
+ const [x2, z2] = displayPolygon[nextIndex]!
165
+ const dx = x2 - x1
166
+ const dz = z2 - z1
167
+ const length = Math.hypot(dx, dz)
168
+ if (length < 1e-6) return []
169
+
170
+ return [
171
+ {
172
+ index,
173
+ length,
174
+ midpoint: [(x1 + x2) / 2, (z1 + z2) / 2] as [number, number],
175
+ rotationY: -Math.atan2(dz, dx),
176
+ },
177
+ ]
178
+ })
179
+ }, [displayPolygon])
180
+
88
181
  // Update vertex position using grid cursor position
89
182
  const handleVertexDrag = useCallback(
90
183
  (vertexIndex: number, position: [number, number]) => {
91
- setPreviewPolygon((prev) => {
92
- const basePolygon = prev ?? polygon
93
- const newPolygon = [...basePolygon]
94
- newPolygon[vertexIndex] = position
95
- return newPolygon
96
- })
184
+ const basePolygon = previewPolygonRef.current ?? polygon
185
+ const newPolygon = [...basePolygon]
186
+ newPolygon[vertexIndex] = position
187
+ updatePreviewPolygon(newPolygon)
97
188
  },
98
- [polygon],
189
+ [polygon, updatePreviewPolygon],
99
190
  )
100
191
 
101
192
  // Commit polygon changes
@@ -103,9 +194,9 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
103
194
  if (previewPolygonRef.current) {
104
195
  onPolygonChange(previewPolygonRef.current)
105
196
  }
106
- setPreviewPolygon(null)
197
+ updatePreviewPolygon(null)
107
198
  setDragState(null)
108
- }, [onPolygonChange])
199
+ }, [onPolygonChange, updatePreviewPolygon])
109
200
 
110
201
  // Handle adding a new vertex at midpoint
111
202
  const handleAddVertex = useCallback(
@@ -117,10 +208,13 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
117
208
  ...basePolygon.slice(afterIndex + 1),
118
209
  ]
119
210
 
120
- setPreviewPolygon(newPolygon)
121
- return afterIndex + 1 // Return new vertex index
211
+ updatePreviewPolygon(newPolygon)
212
+ return {
213
+ polygon: newPolygon,
214
+ vertexIndex: afterIndex + 1,
215
+ }
122
216
  },
123
- [polygon, previewPolygon],
217
+ [polygon, previewPolygon, updatePreviewPolygon],
124
218
  )
125
219
 
126
220
  // Handle deleting a vertex
@@ -131,16 +225,16 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
131
225
 
132
226
  const newPolygon = basePolygon.filter((_, i) => i !== index)
133
227
  onPolygonChange(newPolygon)
134
- setPreviewPolygon(null)
228
+ updatePreviewPolygon(null)
135
229
  },
136
- [polygon, previewPolygon, onPolygonChange, minVertices],
230
+ [polygon, previewPolygon, onPolygonChange, minVertices, updatePreviewPolygon],
137
231
  )
138
232
 
139
233
  // Listen to grid:move events to track cursor position
140
234
  useEffect(() => {
141
235
  const onGridMove = (event: GridEvent) => {
142
- const gridX = Math.round(event.localPosition[0] * 2) / 2
143
- const gridZ = Math.round(event.localPosition[2] * 2) / 2
236
+ const gridX = snapToHalf(event.localPosition[0])
237
+ const gridZ = snapToHalf(event.localPosition[2])
144
238
  const newPosition: [number, number] = [gridX, gridZ]
145
239
 
146
240
  // Play snap sound when cursor moves to a new grid cell during drag
@@ -158,7 +252,37 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
158
252
 
159
253
  // Update vertex position during drag
160
254
  if (dragState?.isDragging) {
161
- handleVertexDrag(dragState.vertexIndex, newPosition)
255
+ if (dragState.mode === 'vertex' && dragState.vertexIndex !== null) {
256
+ handleVertexDrag(dragState.vertexIndex, newPosition)
257
+ } else if (dragState.mode === 'polygon') {
258
+ const deltaX = newPosition[0] - dragState.initialPosition[0]
259
+ const deltaZ = newPosition[1] - dragState.initialPosition[1]
260
+ updatePreviewPolygon(
261
+ dragState.initialPolygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number]),
262
+ )
263
+ } else if (
264
+ dragState.mode === 'edge' &&
265
+ dragState.edgeIndex !== undefined &&
266
+ dragState.edgeNormal
267
+ ) {
268
+ const [normalX, normalZ] = dragState.edgeNormal
269
+ const pointerDeltaX = newPosition[0] - dragState.initialPosition[0]
270
+ const pointerDeltaZ = newPosition[1] - dragState.initialPosition[1]
271
+ const normalDistance = pointerDeltaX * normalX + pointerDeltaZ * normalZ
272
+ const edgeStartIndex = dragState.edgeIndex
273
+ const edgeEndIndex = (edgeStartIndex + 1) % dragState.initialPolygon.length
274
+ const nextPolygon = dragState.initialPolygon.map((point, index) => {
275
+ if (index !== edgeStartIndex && index !== edgeEndIndex) {
276
+ return point
277
+ }
278
+
279
+ return [point[0] + normalX * normalDistance, point[1] + normalZ * normalDistance] as [
280
+ number,
281
+ number,
282
+ ]
283
+ })
284
+ updatePreviewPolygon(nextPolygon)
285
+ }
162
286
  }
163
287
  }
164
288
 
@@ -166,7 +290,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
166
290
  return () => {
167
291
  emitter.off('grid:move', onGridMove)
168
292
  }
169
- }, [dragState, handleVertexDrag])
293
+ }, [dragState, handleVertexDrag, updatePreviewPolygon])
170
294
 
171
295
  // Set up pointer up listener for ending drag
172
296
  useEffect(() => {
@@ -231,6 +355,8 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
231
355
  if (displayPolygon.length < minVertices) return null
232
356
 
233
357
  const canDelete = displayPolygon.length > minVertices
358
+ const handleHeight = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)
359
+ const edgeHandleY = editY + handleHeight - EDGE_HANDLE_HEIGHT / 2
234
360
 
235
361
  const editorContent = (
236
362
  <group>
@@ -257,9 +383,9 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
257
383
  {/* Vertex handles - blue cylinders that match surface height */}
258
384
  {displayPolygon.map(([x, z], index) => {
259
385
  const isHovered = hoveredVertex === index
260
- const isDragging = dragState?.vertexIndex === index
386
+ const isDragging = dragState?.mode === 'vertex' && dragState.vertexIndex === index
261
387
  const radius = 0.1
262
- const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)
388
+ const height = handleHeight
263
389
 
264
390
  return (
265
391
  <mesh
@@ -280,10 +406,13 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
280
406
  onPointerDown={(e) => {
281
407
  if (e.button !== 0) return
282
408
  e.stopPropagation()
409
+ setHoveredEdge(null)
283
410
  setDragState({
284
411
  isDragging: true,
412
+ mode: 'vertex',
285
413
  vertexIndex: index,
286
414
  initialPosition: [x!, z!],
415
+ initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
287
416
  pointerId: e.pointerId,
288
417
  })
289
418
  }}
@@ -305,12 +434,96 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
305
434
  )
306
435
  })}
307
436
 
437
+ {allowPolygonMove && (
438
+ <mesh
439
+ castShadow
440
+ layers={EDITOR_LAYER}
441
+ onClick={(e) => {
442
+ if (e.button !== 0) return
443
+ e.stopPropagation()
444
+ }}
445
+ onPointerDown={(e) => {
446
+ if (e.button !== 0) return
447
+ e.stopPropagation()
448
+ setHoveredEdge(null)
449
+ setDragState({
450
+ isDragging: true,
451
+ mode: 'polygon',
452
+ vertexIndex: null,
453
+ initialPosition: polygonCenter,
454
+ initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
455
+ pointerId: e.pointerId,
456
+ })
457
+ }}
458
+ position={[polygonCenter[0], editY + handleHeight + 0.08, polygonCenter[1]]}
459
+ >
460
+ <sphereGeometry args={[0.09, 20, 20]} />
461
+ <meshStandardMaterial color={dragState?.mode === 'polygon' ? '#22c55e' : '#f59e0b'} />
462
+ </mesh>
463
+ )}
464
+
465
+ {allowEdgeMove &&
466
+ edgeHandles.map(({ index, length, midpoint, rotationY }) => {
467
+ const isHovered = hoveredEdge === index
468
+ const isDragging = dragState?.mode === 'edge' && dragState.edgeIndex === index
469
+
470
+ return (
471
+ <mesh
472
+ key={`edge-${index}`}
473
+ layers={EDITOR_LAYER}
474
+ onClick={(e) => {
475
+ if (e.button !== 0) return
476
+ e.stopPropagation()
477
+ }}
478
+ onPointerDown={(e) => {
479
+ if (e.button !== 0) return
480
+ e.stopPropagation()
481
+ const start = displayPolygon[index]
482
+ const end = displayPolygon[(index + 1) % displayPolygon.length]
483
+ if (!(start && end)) return
484
+
485
+ const edgeNormal = getEdgeNormal(start, end)
486
+ if (!edgeNormal) return
487
+
488
+ setHoveredEdge(null)
489
+ setDragState({
490
+ isDragging: true,
491
+ mode: 'edge',
492
+ vertexIndex: null,
493
+ edgeIndex: index,
494
+ edgeNormal,
495
+ initialPosition: cursorPosition,
496
+ initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
497
+ pointerId: e.pointerId,
498
+ })
499
+ }}
500
+ onPointerEnter={(e) => {
501
+ e.stopPropagation()
502
+ setHoveredEdge(index)
503
+ }}
504
+ onPointerLeave={(e) => {
505
+ e.stopPropagation()
506
+ setHoveredEdge(null)
507
+ }}
508
+ position={[midpoint[0], edgeHandleY, midpoint[1]]}
509
+ rotation={[0, rotationY, 0]}
510
+ >
511
+ <boxGeometry args={[length, EDGE_HANDLE_HEIGHT, EDGE_HANDLE_THICKNESS]} />
512
+ <meshStandardMaterial
513
+ color={isDragging ? '#22c55e' : '#94a3b8'}
514
+ opacity={isDragging ? 0.5 : isHovered ? 0.38 : 0.14}
515
+ transparent
516
+ />
517
+ </mesh>
518
+ )
519
+ })}
520
+
308
521
  {/* Midpoint handles - smaller green cylinders for adding vertices (hidden while dragging) */}
309
522
  {!dragState &&
310
523
  midpoints.map(([x, z], index) => {
311
524
  const isHovered = hoveredMidpoint === index
312
525
  const radius = 0.06
313
- const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)
526
+ const height = handleHeight
314
527
 
315
528
  return (
316
529
  <mesh
@@ -323,12 +536,14 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
323
536
  onPointerDown={(e) => {
324
537
  if (e.button !== 0) return
325
538
  e.stopPropagation()
326
- const newVertexIndex = handleAddVertex(index, [x!, z!])
327
- if (newVertexIndex >= 0) {
539
+ const insertedVertex = handleAddVertex(index, [x!, z!])
540
+ if (insertedVertex.vertexIndex >= 0) {
328
541
  setDragState({
329
542
  isDragging: true,
330
- vertexIndex: newVertexIndex,
543
+ mode: 'vertex',
544
+ vertexIndex: insertedVertex.vertexIndex,
331
545
  initialPosition: [x!, z!],
546
+ initialPolygon: insertedVertex.polygon,
332
547
  pointerId: e.pointerId,
333
548
  })
334
549
  setHoveredMidpoint(null)