@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
@@ -7,19 +7,129 @@ import {
7
7
  type WallNode,
8
8
  } from '@pascal-app/core'
9
9
  import { useViewer } from '@pascal-app/viewer'
10
- import { useEffect, useRef } from 'react'
10
+ import { Html } from '@react-three/drei'
11
+ import { useEffect, useRef, useState } from 'react'
11
12
  import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
12
13
  import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
13
14
  import { EDITOR_LAYER } from '../../../lib/constants'
14
15
  import { sfxEmitter } from '../../../lib/sfx-bus'
15
16
  import { CursorSphere } from '../shared/cursor-sphere'
17
+ import {
18
+ formatAngleRadians,
19
+ getAngleToSegmentReference,
20
+ getSegmentAngleReferenceAtPoint,
21
+ } from '../shared/segment-angle'
16
22
  import {
17
23
  createFenceOnCurrentLevel,
18
- snapFenceDraftPoint,
19
24
  type FencePlanPoint,
25
+ snapFenceDraftPoint,
20
26
  } from './fence-drafting'
21
27
 
22
28
  const FENCE_PREVIEW_HEIGHT = 1.8
29
+ const DRAFT_LABEL_Y = FENCE_PREVIEW_HEIGHT + 0.22
30
+ const DRAFT_ANGLE_LABEL_Y = 0.28
31
+
32
+ type DraftAngleLabel = {
33
+ id: string
34
+ label: string
35
+ position: [number, number, number]
36
+ }
37
+
38
+ type DraftMeasurementState = {
39
+ lengthLabel: string
40
+ lengthPosition: [number, number, number]
41
+ angleLabels: DraftAngleLabel[]
42
+ } | null
43
+
44
+ type SegmentLike = {
45
+ id: string
46
+ start: FencePlanPoint
47
+ end: FencePlanPoint
48
+ curveOffset?: number
49
+ }
50
+
51
+ function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
52
+ if (unit === 'imperial') {
53
+ const feet = value * 3.280_84
54
+ const wholeFeet = Math.floor(feet)
55
+ const inches = Math.round((feet - wholeFeet) * 12)
56
+ if (inches === 12) return `${wholeFeet + 1}'0"`
57
+ return `${wholeFeet}'${inches}"`
58
+ }
59
+
60
+ return `${Number.parseFloat(value.toFixed(2))}m`
61
+ }
62
+
63
+ function getDraftAngleLabels(
64
+ start: FencePlanPoint,
65
+ end: FencePlanPoint,
66
+ segments: SegmentLike[],
67
+ ): DraftAngleLabel[] {
68
+ const draftFromStart: FencePlanPoint = [end[0] - start[0], end[1] - start[1]]
69
+ const draftFromEnd: FencePlanPoint = [start[0] - end[0], start[1] - end[1]]
70
+ const endpoints = [
71
+ { id: 'start', point: start, draftVector: draftFromStart },
72
+ { id: 'end', point: end, draftVector: draftFromEnd },
73
+ ]
74
+ const labels: DraftAngleLabel[] = []
75
+
76
+ for (const endpoint of endpoints) {
77
+ const connectedSegment = segments.find((segment) =>
78
+ Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, segment)),
79
+ )
80
+ if (!connectedSegment) continue
81
+
82
+ const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedSegment)
83
+ if (!connectedReference) continue
84
+
85
+ const angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference)
86
+ if (angle === null) continue
87
+
88
+ labels.push({
89
+ id: endpoint.id,
90
+ label: formatAngleRadians(angle),
91
+ position: [endpoint.point[0], DRAFT_ANGLE_LABEL_Y, endpoint.point[1]],
92
+ })
93
+ }
94
+
95
+ return labels
96
+ }
97
+
98
+ function getDraftMeasurementState(
99
+ start: FencePlanPoint,
100
+ end: FencePlanPoint,
101
+ segments: SegmentLike[],
102
+ unit: 'metric' | 'imperial',
103
+ ): DraftMeasurementState {
104
+ const dx = end[0] - start[0]
105
+ const dz = end[1] - start[1]
106
+ const length = Math.hypot(dx, dz)
107
+
108
+ if (length < 0.01) return null
109
+
110
+ return {
111
+ lengthLabel: formatMeasurement(length, unit),
112
+ lengthPosition: [(start[0] + end[0]) / 2, DRAFT_LABEL_Y, (start[1] + end[1]) / 2],
113
+ angleLabels: getDraftAngleLabels(start, end, segments),
114
+ }
115
+ }
116
+
117
+ function getReferenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLike[] {
118
+ return [
119
+ ...walls.map((wall) => ({
120
+ id: wall.id,
121
+ start: wall.start,
122
+ end: wall.end,
123
+ curveOffset: wall.curveOffset,
124
+ })),
125
+ ...fences.map((fence) => ({
126
+ id: fence.id,
127
+ start: fence.start,
128
+ end: fence.end,
129
+ curveOffset: fence.curveOffset,
130
+ })),
131
+ ]
132
+ }
23
133
 
