@pascal-app/editor 0.6.0 → 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 (122) hide show
  1. package/package.json +9 -5
  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 +20 -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 +32 -55
  10. package/src/components/editor/floorplan-background-selection.ts +113 -0
  11. package/src/components/editor/floorplan-panel.tsx +9855 -3298
  12. package/src/components/editor/index.tsx +269 -21
  13. package/src/components/editor/selection-manager.tsx +575 -13
  14. package/src/components/editor/thumbnail-generator.tsx +38 -7
  15. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  16. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  17. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  18. package/src/components/editor/wall-measurement-label.tsx +267 -36
  19. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  20. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  21. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  22. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
  23. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  24. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
  25. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  26. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  27. package/src/components/editor-2d/svg-paths.ts +119 -0
  28. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  29. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  30. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  31. package/src/components/tools/column/column-tool.tsx +97 -0
  32. package/src/components/tools/column/move-column-tool.tsx +105 -0
  33. package/src/components/tools/door/door-tool.tsx +7 -0
  34. package/src/components/tools/door/move-door-tool.tsx +28 -8
  35. package/src/components/tools/fence/fence-drafting.ts +10 -3
  36. package/src/components/tools/fence/fence-tool.tsx +159 -3
  37. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +129 -18
  38. package/src/components/tools/fence/move-fence-tool.tsx +101 -34
  39. package/src/components/tools/item/move-tool.tsx +10 -1
  40. package/src/components/tools/item/placement-math.ts +30 -1
  41. package/src/components/tools/item/placement-strategies.ts +109 -31
  42. package/src/components/tools/item/placement-types.ts +7 -0
  43. package/src/components/tools/item/use-draft-node.ts +2 -0
  44. package/src/components/tools/item/use-placement-coordinator.tsx +660 -52
  45. package/src/components/tools/roof/move-roof-tool.tsx +22 -15
  46. package/src/components/tools/shared/polygon-editor.tsx +153 -28
  47. package/src/components/tools/shared/segment-angle.ts +156 -0
  48. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  49. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  50. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  51. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  52. package/src/components/tools/tool-manager.tsx +18 -3
  53. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +121 -20
  54. package/src/components/tools/wall/wall-drafting.ts +18 -9
  55. package/src/components/tools/wall/wall-tool.tsx +134 -2
  56. package/src/components/tools/window/move-window-tool.tsx +18 -0
  57. package/src/components/tools/window/window-tool.tsx +5 -0
  58. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  59. package/src/components/ui/action-menu/control-modes.tsx +28 -1
  60. package/src/components/ui/action-menu/index.tsx +91 -1
  61. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  62. package/src/components/ui/action-menu/view-toggles.tsx +424 -35
  63. package/src/components/ui/command-palette/editor-commands.tsx +18 -1
  64. package/src/components/ui/controls/material-picker.tsx +152 -165
  65. package/src/components/ui/controls/slider-control.tsx +66 -18
  66. package/src/components/ui/floating-level-selector.tsx +286 -55
  67. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  68. package/src/components/ui/item-catalog/catalog-items.tsx +1116 -1219
  69. package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
  70. package/src/components/ui/level-duplicate-dialog.tsx +115 -0
  71. package/src/components/ui/panels/ceiling-panel.tsx +1 -25
  72. package/src/components/ui/panels/column-panel.tsx +715 -0
  73. package/src/components/ui/panels/door-panel.tsx +981 -289
  74. package/src/components/ui/panels/fence-panel.tsx +3 -45
  75. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  76. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  77. package/src/components/ui/panels/node-display.ts +39 -0
  78. package/src/components/ui/panels/paint-panel.tsx +138 -0
  79. package/src/components/ui/panels/panel-manager.tsx +210 -1
  80. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  81. package/src/components/ui/panels/reference-panel.tsx +238 -5
  82. package/src/components/ui/panels/roof-panel.tsx +4 -105
  83. package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
  84. package/src/components/ui/panels/slab-panel.tsx +4 -30
  85. package/src/components/ui/panels/spawn-panel.tsx +155 -0
  86. package/src/components/ui/panels/stair-panel.tsx +11 -117
  87. package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
  88. package/src/components/ui/panels/wall-panel.tsx +1 -95
  89. package/src/components/ui/panels/window-panel.tsx +660 -139
  90. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  91. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  92. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
  93. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  94. package/src/components/ui/sidebar/panels/site-panel/index.tsx +109 -24
  95. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
  96. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
  97. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +9 -3
  98. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +8 -5
  99. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  100. package/src/components/ui/viewer-toolbar.tsx +42 -1
  101. package/src/hooks/use-auto-frame.ts +45 -0
  102. package/src/hooks/use-keyboard.ts +64 -7
  103. package/src/hooks/use-mobile.ts +12 -12
  104. package/src/lib/door-interaction.ts +88 -0
  105. package/src/lib/floorplan/geometry.ts +263 -0
  106. package/src/lib/floorplan/index.ts +38 -0
  107. package/src/lib/floorplan/items.ts +179 -0
  108. package/src/lib/floorplan/selection-tool.ts +231 -0
  109. package/src/lib/floorplan/stairs.ts +478 -0
  110. package/src/lib/floorplan/types.ts +57 -0
  111. package/src/lib/floorplan/walls.ts +23 -0
  112. package/src/lib/guide-events.ts +10 -0
  113. package/src/lib/level-duplication.test.ts +72 -0
  114. package/src/lib/level-duplication.ts +153 -0
  115. package/src/lib/local-guide-image.ts +42 -0
  116. package/src/lib/material-paint.ts +284 -0
  117. package/src/lib/roof-duplication.ts +214 -0
  118. package/src/lib/scene-bounds.test.ts +183 -0
  119. package/src/lib/scene-bounds.ts +169 -0
  120. package/src/lib/stair-duplication.ts +126 -0
  121. package/src/lib/window-interaction.ts +86 -0
  122. package/src/store/use-editor.tsx +164 -8
