1
1
using System ;
2
2
using System . Collections . Generic ;
3
+ using System . Diagnostics ;
3
4
using System . Linq ;
4
5
using Godot ;
5
6
using GodotExt ;
6
7
using JetBrains . Annotations ;
8
+ using KdTree ;
9
+ using KdTree . Math ;
7
10
using OpenScadGraphEditor . Library ;
8
11
using OpenScadGraphEditor . Nodes ;
9
12
using OpenScadGraphEditor . Nodes . Reroute ;
@@ -76,8 +79,18 @@ public class ScadGraphEdit : GraphEdit
76
79
77
80
private readonly HashSet < string > _selection = new HashSet < string > ( ) ;
78
81
private readonly Dictionary < string , ScadNodeWidget > _widgets = new Dictionary < string , ScadNodeWidget > ( ) ;
82
+ /// <summary>
83
+ /// Lookup tree for finding the closest connection to a given point.
84
+ /// </summary>
85
+ private KdTree < float , ( ScadConnection Connection , List < Vector2 > BezierPoints ) > _connectionTree =
86
+ new KdTree < float , ( ScadConnection Connection , List < Vector2 > BezierPoints ) > ( 2 , new FloatMath ( ) ) ;
79
87
private ScadConnection _pendingDisconnect ;
80
88
public ScadGraph Graph { get ; private set ; }
89
+
90
+ private Control _connectionHighlightLayer ;
91
+
92
+ private List < Vector2 > _connectionHighlightPoints ;
93
+ private ScadConnection _highlightedConnection ;
81
94
82
95
83
96
public void SelectNodes ( List < ScadNode > nodes )
@@ -123,7 +136,21 @@ public override void _Ready()
123
136
124
137
this . Connect ( "_end_node_move" )
125
138
. To ( this , nameof ( OnEndNodeMove ) ) ;
139
+
140
+ this . Connect ( "draw" )
141
+ . To ( this , nameof ( BuildConnectionLookupTree ) ) ;
142
+
143
+
144
+ _connectionHighlightLayer = this . WithName < Control > ( "CLAYER" ) ;
145
+ _connectionHighlightLayer
146
+ . Connect ( "draw" )
147
+ . To ( this , nameof ( OnConnectionHighlightLayerDraw ) ) ;
148
+
149
+ this . Connect ( "scroll_offset_changed" )
150
+ . To ( this , nameof ( OnScrollOffsetChanged ) ) ;
126
151
}
152
+
153
+
127
154
128
155
public override bool CanDropData ( Vector2 position , object data )
129
156
{
@@ -220,6 +247,9 @@ public void Render(ScadGraph graph)
220
247
ConnectNode ( _widgets [ connection . From . Id ] . Name , connection . FromPort , _widgets [ connection . To . Id ] . Name , connection . ToPort ) ;
221
248
}
222
249
250
+ // build the lookup tree
251
+ BuildConnectionLookupTree ( ) ;
252
+
223
253
// highlight any bound nodes
224
254
HighlightBoundNodes ( ) ;
225
255
@@ -239,7 +269,22 @@ public void Render(ScadGraph graph)
239
269
_selection . Remove ( id ) ;
240
270
}
241
271
}
242
-
272
+
273
+ private void BuildConnectionLookupTree ( )
274
+ {
275
+ _connectionTree =
276
+ new KdTree < float , ( ScadConnection Connection , List < Vector2 > BezierPoints ) > ( 2 , new FloatMath ( ) ) ;
277
+ foreach ( var connection in Graph . GetAllConnections ( ) )
278
+ {
279
+ var connectionPoints = GetConnectionPoints ( connection ) ;
280
+ var bezierPoints = BakeBezierLine ( connectionPoints . Start , connectionPoints . End , 1 ) ;
281
+ foreach ( var bezierPoint in bezierPoints )
282
+ {
283
+ _connectionTree . Add ( new [ ] { bezierPoint . x , bezierPoint . y } , ( connection , bezierPoints ) ) ;
284
+ }
285
+ }
286
+ }
287
+
243
288
/// <summary>
244
289
/// Calculates the graph relative position for any global coordinate taking zoom and scroll offset into account.
245
290
/// </summary>
@@ -264,21 +309,64 @@ private Vector2 LocalToGraphRelative(Vector2 localPosition)
264
309
private void OnPopupRequest ( Vector2 position )
265
310
{
266
311
var relativePosition = GlobalToGraphRelative ( position ) ;
312
+
313
+ if ( _highlightedConnection != null )
314
+ {
315
+ // a right click on a connection will in every case delete the connection, so first
316
+ // make it no longer highlighted but keep the reference.
317
+ var toDelete = _highlightedConnection ;
318
+ _highlightedConnection = null ;
319
+ _connectionHighlightPoints = null ;
320
+
321
+ // if shift is pressed we want to break the connection and insert a reroute node
322
+ // if additionally ctrl is pressed we want to break the connection and insert a wireless reroute node
323
+ if ( KeyMap . IsShiftPressed ( ) )
324
+ {
325
+ var rerouteNode = NodeFactory . Build < RerouteNode > ( ) ;
326
+ if ( KeyMap . IsCmdOrControlPressed ( ) )
327
+ {
328
+ rerouteNode . IsWireless = true ;
329
+ }
330
+ rerouteNode . Offset = relativePosition ;
331
+
332
+ PerformRefactorings ( "Insert reroute node" ,
333
+ // first, delete the connection
334
+ new DeleteConnectionRefactoring ( toDelete ) ,
335
+ // then insert the reroute node and connect it to the old connection's target
336
+ new AddNodeRefactoring ( Graph , rerouteNode , toDelete . To , PortId . Input ( toDelete . ToPort ) ) ,
337
+ // and add a new connection from the old connection's source to the reroute node
338
+ new AddConnectionRefactoring ( new ScadConnection ( Graph , toDelete . From , toDelete . FromPort , rerouteNode , 0 ) )
339
+ ) ;
340
+ return ;
341
+ }
342
+
343
+ // otherwise we want to delete the connection
344
+ PerformRefactorings ( "Delete connection" , new DeleteConnectionRefactoring ( toDelete ) ) ;
345
+ return ;
346
+ }
267
347
268
- var matchingWidgets = _widgets . Values
269
- . FirstOrDefault ( it => new Rect2 ( it . Offset , it . RectSize ) . HasPoint ( relativePosition ) ) ;
348
+
270
349
271
- if ( matchingWidgets == null )
350
+ if ( ! TryGetNodeAtPosition ( relativePosition , out var node ) )
272
351
{
273
352
// right-click in empty space yields you the add dialog
274
353
AddDialogRequested ? . Invoke ( RequestContext . ForPosition ( Graph , relativePosition ) ) ;
275
354
return ;
276
355
}
277
356
278
- NodePopupRequested ? . Invoke ( RequestContext . ForNode ( Graph , position , matchingWidgets . BoundNode ) ) ;
357
+ NodePopupRequested ? . Invoke ( RequestContext . ForNode ( Graph , position , node ) ) ;
279
358
280
359
}
281
360
361
+ private bool TryGetNodeAtPosition ( Vector2 relativePosition , out ScadNode node )
362
+ {
363
+ var firstMatchingWidget = _widgets . Values
364
+ . FirstOrDefault ( it => new Rect2 ( it . Offset , it . RectSize ) . HasPoint ( relativePosition ) ) ;
365
+
366
+ node = firstMatchingWidget ? . BoundNode ;
367
+ return node != null ;
368
+ }
369
+
282
370
283
371
public override void _GuiInput ( InputEvent evt )
284
372
{
@@ -290,6 +378,48 @@ public override void _GuiInput(InputEvent evt)
290
378
return ;
291
379
}
292
380
381
+ if ( evt is InputEventMouseMotion mouseMotionEvent )
382
+ {
383
+ // if the mouse is over a node, don't do anything
384
+ var mousePositionRelativeToTheGraph = GlobalToGraphRelative ( mouseMotionEvent . GlobalPosition ) ;
385
+ if ( TryGetNodeAtPosition ( mousePositionRelativeToTheGraph , out _ ) )
386
+ {
387
+ ClearHighlightedConnection ( ) ;
388
+ return ;
389
+ }
390
+
391
+ // find the closest connection to the mouse
392
+ // we use * zoom because GlobalToGraphRelative will give us the coordinates in graph space and
393
+ // we need to convert them to screen space but relative to the graph widget.
394
+ var mousePosition = mousePositionRelativeToTheGraph * Zoom ;
395
+
396
+ var closest = _connectionTree . GetNearestNeighbours ( new [ ] { mousePosition . x , mousePosition . y } , 1 ) ;
397
+ if ( closest . Length <= 0 )
398
+ {
399
+ ClearHighlightedConnection ( ) ;
400
+ return ;
401
+ }
402
+
403
+
404
+ // are we close enough to the connection?
405
+ var distance = new Vector2 ( closest [ 0 ] . Point [ 0 ] , closest [ 0 ] . Point [ 1 ] ) . DistanceTo ( mousePosition ) ;
406
+ if ( distance > 20 )
407
+ {
408
+ ClearHighlightedConnection ( ) ;
409
+ return ;
410
+ }
411
+
412
+ // no need to redraw, if the connection is the same
413
+ if ( _highlightedConnection == closest [ 0 ] . Value . Connection )
414
+ {
415
+ return ;
416
+ }
417
+
418
+ _highlightedConnection = closest [ 0 ] . Value . Connection ;
419
+ _connectionHighlightPoints = closest [ 0 ] . Value . BezierPoints ;
420
+ _connectionHighlightLayer . Update ( ) ;
421
+ }
422
+
293
423
if ( evt . IsCopy ( ) )
294
424
{
295
425
CopyRequested ? . Invoke ( this , GetSelectedNodes ( ) . ToList ( ) ) ;
@@ -357,6 +487,17 @@ public override void _GuiInput(InputEvent evt)
357
487
}
358
488
}
359
489
490
+ private void ClearHighlightedConnection ( )
491
+ {
492
+ if ( _highlightedConnection == null )
493
+ {
494
+ return ;
495
+ }
496
+ _highlightedConnection = null ;
497
+ _connectionHighlightPoints = null ;
498
+ _connectionHighlightLayer . Update ( ) ;
499
+ }
500
+
360
501
private Vector2 GetPastePosition ( )
361
502
{
362
503
// get the offset over the mouse position
@@ -676,6 +817,107 @@ private void PerformRefactorings(string description, IEnumerable<Refactoring> re
676
817
RefactoringsRequested ? . Invoke ( description , refactoringsAsArray ) ;
677
818
}
678
819
820
+ private ( Vector2 Start , Vector2 End ) GetConnectionPoints ( ScadConnection connection )
821
+ {
822
+ var fromNode = _widgets [ connection . From . Id ] ;
823
+ var toNode = _widgets [ connection . To . Id ] ;
824
+ var fromPort = fromNode . GetConnectionOutputPosition ( connection . FromPort ) + fromNode . Offset * Zoom ;
825
+ var toPort = toNode . GetConnectionInputPosition ( connection . ToPort ) + toNode . Offset * Zoom ;
826
+ return ( fromPort , toPort ) ;
827
+ }
828
+
829
+
830
+ /// <summary>
831
+ /// Bezier interpolation function for the connection lines. This is copied straight from the Godot source code.
832
+ /// </summary>
833
+ private static Vector2 BezierInterpolate ( float t , Vector2 start , Vector2 control1 , Vector2 control2 , Vector2 end )
834
+ {
835
+ var omt = 1.0f - t ;
836
+ var omt2 = omt * omt ;
837
+ var omt3 = omt2 * omt ;
838
+ var t2 = t * t ;
839
+ var t3 = t2 * t ;
840
+
841
+ return start * omt3 + control1 * omt2 * t * 3.0f + control2 * omt * t2 * 3.0f + end * t3 ;
842
+ }
843
+
844
+ /// <summary>
845
+ /// Segment baking function for the connection lines. This is copied straight from the Godot source code + some modifications.
846
+ /// </summary>
847
+ private static void BakeSegment2D ( List < Vector2 > points , float pBegin , float pEnd ,
848
+ Vector2 pA , Vector2 pOut , Vector2 pB , Vector2 pIn , int pDepth , int pMinDepth , int pMaxDepth ,
849
+ float pTol , float pMaxLength , ref int lines )
850
+ {
851
+ var mp = pBegin + ( pEnd - pBegin ) * 0.5f ;
852
+ var beg = BezierInterpolate ( pBegin , pA , pA + pOut , pB + pIn , pB ) ;
853
+ var mid = BezierInterpolate ( mp , pA , pA + pOut , pB + pIn , pB ) ;
854
+ var end = BezierInterpolate ( pEnd , pA , pA + pOut , pB + pIn , pB ) ;
855
+
856
+ var na = ( mid - beg ) . Normalized ( ) ;
857
+ var nb = ( end - mid ) . Normalized ( ) ;
858
+ var dp = Mathf . Rad2Deg ( Mathf . Acos ( na . Dot ( nb ) ) ) ;
859
+
860
+ // the maxLength check is to ensure we don't get longer straight segments of lines because we want
861
+ // to keep the points that make up the lines evenly spaced, so our KDTree can find the closest point
862
+ // to the mouse cursor and we don't get "holes" in the detection when the mouse is over a straight line
863
+ // that technically only needs two points to be drawn. We insert additional points this way so
864
+ // if the line is longer the algorithm can still detect it if the mouse cursor is in the middle of it.
865
+ if ( pDepth >= pMinDepth && ( dp < pTol || pDepth >= pMaxDepth ) && ( beg - end ) . Length ( ) < pMaxLength )
866
+ {
867
+ points . Add ( end ) ;
868
+ lines ++ ;
869
+ }
870
+ else
871
+ {
872
+ BakeSegment2D ( points , pBegin , mp , pA , pOut , pB , pIn , pDepth + 1 , pMinDepth , pMaxDepth , pTol , pMaxLength , ref lines ) ;
873
+ BakeSegment2D ( points , mp , pEnd , pA , pOut , pB , pIn , pDepth + 1 , pMinDepth , pMaxDepth , pTol , pMaxLength , ref lines ) ;
874
+ }
875
+ }
876
+
877
+ /// <summary>
878
+ /// Builds a bezier curve from the given points and returns a list of points that make up the curve.
879
+ /// </summary>
880
+ private List < Vector2 > BakeBezierLine ( Vector2 fromPoint , Vector2 toPoint , float bezierRatio )
881
+ {
882
+ //cubic bezier code
883
+ var diff = toPoint . x - fromPoint . x ;
884
+ var cpLen = GetConstant ( "bezier_len_pos" ) * bezierRatio ;
885
+ var cpNegLen = GetConstant ( "bezier_len_neg" ) * bezierRatio ;
886
+
887
+ var cpOffset = diff > 0
888
+ ? Mathf . Min ( cpLen , diff * 0.5f )
889
+ : Mathf . Max ( Mathf . Min ( cpLen - diff , cpNegLen ) , - diff * 0.5f ) ;
890
+
891
+ var c1 = new Vector2 ( cpOffset * Zoom , 0 ) ;
892
+ var c2 = new Vector2 ( - cpOffset * Zoom , 0 ) ;
893
+
894
+ var lines = 0 ;
895
+
896
+ var points = new List < Vector2 > { fromPoint } ;
897
+ BakeSegment2D ( points , 0 , 1 , fromPoint , c1 , toPoint , c2 , 0 , 3 , 9 , 3 , 20 , ref lines ) ;
898
+ points . Add ( toPoint ) ;
899
+
900
+ return points ;
901
+ }
902
+
903
+ private void OnScrollOffsetChanged ( [ UsedImplicitly ] Vector2 _ )
904
+ {
905
+ // when the scroll offset/zoom is changed our previously highlighted connection is no longer valid
906
+ // so we need to clear it, otherwise we'll get an artifact where the connection is still highlighted
907
+ // in its old position and scale
908
+ _highlightedConnection = null ;
909
+ _connectionHighlightPoints = null ;
910
+ }
911
+
912
+ private void OnConnectionHighlightLayerDraw ( )
913
+ {
914
+ if ( _highlightedConnection == null )
915
+ {
916
+ return ;
917
+ }
918
+ _connectionHighlightLayer . DrawPolyline ( _connectionHighlightPoints . ToArray ( ) , new Color ( 1 , 1 , 1 , 0.5f ) , 5 , true ) ;
919
+ }
920
+
679
921
}
680
922
681
923
}
0 commit comments