using RobotNet10.MapEditor.Shared.DTOs.Node; namespace RobotNet10.MapEditor.Services.State; /// /// Direction/pattern type for a group of nodes /// public enum GroupPattern { /// Nodes form a horizontal line (similar Y values) HorizontalLine, /// Nodes form a vertical line (similar X values) VerticalLine, /// Nodes are scattered, no clear pattern Scattered, /// Single node, no pattern applicable Single } /// /// Represents a group of nodes that can be aligned/distributed together /// public class NodeGroup { public List Nodes { get; set; } = []; public GroupPattern Pattern { get; set; } public bool WillAlign { get; set; } public bool WillDistribute { get; set; } public string GetDescription() { var actions = new List(); if (WillAlign) actions.Add("align"); if (WillDistribute) actions.Add("distribute"); var patternName = Pattern switch { GroupPattern.HorizontalLine => "horizontal", GroupPattern.VerticalLine => "vertical", GroupPattern.Scattered => "scattered", GroupPattern.Single => "single", _ => "unknown" }; if (actions.Count == 0) return $"{Nodes.Count} nodes ({patternName}) - no action"; return $"{Nodes.Count} nodes ({patternName}) → {string.Join(", ", actions)}"; } } /// /// Result of smart auto-format analysis /// public class SmartAutoFormatResult { public List Groups { get; set; } = []; public int TotalNodes { get; set; } public bool WillSnap { get; set; } public int AlignCount { get; set; } public int DistributeCount { get; set; } public string Summary { get; set; } = string.Empty; public bool HasChanges => WillSnap || AlignCount > 0 || DistributeCount > 0; } /// /// Result returned from AutoFormatDialog /// public class SmartAutoFormatDialogResult { public SmartAutoFormatResult Analysis { get; set; } = new(); public SmartAutoFormatConfig Config { get; set; } = new(); } /// /// Configuration for smart auto-format (user-adjustable) /// public class SmartAutoFormatConfig { /// /// Enable snap to grid /// public bool EnableSnap { get; set; } = true; /// /// Grid size for snapping (meters) /// public double SnapGridSize { get; set; } = 0.5; /// /// Enable alignment /// public bool EnableAlign { get; set; } = true; /// /// Enable distribute equally /// public bool EnableDistribute { get; set; } = true; /// /// Minimum spread (length) for a group of nodes to be considered a "line" (meters) /// Nodes must span at least this distance along the line direction. /// Example: if MinLineSpread=1.0m, 3 nodes at x=0, x=0.5, x=0.8 won't form a line (spread=0.8m) /// public double MinLineSpread { get; set; } = 1.0; /// /// Maximum deviation from the line for nodes to be grouped together (meters) /// Lower = stricter line detection (nodes must be more precisely aligned) /// Example: if PatternThreshold=0.3m, nodes with Y varying by ±0.3m can form horizontal line /// public double PatternThreshold { get; set; } = 0.3; } /// /// Smart analyzer that detects groups and determines operations /// NEW APPROACH: Group by LINE PATTERN first, not by distance /// - Find nodes that form horizontal lines (similar Y) /// - Find nodes that form vertical lines (similar X) /// - Each node belongs to at most one group /// - Scattered nodes are NOT aligned/distributed /// public static class SmartAutoFormatAnalyzer { /// /// Analyze nodes and create groups with determined operations /// public static SmartAutoFormatResult Analyze(List nodes, SmartAutoFormatConfig config) { var result = new SmartAutoFormatResult { TotalNodes = nodes.Count }; if (nodes.Count < 2) { result.Summary = "Need at least 2 nodes to format"; return result; } // Step 1: Check if snap is enabled result.WillSnap = config.EnableSnap; // Step 2: Detect line patterns and create groups if (config.EnableAlign || config.EnableDistribute) { var groups = DetectLineGroups(nodes, config); foreach (var group in groups) { result.Groups.Add(group); if (group.WillAlign) result.AlignCount++; if (group.WillDistribute) result.DistributeCount++; } // Add ungrouped nodes as scattered (snap only) var groupedIds = groups.SelectMany(g => g.Nodes).Select(n => n.Id).ToHashSet(); var ungrouped = nodes.Where(n => !groupedIds.Contains(n.Id)).ToList(); if (ungrouped.Count > 0) { result.Groups.Add(new NodeGroup { Nodes = ungrouped, Pattern = GroupPattern.Scattered, WillAlign = false, WillDistribute = false }); } } // Step 3: Build summary result.Summary = BuildSummary(result, config); return result; } /// /// Detect line groups by finding nodes with similar coordinates /// A horizontal line = nodes with similar Y (spread along X) /// A vertical line = nodes with similar X (spread along Y) /// private static List DetectLineGroups(List nodes, SmartAutoFormatConfig config) { var threshold = config.PatternThreshold; var minSpread = config.MinLineSpread; // Minimum spread to be considered a line // Find all potential horizontal lines var horizontalLines = FindLinesAlongAxis(nodes, isHorizontal: true, threshold, minSpread); // Find all potential vertical lines var verticalLines = FindLinesAlongAxis(nodes, isHorizontal: false, threshold, minSpread); // Resolve conflicts: each node can only belong to one group // Priority: line with more nodes wins, then line with less deviation var allLines = new List<(NodeGroup group, double score)>(); foreach (var line in horizontalLines) { var score = CalculateLineScore(line, isHorizontal: true); allLines.Add((line, score)); } foreach (var line in verticalLines) { var score = CalculateLineScore(line, isHorizontal: false); allLines.Add((line, score)); } // Sort by score descending (higher = better) allLines = allLines.OrderByDescending(x => x.score).ToList(); // Assign nodes to best fitting line (greedy) var assigned = new HashSet(); var result = new List(); foreach (var (group, _) in allLines) { // Filter out already assigned nodes var availableNodes = group.Nodes.Where(n => !assigned.Contains(n.Id)).ToList(); if (availableNodes.Count >= 2) { // Recalculate if we should still align/distribute var newGroup = new NodeGroup { Nodes = availableNodes, Pattern = group.Pattern, WillAlign = config.EnableAlign, WillDistribute = config.EnableDistribute && availableNodes.Count >= 3 }; result.Add(newGroup); foreach (var node in availableNodes) { assigned.Add(node.Id); } } } return result; } /// /// Find nodes that form lines along an axis /// For horizontal: group nodes with similar Y values /// For vertical: group nodes with similar X values /// private static List FindLinesAlongAxis( List nodes, bool isHorizontal, double threshold, double minSpread) { var result = new List(); if (nodes.Count < 2) return result; // Sort by the coordinate we're grouping on var sorted = isHorizontal ? nodes.OrderBy(n => n.Y).ToList() : nodes.OrderBy(n => n.X).ToList(); var currentGroup = new List { sorted[0] }; for (int i = 1; i < sorted.Count; i++) { var current = sorted[i]; var groupAvg = isHorizontal ? currentGroup.Average(n => n.Y) : currentGroup.Average(n => n.X); var currentCoord = isHorizontal ? current.Y : current.X; // Check if this node fits in current group if (Math.Abs(currentCoord - groupAvg) <= threshold) { currentGroup.Add(current); } else { // Finalize current group if valid TryAddLineGroup(result, currentGroup, isHorizontal, threshold, minSpread); // Start new group currentGroup = new List { current }; } } // Don't forget last group TryAddLineGroup(result, currentGroup, isHorizontal, threshold, minSpread); return result; } /// /// Add a line group if it meets criteria: /// - At least 2 nodes /// - Spread along the other axis >= minSpread /// - Deviation along grouping axis is within threshold /// private static void TryAddLineGroup( List result, List nodes, bool isHorizontal, double threshold, double minSpread) { if (nodes.Count < 2) return; // Calculate spread along the OTHER axis (perpendicular to grouping) var spread = isHorizontal ? nodes.Max(n => n.X) - nodes.Min(n => n.X) : nodes.Max(n => n.Y) - nodes.Min(n => n.Y); // Calculate deviation along grouping axis var groupCoord = isHorizontal ? nodes.Average(n => n.Y) : nodes.Average(n => n.X); var maxDeviation = isHorizontal ? nodes.Max(n => Math.Abs(n.Y - groupCoord)) : nodes.Max(n => Math.Abs(n.X - groupCoord)); // Must have reasonable spread and be well-aligned if (spread >= minSpread && maxDeviation <= threshold) { result.Add(new NodeGroup { Nodes = nodes.ToList(), Pattern = isHorizontal ? GroupPattern.HorizontalLine : GroupPattern.VerticalLine, WillAlign = true, WillDistribute = nodes.Count >= 3 }); } } /// /// Calculate score for a line group (higher = better fit) /// Score = (node count) * (1 / (1 + avg_deviation)) /// private static double CalculateLineScore(NodeGroup group, bool isHorizontal) { var nodes = group.Nodes; var avgCoord = isHorizontal ? nodes.Average(n => n.Y) : nodes.Average(n => n.X); var avgDeviation = isHorizontal ? nodes.Average(n => Math.Abs(n.Y - avgCoord)) : nodes.Average(n => Math.Abs(n.X - avgCoord)); // More nodes and less deviation = higher score return nodes.Count * (1.0 / (1.0 + avgDeviation)); } private static double CalculateDistance(NodeDto a, NodeDto b) { var dx = a.X - b.X; var dy = a.Y - b.Y; return Math.Sqrt(dx * dx + dy * dy); } private static string BuildSummary(SmartAutoFormatResult result, SmartAutoFormatConfig config) { var parts = new List(); if (result.WillSnap) parts.Add($"Snap to {config.SnapGridSize}m grid"); if (result.AlignCount > 0) parts.Add($"Align {result.AlignCount} group(s)"); if (result.DistributeCount > 0) parts.Add($"Distribute {result.DistributeCount} group(s)"); if (parts.Count == 0) return "No operations to perform"; return string.Join(" → ", parts); } } /// /// Executor for applying auto-format operations /// Order: Snap → Align → Distribute /// public static class SmartAutoFormatExecutor { /// /// Apply auto-format operations in order: Snap → Align → Distribute /// public static Dictionary ApplyFormat( List nodes, SmartAutoFormatResult analysis, SmartAutoFormatConfig config) { // Start with current positions var positions = nodes.ToDictionary(n => n.Id, n => (X: n.X, Y: n.Y)); // Step 1: SNAP TO GRID (all nodes) if (config.EnableSnap) { positions = ApplySnapToGrid(positions, config.SnapGridSize); } // Step 2 & 3: ALIGN and DISTRIBUTE (per group) foreach (var group in analysis.Groups) { if (group.WillAlign) { positions = ApplyAlign(group.Nodes, positions, group.Pattern); } if (group.WillDistribute) { positions = ApplyDistribute(group.Nodes, positions, group.Pattern); } } return positions; } /// /// Snap all coordinates to grid /// private static Dictionary ApplySnapToGrid( Dictionary positions, double gridSize) { var result = new Dictionary(); foreach (var kvp in positions) { var snappedX = Math.Round(kvp.Value.X / gridSize) * gridSize; var snappedY = Math.Round(kvp.Value.Y / gridSize) * gridSize; result[kvp.Key] = (snappedX, snappedY); } return result; } /// /// Align nodes in a group /// private static Dictionary ApplyAlign( List nodes, Dictionary positions, GroupPattern pattern) { var result = new Dictionary(positions); // Get current positions for these nodes var nodePositions = nodes.Select(n => positions[n.Id]).ToList(); if (pattern == GroupPattern.HorizontalLine) { // Align to same Y (average Y) var avgY = nodePositions.Average(p => p.Y); foreach (var node in nodes) { var pos = result[node.Id]; result[node.Id] = (pos.X, avgY); } } else if (pattern == GroupPattern.VerticalLine) { // Align to same X (average X) var avgX = nodePositions.Average(p => p.X); foreach (var node in nodes) { var pos = result[node.Id]; result[node.Id] = (avgX, pos.Y); } } return result; } /// /// Distribute nodes evenly in a group /// private static Dictionary ApplyDistribute( List nodes, Dictionary positions, GroupPattern pattern) { if (nodes.Count < 3) return positions; var result = new Dictionary(positions); bool distributeOnX = pattern == GroupPattern.HorizontalLine; // Sort nodes by current position on distribution axis var sorted = distributeOnX ? nodes.OrderBy(n => positions[n.Id].X).ToList() : nodes.OrderBy(n => positions[n.Id].Y).ToList(); var firstPos = positions[sorted.First().Id]; var lastPos = positions[sorted.Last().Id]; double minVal = distributeOnX ? firstPos.X : firstPos.Y; double maxVal = distributeOnX ? lastPos.X : lastPos.Y; // Calculate even spacing var spacing = (maxVal - minVal) / (sorted.Count - 1); // Apply new positions (keep first and last fixed) for (int i = 1; i < sorted.Count - 1; i++) { var node = sorted[i]; var pos = positions[node.Id]; var newVal = minVal + (i * spacing); result[node.Id] = distributeOnX ? (newVal, pos.Y) : (pos.X, newVal); } return result; } } // ============================================ // LEGACY SUPPORT (for backward compatibility) // ============================================ public enum AlignDirection { Auto, Horizontal, Vertical } public class AutoFormatOptions { public bool AlignEnabled { get; set; } public AlignDirection AlignDirection { get; set; } = AlignDirection.Auto; public bool DistributeEnabled { get; set; } public bool RoundEnabled { get; set; } public int RoundDecimalPlaces { get; set; } = 2; public bool SnapToGridEnabled { get; set; } public double SnapGridSize { get; set; } = 0.5; } public class AutoFormatAnalysisResult { public bool SuggestAlign { get; set; } public AlignDirection SuggestedAlignDirection { get; set; } = AlignDirection.Auto; public bool SuggestDistribute { get; set; } public bool SuggestRound { get; set; } public int SuggestedDecimalPlaces { get; set; } = 2; public string PatternDescription { get; set; } = string.Empty; } public static class AutoFormatAnalyzer { public static AutoFormatAnalysisResult Analyze(List nodes) { var config = new SmartAutoFormatConfig(); var smartResult = SmartAutoFormatAnalyzer.Analyze(nodes, config); var result = new AutoFormatAnalysisResult(); if (smartResult.Groups.Count > 0) { var mainGroup = smartResult.Groups.OrderByDescending(g => g.Nodes.Count).First(); result.SuggestAlign = mainGroup.WillAlign; result.SuggestDistribute = mainGroup.WillDistribute; result.SuggestedAlignDirection = mainGroup.Pattern == GroupPattern.HorizontalLine ? AlignDirection.Horizontal : mainGroup.Pattern == GroupPattern.VerticalLine ? AlignDirection.Vertical : AlignDirection.Auto; result.PatternDescription = smartResult.Summary; } return result; } } public static class AutoFormatExecutor { public static Dictionary ApplyFormat( List nodes, AutoFormatOptions options) { var config = new SmartAutoFormatConfig { EnableSnap = options.SnapToGridEnabled, SnapGridSize = options.SnapGridSize, EnableAlign = options.AlignEnabled, EnableDistribute = options.DistributeEnabled }; var analysis = SmartAutoFormatAnalyzer.Analyze(nodes, config); return SmartAutoFormatExecutor.ApplyFormat(nodes, analysis, config); } }