@pascal-app/editor 0.6.0 → 0.8.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 (157) hide show
  1. package/package.json +13 -9
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +74 -5
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +24 -3
  6. package/src/components/editor/first-person/build-collider-world.ts +363 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +860 -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 +9861 -3297
  12. package/src/components/editor/index.tsx +295 -32
  13. package/src/components/editor/selection-manager.tsx +575 -13
  14. package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
  15. package/src/components/editor/thumbnail-generator.tsx +56 -68
  16. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  17. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  18. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  19. package/src/components/editor/wall-measurement-label.tsx +267 -36
  20. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  21. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  22. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  23. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +124 -0
  24. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  25. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +202 -0
  26. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  27. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  28. package/src/components/editor-2d/svg-paths.ts +119 -0
  29. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +10 -12
  30. package/src/components/systems/roof/roof-edit-system.tsx +1 -1
  31. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  32. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  33. package/src/components/systems/zone/zone-system.tsx +0 -0
  34. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  35. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  36. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  37. package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
  38. package/src/components/tools/column/column-tool.tsx +97 -0
  39. package/src/components/tools/column/move-column-tool.tsx +105 -0
  40. package/src/components/tools/door/door-tool.tsx +7 -0
  41. package/src/components/tools/door/move-door-tool.tsx +28 -8
  42. package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
  43. package/src/components/tools/fence/fence-drafting.ts +10 -3
  44. package/src/components/tools/fence/fence-tool.tsx +160 -4
  45. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +139 -25
  46. package/src/components/tools/fence/move-fence-tool.tsx +111 -40
  47. package/src/components/tools/item/move-tool.tsx +7 -1
  48. package/src/components/tools/item/placement-math.ts +32 -5
  49. package/src/components/tools/item/placement-strategies.ts +110 -31
  50. package/src/components/tools/item/placement-types.ts +7 -0
  51. package/src/components/tools/item/use-draft-node.ts +1 -0
  52. package/src/components/tools/item/use-placement-coordinator.tsx +558 -52
  53. package/src/components/tools/roof/move-roof-tool.tsx +29 -17
  54. package/src/components/tools/select/box-select-tool.tsx +12 -17
  55. package/src/components/tools/shared/polygon-editor.tsx +153 -28
  56. package/src/components/tools/shared/segment-angle.ts +156 -0
  57. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  58. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  59. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  60. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  61. package/src/components/tools/tool-manager.tsx +20 -5
  62. package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +131 -27
  64. package/src/components/tools/wall/move-wall-tool.tsx +6 -4
  65. package/src/components/tools/wall/wall-drafting.ts +18 -9
  66. package/src/components/tools/wall/wall-tool.tsx +136 -4
  67. package/src/components/tools/window/move-window-tool.tsx +18 -0
  68. package/src/components/tools/window/window-tool.tsx +5 -0
  69. package/src/components/tools/zone/zone-tool.tsx +20 -5
  70. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  71. package/src/components/ui/action-menu/control-modes.tsx +34 -1
  72. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  73. package/src/components/ui/action-menu/index.tsx +98 -59
  74. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  75. package/src/components/ui/action-menu/view-toggles.tsx +418 -41
  76. package/src/components/ui/command-palette/editor-commands.tsx +24 -5
  77. package/src/components/ui/command-palette/index.tsx +4 -255
  78. package/src/components/ui/controls/material-picker.tsx +154 -164
  79. package/src/components/ui/controls/slider-control.tsx +66 -18
  80. package/src/components/ui/floating-level-selector.tsx +286 -55
  81. package/src/components/ui/helpers/helper-manager.tsx +10 -0
  82. package/src/components/ui/item-catalog/catalog-items.tsx +2563 -1239
  83. package/src/components/ui/item-catalog/item-catalog.tsx +96 -187
  84. package/src/components/ui/level-duplicate-dialog.tsx +113 -0
  85. package/src/components/ui/panels/ceiling-panel.tsx +3 -28
  86. package/src/components/ui/panels/column-panel.tsx +759 -0
  87. package/src/components/ui/panels/door-panel.tsx +989 -290
  88. package/src/components/ui/panels/fence-panel.tsx +2 -49
  89. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  90. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  91. package/src/components/ui/panels/node-display.ts +39 -0
  92. package/src/components/ui/panels/paint-panel.tsx +163 -0
  93. package/src/components/ui/panels/panel-manager.tsx +208 -28
  94. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  95. package/src/components/ui/panels/reference-panel.tsx +253 -5
  96. package/src/components/ui/panels/roof-panel.tsx +13 -64
  97. package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
  98. package/src/components/ui/panels/slab-panel.tsx +4 -30
  99. package/src/components/ui/panels/spawn-panel.tsx +161 -0
  100. package/src/components/ui/panels/stair-panel.tsx +20 -74
  101. package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
  102. package/src/components/ui/panels/wall-panel.tsx +10 -8
  103. package/src/components/ui/panels/window-panel.tsx +668 -139
  104. package/src/components/ui/primitives/number-input.tsx +1 -1
  105. package/src/components/ui/primitives/sidebar.tsx +0 -0
  106. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  107. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  108. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  109. package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
  110. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  112. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
  113. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
  114. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  115. package/src/components/ui/sidebar/panels/site-panel/index.tsx +105 -22
  116. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
  117. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
  118. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +76 -0
  119. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +11 -3
  120. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +10 -5
  121. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/slider.tsx +1 -1
  124. package/src/components/viewer-overlay.tsx +0 -0
  125. package/src/components/viewer-zone-system.tsx +0 -0
  126. package/src/hooks/use-auto-frame.ts +45 -0
  127. package/src/hooks/use-auto-save.ts +14 -0
  128. package/src/hooks/use-keyboard.ts +74 -7
  129. package/src/hooks/use-mobile.ts +12 -12
  130. package/src/index.tsx +8 -1
  131. package/src/lib/door-interaction.ts +88 -0
  132. package/src/lib/floorplan/geometry.ts +263 -0
  133. package/src/lib/floorplan/index.ts +38 -0
  134. package/src/lib/floorplan/items.ts +179 -0
  135. package/src/lib/floorplan/selection-tool.ts +231 -0
  136. package/src/lib/floorplan/stairs.ts +478 -0
  137. package/src/lib/floorplan/types.ts +57 -0
  138. package/src/lib/floorplan/walls.ts +23 -0
  139. package/src/lib/guide-events.ts +10 -0
  140. package/src/lib/level-duplication.test.ts +70 -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/scene.ts +0 -0
  148. package/src/lib/sfx-bus.ts +2 -0
  149. package/src/lib/sfx-player.ts +5 -5
  150. package/src/lib/stair-duplication.ts +126 -0
  151. package/src/lib/window-interaction.ts +86 -0
  152. package/src/store/use-editor.tsx +186 -62
  153. package/tsconfig.json +2 -1
  154. package/src/components/feedback-dialog.tsx +0 -265
  155. package/src/components/pascal-radio.tsx +0 -280
  156. package/src/components/preview-button.tsx +0 -16
  157. package/src/components/ui/viewer-toolbar.tsx +0 -395