24
134
  const updateFencePreview = (mesh: Mesh, start: Vector3, end: Vector3) => {
25
135
  const direction = new Vector3(end.x - start.x, 0, end.z - start.z)
@@ -70,12 +180,14 @@ const getCurrentLevelElements = (): { walls: WallNode[]; fences: FenceNode[] } =
70
180
  }
71
181
 
72
182
  export const FenceTool: React.FC = () => {
183
+ const unit = useViewer((state) => state.unit)
73
184
  const cursorRef = useRef<Group>(null)
74
185
  const previewRef = useRef<Mesh>(null!)
75
186
  const startingPoint = useRef(new Vector3(0, 0, 0))
76
187
  const endingPoint = useRef(new Vector3(0, 0, 0))
77
188
  const buildingState = useRef(0)
78
189
  const shiftPressed = useRef(false)
190
+ const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
79
191
 
80
192
  useEffect(() => {
81
193
  let previousFenceEnd: [number, number] | null = null
@@ -107,9 +219,18 @@ export const FenceTool: React.FC = () => {
107
219
  previousFenceEnd = currentFenceEnd
108
220
 
109
221
  updateFencePreview(previewRef.current, startingPoint.current, endingPoint.current)
222
+ setDraftMeasurement(
223
+ getDraftMeasurementState(
224
+ [startingPoint.current.x, startingPoint.current.z],
225
+ snappedLocal,
226
+ getReferenceSegments(walls, fences),
227
+ unit,
228
+ ),
229
+ )
110
230
  } else {
111
231
  const snappedPoint = snapFenceDraftPoint({ point: localPoint, walls, fences })
112
232
  cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1])
233
+ setDraftMeasurement(null)
113
234
  }
114
235
  }
115
236
 
@@ -123,6 +244,7 @@ export const FenceTool: React.FC = () => {
123
244
  endingPoint.current.copy(startingPoint.current)
124
245
  buildingState.current = 1
125
246
  previewRef.current.visible = true
247
+ setDraftMeasurement(null)
126
248
  } else {
127
249
  const snappedEnd = snapFenceDraftPoint({
128
250
  point: localClick,
@@ -137,6 +259,7 @@ export const FenceTool: React.FC = () => {
137
259
  createFenceOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd)
138
260
  previewRef.current.visible = false
139
261
  buildingState.current = 0
262
+ setDraftMeasurement(null)
140
263
  }
141
264
  }
142
265
 
@@ -153,6 +276,7 @@ export const FenceTool: React.FC = () => {
153
276
  markToolCancelConsumed()
154
277
  buildingState.current = 0
155
278
  previewRef.current.visible = false
279
+ setDraftMeasurement(null)
156
280
  }
157
281
  }
158
282
 
@@ -169,7 +293,7 @@ export const FenceTool: React.FC = () => {
169
293
  window.removeEventListener('keydown', onKeyDown)
170
294
  window.removeEventListener('keyup', onKeyUp)
171
295
  }
172
- }, [])
296
+ }, [unit])
173
297
 
174
298
  return (
175
299
  <group>
@@ -185,6 +309,38 @@ export const FenceTool: React.FC = () => {
185
309
  transparent
186
310
  />
187
311
  </mesh>
312
+
313
+ {draftMeasurement && (
314
+ <>
315
+ <DraftMeasurementLabel
316
+ label={draftMeasurement.lengthLabel}
317
+ position={draftMeasurement.lengthPosition}
318
+ />
319
+ {draftMeasurement.angleLabels.map((angleLabel) => (
320
+ <DraftMeasurementLabel
321
+ key={angleLabel.id}
322
+ label={angleLabel.label}
323
+ position={angleLabel.position}
324
+ />
325
+ ))}
326
+ </>
327
+ )}
188
328
  </group>
189
329
  )
190
330
  }
