578 lines
20 KiB
C#
578 lines
20 KiB
C#
using RobotNet10.MapEditor.Shared.DTOs.Node;
|
|
|
|
namespace RobotNet10.MapEditor.Services.State;
|
|
|
|
/// <summary>
|
|
/// Direction/pattern type for a group of nodes
|
|
/// </summary>
|
|
public enum GroupPattern
|
|
{
|
|
/// <summary>Nodes form a horizontal line (similar Y values)</summary>
|
|
HorizontalLine,
|
|
/// <summary>Nodes form a vertical line (similar X values)</summary>
|
|
VerticalLine,
|
|
/// <summary>Nodes are scattered, no clear pattern</summary>
|
|
Scattered,
|
|
/// <summary>Single node, no pattern applicable</summary>
|
|
Single
|
|
}
|
|
|
|
/// <summary>
|
|
/// Represents a group of nodes that can be aligned/distributed together
|
|
/// </summary>
|
|
public class NodeGroup
|
|
{
|
|
public List<NodeDto> 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<string>();
|
|
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)}";
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of smart auto-format analysis
|
|
/// </summary>
|
|
public class SmartAutoFormatResult
|
|
{
|
|
public List<NodeGroup> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result returned from AutoFormatDialog
|
|
/// </summary>
|
|
public class SmartAutoFormatDialogResult
|
|
{
|
|
public SmartAutoFormatResult Analysis { get; set; } = new();
|
|
public SmartAutoFormatConfig Config { get; set; } = new();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Configuration for smart auto-format (user-adjustable)
|
|
/// </summary>
|
|
public class SmartAutoFormatConfig
|
|
{
|
|
/// <summary>
|
|
/// Enable snap to grid
|
|
/// </summary>
|
|
public bool EnableSnap { get; set; } = true;
|
|
|
|
/// <summary>
|
|
/// Grid size for snapping (meters)
|
|
/// </summary>
|
|
public double SnapGridSize { get; set; } = 0.5;
|
|
|
|
/// <summary>
|
|
/// Enable alignment
|
|
/// </summary>
|
|
public bool EnableAlign { get; set; } = true;
|
|
|
|
/// <summary>
|
|
/// Enable distribute equally
|
|
/// </summary>
|
|
public bool EnableDistribute { get; set; } = true;
|
|
|
|
/// <summary>
|
|
/// 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)
|
|
/// </summary>
|
|
public double MinLineSpread { get; set; } = 1.0;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// </summary>
|
|
public double PatternThreshold { get; set; } = 0.3;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// </summary>
|
|
public static class SmartAutoFormatAnalyzer
|
|
{
|
|
/// <summary>
|
|
/// Analyze nodes and create groups with determined operations
|
|
/// </summary>
|
|
public static SmartAutoFormatResult Analyze(List<NodeDto> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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)
|
|
/// </summary>
|
|
private static List<NodeGroup> DetectLineGroups(List<NodeDto> 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<Guid>();
|
|
var result = new List<NodeGroup>();
|
|
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Find nodes that form lines along an axis
|
|
/// For horizontal: group nodes with similar Y values
|
|
/// For vertical: group nodes with similar X values
|
|
/// </summary>
|
|
private static List<NodeGroup> FindLinesAlongAxis(
|
|
List<NodeDto> nodes, bool isHorizontal, double threshold, double minSpread)
|
|
{
|
|
var result = new List<NodeGroup>();
|
|
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<NodeDto> { 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<NodeDto> { current };
|
|
}
|
|
}
|
|
|
|
// Don't forget last group
|
|
TryAddLineGroup(result, currentGroup, isHorizontal, threshold, minSpread);
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// </summary>
|
|
private static void TryAddLineGroup(
|
|
List<NodeGroup> result, List<NodeDto> 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
|
|
});
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calculate score for a line group (higher = better fit)
|
|
/// Score = (node count) * (1 / (1 + avg_deviation))
|
|
/// </summary>
|
|
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<string>();
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Executor for applying auto-format operations
|
|
/// Order: Snap → Align → Distribute
|
|
/// </summary>
|
|
public static class SmartAutoFormatExecutor
|
|
{
|
|
/// <summary>
|
|
/// Apply auto-format operations in order: Snap → Align → Distribute
|
|
/// </summary>
|
|
public static Dictionary<Guid, (double X, double Y)> ApplyFormat(
|
|
List<NodeDto> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Snap all coordinates to grid
|
|
/// </summary>
|
|
private static Dictionary<Guid, (double X, double Y)> ApplySnapToGrid(
|
|
Dictionary<Guid, (double X, double Y)> positions,
|
|
double gridSize)
|
|
{
|
|
var result = new Dictionary<Guid, (double X, double Y)>();
|
|
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Align nodes in a group
|
|
/// </summary>
|
|
private static Dictionary<Guid, (double X, double Y)> ApplyAlign(
|
|
List<NodeDto> nodes,
|
|
Dictionary<Guid, (double X, double Y)> positions,
|
|
GroupPattern pattern)
|
|
{
|
|
var result = new Dictionary<Guid, (double X, double Y)>(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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Distribute nodes evenly in a group
|
|
/// </summary>
|
|
private static Dictionary<Guid, (double X, double Y)> ApplyDistribute(
|
|
List<NodeDto> nodes,
|
|
Dictionary<Guid, (double X, double Y)> positions,
|
|
GroupPattern pattern)
|
|
{
|
|
if (nodes.Count < 3) return positions;
|
|
|
|
var result = new Dictionary<Guid, (double X, double Y)>(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<NodeDto> 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<Guid, (double X, double Y)> ApplyFormat(
|
|
List<NodeDto> 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);
|
|
}
|
|
}
|