@@ -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,11 +293,11 @@ 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>
176
- <CursorSphere ref={cursorRef} height={FENCE_PREVIEW_HEIGHT} />
300
+ <CursorSphere height={FENCE_PREVIEW_HEIGHT} ref={cursorRef} />
177
301
  <mesh layers={EDITOR_LAYER} ref={previewRef} renderOrder={1} visible={false}>
178
302
  <shapeGeometry />
179
303
  <meshBasicMaterial
@@ -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 font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
342
+ {label}
343
+ </div>
344
+ </Html>
345
+ )
346
+ }
@@ -2,32 +2,113 @@
2
2
 
3
3
  import {
4
4
  type AnyNodeId,
5
- type FenceNode,
6
- type WallNode,
7
5
  emitter,
6
+ type FenceNode,
8
7
  type GridEvent,
9
8
  pauseSceneHistory,
10
9
  resumeSceneHistory,
11
10
  useScene,
11
+ type WallNode,
12
12
  } from '@pascal-app/core'
13
- import { Html } from '@react-three/drei'
14
13
  import { useViewer } from '@pascal-app/viewer'
14
+ import { Html } from '@react-three/drei'
15
15
  import { useCallback, useEffect, useRef, useState } from 'react'
16
16
  import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
17
17
  import { sfxEmitter } from '../../../lib/sfx-bus'