331
+
332
+ function DraftMeasurementLabel({
333
+ label,
334
+ position,
335
+ }: {
336
+ label: string
337
+ position: [number, number, number]
338
+ }) {
339
+ return (
340
+ <Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
341
+ <div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md">
342
+ {label}
343
+ </div>
344
+ </Html>
345
+ )
346
+ }
@@ -0,0 +1,438 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNodeId,
5
+ emitter,
6
+ type FenceNode,
7
+ type GridEvent,
8
+ pauseSceneHistory,
9
+ resumeSceneHistory,
10
+ useScene,
11
+ type WallNode,
12
+ } from '@pascal-app/core'
13
+ import { useViewer } from '@pascal-app/viewer'
14
+ import { Html } from '@react-three/drei'
15
+ import { useCallback, useEffect, useRef, useState } from 'react'
16
+ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
17
+ import { sfxEmitter } from '../../../lib/sfx-bus'
18
+ import useEditor, { type MovingFenceEndpoint } from '../../../store/use-editor'
19
+ import { CursorSphere } from '../shared/cursor-sphere'
20
+ import {
21
+ formatAngleRadians,
22
+ getAngleToSegmentReference,
23
+ getSegmentAngleReferenceAtPoint,
24
+ } from '../shared/segment-angle'
25
+ import { isWallLongEnough } from '../wall/wall-drafting'
26
+ import { type FencePlanPoint, snapFenceDraftPoint } from './fence-drafting'
27
+
28
+ function samePoint(a: FencePlanPoint, b: FencePlanPoint) {
29
+ return a[0] === b[0] && a[1] === b[1]
30
+ }
31
+
32
+ type SegmentLike = {
33
+ id: string
34
+ start: FencePlanPoint
35
+ end: FencePlanPoint
36
+ curveOffset?: number
37
+ }
38
+
39
+ type AngleLabelState = {
40
+ label: string
41
+ position: [number, number, number]
42
+ } | null
43
+
44
+ function getEndpointAngleLabel(args: {
45
+ preview: { start: FencePlanPoint; end: FencePlanPoint; curveOffset?: number }
46
+ segments: SegmentLike[]
47
+ nodeId: FenceNode['id']
48
+ }): AngleLabelState {
49
+ const { preview, segments, nodeId } = args
50
+ const endpoints = [
51
+ {
52
+ point: preview.start,
53
+ },
54
+ {
55
+ point: preview.end,
56
+ },
57
+ ]
58
+ const targetSegment: SegmentLike = {
59
+ id: nodeId,
60
+ start: preview.start,
61
+ end: preview.end,
62
+ curveOffset: preview.curveOffset,
63
+ }
64
+
65
+ for (const endpoint of endpoints) {
66
+ const targetReference = getSegmentAngleReferenceAtPoint(endpoint.point, targetSegment)
67
+ if (!targetReference) continue
68
+
69
+ const connectedSegment = segments.find(
70
+ (segment) =>
71
+ segment.id !== nodeId && Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, segment)),
72
+ )
73
+ if (!connectedSegment) continue
74
+
75
+ const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedSegment)
76
+ if (!connectedReference) continue
77
+
78
+ const angle = getAngleToSegmentReference(targetReference.vector, connectedReference)
79
+ if (angle === null) continue
80
+
81
+ return {
82
+ label: formatAngleRadians(angle),
83
+ position: [endpoint.point[0], 0.34, endpoint.point[1]],
84
+ }
85
+ }
86
+
87
+ return null
88
+ }
89
+
90
+ function getReferenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLike[] {
91
+ return [
92
+ ...walls.map((wall) => ({
93
+ id: wall.id,
94
+ start: wall.start,
95
+ end: wall.end,
96
+ curveOffset: wall.curveOffset,
97
+ })),
98
+ ...fences.map((fence) => ({
99
+ id: fence.id,
100
+ start: fence.start,
101
+ end: fence.end,
102
+ curveOffset: fence.curveOffset,
103
+ })),
104
+ ]
105
+ }
106
+
107
+ type LinkedFenceSnapshot = {
108
+ id: FenceNode['id']
109
+ start: FencePlanPoint
110
+ end: FencePlanPoint
111
+ curveOffset?: number
112
+ }
113
+
114
+ function getLinkedFenceSnapshots(args: {
115
+ fenceId: FenceNode['id']
116
+ fenceParentId: string | null
117
+ originalStart: FencePlanPoint
118
+ originalEnd: FencePlanPoint
119
+ }) {
120
+ const { fenceId, fenceParentId, originalStart, originalEnd } = args
121
+ const { nodes } = useScene.getState()
122
+ const snapshots: LinkedFenceSnapshot[] = []
123
+
124
+ for (const node of Object.values(nodes)) {
125
+ if (!(node?.type === 'fence' && node.id !== fenceId)) {
126
+ continue
127
+ }
128
+
129
+ if ((node.parentId ?? null) !== fenceParentId) {
130
+ continue
131
+ }
132
+
133
+ if (
134
+ !samePoint(node.start, originalStart) &&
135
+ !samePoint(node.start, originalEnd) &&
136
+ !samePoint(node.end, originalStart) &&
137
+ !samePoint(node.end, originalEnd)
138
+ ) {
139
+ continue
140
+ }
141
+
142
+ snapshots.push({
143
+ id: node.id,
144
+ start: [...node.start] as FencePlanPoint,
145
+ end: [...node.end] as FencePlanPoint,
146
+ curveOffset: node.curveOffset,
147
+ })
148
+ }
149
+
150
+ return snapshots
151
+ }
152
+
153
+ function getLinkedFenceUpdates(
154
+ linkedFences: LinkedFenceSnapshot[],
155
+ originalStart: FencePlanPoint,
156
+ originalEnd: FencePlanPoint,
157
+ nextStart: FencePlanPoint,
158
+ nextEnd: FencePlanPoint,
159
+ ) {
160
+ return linkedFences.map((fence) => ({
161
+ id: fence.id,
162
+ curveOffset: fence.curveOffset,
163
+ start: samePoint(fence.start, originalStart)
164
+ ? nextStart
165
+ : samePoint(fence.start, originalEnd)
166
+ ? nextEnd
167
+ : fence.start,
168
+ end: samePoint(fence.end, originalStart)
169
+ ? nextStart
170
+ : samePoint(fence.end, originalEnd)
171
+ ? nextEnd
172
+ : fence.end,
173
+ }))
174
+ }
175
+
176
+ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = ({ target }) => {
177
+ const activatedAtRef = useRef<number>(Date.now())
178
+ const previousGridPosRef = useRef<FencePlanPoint | null>(null)
179
+ const shiftPressedRef = useRef(false)
180
+ const altPressedRef = useRef(false)
181
+ const nodeIdRef = useRef(target.fence.id)
182
+ const originalStartRef = useRef<FencePlanPoint>([...target.fence.start] as FencePlanPoint)
183
+ const originalEndRef = useRef<FencePlanPoint>([...target.fence.end] as FencePlanPoint)
184
+ const fixedPointRef = useRef<FencePlanPoint>(
185
+ target.endpoint === 'start'
186
+ ? ([...target.fence.end] as FencePlanPoint)
187
+ : ([...target.fence.start] as FencePlanPoint),
188
+ )
189
+ const linkedOriginalsRef = useRef(
190
+ getLinkedFenceSnapshots({
191
+ fenceId: target.fence.id,
192
+ fenceParentId: target.fence.parentId ?? null,
193
+ originalStart: target.fence.start,
194
+ originalEnd: target.fence.end,
195
+ }),
196
+ )
197
+ const previewRef = useRef<{ start: FencePlanPoint; end: FencePlanPoint } | null>(null)
198
+ const [angleLabel, setAngleLabel] = useState<AngleLabelState>(null)
199
+
200
+ const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
201
+ const point = target.endpoint === 'start' ? target.fence.start : target.fence.end
202
+ return [point[0], 0, point[1]]
203
+ })
204
+ const [altPressed, setAltPressed] = useState(false)
205
+
206
+ const exitMoveMode = useCallback(() => {
207
+ useEditor.getState().setMovingFenceEndpoint(null)
208
+ }, [])
209
+
210
+ useEffect(() => {
211
+ const nodeId = nodeIdRef.current
212
+ const originalStart = originalStartRef.current
213
+ const originalEnd = originalEndRef.current
214
+ const fixedPoint = fixedPointRef.current
215
+ const siblings = Object.values(useScene.getState().nodes)
216
+ const levelWalls = siblings.filter(
217
+ (node): node is WallNode =>
218
+ node?.type === 'wall' && (node.parentId ?? null) === (target.fence.parentId ?? null),
219
+ )
220
+ const levelFences = siblings.filter(
221
+ (node): node is FenceNode =>
222
+ node?.type === 'fence' && (node.parentId ?? null) === (target.fence.parentId ?? null),
223
+ )
224
+
225
+ pauseSceneHistory(useScene)
226
+ let wasCommitted = false
227
+
228
+ const applyNodePreview = (
229
+ updates: Array<{ id: FenceNode['id']; start: FencePlanPoint; end: FencePlanPoint }>,
230
+ ) => {
231
+ useScene.getState().updateNodes(
232
+ updates.map((entry) => ({
233
+ id: entry.id as AnyNodeId,
234
+ data: { start: entry.start, end: entry.end },
235
+ })),
236
+ )
237
+ for (const entry of updates) {
238
+ useScene.getState().markDirty(entry.id as AnyNodeId)
239
+ }
240
+ }
241
+
242
+ const applyPreview = (movingPoint: FencePlanPoint, detachLinkedFences = false) => {
243
+ const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
244
+ const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
245
+ const linkedUpdates = detachLinkedFences
246
+ ? []
247
+ : getLinkedFenceUpdates(
248
+ linkedOriginalsRef.current,
249
+ originalStart,
250
+ originalEnd,
251
+ nextStart,
252
+ nextEnd,
253
+ )
254
+ previewRef.current = { start: nextStart, end: nextEnd }
255
+ setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
256
+ setAngleLabel(
257
+ getEndpointAngleLabel({
258
+ preview: { start: nextStart, end: nextEnd, curveOffset: target.fence.curveOffset },
259
+ segments: [...getReferenceSegments(levelWalls, levelFences), ...linkedUpdates],
260
+ nodeId,
261
+ }),
262
+ )
263
+ applyNodePreview([{ id: nodeId, start: nextStart, end: nextEnd }, ...linkedUpdates])
264
+ }
265
+
266
+ const restoreOriginal = (clearAngleLabel = true) => {
267
+ applyNodePreview([
268
+ { id: nodeId, start: originalStart, end: originalEnd },
269
+ ...linkedOriginalsRef.current,
270
+ ])
271
+ if (clearAngleLabel) {
272
+ setAngleLabel(null)
273
+ }
274
+ }
275
+
276
+ const onGridMove = (event: GridEvent) => {
277
+ const planPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
278
+ const snappedPoint = snapFenceDraftPoint({
279
+ point: planPoint,
280
+ walls: levelWalls,
281
+ fences: levelFences,
282
+ start: fixedPoint,
283
+ angleSnap: !shiftPressedRef.current,
284
+ ignoreFenceIds: [nodeId],
285
+ })
286
+
287
+ if (
288
+ previousGridPosRef.current &&
289
+ (snappedPoint[0] !== previousGridPosRef.current[0] ||
290
+ snappedPoint[1] !== previousGridPosRef.current[1])
291
+ ) {
292
+ sfxEmitter.emit('sfx:grid-snap')
293
+ }
294
+ previousGridPosRef.current = snappedPoint
295
+
296
+ applyPreview(snappedPoint, event.nativeEvent.altKey)
297
+ }
298
+
299
+ const onGridClick = (event: GridEvent) => {
300
+ if (Date.now() - activatedAtRef.current < 150) {
301
+ event.nativeEvent?.stopPropagation?.()
302
+ return
303
+ }
304
+
305
+ const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
306
+ const hasChanged =
307
+ !samePoint(preview.start, originalStart) || !samePoint(preview.end, originalEnd)
308
+
309
+ if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
310
+ wasCommitted = true
311
+
312
+ applyNodePreview([
313
+ { id: nodeId, start: originalStart, end: originalEnd },
314
+ ...linkedOriginalsRef.current,
315
+ ])
316
+
317
+ resumeSceneHistory(useScene)
318
+ applyNodePreview([
319
+ { id: nodeId, start: preview.start, end: preview.end },
320
+ ...(altPressedRef.current
321
+ ? []
322
+ : getLinkedFenceUpdates(
323
+ linkedOriginalsRef.current,
324
+ originalStart,
325
+ originalEnd,
326
+ preview.start,
327
+ preview.end,
328
+ )),
329
+ ])
330
+ pauseSceneHistory(useScene)
331
+ sfxEmitter.emit('sfx:item-place')
332
+ }
333
+
334
+ useViewer.getState().setSelection({ selectedIds: [nodeId] })
335
+ setAngleLabel(null)
336
+ exitMoveMode()
337
+ event.nativeEvent?.stopPropagation?.()
338
+ }
339
+
340
+ const onCancel = () => {
341
+ restoreOriginal()
342
+ useViewer.getState().setSelection({ selectedIds: [nodeId] })
343
+ resumeSceneHistory(useScene)
344
+ setAngleLabel(null)
345
+ markToolCancelConsumed()
346
+ exitMoveMode()
347
+ }
348
+
349
+ const onKeyDown = (event: KeyboardEvent) => {
350
+ if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
351
+ return
352
+ }
353
+ if (event.key === 'Shift') {
354
+ shiftPressedRef.current = true
355
+ }
356
+ if (event.key === 'Alt') {
357
+ altPressedRef.current = true
358
+ setAltPressed(true)
359
+ }
360
+ }
361
+
362
+ const onKeyUp = (event: KeyboardEvent) => {
363
+ if (event.key === 'Shift') {
364
+ shiftPressedRef.current = false
365
+ }
366
+ if (event.key === 'Alt') {
367
+ altPressedRef.current = false
368
+ setAltPressed(false)
369
+ }
370
+ }
371
+
372
+ const onWindowBlur = () => {
373
+ shiftPressedRef.current = false
374
+ altPressedRef.current = false
375
+ setAltPressed(false)
376
+ }
377
+
378
+ emitter.on('grid:move', onGridMove)
379
+ emitter.on('grid:click', onGridClick)
380
+ emitter.on('tool:cancel', onCancel)
381
+ window.addEventListener('keydown', onKeyDown)
382
+ window.addEventListener('keyup', onKeyUp)
383
+ window.addEventListener('blur', onWindowBlur)
384
+
385
+ return () => {
386
+ if (!wasCommitted) {
387
+ restoreOriginal(false)
388
+ }
389
+ resumeSceneHistory(useScene)
390
+ emitter.off('grid:move', onGridMove)
391
+ emitter.off('grid:click', onGridClick)
392
+ emitter.off('tool:cancel', onCancel)
393
+ window.removeEventListener('keydown', onKeyDown)
394
+ window.removeEventListener('keyup', onKeyUp)
395
+ window.removeEventListener('blur', onWindowBlur)
396
+ }
397
+ }, [exitMoveMode, target])
398
+
399
+ return (
400
+ <group>
401
+ <CursorSphere position={cursorLocalPos} showTooltip={false} />
402
+ <Html
403
+ position={[cursorLocalPos[0], 0, cursorLocalPos[2]]}
404
+ style={{ pointerEvents: 'none', touchAction: 'none' }}
405
+ zIndexRange={[100, 0]}
406
+ >
407
+ <div className="translate-y-10">
408
+ <div
409
+ className={`whitespace-nowrap rounded-full border px-2 py-1 text-[11px] font-medium shadow-lg backdrop-blur-md transition-colors ${
410
+ altPressed
411
+ ? 'border-amber-500/70 bg-amber-500/15 text-amber-100'
412
+ : 'border-border/70 bg-background/90 text-foreground/80'
413
+ }`}
414
+ >
415
+ {altPressed ? 'Detach endpoint' : 'Drag endpoint'}
416
+ </div>
417
+ </div>
418
+ </Html>
419
+ {angleLabel && <EndpointAngleLabel label={angleLabel.label} position={angleLabel.position} />}
420
+ </group>
421
+ )
422
+ }
423
+
424
+ function EndpointAngleLabel({
425
+ label,
426
+ position,
427
+ }: {
428
+ label: string
429
+ position: [number, number, number]
430
+ }) {
431
+ return (
432
+ <Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
433
+ <div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md">
434
+ {label}
435
+ </div>
436
+ </Html>
437
+ )
438
+ }