Initial commit
This commit is contained in:
@@ -0,0 +1,577 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user