18
18
  import useEditor, { type MovingFenceEndpoint } from '../../../store/use-editor'
19
19
  import { CursorSphere } from '../shared/cursor-sphere'
20
- import { snapFenceDraftPoint, type FencePlanPoint } from './fence-drafting'
20
+ import {
21
+ formatAngleRadians,
22
+ getAngleToSegmentReference,
23
+ getSegmentAngleReferenceAtPoint,
24
+ } from '../shared/segment-angle'
21
25
  import { isWallLongEnough } from '../wall/wall-drafting'
26
+ import { type FencePlanPoint, snapFenceDraftPoint } from './fence-drafting'
22
27
 
23
28
  function samePoint(a: FencePlanPoint, b: FencePlanPoint) {
24
29
  return a[0] === b[0] && a[1] === b[1]
25
30
  }
26
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
+
27
107
  type LinkedFenceSnapshot = {
28
108
  id: FenceNode['id']
29
109
  start: FencePlanPoint
30
110
  end: FencePlanPoint
111
+ curveOffset?: number
31
112
  }
32
113
 
33
114
  function getLinkedFenceSnapshots(args: {
@@ -50,10 +131,12 @@ function getLinkedFenceSnapshots(args: {
50
131
  }
51
132
 
52
133
  if (
53
- !samePoint(node.start, originalStart) &&
54
- !samePoint(node.start, originalEnd) &&
55
- !samePoint(node.end, originalStart) &&
56
- !samePoint(node.end, originalEnd)
134
+ !(
135
+ samePoint(node.start, originalStart) ||
136
+ samePoint(node.start, originalEnd) ||
137
+ samePoint(node.end, originalStart) ||
138
+ samePoint(node.end, originalEnd)
139
+ )
57
140
  ) {
58
141
  continue
59
142
  }
@@ -62,6 +145,7 @@ function getLinkedFenceSnapshots(args: {
62
145
  id: node.id,
63
146
  start: [...node.start] as FencePlanPoint,
64
147
  end: [...node.end] as FencePlanPoint,
148
+ curveOffset: node.curveOffset,
65
149
  })
66
150
  }
67
151
 
@@ -77,6 +161,7 @@ function getLinkedFenceUpdates(
77
161
  ) {
78
162
  return linkedFences.map((fence) => ({
79
163
  id: fence.id,
164
+ curveOffset: fence.curveOffset,
80
165
  start: samePoint(fence.start, originalStart)
81
166
  ? nextStart
82
167
  : samePoint(fence.start, originalEnd)
@@ -112,6 +197,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
112
197
  }),
113
198
  )
114
199
  const previewRef = useRef<{ start: FencePlanPoint; end: FencePlanPoint } | null>(null)
200
+ const [angleLabel, setAngleLabel] = useState<AngleLabelState>(null)
115
201
 
116
202
  const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
117
203
  const point = target.endpoint === 'start' ? target.fence.start : target.fence.end
@@ -158,27 +244,35 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
158
244
  const applyPreview = (movingPoint: FencePlanPoint, detachLinkedFences = false) => {
159
245
  const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
160
246
  const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
247
+ const linkedUpdates = detachLinkedFences
248
+ ? []
249
+ : getLinkedFenceUpdates(
250
+ linkedOriginalsRef.current,
251
+ originalStart,
252
+ originalEnd,
253
+ nextStart,
254
+ nextEnd,
255
+ )
161
256
  previewRef.current = { start: nextStart, end: nextEnd }
162
257
  setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
163
- applyNodePreview([
164
- { id: nodeId, start: nextStart, end: nextEnd },
165
- ...(detachLinkedFences
166
- ? []
167
- : getLinkedFenceUpdates(
168
- linkedOriginalsRef.current,
169
- originalStart,
170
- originalEnd,
171
- nextStart,
172
- nextEnd,
173
- )),
174
- ])
258
+ setAngleLabel(
259
+ getEndpointAngleLabel({
260
+ preview: { start: nextStart, end: nextEnd, curveOffset: target.fence.curveOffset },
261
+ segments: [...getReferenceSegments(levelWalls, levelFences), ...linkedUpdates],
262
+ nodeId,
263
+ }),
264
+ )
265
+ applyNodePreview([{ id: nodeId, start: nextStart, end: nextEnd }, ...linkedUpdates])
175
266
  }
176
267
 
177
- const restoreOriginal = () => {
268
+ const restoreOriginal = (clearAngleLabel = true) => {
178
269
  applyNodePreview([
179
270
  { id: nodeId, start: originalStart, end: originalEnd },
180
271
  ...linkedOriginalsRef.current,
181
272
  ])
273
+ if (clearAngleLabel) {
274
+ setAngleLabel(null)
275
+ }
182
276
  }
183
277
 
184
278
  const onGridMove = (event: GridEvent) => {
@@ -211,8 +305,9 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
211
305
  }
212
306
 
213
307
  const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
214
- const hasChanged =
215
- !samePoint(preview.start, originalStart) || !samePoint(preview.end, originalEnd)
308
+ const hasChanged = !(
309
+ samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd)
310
+ )
216
311
 
217
312
  if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
218
313
  wasCommitted = true
@@ -240,6 +335,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
240
335
  }
241
336
 
242
337
  useViewer.getState().setSelection({ selectedIds: [nodeId] })
338
+ setAngleLabel(null)
243
339
  exitMoveMode()
244
340
  event.nativeEvent?.stopPropagation?.()
245
341
  }
@@ -248,6 +344,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
248
344
  restoreOriginal()
249
345
  useViewer.getState().setSelection({ selectedIds: [nodeId] })
250
346
  resumeSceneHistory(useScene)
347
+ setAngleLabel(null)
251
348
  markToolCancelConsumed()
252
349
  exitMoveMode()
253
350
  }
