Initial commit

This commit is contained in:
2026-07-03 16:37:12 +07:00
commit 63b8c1ea8b
1931 changed files with 640587 additions and 0 deletions

View File

@@ -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);
}
}