#nullable enable using System.Diagnostics; using ImGuiNET; using T3.Core.DataTypes.Vector; using T3.Core.Utils; using T3.Core.Operator; using T3.Editor.Gui.Interaction; using T3.Editor.Gui.Interaction.Snapping; using T3.Editor.Gui.MagGraph.Model; using T3.Editor.Gui.MagGraph.States; using T3.Editor.Gui.Styling; using T3.Editor.Gui.UiHelpers; using T3.Editor.UiModel; using T3.Editor.UiModel.Commands.Graph; using T3.Editor.UiModel.InputsAndTypes; using T3.Editor.UiModel.Modification; using T3.Editor.UiModel.ProjectHandling; using T3.Editor.UiModel.Selection; using MagGraphView = T3.Editor.Gui.MagGraph.Ui.MagGraphView; using Vector2 = System.Numerics.Vector2; // ReSharper disable ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator // ReSharper disable UseWithExpressionToCopyStruct namespace T3.Editor.Gui.MagGraph.Interaction; /// /// Provides functions for moving, snapping, connecting, insertion etc. of operators. It controlled by the /// /// /// Things would be slightly more efficient if this would would use SnapGraphItems. However this would /// prevent us from reusing fence selection. Enforcing this to be used for dragging inputs and outputs /// makes this class unnecessarily complex. /// internal sealed partial class MagItemMovement { internal MagItemMovement(GraphUiContext graphUiContext, MagGraphView magGraphView, MagGraphLayout layout, NodeSelection nodeSelection) { _view = magGraphView; _layout = layout; _nodeSelection = nodeSelection; _context = graphUiContext; } private readonly GraphUiContext _context; internal void PrepareFrame(GraphUiContext context) { //PrepareDragInteraction(); _snapHandlerX.DrawSnapIndicator(context.View, UiColors.StatusActivated.Fade(0.3f)); } internal void PrepareDragInteraction() { UpdateConnectionsToDraggedItems(DraggedItems); // Sadly structure might change during drag... UpdateSnappedConnectionsToDraggedItems(); } internal void CompleteDragOperation(GraphUiContext context) { Debug.Assert(context.MacroCommand != null); if (context.MacroCommand != null) { context.MoveElementsCommand?.StoreCurrentValues(); CompleteSlowGrow(context); GrowSectionsToFitDisplacedMembers(context); context.CompleteMacroCommand(); // Section ownership is re-derived from geometry on the layout refresh _layout.FlagStructureAsChanged(); } if (!InputPicking.TryInitializeInputSelectionPickerForDraggedItem(context)) Reset(); } /// /// Dragging ops slowly against their section's bottom/right border makes the border /// yield. The speed test only decides the initial engagement — once the border /// yielded, it follows the dragged ops continuously, so the growth is smooth instead /// of stuttering with the speed hovering around the threshold. Pulling the ops fully /// back inside (or holding Shift) releases the latch, and the next border contact /// decides afresh. Disabled while the frame is selected, or when /// is 0. /// private void TrySlowGrowSection(GraphUiContext context) { var unlockSpeed = UserSettings.Config.SectionSlowResizeSpeed; if (unlockSpeed <= 0 || _slowGrowSectionId == Guid.Empty || DraggedItems.Count == 0) return; var io = ImGui.GetIO(); var speed = io.MouseDelta.Length() / MathF.Max(io.DeltaTime, 0.0001f) / T3Ui.UiScaleFactor; _dampedDragSpeed = MathUtils.Lerp(_dampedDragSpeed, speed, 0.3f); var symbolUi = context.CompositionInstance.GetSymbolUi(); if (!symbolUi.Sections.TryGetValue(_slowGrowSectionId, out var section) || section.Collapsed || section.IsHiddenInCollapsedSection || context.Selector.IsNodeSelected(section)) return; var draggedBounds = MagGraphItem.GetItemsBounds(DraggedItems); var boundsMin = section.PosOnCanvas; var boundsMax = section.PosOnCanvas + section.Size; // Border contact requires overlapping the frame on the other axis too - an op // dragged past the frame's corner is outside, not pressing against the border var overlapsVertically = draggedBounds.Min.Y < boundsMax.Y && draggedBounds.Max.Y > boundsMin.Y; var overlapsHorizontally = draggedBounds.Min.X < boundsMax.X && draggedBounds.Max.X > boundsMin.X; var touchesRight = overlapsVertically && draggedBounds.Max.X > boundsMax.X && draggedBounds.Min.X < boundsMax.X; var touchesBottom = overlapsHorizontally && draggedBounds.Max.Y > boundsMax.Y && draggedBounds.Min.Y < boundsMax.Y; if (io.KeyShift) { _slowGrowEngaged = false; return; } if (!_slowGrowEngaged) { // Fully back inside? The next contact decides afresh. if (!touchesRight && !touchesBottom) return; if (_dampedDragSpeed > unlockSpeed) return; _slowGrowEngaged = true; } // Follow the dragged ops continuously while engaged var newMax = boundsMax; if (touchesRight) newMax.X = draggedBounds.Max.X + SectionTree.ContentPadding.X; if (touchesBottom) newMax.Y = draggedBounds.Max.Y + SectionTree.ContentPadding.Y; if (draggedBounds.Max.X <= boundsMax.X && draggedBounds.Max.Y <= boundsMax.Y) { // Ops returned fully inside - release the latch (the frame keeps its size) _slowGrowEngaged = false; return; } if (newMax == boundsMax) return; if (_slowGrowCommand == null) { _slowGrowCommand = new ModifyCanvasElementsCommand(symbolUi, [section], context.Selector); _growPushPreview.Engage(symbolUi, section, context.Selector); } _slowGrewX |= newMax.X > boundsMax.X; _slowGrewY |= newMax.Y > boundsMax.Y; section.Size = newMax - section.PosOnCanvas; // Push neighbors ahead of the growing border right away - leaving overlaps // unresolved until release would let ownership derivation flip memberships mid-drag _growPushPreview.Update(section, pushDown: _slowGrewY, pushRight: _slowGrewX); _layout.FlagStructureAsChanged(); } /// /// Folds the accumulated slow-grow resize into the drag's MacroCommand and pushes /// neighbors out of the grown bounds. /// private void CompleteSlowGrow(GraphUiContext context) { if (_slowGrowCommand == null) { _slowGrowSectionId = Guid.Empty; return; } _slowGrowCommand.StoreCurrentValues(); context.MacroCommand!.AddExecutedCommandForUndo(_slowGrowCommand); _growPushPreview.Complete(context.MacroCommand); // Sibling overlaps are already resolved by the live preview; this settles // ancestor growth for nested frames var symbolUi = context.CompositionInstance.GetSymbolUi(); if (symbolUi.Sections.TryGetValue(_slowGrowSectionId, out var grownSection)) { // Only resolve along axes that actually grew - resolving the other axis would // "fix" remaining overlaps by flinging neighbors sideways var bounds = ImRect.RectWithSize(grownSection.PosOnCanvas, grownSection.Size); if (_slowGrewY) SectionTree.ResolveBoundsExpansion(symbolUi, grownSection, bounds, Vector2.UnitY, context.MacroCommand, context.Selector); if (_slowGrewX) SectionTree.ResolveBoundsExpansion(symbolUi, grownSection, bounds, Vector2.UnitX, context.MacroCommand, context.Selector); } _slowGrowCommand = null; _slowGrowSectionId = Guid.Empty; } private Guid _slowGrowSectionId; private ModifyCanvasElementsCommand? _slowGrowCommand; private readonly SectionPushPreview _growPushPreview = new(); private bool _slowGrowEngaged; private bool _slowGrewX; private bool _slowGrewY; private float _dampedDragSpeed; /// /// Splice-inserting into a stack pushes the ops below/right of the insertion — when such /// displaced members cross their section's bottom or right border, the section grows to /// keep them and neighboring frames get pushed out of the claimed area, all folded into /// the drag's MacroCommand. Dragged items themselves are ignored, so deliberately /// dragging an op out of a frame never grows it. /// private void GrowSectionsToFitDisplacedMembers(GraphUiContext context) { Debug.Assert(context.MacroCommand != null); var symbolUi = context.CompositionInstance.GetSymbolUi(); if (symbolUi.Sections.Count == 0) return; _draggedChildIds.Clear(); foreach (var item in DraggedItems) { if (item.ChildUi != null) _draggedChildIds.Add(item.ChildUi.Id); } foreach (var section in symbolUi.Sections.Values) { if (section.Collapsed || section.IsHiddenInCollapsedSection) continue; GrowSectionToFitContent(context, symbolUi, section, _draggedChildIds); } } /// /// Grows a frame (bottom/right) so its member ops stay inside, pushing siblings and /// growing ancestors via the bounds-expansion cascade. Folds into the interaction's /// MacroCommand. Ops in are ignored, so a /// deliberate drag out of the frame never grows it. /// private static void GrowSectionToFitContent(GraphUiContext context, SymbolUi symbolUi, Section section, HashSet? excludedChildIds) { Debug.Assert(context.MacroCommand != null); var contentMax = new Vector2(float.NegativeInfinity, float.NegativeInfinity); var hasContent = false; foreach (var childUi in symbolUi.ChildUis.Values) { if (childUi.SectionId != section.Id) continue; if (excludedChildIds != null && excludedChildIds.Contains(childUi.Id)) continue; contentMax = Vector2.Max(contentMax, childUi.PosOnCanvas + childUi.Size); hasContent = true; } if (!hasContent) return; var boundsMax = section.PosOnCanvas + section.Size; var requiredMax = Vector2.Max(boundsMax, contentMax + SectionTree.ContentPadding); var grewX = requiredMax.X > boundsMax.X + 0.5f; var grewY = requiredMax.Y > boundsMax.Y + 0.5f; if (!grewX && !grewY) return; var resizeCommand = new ModifyCanvasElementsCommand(symbolUi, [section], context.Selector); section.Size = requiredMax - section.PosOnCanvas; resizeCommand.StoreCurrentValues(); context.MacroCommand!.AddExecutedCommandForUndo(resizeCommand); var newBounds = ImRect.RectWithSize(section.PosOnCanvas, section.Size); if (grewY) SectionTree.ResolveBoundsExpansion(symbolUi, section, newBounds, Vector2.UnitY, context.MacroCommand, context.Selector); if (grewX) SectionTree.ResolveBoundsExpansion(symbolUi, section, newBounds, Vector2.UnitX, context.MacroCommand, context.Selector); } private static readonly HashSet _draggedChildIds = []; /// /// Reset to avoid accidental dragging of previous elements /// internal void StopDragOperation() { _lastAppliedOffset = Vector2.Zero; SpliceSets.Clear(); DraggedItems.Clear(); // If the drag ended without CompleteDragOperation, take the uncommitted // slow-grow preview back if (_slowGrowCommand != null) { _slowGrowCommand.Undo(); _slowGrowCommand = null; } _growPushPreview.CancelAndReset(); _slowGrowSectionId = Guid.Empty; } /// /// This will reset the state including all highlights and indicators /// internal void Reset() { // TODO: should be done by states... _context.ActiveSourceItem = null; _context.DraggedPrimaryOutputType = null; DraggedItems.Clear(); _shakeDetector.ResetShaking(); } internal static void SelectActiveItem(GraphUiContext context) { var item = context.ActiveItem; if (item == null) return; var append = ImGui.GetIO().KeyShift; if (context.Selector.IsNodeSelected(item)) { if (append) { context.Selector.DeselectNode(item, item.Instance); } } else { if (append) { item.AddToSelection(context.Selector); } else { item.Select(context.Selector); } } } internal void UpdateDragging(GraphUiContext context) { if (!T3Ui.IsCurrentlySaving && _shakeDetector.TestDragForShake(ImGui.GetMousePos())) { _shakeDetector.ResetShaking(); if (HandleShakeDisconnect(context)) { _layout.FlagStructureAsChanged(); return; } } var snappingChanged = HandleSnappedDragging(context); TrySlowGrowSection(context); if (!snappingChanged) return; _layout.FlagStructureAsChanged(); HandleUnsnapAndCollapse(context); if (!_snapping.IsSnapped) return; if (_snapping.IsInsertion) { TrySplitInsert(context); } else { TryCreateNewConnectionFromSnap(context); } } private bool HandleShakeDisconnect(GraphUiContext context) { //Log.Debug("Shake it!"); Debug.Assert(context.MacroCommand != null); if (_connectionsToDraggedItems.Count == 0) return false; NodeActions.DisconnectDraggedNodes(context.CompositionInstance, DraggedItems.Select(i => i.Selectable).ToList()); // foreach (var c in _borderConnections) // { // context.MacroCommand.AddAndExecCommand(new DeleteConnectionCommand(context.CompositionOp.Symbol, c.AsSymbolConnection(), 0)); // } _layout.FlagStructureAsChanged(); return true; } internal static void UpdateLongPressIndicator(float longTapProgress) { var dl = ImGui.GetWindowDrawList(); dl.AddCircle(ImGui.GetMousePos(), 100 * (1 - longTapProgress), Color.White.Fade(MathF.Pow(longTapProgress, 3))); } /// /// Update dragged items and use anchor definitions to identify and use potential snap targets /// private bool HandleSnappedDragging(GraphUiContext context) { var dl = ImGui.GetWindowDrawList(); if (!_hasDragged) { _dragStartPosInOpOnCanvas = context.View.InverseTransformPositionFloat(ImGui.GetMousePos()); _hasDragged = true; // Remember the frame the drag started in for the slow-grow interaction _slowGrowSectionId = Guid.Empty; _slowGrowCommand = null; _growPushPreview.Reset(); _slowGrowEngaged = false; _slowGrewX = false; _slowGrewY = false; _dampedDragSpeed = UserSettings.Config.SectionSlowResizeSpeed * 3; // start above unlock so a fresh grab doesn't engage instantly // Rearranging a selection that includes frames must never auto-grow or push - // that's deliberate layout work, not ops pressing against a border var selectionIncludesSection = false; foreach (var node in _nodeSelection.Selection) { if (node is not Section) continue; selectionIncludesSection = true; break; } if (!selectionIncludesSection) { foreach (var item in DraggedItems) { if (item.ChildUi == null || item.ChildUi.SectionId == Guid.Empty) continue; _slowGrowSectionId = item.ChildUi.SectionId; break; } } } var mousePosOnCanvas = context.View.InverseTransformPositionFloat(ImGui.GetMousePos()); var requestedDeltaOnCanvas = mousePosOnCanvas - _dragStartPosInOpOnCanvas; var dragExtend = MagGraphItem.GetItemsBounds(DraggedItems); dragExtend.Expand(SnapThreshold * context.View.Scale.X); if (_view.ShowDebug) { dl.AddCircle(_view.TransformPosition(_dragStartPosInOpOnCanvas), 10, Color.Blue); dl.AddLine(_view.TransformPosition(_dragStartPosInOpOnCanvas), _view.TransformPosition(_dragStartPosInOpOnCanvas + requestedDeltaOnCanvas), Color.Blue); dl.AddRect(_view.TransformPosition(dragExtend.Min), _view.TransformPosition(dragExtend.Max), Color.Green.Fade(0.1f)); } var overlappingItems = new List(); foreach (var otherItem in _layout.Items.Values) { if (DraggedItems.Contains(otherItem) || !dragExtend.Overlaps(otherItem.Area)) continue; overlappingItems.Add(otherItem); } // Move back to non-snapped position foreach (var n in DraggedItems) { n.PosOnCanvas -= _lastAppliedOffset; // Move to position n.PosOnCanvas += requestedDeltaOnCanvas; // Move to request position } // Only drag selected sections along when the drag started from the selection itself, // not when dragging an unselected snapped group. if (_draggedItemsFromSelection && DraggedItems.Count != _context.Selector.Selection.Count) { foreach (var a in _context.Selector.Selection) { if (a is not Section) continue; a.PosOnCanvas -= _lastAppliedOffset; // Move to position a.PosOnCanvas += requestedDeltaOnCanvas; // Move to request position } } _lastAppliedOffset = requestedDeltaOnCanvas; _snapping.Reset(); foreach (var ip in SpliceSets) { var insertionAnchorItem = DraggedItems.FirstOrDefault(i => i.Id == ip.InputItemId); if (insertionAnchorItem == null) continue; foreach (var otherItem in overlappingItems) { _snapping.TestItemsForInsertion(otherItem, insertionAnchorItem, ip, _view); } } foreach (var otherItem in overlappingItems) { foreach (var draggedItem in DraggedItems) { _snapping.TestItemsForSnap(otherItem, draggedItem, false, _view); _snapping.TestItemsForSnap(draggedItem, otherItem, true, _view); } } // Highlight best distance if (_view.ShowDebug && _snapping.BestDistance < 500) { var p1 = _view.TransformPosition(_snapping.OutAnchorPos); var p2 = _view.TransformPosition(_snapping.InputAnchorPos); dl.AddLine(p1, p2, UiColors.ForegroundFull.Fade(0.1f), 6); } // Snapped var snapPositionChanged = false; if (_snapping.IsSnapped) { var bestSnapDelta = _snapping.Reverse ? _snapping.InputAnchorPos - _snapping.OutAnchorPos : _snapping.OutAnchorPos - _snapping.InputAnchorPos; // var snapTargetPos = _snapping.Reverse // ? _snapping.InputAnchorPos // : _snapping.OutAnchorPos; var snapPos = mousePosOnCanvas + bestSnapDelta; // dl.AddLine(_canvas.TransformPosition(mousePosOnCanvas), // context.Canvas.TransformPosition(mousePosOnCanvas) + _canvas.TransformDirection(bestSnapDelta), // Color.White); if (Vector2.Distance(snapPos, _lastSnapDragPositionOnCanvas) > 2) // ugh. Magic number { snapPositionChanged = true; LastSnapTime = ImGui.GetTime(); _lastSnapDragPositionOnCanvas = snapPos; LastSnapTargetPositionOnCanvas = _snapping.Reverse ? _snapping.InputAnchorPos : _snapping.OutAnchorPos; } foreach (var n in DraggedItems) { n.PosOnCanvas += bestSnapDelta; } _lastAppliedOffset += bestSnapDelta; } // Unsnapped... else { _lastSnapDragPositionOnCanvas = Vector2.Zero; LastSnapTime = double.NegativeInfinity; HandleHorizontalAlignmentSnapping(context); } var snappingChanged = _snapping.IsSnapped != _wasSnapped || _snapping.IsSnapped && snapPositionChanged; _wasSnapped = _snapping.IsSnapped; return snappingChanged; } private void HandleHorizontalAlignmentSnapping(GraphUiContext context) { if (!UserSettings.Config.EnableHorizontalSnapping) return; var snapFactor = 1f; var newDragPosInCanvas = _draggedSelectables[0].PosOnCanvas; var isFirst = true; ImRect bounds = default; foreach (var item in DraggedItems) { var itemBounds = item.Bounds; if (isFirst) { bounds = itemBounds; isFirst = false; continue; } bounds.Add(itemBounds); } bounds.Expand(200); _visibleItemsForSnapping.Clear(); foreach (var snapTo in context.Layout.Items.Values) { if (bounds.Overlaps(snapTo.Bounds)) _visibleItemsForSnapping.Add(snapTo); } if (!_snapHandlerX.TryCheckForSnapping(newDragPosInCanvas.X, out var snappedXValue, context.View.Scale.X * snapFactor, DraggedItems, _visibleItemsForSnapping )) return; var snapDelta = snappedXValue - newDragPosInCanvas.X; var offset = new Vector2((float)snapDelta, 0); foreach (var n in DraggedItems) { n.PosOnCanvas += offset; } _lastAppliedOffset += offset; } private static readonly ValueSnapHandler _snapHandlerX = new(SnapResult.Orientations.Horizontal); private static readonly List _visibleItemsForSnapping = []; public void StartDragOperation(GraphUiContext context) { _draggedSelectables = DraggedItems.Select(i => i as ISelectableCanvasObject).ToList(); if (_draggedSelectables.Count == 0) return; var macroCommand = context.StartMacroCommand("Move nodes"); context.MoveElementsCommand = new ModifyCanvasElementsCommand(context.CompositionInstance.Symbol.Id, _draggedSelectables, _nodeSelection); macroCommand.AddExecutedCommandForUndo(context.MoveElementsCommand); _lastAppliedOffset = Vector2.Zero; _hasDragged = false; UpdateConnectionsToDraggedItems(DraggedItems); UpdateSnappedConnectionsToDraggedItems(); var mousePosInCanvas = context.View.InverseTransformPositionFloat(ImGui.GetMousePos()); InitSpliceLinks(DraggedItems, mousePosInCanvas); InitPrimaryDraggedOutput(); _unsnappedBorderConnectionsBeforeDrag.Clear(); foreach (var c in _connectionsToDraggedItems) { if (!c.IsSnapped) { _unsnappedBorderConnectionsBeforeDrag.Add(c.ConnectionHash); } } _context.CompositionInstance.Symbol.GetSymbolUi().FlagAsModified(); } private readonly HashSet _unsnappedBorderConnectionsBeforeDrag = []; /// /// Handles op disconnection and collapsed /// private void HandleUnsnapAndCollapse(GraphUiContext context) { Debug.Assert(context.MacroCommand != null); var unsnappedConnections = new List(); //var enableDisconnected = UserSettings.Config.DisconnectOnUnsnap ^ ImGui.GetIO().KeyShift; // if (!enableDisconnected) // return; // Delete unsnapped connections foreach (var mc in _layout.MagConnections) { if (!IsBorderConnection(mc, DraggedItems)) continue; if (_unsnappedBorderConnectionsBeforeDrag.Contains(mc.ConnectionHash)) continue; var existedBeforeDragStart = _connectionToDragItemHashes.Contains(mc.ConnectionHash); if (existedBeforeDragStart) { var isVerticalStackDisconnect = FindVerticalCollapsableConnectionPairs(_connectionsToDraggedItems); var isHorizontalStackDisconnect = FindHorizontalCollapsableConnectionPairs(_connectionsToDraggedItems); if(isVerticalStackDisconnect.Count != 1 && isHorizontalStackDisconnect.Count != 1) continue; } unsnappedConnections.Add(mc); var targetItemInputLine = mc.TargetItem.InputLines[mc.InputLineIndex]; var connection = mc.AsSymbolConnection(); context.MacroCommand.AddAndExecCommand(new DeleteConnectionCommand(context.CompositionInstance.Symbol, connection, targetItemInputLine.MultiInputIndex)); mc.IsTemporary = true; } if (TryCollapseDragFromVerticalStack(context, unsnappedConnections)) return; if (TryCollapseDragFromHorizontalStack(context, unsnappedConnections)) return; TryCollapseDisconnectedInputs(context, unsnappedConnections); } private bool TryCollapseDragFromVerticalStack(GraphUiContext context, List unsnappedConnections) { Debug.Assert(context.MacroCommand != null); if (unsnappedConnections.Count == 0) return false; var list = FindVerticalCollapsableConnectionPairs(unsnappedConnections); if (list.Count > 1) { Log.Debug("Collapsing has too many possibilities"); return false; } if (list.Count == 0) return false; var pair = list[0]; var bridgeConnection = new Symbol.Connection(pair.Ca.SourceParentOrChildId, pair.Ca.SourceOutput.Id, pair.Cb.TargetParentOrChildId, pair.Cb.TargetInput.Id); if (Structure.CheckForCycle(context.CompositionInstance.Symbol, bridgeConnection)) { Log.Debug("Sorry, this connection would create a cycle. (1)"); return false; } var potentialMovers = CollectSnappedItems(pair.Cb.TargetItem); // Clarify if the subset of items snapped to lower target op is sufficient to fill most gaps // First find movable items and then create command with movements var movableItems = MoveToCollapseVerticalGaps(pair.Ca, pair.Cb, potentialMovers, true); if (movableItems.Count == 0) return false; var affectedItemsAsNodes = movableItems.Select(i => i as ISelectableCanvasObject).ToList(); var newMoveCommand = new ModifyCanvasElementsCommand(context.CompositionInstance.Symbol.Id, affectedItemsAsNodes, _nodeSelection); context.MacroCommand.AddExecutedCommandForUndo(newMoveCommand); MoveToCollapseVerticalGaps(pair.Ca, pair.Cb, movableItems, false); newMoveCommand.StoreCurrentValues(); context.MacroCommand.AddAndExecCommand(new AddConnectionCommand(context.CompositionInstance.Symbol, bridgeConnection, pair.Cb.MultiInputIndex)); return true; } private bool TryCollapseDragFromHorizontalStack(GraphUiContext context, List unsnappedConnections) { Debug.Assert(context.MacroCommand != null); if (unsnappedConnections.Count == 0) return false; var list = FindHorizontalCollapsableConnectionPairs(unsnappedConnections); if (list.Count > 1) { Log.Debug("Collapsing has too many possibilities"); return false; } if (list.Count == 0) return false; var pair = list[0]; var bridgeConnection = new Symbol.Connection(pair.Ca.SourceParentOrChildId, pair.Ca.SourceOutput.Id, pair.Cb.TargetParentOrChildId, pair.Cb.TargetInput.Id); if (Structure.CheckForCycle(context.CompositionInstance.Symbol, bridgeConnection)) { Log.Debug("Sorry, this connection would create a cycle. (1)"); return false; } var potentialMovers = CollectSnappedItems(pair.Cb.TargetItem); // First find movable items and then create command with movements var movableItems = MoveToCollapseHorizontalGaps(pair.Ca, pair.Cb, potentialMovers, true); if (movableItems.Count == 0) return false; var affectedItemsAsNodes = movableItems.Select(i => i as ISelectableCanvasObject).ToList(); var newMoveCommand = new ModifyCanvasElementsCommand(context.CompositionInstance.Symbol.Id, affectedItemsAsNodes, _nodeSelection); context.MacroCommand.AddExecutedCommandForUndo(newMoveCommand); MoveToCollapseHorizontalGaps(pair.Ca, pair.Cb, movableItems, false); newMoveCommand.StoreCurrentValues(); context.MacroCommand.AddAndExecCommand(new AddConnectionCommand(context.CompositionInstance.Symbol, bridgeConnection, pair.Cb.MultiInputIndex)); return true; } internal static List FindLinkedVerticalCollapsableConnectionPairs(List unsnappedConnections) { // Find collapses var pairs = new List(); var ordered = unsnappedConnections.OrderBy(x => x.SourcePos.Y); var capturedConnections = new HashSet(); // Flood fill vertical snap pairs foreach (var topC in ordered) { if (topC.Style != MagGraphConnection.ConnectionStyles.MainOutToMainInSnappedVertical) continue; if (capturedConnections.Contains(topC)) continue; var linkedOutConnection = topC; // Find matching linked connections while (true) { if (!linkedOutConnection.IsSnapped) break; var targetItem = linkedOutConnection.TargetItem; if (targetItem == null) break; if (!targetItem.TryGetPrimaryOutConnections(out var outConnections)) break; var cOut = outConnections[0]; if (!unsnappedConnections.Contains(cOut)) break; linkedOutConnection = cOut; capturedConnections.Add(linkedOutConnection); } if (linkedOutConnection != topC && topC.Type == linkedOutConnection.Type) { pairs.Add(new SnapCollapseConnectionPair(topC, linkedOutConnection)); } } return pairs; } private static List FindVerticalCollapsableConnectionPairs(List unsnappedConnections) { var list = new List(); // Collapse ops dragged from vertical stack... var potentialLinks = new List(); foreach (var mc in unsnappedConnections) { if (mc.Style == MagGraphConnection.ConnectionStyles.MainOutToMainInSnappedVertical) { potentialLinks.Clear(); foreach (var cb in unsnappedConnections) { if (mc != cb && cb.Style == MagGraphConnection.ConnectionStyles.MainOutToMainInSnappedVertical && cb.SourcePos.Y > mc.SourcePos.Y && Math.Abs(cb.SourcePos.X - mc.SourcePos.X) < SnapTolerance && cb.Type == mc.Type) { potentialLinks.Add(cb); } } if (potentialLinks.Count == 1) { list.Add(new SnapCollapseConnectionPair(mc, potentialLinks[0])); } else if (potentialLinks.Count > 1) { Log.Debug("Collapsing with gaps not supported yet"); } } } return list; } private static List FindHorizontalCollapsableConnectionPairs(List unsnappedConnections) { var list = new List(); // Collapse ops dragged from vertical stack... var potentialLinks = new List(); foreach (var mc in unsnappedConnections) { if (mc.Style == MagGraphConnection.ConnectionStyles.MainOutToMainInSnappedHorizontal) { potentialLinks.Clear(); foreach (var cb in unsnappedConnections) { if (mc != cb && (cb.Style == MagGraphConnection.ConnectionStyles.MainOutToMainInSnappedHorizontal || cb.Style ==MagGraphConnection.ConnectionStyles.MainOutToInputSnappedHorizontal) && cb.SourcePos.X > mc.SourcePos.X && Math.Abs(cb.SourcePos.Y - mc.SourcePos.Y) < SnapTolerance && cb.Type == mc.Type) { potentialLinks.Add(cb); } } if (potentialLinks.Count == 1) { list.Add(new SnapCollapseConnectionPair(mc, potentialLinks[0])); } else if (potentialLinks.Count > 1) { Log.Debug("Collapsing with gaps not supported yet"); } } } return list; } private void TryCollapseDisconnectedInputs(GraphUiContext context, List unsnappedConnections) { if (unsnappedConnections.Count == 0) return; var snappedItems = new HashSet(); foreach (var mc in unsnappedConnections) { CollectSnappedItems(mc.TargetItem, snappedItems); } // Collapse lines of no longer visible inputs stack... var collapseLines = new HashSet(); foreach (var mc in unsnappedConnections) { var disconnectedInputWouldCollapseLine = DisconnectedInputWouldCollapseLine(mc); if (disconnectedInputWouldCollapseLine) { collapseLines.Add(mc.SourcePos.Y); } } if (collapseLines.Count == 0) return; foreach (var y in collapseLines.OrderDescending()) { MoveSnappedItemsVertically(context, snappedItems, y, -MagGraphItem.GridSize.Y ); } } public static bool DisconnectedInputWouldCollapseLine(MagGraphConnection connection) { var inputWasNotPrimary = connection.InputLineIndex > 0; if (connection.TargetItem.Variant != MagGraphItem.Variants.Operator) return false; var inputWasOptional = connection.TargetItem.InputLines[connection.InputLineIndex].InputUi.Relevancy == Relevancy.Optional; var multiInputConnectionCount = 0; foreach (var line in connection.TargetItem.InputLines) { if (line.InputUi.Id == connection.TargetItem.InputLines[connection.InputLineIndex].Id) multiInputConnectionCount++; } var multiInputHadOtherConnectedMultiInput = multiInputConnectionCount > 1; var connectedToLeftInput = connection.Style == MagGraphConnection.ConnectionStyles.BottomToLeft || connection.Style == MagGraphConnection.ConnectionStyles.RightToLeft || connection.Style == MagGraphConnection.ConnectionStyles.MainOutToMainInSnappedHorizontal || connection.Style == MagGraphConnection.ConnectionStyles.MainOutToInputSnappedHorizontal; if (connectedToLeftInput && (inputWasNotPrimary && inputWasOptional) || multiInputHadOtherConnectedMultiInput) return true; return false; } /// /// Iterate through gap lines and move items below upwards /// public static HashSet MoveToCollapseVerticalGaps(MagGraphConnection ca, MagGraphConnection cb, HashSet movableItems, bool dryRun) { var minY = ca.SourcePos.Y; var maxY = cb.TargetPos.Y; var lineWidth = MagGraphItem.GridSize.Y; var snapCount = (maxY - minY) / lineWidth; var roundedSnapCount = (int)(snapCount + 0.5f); var affectedItems = new HashSet(); for (var lineIndex = roundedSnapCount - 1; lineIndex >= 0; lineIndex--) { var isLineBlocked = false; var middleSnapLineY = minY + (0.5f + lineIndex) * lineWidth; foreach (var item in movableItems) { if (item.Area.Min.Y >= middleSnapLineY || item.Area.Max.Y <= middleSnapLineY) continue; isLineBlocked = true; break; } if (isLineBlocked) continue; // move lines below up one step foreach (var item in movableItems) { if (item.PosOnCanvas.Y < middleSnapLineY) continue; affectedItems.Add(item); if (!dryRun) item.PosOnCanvas -= new Vector2(0, lineWidth); } } return affectedItems; } /// /// Try to close gap be looking for disjoint snapped set on the left and right /// private HashSet MoveToCollapseHorizontalGaps(MagGraphConnection ca, MagGraphConnection cb, HashSet movableItems, bool dryRun) { var counterOffset = Vector2.Zero; foreach (var ss in SpliceSets) { if (ss.InputItemId == ca.TargetItem.Id && ss.Direction == MagGraphItem.Directions.Horizontal) { counterOffset = ss.DragPositionWithinBlock; } } var leftBatch = CollectSnappedItems(ca.SourceItem, null, true, ca.ConnectionHash); var rightBatch = CollectSnappedItems(cb.TargetItem, null, true, ca.ConnectionHash); var onlyLeft = new HashSet(leftBatch); onlyLeft.ExceptWith(rightBatch); var onlyRight = new HashSet(rightBatch); onlyRight.ExceptWith(leftBatch); var intersection = new HashSet(rightBatch); intersection.IntersectWith(leftBatch); if (intersection.Count != 0) { return []; } var affectedItems = new HashSet(onlyLeft); affectedItems.UnionWith(onlyRight); if (dryRun) return affectedItems; foreach (var item in onlyRight) { item.PosOnCanvas += new Vector2( counterOffset.X - MagGraphItem.GridSize.X, 0); } foreach (var item in onlyLeft) { item.PosOnCanvas += new Vector2(counterOffset.X, 0); } return affectedItems; } /// /// A pair of snapped connections that can be fused after unsnapping some items from a stack /// internal sealed record SnapCollapseConnectionPair(MagGraphConnection Ca, MagGraphConnection Cb); /// /// Search for potential new connections through snapping /// private void TryCreateNewConnectionFromSnap(GraphUiContext context) { if (!_snapping.IsSnapped) return; Debug.Assert(context.MacroCommand != null); var newConnections = new List(); foreach (var draggedItem in DraggedItems) { foreach (var otherItem in _layout.Items.Values) { if (DraggedItems.Contains(otherItem)) continue; GetPotentialConnectionsAfterSnap(ref newConnections, draggedItem, otherItem); GetPotentialConnectionsAfterSnap(ref newConnections, otherItem, draggedItem); } } foreach (var potentialConnection in newConnections) { // Avoid accidental vertical double connections to item above when snapping a horizontal connection if (potentialConnection.TargetItem.OutputLines.Length > 0 && potentialConnection.TargetItem.OutputLines[0].ConnectionsOut.Count > 0) { var wouldConnectionOutSnapAfterMove = false; foreach (var targetOutConnection in potentialConnection.TargetItem.OutputLines[0].ConnectionsOut) { if (targetOutConnection.IsSnapped) continue; var newOutputPos = targetOutConnection.SourceItem.PosOnCanvas + new Vector2(MagGraphItem.GridSize.X, MagGraphItem.GridSize.Y * (0.5f + targetOutConnection.VisibleOutputIndex)); if (Vector2.Distance(newOutputPos, targetOutConnection.TargetPos) < 0.01f) { wouldConnectionOutSnapAfterMove = true; } } if (wouldConnectionOutSnapAfterMove) { Log.Debug("Avoiding double vertical connection..."); continue; } } // Avoid accidental vertical double connections to item below when snapping a horizontal connection if (potentialConnection.SourceItem.OutputLines.Length > 0 && potentialConnection.SourceItem.OutputLines[0].ConnectionsOut.Count > 0) { var wouldConnectionOutSnapAfterMove = false; foreach (var sourceOutConnection in potentialConnection.SourceItem.OutputLines[0].ConnectionsOut) { // if (sourceOutConnection.IsSnapped && !sourceOutConnection.WasDisconnected.) // continue; var newOutputPos = sourceOutConnection.SourceItem.PosOnCanvas + new Vector2(MagGraphItem.GridSize.X, MagGraphItem.GridSize.Y * (0.5f + sourceOutConnection.VisibleOutputIndex)); if (Vector2.Distance(newOutputPos, sourceOutConnection.TargetPos) < 4f) { wouldConnectionOutSnapAfterMove = true; } } if (wouldConnectionOutSnapAfterMove) { Log.Debug("Avoiding double connection..."); continue; } } var sourceParentOfSymbolChildId = potentialConnection.SourceItem.Variant == MagGraphItem.Variants.Input ? Guid.Empty : potentialConnection.SourceItem.Id; var targetParentOfSymbolChildId = potentialConnection.TargetItem.Variant == MagGraphItem.Variants.Output ? Guid.Empty : potentialConnection.TargetItem.Id; var newConnection = new Symbol.Connection(sourceParentOfSymbolChildId, potentialConnection.OutputLine.Id, targetParentOfSymbolChildId, potentialConnection.InputLine.Id); if (Structure.CheckForCycle(context.CompositionInstance.Symbol, newConnection)) { Log.Debug("Sorry, this connection would create a cycle. (4)"); continue; } context.MacroCommand.AddAndExecCommand(new AddConnectionCommand(context.CompositionInstance.Symbol, newConnection, potentialConnection.InputLine.MultiInputIndex)); } } private static void GetPotentialConnectionsAfterSnap(ref List result, MagGraphItem a, MagGraphItem b) { MagGraphConnection? inConnection; for (var bInputLineIndex = 0; bInputLineIndex < b.InputLines.Length; bInputLineIndex++) { ref var bInputLine = ref b.InputLines[bInputLineIndex]; inConnection = bInputLine.ConnectionIn; int aOutLineIndex; for (aOutLineIndex = 0; aOutLineIndex < a.OutputLines.Length; aOutLineIndex++) { ref var outputLine = ref a.OutputLines[aOutLineIndex]; // Avoid copying data from array if (bInputLine.Type != outputLine.Output.ValueType) continue; // vertical if (aOutLineIndex == 0 && bInputLineIndex == 0) { AddPossibleNewConnections(ref result, ref outputLine, ref bInputLine, new Vector2(a.Area.Min.X + MagGraphItem.WidthHalf, a.Area.Max.Y), new Vector2(b.Area.Min.X + MagGraphItem.WidthHalf, b.Area.Min.Y)); } // horizontal if (outputLine.Output.ValueType == bInputLine.Type) { AddPossibleNewConnections(ref result, ref outputLine, ref bInputLine, new Vector2(a.Area.Max.X, a.Area.Min.Y + (0.5f + outputLine.VisibleIndex) * MagGraphItem.LineHeight), new Vector2(b.Area.Min.X, b.Area.Min.Y + (0.5f + bInputLine.VisibleIndex) * MagGraphItem.LineHeight)); } } } return; void AddPossibleNewConnections(ref List newConnections, ref MagGraphItem.OutputLine outputLine, ref MagGraphItem.InputLine inputLine, Vector2 outPos, Vector2 inPos) { if (Vector2.Distance(outPos, inPos) > SnapTolerance) return; if (inConnection != null) return; // Clarify if outConnection should also be empty... if (outputLine.ConnectionsOut.Count > 0) { if (outputLine.ConnectionsOut[0].IsSnapped && (outputLine.ConnectionsOut[0].SourcePos - inPos).Length() < SnapTolerance) return; } newConnections.Add(new PotentialConnection(a, outputLine, b, inputLine)); } } private sealed record PotentialConnection( MagGraphItem SourceItem, MagGraphItem.OutputLine OutputLine, MagGraphItem TargetItem, MagGraphItem.InputLine InputLine); private void UpdateConnectionsToDraggedItems(HashSet draggedItems) { _connectionsToDraggedItems.Clear(); _connectionToDragItemHashes.Clear(); // This could be optimized by only looking for dragged item connections foreach (var c in _layout.MagConnections) { if (!IsBorderConnection(c, draggedItems)) continue; _connectionsToDraggedItems.Add(c); _connectionToDragItemHashes.Add(c.ConnectionHash); } } private static bool IsBorderConnection(MagGraphConnection c, HashSet draggedItems) { var isTargetDragged = draggedItems.Contains(c.TargetItem); var isSourceDragged = draggedItems.Contains(c.SourceItem); return isTargetDragged != isSourceDragged; } /// /// This is updated every frame... /// private void UpdateSnappedConnectionsToDraggedItems() { _snappedConnectionToDragItemHashes.Clear(); foreach (var c in _connectionsToDraggedItems) { if (c.IsSnapped) _snappedConnectionToDragItemHashes.Add(c.ConnectionHash); } } /// /// When starting a new drag operation, we try to identify border input anchors of the dragged items, /// that can be used to insert them between other snapped items. /// private void InitSpliceLinks(HashSet draggedItems, Vector2 mousePosInCanvas) { SpliceSets.Clear(); foreach (var inputItemA in draggedItems) { var itemAInputCount = inputItemA.GetInputAnchorCount(); MagGraphItem.InputAnchorPoint inputAnchor = default; for (var inputAnchorIndex = 0; inputAnchorIndex < itemAInputCount; inputAnchorIndex++) { inputItemA.GetInputAnchorAtIndex(inputAnchorIndex, ref inputAnchor); // make sure it's a snapped border connection if (inputAnchor.SnappedConnectionHash != MagGraphItem.FreeAnchor && !_snappedConnectionToDragItemHashes.Contains(inputAnchor.SnappedConnectionHash)) { continue; } var spliceSets = new List(); foreach (var outputItemB in draggedItems) { var xy = inputAnchor.Direction == MagGraphItem.Directions.Horizontal ? 0 : 1; if (Math.Abs(inputItemA.PosOnCanvas[1 - xy] - outputItemB.PosOnCanvas[1 - xy]) > SnapTolerance) continue; var outputAnchorCount = outputItemB.GetOutputAnchorCount(); MagGraphItem.OutputAnchorPoint outputAnchor = default; for (var outputIndex = 0; outputIndex < outputAnchorCount; outputIndex++) { outputItemB.GetOutputAnchorAtIndex(outputIndex, ref outputAnchor); if (outputAnchor.SnappedConnectionHash != MagGraphItem.FreeAnchor && !_snappedConnectionToDragItemHashes.Contains(outputAnchor.SnappedConnectionHash)) { continue; } if ( outputAnchor.Direction != inputAnchor.Direction || inputAnchor.ConnectionType != outputAnchor.ConnectionType) { continue; } var inputAnchorPositionOnCanvas = inputAnchor.PositionOnCanvas - inputItemA.PosOnCanvas; spliceSets.Add(new SpliceLink(inputItemA.Id, inputAnchor.SlotId, outputItemB.Id, outputAnchor.SlotId, inputAnchor.Direction, inputAnchor.ConnectionType, outputAnchor.PositionOnCanvas[xy] - inputAnchor.PositionOnCanvas[xy], mousePosInCanvas - inputItemA.PosOnCanvas, inputAnchorPositionOnCanvas )); } } // Skip insertion lines with gaps if (spliceSets.Count == 1) { SpliceSets.Add(spliceSets[0]); } } } } /// /// Search for potential new connections through snapping /// // ReSharper disable once UnusedMethodReturnValue.Local private static bool TrySplitInsert(GraphUiContext context) { if (!_snapping.IsSnapped || _snapping.TargetItem == null || _snapping.SourceItem == null) return false; Debug.Assert(context.MacroCommand != null); var spliceLink = _snapping.InsertionPoint; // Split connection var connection = _snapping.TargetItem.InputLines[_snapping.InputLineIndex].ConnectionIn; if (connection == null) { Log.Warning("Missing connection?"); return true; } if (spliceLink == null) { Log.Warning("Insertion point is undefined?"); return false; } var connectionIntoSplice = new Symbol.Connection(connection.SourceParentOrChildId, connection.SourceOutput.Id, spliceLink.InputItemId, spliceLink.InputId); var connectionOutOfSplice = new Symbol.Connection(spliceLink.OutputItemId, spliceLink.OutputId, connection.TargetParentOrChildId, connection.TargetInput.Id); if (Structure.CheckForCycle(context.CompositionInstance.Symbol, connectionIntoSplice)) { Log.Debug("Sorry, this connection would create a cycle. (2)"); return false; } if (Structure.CheckForCycle(context.CompositionInstance.Symbol, connectionOutOfSplice)) { Log.Debug("Sorry, this connection would create a cycle. (3)"); return false; } context.MacroCommand.AddAndExecCommand(new DeleteConnectionCommand(context.CompositionInstance.Symbol, connection.AsSymbolConnection(), connection.MultiInputIndex)); context.MacroCommand.AddAndExecCommand(new AddConnectionCommand(context.CompositionInstance.Symbol, connectionIntoSplice, 0)); context.MacroCommand.AddAndExecCommand(new AddConnectionCommand(context.CompositionInstance.Symbol, connectionOutOfSplice, connection.MultiInputIndex)); // Move items if (spliceLink.Direction == MagGraphItem.Directions.Vertical) { MoveSnappedItemsVertically(context, CollectSnappedItems(_snapping.TargetItem), _snapping.OutAnchorPos.Y - MagGraphItem.GridSize.Y / 2, spliceLink.Distance); } else { var leftBatch = CollectSnappedItems(_snapping.SourceItem, null, true, connection.ConnectionHash); var rightBatch = CollectSnappedItems(_snapping.TargetItem, null, true, connection.ConnectionHash); var onlyLeft = new HashSet(leftBatch); onlyLeft.ExceptWith(rightBatch); var onlyRight = new HashSet(rightBatch); onlyRight.ExceptWith(leftBatch); var intersection = new HashSet(rightBatch); intersection.IntersectWith(leftBatch); MoveItems(context, onlyLeft, -new Vector2(spliceLink.DragPositionWithinBlock.X ,0)); MoveItems(context, onlyRight, new Vector2(spliceLink.Distance - spliceLink.DragPositionWithinBlock.X,0)); MoveSnappedItemsHorizontally(context, intersection, _snapping.OutAnchorPos.X - MagGraphItem.GridSize.X / 2, spliceLink.Distance); } return true; } /// /// Creates and applies a command to move items vertically /// /// /// True if some items where moved /// public static void MoveSnappedItemsVertically(GraphUiContext context, HashSet snappedItems, float yThreshold, float yDistance) { Debug.Assert(context.MacroCommand != null); var movableItems = new List(); foreach (var otherItem in snappedItems) { if (otherItem.PosOnCanvas.Y > yThreshold) { movableItems.Add(otherItem); } } if (movableItems.Count == 0) return; MoveItems(context, movableItems, new Vector2(0, yDistance)); } /// /// Creates and applies a command to move items vertically /// /// /// True if some items where moved /// public static void MoveSnappedItemsHorizontally(GraphUiContext context, HashSet snappedItems, float xThreshold, float xDistance) { Debug.Assert(context.MacroCommand != null); var movableItems = new List(); foreach (var otherItem in snappedItems) { if (otherItem.PosOnCanvas.X > xThreshold) { movableItems.Add(otherItem); } } if (movableItems.Count == 0) return; MoveItems(context, movableItems, new Vector2(xDistance,0)); } private static void MoveItems(GraphUiContext context, IEnumerable movableItems, Vector2 offset) { Debug.Assert(context.MacroCommand != null); // Move items left... var affectedItemsAsNodes = movableItems.Select(i => i as ISelectableCanvasObject).ToList(); var newMoveComment = new ModifyCanvasElementsCommand(context.CompositionInstance.Symbol.Id, affectedItemsAsNodes, context.Selector); context.MacroCommand.AddExecutedCommandForUndo(newMoveComment); Log.Debug($"MoveItems: {affectedItemsAsNodes.Count} items by {offset}"); foreach (var item in affectedItemsAsNodes) { item.PosOnCanvas += offset; } newMoveComment.StoreCurrentValues(); // Programmatic pushes (splice inserts, added input rows, placeholder insertion) can // shove ops across their section's bottom/right border - grow those frames to keep them _growSectionIds.Clear(); var symbolUi = context.CompositionInstance.GetSymbolUi(); foreach (var item in movableItems) { if (item.ChildUi == null || item.ChildUi.SectionId == Guid.Empty) continue; if (!_growSectionIds.Add(item.ChildUi.SectionId)) continue; if (!symbolUi.Sections.TryGetValue(item.ChildUi.SectionId, out var section) || section.Collapsed || section.IsHiddenInCollapsedSection) continue; GrowSectionToFitContent(context, symbolUi, section, excludedChildIds: null); } } private static readonly HashSet _growSectionIds = []; private void InitPrimaryDraggedOutput() { _context.DraggedPrimaryOutputType = null; _context.ItemForInputSelection = null; _context.ActiveSourceItem = FindPrimaryOutputItem(); if (_context.ActiveSourceItem == null) return; _context.DraggedPrimaryOutputType = _context.ActiveSourceItem.PrimaryType; } /// /// Snapping an item onto a hidden parameter is a tricky ui problem. /// To allow this interaction we add "peek" anchor indicator to the dragged item set. /// If this is dropped without snapping onto an operator with hidden parameters of the matching type, /// we present cables.gl inspired picker interface. /// /// Set primary types to indicate targets for dragging /// This part is a little fishy. To have "some" solution we try to identify dragged /// constellations that has a single free horizontal output anchor on the left column. /// private MagGraphItem? FindPrimaryOutputItem() { if (DraggedItems.Count == 0) return null; if (DraggedItems.Count == 1) return DraggedItems.First(); var itemsOrderedByX = DraggedItems.OrderByDescending(c => c.PosOnCanvas.X); var rightItem = itemsOrderedByX.First(); // Check if there are multiple items on right edge column... var rightColumnItemCount = 0; foreach (var i in DraggedItems) { if (Math.Abs(i.PosOnCanvas.X - rightItem.PosOnCanvas.X) < SnapTolerance) rightColumnItemCount++; } if (rightColumnItemCount > 1) return null; if (DraggedItems.Count != CollectSnappedItems(rightItem).Count) return null; return rightItem; } /// /// Add snapped items to the given set or create new set /// public static HashSet CollectSnappedItems(MagGraphItem rootItem, HashSet? set = null, bool includeRoot= true, int ignoreConnectionHash= 0) { set ??= []; Collect(rootItem); if (!includeRoot) set.Remove(rootItem); return set; void Collect(MagGraphItem item) { if (!set.Add(item)) return; for (var index = 0; index < item.InputLines.Length; index++) { var c = item.InputLines[index].ConnectionIn; if (c == null || c.ConnectionHash == ignoreConnectionHash) continue; if (c.IsSnapped && !c.IsTemporary) Collect(c.SourceItem); } for (var index = 0; index < item.OutputLines.Length; index++) { var connections = item.OutputLines[index].ConnectionsOut; foreach (var c in connections) { if (c.ConnectionHash != ignoreConnectionHash && c.IsSnapped) Collect(c.TargetItem); } } } } public static HashSet CollectSnappedItems(IEnumerable rootItems) { var set = new HashSet(); foreach (var i in rootItems) { CollectSnappedItems(i, set); } return set; } internal void SetDraggedItemIds(List selectedIds) { DraggedItems.Clear(); _draggedItemsFromSelection = false; foreach (var id in selectedIds) { if (_layout.Items.TryGetValue(id, out var i)) { DraggedItems.Add(i); } } } internal void SetDraggedItems(List selection) { DraggedItems.Clear(); _draggedItemsFromSelection = true; foreach (var s in selection) { if (_layout.Items.TryGetValue(s.Id, out var i)) { DraggedItems.Add(i); } } } internal void SetDraggedItemIdsToSnappedForItem(MagGraphItem item) { DraggedItems.Clear(); _draggedItemsFromSelection = false; CollectSnappedItems(item, DraggedItems); } internal bool IsItemDragged(MagGraphItem item) => DraggedItems.Contains(item); internal double LastSnapTime = double.NegativeInfinity; private Vector2 _lastSnapDragPositionOnCanvas; /** for visual indication only */ internal Vector2 LastSnapTargetPositionOnCanvas; internal readonly List SpliceSets = []; private Vector2 _lastAppliedOffset; private const float SnapThreshold = 30; /// /// Connections between dragged and non-dragged items /// private readonly List _connectionsToDraggedItems = []; private readonly HashSet _connectionToDragItemHashes = []; private readonly HashSet _snappedConnectionToDragItemHashes = []; private bool _wasSnapped; private static readonly MagItemMovement.Snapping _snapping = new(); private static bool _hasDragged; private Vector2 _dragStartPosInOpOnCanvas; private readonly ShakeDetector _shakeDetector = new(); internal readonly HashSet DraggedItems = []; private bool _draggedItemsFromSelection; private List _draggedSelectables = []; /// /// Defines a method how the currently dragged nodes could split some other snapped graph nodes /// either horizontally or vertically. It provides information on how to snap, what type of /// connection would be split and which inputs and outputs to splice in. /// /// The distance from top left position of InputItem (A) to where the mouse drag started. internal sealed record SpliceLink( Guid InputItemId, Guid InputId, Guid OutputItemId, Guid OutputId, MagGraphItem.Directions Direction, Type Type, float Distance, Vector2 DragPositionWithinBlock, Vector2 AnchorOffset); private readonly MagGraphView _view; private readonly MagGraphLayout _layout; private readonly NodeSelection _nodeSelection; private const float SnapTolerance = 0.01f; }