@@ -16,6 +16,7 @@ import {
16
16
  import { useViewer } from '@pascal-app/viewer'
17
17
  import { useCallback, useEffect, useRef, useState } from 'react'
18
18
  import * as THREE from 'three'
19
+ import { clearRoofDuplicateMetadata } from '../../../lib/roof-duplication'
19
20
  import { sfxEmitter } from '../../../lib/sfx-bus'
20
21
  import useEditor from '../../../store/use-editor'
21
22
  import { snapFenceDraftPoint } from '../fence/fence-drafting'
@@ -100,6 +101,7 @@ export const MoveRoofTool: React.FC<{
100
101
  // resetting the mesh position (it resets on dirty) and from triggering
101
102
  // expensive merged-mesh CSG rebuilds on every frame.
102
103
  let wasCommitted = false
104
+ let wasCancelled = false
103
105
 
104
106
  // Track pending rotation — no store updates during drag
105
107
  let pendingRotation: number = movingNode.rotation as number
@@ -251,11 +253,19 @@ export const MoveRoofTool: React.FC<{
251
253
  // Resume temporal and apply the final state as a single undoable step.
252
254
  useScene.temporal.getState().resume()
253
255
 
254
- useScene.getState().updateNode(movingNode.id, {
255
- position: [localX, movingNode.position[1], localZ],
256
- rotation: pendingRotation,
257
- metadata: committedMeta,
258
- })
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
+ }
259
269
 
260
270
  useScene.temporal.getState().pause()
261
271
 
@@ -267,6 +277,7 @@ export const MoveRoofTool: React.FC<{
267
277
  }
268
278
 
269
279
  const onCancel = () => {
280
+ wasCancelled = true
270
281
  useLiveTransforms.getState().clear(movingNode.id)
271
282
  if (isNew) {
272
283
  useScene.getState().deleteNode(movingNode.id)
@@ -325,16 +336,12 @@ export const MoveRoofTool: React.FC<{
325
336
  // Clear ephemeral live transform
326
337
  useLiveTransforms.getState().clear(movingNode.id)
327
338
 
328
- if (!wasCommitted) {
329
- if (isNew) {
330
- useScene.getState().deleteNode(movingNode.id)
331
- } else {
332
- useScene.getState().updateNode(movingNode.id, {
333
- position: original.position,
334
- rotation: original.rotation,
335
- metadata: original.metadata,
336
- })
337
- }
339
+ if (!(wasCommitted || wasCancelled || isNew)) {
340
+ useScene.getState().updateNode(movingNode.id, {
341
+ position: original.position,
342
+ rotation: original.rotation,
343
+ metadata: original.metadata,
344
+ })
338
345
  }
339
346
  useScene.temporal.getState().resume()
340
347
  emitter.off('grid:move', onGridMove)
@@ -10,8 +10,10 @@ const Y_OFFSET = 0.02
10
10
 
11
11
  type DragState = {
12
12
  isDragging: boolean
13
- mode: 'vertex' | 'polygon'
13
+ mode: 'vertex' | 'polygon' | 'edge'
14
14
  vertexIndex: number | null
15
+ edgeIndex?: number
16
+ edgeNormal?: [number, number]
15
17
  initialPosition: [number, number]
16
18
  initialPolygon: Array<[number, number]>
17
19
  pointerId: number
@@ -28,6 +30,8 @@ export interface PolygonEditorProps {
28
30
  surfaceHeight?: number
29
31
  /** Whether to show the center handle that moves the entire polygon. */
30
32
  allowPolygonMove?: boolean
33
+ /** Whether polygon edges can be dragged along their perpendicular normal. */
34
+ allowEdgeMove?: boolean
31
35
  }
32
36
 
33
37
  /**
@@ -35,6 +39,17 @@ export interface PolygonEditorProps {
35
39
  * Used by zone and site boundary editors
36
40
  */
37
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
+ }
38
53
 
39
54
  export const PolygonEditor: React.FC<PolygonEditorProps> = ({
40
55
  polygon,
@@ -44,6 +59,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
44
59
  levelId,
45
60
  surfaceHeight = 0,
46
61
  allowPolygonMove = false,
62
+ allowEdgeMove = false,
47
63
  }) => {
48
64
  const [levelNode, setLevelNode] = useState<Object3D | null>(() =>
49
65
  levelId ? (sceneRegistry.nodes.get(levelId) ?? null) : null,
@@ -89,6 +105,11 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
89
105
  const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
90
106
  const previewPolygonRef = useRef<Array<[number, number]> | null>(null)
91
107
 
108
+ const updatePreviewPolygon = useCallback((nextPolygon: Array<[number, number]> | null) => {
109
+ previewPolygonRef.current = nextPolygon
110
+ setPreviewPolygon(nextPolygon)
111
+ }, [])
112
+
92
113
  // Keep ref in sync
93
114
  useEffect(() => {
94
115
  previewPolygonRef.current = previewPolygon
@@ -96,6 +117,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
96
117
 
97
118
  const [hoveredVertex, setHoveredVertex] = useState<number | null>(null)
98
119
  const [hoveredMidpoint, setHoveredMidpoint] = useState<number | null>(null)
120
+ const [hoveredEdge, setHoveredEdge] = useState<number | null>(null)
99
121
  const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
100
122
 
101
123
  const lineRef = useRef<Line>(null!)
@@ -106,7 +128,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
106
128
  if (polygon !== lastPolygonRef.current) {
107
129
  lastPolygonRef.current = polygon
108
130
  // External change (e.g. undo/redo) — clear any stale preview/drag state
109
- if (previewPolygon) setPreviewPolygon(null)
131
+ if (previewPolygon) updatePreviewPolygon(null)
110
132
  if (dragState) setDragState(null)
111
133
  }
112
134
 
@@ -134,17 +156,37 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
134
156
  })
135
157
  }, [displayPolygon])
136
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
+
137
181
  // Update vertex position using grid cursor position
138
182
  const handleVertexDrag = useCallback(
139
183
  (vertexIndex: number, position: [number, number]) => {
140
- setPreviewPolygon((prev) => {
141
- const basePolygon = prev ?? polygon
142
- const newPolygon = [...basePolygon]
143
- newPolygon[vertexIndex] = position
144
- return newPolygon
145
- })
184
+ const basePolygon = previewPolygonRef.current ?? polygon
185
+ const newPolygon = [...basePolygon]
186
+ newPolygon[vertexIndex] = position
187
+ updatePreviewPolygon(newPolygon)
146
188
  },
147
- [polygon],
189
+ [polygon, updatePreviewPolygon],
148
190
  )
149
191
 
150
192
  // Commit polygon changes
@@ -152,9 +194,9 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
152
194
  if (previewPolygonRef.current) {
153
195
  onPolygonChange(previewPolygonRef.current)
154
196
  }
155
- setPreviewPolygon(null)
197
+ updatePreviewPolygon(null)
156
198
  setDragState(null)
157
- }, [onPolygonChange])
199
+ }, [onPolygonChange, updatePreviewPolygon])
158
200
 
159
201
  // Handle adding a new vertex at midpoint
160
202
  const handleAddVertex = useCallback(
@@ -166,10 +208,13 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
166
208
  ...basePolygon.slice(afterIndex + 1),
167
209
  ]
168
210
 
169
- setPreviewPolygon(newPolygon)
170
- return afterIndex + 1 // Return new vertex index
211
+ updatePreviewPolygon(newPolygon)
212
+ return {
213
+ polygon: newPolygon,
214
+ vertexIndex: afterIndex + 1,
215
+ }
171
216
  },
172
- [polygon, previewPolygon],
217
+ [polygon, previewPolygon, updatePreviewPolygon],
173
218
  )