@@ -290,7 +387,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
290
387
 
291
388
  return () => {
292
389
  if (!wasCommitted) {
293
- restoreOriginal()
390
+ restoreOriginal(false)
294
391
  }
295
392
  resumeSceneHistory(useScene)
296
393
  emitter.off('grid:move', onGridMove)
@@ -312,7 +409,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
312
409
  >
313
410
  <div className="translate-y-10">
314
411
  <div
315
- className={`whitespace-nowrap rounded-full border px-2 py-1 text-[11px] font-medium shadow-lg backdrop-blur-md transition-colors ${
412
+ className={`whitespace-nowrap rounded-full border px-2 py-1 font-medium text-[11px] shadow-lg backdrop-blur-md transition-colors ${
316
413
  altPressed
317
414
  ? 'border-amber-500/70 bg-amber-500/15 text-amber-100'
318
415
  : 'border-border/70 bg-background/90 text-foreground/80'
@@ -322,6 +419,23 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
322
419
  </div>
323
420
  </div>
324
421
  </Html>
422
+ {angleLabel && <EndpointAngleLabel label={angleLabel.label} position={angleLabel.position} />}
325
423
  </group>
326
424
  )
327
425
  }
426
+
427
+ function EndpointAngleLabel({
428
+ label,
429
+ position,
430
+ }: {
431
+ label: string
432
+ position: [number, number, number]
433
+ }) {
434
+ return (
435
+ <Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
436
+ <div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
437
+ {label}
438
+ </div>
439
+ </Html>
440
+ )
441
+ }