174
219
 
175
220
  // Handle deleting a vertex
@@ -180,9 +225,9 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
180
225
 
181
226
  const newPolygon = basePolygon.filter((_, i) => i !== index)
182
227
  onPolygonChange(newPolygon)
183
- setPreviewPolygon(null)
228
+ updatePreviewPolygon(null)
184
229
  },
185
- [polygon, previewPolygon, onPolygonChange, minVertices],
230
+ [polygon, previewPolygon, onPolygonChange, minVertices, updatePreviewPolygon],
186
231
  )
187
232
 
188
233
  // Listen to grid:move events to track cursor position
@@ -212,9 +257,31 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
212
257
  } else if (dragState.mode === 'polygon') {
213
258
  const deltaX = newPosition[0] - dragState.initialPosition[0]
214
259
  const deltaZ = newPosition[1] - dragState.initialPosition[1]
215
- setPreviewPolygon(
260
+ updatePreviewPolygon(
216
261
  dragState.initialPolygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number]),
217
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)
218
285
  }
219
286
  }
220
287
  }
@@ -223,7 +290,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
223
290
  return () => {
224
291
  emitter.off('grid:move', onGridMove)
225
292
  }
226
- }, [dragState, handleVertexDrag])
293
+ }, [dragState, handleVertexDrag, updatePreviewPolygon])
227
294
 
228
295
  // Set up pointer up listener for ending drag
229
296
  useEffect(() => {
@@ -288,6 +355,8 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
288
355
  if (displayPolygon.length < minVertices) return null
289
356
 
290
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
291
360
 
292
361
  const editorContent = (
293
362
  <group>
@@ -316,7 +385,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
316
385
  const isHovered = hoveredVertex === index
317
386
  const isDragging = dragState?.mode === 'vertex' && dragState.vertexIndex === index
318
387
  const radius = 0.1
319
- const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)
388
+ const height = handleHeight
320
389
 
321
390
  return (
322
391
  <mesh
@@ -337,6 +406,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
337
406
  onPointerDown={(e) => {
338
407
  if (e.button !== 0) return
339
408
  e.stopPropagation()
409
+ setHoveredEdge(null)
340
410
  setDragState({
341
411
  isDragging: true,
342
412
  mode: 'vertex',
@@ -375,6 +445,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
375
445
  onPointerDown={(e) => {
376
446
  if (e.button !== 0) return
377
447
  e.stopPropagation()
448
+ setHoveredEdge(null)
378
449
  setDragState({
379
450
  isDragging: true,
380
451
  mode: 'polygon',
@@ -384,23 +455,75 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
384
455
  pointerId: e.pointerId,
385
456
  })
386
457
  }}
387
- position={[
388
- polygonCenter[0],
389
- editY + Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02) + 0.08,
390
- polygonCenter[1],
391
- ]}
458
+ position={[polygonCenter[0], editY + handleHeight + 0.08, polygonCenter[1]]}
392
459
  >
393
460
  <sphereGeometry args={[0.09, 20, 20]} />
394
461
  <meshStandardMaterial color={dragState?.mode === 'polygon' ? '#22c55e' : '#f59e0b'} />
395
462
  </mesh>
396
463
  )}
397
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
+
398
521
  {/* Midpoint handles - smaller green cylinders for adding vertices (hidden while dragging) */}
399
522
  {!dragState &&
400
523
  midpoints.map(([x, z], index) => {
401
524
  const isHovered = hoveredMidpoint === index
402
525
  const radius = 0.06
403
- const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)
526
+ const height = handleHeight
404
527
 
405
528
  return (
406
529
  <mesh
@@ -413,12 +536,14 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
413
536
  onPointerDown={(e) => {
414
537
  if (e.button !== 0) return
415
538
  e.stopPropagation()
416
- const newVertexIndex = handleAddVertex(index, [x!, z!])
417
- if (newVertexIndex >= 0) {
539
+ const insertedVertex = handleAddVertex(index, [x!, z!])
540
+ if (insertedVertex.vertexIndex >= 0) {
418
541
  setDragState({
419
542
  isDragging: true,
420
- vertexIndex: newVertexIndex,
543
+ mode: 'vertex',
544
+ vertexIndex: insertedVertex.vertexIndex,
421
545
  initialPosition: [x!, z!],
546
+ initialPolygon: insertedVertex.polygon,
422
547
  pointerId: e.pointerId,
423
548
  })
424
549
  setHoveredMidpoint(null)
@@ -0,0 +1,156 @@
1
+ import {
2
+ type FenceNode,
3
+ getWallCurveFrameAt,
4
+ getWallCurveLength,
5
+ isCurvedWall,
6
+ type WallNode,
7
+ } from '@pascal-app/core'
8
+
9
+ export type PlanPoint = [number, number]
10
+
11
+ export type SegmentAngleLike = Pick<WallNode | FenceNode, 'start' | 'end' | 'curveOffset'>
12
+
13
+ export type SegmentAngleReference = {
14
+ vector: PlanPoint
15
+ orientation: 'directed' | 'axis'
16
+ }
17
+
18
+ const POINT_MATCH_TOLERANCE = 1e-5
19
+ const SEGMENT_POINT_TOLERANCE = 0.15
20
+ const CURVE_TANGENT_SAMPLE_SPACING = 0.08
21
+
22
+ function distanceSquared(a: PlanPoint, b: PlanPoint) {
23
+ const dx = a[0] - b[0]
24
+ const dz = a[1] - b[1]
25
+
26
+ return dx * dx + dz * dz
27
+ }
28
+
29
+ function pointsMatch(a: PlanPoint, b: PlanPoint, tolerance = POINT_MATCH_TOLERANCE) {
30
+ return distanceSquared(a, b) <= tolerance * tolerance
31
+ }
32
+
33
+ function getProjectedPointOnSegment(point: PlanPoint, segment: SegmentAngleLike): PlanPoint | null {
34
+ const [x1, z1] = segment.start
35
+ const [x2, z2] = segment.end
36
+ const dx = x2 - x1
37
+ const dz = z2 - z1
38
+ const lengthSquared = dx * dx + dz * dz
39
+
40
+ if (lengthSquared < 1e-9) {
41
+ return null
42
+ }
43
+
44
+ const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared
45
+ if (t <= 0 || t >= 1) {
46
+ return null
47
+ }
48
+
49
+ return [x1 + dx * t, z1 + dz * t]
50
+ }
51
+
52
+ function getCurveTangentAtPoint(point: PlanPoint, segment: SegmentAngleLike): PlanPoint | null {
53
+ const curveLength = getWallCurveLength(segment)
54
+ const sampleCount = Math.max(24, Math.ceil(curveLength / CURVE_TANGENT_SAMPLE_SPACING))
55
+ let best: { distance: number; tangent: PlanPoint } | null = null
56
+
57
+ for (let index = 0; index <= sampleCount; index += 1) {
58
+ const frame = getWallCurveFrameAt(segment, index / sampleCount)
59
+ const candidate: PlanPoint = [frame.point.x, frame.point.y]
60
+ const distance = distanceSquared(point, candidate)
61
+
62
+ if (best && distance >= best.distance) {
63
+ continue
64
+ }
65
+
66
+ best = {
67
+ distance,
68
+ tangent: [frame.tangent.x, frame.tangent.y],
69
+ }
70
+ }
71
+
72
+ if (!best || best.distance > SEGMENT_POINT_TOLERANCE * SEGMENT_POINT_TOLERANCE) {
73
+ return null
74
+ }
75
+
76
+ return best.tangent
77
+ }
78
+
79
+ export function formatAngleRadians(angle: number) {
80
+ return `${Math.round((angle * 180) / Math.PI)}°`
81
+ }
82
+
83
+ export function getAngleBetweenVectors(first: PlanPoint, second: PlanPoint): number | null {
84
+ const firstLength = Math.hypot(first[0], first[1])
85
+ const secondLength = Math.hypot(second[0], second[1])
86
+
87
+ if (firstLength < 1e-6 || secondLength < 1e-6) return null
88
+
89
+ const dot = first[0] * second[0] + first[1] * second[1]
90
+ const cosine = Math.min(1, Math.max(-1, dot / (firstLength * secondLength)))
91
+
92
+ return Math.acos(cosine)
93
+ }
94
+
95
+ export function getAngleToSegmentReference(
96
+ vector: PlanPoint,
97
+ reference: SegmentAngleReference,
98
+ ): number | null {
99
+ const angle = getAngleBetweenVectors(vector, reference.vector)
100
+
101
+ if (angle === null || reference.orientation === 'directed') {
102
+ return angle
103
+ }
104
+
105
+ const reverseAngle = getAngleBetweenVectors(vector, [-reference.vector[0], -reference.vector[1]])
106
+
107
+ if (reverseAngle === null) {
108
+ return angle
109
+ }
110
+
111
+ return Math.min(angle, reverseAngle)
112
+ }
113
+
114
+ export function getSegmentAngleReferenceAtPoint(
115
+ point: PlanPoint,
116
+ segment: SegmentAngleLike,
117
+ ): SegmentAngleReference | null {
118
+ if (pointsMatch(point, segment.start)) {
119
+ const frame = getWallCurveFrameAt(segment, 0)
120
+
121
+ return {
122
+ vector: [frame.tangent.x, frame.tangent.y],
123
+ orientation: 'directed',
124
+ }
125
+ }
126
+
127
+ if (pointsMatch(point, segment.end)) {
128
+ const frame = getWallCurveFrameAt(segment, 1)
129
+
130
+ return {
131
+ vector: [-frame.tangent.x, -frame.tangent.y],
132
+ orientation: 'directed',
133
+ }
134
+ }
135
+
136
+ if (isCurvedWall(segment)) {
137
+ const tangent = getCurveTangentAtPoint(point, segment)
138
+
139
+ return tangent
140
+ ? {
141
+ vector: tangent,
142
+ orientation: 'axis',
143
+ }
144
+ : null
145
+ }
146
+
147
+ const projected = getProjectedPointOnSegment(point, segment)
148
+ if (!projected || !pointsMatch(point, projected, SEGMENT_POINT_TOLERANCE)) {
149
+ return null
150
+ }
151
+
152
+ return {
153
+ vector: [segment.end[0] - segment.start[0], segment.end[1] - segment.start[1]],
154
+ orientation: 'axis',
155
+ }
156
+ }
@@ -31,6 +31,7 @@ export const SlabBoundaryEditor: React.FC<SlabBoundaryEditorProps> = ({ slabId }
31
31
 
32
32
  return (
33
33
  <PolygonEditor
34
+ allowEdgeMove
34
35
  color="#a3a3a3"
35
36
  levelId={resolveLevelId(slab, useScene.getState().nodes)}
36
37
  minVertices={3}
@@ -36,6 +36,7 @@ export const SlabHoleEditor: React.FC<SlabHoleEditorProps> = ({ slabId, holeInde
36
36
 
37
37
  return (
38
38
  <PolygonEditor
39
+ allowEdgeMove
39
40
  allowPolygonMove
40
41
  color="#ef4444"
41
42
  levelId={resolveLevelId(slab, useScene.getState().nodes)} // red for holes
@@ -0,0 +1,101 @@
1
+ import '../../../three-types'
2
+
3
+ import {
4
+ emitter,
5
+ type GridEvent,
6
+ type SpawnNode,
7
+ sceneRegistry,
8
+ useLiveTransforms,
9
+ useScene,
10
+ } from '@pascal-app/core'
11
+ import { useCallback, useEffect, useState } from 'react'
12
+ import { Vector3 } from 'three'
13
+ import { sfxEmitter } from '../../../lib/sfx-bus'
14
+ import useEditor from '../../../store/use-editor'
15
+ import { CursorSphere } from '../shared/cursor-sphere'
16
+
17
+ const roundToHalf = (value: number) => Math.round(value * 2) / 2
18
+ const worldVector = new Vector3()
19
+
20
+ function getLevelLocalSpawnPosition(node: SpawnNode, event: GridEvent): [number, number, number] {
21
+ const levelObject = node.parentId ? sceneRegistry.nodes.get(node.parentId) : null
22
+ if (!levelObject) {
23
+ return [
24
+ roundToHalf(event.localPosition[0]),
25
+ event.localPosition[1],
26
+ roundToHalf(event.localPosition[2]),
27
+ ]
28
+ }
29
+
30
+ worldVector.set(event.position[0], event.position[1], event.position[2])
31
+ levelObject.updateWorldMatrix(true, false)
32
+ levelObject.worldToLocal(worldVector)
33
+
34
+ return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)]
35
+ }
36
+
37
+ export const MoveSpawnTool: React.FC<{
38
+ node: SpawnNode
39
+ onCommitted?: (nodeId: SpawnNode['id']) => void
40
+ }> = ({ node, onCommitted }) => {
41
+ const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position)
42
+
43
+ const exitMoveMode = useCallback(() => {
44
+ useEditor.getState().setMovingNode(null)
45
+ }, [])
46
+
47
+ useEffect(() => {
48
+ useScene.temporal.getState().pause()
49
+
50
+ let committed = false
51
+
52
+ const onGridMove = (event: GridEvent) => {
53
+ const nextPosition: [number, number, number] = [
54
+ roundToHalf(event.localPosition[0]),
55
+ event.localPosition[1],
56
+ roundToHalf(event.localPosition[2]),
57
+ ]
58
+ setPreviewPosition(nextPosition)
59
+ useLiveTransforms.getState().set(node.id, {
60
+ position: [...nextPosition],
61
+ rotation: node.rotation,
62
+ })
63
+ }
64
+
65
+ const onGridClick = (event: GridEvent) => {
66
+ const nextPosition = getLevelLocalSpawnPosition(node, event)
67
+
68
+ committed = true
69
+ useScene.temporal.getState().resume()
70
+ useScene.getState().updateNode(node.id, { position: nextPosition })
71
+ onCommitted?.(node.id)
72
+ useLiveTransforms.getState().clear(node.id)
73
+ sfxEmitter.emit('sfx:item-place')
74
+ exitMoveMode()
75
+ }
76
+
77
+ const onCancel = () => {
78
+ useLiveTransforms.getState().clear(node.id)
79
+ useScene.temporal.getState().resume()
80
+ exitMoveMode()
81
+ }
82
+
83
+ emitter.on('grid:move', onGridMove)
84
+ emitter.on('grid:click', onGridClick)
85
+ emitter.on('tool:cancel', onCancel)
86
+
87
+ return () => {
88
+ emitter.off('grid:move', onGridMove)
89
+ emitter.off('grid:click', onGridClick)
90
+ emitter.off('tool:cancel', onCancel)
91
+ useLiveTransforms.getState().clear(node.id)
92
+ if (!committed) {
93
+ useScene.temporal.getState().resume()
94
+ }
95
+ }
96
+ }, [exitMoveMode, node, onCommitted])
97
+
98
+ return (
99
+ <CursorSphere color="#60a5fa" height={2.2} position={previewPosition} showTooltip={false} />
100
+ )
101
+ }