Initial commit
This commit is contained in:
299
srcs/RobotNet10/Commons/RobotNet10.Common/KDTTree.cs
Normal file
299
srcs/RobotNet10/Commons/RobotNet10.Common/KDTTree.cs
Normal file
@@ -0,0 +1,299 @@
|
||||
using RobotNet10.Common.Models;
|
||||
|
||||
namespace RobotNet10.Common;
|
||||
|
||||
/// <summary>
|
||||
/// KD-Tree implementation for efficient 2D spatial search of nodes.
|
||||
/// Thread-safe for read operations after construction.
|
||||
/// Time complexity: O(n log n) build, O(log n) search average case.
|
||||
/// </summary>
|
||||
public class KDTree
|
||||
{
|
||||
private readonly KDTreeNode? _root;
|
||||
private readonly List<KDTreeData> _nodes;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new KD-Tree from a collection of nodes.
|
||||
/// </summary>
|
||||
/// <param name="nodes">The nodes to index. Original collection is not modified.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when nodes is null.</exception>
|
||||
public KDTree(IEnumerable<KDTreeData> nodes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nodes);
|
||||
|
||||
// Create a copy to avoid mutating input
|
||||
_nodes = [.. nodes];
|
||||
|
||||
if (_nodes.Count == 0)
|
||||
{
|
||||
_root = null;
|
||||
return;
|
||||
}
|
||||
|
||||
_root = BuildTree(0, _nodes.Count - 1, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of nodes in the tree.
|
||||
/// </summary>
|
||||
public int Count => _nodes.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the KD-Tree using index-based recursion to avoid memory allocation overhead.
|
||||
/// Time complexity: O(n log n)
|
||||
/// </summary>
|
||||
private KDTreeNode? BuildTree(int start, int end, int depth)
|
||||
{
|
||||
if (start > end)
|
||||
return null;
|
||||
|
||||
int axis = depth % 2;
|
||||
|
||||
// Use QuickSelect to find median without full sort
|
||||
int medianIndex = QuickSelect(start, end, (start + end) / 2, axis);
|
||||
|
||||
return new KDTreeNode(
|
||||
node: _nodes[medianIndex],
|
||||
axis: axis,
|
||||
left: BuildTree(start, medianIndex - 1, depth + 1),
|
||||
right: BuildTree(medianIndex + 1, end, depth + 1)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// QuickSelect algorithm to find the k-th smallest element.
|
||||
/// Time complexity: O(n) average, O(n²) worst case.
|
||||
/// </summary>
|
||||
private int QuickSelect(int left, int right, int k, int axis)
|
||||
{
|
||||
while (left < right)
|
||||
{
|
||||
int pivotIndex = Partition(left, right, axis);
|
||||
|
||||
if (pivotIndex == k)
|
||||
return k;
|
||||
else if (k < pivotIndex)
|
||||
right = pivotIndex - 1;
|
||||
else
|
||||
left = pivotIndex + 1;
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Partitions the array for QuickSelect using median-of-three pivot selection.
|
||||
/// This is the CORRECTED version that handles all edge cases properly.
|
||||
/// </summary>
|
||||
private int Partition(int left, int right, int axis)
|
||||
{
|
||||
// Handle small subarrays
|
||||
if (right - left < 2)
|
||||
{
|
||||
if (right > left && CompareNodes(_nodes[right], _nodes[left], axis) < 0)
|
||||
Swap(left, right);
|
||||
return left;
|
||||
}
|
||||
|
||||
// Use median-of-three pivot selection for better performance
|
||||
int mid = left + (right - left) / 2; // Overflow-safe
|
||||
|
||||
// Sort left, mid, right to get median as pivot
|
||||
if (CompareNodes(_nodes[mid], _nodes[left], axis) < 0)
|
||||
Swap(left, mid);
|
||||
if (CompareNodes(_nodes[right], _nodes[left], axis) < 0)
|
||||
Swap(left, right);
|
||||
if (CompareNodes(_nodes[right], _nodes[mid], axis) < 0)
|
||||
Swap(mid, right);
|
||||
|
||||
// Now: nodes[left] <= nodes[mid] <= nodes[right]
|
||||
// Use mid as pivot and hide it at right-1
|
||||
KDTreeData pivot = _nodes[mid];
|
||||
Swap(mid, right - 1);
|
||||
|
||||
// Partition with pivot at right-1
|
||||
int i = left + 1; // Start after left (which is already <= pivot)
|
||||
int j = right - 2; // Start before pivot position
|
||||
|
||||
while (i <= j)
|
||||
{
|
||||
// Find element >= pivot from left
|
||||
while (i <= j && CompareNodes(_nodes[i], pivot, axis) < 0)
|
||||
i++;
|
||||
|
||||
// Find element <= pivot from right
|
||||
while (i <= j && CompareNodes(_nodes[j], pivot, axis) > 0)
|
||||
j--;
|
||||
|
||||
if (i < j)
|
||||
{
|
||||
Swap(i, j);
|
||||
i++;
|
||||
j--;
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Put pivot in its final position
|
||||
Swap(i, right - 1);
|
||||
return i;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares two nodes along the specified axis.
|
||||
/// </summary>
|
||||
private static int CompareNodes(KDTreeData a, KDTreeData b, int axis)
|
||||
{
|
||||
return axis == 0 ? a.X.CompareTo(b.X) : a.Y.CompareTo(b.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Swaps two elements in the nodes list using tuple deconstruction.
|
||||
/// </summary>
|
||||
private void Swap(int i, int j)
|
||||
{
|
||||
if (i != j)
|
||||
{
|
||||
(_nodes[j], _nodes[i]) = (_nodes[i], _nodes[j]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the nearest node to the given coordinates within the specified distance limit.
|
||||
/// Time complexity: O(log n) average, O(n) worst case.
|
||||
/// </summary>
|
||||
/// <param name="x">X coordinate</param>
|
||||
/// <param name="y">Y coordinate</param>
|
||||
/// <param name="limitDistance">Maximum search distance</param>
|
||||
/// <returns>The nearest node within limit, or null if none found</returns>
|
||||
/// <exception cref="ArgumentOutOfRangeException">Thrown when limitDistance is negative.</exception>
|
||||
public KDTreeData? FindNearest(double x, double y, double limitDistance)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(limitDistance);
|
||||
|
||||
if (_root == null)
|
||||
return null;
|
||||
|
||||
double limitDistSquared = limitDistance * limitDistance;
|
||||
var result = FindNearestRecursive(_root, x, y, null, double.MaxValue, limitDistSquared);
|
||||
|
||||
return result.BestNode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively finds the nearest node using squared distances to avoid sqrt operations.
|
||||
/// </summary>
|
||||
private static SearchResult FindNearestRecursive(
|
||||
KDTreeNode? node,
|
||||
double x,
|
||||
double y,
|
||||
KDTreeData? bestNode,
|
||||
double bestDistSquared,
|
||||
double limitDistSquared)
|
||||
{
|
||||
if (node == null)
|
||||
return new SearchResult(bestNode, bestDistSquared);
|
||||
|
||||
// Calculate squared distance (avoid sqrt for performance)
|
||||
double dx = node.Node.X - x;
|
||||
double dy = node.Node.Y - y;
|
||||
double distSquared = dx * dx + dy * dy;
|
||||
|
||||
// Update best if this node is closer and within limit
|
||||
if (distSquared < bestDistSquared && distSquared <= limitDistSquared)
|
||||
{
|
||||
bestNode = node.Node;
|
||||
bestDistSquared = distSquared;
|
||||
}
|
||||
|
||||
// Determine which side to search first
|
||||
double delta = node.Axis == 0 ? x - node.Node.X : y - node.Node.Y;
|
||||
KDTreeNode? nearSide = delta < 0 ? node.Left : node.Right;
|
||||
KDTreeNode? farSide = delta < 0 ? node.Right : node.Left;
|
||||
|
||||
// Search near side
|
||||
var result = FindNearestRecursive(nearSide, x, y, bestNode, bestDistSquared, limitDistSquared);
|
||||
bestNode = result.BestNode;
|
||||
bestDistSquared = result.BestDistSquared;
|
||||
|
||||
// Only search far side if it could contain a closer point
|
||||
double deltaSquared = delta * delta;
|
||||
if (deltaSquared < bestDistSquared)
|
||||
{
|
||||
result = FindNearestRecursive(farSide, x, y, bestNode, bestDistSquared, limitDistSquared);
|
||||
bestNode = result.BestNode;
|
||||
bestDistSquared = result.BestDistSquared;
|
||||
}
|
||||
|
||||
return new SearchResult(bestNode, bestDistSquared);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds all nodes within the specified radius from the given coordinates.
|
||||
/// </summary>
|
||||
/// <param name="x">X coordinate</param>
|
||||
/// <param name="y">Y coordinate</param>
|
||||
/// <param name="radius">Search radius</param>
|
||||
/// <returns>List of all nodes within the radius</returns>
|
||||
public List<KDTreeData> FindInRadius(double x, double y, double radius)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(radius);
|
||||
|
||||
if (_root == null)
|
||||
return [];
|
||||
|
||||
var result = new List<KDTreeData>();
|
||||
double radiusSquared = radius * radius;
|
||||
FindInRadiusRecursive(_root, x, y, radiusSquared, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively finds all nodes within radius.
|
||||
/// </summary>
|
||||
private static void FindInRadiusRecursive(
|
||||
KDTreeNode? node,
|
||||
double x,
|
||||
double y,
|
||||
double radiusSquared,
|
||||
List<KDTreeData> result)
|
||||
{
|
||||
if (node == null)
|
||||
return;
|
||||
|
||||
double dx = node.Node.X - x;
|
||||
double dy = node.Node.Y - y;
|
||||
double distSquared = dx * dx + dy * dy;
|
||||
|
||||
if (distSquared <= radiusSquared)
|
||||
result.Add(node.Node);
|
||||
|
||||
double delta = node.Axis == 0 ? x - node.Node.X : y - node.Node.Y;
|
||||
double deltaSquared = delta * delta;
|
||||
|
||||
// Search both sides if sphere intersects splitting plane
|
||||
if (delta < 0)
|
||||
{
|
||||
FindInRadiusRecursive(node.Left, x, y, radiusSquared, result);
|
||||
if (deltaSquared <= radiusSquared)
|
||||
FindInRadiusRecursive(node.Right, x, y, radiusSquared, result);
|
||||
}
|
||||
else
|
||||
{
|
||||
FindInRadiusRecursive(node.Right, x, y, radiusSquared, result);
|
||||
if (deltaSquared <= radiusSquared)
|
||||
FindInRadiusRecursive(node.Left, x, y, radiusSquared, result);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal struct to return search results without allocations.
|
||||
/// </summary>
|
||||
private readonly struct SearchResult(KDTreeData? bestNode, double bestDistSquared)
|
||||
{
|
||||
public readonly KDTreeData? BestNode = bestNode;
|
||||
public readonly double BestDistSquared = bestDistSquared;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace RobotNet10.Common.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a node in the KD-Tree structure.
|
||||
/// Immutable to prevent structural corruption.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Initializes a new KD-Tree node.
|
||||
/// </remarks>
|
||||
public class KDTreeNode(KDTreeData node, int axis, KDTreeNode? left = null, KDTreeNode? right = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// The spatial node stored at this tree node.
|
||||
/// </summary>
|
||||
public KDTreeData Node { get; } = node ?? throw new ArgumentNullException(nameof(node));
|
||||
|
||||
/// <summary>
|
||||
/// Left child (contains points with smaller values along the split axis).
|
||||
/// </summary>
|
||||
public KDTreeNode? Left { get; } = left;
|
||||
|
||||
/// <summary>
|
||||
/// Right child (contains points with larger values along the split axis).
|
||||
/// </summary>
|
||||
public KDTreeNode? Right { get; } = right;
|
||||
|
||||
/// <summary>
|
||||
/// The axis used for splitting: 0 for X, 1 for Y.
|
||||
/// </summary>
|
||||
public int Axis { get; } = axis;
|
||||
}
|
||||
|
||||
public record KDTreeData(string Id, double X, double Y);
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace RobotNet10.Common.Models;
|
||||
|
||||
public class SpaceEdge
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public double StartX { get; set; }
|
||||
public double StartY { get; set; }
|
||||
public double EndX { get; set; }
|
||||
public double EndY { get; set; }
|
||||
public int Degree { get; set; }
|
||||
public double ControlPoint1X { get; set; }
|
||||
public double ControlPoint1Y { get; set; }
|
||||
public double ControlPoint2X { get; set; }
|
||||
public double ControlPoint2Y { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace RobotNet10.Common.Models;
|
||||
|
||||
public record SpaceNode(double X, double Y)
|
||||
{
|
||||
public double DistanceTo(SpaceNode other)
|
||||
{
|
||||
double dx = X - other.X;
|
||||
double dy = Y - other.Y;
|
||||
return Math.Sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="NLog" Version="6.1.1" />
|
||||
<PackageReference Include="NLog.Web.AspNetCore" Version="6.1.2" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
365
srcs/RobotNet10/Commons/RobotNet10.Common/SpaceCompute.cs
Normal file
365
srcs/RobotNet10/Commons/RobotNet10.Common/SpaceCompute.cs
Normal file
@@ -0,0 +1,365 @@
|
||||
using RobotNet10.Common.Models;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace RobotNet10.Common;
|
||||
|
||||
public class SpaceCompute
|
||||
{
|
||||
public static SpaceNode BezierPoint([Range(0, 1)] double t, SpaceEdge edge)
|
||||
{
|
||||
t = Math.Clamp(t, 0.0, 1.0);
|
||||
if (edge.Degree == 1) return new SpaceNode(edge.StartX + t * (edge.EndX - edge.StartX), edge.StartY + t * (edge.EndY - edge.StartY));
|
||||
else if (edge.Degree == 2)
|
||||
{
|
||||
return new((1 - t) * (1 - t) * edge.StartX + 2 * t * (1 - t) * edge.ControlPoint1X + t * t * edge.EndX,
|
||||
(1 - t) * (1 - t) * edge.StartY + 2 * t * (1 - t) * edge.ControlPoint1Y + t * t * edge.EndY);
|
||||
}
|
||||
else if (edge.Degree == 3)
|
||||
{
|
||||
return new(Math.Pow(1 - t, 3) * edge.StartX + 3 * Math.Pow(1 - t, 2) * t * edge.ControlPoint1X + 3 * Math.Pow(t, 2) * (1 - t) * edge.ControlPoint2X + Math.Pow(t, 3) * edge.EndX,
|
||||
Math.Pow(1 - t, 3) * edge.StartY + 3 * Math.Pow(1 - t, 2) * t * edge.ControlPoint1Y + 3 * Math.Pow(t, 2) * (1 - t) * edge.ControlPoint2Y + Math.Pow(t, 3) * edge.EndY);
|
||||
}
|
||||
return new(edge.EndX, edge.EndY);
|
||||
}
|
||||
|
||||
// Giải phương trình bậc 2: ax² + bx + c = 0
|
||||
public static double[] SolveQuadratic(double a, double b, double c)
|
||||
{
|
||||
var roots = new List<double>();
|
||||
|
||||
if (Math.Abs(a) < 1e-10)
|
||||
{
|
||||
if (Math.Abs(b) > 1e-10)
|
||||
{
|
||||
roots.Add(-c / b);
|
||||
}
|
||||
return [.. roots];
|
||||
}
|
||||
|
||||
double discriminant = b * b - 4 * a * c;
|
||||
|
||||
if (discriminant >= 0)
|
||||
{
|
||||
double sqrtD = Math.Sqrt(discriminant);
|
||||
roots.Add((-b + sqrtD) / (2 * a));
|
||||
roots.Add((-b - sqrtD) / (2 * a));
|
||||
}
|
||||
|
||||
return [.. roots];
|
||||
}
|
||||
|
||||
// Giải phương trình bậc 3: ax³ + bx² + cx + d = 0
|
||||
public static double[] SolveCubic(double a, double b, double c, double d)
|
||||
{
|
||||
if (Math.Abs(a) < 1e-10)
|
||||
{
|
||||
// Phương trình bậc 2
|
||||
return SolveQuadratic(b, c, d);
|
||||
}
|
||||
|
||||
// Chuẩn hóa về dạng x³ + px + q = 0 (depressed cubic)
|
||||
b /= a;
|
||||
c /= a;
|
||||
d /= a;
|
||||
|
||||
double p = (3 * c - b * b) / 3;
|
||||
double q = (2 * b * b * b - 9 * b * c + 27 * d) / 27;
|
||||
|
||||
double discriminant = q * q / 4 + p * p * p / 27;
|
||||
|
||||
var roots = new List<double>();
|
||||
|
||||
if (discriminant >= 0)
|
||||
{
|
||||
// Một nghiệm thực
|
||||
double sqrtD = Math.Sqrt(discriminant);
|
||||
double term1 = -q / 2 + sqrtD;
|
||||
double term2 = -q / 2 - sqrtD;
|
||||
|
||||
// Tính căn bậc 3, xử lý số âm
|
||||
double u = term1 >= 0
|
||||
? Math.Pow(term1, 1.0 / 3)
|
||||
: -Math.Pow(-term1, 1.0 / 3);
|
||||
double v = term2 >= 0
|
||||
? Math.Pow(term2, 1.0 / 3)
|
||||
: -Math.Pow(-term2, 1.0 / 3);
|
||||
|
||||
double root = u + v - b / 3;
|
||||
|
||||
if (!double.IsNaN(root) && !double.IsInfinity(root))
|
||||
{
|
||||
roots.Add(root);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Ba nghiệm thực (trường hợp lượng giác)
|
||||
double r = Math.Sqrt(-p * p * p / 27);
|
||||
|
||||
if (r > 1e-10)
|
||||
{
|
||||
double acosArg = -q / (2 * r);
|
||||
// Clamp acosArg vào [-1, 1] để tránh NaN
|
||||
acosArg = Math.Max(-1.0, Math.Min(1.0, acosArg));
|
||||
double phi = Math.Acos(acosArg);
|
||||
double temp = 2 * Math.Pow(r, 1.0 / 3);
|
||||
|
||||
roots.Add(temp * Math.Cos(phi / 3) - b / 3);
|
||||
roots.Add(temp * Math.Cos((phi + 2 * Math.PI) / 3) - b / 3);
|
||||
roots.Add(temp * Math.Cos((phi + 4 * Math.PI) / 3) - b / 3);
|
||||
}
|
||||
}
|
||||
|
||||
return [.. roots];
|
||||
}
|
||||
|
||||
|
||||
// Phương pháp chính xác hơn sử dụng giải phương trình bậc 3
|
||||
public static (double distance, double time) DistanceToQuadraticBezier(SpaceNode nodeRef, SpaceEdge edge)
|
||||
{
|
||||
// Đạo hàm của hàm khoảng cách bình phương theo t
|
||||
// Giải phương trình bậc 3: d/dt[|P(t) - G|²] = 0
|
||||
|
||||
// Quadratic Bezier: P(t) = (1-t)²P₀ + 2t(1-t)P₁ + t²P₂
|
||||
// Đạo hàm: P'(t) = 2(1-t)(P₁-P₀) + 2t(P₂-P₁)
|
||||
// Khoảng cách bình phương: D(t) = |P(t) - G|²
|
||||
// Đạo hàm: D'(t) = 2(P(t) - G) · P'(t) = 0
|
||||
|
||||
// Viết lại: P(t) = P₀ + 2t(P₁-P₀) + t²(P₂-2P₁+P₀)
|
||||
// P'(t) = 2(P₁-P₀) + 2t(P₂-2P₁+P₀)
|
||||
// (P(t) - G) · P'(t) = 0
|
||||
|
||||
double ax = edge.StartX - 2 * edge.ControlPoint1X + edge.EndX;
|
||||
double ay = edge.StartY - 2 * edge.ControlPoint1Y + edge.EndY;
|
||||
double bx = 2 * (edge.ControlPoint1X - edge.StartX);
|
||||
double by = 2 * (edge.ControlPoint1Y - edge.StartY);
|
||||
double cx = edge.StartX - nodeRef.X;
|
||||
double cy = edge.StartY - nodeRef.Y;
|
||||
|
||||
// Hệ số của phương trình bậc 3: At³ + Bt² + Ct + D = 0
|
||||
// Từ: (P(t) - G) · P'(t) = 0
|
||||
//
|
||||
// Với: P(t) = P₀ + bt + at², P'(t) = b + 2at
|
||||
// c = P₀ - G
|
||||
// (c + bt + at²) · (b + 2at) = 0
|
||||
//
|
||||
// Khai triển tích vô hướng:
|
||||
// c·b + c·2at + bt·b + bt·2at + at²·b + at²·2at = 0
|
||||
// = c·b + 2c·a·t + b·b·t + 2a·b·t² + a·b·t² + 2a·a·t³ = 0
|
||||
// = c·b + (2c·a + b·b)·t + 3a·b·t² + 2a·a·t³ = 0
|
||||
//
|
||||
// Vậy: A = 2a·a = 2(aₓ² + aᵧ²)
|
||||
// B = 3a·b = 3(aₓbₓ + aᵧbᵧ)
|
||||
// C = 2c·a + b·b = 2(cₓaₓ + cᵧaᵧ) + (bₓ² + bᵧ²)
|
||||
// D = c·b = cₓbₓ + cᵧbᵧ
|
||||
double A = 2 * (ax * ax + ay * ay);
|
||||
double B = 3 * (ax * bx + ay * by);
|
||||
double C = 2 * (ax * cx + ay * cy) + (bx * bx + by * by);
|
||||
double D = bx * cx + by * cy;
|
||||
|
||||
// Tìm các nghiệm của phương trình bậc 3
|
||||
var roots = SolveCubic(A, B, C, D);
|
||||
|
||||
// Tính khoảng cách tại 2 đầu mút
|
||||
double distStart = nodeRef.DistanceTo(new(edge.StartX, edge.StartY));
|
||||
double distEnd = nodeRef.DistanceTo(new(edge.EndX, edge.EndY));
|
||||
|
||||
// Khởi tạo với giá trị tại 2 đầu mút
|
||||
double minDist = Math.Min(distStart, distEnd);
|
||||
double time = distStart < distEnd ? 0 : 1;
|
||||
|
||||
// Kiểm tra khoảng cách tại các điểm tới hạn (nghiệm của đạo hàm)
|
||||
foreach (double t in roots)
|
||||
{
|
||||
// Bỏ qua NaN và Infinity
|
||||
if (double.IsNaN(t) || double.IsInfinity(t)) continue;
|
||||
|
||||
// Chỉ xét nghiệm trong khoảng [0, 1]
|
||||
if (t >= 0 && t <= 1)
|
||||
{
|
||||
SpaceNode p = BezierPoint(t, edge);
|
||||
double dist = nodeRef.DistanceTo(p);
|
||||
|
||||
// Cập nhật minDist và time nếu tìm thấy khoảng cách nhỏ hơn
|
||||
if (dist < minDist)
|
||||
{
|
||||
minDist = dist;
|
||||
time = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kiểm tra lại khoảng cách tại 2 đầu mút (chỉ cập nhật nếu nhỏ hơn)
|
||||
// Lưu ý: Không ghi đè nếu minDist đã được cập nhật từ nghiệm trong (0,1)
|
||||
if (distStart < minDist)
|
||||
{
|
||||
minDist = distStart;
|
||||
time = 0;
|
||||
}
|
||||
if (distEnd < minDist)
|
||||
{
|
||||
minDist = distEnd;
|
||||
time = 1;
|
||||
}
|
||||
|
||||
return (minDist, time);
|
||||
}
|
||||
|
||||
// Phương pháp lấy mẫu - Đơn giản nhưng chậm hơn
|
||||
public static (double distance, double time) DistanceToCubicBezier(SpaceNode nodeRef, SpaceEdge edge)
|
||||
{
|
||||
double bestT = 0;
|
||||
double minDistance = Math.Sqrt(Math.Pow(nodeRef.X - edge.StartX, 2) + Math.Pow(nodeRef.Y - edge.StartY, 2));
|
||||
var length = GetEdgeLength(edge, 0.3);
|
||||
double step = 0.3 / (length == 0 ? 0.1 : length);
|
||||
|
||||
// Bước 1: Lấy mẫu thô
|
||||
for (double t = 0; t <= 1; t += step)
|
||||
{
|
||||
SpaceNode p = BezierPoint(t, edge);
|
||||
double dist = nodeRef.DistanceTo(p);
|
||||
|
||||
if (dist < minDistance)
|
||||
{
|
||||
minDistance = dist;
|
||||
bestT = t;
|
||||
}
|
||||
}
|
||||
|
||||
// Bước 2: Tối ưu hóa chính xác hơn
|
||||
double epsilon = 1e-6;
|
||||
step = 0.01;
|
||||
|
||||
for (int iter = 0; iter < 10; iter++)
|
||||
{
|
||||
double t1 = Math.Max(0, bestT - step);
|
||||
double t2 = Math.Min(1, bestT + step);
|
||||
|
||||
double d0 = nodeRef.DistanceTo(BezierPoint(t1, edge));
|
||||
double d1 = nodeRef.DistanceTo(BezierPoint(bestT, edge));
|
||||
double d2 = nodeRef.DistanceTo(BezierPoint(t2, edge));
|
||||
|
||||
if (d0 < d1)
|
||||
{
|
||||
bestT = t1;
|
||||
minDistance = d0;
|
||||
}
|
||||
else if (d2 < d1)
|
||||
{
|
||||
bestT = t2;
|
||||
minDistance = d2;
|
||||
}
|
||||
else
|
||||
{
|
||||
step *= 0.5;
|
||||
}
|
||||
|
||||
if (step < epsilon) break;
|
||||
}
|
||||
|
||||
return (minDistance, bestT);
|
||||
}
|
||||
|
||||
public static (double x, double y, double distance, double time) GetProjectionOnEdge(double x, double y, SpaceEdge edge)
|
||||
{
|
||||
if (edge.Degree == 2)
|
||||
{
|
||||
(double distance, double time) = DistanceToQuadraticBezier(new(x, y), edge);
|
||||
var node = BezierPoint(time, edge);
|
||||
return (node.X, node.Y, distance, time);
|
||||
}
|
||||
else if (edge.Degree == 3)
|
||||
{
|
||||
(double distance, var time) = DistanceToCubicBezier(new(x, y), edge);
|
||||
var node = BezierPoint(time, edge);
|
||||
return (node.X, node.Y, distance, time);
|
||||
}
|
||||
else
|
||||
{
|
||||
double time = 0;
|
||||
var edgeLengthSquared = Math.Pow(edge.StartX - edge.EndX, 2) + Math.Pow(edge.StartY - edge.EndY, 2);
|
||||
if (edgeLengthSquared > 0)
|
||||
{
|
||||
time = Math.Max(0, Math.Min(1, ((x - edge.StartX) * (edge.EndX - edge.StartX) + (y - edge.StartY) * (edge.EndY - edge.StartY)) / edgeLengthSquared));
|
||||
}
|
||||
|
||||
double nearestX = edge.StartX + time * (edge.EndX - edge.StartX);
|
||||
double nearestY = edge.StartY + time * (edge.EndY - edge.StartY);
|
||||
|
||||
return (nearestX, nearestY, Math.Sqrt(Math.Pow(x - nearestX, 2) + Math.Pow(y - nearestY, 2)), time);
|
||||
}
|
||||
}
|
||||
|
||||
public static double GetEdgeLength(SpaceEdge edge, double resolution)
|
||||
{
|
||||
var lineLength = Math.Sqrt(Math.Pow(edge.StartX - edge.EndX, 2) + Math.Pow(edge.StartY - edge.EndY, 2));
|
||||
if (edge.Degree == 1)
|
||||
{
|
||||
return lineLength;
|
||||
}
|
||||
else if (edge.Degree == 2)
|
||||
{
|
||||
if (lineLength <= 0) return 0;
|
||||
double step = resolution / lineLength;
|
||||
double distance = 0;
|
||||
|
||||
for (double t = step; t <= 1.001; t += step)
|
||||
{
|
||||
var timePoint = BezierPoint(t - step, edge);
|
||||
var lastTimePoint = BezierPoint(t, edge);
|
||||
distance += Math.Sqrt(Math.Pow(timePoint.X - lastTimePoint.X, 2) + Math.Pow(timePoint.Y - lastTimePoint.Y, 2));
|
||||
}
|
||||
|
||||
return Math.Round(distance, 3);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (lineLength <= 0) return 0;
|
||||
double step = resolution / lineLength;
|
||||
double distance = 0;
|
||||
for (double t = step; t <= 1.001; t += step)
|
||||
{
|
||||
var sTime = t - step;
|
||||
var timePoint = BezierPoint(1 - sTime, edge);
|
||||
sTime = t;
|
||||
var lastTimePoint = BezierPoint(1 - sTime, edge);
|
||||
|
||||
distance += Math.Sqrt(Math.Pow(timePoint.X - lastTimePoint.X, 2) + Math.Pow(timePoint.Y - lastTimePoint.Y, 2));
|
||||
}
|
||||
return Math.Round(distance, 3);
|
||||
}
|
||||
}
|
||||
|
||||
public static double GetVectorAngle(double originNodeX, double originNodeY, double vector1X, double vector1Y, double vector2X, double vector2Y)
|
||||
{
|
||||
double BA_x = vector1X - originNodeX;
|
||||
double BA_y = vector1Y - originNodeY;
|
||||
double BC_x = vector2X - originNodeX;
|
||||
double BC_y = vector2Y - originNodeY;
|
||||
// Tính độ dài của các vector AB và BC
|
||||
double lengthAB = Math.Sqrt(BA_x * BA_x + BA_y * BA_y);
|
||||
double lengthBC = Math.Sqrt(BC_x * BC_x + BC_y * BC_y);
|
||||
// Tính tích vô hướng của AB và BC
|
||||
double dotProduct = BA_x * BC_x + BA_y * BC_y;
|
||||
if (lengthAB * lengthBC == 0) return 0;
|
||||
if (dotProduct / (lengthAB * lengthBC) > 1) return 0;
|
||||
if (dotProduct / (lengthAB * lengthBC) < -1) return 180;
|
||||
return Math.Acos(dotProduct / (lengthAB * lengthBC)) * (180.0 / Math.PI);
|
||||
}
|
||||
|
||||
public static double NormalizeDegreeAngle(double angle)
|
||||
{
|
||||
angle = angle % 360;
|
||||
if (angle > 180) angle -= 360;
|
||||
else if (angle < -180) angle += 360;
|
||||
return angle;
|
||||
}
|
||||
|
||||
public static double NormalizeRadianAngle(double angle)
|
||||
{
|
||||
angle = angle % (2 * Math.PI);
|
||||
if (angle > Math.PI) angle -= (2 * Math.PI);
|
||||
else if (angle < -Math.PI) angle += (2 * Math.PI);
|
||||
return angle;
|
||||
}
|
||||
}
|
||||
135
srcs/RobotNet10/Commons/RobotNet10.Common/WatchThread.cs
Normal file
135
srcs/RobotNet10/Commons/RobotNet10.Common/WatchThread.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace RobotNet10.Common;
|
||||
|
||||
public class WatchThread<T>(int Interval, Action Callback, ILogger<T>? Logger, ThreadPriority Priority = ThreadPriority.Highest) : IDisposable where T : class
|
||||
{
|
||||
public bool Disposed;
|
||||
|
||||
private Thread? Thread;
|
||||
private CancellationTokenSource? ThreadCts;
|
||||
|
||||
private long NextDueTime;
|
||||
private readonly Lock Lock = new();
|
||||
private void Handler(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
bool shouldRun = false;
|
||||
lock (Lock)
|
||||
{
|
||||
if (Disposed) return;
|
||||
long now = GetCurrentTimeMs();
|
||||
if (now >= NextDueTime)
|
||||
{
|
||||
shouldRun = true;
|
||||
long scheduledTime = NextDueTime;
|
||||
NextDueTime += Interval;
|
||||
|
||||
if (now - scheduledTime > Interval / 2)
|
||||
{
|
||||
NextDueTime = now + Interval;
|
||||
if (Logger is not null && Logger.IsEnabled(LogLevel.Warning)) Logger.LogWarning("WatchThread Warning: Elapsed time {peak}ms exceeds interval {Interval}ms.", now - scheduledTime + Interval, Interval);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRun)
|
||||
{
|
||||
try { Callback.Invoke(); }
|
||||
catch (Exception ex) { if (Logger is not null && Logger.IsEnabled(LogLevel.Error)) Logger.LogError("Callback error: {ex}", ex.Message); }
|
||||
}
|
||||
|
||||
lock (Lock)
|
||||
{
|
||||
if (Disposed) return;
|
||||
long now = GetCurrentTimeMs();
|
||||
long delay = NextDueTime - now;
|
||||
if (delay < 0) delay = 0;
|
||||
Thread.Sleep((int)delay);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (Logger is not null && Logger.IsEnabled(LogLevel.Error)) Logger.LogError("WatchThread Error: {ex}", ex.Message);
|
||||
Thread.Sleep(Interval);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static long GetCurrentTimeMs()
|
||||
{
|
||||
return Stopwatch.GetTimestamp() * 1000 / Stopwatch.Frequency;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
if (!Disposed)
|
||||
{
|
||||
if (Thread?.IsAlive == true) return;
|
||||
|
||||
NextDueTime = GetCurrentTimeMs() + Interval;
|
||||
ThreadCts = new CancellationTokenSource();
|
||||
Thread = new Thread(() => Handler(ThreadCts.Token))
|
||||
{
|
||||
Priority = Priority,
|
||||
IsBackground = false,
|
||||
Name = $"WatchThread-{typeof(T).Name}"
|
||||
};
|
||||
Thread.Start();
|
||||
}
|
||||
else throw new ObjectDisposedException(nameof(WatchThread<>));
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
Thread? threadToJoin;
|
||||
lock (Lock)
|
||||
{
|
||||
if (Thread == null) return;
|
||||
ThreadCts?.Cancel();
|
||||
threadToJoin = Thread;
|
||||
}
|
||||
|
||||
// If Stop() is called from within the Callback (same thread), skip Join to avoid deadlock
|
||||
if (threadToJoin != null && threadToJoin != Thread.CurrentThread)
|
||||
{
|
||||
if (!threadToJoin.Join(TimeSpan.FromSeconds(5)))
|
||||
{
|
||||
Logger?.LogWarning("Thread did not stop gracefully");
|
||||
}
|
||||
}
|
||||
|
||||
lock (Lock)
|
||||
{
|
||||
ThreadCts?.Dispose();
|
||||
ThreadCts = null;
|
||||
Thread = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (Disposed) return;
|
||||
|
||||
Disposed = true;
|
||||
if (disposing) Stop();
|
||||
}
|
||||
|
||||
~WatchThread()
|
||||
{
|
||||
Dispose(false);
|
||||
}
|
||||
}
|
||||
135
srcs/RobotNet10/Commons/RobotNet10.Common/WatchThreadAsync.cs
Normal file
135
srcs/RobotNet10/Commons/RobotNet10.Common/WatchThreadAsync.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace RobotNet10.Common;
|
||||
|
||||
public class WatchThreadAsync<T>(int Interval, Func<Task> Callback, ILogger<T>? Logger, ThreadPriority Priority = ThreadPriority.Highest) : IDisposable where T : class
|
||||
{
|
||||
public bool Disposed;
|
||||
|
||||
private Thread? Thread;
|
||||
private CancellationTokenSource? ThreadCts;
|
||||
|
||||
private long NextDueTime;
|
||||
private readonly Lock Lock = new();
|
||||
private async Task Handler(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
bool shouldRun = false;
|
||||
lock (Lock)
|
||||
{
|
||||
if (Disposed) return;
|
||||
long now = GetCurrentTimeMs();
|
||||
if (now >= NextDueTime)
|
||||
{
|
||||
shouldRun = true;
|
||||
long scheduledTime = NextDueTime;
|
||||
NextDueTime += Interval;
|
||||
|
||||
if (now - scheduledTime > Interval / 2)
|
||||
{
|
||||
NextDueTime = now + Interval;
|
||||
if (Logger is not null && Logger.IsEnabled(LogLevel.Warning)) Logger.LogWarning("WatchThreadAsync Warning: Elapsed time {peak}ms exceeds interval {Interval}ms.", now - scheduledTime + Interval, Interval);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRun)
|
||||
{
|
||||
try { await Callback.Invoke(); }
|
||||
catch (Exception ex) { if (Logger is not null && Logger.IsEnabled(LogLevel.Error)) Logger.LogError("Callback error: {ex}", ex.Message); }
|
||||
}
|
||||
|
||||
lock (Lock)
|
||||
{
|
||||
if (Disposed) return;
|
||||
long now = GetCurrentTimeMs();
|
||||
long delay = NextDueTime - now;
|
||||
if (delay < 0) delay = 0;
|
||||
Thread.Sleep((int)delay);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (Logger is not null && Logger.IsEnabled(LogLevel.Error)) Logger.LogError("WatchThreadAsync Error: {ex}", ex.Message);
|
||||
Thread.Sleep(Interval);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static long GetCurrentTimeMs()
|
||||
{
|
||||
return Stopwatch.GetTimestamp() * 1000 / Stopwatch.Frequency;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
if (!Disposed)
|
||||
{
|
||||
if (Thread?.IsAlive == true) return;
|
||||
|
||||
NextDueTime = GetCurrentTimeMs() + Interval;
|
||||
ThreadCts = new CancellationTokenSource();
|
||||
Thread = new Thread(async () => await Handler(ThreadCts.Token))
|
||||
{
|
||||
Priority = Priority,
|
||||
IsBackground = false,
|
||||
Name = $"WatchThreadAsync-{typeof(T).Name}"
|
||||
};
|
||||
Thread.Start();
|
||||
}
|
||||
else throw new ObjectDisposedException(nameof(WatchThreadAsync<>));
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
Thread? threadToJoin;
|
||||
lock (Lock)
|
||||
{
|
||||
if (Thread == null) return;
|
||||
ThreadCts?.Cancel();
|
||||
threadToJoin = Thread;
|
||||
}
|
||||
|
||||
// If Stop() is called from within the Callback (same thread), skip Join to avoid deadlock
|
||||
if (threadToJoin != null && threadToJoin != Thread.CurrentThread)
|
||||
{
|
||||
if (!threadToJoin.Join(TimeSpan.FromSeconds(5)))
|
||||
{
|
||||
Logger?.LogWarning("Thread did not stop gracefully");
|
||||
}
|
||||
}
|
||||
|
||||
lock (Lock)
|
||||
{
|
||||
ThreadCts?.Dispose();
|
||||
ThreadCts = null;
|
||||
Thread = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (Disposed) return;
|
||||
|
||||
Disposed = true;
|
||||
if (disposing) Stop();
|
||||
}
|
||||
|
||||
~WatchThreadAsync()
|
||||
{
|
||||
Dispose(false);
|
||||
}
|
||||
}
|
||||
112
srcs/RobotNet10/Commons/RobotNet10.Common/WatchTimer.cs
Normal file
112
srcs/RobotNet10/Commons/RobotNet10.Common/WatchTimer.cs
Normal file
@@ -0,0 +1,112 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace RobotNet10.Common;
|
||||
|
||||
public class WatchTimer<T>(int Interval, Action Callback, ILogger<T>? Logger) : IDisposable where T : class
|
||||
{
|
||||
private Timer? Timer;
|
||||
public bool Disposed;
|
||||
|
||||
private long NextDueTime;
|
||||
private readonly Lock Lock = new();
|
||||
|
||||
private void Handler(object? state)
|
||||
{
|
||||
try
|
||||
{
|
||||
bool shouldRun = false;
|
||||
lock (Lock)
|
||||
{
|
||||
if (Disposed) return;
|
||||
long now = GetCurrentTimeMs();
|
||||
if (now >= NextDueTime)
|
||||
{
|
||||
shouldRun = true;
|
||||
long scheduledTime = NextDueTime;
|
||||
NextDueTime += Interval;
|
||||
|
||||
if (now - scheduledTime > Interval / 2)
|
||||
{
|
||||
NextDueTime = now + Interval;
|
||||
if(Logger is not null && Logger.IsEnabled(LogLevel.Warning)) Logger.LogWarning("WatchTimer Warning: Elapsed time {peak}ms exceeds interval {Interval}ms.", now - scheduledTime + Interval, Interval);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRun)
|
||||
{
|
||||
try { Callback.Invoke(); }
|
||||
catch (Exception ex) { if (Logger is not null && Logger.IsEnabled(LogLevel.Error)) Logger.LogError("Callback error: {ex}", ex.Message); }
|
||||
}
|
||||
|
||||
lock (Lock)
|
||||
{
|
||||
if (Disposed) return;
|
||||
long now = GetCurrentTimeMs();
|
||||
long delay = NextDueTime - now;
|
||||
if (delay < 0) delay = 0;
|
||||
Timer?.Change(delay, Timeout.Infinite);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if(Logger is not null && Logger.IsEnabled(LogLevel.Error)) Logger.LogError("WatchTimer Error: {ex}", ex.Message);
|
||||
Timer?.Change(Interval, Timeout.Infinite);
|
||||
}
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (!Disposed)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
NextDueTime = GetCurrentTimeMs() + Interval;
|
||||
Timer = new Timer(Handler, null, Timeout.Infinite, Timeout.Infinite);
|
||||
Timer.Change(Interval, Timeout.Infinite);
|
||||
}
|
||||
}
|
||||
else throw new ObjectDisposedException(nameof(WatchTimer<>));
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (Disposed) return;
|
||||
|
||||
if (Timer != null)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
Timer.Change(Timeout.Infinite, Timeout.Infinite);
|
||||
Timer.Dispose();
|
||||
Timer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static long GetCurrentTimeMs()
|
||||
{
|
||||
return Stopwatch.GetTimestamp() * 1000 / Stopwatch.Frequency;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (Disposed) return;
|
||||
|
||||
if (disposing) Stop();
|
||||
|
||||
Disposed = true;
|
||||
}
|
||||
|
||||
~WatchTimer()
|
||||
{
|
||||
Dispose(false);
|
||||
}
|
||||
}
|
||||
114
srcs/RobotNet10/Commons/RobotNet10.Common/WatchTimerAsync.cs
Normal file
114
srcs/RobotNet10/Commons/RobotNet10.Common/WatchTimerAsync.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace RobotNet10.Common;
|
||||
|
||||
public class WatchTimerAsync<T>(int interval, Func<Task> Callback, ILogger<T>? Logger) : IDisposable where T : class
|
||||
{
|
||||
private Timer? Timer;
|
||||
public bool Disposed;
|
||||
|
||||
private long NextDueTime;
|
||||
private readonly Lock Lock = new();
|
||||
|
||||
public int Interval => interval;
|
||||
|
||||
private async void Handler(object? state)
|
||||
{
|
||||
try
|
||||
{
|
||||
bool shouldRun = false;
|
||||
lock (Lock)
|
||||
{
|
||||
if (Disposed) return;
|
||||
long now = GetCurrentTimeMs();
|
||||
if (now >= NextDueTime)
|
||||
{
|
||||
shouldRun = true;
|
||||
long scheduledTime = NextDueTime;
|
||||
NextDueTime += interval;
|
||||
|
||||
if (now - scheduledTime > interval / 2)
|
||||
{
|
||||
NextDueTime = now + interval;
|
||||
if (Logger is not null && Logger.IsEnabled(LogLevel.Warning)) Logger.LogWarning("WatchTimerAsync Warning: Elapsed time {peak}ms exceeds interval {interval}ms.", now - scheduledTime + interval, interval);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRun)
|
||||
{
|
||||
try { await Callback.Invoke(); }
|
||||
catch (Exception ex) { if (Logger is not null && Logger.IsEnabled(LogLevel.Error)) Logger.LogError("Callback error: {ex}", ex.Message); }
|
||||
}
|
||||
|
||||
lock (Lock)
|
||||
{
|
||||
if (Disposed) return;
|
||||
long now = GetCurrentTimeMs();
|
||||
long delay = NextDueTime - now;
|
||||
if (delay < 0) delay = 0;
|
||||
Timer?.Change(delay, Timeout.Infinite);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (Logger is not null && Logger.IsEnabled(LogLevel.Error)) Logger.LogError("WatchTimerAsync Error: {ex}", ex.Message);
|
||||
Timer?.Change(interval, Timeout.Infinite);
|
||||
}
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (!Disposed)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
NextDueTime = GetCurrentTimeMs() + interval;
|
||||
Timer = new Timer(Handler, null, Timeout.Infinite, Timeout.Infinite);
|
||||
Timer.Change(interval, Timeout.Infinite);
|
||||
}
|
||||
}
|
||||
else throw new ObjectDisposedException(nameof(WatchTimerAsync<>));
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (Disposed) return;
|
||||
|
||||
if (Timer != null)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
Timer.Change(Timeout.Infinite, Timeout.Infinite);
|
||||
Timer.Dispose();
|
||||
Timer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static long GetCurrentTimeMs()
|
||||
{
|
||||
return Stopwatch.GetTimestamp() * 1000 / Stopwatch.Frequency;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (Disposed) return;
|
||||
|
||||
if (disposing) Stop();
|
||||
|
||||
Disposed = true;
|
||||
}
|
||||
|
||||
~WatchTimerAsync()
|
||||
{
|
||||
Dispose(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using RobotNet10.CustomConfiguration.DTOs;
|
||||
using RobotNet10.CustomConfiguration.DTOs.Requests;
|
||||
using RobotNet10.CustomConfiguration.Helpers;
|
||||
using RobotNet10.CustomConfiguration.Models;
|
||||
using RobotNet10.CustomConfiguration.Services;
|
||||
|
||||
namespace RobotNet10.CustomConfiguration.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// API controller cho quản lý configuration files
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/configs")]
|
||||
[Authorize]
|
||||
public class ConfigController(IConfigService configService) : ControllerBase
|
||||
{
|
||||
private readonly IConfigService _configService = configService;
|
||||
|
||||
// ==========================================
|
||||
// CONFIG FILE MANAGEMENT
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Tạo config mới
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(ConfigFileDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<ConfigFileDto>> CreateConfig([FromBody] CreateConfigRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Validate request
|
||||
if (string.IsNullOrWhiteSpace(request.ConfigType))
|
||||
{
|
||||
return BadRequest(new { error = "ConfigType cannot be empty" });
|
||||
}
|
||||
|
||||
// Check if ConfigType already exists
|
||||
if (await _configService.ConfigTypeExistsAsync(request.ConfigType))
|
||||
{
|
||||
return Conflict(new { error = $"Config with type '{request.ConfigType}' already exists" });
|
||||
}
|
||||
|
||||
// Convert DTOs to Models
|
||||
var variables = request.Variables.Select(MapDtoToVariable).ToList();
|
||||
|
||||
// Create config
|
||||
var config = await _configService.CreateConfigAsync(
|
||||
request.ConfigType,
|
||||
variables,
|
||||
request.Description
|
||||
);
|
||||
|
||||
var dto = MapConfigToDto(config);
|
||||
return CreatedAtAction(
|
||||
nameof(GetConfigById),
|
||||
new { id = config.Id },
|
||||
dto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lấy tất cả configs (metadata only)
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(List<ConfigFileMetadataDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<ConfigFileMetadataDto>>> GetAllConfigs([FromQuery] string? search)
|
||||
{
|
||||
var configs = await _configService.SearchConfigsAsync(search);
|
||||
var dtos = configs.Select(MapMetadataToDto).ToList();
|
||||
return Ok(dtos);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lấy config theo ID
|
||||
/// </summary>
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(typeof(ConfigFileDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ConfigFileDto>> GetConfigById(Guid id)
|
||||
{
|
||||
var config = await _configService.GetConfigByIdAsync(id);
|
||||
if (config == null)
|
||||
{
|
||||
return NotFound(new { error = $"Config with ID '{id}' not found" });
|
||||
}
|
||||
|
||||
return Ok(MapConfigToDto(config));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lấy config theo ConfigType
|
||||
/// </summary>
|
||||
[HttpGet("by-type/{configType}")]
|
||||
[ProducesResponseType(typeof(ConfigFileDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ConfigFileDto>> GetConfigByType(string configType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configType))
|
||||
{
|
||||
return BadRequest(new { error = "ConfigType cannot be empty" });
|
||||
}
|
||||
|
||||
var config = await _configService.GetConfigByTypeAsync(configType);
|
||||
if (config == null)
|
||||
{
|
||||
return NotFound(new { error = $"Config with type '{configType}' not found" });
|
||||
}
|
||||
|
||||
return Ok(MapConfigToDto(config));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kiểm tra ConfigType có tồn tại không
|
||||
/// </summary>
|
||||
[HttpGet("exists/{configType}")]
|
||||
[ProducesResponseType(typeof(bool), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<bool>> ConfigTypeExists(string configType)
|
||||
{
|
||||
var exists = await _configService.ConfigTypeExistsAsync(configType);
|
||||
return Ok(exists);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cập nhật config
|
||||
/// </summary>
|
||||
[HttpPut("{id:guid}")]
|
||||
[ProducesResponseType(typeof(ConfigFileDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<ConfigFileDto>> UpdateConfig(Guid id, [FromBody] UpdateConfigRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Convert DTOs to Models if provided
|
||||
List<ConfigVariable>? variables = null;
|
||||
if (request.Variables != null)
|
||||
{
|
||||
variables = [.. request.Variables.Select(MapDtoToVariable)];
|
||||
}
|
||||
|
||||
var config = await _configService.UpdateConfigAsync(id, variables, request.Description);
|
||||
return Ok(MapConfigToDto(config));
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound(new { error = $"Config with ID '{id}' not found" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Xóa config
|
||||
/// </summary>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> DeleteConfig(Guid id)
|
||||
{
|
||||
var deleted = await _configService.DeleteConfigAsync(id);
|
||||
if (!deleted)
|
||||
{
|
||||
return NotFound(new { error = $"Config with ID '{id}' not found" });
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// IMPORT/EXPORT
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Import config từ JSON file
|
||||
/// </summary>
|
||||
[HttpPost("import")]
|
||||
[ProducesResponseType(typeof(ConfigFileDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<ConfigFileDto>> ImportConfig(
|
||||
IFormFile file,
|
||||
[FromForm] string configType,
|
||||
[FromForm] string? description = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
{
|
||||
return BadRequest(new { error = "File is required" });
|
||||
}
|
||||
|
||||
if (!file.FileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return BadRequest(new { error = "Only JSON files are supported" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(configType))
|
||||
{
|
||||
return BadRequest(new { error = "ConfigType is required" });
|
||||
}
|
||||
|
||||
// Validate ConfigType doesn't exist
|
||||
var exists = await _configService.ConfigTypeExistsAsync(configType);
|
||||
if (exists)
|
||||
{
|
||||
return Conflict(new { error = $"Config with type '{configType}' already exists" });
|
||||
}
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
// Import config with description (if provided, it overrides description from file)
|
||||
// Note: CreateConfigAsync inside ImportConfigFromJsonAsync already validates before saving
|
||||
var config = await _configService.ImportConfigFromJsonAsync(stream, configType, description);
|
||||
|
||||
var dto = MapConfigToDto(config);
|
||||
return CreatedAtAction(
|
||||
nameof(GetConfigById),
|
||||
new { id = config.Id },
|
||||
dto);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Conflict(new { error = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export config ra JSON file
|
||||
/// </summary>
|
||||
[HttpGet("{id:guid}/export")]
|
||||
[ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||
public async Task<ActionResult> ExportConfig(Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var config = await _configService.GetConfigByIdAsync(id);
|
||||
|
||||
if (config == null)
|
||||
{
|
||||
return NotFound(new { error = $"Config with ID '{id}' not found" });
|
||||
}
|
||||
|
||||
var stream = await _configService.ExportConfigToJsonAsync(config);
|
||||
|
||||
// Ensure stream is at the beginning
|
||||
if (stream.CanSeek && stream.Position != 0)
|
||||
{
|
||||
stream.Position = 0;
|
||||
}
|
||||
|
||||
// Ensure stream has data
|
||||
if (stream.Length == 0)
|
||||
{
|
||||
return StatusCode(500, new { error = "Export stream is empty" });
|
||||
}
|
||||
|
||||
return File(stream, "application/json", $"{config.ConfigType}.config.json");
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound(new { error = $"Config with ID '{id}' not found" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = $"Error exporting config: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// VARIABLE MANAGEMENT
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Cập nhật giá trị của một variable
|
||||
/// </summary>
|
||||
[HttpPut("{id:guid}/variables/{variableName}")]
|
||||
[ProducesResponseType(typeof(ConfigFileDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<ConfigFileDto>> UpdateVariable(
|
||||
Guid id,
|
||||
string variableName,
|
||||
[FromBody] UpdateVariableRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(variableName))
|
||||
{
|
||||
return BadRequest(new { error = "Variable name cannot be empty" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var config = await _configService.UpdateVariableAsync(id, variableName, request.Value);
|
||||
return Ok(MapConfigToDto(config));
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return NotFound(new { error = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thêm variable mới vào config
|
||||
/// </summary>
|
||||
[HttpPost("{id:guid}/variables")]
|
||||
[ProducesResponseType(typeof(ConfigFileDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<ConfigFileDto>> AddVariable(
|
||||
Guid id,
|
||||
[FromBody] ConfigVariableDto variableDto)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(variableDto.Name))
|
||||
{
|
||||
return BadRequest(new { error = "Variable name cannot be empty" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(variableDto.Type))
|
||||
{
|
||||
return BadRequest(new { error = "Variable type cannot be empty" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var variable = MapDtoToVariable(variableDto);
|
||||
var config = await _configService.AddVariableAsync(id, variable);
|
||||
return Ok(MapConfigToDto(config));
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return NotFound(new { error = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Xóa variable khỏi config
|
||||
/// </summary>
|
||||
[HttpDelete("{id:guid}/variables/{variableName}")]
|
||||
[ProducesResponseType(typeof(ConfigFileDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ConfigFileDto>> RemoveVariable(Guid id, string variableName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(variableName))
|
||||
{
|
||||
return BadRequest(new { error = "Variable name cannot be empty" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var config = await _configService.RemoveVariableAsync(id, variableName);
|
||||
return Ok(MapConfigToDto(config));
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return NotFound(new { error = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// MAPPING HELPERS
|
||||
// ==========================================
|
||||
|
||||
private static ConfigFileDto MapConfigToDto(ConfigFile config)
|
||||
{
|
||||
return new ConfigFileDto
|
||||
{
|
||||
Id = config.Id,
|
||||
ConfigType = config.ConfigType,
|
||||
Variables = [.. config.Variables.Select(MapVariableToDto)],
|
||||
CreatedAt = config.CreatedAt,
|
||||
UpdatedAt = config.UpdatedAt,
|
||||
Description = config.Description
|
||||
};
|
||||
}
|
||||
|
||||
private static ConfigFileMetadataDto MapMetadataToDto(ConfigFileMetadata metadata)
|
||||
{
|
||||
return new ConfigFileMetadataDto
|
||||
{
|
||||
Id = metadata.Id,
|
||||
ConfigType = metadata.ConfigType,
|
||||
CreatedAt = metadata.CreatedAt,
|
||||
UpdatedAt = metadata.UpdatedAt,
|
||||
Description = metadata.Description
|
||||
};
|
||||
}
|
||||
|
||||
private static ConfigVariableDto MapVariableToDto(ConfigVariable variable)
|
||||
{
|
||||
return new ConfigVariableDto
|
||||
{
|
||||
Name = variable.Name,
|
||||
Type = variable.Type.ToString().ToLower(),
|
||||
Value = variable.Value,
|
||||
Min = variable.Min,
|
||||
Max = variable.Max,
|
||||
Roles = variable.Roles,
|
||||
EnumValues = variable.EnumValues
|
||||
};
|
||||
}
|
||||
|
||||
private static ConfigVariable MapDtoToVariable(ConfigVariableDto dto)
|
||||
{
|
||||
// Parse type string to enum
|
||||
var type = VariableTypeConverter.ParseType(dto.Type);
|
||||
|
||||
return new ConfigVariable
|
||||
{
|
||||
Name = dto.Name,
|
||||
Type = type,
|
||||
Value = dto.Value,
|
||||
Min = dto.Min,
|
||||
Max = dto.Max,
|
||||
Roles = dto.Roles ?? string.Empty,
|
||||
EnumValues = dto.EnumValues
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace RobotNet10.CustomConfiguration.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// DTO cho ConfigFile (response)
|
||||
/// </summary>
|
||||
public class ConfigFileDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string ConfigType { get; set; } = string.Empty; // Tên định danh duy nhất
|
||||
public List<ConfigVariableDto> Variables { get; set; } = [];
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace RobotNet10.CustomConfiguration.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// DTO cho ConfigFileMetadata (list view)
|
||||
/// </summary>
|
||||
public class ConfigFileMetadataDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string ConfigType { get; set; } = string.Empty; // Tên định danh duy nhất
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace RobotNet10.CustomConfiguration.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// DTO cho ConfigVariable
|
||||
/// </summary>
|
||||
public class ConfigVariableDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = string.Empty; // "string", "int", "double", "bool", "object", "array", "enum"
|
||||
public object? Value { get; set; }
|
||||
|
||||
// Optional properties
|
||||
public double? Min { get; set; } // Cho int và double (0 nếu không dùng)
|
||||
public double? Max { get; set; } // Cho int và double (0 nếu không dùng)
|
||||
public string Roles { get; set; } = string.Empty; // Roles string (có thể empty)
|
||||
public List<string>? EnumValues { get; set; } // Cho type Enum - danh sách các giá trị cho phép
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using RobotNet10.CustomConfiguration.DTOs;
|
||||
|
||||
namespace RobotNet10.CustomConfiguration.DTOs.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO cho tạo config mới
|
||||
/// </summary>
|
||||
public class CreateConfigRequest
|
||||
{
|
||||
public string ConfigType { get; set; } = string.Empty; // Tên định danh duy nhất (ví dụ: MQTTBrokerConfig)
|
||||
public List<ConfigVariableDto> Variables { get; set; } = [];
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using RobotNet10.CustomConfiguration.DTOs;
|
||||
|
||||
namespace RobotNet10.CustomConfiguration.DTOs.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO cho update config
|
||||
/// </summary>
|
||||
public class UpdateConfigRequest
|
||||
{
|
||||
public List<ConfigVariableDto>? Variables { get; set; }
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace RobotNet10.CustomConfiguration.DTOs.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO cho update variable value
|
||||
/// </summary>
|
||||
public class UpdateVariableRequest
|
||||
{
|
||||
public object? Value { get; set; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
namespace RobotNet10.CustomConfiguration.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Event arguments cho ConfigChanged event
|
||||
/// </summary>
|
||||
public class ConfigChangedEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// ConfigType của config đã thay đổi
|
||||
/// </summary>
|
||||
public string ConfigType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// ID của config đã thay đổi
|
||||
/// </summary>
|
||||
public Guid ConfigId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Loại thay đổi: Created, Updated, Deleted, VariableAdded, VariableUpdated, VariableRemoved
|
||||
/// </summary>
|
||||
public ConfigChangeType ChangeType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Tên variable nếu thay đổi liên quan đến variable (optional)
|
||||
/// </summary>
|
||||
public string? VariableName { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loại thay đổi của config
|
||||
/// </summary>
|
||||
public enum ConfigChangeType
|
||||
{
|
||||
/// <summary>
|
||||
/// Config được tạo mới
|
||||
/// </summary>
|
||||
Created,
|
||||
|
||||
/// <summary>
|
||||
/// Config được cập nhật (metadata hoặc variables)
|
||||
/// </summary>
|
||||
Updated,
|
||||
|
||||
/// <summary>
|
||||
/// Config bị xóa
|
||||
/// </summary>
|
||||
Deleted,
|
||||
|
||||
/// <summary>
|
||||
/// Variable được thêm vào config
|
||||
/// </summary>
|
||||
VariableAdded,
|
||||
|
||||
/// <summary>
|
||||
/// Variable được cập nhật
|
||||
/// </summary>
|
||||
VariableUpdated,
|
||||
|
||||
/// <summary>
|
||||
/// Variable bị xóa khỏi config
|
||||
/// </summary>
|
||||
VariableRemoved
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using RobotNet10.CustomConfiguration.Services;
|
||||
using RobotNet10.StorageManager;
|
||||
|
||||
namespace RobotNet10.CustomConfiguration.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods cho dependency injection
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Đăng ký các services cho CustomConfiguration
|
||||
/// StorageConfig sẽ được inject từ project sử dụng thư viện này
|
||||
/// </summary>
|
||||
public static IServiceCollection AddCustomConfiguration(
|
||||
this IServiceCollection services,
|
||||
StorageConfig storageConfig)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(storageConfig);
|
||||
|
||||
// Register StorageConfig as named options (matching ConfigService constructor)
|
||||
services.Configure<StorageConfig>("StorageConfigs", options =>
|
||||
{
|
||||
options.UsingLocal = storageConfig.UsingLocal;
|
||||
options.LocalFolder = storageConfig.LocalFolder;
|
||||
options.Bucket = storageConfig.Bucket;
|
||||
options.MinioConfig = storageConfig.MinioConfig;
|
||||
options.RetryCount = storageConfig.RetryCount;
|
||||
});
|
||||
|
||||
// Register ConfigService (creates StorageManager internally)
|
||||
services.AddSingleton<IConfigService, ConfigService>();
|
||||
|
||||
// Register ConfigManager (depends on IConfigService)
|
||||
services.AddSingleton<IConfigManager, ConfigManager>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Đăng ký các services cho CustomConfiguration với StorageConfig từ IConfiguration
|
||||
/// </summary>
|
||||
public static IServiceCollection AddCustomConfiguration(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
string configSectionName = "StorageConfig")
|
||||
{
|
||||
services.Configure<StorageConfig>("StorageConfigs", options =>
|
||||
{
|
||||
configuration.GetSection(configSectionName).Bind(options);
|
||||
});
|
||||
|
||||
// Register ConfigService (creates StorageManager internally)
|
||||
services.AddSingleton<IConfigService, ConfigService>();
|
||||
|
||||
// Register ConfigManager (depends on IConfigService)
|
||||
services.AddSingleton<IConfigManager, ConfigManager>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
using RobotNet10.CustomConfiguration.Models;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace RobotNet10.CustomConfiguration.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Helper class cho parse và serialize JSON config files
|
||||
/// Format JSON mới: Object với metadata và variables
|
||||
/// Format: {"id": "guid", "configType": "...", "createdAt": "...", "updatedAt": "...", "description": "...", "variables": [...]}
|
||||
/// </summary>
|
||||
public static class JsonConfigParser
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Parse JSON stream thành ConfigFile (format mới với metadata)
|
||||
/// </summary>
|
||||
public static ConfigFile ParseConfigFile(Stream jsonStream)
|
||||
{
|
||||
using var reader = new StreamReader(jsonStream);
|
||||
var json = reader.ReadToEnd();
|
||||
return ParseConfigFile(json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse JSON string thành ConfigFile (format mới với metadata)
|
||||
/// </summary>
|
||||
public static ConfigFile ParseConfigFile(string json)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(json, nameof(json));
|
||||
|
||||
using var jsonDoc = JsonDocument.Parse(json);
|
||||
var root = jsonDoc.RootElement;
|
||||
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new ArgumentException("JSON must be an object with metadata and variables");
|
||||
}
|
||||
|
||||
// Parse metadata
|
||||
var id = root.TryGetProperty("id", out var idProp) && idProp.ValueKind == JsonValueKind.String
|
||||
? Guid.Parse(idProp.GetString() ?? throw new ArgumentException("Invalid id format"))
|
||||
: throw new ArgumentException("id is required");
|
||||
|
||||
var configType = root.GetProperty("configType").GetString()
|
||||
?? throw new ArgumentException("configType is required");
|
||||
|
||||
var createdAt = root.TryGetProperty("createdAt", out var createdAtProp) && createdAtProp.ValueKind == JsonValueKind.String
|
||||
? DateTime.Parse(createdAtProp.GetString() ?? throw new ArgumentException("Invalid createdAt format"))
|
||||
: DateTime.UtcNow;
|
||||
|
||||
var updatedAt = root.TryGetProperty("updatedAt", out var updatedAtProp) && updatedAtProp.ValueKind == JsonValueKind.String
|
||||
? DateTime.Parse(updatedAtProp.GetString() ?? throw new ArgumentException("Invalid updatedAt format"))
|
||||
: DateTime.UtcNow;
|
||||
|
||||
var description = root.TryGetProperty("description", out var descProp) && descProp.ValueKind == JsonValueKind.String
|
||||
? descProp.GetString()
|
||||
: null;
|
||||
|
||||
// Parse variables
|
||||
if (!root.TryGetProperty("variables", out var variablesProp) || variablesProp.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
throw new ArgumentException("variables array is required");
|
||||
}
|
||||
|
||||
var variables = ParseVariablesArray(variablesProp);
|
||||
|
||||
return new ConfigFile
|
||||
{
|
||||
Id = id,
|
||||
ConfigType = configType,
|
||||
Variables = variables,
|
||||
CreatedAt = createdAt,
|
||||
UpdatedAt = updatedAt,
|
||||
Description = description,
|
||||
FilePath = $"configs/{configType}.config.json"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse variables array từ JSON element
|
||||
/// </summary>
|
||||
private static List<ConfigVariable> ParseVariablesArray(JsonElement variablesElement)
|
||||
{
|
||||
var variables = new List<ConfigVariable>();
|
||||
|
||||
foreach (var element in variablesElement.EnumerateArray())
|
||||
{
|
||||
var variable = new ConfigVariable
|
||||
{
|
||||
Name = element.GetProperty("name").GetString() ?? throw new ArgumentException("Variable name is required"),
|
||||
Type = VariableTypeConverter.ParseType(element.GetProperty("type").GetString() ?? throw new ArgumentException("Variable type is required")),
|
||||
Value = ParseValue(element),
|
||||
Min = element.TryGetProperty("min", out var minProp) && minProp.ValueKind != JsonValueKind.Null
|
||||
? minProp.GetDouble()
|
||||
: null,
|
||||
Max = element.TryGetProperty("max", out var maxProp) && maxProp.ValueKind != JsonValueKind.Null
|
||||
? maxProp.GetDouble()
|
||||
: null,
|
||||
Roles = element.TryGetProperty("roles", out var rolesProp)
|
||||
? rolesProp.GetString() ?? string.Empty
|
||||
: string.Empty,
|
||||
EnumValues = element.TryGetProperty("enumValues", out var enumProp) && enumProp.ValueKind == JsonValueKind.Array
|
||||
? [.. enumProp.EnumerateArray().Select(e => e.GetString() ?? string.Empty)]
|
||||
: null
|
||||
};
|
||||
|
||||
variables.Add(variable);
|
||||
}
|
||||
|
||||
return variables;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse JSON stream thành list of ConfigVariable (backward compatibility cho import từ format cũ)
|
||||
/// Format cũ: [{"name": "port", "type": "int", "value": 8080, "Min": 0, "Max": 65535, "Roles": ""}, ...]
|
||||
/// </summary>
|
||||
public static List<ConfigVariable> ParseVariables(Stream jsonStream)
|
||||
{
|
||||
using var reader = new StreamReader(jsonStream);
|
||||
var json = reader.ReadToEnd();
|
||||
return ParseVariables(json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse JSON string thành list of ConfigVariable (backward compatibility cho import từ format cũ)
|
||||
/// </summary>
|
||||
public static List<ConfigVariable> ParseVariables(string json)
|
||||
{
|
||||
using var jsonDoc = JsonDocument.Parse(json);
|
||||
var root = jsonDoc.RootElement;
|
||||
|
||||
// Check if it's new format (object) or old format (array)
|
||||
if (root.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
// New format - extract variables
|
||||
if (root.TryGetProperty("variables", out var variablesProp) && variablesProp.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return ParseVariablesArray(variablesProp);
|
||||
}
|
||||
throw new ArgumentException("JSON object must contain 'variables' array");
|
||||
}
|
||||
|
||||
if (root.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
throw new ArgumentException("JSON must be an array of variables (old format) or object with metadata and variables (new format)");
|
||||
}
|
||||
|
||||
// Old format - array of variables
|
||||
return ParseVariablesArray(root);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse value từ JSON element theo type
|
||||
/// </summary>
|
||||
private static object? ParseValue(JsonElement element)
|
||||
{
|
||||
if (!element.TryGetProperty("value", out var valueProp))
|
||||
{
|
||||
throw new ArgumentException("Variable value is required");
|
||||
}
|
||||
|
||||
var typeStr = element.GetProperty("type").GetString()?.ToLower();
|
||||
return typeStr switch
|
||||
{
|
||||
"string" => valueProp.GetString(),
|
||||
"int" => valueProp.GetInt32(),
|
||||
"double" => valueProp.GetDouble(),
|
||||
"bool" => valueProp.GetBoolean(),
|
||||
"object" => ParseJsonObject(valueProp),
|
||||
"array" => ParseJsonArray(valueProp),
|
||||
"enum" => valueProp.GetString(), // Enum values are strings
|
||||
_ => throw new ArgumentException($"Unsupported type: {typeStr}")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse JSON object thành Dictionary<string, object>
|
||||
/// </summary>
|
||||
private static Dictionary<string, object> ParseJsonObject(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new ArgumentException("Value must be a JSON object");
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<Dictionary<string, object>>( element.GetRawText()) ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse JSON array thành List<object>
|
||||
/// </summary>
|
||||
private static List<object> ParseJsonArray(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
throw new ArgumentException("Value must be a JSON array");
|
||||
}
|
||||
|
||||
var result = new List<object>();
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
result.Add(item.GetRawText());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialize ConfigFile thành JSON string (format mới với metadata và variables)
|
||||
/// </summary>
|
||||
public static string SerializeConfigFile(ConfigFile config)
|
||||
{
|
||||
var jsonObject = new
|
||||
{
|
||||
id = config.Id,
|
||||
configType = config.ConfigType,
|
||||
createdAt = config.CreatedAt.ToString("O"), // ISO 8601 format
|
||||
updatedAt = config.UpdatedAt.ToString("O"), // ISO 8601 format
|
||||
description = config.Description,
|
||||
variables = config.Variables.Select(v => new
|
||||
{
|
||||
name = v.Name,
|
||||
type = v.Type.ToString().ToLower(),
|
||||
value = SerializeValue(v.Value, v.Type),
|
||||
min = v.Min,
|
||||
max = v.Max,
|
||||
roles = v.Roles ?? string.Empty,
|
||||
enumValues = v.EnumValues
|
||||
}).ToArray()
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(jsonObject, JsonOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialize list of ConfigVariable thành JSON string (chỉ variables, dùng cho export backward compatibility)
|
||||
/// </summary>
|
||||
public static string SerializeVariables(List<ConfigVariable> variables)
|
||||
{
|
||||
var jsonArray = variables.Select(v => new
|
||||
{
|
||||
name = v.Name,
|
||||
type = v.Type.ToString().ToLower(),
|
||||
value = SerializeValue(v.Value, v.Type),
|
||||
min = v.Min,
|
||||
max = v.Max,
|
||||
roles = v.Roles ?? string.Empty,
|
||||
enumValues = v.EnumValues
|
||||
}).ToArray();
|
||||
|
||||
return JsonSerializer.Serialize(jsonArray, JsonOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialize value theo type (đặc biệt cho Object và Array)
|
||||
/// </summary>
|
||||
private static object SerializeValue(object? value, ConfigVariableType type)
|
||||
{
|
||||
if (value == null) return null!;
|
||||
|
||||
return type switch
|
||||
{
|
||||
ConfigVariableType.Object => value is Dictionary<string, object> dict
|
||||
? dict
|
||||
: System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>>(value.ToString() ?? "{}", JsonOptions) ?? [],
|
||||
ConfigVariableType.Array => value is List<object> list
|
||||
? list
|
||||
: System.Text.Json.JsonSerializer.Deserialize<List<object>>(value.ToString() ?? "[]", JsonOptions) ?? [],
|
||||
_ => value
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
using RobotNet10.CustomConfiguration.Models;
|
||||
using System.Globalization;
|
||||
|
||||
namespace RobotNet10.CustomConfiguration.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Helper class cho convert giữa variable type và value
|
||||
/// </summary>
|
||||
public static class VariableTypeConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// Convert string type name thành ConfigVariableType enum
|
||||
/// </summary>
|
||||
public static ConfigVariableType ParseType(string typeName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(typeName, nameof(typeName));
|
||||
|
||||
return typeName.ToLower() switch
|
||||
{
|
||||
"string" => ConfigVariableType.String,
|
||||
"int" => ConfigVariableType.Int,
|
||||
"double" => ConfigVariableType.Double,
|
||||
"bool" => ConfigVariableType.Bool,
|
||||
"object" => ConfigVariableType.Object,
|
||||
"array" => ConfigVariableType.Array,
|
||||
"enum" => ConfigVariableType.Enum,
|
||||
_ => throw new ArgumentException($"Invalid variable type: {typeName}")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert value sang đúng type
|
||||
/// </summary>
|
||||
public static object ConvertValue(ConfigVariableType type, object? value)
|
||||
{
|
||||
if (value == null)
|
||||
throw new ArgumentNullException(nameof(value), "Value cannot be null");
|
||||
|
||||
return type switch
|
||||
{
|
||||
ConfigVariableType.String => value.ToString() ?? string.Empty,
|
||||
ConfigVariableType.Int => Convert.ToInt32(value, CultureInfo.InvariantCulture),
|
||||
ConfigVariableType.Double => Convert.ToDouble(value, CultureInfo.InvariantCulture),
|
||||
ConfigVariableType.Bool => Convert.ToBoolean(value, CultureInfo.InvariantCulture),
|
||||
ConfigVariableType.Object => ConvertToObject(value),
|
||||
ConfigVariableType.Array => ConvertToArray(value),
|
||||
ConfigVariableType.Enum => value.ToString() ?? string.Empty, // Enum values are strings
|
||||
_ => throw new ArgumentException($"Unsupported type: {type}")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert value thành Dictionary<string, object> (Object type)
|
||||
/// </summary>
|
||||
private static Dictionary<string, object> ConvertToObject(object value)
|
||||
{
|
||||
if (value is Dictionary<string, object> dict)
|
||||
return dict;
|
||||
|
||||
if (value is System.Text.Json.JsonElement jsonElement && jsonElement.ValueKind == System.Text.Json.JsonValueKind.Object)
|
||||
{
|
||||
var result = new Dictionary<string, object>();
|
||||
foreach (var prop in jsonElement.EnumerateObject())
|
||||
{
|
||||
result[prop.Name] = prop.Value.GetRawText();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Try parse as JSON string
|
||||
var jsonString = value.ToString();
|
||||
if (!string.IsNullOrEmpty(jsonString))
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(jsonString);
|
||||
if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object)
|
||||
{
|
||||
var result = new Dictionary<string, object>();
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
result[prop.Name] = prop.Value.GetRawText();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
throw new ArgumentException("Value cannot be converted to Object type");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert value thành List<object> (Array type)
|
||||
/// </summary>
|
||||
private static List<object> ConvertToArray(object value)
|
||||
{
|
||||
if (value is List<object> list)
|
||||
return list;
|
||||
|
||||
if (value is System.Text.Json.JsonElement jsonElement && jsonElement.ValueKind == System.Text.Json.JsonValueKind.Array)
|
||||
{
|
||||
var result = new List<object>();
|
||||
foreach (var item in jsonElement.EnumerateArray())
|
||||
{
|
||||
result.Add(item.GetRawText());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Try parse as JSON string
|
||||
var jsonString = value.ToString();
|
||||
if (!string.IsNullOrEmpty(jsonString))
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(jsonString);
|
||||
if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array)
|
||||
{
|
||||
var result = new List<object>();
|
||||
foreach (var item in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
result.Add(item.GetRawText());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
throw new ArgumentException("Value cannot be converted to Array type");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate value có đúng type không
|
||||
/// </summary>
|
||||
public static bool IsValidValue(ConfigVariableType type, object? value)
|
||||
{
|
||||
if (value == null) return false;
|
||||
|
||||
return type switch
|
||||
{
|
||||
ConfigVariableType.String => value is string,
|
||||
ConfigVariableType.Int => value is int || int.TryParse(value.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out _),
|
||||
ConfigVariableType.Double => value is double || double.TryParse(value.ToString(), NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out _),
|
||||
ConfigVariableType.Bool => value is bool || bool.TryParse(value.ToString(), out _),
|
||||
ConfigVariableType.Object => value is Dictionary<string, object> ||
|
||||
(value is System.Text.Json.JsonElement je && je.ValueKind == System.Text.Json.JsonValueKind.Object) ||
|
||||
TryParseJsonObject(value),
|
||||
ConfigVariableType.Array => value is List<object> ||
|
||||
(value is System.Text.Json.JsonElement je && je.ValueKind == System.Text.Json.JsonValueKind.Array) ||
|
||||
TryParseJsonArray(value),
|
||||
ConfigVariableType.Enum => value is string, // Enum values are always strings
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryParseJsonObject(object value)
|
||||
{
|
||||
try
|
||||
{
|
||||
var jsonString = value.ToString();
|
||||
if (string.IsNullOrEmpty(jsonString)) return false;
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(jsonString);
|
||||
return doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object;
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseJsonArray(object value)
|
||||
{
|
||||
try
|
||||
{
|
||||
var jsonString = value.ToString();
|
||||
if (string.IsNullOrEmpty(jsonString)) return false;
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(jsonString);
|
||||
return doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array;
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace RobotNet10.CustomConfiguration.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Model đại diện cho một file config
|
||||
/// ConfigType là tên định danh duy nhất (ví dụ: MQTTBrokerConfig)
|
||||
/// </summary>
|
||||
public class ConfigFile
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string ConfigType { get; set; } = string.Empty; // Tên định danh duy nhất (ví dụ: MQTTBrokerConfig)
|
||||
public string FilePath { get; set; } = string.Empty; // Path trong StorageManager
|
||||
public List<ConfigVariable> Variables { get; set; } = [];
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace RobotNet10.CustomConfiguration.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Metadata của config file (không bao gồm nội dung variables)
|
||||
/// </summary>
|
||||
public class ConfigFileMetadata
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string ConfigType { get; set; } = string.Empty; // Tên định danh duy nhất
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace RobotNet10.CustomConfiguration.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Model đại diện cho một variable trong config
|
||||
/// </summary>
|
||||
public class ConfigVariable
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public ConfigVariableType Type { get; set; }
|
||||
public object? Value { get; set; }
|
||||
|
||||
// Optional properties
|
||||
public double? Min { get; set; } // Cho int và double
|
||||
public double? Max { get; set; } // Cho int và double
|
||||
public string Roles { get; set; } = string.Empty; // Roles string (có thể empty)
|
||||
public List<string>? EnumValues { get; set; } // Cho type Enum - danh sách các giá trị cho phép
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace RobotNet10.CustomConfiguration.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Enum định nghĩa các loại variable type được hỗ trợ
|
||||
/// </summary>
|
||||
public enum ConfigVariableType
|
||||
{
|
||||
String,
|
||||
Int,
|
||||
Double,
|
||||
Bool,
|
||||
Object, // JSON object (Dictionary<string, object>)
|
||||
Array, // JSON array (List<object>)
|
||||
Enum // Enum với các giá trị được định nghĩa trong EnumValues
|
||||
}
|
||||
|
||||
785
srcs/RobotNet10/Commons/RobotNet10.CustomConfiguration/README.md
Normal file
785
srcs/RobotNet10/Commons/RobotNet10.CustomConfiguration/README.md
Normal file
@@ -0,0 +1,785 @@
|
||||
# RobotNet10.CustomConfiguration
|
||||
|
||||
Thư viện quản lý cấu hình động cho ứng dụng RobotNet10, cho phép import/export, chỉnh sửa và quản lý các file cấu hình dạng JSON với hỗ trợ nhiều kiểu dữ liệu.
|
||||
|
||||
## 📋 Mục lục
|
||||
|
||||
- [Tính năng](#tính-năng)
|
||||
- [Cấu trúc Project](#cấu-trúc-project)
|
||||
- [Cài đặt và Cấu hình](#cài-đặt-và-cấu-hình)
|
||||
- [Hướng dẫn sử dụng Backend](#hướng-dẫn-sử-dụng-backend)
|
||||
- [Hướng dẫn sử dụng Frontend](#hướng-dẫn-sử-dụng-frontend)
|
||||
- [Format JSON Config](#format-json-config)
|
||||
- [API Endpoints](#api-endpoints)
|
||||
- [Ví dụ sử dụng](#ví-dụ-sử-dụng)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
## ✨ Tính năng
|
||||
|
||||
- ✅ **Import/Export Config**: Import và export các file cấu hình dạng JSON
|
||||
- ✅ **Quản lý Config Files**: Tạo, đọc, cập nhật, xóa các file cấu hình
|
||||
- ✅ **Quản lý Variables**: Thêm, sửa, xóa các biến trong config
|
||||
- ✅ **Hỗ trợ nhiều kiểu dữ liệu**: String, Int, Double, Bool, Object, Array, Enum
|
||||
- ✅ **Validation**: Kiểm tra tính hợp lệ của dữ liệu theo type và constraints
|
||||
- ✅ **Tìm kiếm**: Tìm kiếm config theo tên, loại
|
||||
- ✅ **UI Component**: Component Blazor sẵn có để quản lý config qua giao diện
|
||||
- ✅ **Storage Manager**: Tích hợp với RobotNet10.StorageManager (Local hoặc MinIO)
|
||||
|
||||
## 📁 Cấu trúc Project
|
||||
|
||||
```
|
||||
RobotNet10.CustomConfiguration/
|
||||
├── Controllers/
|
||||
│ └── ConfigController.cs # REST API Controller
|
||||
├── DTOs/
|
||||
│ ├── ConfigFileDto.cs
|
||||
│ ├── ConfigFileMetadataDto.cs
|
||||
│ ├── ConfigVariableDto.cs
|
||||
│ └── Requests/
|
||||
│ ├── CreateConfigRequest.cs
|
||||
│ ├── UpdateConfigRequest.cs
|
||||
│ └── UpdateVariableRequest.cs
|
||||
├── Extensions/
|
||||
│ └── ServiceCollectionExtensions.cs # DI Extension methods
|
||||
├── Helpers/
|
||||
│ ├── JsonConfigParser.cs # Parse/Serialize JSON
|
||||
│ └── VariableTypeConverter.cs # Convert variable types
|
||||
├── Models/
|
||||
│ ├── ConfigFile.cs
|
||||
│ ├── ConfigFileMetadata.cs
|
||||
│ ├── ConfigVariable.cs
|
||||
│ └── ConfigVariableType.cs
|
||||
├── Services/
|
||||
│ ├── IConfigService.cs
|
||||
│ ├── ConfigService.cs
|
||||
│ ├── ConfigService.Implementation.cs
|
||||
│ └── ConfigService.Metadata.cs
|
||||
└── Validators/
|
||||
└── ConfigValidator.cs # Validation logic
|
||||
```
|
||||
|
||||
## 🚀 Cài đặt và Cấu hình
|
||||
|
||||
### 1. Thêm Project Reference
|
||||
|
||||
Thêm reference vào project của bạn:
|
||||
|
||||
```xml
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Commons\RobotNet10.CustomConfiguration\RobotNet10.CustomConfiguration.csproj" />
|
||||
</ItemGroup>
|
||||
```
|
||||
|
||||
### 2. Cấu hình Backend (ASP.NET Core)
|
||||
|
||||
#### Bước 1: Thêm using trong `Program.cs`
|
||||
|
||||
```csharp
|
||||
using RobotNet10.CustomConfiguration.Extensions;
|
||||
using RobotNet10.StorageManager;
|
||||
```
|
||||
|
||||
#### Bước 2: Đăng ký Services
|
||||
|
||||
**Cách 1: Từ appsettings.json (Khuyến nghị)**
|
||||
|
||||
```csharp
|
||||
// Trong Program.cs
|
||||
builder.Services.AddCustomConfiguration(builder.Configuration, "StorageConfig");
|
||||
```
|
||||
|
||||
Thêm vào `appsettings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"StorageConfig": {
|
||||
"UsingLocal": true,
|
||||
"LocalFolder": "Configs",
|
||||
"Bucket": "",
|
||||
"RetryCount": 3,
|
||||
"MinioConfig": {
|
||||
"Endpoint": "localhost:9000",
|
||||
"User": "minioadmin",
|
||||
"Password": "minioadmin",
|
||||
"EnableSSL": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Cách 2: Trực tiếp trong code**
|
||||
|
||||
```csharp
|
||||
var storageConfig = new StorageConfig
|
||||
{
|
||||
UsingLocal = true,
|
||||
LocalFolder = "Configs",
|
||||
Bucket = "",
|
||||
RetryCount = 3
|
||||
};
|
||||
|
||||
builder.Services.AddCustomConfiguration(storageConfig);
|
||||
```
|
||||
|
||||
#### Bước 3: Đảm bảo đã map Controllers
|
||||
|
||||
```csharp
|
||||
var app = builder.Build();
|
||||
|
||||
// ... middleware ...
|
||||
|
||||
app.MapControllers(); // Đảm bảo có dòng này
|
||||
|
||||
app.Run();
|
||||
```
|
||||
|
||||
### 3. Cấu hình Frontend (Blazor)
|
||||
|
||||
#### Bước 1: Thêm Project Reference
|
||||
|
||||
```xml
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Components\RobotNet10.CustomConfigurationEditor\RobotNet10.CustomConfigurationEditor.csproj" />
|
||||
</ItemGroup>
|
||||
```
|
||||
|
||||
#### Bước 2: Đăng ký Services trong `Program.cs` hoặc `Client/Program.cs`
|
||||
|
||||
```csharp
|
||||
using RobotNet10.CustomConfigurationEditor.Services.API;
|
||||
using RobotNet10.CustomConfigurationEditor.Services.State;
|
||||
|
||||
// HttpClient (nếu chưa có)
|
||||
builder.Services.AddScoped(sp => new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
|
||||
});
|
||||
|
||||
// MudBlazor (nếu chưa có)
|
||||
builder.Services.AddMudServices();
|
||||
|
||||
// CustomConfiguration Services
|
||||
builder.Services.AddScoped<ConfigApiService>();
|
||||
builder.Services.AddScoped<ConfigManagerState>();
|
||||
```
|
||||
|
||||
#### Bước 3: Copy JavaScript file
|
||||
|
||||
Copy file `downloadFile.js` từ `RobotNet10.CustomConfigurationEditor/wwwroot/js/downloadFile.js` vào `wwwroot/js/` của project frontend.
|
||||
|
||||
Thêm vào `index.html` hoặc `App.razor`:
|
||||
|
||||
```html
|
||||
<script src="js/downloadFile.js"></script>
|
||||
```
|
||||
|
||||
#### Bước 4: Thêm using trong `_Imports.razor`
|
||||
|
||||
```razor
|
||||
@using RobotNet10.CustomConfigurationEditor.Components.ConfigManager
|
||||
```
|
||||
|
||||
## 📖 Hướng dẫn sử dụng Backend
|
||||
|
||||
### Sử dụng IConfigService
|
||||
|
||||
Inject `IConfigService` vào service hoặc controller của bạn:
|
||||
|
||||
```csharp
|
||||
public class MyService
|
||||
{
|
||||
private readonly IConfigService _configService;
|
||||
|
||||
public MyService(IConfigService configService)
|
||||
{
|
||||
_configService = configService;
|
||||
}
|
||||
|
||||
public async Task<ConfigFile> GetMqttConfigAsync()
|
||||
{
|
||||
return await _configService.GetConfigByTypeAsync("MQTTBrokerConfig");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Các phương thức chính
|
||||
|
||||
```csharp
|
||||
// Lấy tất cả configs (metadata)
|
||||
var configs = await _configService.GetAllConfigsAsync();
|
||||
|
||||
// Lấy config theo ID
|
||||
var config = await _configService.GetConfigByIdAsync(id);
|
||||
|
||||
// Lấy config theo ConfigType
|
||||
var mqttConfig = await _configService.GetConfigByTypeAsync("MQTTBrokerConfig");
|
||||
|
||||
// Tạo config mới
|
||||
var newConfig = await _configService.CreateConfigAsync(
|
||||
configType: "MyConfig",
|
||||
variables: variables,
|
||||
description: "My configuration"
|
||||
);
|
||||
|
||||
// Cập nhật config
|
||||
await _configService.UpdateConfigAsync(id, variables, description);
|
||||
|
||||
// Xóa config
|
||||
await _configService.DeleteConfigAsync(id);
|
||||
|
||||
// Import từ file
|
||||
using var stream = File.OpenRead("config.json");
|
||||
var imported = await _configService.ImportConfigAsync(stream, "config.json", "MyConfig");
|
||||
|
||||
// Export ra file
|
||||
var exportStream = await _configService.ExportConfigAsync(id);
|
||||
|
||||
// Cập nhật variable
|
||||
await _configService.UpdateVariableAsync(id, "port", 8080);
|
||||
|
||||
// Thêm variable
|
||||
await _configService.AddVariableAsync(id, newVariable);
|
||||
|
||||
// Xóa variable
|
||||
await _configService.RemoveVariableAsync(id, "variableName");
|
||||
```
|
||||
|
||||
## 🎨 Hướng dẫn sử dụng Frontend
|
||||
|
||||
### Sử dụng Component
|
||||
|
||||
Tạo page mới hoặc thêm vào page hiện có:
|
||||
|
||||
```razor
|
||||
@page "/config-manager"
|
||||
@using RobotNet10.CustomConfigurationEditor.Components.ConfigManager
|
||||
|
||||
<ConfigManagerComponent />
|
||||
```
|
||||
|
||||
### Sử dụng ConfigManagerState
|
||||
|
||||
Inject `ConfigManagerState` vào component của bạn:
|
||||
|
||||
```razor
|
||||
@inject ConfigManagerState State
|
||||
|
||||
<MudButton OnClick="LoadConfigs">Load Configs</MudButton>
|
||||
|
||||
@code {
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await State.LoadConfigsAsync();
|
||||
}
|
||||
|
||||
private async Task LoadConfigs()
|
||||
{
|
||||
await State.LoadConfigsAsync();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Các phương thức State
|
||||
|
||||
```csharp
|
||||
// Load tất cả configs
|
||||
await State.LoadConfigsAsync();
|
||||
|
||||
// Load với search query
|
||||
await State.LoadConfigsAsync("MQTT");
|
||||
|
||||
// Load config theo ID
|
||||
await State.LoadConfigByIdAsync(id);
|
||||
|
||||
// Load config theo ConfigType
|
||||
await State.LoadConfigByTypeAsync("MQTTBrokerConfig");
|
||||
|
||||
// Select config
|
||||
await State.SelectConfigAsync(configMetadata);
|
||||
|
||||
// Tạo config mới
|
||||
var config = await State.CreateConfigAsync(
|
||||
configType: "MyConfig",
|
||||
variables: variables,
|
||||
description: "My config"
|
||||
);
|
||||
|
||||
// Cập nhật config
|
||||
await State.UpdateConfigAsync(variables, description);
|
||||
|
||||
// Xóa config
|
||||
await State.DeleteConfigAsync(id);
|
||||
|
||||
// Import config
|
||||
using var stream = file.OpenReadStream();
|
||||
var imported = await State.ImportConfigAsync(stream, file.Name, "MyConfig");
|
||||
|
||||
// Export config
|
||||
var stream = await State.ExportConfigAsync(id);
|
||||
|
||||
// Cập nhật variable
|
||||
await State.UpdateVariableAsync("port", 8080);
|
||||
|
||||
// Thêm variable
|
||||
await State.AddVariableAsync(newVariable);
|
||||
|
||||
// Xóa variable
|
||||
await State.RemoveVariableAsync("variableName");
|
||||
```
|
||||
|
||||
## 📄 Format JSON Config
|
||||
|
||||
### Cấu trúc cơ bản
|
||||
|
||||
File config là một mảng JSON chứa các variable:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "port",
|
||||
"type": "int",
|
||||
"value": 8080,
|
||||
"Min": 0,
|
||||
"Max": 65535,
|
||||
"Roles": ""
|
||||
},
|
||||
{
|
||||
"name": "host",
|
||||
"type": "string",
|
||||
"value": "localhost",
|
||||
"Roles": ""
|
||||
},
|
||||
{
|
||||
"name": "enableSSL",
|
||||
"type": "bool",
|
||||
"value": true,
|
||||
"Roles": ""
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Các kiểu dữ liệu hỗ trợ
|
||||
|
||||
#### 1. String
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "host",
|
||||
"type": "string",
|
||||
"value": "localhost",
|
||||
"Roles": ""
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Int
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "port",
|
||||
"type": "int",
|
||||
"value": 8080,
|
||||
"Min": 0,
|
||||
"Max": 65535,
|
||||
"Roles": ""
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Double
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "timeout",
|
||||
"type": "double",
|
||||
"value": 30.5,
|
||||
"Min": 0.0,
|
||||
"Max": 100.0,
|
||||
"Roles": ""
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Bool
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "enableSSL",
|
||||
"type": "bool",
|
||||
"value": true,
|
||||
"Roles": ""
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. Enum
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "logLevel",
|
||||
"type": "enum",
|
||||
"value": "Info",
|
||||
"EnumValues": ["Debug", "Info", "Warning", "Error"],
|
||||
"Roles": ""
|
||||
}
|
||||
```
|
||||
|
||||
#### 6. Object
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "database",
|
||||
"type": "object",
|
||||
"value": {
|
||||
"host": "localhost",
|
||||
"port": 5432,
|
||||
"name": "mydb"
|
||||
},
|
||||
"Roles": ""
|
||||
}
|
||||
```
|
||||
|
||||
#### 7. Array
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "allowedIPs",
|
||||
"type": "array",
|
||||
"value": ["192.168.1.1", "192.168.1.2", "10.0.0.1"],
|
||||
"Roles": ""
|
||||
}
|
||||
```
|
||||
|
||||
### Các thuộc tính
|
||||
|
||||
| Thuộc tính | Bắt buộc | Mô tả |
|
||||
|-----------|----------|-------|
|
||||
| `name` | ✅ | Tên của variable (duy nhất trong config) |
|
||||
| `type` | ✅ | Kiểu dữ liệu: `string`, `int`, `double`, `bool`, `enum`, `object`, `array` |
|
||||
| `value` | ✅ | Giá trị của variable |
|
||||
| `Min` | ❌ | Giá trị tối thiểu (cho `int` và `double`) |
|
||||
| `Max` | ❌ | Giá trị tối đa (cho `int` và `double`) |
|
||||
| `EnumValues` | ❌ | Danh sách giá trị cho phép (cho `enum`) |
|
||||
| `Roles` | ❌ | Roles string (có thể để trống) |
|
||||
|
||||
## 🔌 API Endpoints
|
||||
|
||||
### Config File Management
|
||||
|
||||
#### GET `/api/configs`
|
||||
Lấy tất cả configs (metadata only)
|
||||
|
||||
**Query Parameters:**
|
||||
- `search` (optional): Tìm kiếm theo tên hoặc ConfigType
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "guid",
|
||||
"configType": "MQTTBrokerConfig",
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"updatedAt": "2024-01-01T00:00:00Z",
|
||||
"description": "MQTT Broker Configuration"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### GET `/api/configs/{id}`
|
||||
Lấy config theo ID
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"id": "guid",
|
||||
"configType": "MQTTBrokerConfig",
|
||||
"variables": [...],
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"updatedAt": "2024-01-01T00:00:00Z",
|
||||
"description": "MQTT Broker Configuration"
|
||||
}
|
||||
```
|
||||
|
||||
#### GET `/api/configs/by-type/{configType}`
|
||||
Lấy config theo ConfigType
|
||||
|
||||
**Response:** `200 OK` (same as GET by ID)
|
||||
|
||||
#### GET `/api/configs/exists/{configType}`
|
||||
Kiểm tra ConfigType có tồn tại không
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
true
|
||||
```
|
||||
|
||||
#### POST `/api/configs`
|
||||
Tạo config mới
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"configType": "MyConfig",
|
||||
"variables": [
|
||||
{
|
||||
"name": "port",
|
||||
"type": "int",
|
||||
"value": 8080
|
||||
}
|
||||
],
|
||||
"description": "My configuration"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `201 Created`
|
||||
|
||||
#### PUT `/api/configs/{id}`
|
||||
Cập nhật config
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"variables": [...],
|
||||
"description": "Updated description"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `200 OK`
|
||||
|
||||
#### DELETE `/api/configs/{id}`
|
||||
Xóa config
|
||||
|
||||
**Response:** `204 No Content`
|
||||
|
||||
### Import/Export
|
||||
|
||||
#### POST `/api/configs/import`
|
||||
Import config từ JSON file
|
||||
|
||||
**Request:** `multipart/form-data`
|
||||
- `file`: JSON file
|
||||
- `configType`: ConfigType name
|
||||
|
||||
**Response:** `200 OK`
|
||||
|
||||
#### GET `/api/configs/{id}/export`
|
||||
Export config ra JSON file
|
||||
|
||||
**Response:** `200 OK` (application/json)
|
||||
|
||||
### Variable Management
|
||||
|
||||
#### PUT `/api/configs/{id}/variables/{variableName}`
|
||||
Cập nhật giá trị variable
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"variableName": "port",
|
||||
"value": 8080
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `200 OK`
|
||||
|
||||
#### POST `/api/configs/{id}/variables`
|
||||
Thêm variable mới
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"name": "newVariable",
|
||||
"type": "string",
|
||||
"value": "value"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `200 OK`
|
||||
|
||||
#### DELETE `/api/configs/{id}/variables/{variableName}`
|
||||
Xóa variable
|
||||
|
||||
**Response:** `200 OK`
|
||||
|
||||
## 💡 Ví dụ sử dụng
|
||||
|
||||
### Ví dụ 1: Tạo MQTT Broker Config
|
||||
|
||||
```csharp
|
||||
var variables = new List<ConfigVariable>
|
||||
{
|
||||
new ConfigVariable
|
||||
{
|
||||
Name = "host",
|
||||
Type = ConfigVariableType.String,
|
||||
Value = "localhost"
|
||||
},
|
||||
new ConfigVariable
|
||||
{
|
||||
Name = "port",
|
||||
Type = ConfigVariableType.Int,
|
||||
Value = 1883,
|
||||
Min = 0,
|
||||
Max = 65535
|
||||
},
|
||||
new ConfigVariable
|
||||
{
|
||||
Name = "enableSSL",
|
||||
Type = ConfigVariableType.Bool,
|
||||
Value = false
|
||||
}
|
||||
};
|
||||
|
||||
var config = await _configService.CreateConfigAsync(
|
||||
configType: "MQTTBrokerConfig",
|
||||
variables: variables,
|
||||
description: "MQTT Broker Configuration"
|
||||
);
|
||||
```
|
||||
|
||||
### Ví dụ 2: Import Config từ File
|
||||
|
||||
```csharp
|
||||
using var stream = File.OpenRead("mqtt-config.json");
|
||||
var config = await _configService.ImportConfigAsync(
|
||||
stream: stream,
|
||||
fileName: "mqtt-config.json",
|
||||
configType: "MQTTBrokerConfig"
|
||||
);
|
||||
```
|
||||
|
||||
### Ví dụ 3: Sử dụng Config trong Service
|
||||
|
||||
```csharp
|
||||
public class MqttService
|
||||
{
|
||||
private readonly IConfigService _configService;
|
||||
|
||||
public MqttService(IConfigService configService)
|
||||
{
|
||||
_configService = configService;
|
||||
}
|
||||
|
||||
public async Task ConnectAsync()
|
||||
{
|
||||
var config = await _configService.GetConfigByTypeAsync("MQTTBrokerConfig");
|
||||
|
||||
var host = config.Variables.First(v => v.Name == "host").Value?.ToString();
|
||||
var port = (int)config.Variables.First(v => v.Name == "port").Value!;
|
||||
|
||||
// Connect to MQTT broker using host and port
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Ví dụ 4: Cập nhật Variable qua API
|
||||
|
||||
```csharp
|
||||
// C# HttpClient
|
||||
var client = new HttpClient();
|
||||
var request = new
|
||||
{
|
||||
variableName = "port",
|
||||
value = 8883
|
||||
};
|
||||
|
||||
var response = await client.PutAsJsonAsync(
|
||||
"https://api.example.com/api/configs/{id}/variables/port",
|
||||
request
|
||||
);
|
||||
```
|
||||
|
||||
### Ví dụ 5: Frontend - Sử dụng Component
|
||||
|
||||
```razor
|
||||
@page "/settings/config"
|
||||
@using RobotNet10.CustomConfigurationEditor.Components.ConfigManager
|
||||
|
||||
<PageTitle>Configuration Manager</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
|
||||
<ConfigManagerComponent />
|
||||
</MudContainer>
|
||||
```
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Lỗi: "ConfigType already exists"
|
||||
|
||||
**Nguyên nhân:** ConfigType đã tồn tại trong hệ thống.
|
||||
|
||||
**Giải pháp:**
|
||||
- Sử dụng ConfigType khác
|
||||
- Xóa config cũ trước khi tạo mới
|
||||
- Kiểm tra bằng `ConfigTypeExistsAsync()` trước khi tạo
|
||||
|
||||
### Lỗi: "Invalid variable type"
|
||||
|
||||
**Nguyên nhân:** Type của variable không hợp lệ.
|
||||
|
||||
**Giải pháp:**
|
||||
- Kiểm tra type là một trong: `string`, `int`, `double`, `bool`, `enum`, `object`, `array`
|
||||
- Đảm bảo value phù hợp với type
|
||||
|
||||
### Lỗi: "Value out of range"
|
||||
|
||||
**Nguyên nhân:** Giá trị vượt quá Min/Max.
|
||||
|
||||
**Giải pháp:**
|
||||
- Kiểm tra giá trị nằm trong khoảng Min và Max
|
||||
- Cập nhật Min/Max nếu cần
|
||||
|
||||
### Lỗi: "Invalid JSON format"
|
||||
|
||||
**Nguyên nhân:** File JSON không đúng format.
|
||||
|
||||
**Giải pháp:**
|
||||
- Kiểm tra file là mảng JSON hợp lệ
|
||||
- Đảm bảo mỗi variable có `name`, `type`, `value`
|
||||
- Validate JSON trước khi import
|
||||
|
||||
### Lỗi: "StorageManager not initialized"
|
||||
|
||||
**Nguyên nhân:** StorageConfig chưa được cấu hình.
|
||||
|
||||
**Giải pháp:**
|
||||
- Đảm bảo đã gọi `AddCustomConfiguration()` trong `Program.cs`
|
||||
- Kiểm tra `StorageConfig` trong `appsettings.json`
|
||||
|
||||
### Frontend: Component không hiển thị
|
||||
|
||||
**Nguyên nhân:** Services chưa được đăng ký.
|
||||
|
||||
**Giải pháp:**
|
||||
- Kiểm tra đã đăng ký `ConfigApiService` và `ConfigManagerState`
|
||||
- Đảm bảo có `HttpClient` với `BaseAddress`
|
||||
- Kiểm tra đã có `MudBlazor` services
|
||||
|
||||
### Frontend: Export không hoạt động
|
||||
|
||||
**Nguyên nhân:** JavaScript file chưa được thêm.
|
||||
|
||||
**Giải pháp:**
|
||||
- Copy `downloadFile.js` vào `wwwroot/js/`
|
||||
- Thêm script tag vào `index.html` hoặc `App.razor`
|
||||
|
||||
## 📝 Lưu ý
|
||||
|
||||
1. **ConfigType là duy nhất**: Mỗi ConfigType chỉ có thể tồn tại một lần trong hệ thống
|
||||
2. **Validation**: Tất cả dữ liệu đều được validate trước khi lưu
|
||||
3. **Storage**: Config files được lưu trong `configs/{ConfigType}.json` trong StorageManager
|
||||
4. **Metadata**: Metadata được lưu trong `configs/_metadata.json`
|
||||
5. **Thread Safety**: Services được đăng ký là `Scoped`, phù hợp cho web applications
|
||||
|
||||
## 📚 Tài liệu tham khảo
|
||||
|
||||
- [RobotNet10.StorageManager Documentation](../RobotNet10.StorageManager/README.md)
|
||||
- [MudBlazor Documentation](https://mudblazor.com/)
|
||||
- [ASP.NET Core Documentation](https://docs.microsoft.com/aspnet/core)
|
||||
|
||||
## 🤝 Đóng góp
|
||||
|
||||
Nếu bạn phát hiện lỗi hoặc có đề xuất cải thiện, vui lòng tạo issue hoặc pull request.
|
||||
|
||||
## 📄 License
|
||||
|
||||
[Thêm thông tin license nếu có]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.3.9" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\RobotNet10.StorageManager\RobotNet10.StorageManager.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,194 @@
|
||||
using RobotNet10.CustomConfiguration.Events;
|
||||
using RobotNet10.CustomConfiguration.Models;
|
||||
|
||||
namespace RobotNet10.CustomConfiguration.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation của IConfigManager
|
||||
/// Service cho quản lý và truy vấn configuration variables
|
||||
/// </summary>
|
||||
public class ConfigManager : IConfigManager, IDisposable
|
||||
{
|
||||
private readonly IConfigService _configService;
|
||||
|
||||
// ==========================================
|
||||
// EVENTS
|
||||
// ==========================================
|
||||
|
||||
public event EventHandler<ConfigChangedEventArgs>? ConfigChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Constructor - Subscribe vào ConfigChanged event từ IConfigService
|
||||
/// </summary>
|
||||
public ConfigManager(IConfigService configService)
|
||||
{
|
||||
_configService = configService ?? throw new ArgumentNullException(nameof(configService));
|
||||
|
||||
// Forward events from IConfigService to IConfigManager
|
||||
_configService.ConfigChanged += OnConfigServiceChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forward ConfigChanged event from IConfigService to IConfigManager subscribers
|
||||
/// </summary>
|
||||
private void OnConfigServiceChanged(object? sender, ConfigChangedEventArgs args)
|
||||
{
|
||||
ConfigChanged?.Invoke(this, args);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// CONFIG TYPE OPERATIONS
|
||||
// ==========================================
|
||||
|
||||
public async Task<ConfigFile?> GetConfigByTypeAsync(string configType)
|
||||
{
|
||||
return await _configService.GetConfigByTypeAsync(configType);
|
||||
}
|
||||
|
||||
public async Task<bool> ConfigTypeExistsAsync(string configType)
|
||||
{
|
||||
return await _configService.ConfigTypeExistsAsync(configType);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// VARIABLE EXISTENCE CHECK
|
||||
// ==========================================
|
||||
|
||||
public async Task<bool> VariableExistsAsync(string configType, string variableName)
|
||||
{
|
||||
var config = await _configService.GetConfigByTypeAsync(configType);
|
||||
if (config == null)
|
||||
return false;
|
||||
|
||||
return config.Variables.Any(v => v.Name.Equals(variableName, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public async Task<bool> VariableExistsAsync(Guid configId, string variableName)
|
||||
{
|
||||
var config = await _configService.GetConfigByIdAsync(configId);
|
||||
if (config == null)
|
||||
return false;
|
||||
|
||||
return config.Variables.Any(v => v.Name.Equals(variableName, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// GET VARIABLE VALUE
|
||||
// ==========================================
|
||||
|
||||
public async Task<object?> GetVariableValueAsync(string configType, string variableName)
|
||||
{
|
||||
var variable = await GetVariableAsync(configType, variableName);
|
||||
return variable?.Value;
|
||||
}
|
||||
|
||||
public async Task<object?> GetVariableValueAsync(Guid configId, string variableName)
|
||||
{
|
||||
var variable = await GetVariableAsync(configId, variableName);
|
||||
return variable?.Value;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// GET VARIABLE(S)
|
||||
// ==========================================
|
||||
|
||||
public async Task<ConfigVariable?> GetVariableAsync(string configType, string variableName)
|
||||
{
|
||||
var config = await _configService.GetConfigByTypeAsync(configType);
|
||||
if (config == null)
|
||||
return null;
|
||||
|
||||
return config.Variables.FirstOrDefault(v => v.Name.Equals(variableName, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public async Task<List<ConfigVariable>> GetVariablesAsync(string configType)
|
||||
{
|
||||
var config = await _configService.GetConfigByTypeAsync(configType);
|
||||
return config?.Variables ?? [];
|
||||
}
|
||||
|
||||
public async Task<ConfigVariable?> GetVariableAsync(Guid configId, string variableName)
|
||||
{
|
||||
var config = await _configService.GetConfigByIdAsync(configId);
|
||||
if (config == null)
|
||||
return null;
|
||||
|
||||
return config.Variables.FirstOrDefault(v => v.Name.Equals(variableName, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public async Task<List<ConfigVariable>> GetVariablesAsync(Guid configId)
|
||||
{
|
||||
var config = await _configService.GetConfigByIdAsync(configId);
|
||||
return config?.Variables ?? [];
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// GET VARIABLE TYPE
|
||||
// ==========================================
|
||||
|
||||
public async Task<string?> GetVariableTypeAsync(string configType, string variableName)
|
||||
{
|
||||
var variable = await GetVariableAsync(configType, variableName);
|
||||
return variable != null ? ConvertTypeToString(variable.Type) : null;
|
||||
}
|
||||
|
||||
public async Task<string?> GetVariableTypeAsync(Guid configId, string variableName)
|
||||
{
|
||||
var variable = await GetVariableAsync(configId, variableName);
|
||||
return variable != null ? ConvertTypeToString(variable.Type) : null;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// EVENT TRIGGERS
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Trigger ConfigChanged event
|
||||
/// Method này có thể được gọi từ bên ngoài hoặc từ các service khác khi có thay đổi
|
||||
/// </summary>
|
||||
public void OnConfigChanged(string configType, Guid configId, ConfigChangeType changeType, string? variableName = null)
|
||||
{
|
||||
var args = new ConfigChangedEventArgs
|
||||
{
|
||||
ConfigType = configType,
|
||||
ConfigId = configId,
|
||||
ChangeType = changeType,
|
||||
VariableName = variableName
|
||||
};
|
||||
|
||||
ConfigChanged?.Invoke(this, args);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// DISPOSE
|
||||
// ==========================================
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_configService.ConfigChanged -= OnConfigServiceChanged;
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// HELPER METHODS
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Convert ConfigVariableType enum thành string
|
||||
/// </summary>
|
||||
private static string ConvertTypeToString(ConfigVariableType type)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
ConfigVariableType.String => "string",
|
||||
ConfigVariableType.Int => "int",
|
||||
ConfigVariableType.Double => "double",
|
||||
ConfigVariableType.Bool => "bool",
|
||||
ConfigVariableType.Object => "object",
|
||||
ConfigVariableType.Array => "array",
|
||||
ConfigVariableType.Enum => "enum",
|
||||
_ => throw new ArgumentException($"Unknown variable type: {type}")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
using RobotNet10.CustomConfiguration.Events;
|
||||
using RobotNet10.CustomConfiguration.Helpers;
|
||||
using RobotNet10.CustomConfiguration.Models;
|
||||
using RobotNet10.CustomConfiguration.Validators;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace RobotNet10.CustomConfiguration.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation của các methods trong ConfigService
|
||||
/// </summary>
|
||||
public partial class ConfigService
|
||||
{
|
||||
public async Task<ConfigFile> CreateConfigAsync(string configType, List<ConfigVariable> variables, string? description = null)
|
||||
{
|
||||
// Validate ConfigType
|
||||
if (string.IsNullOrWhiteSpace(configType))
|
||||
{
|
||||
throw new ArgumentException("ConfigType cannot be empty", nameof(configType));
|
||||
}
|
||||
|
||||
// Check if ConfigType already exists
|
||||
var existing = await GetMetadataByTypeAsync(configType);
|
||||
if (existing != null)
|
||||
{
|
||||
throw new InvalidOperationException($"Config with type '{configType}' already exists");
|
||||
}
|
||||
|
||||
// Create config file
|
||||
var config = new ConfigFile
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ConfigType = configType,
|
||||
Variables = variables,
|
||||
Description = description,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
FilePath = $"{ConfigPath}/{configType}{ConfigFileExtension}"
|
||||
};
|
||||
|
||||
// Validate config
|
||||
var validationResult = ConfigValidator.ValidateConfig(config);
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
throw new ArgumentException($"Invalid config: {string.Join("; ", validationResult.Errors)}");
|
||||
}
|
||||
|
||||
// Save config file (includes metadata)
|
||||
await SaveConfigFileAsync(config);
|
||||
|
||||
// Trigger event
|
||||
OnConfigChanged(config, ConfigChangeType.Created);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
public async Task<ConfigFile?> GetConfigByIdAsync(Guid id)
|
||||
{
|
||||
// Load all configs and find by ID
|
||||
var allMetadata = await LoadAllMetadataAsync();
|
||||
var metadata = allMetadata.FirstOrDefault(m => m.Id == id);
|
||||
if (metadata == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await LoadConfigFileAsync(metadata.ConfigType);
|
||||
}
|
||||
|
||||
public async Task<ConfigFile?> GetConfigByTypeAsync(string configType)
|
||||
{
|
||||
return await LoadConfigFileAsync(configType);
|
||||
}
|
||||
|
||||
public async Task<List<ConfigFileMetadata>> GetAllConfigsAsync()
|
||||
{
|
||||
var metadata = await LoadAllMetadataAsync();
|
||||
return [.. metadata.OrderBy(m => m.ConfigType)];
|
||||
}
|
||||
|
||||
public async Task<List<ConfigFileMetadata>> SearchConfigsAsync(string? searchText)
|
||||
{
|
||||
var allMetadata = await GetAllConfigsAsync();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(searchText))
|
||||
{
|
||||
return allMetadata;
|
||||
}
|
||||
|
||||
var searchLower = searchText.ToLower();
|
||||
return [.. allMetadata.Where(m =>
|
||||
m.ConfigType.ToLower().Contains(searchLower) ||
|
||||
(m.Description != null && m.Description.ToLower().Contains(searchLower))
|
||||
)];
|
||||
}
|
||||
|
||||
public async Task<ConfigFile> UpdateConfigAsync(Guid id, List<ConfigVariable>? variables = null, string? description = null)
|
||||
{
|
||||
var config = await GetConfigByIdAsync(id) ?? throw new KeyNotFoundException($"Config with ID '{id}' not found");
|
||||
|
||||
// Update variables if provided
|
||||
if (variables != null)
|
||||
{
|
||||
config.Variables = variables;
|
||||
}
|
||||
|
||||
// Update description if provided
|
||||
if (description != null)
|
||||
{
|
||||
config.Description = description;
|
||||
}
|
||||
|
||||
config.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Validate config
|
||||
var validationResult = ConfigValidator.ValidateConfig(config);
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
throw new ArgumentException($"Invalid config: {string.Join("; ", validationResult.Errors)}");
|
||||
}
|
||||
|
||||
// Save config file (includes metadata)
|
||||
await SaveConfigFileAsync(config);
|
||||
|
||||
// Trigger event
|
||||
OnConfigChanged(config, ConfigChangeType.Updated);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteConfigAsync(Guid id)
|
||||
{
|
||||
// Find config by ID
|
||||
var allMetadata = await LoadAllMetadataAsync();
|
||||
var metadata = allMetadata.FirstOrDefault(m => m.Id == id);
|
||||
if (metadata == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delete config file
|
||||
// objectName should be {configType}.config so StorageManager adds .json to make {configType}.config.json
|
||||
var objectName = $"{metadata.ConfigType}.config";
|
||||
var exists = await _storageManager.ExistsAsync(ConfigPath, objectName, CancellationToken.None);
|
||||
if (exists)
|
||||
{
|
||||
await _storageManager.DeleteAsync(ConfigPath, objectName, CancellationToken.None);
|
||||
|
||||
// Trigger event
|
||||
var deletedConfig = new ConfigFile
|
||||
{
|
||||
Id = metadata.Id,
|
||||
ConfigType = metadata.ConfigType,
|
||||
CreatedAt = metadata.CreatedAt,
|
||||
UpdatedAt = metadata.UpdatedAt,
|
||||
Description = metadata.Description
|
||||
};
|
||||
OnConfigChanged(deletedConfig, ConfigChangeType.Deleted);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> ConfigTypeExistsAsync(string configType)
|
||||
{
|
||||
var metadata = await GetMetadataByTypeAsync(configType);
|
||||
return metadata != null;
|
||||
}
|
||||
|
||||
public async Task<ConfigFile> ImportConfigFromJsonAsync(Stream jsonStream, string configType)
|
||||
{
|
||||
return await ImportConfigFromJsonAsync(jsonStream, configType, null);
|
||||
}
|
||||
|
||||
public async Task<ConfigFile> ImportConfigFromJsonAsync(Stream jsonStream, string configType, string? description)
|
||||
{
|
||||
// Validate ConfigType
|
||||
if (string.IsNullOrWhiteSpace(configType))
|
||||
{
|
||||
throw new ArgumentException("ConfigType cannot be empty", nameof(configType));
|
||||
}
|
||||
|
||||
// Check if ConfigType already exists
|
||||
var existing = await GetMetadataByTypeAsync(configType);
|
||||
if (existing != null)
|
||||
{
|
||||
throw new InvalidOperationException($"Config with type '{configType}' already exists");
|
||||
}
|
||||
|
||||
// Try to parse as full config file (new format with metadata)
|
||||
// Reset stream position first
|
||||
jsonStream.Position = 0;
|
||||
string? fileDescription = null;
|
||||
List<ConfigVariable> variables;
|
||||
|
||||
try
|
||||
{
|
||||
// Try to parse as ConfigFile (new format)
|
||||
var configFile = JsonConfigParser.ParseConfigFile(jsonStream);
|
||||
// If successful, use description from file
|
||||
fileDescription = configFile.Description;
|
||||
variables = configFile.Variables;
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or ArgumentException)
|
||||
{
|
||||
// If parsing as ConfigFile fails, try old format (array of variables)
|
||||
jsonStream.Position = 0;
|
||||
variables = JsonConfigParser.ParseVariables(jsonStream);
|
||||
}
|
||||
|
||||
// Use description from parameter if provided, otherwise use description from file
|
||||
var finalDescription = !string.IsNullOrWhiteSpace(description) ? description : fileDescription;
|
||||
|
||||
// Create config with description (from parameter or file)
|
||||
var config = await CreateConfigAsync(configType, variables, finalDescription);
|
||||
|
||||
// Note: CreateConfigAsync already triggers Created event, so no need to trigger again
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
public async Task<Stream> ExportConfigToJsonAsync(Guid id)
|
||||
{
|
||||
var config = await GetConfigByIdAsync(id);
|
||||
return config == null ? throw new KeyNotFoundException($"Config with ID '{id}' not found") : await ExportConfigToJsonAsync(config);
|
||||
}
|
||||
|
||||
public Task<Stream> ExportConfigToJsonAsync(ConfigFile config)
|
||||
{
|
||||
// Export với format mới (metadata + variables)
|
||||
var json = JsonConfigParser.SerializeConfigFile(config);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
Stream stream = new MemoryStream(bytes)
|
||||
{
|
||||
Position = 0 // Ensure stream is at the beginning
|
||||
};
|
||||
return Task.FromResult(stream);
|
||||
}
|
||||
|
||||
public async Task<ConfigFile> UpdateVariableAsync(Guid configId, string variableName, object? value)
|
||||
{
|
||||
var config = await GetConfigByIdAsync(configId) ??
|
||||
throw new KeyNotFoundException($"Config with ID '{configId}' not found");
|
||||
var variable = config.Variables.FirstOrDefault(v => v.Name.Equals(variableName, StringComparison.OrdinalIgnoreCase)) ??
|
||||
throw new KeyNotFoundException($"Variable '{variableName}' not found in config");
|
||||
variable.Value = value;
|
||||
config.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Validate
|
||||
var validationResult = ConfigValidator.ValidateConfig(config);
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
throw new ArgumentException($"Invalid config: {string.Join("; ", validationResult.Errors)}");
|
||||
}
|
||||
|
||||
// Save config file (includes metadata)
|
||||
await SaveConfigFileAsync(config);
|
||||
|
||||
// Trigger event
|
||||
OnConfigChanged(config, ConfigChangeType.VariableUpdated, variableName);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
public async Task<ConfigFile> AddVariableAsync(Guid configId, ConfigVariable variable)
|
||||
{
|
||||
var config = await GetConfigByIdAsync(configId) ?? throw new KeyNotFoundException($"Config with ID '{configId}' not found");
|
||||
|
||||
// Check if variable name already exists
|
||||
if (config.Variables.Any(v => v.Name.Equals(variable.Name, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
throw new InvalidOperationException($"Variable '{variable.Name}' already exists in config");
|
||||
}
|
||||
|
||||
config.Variables.Add(variable);
|
||||
config.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Validate
|
||||
var validationResult = ConfigValidator.ValidateConfig(config);
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
throw new ArgumentException($"Invalid config: {string.Join("; ", validationResult.Errors)}");
|
||||
}
|
||||
|
||||
// Save config file (includes metadata)
|
||||
await SaveConfigFileAsync(config);
|
||||
|
||||
// Trigger event
|
||||
OnConfigChanged(config, ConfigChangeType.VariableAdded, variable.Name);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
public async Task<ConfigFile> RemoveVariableAsync(Guid configId, string variableName)
|
||||
{
|
||||
var config = await GetConfigByIdAsync(configId) ??
|
||||
throw new KeyNotFoundException($"Config with ID '{configId}' not found");
|
||||
var variable = config.Variables.FirstOrDefault(v => v.Name.Equals(variableName, StringComparison.OrdinalIgnoreCase)) ??
|
||||
throw new KeyNotFoundException($"Variable '{variableName}' not found in config");
|
||||
config.Variables.Remove(variable);
|
||||
config.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Validate
|
||||
var validationResult = ConfigValidator.ValidateConfig(config);
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
throw new ArgumentException($"Invalid config: {string.Join("; ", validationResult.Errors)}");
|
||||
}
|
||||
|
||||
// Save config file (includes metadata)
|
||||
await SaveConfigFileAsync(config);
|
||||
|
||||
// Trigger event
|
||||
OnConfigChanged(config, ConfigChangeType.VariableRemoved, variableName);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// PRIVATE HELPER METHODS
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Load config file từ StorageManager (format mới với metadata)
|
||||
/// </summary>
|
||||
private async Task<ConfigFile?> LoadConfigFileAsync(string configType)
|
||||
{
|
||||
try
|
||||
{
|
||||
// objectName should be {configType}.config so StorageManager finds {configType}.config.json
|
||||
var objectName = $"{configType}.config";
|
||||
var exists = await _storageManager.ExistsAsync(ConfigPath, objectName, CancellationToken.None);
|
||||
if (!exists)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get file stream from StorageManager (works with both local and remote storage)
|
||||
using var stream = await _storageManager.GetFileAsync(ConfigPath, objectName, CancellationToken.None);
|
||||
|
||||
// Parse config file directly from stream (format mới với metadata)
|
||||
return JsonConfigParser.ParseConfigFile(stream);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Config file corrupted/invalid format - treat as not found
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save config file vào StorageManager (format mới với metadata)
|
||||
/// </summary>
|
||||
private async Task SaveConfigFileAsync(ConfigFile config)
|
||||
{
|
||||
// Serialize với format mới (metadata + variables)
|
||||
var json = JsonConfigParser.SerializeConfigFile(config);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
|
||||
// objectName should be {configType}.config so StorageManager adds .json to make {configType}.config.json
|
||||
var objectName = $"{config.ConfigType}.config";
|
||||
|
||||
using var stream = new MemoryStream(bytes);
|
||||
await _storageManager.UploadAsync(
|
||||
ConfigPath,
|
||||
objectName,
|
||||
stream,
|
||||
stream.Length,
|
||||
"application/json",
|
||||
CancellationToken.None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using RobotNet10.CustomConfiguration.Helpers;
|
||||
using RobotNet10.CustomConfiguration.Models;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace RobotNet10.CustomConfiguration.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Helper methods cho quản lý metadata (load từ config files)
|
||||
/// </summary>
|
||||
public partial class ConfigService
|
||||
{
|
||||
/// <summary>
|
||||
/// Load tất cả config files và trả về metadata
|
||||
/// </summary>
|
||||
private async Task<List<ConfigFileMetadata>> LoadAllMetadataAsync()
|
||||
{
|
||||
var metadataList = new List<ConfigFileMetadata>();
|
||||
|
||||
try
|
||||
{
|
||||
// List all files in configs directory
|
||||
var files = await _storageManager.ListAsync(ConfigPath, recursive: false, CancellationToken.None);
|
||||
|
||||
// Filter only .config.json files
|
||||
var configFiles = files.Where(f => f.EndsWith(ConfigFileExtension, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
foreach (var fileName in configFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Extract configType from filename: {configType}.config.json
|
||||
var configType = fileName.Replace(ConfigFileExtension, "", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Load config file to get metadata
|
||||
var config = await LoadConfigFileAsync(configType);
|
||||
if (config != null)
|
||||
{
|
||||
metadataList.Add(new ConfigFileMetadata
|
||||
{
|
||||
Id = config.Id,
|
||||
ConfigType = config.ConfigType,
|
||||
CreatedAt = config.CreatedAt,
|
||||
UpdatedAt = config.UpdatedAt,
|
||||
Description = config.Description
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Skip corrupted config files
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Storage unavailable - return what we have so far
|
||||
return metadataList;
|
||||
}
|
||||
|
||||
return metadataList;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get metadata by ConfigType (load từ config file)
|
||||
/// </summary>
|
||||
private async Task<ConfigFileMetadata?> GetMetadataByTypeAsync(string configType)
|
||||
{
|
||||
var config = await LoadConfigFileAsync(configType);
|
||||
if (config == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ConfigFileMetadata
|
||||
{
|
||||
Id = config.Id,
|
||||
ConfigType = config.ConfigType,
|
||||
CreatedAt = config.CreatedAt,
|
||||
UpdatedAt = config.UpdatedAt,
|
||||
Description = config.Description
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using RobotNet10.CustomConfiguration.Events;
|
||||
using RobotNet10.CustomConfiguration.Models;
|
||||
using RobotNet10.StorageManager;
|
||||
|
||||
namespace RobotNet10.CustomConfiguration.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service implementation cho quản lý configuration files
|
||||
/// Sử dụng StorageManager để lưu trữ file JSON
|
||||
/// </summary>
|
||||
public partial class ConfigService : IConfigService, IDisposable
|
||||
{
|
||||
private readonly StorageManager.StorageManager _storageManager;
|
||||
private const string ConfigPath = "configs"; // Path trong StorageManager
|
||||
private const string ConfigFileExtension = ".config.json"; // Extension cho config files
|
||||
private const string StorageConfigsKey = "StorageConfigs"; // Named options key cho IOptionsMonitor
|
||||
|
||||
// ==========================================
|
||||
// EVENTS
|
||||
// ==========================================
|
||||
|
||||
public event EventHandler<ConfigChangedEventArgs>? ConfigChanged;
|
||||
|
||||
public ConfigService(IOptionsMonitor<StorageConfig> optionsSnapshot)
|
||||
{
|
||||
var config = optionsSnapshot.Get(StorageConfigsKey);
|
||||
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
_storageManager = new StorageManager.StorageManager(config);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trigger ConfigChanged event
|
||||
/// </summary>
|
||||
protected virtual void OnConfigChanged(ConfigFile config, ConfigChangeType changeType, string? variableName = null)
|
||||
{
|
||||
var args = new ConfigChangedEventArgs
|
||||
{
|
||||
ConfigType = config.ConfigType,
|
||||
ConfigId = config.Id,
|
||||
ChangeType = changeType,
|
||||
VariableName = variableName
|
||||
};
|
||||
|
||||
ConfigChanged?.Invoke(this, args);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_storageManager?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
using RobotNet10.CustomConfiguration.Events;
|
||||
using RobotNet10.CustomConfiguration.Models;
|
||||
|
||||
namespace RobotNet10.CustomConfiguration.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service interface cho quản lý và truy vấn configuration variables
|
||||
/// </summary>
|
||||
public interface IConfigManager
|
||||
{
|
||||
// ==========================================
|
||||
// EVENTS
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Event được trigger khi có thay đổi trong config (tạo mới, cập nhật, xóa, hoặc thay đổi variables)
|
||||
/// </summary>
|
||||
event EventHandler<ConfigChangedEventArgs>? ConfigChanged;
|
||||
|
||||
// ==========================================
|
||||
// CONFIG TYPE OPERATIONS
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Lấy config theo ConfigType (đầy đủ thông tin bao gồm variables)
|
||||
/// </summary>
|
||||
Task<ConfigFile?> GetConfigByTypeAsync(string configType);
|
||||
|
||||
/// <summary>
|
||||
/// Kiểm tra ConfigType có tồn tại không
|
||||
/// </summary>
|
||||
Task<bool> ConfigTypeExistsAsync(string configType);
|
||||
|
||||
// ==========================================
|
||||
// VARIABLE EXISTENCE CHECK
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Kiểm tra variable có tồn tại không (theo ConfigType)
|
||||
/// </summary>
|
||||
Task<bool> VariableExistsAsync(string configType, string variableName);
|
||||
|
||||
/// <summary>
|
||||
/// Kiểm tra variable có tồn tại không (theo ConfigId)
|
||||
/// </summary>
|
||||
Task<bool> VariableExistsAsync(Guid configId, string variableName);
|
||||
|
||||
// ==========================================
|
||||
// GET VARIABLE VALUE
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Lấy giá trị (Value) của variable (theo ConfigType)
|
||||
/// </summary>
|
||||
Task<object?> GetVariableValueAsync(string configType, string variableName);
|
||||
|
||||
/// <summary>
|
||||
/// Lấy giá trị (Value) của variable (theo ConfigId)
|
||||
/// </summary>
|
||||
Task<object?> GetVariableValueAsync(Guid configId, string variableName);
|
||||
|
||||
// ==========================================
|
||||
// GET VARIABLE(S)
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Lấy một variable cụ thể (theo ConfigType)
|
||||
/// </summary>
|
||||
Task<ConfigVariable?> GetVariableAsync(string configType, string variableName);
|
||||
|
||||
/// <summary>
|
||||
/// Lấy tất cả variables của một ConfigType
|
||||
/// </summary>
|
||||
Task<List<ConfigVariable>> GetVariablesAsync(string configType);
|
||||
|
||||
/// <summary>
|
||||
/// Lấy một variable cụ thể (theo ConfigId)
|
||||
/// </summary>
|
||||
Task<ConfigVariable?> GetVariableAsync(Guid configId, string variableName);
|
||||
|
||||
/// <summary>
|
||||
/// Lấy tất cả variables của một ConfigId
|
||||
/// </summary>
|
||||
Task<List<ConfigVariable>> GetVariablesAsync(Guid configId);
|
||||
|
||||
// ==========================================
|
||||
// GET VARIABLE TYPE
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Lấy kiểu dữ liệu của variable dưới dạng string (theo ConfigType)
|
||||
/// Trả về: "string", "int", "double", "bool", "object", "array", "enum"
|
||||
/// </summary>
|
||||
Task<string?> GetVariableTypeAsync(string configType, string variableName);
|
||||
|
||||
/// <summary>
|
||||
/// Lấy kiểu dữ liệu của variable dưới dạng string (theo ConfigId)
|
||||
/// Trả về: "string", "int", "double", "bool", "object", "array", "enum"
|
||||
/// </summary>
|
||||
Task<string?> GetVariableTypeAsync(Guid configId, string variableName);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using RobotNet10.CustomConfiguration.Events;
|
||||
using RobotNet10.CustomConfiguration.Models;
|
||||
|
||||
namespace RobotNet10.CustomConfiguration.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service interface cho quản lý configuration files
|
||||
/// </summary>
|
||||
public interface IConfigService
|
||||
{
|
||||
// ==========================================
|
||||
// EVENTS
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Event được trigger khi có thay đổi trong config (tạo mới, cập nhật, xóa, hoặc thay đổi variables)
|
||||
/// </summary>
|
||||
event EventHandler<ConfigChangedEventArgs>? ConfigChanged;
|
||||
|
||||
// ==========================================
|
||||
// CONFIG FILE MANAGEMENT
|
||||
// ==========================================
|
||||
|
||||
Task<ConfigFile> CreateConfigAsync(string configType, List<ConfigVariable> variables, string? description = null);
|
||||
Task<ConfigFile?> GetConfigByIdAsync(Guid id);
|
||||
Task<ConfigFile?> GetConfigByTypeAsync(string configType);
|
||||
Task<List<ConfigFileMetadata>> GetAllConfigsAsync();
|
||||
Task<List<ConfigFileMetadata>> SearchConfigsAsync(string? searchText);
|
||||
Task<ConfigFile> UpdateConfigAsync(Guid id, List<ConfigVariable>? variables = null, string? description = null);
|
||||
Task<bool> DeleteConfigAsync(Guid id);
|
||||
Task<bool> ConfigTypeExistsAsync(string configType);
|
||||
|
||||
// ==========================================
|
||||
// IMPORT/EXPORT
|
||||
// ==========================================
|
||||
|
||||
Task<ConfigFile> ImportConfigFromJsonAsync(Stream jsonStream, string configType);
|
||||
Task<ConfigFile> ImportConfigFromJsonAsync(Stream jsonStream, string configType, string? description);
|
||||
Task<Stream> ExportConfigToJsonAsync(Guid id);
|
||||
Task<Stream> ExportConfigToJsonAsync(ConfigFile config);
|
||||
|
||||
// ==========================================
|
||||
// VARIABLE MANAGEMENT
|
||||
// ==========================================
|
||||
|
||||
Task<ConfigFile> UpdateVariableAsync(Guid configId, string variableName, object? value);
|
||||
Task<ConfigFile> AddVariableAsync(Guid configId, ConfigVariable variable);
|
||||
Task<ConfigFile> RemoveVariableAsync(Guid configId, string variableName);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
using RobotNet10.CustomConfiguration.Models;
|
||||
using System.Globalization;
|
||||
|
||||
namespace RobotNet10.CustomConfiguration.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// Validator cho config files và variables
|
||||
/// </summary>
|
||||
public static class ConfigValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Validate một config file
|
||||
/// </summary>
|
||||
public static ValidationResult ValidateConfig(ConfigFile config)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
// Validate ConfigType
|
||||
if (string.IsNullOrWhiteSpace(config.ConfigType))
|
||||
{
|
||||
errors.Add("ConfigType cannot be empty");
|
||||
}
|
||||
|
||||
// Validate Variables
|
||||
if (config.Variables != null && config.Variables.Count > 0)
|
||||
{
|
||||
foreach (var variable in config.Variables)
|
||||
{
|
||||
var variableErrors = ValidateVariable(variable);
|
||||
errors.AddRange(variableErrors);
|
||||
}
|
||||
}
|
||||
|
||||
return new ValidationResult
|
||||
{
|
||||
IsValid = errors.Count == 0,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate một variable
|
||||
/// </summary>
|
||||
public static List<string> ValidateVariable(ConfigVariable variable)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
// Validate Name
|
||||
if (string.IsNullOrWhiteSpace(variable.Name))
|
||||
{
|
||||
errors.Add("Variable name cannot be empty");
|
||||
}
|
||||
|
||||
// Validate Type
|
||||
if (!Enum.IsDefined(variable.Type))
|
||||
{
|
||||
errors.Add($"Invalid variable type: {variable.Type}");
|
||||
}
|
||||
|
||||
// Validate Value theo Type
|
||||
if (variable.Value == null)
|
||||
{
|
||||
errors.Add($"Variable '{variable.Name}' value cannot be null");
|
||||
}
|
||||
else
|
||||
{
|
||||
var valueErrors = ValidateValueByType(variable.Name, variable.Type, variable.Value, variable.Min, variable.Max, variable);
|
||||
errors.AddRange(valueErrors);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate value theo type và Min/Max constraints
|
||||
/// </summary>
|
||||
private static List<string> ValidateValueByType(string variableName, ConfigVariableType type, object value, double? min, double? max, ConfigVariable? variable = null)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case ConfigVariableType.Int:
|
||||
// Try parse
|
||||
if (int.TryParse(value.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedInt))
|
||||
{
|
||||
if (min.HasValue && parsedInt < min.Value)
|
||||
errors.Add($"Variable '{variableName}' value {parsedInt} is less than Min {min.Value}");
|
||||
if (max.HasValue && parsedInt > max.Value)
|
||||
errors.Add($"Variable '{variableName}' value {parsedInt} is greater than Max {max.Value}");
|
||||
}
|
||||
else
|
||||
{
|
||||
errors.Add($"Variable '{variableName}' must be an integer");
|
||||
}
|
||||
break;
|
||||
|
||||
case ConfigVariableType.Double:
|
||||
// Try parse
|
||||
if (double.TryParse(value.ToString(), NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var parsedDouble))
|
||||
{
|
||||
if (min.HasValue && parsedDouble < min.Value)
|
||||
errors.Add($"Variable '{variableName}' value {parsedDouble} is less than Min {min.Value}");
|
||||
if (max.HasValue && parsedDouble > max.Value)
|
||||
errors.Add($"Variable '{variableName}' value {parsedDouble} is greater than Max {max.Value}");
|
||||
}
|
||||
else
|
||||
{
|
||||
errors.Add($"Variable '{variableName}' must be a double");
|
||||
}
|
||||
break;
|
||||
|
||||
case ConfigVariableType.Bool:
|
||||
if (!bool.TryParse(value.ToString(), out _))
|
||||
{
|
||||
errors.Add($"Variable '{variableName}' must be a boolean");
|
||||
}
|
||||
break;
|
||||
|
||||
case ConfigVariableType.Object:
|
||||
// Object có thể là Dictionary<string, object> hoặc JsonElement
|
||||
if (value is not Dictionary<string, object> &&
|
||||
value is not System.Text.Json.JsonElement)
|
||||
{
|
||||
// Try parse as JSON object
|
||||
try
|
||||
{
|
||||
var jsonString = value.ToString();
|
||||
if (jsonString != null)
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(jsonString);
|
||||
if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Object)
|
||||
{
|
||||
errors.Add($"Variable '{variableName}' must be a JSON object");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
errors.Add($"Variable '{variableName}' must be a valid JSON object");
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case ConfigVariableType.Array:
|
||||
// Array có thể là List<object> hoặc JsonElement
|
||||
if (value is not List<object> &&
|
||||
value is not System.Text.Json.JsonElement)
|
||||
{
|
||||
// Try parse as JSON array
|
||||
try
|
||||
{
|
||||
var jsonString = value.ToString();
|
||||
if (jsonString != null)
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(jsonString);
|
||||
if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Array)
|
||||
{
|
||||
errors.Add($"Variable '{variableName}' must be a JSON array");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
errors.Add($"Variable '{variableName}' must be a valid JSON array");
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case ConfigVariableType.Enum:
|
||||
if (variable == null || variable.EnumValues == null || variable.EnumValues.Count == 0)
|
||||
{
|
||||
errors.Add($"Variable '{variableName}' of type Enum must have EnumValues defined");
|
||||
}
|
||||
else
|
||||
{
|
||||
var valueStr = value?.ToString();
|
||||
if (string.IsNullOrEmpty(valueStr) || !variable.EnumValues.Contains(valueStr))
|
||||
{
|
||||
errors.Add($"Variable '{variableName}' value '{valueStr}' is not in allowed enum values: {string.Join(", ", variable.EnumValues)}");
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kết quả validation
|
||||
/// </summary>
|
||||
public class ValidationResult
|
||||
{
|
||||
public bool IsValid { get; set; }
|
||||
public List<string> Errors { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
using RobotNet10.GlobalPathPlanner.Model;
|
||||
using RobotNet10.GlobalPathPlanner.Space;
|
||||
|
||||
namespace RobotNet10.GlobalPathPlanner.AStar;
|
||||
|
||||
public class AStarPlanner(List<GlobalNode> Nodes, List<GlobalEdge> Edges)
|
||||
{
|
||||
private GlobalEdge[]? GetClosesEdges(GlobalNode nodeRef, double limitDistance)
|
||||
{
|
||||
double minDistance = double.MaxValue;
|
||||
List<GlobalEdge> edgesResult = [];
|
||||
foreach (var edge in Edges)
|
||||
{
|
||||
var startNode = Nodes.FirstOrDefault(node => node.Id == edge.StartNodeId);
|
||||
var endNode = Nodes.FirstOrDefault(node => node.Id == edge.EndNodeId);
|
||||
if (startNode is null || endNode is null) continue;
|
||||
|
||||
var distance = MathExtensions.DistanceToEdge(nodeRef, startNode, endNode, edge);
|
||||
if (distance < minDistance)
|
||||
{
|
||||
minDistance = distance;
|
||||
edgesResult = [edge];
|
||||
var reverseEdge = Edges.FirstOrDefault(e => e.EndNodeId == startNode.Id && e.StartNodeId == endNode.Id);
|
||||
if (reverseEdge != null) edgesResult = [.. edgesResult, reverseEdge];
|
||||
}
|
||||
}
|
||||
if (minDistance <= limitDistance) return [.. edgesResult];
|
||||
else return null;
|
||||
}
|
||||
|
||||
private GlobalNode? GetOnNode(double x, double y, double limitDistance)
|
||||
{
|
||||
KDTree KDTree = new(Nodes);
|
||||
return KDTree.FindNearest(x, y, limitDistance);
|
||||
}
|
||||
|
||||
private List<GlobalNode> GetNegativeNodes(Guid nodeId)
|
||||
{
|
||||
var node = Nodes.FirstOrDefault(p => p.Id == nodeId);
|
||||
if (node is null) return [];
|
||||
|
||||
var ListNegativeNodes = new List<GlobalNode>();
|
||||
var ListPaths = Edges.Where(p => p.StartNodeId == nodeId);
|
||||
foreach (var path in ListPaths)
|
||||
{
|
||||
var negativeNode = Nodes.FirstOrDefault(p => p.Id == path.EndNodeId);
|
||||
if (negativeNode is not null) ListNegativeNodes.Add(negativeNode);
|
||||
}
|
||||
return ListNegativeNodes;
|
||||
}
|
||||
|
||||
private double GetNegativeCost(AStarNode currenNode, AStarNode negativeNode)
|
||||
{
|
||||
var negativeEdges = Edges.Where(e => e.StartNodeId == currenNode.Id && e.EndNodeId == negativeNode.Id || e.StartNodeId == negativeNode.Id && e.EndNodeId == currenNode.Id).ToList();
|
||||
double minDistance = double.MaxValue;
|
||||
foreach (var edge in negativeEdges)
|
||||
{
|
||||
var startNode = Nodes.FirstOrDefault(n => n.Id == edge.StartNodeId);
|
||||
var endNode = Nodes.FirstOrDefault(n => n.Id == edge.EndNodeId);
|
||||
if (startNode is null || endNode is null) return 0;
|
||||
var distance = MathExtensions.GetEdgeLength(startNode, endNode, edge);
|
||||
if (distance < minDistance) minDistance = distance;
|
||||
}
|
||||
return minDistance != double.MaxValue ? minDistance : 0;
|
||||
}
|
||||
|
||||
private List<AStarNode> GetNegativeAStarNodes(AStarNode nodeCurrent, GlobalNode endNode)
|
||||
{
|
||||
var possiblePointNegative = new List<AStarNode>();
|
||||
foreach (var nodeNegative in nodeCurrent.NegativeNodes)
|
||||
{
|
||||
nodeNegative.Parent = nodeCurrent;
|
||||
var ListNodesNegative = GetNegativeNodes(nodeNegative.Id);
|
||||
foreach (var item in ListNodesNegative)
|
||||
{
|
||||
nodeNegative.NegativeNodes.Add(new AStarNode()
|
||||
{
|
||||
Id = item.Id,
|
||||
X = item.X,
|
||||
Y = item.Y,
|
||||
Name = item.Name,
|
||||
});
|
||||
}
|
||||
var cost = GetNegativeCost(nodeCurrent, nodeNegative);
|
||||
nodeNegative.Cost = (cost > 0 ? cost : Math.Sqrt(Math.Pow(nodeCurrent.X - nodeNegative.X, 2) + Math.Pow(nodeCurrent.Y - nodeNegative.Y, 2))) + nodeCurrent.Cost;
|
||||
nodeNegative.Heuristic = Math.Abs(endNode.X - nodeNegative.X) + Math.Abs(endNode.Y - nodeNegative.Y);
|
||||
possiblePointNegative.Add(nodeNegative);
|
||||
}
|
||||
return possiblePointNegative;
|
||||
}
|
||||
|
||||
private List<AStarNode> Find(AStarNode startNode, GlobalNode endNode, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var activeNodes = new PriorityQueue<AStarNode>((a, b) => a.TotalCost.CompareTo(b.TotalCost));
|
||||
var visitedNodes = new HashSet<AStarNode>();
|
||||
List<AStarNode> Path = [];
|
||||
activeNodes.Enqueue(startNode);
|
||||
|
||||
while (activeNodes.Count != 0 && (!cancellationToken.HasValue || !cancellationToken.Value.IsCancellationRequested))
|
||||
{
|
||||
var checkNode = activeNodes.Dequeue();
|
||||
if (checkNode.Id == endNode.Id)
|
||||
{
|
||||
var node = checkNode;
|
||||
while (node != null)
|
||||
{
|
||||
if (cancellationToken.HasValue && cancellationToken.Value.IsCancellationRequested) return [];
|
||||
Path.Add(node);
|
||||
node = node.Parent;
|
||||
}
|
||||
return Path;
|
||||
}
|
||||
|
||||
visitedNodes.Add(checkNode);
|
||||
|
||||
var ListNodeNegative = GetNegativeAStarNodes(checkNode, endNode);
|
||||
foreach (var node in ListNodeNegative)
|
||||
{
|
||||
if (visitedNodes.TryGetValue(node, out AStarNode? value) && value is not null)
|
||||
{
|
||||
if (value.TotalCost > node.TotalCost)
|
||||
{
|
||||
visitedNodes.Remove(value);
|
||||
activeNodes.Enqueue(node);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
var activeNode = activeNodes.Items.FirstOrDefault(n => n.Id == node.Id);
|
||||
if (activeNode is not null && activeNode.TotalCost > node.TotalCost)
|
||||
{
|
||||
activeNodes.Items.Remove(activeNode);
|
||||
activeNodes.Enqueue(node);
|
||||
}
|
||||
else if (activeNode is null)
|
||||
{
|
||||
activeNodes.Enqueue(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public (GlobalNode[] pathNodes, GlobalEdge? closeEdges) Planning(double x, double y, GlobalNode goal, double maxDistanceToEdge, double maxDistanceToNode, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
var Path = new List<GlobalNode>();
|
||||
|
||||
AStarNode RobotNode = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
X = x,
|
||||
Y = y,
|
||||
Name = "RobotCurrentNode",
|
||||
};
|
||||
GlobalEdge[] closesEdges = [];
|
||||
var closesNode = GetOnNode(x, y, maxDistanceToNode);
|
||||
if (closesNode is not null)
|
||||
{
|
||||
if (closesNode.Id == goal.Id) return ([goal], null);
|
||||
RobotNode.Name = closesNode.Name;
|
||||
RobotNode.Id = closesNode.Id;
|
||||
RobotNode.X = closesNode.X;
|
||||
RobotNode.Y = closesNode.Y;
|
||||
|
||||
foreach (var negativeNode in GetNegativeNodes(RobotNode.Id))
|
||||
{
|
||||
var cost = GetNegativeCost(RobotNode, new() { Id = negativeNode.Id, X = negativeNode.X, Y = negativeNode.Y });
|
||||
RobotNode.NegativeNodes.Add(new()
|
||||
{
|
||||
Id = negativeNode.Id,
|
||||
X = negativeNode.X,
|
||||
Y = negativeNode.Y,
|
||||
Name = negativeNode.Name,
|
||||
Cost = cost > 0 ? cost : Math.Sqrt(Math.Pow(RobotNode.X - negativeNode.X, 2) + Math.Pow(RobotNode.Y - negativeNode.Y, 2)),
|
||||
Heuristic = Math.Abs(goal.X - negativeNode.X) + Math.Abs(goal.Y - negativeNode.Y),
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
closesEdges = GetClosesEdges(new() { X = x, Y = y }, maxDistanceToEdge) ?? [];
|
||||
if (closesEdges is null || closesEdges.Length == 0) throw new Exception("The robot is too far from the route");
|
||||
|
||||
var edgeToGoal = closesEdges.FirstOrDefault(e => e.EndNodeId == goal.Id);
|
||||
if (edgeToGoal != null)
|
||||
{
|
||||
var endNode = Nodes.FirstOrDefault(n => n.Id == edgeToGoal.EndNodeId);
|
||||
if (endNode != null) return ([new() {
|
||||
Id = RobotNode.Id,
|
||||
X = RobotNode.X,
|
||||
Y = RobotNode.Y,
|
||||
Name = RobotNode.Name,
|
||||
MapId = endNode.MapId,
|
||||
}, endNode], edgeToGoal);
|
||||
}
|
||||
foreach (var edge in closesEdges)
|
||||
{
|
||||
var node = Nodes.FirstOrDefault(n => n.Id == edge.EndNodeId);
|
||||
if (node == null) continue;
|
||||
RobotNode.NegativeNodes.Add(new()
|
||||
{
|
||||
Id = node.Id,
|
||||
X = node.X,
|
||||
Y = node.Y,
|
||||
Name = node.Name,
|
||||
Cost = Math.Sqrt(Math.Pow(RobotNode.X - node.X, 2) + Math.Pow(RobotNode.Y - node.Y, 2)),
|
||||
Heuristic = Math.Abs(goal.X - node.X) + Math.Abs(goal.Y - node.Y),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (RobotNode.NegativeNodes.Count < 1) throw new Exception($"The path to {goal.Name} - {goal.Id} does not exist from [{x}, {y}]");
|
||||
|
||||
var path = Find(RobotNode, goal, cancellationToken);
|
||||
if (cancellationToken is not null && cancellationToken.Value.IsCancellationRequested) throw new OperationCanceledException();
|
||||
if (path is null || path.Count < 1) throw new Exception($"The path to {goal.Name} - {goal.Id} does not exist from [{x}, {y}]");
|
||||
path.Reverse();
|
||||
foreach (var node in path)
|
||||
{
|
||||
if (node.Name == "RobotCurrentNode")
|
||||
{
|
||||
Path.Add(new()
|
||||
{
|
||||
Id = RobotNode.Id,
|
||||
Name = RobotNode.Name,
|
||||
X = RobotNode.X,
|
||||
Y = RobotNode.Y,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
var nodedb = Nodes.FirstOrDefault(p => p.Id == node.Id) ?? throw new Exception($"Map data error: Node {node.Id} does not exist in the map");
|
||||
Path.Add(nodedb);
|
||||
}
|
||||
var startEdge = path.Count > 1 ? Edges.FirstOrDefault(e => e.StartNodeId == path[0].Id && e.EndNodeId == path[1].Id) : null;
|
||||
if (startEdge is null && closesEdges is not null && closesEdges.Length > 0)
|
||||
{
|
||||
startEdge = path.Count > 1 ? closesEdges.FirstOrDefault(e => e.EndNodeId == path[1].Id) : null;
|
||||
}
|
||||
Console.WriteLine($"AStar Planner Found: {string.Join(",", Path.Select(n => $"({n.X} - {n.Y})"))}");
|
||||
return ([.. Path], startEdge);
|
||||
}
|
||||
|
||||
public (GlobalNode[] pathNodes, GlobalEdge? closeEdges) Planning(GlobalNode startNode, GlobalNode goal, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
var Path = new List<GlobalNode>();
|
||||
var currentNode = new AStarNode
|
||||
{
|
||||
Id = startNode.Id,
|
||||
X = startNode.X,
|
||||
Y = startNode.Y,
|
||||
NegativeNodes = [..GetNegativeNodes(startNode.Id).Select(n => new AStarNode
|
||||
{
|
||||
Id = n.Id,
|
||||
Name = n.Name,
|
||||
X = n.X,
|
||||
Y = n.Y,
|
||||
Cost = Math.Sqrt(Math.Pow(startNode.X - n.X, 2) + Math.Pow(startNode.Y - n.Y, 2)),
|
||||
Heuristic = Math.Abs(goal.X - n.X) + Math.Abs(goal.Y - n.Y),
|
||||
})],
|
||||
};
|
||||
var path = Find(currentNode, goal, cancellationToken);
|
||||
if (cancellationToken is not null && cancellationToken.Value.IsCancellationRequested) throw new OperationCanceledException();
|
||||
if (path is null || path.Count < 1) throw new Exception($"The path to {goal.Name} - {goal.Id} does not exist from [{startNode.Name} - {startNode.Id}]");
|
||||
path.Reverse();
|
||||
foreach (var node in path)
|
||||
{
|
||||
var nodedb = Nodes.FirstOrDefault(p => p.Id == node.Id) ?? throw new Exception($"Map data error: Node {node.Id} does not exist in the map");
|
||||
Path.Add(nodedb);
|
||||
}
|
||||
var startEdge = path.Count > 1 ? Edges.FirstOrDefault(e => e.StartNodeId == path[0].Id && e.EndNodeId == path[1].Id) : null;
|
||||
return ([.. Path], startEdge);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
using RobotNet10.GlobalPathPlanner.AStar;
|
||||
using RobotNet10.GlobalPathPlanner.Model;
|
||||
|
||||
namespace RobotNet10.GlobalPathPlanner.Differential;
|
||||
|
||||
public class DifferentialPlanner : IPathPlanner
|
||||
{
|
||||
private List<GlobalNode> Nodes = [];
|
||||
private List<GlobalEdge> Edges = [];
|
||||
private const double Ratio = 0.1;
|
||||
|
||||
private PathPlannerOptions Options = new()
|
||||
{
|
||||
LimitDistanceToEdge = 2,
|
||||
LimitDistanceToNode = 0.3,
|
||||
ResolutionSplit = 0.1,
|
||||
ChangeOrientationAngle = 89
|
||||
};
|
||||
public void SetData(GlobalNode[] nodes, GlobalEdge[] edges)
|
||||
{
|
||||
Nodes = [.. nodes];
|
||||
Edges = [.. edges];
|
||||
}
|
||||
|
||||
public void SetOptions(PathPlannerOptions options)
|
||||
{
|
||||
Options = options;
|
||||
}
|
||||
|
||||
public (GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanning(double x, double y, double theta, Guid goalId, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
var goal = Nodes.FirstOrDefault(n => n.Id == goalId) ?? throw new Exception($"Destination {goalId} does not exist in the map");
|
||||
|
||||
using CancellationTokenSource cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken ?? CancellationToken.None);
|
||||
if(Options.TimeOut != null && Options.TimeOut.HasValue) cancellation.CancelAfter(Options.TimeOut.Value);
|
||||
|
||||
try
|
||||
{
|
||||
var AStarPathPlanner = new AStarPlanner(Nodes, Edges);
|
||||
(var path, var closesEdge) = AStarPathPlanner.Planning(x, y,
|
||||
new GlobalNode() { Id = goal.Id, Name = goal.Name, X = goal.X, Y = goal.Y },
|
||||
Options.LimitDistanceToEdge,
|
||||
Options.LimitDistanceToNode,
|
||||
cancellation.Token);
|
||||
|
||||
if (path is null || path.Length < 1) throw new Exception($"The path to {goal.Name} - {goal.Id} does not exist from [{x}, {y}, {theta}]");
|
||||
if (path.Length == 1) return (path, []);
|
||||
|
||||
var edgeplannings = MathExtensions.GetEdgesPlanning([.. path], [.. Edges], closesEdge);
|
||||
|
||||
Orientation CurrenDirection = MathExtensions.GetOrientationStart(path[0], path[1], edgeplannings[0], theta, Ratio, Options.ChangeOrientationAngle);
|
||||
var FinalDirection = MathExtensions.GetOrientations(CurrenDirection, path, edgeplannings, Ratio, Options.ChangeOrientationAngle);
|
||||
for (int i = 0; i < path.Length; i++)
|
||||
{
|
||||
path[i] = new GlobalNode
|
||||
{
|
||||
Id = path[i].Id,
|
||||
X = path[i].X,
|
||||
Y = path[i].Y,
|
||||
Name = path[i].Name,
|
||||
MapId = path[i].MapId,
|
||||
Orientation = FinalDirection[i]
|
||||
};
|
||||
}
|
||||
|
||||
return ([.. path], [.. edgeplannings]);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (cancellationToken is not null && cancellationToken.Value.IsCancellationRequested) throw new OperationCanceledException();
|
||||
else throw new TimeoutException();
|
||||
}
|
||||
}
|
||||
|
||||
public (GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanningWithStartDirection(double x, double y, double theta, Guid goalId, Orientation startDiretion = Orientation.NONE, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
var basicPath = PathPlanning(x, y, theta, goalId, cancellationToken);
|
||||
if (basicPath.Nodes.Length < 1) return basicPath;
|
||||
|
||||
if (startDiretion == basicPath.Nodes[0].Orientation || startDiretion == Orientation.NONE) return basicPath;
|
||||
foreach (var node in basicPath.Nodes)
|
||||
{
|
||||
node.Orientation = node.Orientation == Orientation.FORWARD ? Orientation.BACKWARD : Orientation.FORWARD;
|
||||
}
|
||||
|
||||
return basicPath;
|
||||
}
|
||||
|
||||
public (GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanningWithFinalDirection(double x, double y, double theta, Guid goalId, Orientation goalDirection = Orientation.NONE, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
var basicPath = PathPlanning(x, y, theta, goalId, cancellationToken);
|
||||
if (basicPath.Nodes.Length < 1) return basicPath;
|
||||
|
||||
if (goalDirection == basicPath.Nodes[^1].Orientation || goalDirection == Orientation.NONE) return basicPath;
|
||||
foreach (var node in basicPath.Nodes)
|
||||
{
|
||||
node.Orientation = node.Orientation == Orientation.FORWARD ? Orientation.BACKWARD : Orientation.FORWARD;
|
||||
}
|
||||
|
||||
return basicPath;
|
||||
}
|
||||
|
||||
public (GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanningWithAngle(double x, double y, double theta, Guid goalId, double goalAngle, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
var basicPath = PathPlanning(x, y, theta, goalId, cancellationToken);
|
||||
if (basicPath.Nodes.Length < 1) return basicPath;
|
||||
|
||||
Orientation goalDirection = MathExtensions.GetOrientationEnd(basicPath.Nodes[^1], basicPath.Nodes[^2], basicPath.Edges[^1], goalAngle, Ratio, Options.ChangeOrientationAngle);
|
||||
if (goalDirection == basicPath.Nodes[^1].Orientation) return basicPath;
|
||||
foreach (var node in basicPath.Nodes)
|
||||
{
|
||||
node.Orientation = node.Orientation == Orientation.FORWARD ? Orientation.BACKWARD : Orientation.FORWARD;
|
||||
}
|
||||
|
||||
return basicPath;
|
||||
}
|
||||
|
||||
public (GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanning(Guid startNodeId, double theta, Guid goalId, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
var goal = Nodes.FirstOrDefault(n => n.Id == goalId) ?? throw new Exception($"Destination {goalId} does not exist in the map");
|
||||
var startNode = Nodes.FirstOrDefault(n => n.Id == startNodeId) ?? throw new Exception($"Starting node {startNodeId} does not exist in the map");
|
||||
|
||||
using CancellationTokenSource cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken ?? CancellationToken.None);
|
||||
if (Options.TimeOut != null && Options.TimeOut.HasValue) cancellation.CancelAfter(Options.TimeOut.Value);
|
||||
|
||||
try
|
||||
{
|
||||
var AStarPathPlanner = new AStarPlanner(Nodes, Edges);
|
||||
(var path, var closesEdge) = AStarPathPlanner.Planning(startNode, goal, cancellation.Token);
|
||||
|
||||
if (path is null || path.Length < 1) throw new Exception($"The path to {goal.Name} - {goal.Id} does not exist from [{startNode.Name} - {startNode.Id}]");
|
||||
if (path.Length == 1) return (path, []);
|
||||
|
||||
var edgeplannings = MathExtensions.GetEdgesPlanning([.. path], [.. Edges], closesEdge);
|
||||
|
||||
Orientation CurrenDirection = MathExtensions.GetOrientationStart(path[0], path[1], edgeplannings[0], theta, Ratio, Options.ChangeOrientationAngle);
|
||||
var FinalDirection = MathExtensions.GetOrientations(CurrenDirection, path, edgeplannings, Ratio, Options.ChangeOrientationAngle);
|
||||
for (int i = 0; i < path.Length; i++)
|
||||
{
|
||||
path[i] = new GlobalNode
|
||||
{
|
||||
Id = path[i].Id,
|
||||
X = path[i].X,
|
||||
Y = path[i].Y,
|
||||
Name = path[i].Name,
|
||||
MapId = path[i].MapId,
|
||||
Orientation = FinalDirection[i]
|
||||
};
|
||||
}
|
||||
|
||||
return ([.. path], [.. edgeplannings]);
|
||||
}
|
||||
catch(OperationCanceledException)
|
||||
{
|
||||
if (cancellationToken is not null && cancellationToken.Value.IsCancellationRequested) throw new OperationCanceledException();
|
||||
else throw new TimeoutException();
|
||||
}
|
||||
}
|
||||
|
||||
public (GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanningWithStartDirection(Guid startNodeId, double theta, Guid goalId, Orientation startDiretion = Orientation.NONE, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
var basicPath = PathPlanning(startNodeId, theta, goalId, cancellationToken);
|
||||
if (basicPath.Nodes.Length < 1) return basicPath;
|
||||
|
||||
if (startDiretion == basicPath.Nodes[0].Orientation || startDiretion == Orientation.NONE) return basicPath;
|
||||
foreach (var node in basicPath.Nodes)
|
||||
{
|
||||
node.Orientation = node.Orientation == Orientation.FORWARD ? Orientation.BACKWARD : Orientation.FORWARD;
|
||||
}
|
||||
|
||||
return basicPath;
|
||||
}
|
||||
|
||||
public (GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanningWithFinalDirection(Guid startNodeId, double theta, Guid goalId, Orientation goalDirection = Orientation.NONE, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
var basicPath = PathPlanning(startNodeId, theta, goalId, cancellationToken);
|
||||
if (basicPath.Nodes.Length < 1) return basicPath;
|
||||
|
||||
if (goalDirection == basicPath.Nodes[^1].Orientation || goalDirection == Orientation.NONE) return basicPath;
|
||||
foreach (var node in basicPath.Nodes)
|
||||
{
|
||||
node.Orientation = node.Orientation == Orientation.FORWARD ? Orientation.BACKWARD : Orientation.FORWARD;
|
||||
}
|
||||
|
||||
return basicPath;
|
||||
}
|
||||
|
||||
public (GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanningWithAngle(Guid startNodeId, double theta, Guid goalId, double goalAngle, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
var basicPath = PathPlanning(startNodeId, theta, goalId, cancellationToken);
|
||||
if (basicPath.Nodes.Length < 1) return basicPath;
|
||||
|
||||
Orientation goalDirection = MathExtensions.GetOrientationEnd(basicPath.Nodes[^1], basicPath.Nodes[^2], basicPath.Edges[^1], goalAngle, Ratio, Options.ChangeOrientationAngle);
|
||||
if (goalDirection == basicPath.Nodes[^1].Orientation) return basicPath;
|
||||
foreach (var node in basicPath.Nodes)
|
||||
{
|
||||
node.Orientation = node.Orientation == Orientation.FORWARD ? Orientation.BACKWARD : Orientation.FORWARD;
|
||||
}
|
||||
|
||||
return basicPath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
using RobotNet10.GlobalPathPlanner.AStar;
|
||||
using RobotNet10.GlobalPathPlanner.Model;
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace RobotNet10.GlobalPathPlanner.Forklift;
|
||||
|
||||
public class ForkliftPathPlanner : IPathPlanner
|
||||
{
|
||||
private List<GlobalNode> Nodes = [];
|
||||
private List<GlobalEdge> Edges = [];
|
||||
private const double Ratio = 0.1;
|
||||
|
||||
private PathPlannerOptions Options = new()
|
||||
{
|
||||
LimitDistanceToEdge = 1,
|
||||
LimitDistanceToNode = 0.3,
|
||||
ResolutionSplit = 0.1,
|
||||
ChangeOrientationAngle = 89
|
||||
};
|
||||
public void SetData(GlobalNode[] nodes, GlobalEdge[] edges)
|
||||
{
|
||||
Nodes = [.. nodes];
|
||||
Edges = [.. edges];
|
||||
}
|
||||
|
||||
public void SetOptions(PathPlannerOptions options)
|
||||
{
|
||||
Options = options;
|
||||
}
|
||||
|
||||
private static bool TStructureExisted(List<TStructure> TStructures, GlobalNode node1, GlobalNode node2, GlobalNode node3)
|
||||
{
|
||||
var TStructureExistedStep1 = TStructures.Where(ts => ts.Node1 == node1 || ts.Node2 == node1 || ts.Node3 == node1).ToList();
|
||||
if (TStructureExistedStep1.Count != 0)
|
||||
{
|
||||
var TStructureExistedStep2 = TStructureExistedStep1.Where(ts => ts.Node1 == node2 || ts.Node2 == node2 || ts.Node3 == node2).ToList();
|
||||
if (TStructureExistedStep2.Count != 0)
|
||||
{
|
||||
var TStructureExistedStep3 = TStructureExistedStep2.Where(ts => ts.Node1 == node3 || ts.Node2 == node3 || ts.Node3 == node3).ToList();
|
||||
if (TStructureExistedStep3.Count != 0) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private TStructure[] GetTStructure()
|
||||
{
|
||||
List<TStructure> TStructures = [];
|
||||
foreach (var node in Nodes)
|
||||
{
|
||||
var inEdges = Edges.Where(edge => edge.StartNodeId == node.Id || edge.EndNodeId == node.Id).ToList();
|
||||
if (inEdges.Count < 2) continue;
|
||||
List<GlobalNode> inNodes = [];
|
||||
foreach (var edge in inEdges)
|
||||
{
|
||||
var endNode = Nodes.FirstOrDefault(n => n.Id == (node.Id == edge.EndNodeId ? edge.StartNodeId : edge.EndNodeId));
|
||||
if (endNode is null) continue;
|
||||
inNodes.Add(endNode);
|
||||
}
|
||||
for (int i = 0; i < inNodes.Count - 1; i++)
|
||||
{
|
||||
for (int j = i + 1; j < inNodes.Count; j++)
|
||||
{
|
||||
if (TStructureExisted(TStructures, node, inNodes[i], inNodes[j])) continue;
|
||||
var edgeT = Edges.FirstOrDefault(e => (e.StartNodeId == inNodes[i].Id && e.EndNodeId == inNodes[j].Id) ||
|
||||
(e.EndNodeId == inNodes[i].Id && e.StartNodeId == inNodes[j].Id));
|
||||
var edge1 = inEdges.FirstOrDefault(edge => edge.StartNodeId == inNodes[i].Id || edge.EndNodeId == inNodes[i].Id);
|
||||
var edge2 = inEdges.FirstOrDefault(edge => edge.StartNodeId == inNodes[j].Id || edge.EndNodeId == inNodes[j].Id);
|
||||
if (edgeT is null || edge1 is null || edge2 is null) continue;
|
||||
if (edgeT.Degree == 1 &&
|
||||
edge1.Degree == 1 &&
|
||||
edge2.Degree == 1) continue;
|
||||
|
||||
TStructures.Add(new()
|
||||
{
|
||||
Node1 = node,
|
||||
Node2 = inNodes[i],
|
||||
Node3 = inNodes[j],
|
||||
Edge12 = edge1,
|
||||
Edge13 = edge2,
|
||||
Edge23 = edgeT,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return [.. TStructures];
|
||||
}
|
||||
|
||||
private (bool IsSuccess, GlobalNode? intraNode, TStructure? tstructure) IsReverse(GlobalNode currentNode, GlobalNode olderNode, GlobalNode? futureNode, GlobalEdge olderedge, GlobalEdge? futureedge, double startAngle, List<TStructure> tstructures)
|
||||
{
|
||||
var tstructures1 = tstructures.Where(t => t.Node1.Id == currentNode.Id || t.Node2.Id == currentNode.Id || t.Node3.Id == currentNode.Id).ToList();
|
||||
if (tstructures1 is null || tstructures1.Count < 1) return (false, null, null);
|
||||
var tstructures2 = tstructures1.Where(t => t.Node1.Id == olderNode.Id || t.Node2.Id == olderNode.Id || t.Node3.Id == olderNode.Id).ToList();
|
||||
if (tstructures2 is null || tstructures2.Count < 1) return (false, null, null);
|
||||
foreach (var ts in tstructures2)
|
||||
{
|
||||
var midleReverse = ts.IsDriectionReverse(currentNode, olderNode, Options.ChangeOrientationAngle);
|
||||
var intraNode = ts.GetIntraNode(currentNode, olderNode);
|
||||
if (intraNode is null) continue;
|
||||
|
||||
if (!ts.IsAccessDirection(olderNode, intraNode) || !ts.IsAccessDirection(intraNode, currentNode)) continue;
|
||||
|
||||
var currentDirection = MathExtensions.GetOrientationStart(olderNode, currentNode, olderedge, startAngle, Ratio, Options.ChangeOrientationAngle);
|
||||
var intraEdge = ts.GetEdge(olderNode, intraNode);
|
||||
if (intraEdge is null) continue;
|
||||
var branchDirection = MathExtensions.GetOrientationStart(olderNode, intraNode, intraEdge, startAngle, Ratio, Options.ChangeOrientationAngle);
|
||||
bool firstReverse = branchDirection != currentDirection;
|
||||
|
||||
bool endReverse = false;
|
||||
if (futureNode is not null && futureedge is not null)
|
||||
{
|
||||
startAngle = MathExtensions.GetEndAngle(olderNode, currentNode, olderedge, 1 - Ratio);
|
||||
currentDirection = MathExtensions.GetOrientationEnd(currentNode, futureNode, futureedge, startAngle, Ratio, Options.ChangeOrientationAngle);
|
||||
intraEdge = ts.GetEdge(currentNode, intraNode);
|
||||
if (intraEdge is null) continue;
|
||||
startAngle = MathExtensions.GetEndAngle(intraNode, currentNode, intraEdge, 1 - Ratio);
|
||||
branchDirection = MathExtensions.GetOrientationEnd(currentNode, futureNode, futureedge, startAngle, Ratio, Options.ChangeOrientationAngle);
|
||||
endReverse = branchDirection != currentDirection;
|
||||
}
|
||||
|
||||
if (!midleReverse)
|
||||
{
|
||||
if ((!firstReverse && !endReverse) || (firstReverse && endReverse)) continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
if ((firstReverse && !endReverse) || (!firstReverse && endReverse)) continue;
|
||||
}
|
||||
return (true, intraNode, ts);
|
||||
}
|
||||
return (false, null, null);
|
||||
}
|
||||
|
||||
private List<GlobalNode> GetIntermediateNode(GlobalNode startNode, GlobalNode endNode)
|
||||
{
|
||||
var edge1s = Edges.Where(e => e.StartNodeId == startNode.Id).ToList();
|
||||
var edge2s = Edges.Where(e => e.StartNodeId == endNode.Id).ToList();
|
||||
if (edge1s is null || edge2s is null || edge1s.Count < 2 || edge2s.Count < 2) return [];
|
||||
List<GlobalNode> node1 = [];
|
||||
List<GlobalNode> IntermediateNode = [];
|
||||
foreach (var edge1 in edge1s)
|
||||
{
|
||||
if (edge1.Degree != 1) continue;
|
||||
if (edge1.EndNodeId == endNode.Id) continue;
|
||||
var interNode = Nodes.FirstOrDefault(n => n.Id == edge1.EndNodeId);
|
||||
if (interNode is null) continue;
|
||||
node1.Add(interNode);
|
||||
}
|
||||
if (node1.Count == 0) return [];
|
||||
foreach (var edge2 in edge2s)
|
||||
{
|
||||
if (edge2.Degree != 1) continue;
|
||||
if (edge2.EndNodeId == startNode.Id) continue;
|
||||
var interNode = Nodes.FirstOrDefault(n => n.Id == edge2.EndNodeId);
|
||||
if (interNode is null) continue;
|
||||
if (node1.Any(n => n.Id == interNode.Id) && !IntermediateNode.Any(n => n.Id == interNode.Id) && interNode.Id != startNode.Id)
|
||||
IntermediateNode.Add(interNode);
|
||||
}
|
||||
return IntermediateNode;
|
||||
}
|
||||
|
||||
private (GlobalNode[] NodesFilter, GlobalEdge[] EdgesFilter) FilterPathPlanning(GlobalNode[] nodes, GlobalEdge[] edges, GlobalEdge? closesEdge)
|
||||
{
|
||||
if (nodes.Length <= 1 || edges.Length < 1 || nodes.Length - 1 != edges.Length) return ([], []);
|
||||
List<GlobalNode> nodeFilter = [nodes[0]];
|
||||
for (int i = 1; i < nodes.Length - 1; i++)
|
||||
{
|
||||
var IntermediateNode = GetIntermediateNode(nodes[i - 1], nodes[i]);
|
||||
if (IntermediateNode is null || IntermediateNode.Count == 0)
|
||||
{
|
||||
nodeFilter.Add(nodes[i]);
|
||||
continue;
|
||||
}
|
||||
if (IntermediateNode.Any(n => n.Id == nodes[i + 1].Id))
|
||||
{
|
||||
nodeFilter.Add(nodes[i + 1]);
|
||||
i++;
|
||||
}
|
||||
else nodeFilter.Add(nodes[i]);
|
||||
}
|
||||
if (nodeFilter[^1].Id != nodes[^1].Id)
|
||||
nodeFilter.Add(nodes[^1]);
|
||||
var edgeFilter = MathExtensions.GetEdgesPlanning([.. nodeFilter], [.. Edges], closesEdge);
|
||||
if (nodeFilter.Count - 1 != edgeFilter.Length) return ([], []);
|
||||
return ([.. nodeFilter], [.. edgeFilter]);
|
||||
}
|
||||
|
||||
private (GlobalNode[] Nodes, GlobalEdge[] Edges) CheckPathWithFinalDirection(GlobalNode[] nodes, GlobalEdge[] edges, double currentAngle, Orientation goalDirection = Orientation.NONE)
|
||||
{
|
||||
if ((nodes[^1].Orientation == goalDirection && MathExtensions.GetEdgesLength([.. edges], [.. Nodes]) < 10) || goalDirection == Orientation.NONE)
|
||||
return FilterPathPlanning([.. nodes], [.. edges], null);
|
||||
|
||||
var edgeplannings = edges.ToList();
|
||||
var nodeplannings = nodes.ToList();
|
||||
|
||||
var TStructures = GetTStructure();
|
||||
|
||||
Guid LastReverseDirectionId = Guid.Empty;
|
||||
GlobalNode LastNodeReverseDirection = new();
|
||||
for (int i = 1; i < nodeplannings.Count; i++)
|
||||
{
|
||||
if (nodeplannings[i].Orientation == Orientation.FORWARD) continue;
|
||||
GlobalNode? futureNode = null;
|
||||
GlobalEdge? futureEdge = null;
|
||||
if (i < nodeplannings.Count - 1)
|
||||
{
|
||||
futureNode = nodeplannings[i + 1];
|
||||
futureEdge = edgeplannings[i];
|
||||
}
|
||||
double startAngle = currentAngle;
|
||||
if (i >= 2) startAngle = MathExtensions.GetEndAngle(nodeplannings[i - 2], nodeplannings[i - 1], edgeplannings[i - 2], Ratio);
|
||||
(var IsSuccess, var intraNode, var tstructure) = IsReverse(nodeplannings[i], nodeplannings[i - 1], futureNode, edgeplannings[i - 1], futureEdge, startAngle, [.. TStructures]);
|
||||
if (!IsSuccess || intraNode is null || tstructure is null) continue;
|
||||
var edge1 = Edges.FirstOrDefault(e => (e.StartNodeId == nodeplannings[i - 1].Id && e.EndNodeId == intraNode.Id) ||
|
||||
e.EndNodeId == nodeplannings[i - 1].Id && e.StartNodeId == intraNode.Id);
|
||||
var edge2 = Edges.FirstOrDefault(e => (e.StartNodeId == nodeplannings[i].Id && e.EndNodeId == intraNode.Id) ||
|
||||
e.EndNodeId == nodeplannings[i].Id && e.StartNodeId == intraNode.Id);
|
||||
if (edge1 is null || edge2 is null) continue;
|
||||
edgeplannings.RemoveAt(i - 1);
|
||||
edgeplannings.Insert(i - 1, new()
|
||||
{
|
||||
Id = edge1.Id,
|
||||
StartNodeId = nodeplannings[i - 1].Id,
|
||||
EndNodeId = intraNode.Id,
|
||||
Degree = edge1.Degree,
|
||||
ControlPoint1X = edge1.ControlPoint1X,
|
||||
ControlPoint1Y = edge1.ControlPoint1Y,
|
||||
ControlPoint2X = edge1.ControlPoint2X,
|
||||
ControlPoint2Y = edge1.ControlPoint2Y
|
||||
});
|
||||
edgeplannings.Insert(i, new()
|
||||
{
|
||||
Id = edge2.Id,
|
||||
StartNodeId = intraNode.Id,
|
||||
EndNodeId = nodeplannings[i].Id,
|
||||
Degree = edge2.Degree,
|
||||
ControlPoint1X = edge2.ControlPoint1X,
|
||||
ControlPoint1Y = edge2.ControlPoint1Y,
|
||||
ControlPoint2X = edge2.ControlPoint2X,
|
||||
ControlPoint2Y = edge2.ControlPoint2Y
|
||||
});
|
||||
nodeplannings.Insert(i, intraNode);
|
||||
var directionInPath = MathExtensions.GetOrientations(nodeplannings[0].Orientation, [.. nodeplannings], [.. edgeplannings], Ratio, Options.ChangeOrientationAngle);
|
||||
for (int j = 0; j < nodeplannings.Count; j++)
|
||||
{
|
||||
nodeplannings[j].Orientation = directionInPath[j];
|
||||
}
|
||||
LastReverseDirectionId = tstructure.Id;
|
||||
LastNodeReverseDirection = nodeplannings[i + 1];
|
||||
i++;
|
||||
}
|
||||
|
||||
if (nodeplannings[^1].Orientation == goalDirection) return FilterPathPlanning([.. nodeplannings], [.. edgeplannings], null);
|
||||
|
||||
for (int i = nodeplannings.Count - 1; i > 0; i--)
|
||||
{
|
||||
GlobalNode? futureNode = null;
|
||||
GlobalEdge? futureEdge = null;
|
||||
if (i < nodeplannings.Count - 1)
|
||||
{
|
||||
futureNode = nodeplannings[i + 1];
|
||||
futureEdge = edgeplannings[i];
|
||||
}
|
||||
double startAngle = currentAngle;
|
||||
if (i >= 2) startAngle = MathExtensions.GetEndAngle(nodeplannings[i - 2], nodeplannings[i - 1], edgeplannings[i - 2], Ratio);
|
||||
(var IsSuccess, var intraNode, var tstructure) = IsReverse(nodeplannings[i], nodeplannings[i - 1], futureNode, edgeplannings[i - 1], futureEdge, startAngle, [.. TStructures]);
|
||||
if (!IsSuccess || intraNode is null || tstructure is null) continue;
|
||||
|
||||
if (nodeplannings[i - 1].Id == LastNodeReverseDirection.Id)
|
||||
{
|
||||
var edge = Edges.FirstOrDefault(e => (e.StartNodeId == nodeplannings[i - 2].Id && e.EndNodeId == nodeplannings[i].Id) ||
|
||||
(e.StartNodeId == nodeplannings[i].Id && e.EndNodeId == nodeplannings[i - 2].Id));
|
||||
if (edge is null) continue;
|
||||
edgeplannings.Insert(i - 2, new()
|
||||
{
|
||||
Id = edge.Id,
|
||||
StartNodeId = nodeplannings[i - 2].Id,
|
||||
EndNodeId = nodeplannings[i].Id,
|
||||
Degree = edge.Degree,
|
||||
ControlPoint1X = edge.ControlPoint1X,
|
||||
ControlPoint1Y = edge.ControlPoint1Y,
|
||||
ControlPoint2X = edge.ControlPoint2X,
|
||||
ControlPoint2Y = edge.ControlPoint2Y
|
||||
});
|
||||
edgeplannings.RemoveAt(i);
|
||||
edgeplannings.RemoveAt(i - 1);
|
||||
nodeplannings.RemoveAt(i - 1);
|
||||
}
|
||||
else if (tstructure.Id != LastReverseDirectionId || i < 2)
|
||||
{
|
||||
var edge1 = Edges.FirstOrDefault(e => (e.StartNodeId == nodeplannings[i - 1].Id && e.EndNodeId == intraNode.Id) ||
|
||||
e.EndNodeId == nodeplannings[i - 1].Id && e.StartNodeId == intraNode.Id);
|
||||
var edge2 = Edges.FirstOrDefault(e => (e.StartNodeId == nodeplannings[i].Id && e.EndNodeId == intraNode.Id) ||
|
||||
e.EndNodeId == nodeplannings[i].Id && e.StartNodeId == intraNode.Id);
|
||||
if (edge1 is null || edge2 is null) continue;
|
||||
edgeplannings.RemoveAt(i - 1);
|
||||
edgeplannings.Insert(i - 1, new()
|
||||
{
|
||||
Id = edge1.Id,
|
||||
StartNodeId = nodeplannings[i - 1].Id,
|
||||
EndNodeId = intraNode.Id,
|
||||
Degree = edge1.Degree,
|
||||
ControlPoint1X = edge1.ControlPoint1X,
|
||||
ControlPoint1Y = edge1.ControlPoint1Y,
|
||||
ControlPoint2X = edge1.ControlPoint2X,
|
||||
ControlPoint2Y = edge1.ControlPoint2Y,
|
||||
});
|
||||
edgeplannings.Insert(i, new()
|
||||
{
|
||||
Id = edge2.Id,
|
||||
StartNodeId = intraNode.Id,
|
||||
EndNodeId = nodeplannings[i].Id,
|
||||
Degree = edge2.Degree,
|
||||
ControlPoint1X = edge2.ControlPoint1X,
|
||||
ControlPoint1Y = edge2.ControlPoint1Y,
|
||||
ControlPoint2X = edge2.ControlPoint2X,
|
||||
ControlPoint2Y = edge2.ControlPoint2Y,
|
||||
});
|
||||
nodeplannings.Insert(i, intraNode);
|
||||
}
|
||||
else
|
||||
{
|
||||
var edge = Edges.FirstOrDefault(e => (e.StartNodeId == nodeplannings[i - 2].Id && e.EndNodeId == nodeplannings[i].Id) ||
|
||||
(e.StartNodeId == nodeplannings[i].Id && e.EndNodeId == nodeplannings[i - 2].Id));
|
||||
if (edge is null) continue;
|
||||
edgeplannings.Insert(i - 2, new()
|
||||
{
|
||||
Id = edge.Id,
|
||||
StartNodeId = nodeplannings[i - 2].Id,
|
||||
EndNodeId = nodeplannings[i].Id,
|
||||
Degree = edge.Degree,
|
||||
ControlPoint1X = edge.ControlPoint1X,
|
||||
ControlPoint1Y = edge.ControlPoint1Y,
|
||||
ControlPoint2X = edge.ControlPoint2X,
|
||||
ControlPoint2Y = edge.ControlPoint2Y,
|
||||
});
|
||||
edgeplannings.RemoveAt(i);
|
||||
edgeplannings.RemoveAt(i - 1);
|
||||
nodeplannings.RemoveAt(i - 1);
|
||||
}
|
||||
var directionInPath = MathExtensions.GetOrientations(nodeplannings[0].Orientation, [.. nodeplannings], [.. edgeplannings], Ratio, Options.ChangeOrientationAngle);
|
||||
if (directionInPath[^1] == goalDirection)
|
||||
{
|
||||
for (int j = 0; j < nodeplannings.Count; j++)
|
||||
{
|
||||
nodeplannings[j].Orientation = directionInPath[j];
|
||||
}
|
||||
return FilterPathPlanning([.. nodeplannings], [.. edgeplannings], null);
|
||||
}
|
||||
}
|
||||
throw new Exception("The path to the destination does not satisfy the conditions");
|
||||
}
|
||||
|
||||
public (GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanning(double x, double y, double theta, Guid goalId, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
var goal = Nodes.FirstOrDefault(n => n.Id == goalId) ?? throw new Exception($"Destination {goalId} does not exist in the map");
|
||||
|
||||
using CancellationTokenSource cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken ?? CancellationToken.None);
|
||||
if (Options.TimeOut != null && Options.TimeOut.HasValue) cancellation.CancelAfter(Options.TimeOut.Value);
|
||||
|
||||
try
|
||||
{
|
||||
var AStarPathPlanner = new AStarPlanner(Nodes, Edges);
|
||||
(var path, var closesEdge) = AStarPathPlanner.Planning(x,
|
||||
y,
|
||||
new GlobalNode() { Id = goal.Id, Name = goal.Name, X = goal.X, Y = goal.Y },
|
||||
Options.LimitDistanceToEdge,
|
||||
Options.LimitDistanceToNode,
|
||||
cancellation.Token);
|
||||
|
||||
if (path is null || path.Length < 1) throw new Exception($"The path to {goal.Name} - {goal.Id} does not exist from [{x}, {y}, {theta}]");
|
||||
if (path.Length == 1) return (path, []);
|
||||
|
||||
var edgeplannings = MathExtensions.GetEdgesPlanning([.. path], [.. Edges], closesEdge);
|
||||
|
||||
Orientation CurrenDirection = MathExtensions.GetOrientationStart(path[0], path[1], edgeplannings[0], theta, Ratio, Options.ChangeOrientationAngle);
|
||||
var FinalDirection = MathExtensions.GetOrientations(CurrenDirection, path, edgeplannings, Ratio, Options.ChangeOrientationAngle);
|
||||
for (int i = 0; i < path.Length; i++)
|
||||
{
|
||||
path[i].Orientation = FinalDirection[i];
|
||||
}
|
||||
|
||||
return ([.. path], [.. edgeplannings]);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (cancellationToken is not null && cancellationToken.Value.IsCancellationRequested) throw new OperationCanceledException();
|
||||
else throw new TimeoutException();
|
||||
}
|
||||
}
|
||||
|
||||
public (GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanningWithStartDirection(double x, double y, double theta, Guid goalId, Orientation startDiretion = Orientation.NONE, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public (GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanningWithFinalDirection(double x, double y, double theta, Guid goalId, Orientation goalDirection = Orientation.NONE, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
var basicPath = PathPlanning(x, y, theta, goalId, cancellationToken);
|
||||
if (basicPath.Nodes.Length < 1) return basicPath;
|
||||
|
||||
return CheckPathWithFinalDirection(basicPath.Nodes, basicPath.Edges, theta, goalDirection);
|
||||
}
|
||||
|
||||
public (GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanningWithAngle(double x, double y, double theta, Guid goalId, double goalAngle, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
var basicPath = PathPlanning(x, y, theta, goalId, cancellationToken);
|
||||
if (basicPath.Nodes.Length < 1) return basicPath;
|
||||
|
||||
Orientation goalDirection = MathExtensions.GetOrientationEnd(basicPath.Nodes[^1], basicPath.Nodes[^2], basicPath.Edges[^1], goalAngle, Ratio, Options.ChangeOrientationAngle);
|
||||
|
||||
return CheckPathWithFinalDirection(basicPath.Nodes, basicPath.Edges, theta, goalDirection);
|
||||
}
|
||||
|
||||
public (GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanning(Guid startNodeId, double theta, Guid goalId, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
var goal = Nodes.FirstOrDefault(n => n.Id == goalId) ?? throw new Exception($"Destination {goalId} does not exist in the map");
|
||||
var startNode = Nodes.FirstOrDefault(n => n.Id == startNodeId) ?? throw new Exception($"Starting node {startNodeId} does not exist in the map");
|
||||
|
||||
using CancellationTokenSource cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken ?? CancellationToken.None);
|
||||
if (Options.TimeOut != null && Options.TimeOut.HasValue) cancellation.CancelAfter(Options.TimeOut.Value);
|
||||
try
|
||||
{
|
||||
var AStarPathPlanner = new AStarPlanner(Nodes, Edges);
|
||||
(var path, var closesEdge) = AStarPathPlanner.Planning(startNode, goal, cancellation.Token);
|
||||
|
||||
if (path is null || path.Length < 1) throw new Exception($"The path to {goal.Name} - {goal.Id} does not exist from [{startNode.Name} - {startNode.Id}]");
|
||||
if (path.Length == 1) return (path, []);
|
||||
|
||||
var edgeplannings = MathExtensions.GetEdgesPlanning([.. path], [.. Edges], closesEdge);
|
||||
|
||||
Orientation CurrenDirection = MathExtensions.GetOrientationStart(path[0], path[1], edgeplannings[0], theta, Ratio, Options.ChangeOrientationAngle);
|
||||
var FinalDirection = MathExtensions.GetOrientations(CurrenDirection, path, edgeplannings, Ratio, Options.ChangeOrientationAngle);
|
||||
for (int i = 0; i < path.Length; i++)
|
||||
{
|
||||
path[i].Orientation = FinalDirection[i];
|
||||
}
|
||||
|
||||
return ([.. path], [.. edgeplannings]);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (cancellationToken is not null && cancellationToken.Value.IsCancellationRequested) throw new OperationCanceledException();
|
||||
else throw new TimeoutException();
|
||||
}
|
||||
}
|
||||
|
||||
public (GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanningWithStartDirection(Guid startNodeId, double theta, Guid goalId, Orientation startDiretion = Orientation.NONE, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public (GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanningWithFinalDirection(Guid startNodeId, double theta, Guid goalId, Orientation goalDirection = Orientation.NONE, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
var basicPath = PathPlanning(startNodeId, theta, goalId, cancellationToken);
|
||||
if (basicPath.Nodes.Length < 1) return basicPath;
|
||||
|
||||
return CheckPathWithFinalDirection(basicPath.Nodes, basicPath.Edges, theta, goalDirection);
|
||||
}
|
||||
|
||||
public (GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanningWithAngle(Guid startNodeId, double theta, Guid goalId, double goalAngle, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
var basicPath = PathPlanning(startNodeId, theta, goalId, cancellationToken);
|
||||
if (basicPath.Nodes.Length < 1) return basicPath;
|
||||
|
||||
Orientation goalDirection = MathExtensions.GetOrientationEnd(basicPath.Nodes[^1], basicPath.Nodes[^2], basicPath.Edges[^1], goalAngle, Ratio, Options.ChangeOrientationAngle);
|
||||
|
||||
return CheckPathWithFinalDirection(basicPath.Nodes, basicPath.Edges, theta, goalDirection);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using RobotNet10.GlobalPathPlanner.Model;
|
||||
|
||||
namespace RobotNet10.GlobalPathPlanner.Forklift;
|
||||
|
||||
public enum TStructureDirection
|
||||
{
|
||||
NODE1_NODE2_NODE3,
|
||||
NODE1_NODE3_NODE2,
|
||||
NODE2_NODE1_NODE3,
|
||||
NODE2_NODE3_NODE1,
|
||||
NODE3_NODE2_NODE1,
|
||||
NODE3_NODE1_NODE2,
|
||||
}
|
||||
|
||||
public class TStructure
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public GlobalNode Node1 { get; set; } = new();
|
||||
public GlobalNode Node2 { get; set; } = new();
|
||||
public GlobalNode Node3 { get; set; } = new();
|
||||
public GlobalEdge? Edge12 { get; set; }
|
||||
public GlobalEdge? Edge13 { get; set; }
|
||||
public GlobalEdge? Edge23 { get; set; }
|
||||
public GlobalEdge? Edge21 { get; set; }
|
||||
public GlobalEdge? Edge31 { get; set; }
|
||||
public GlobalEdge? Edge32 { get; set; }
|
||||
private const double Ratio = 0.1;
|
||||
|
||||
public bool IsDriectionReverse(TStructureDirection direction, double changeOrientationAngle)
|
||||
{
|
||||
GlobalNode OriginNode = new();
|
||||
GlobalNode ToWardNode1 = new();
|
||||
GlobalNode ToWardNode2 = new();
|
||||
GlobalEdge ToWardEdge1 = new();
|
||||
GlobalEdge ToWardEdge2 = new();
|
||||
|
||||
switch (direction)
|
||||
{
|
||||
case TStructureDirection.NODE3_NODE2_NODE1:
|
||||
if (Edge21 is null || Edge32 is null) return false;
|
||||
OriginNode = Node2;
|
||||
ToWardNode1 = Node1;
|
||||
ToWardNode2 = Node3;
|
||||
ToWardEdge1 = Edge21;
|
||||
ToWardEdge2 = Edge32;
|
||||
break;
|
||||
case TStructureDirection.NODE1_NODE2_NODE3:
|
||||
if (Edge12 is null || Edge23 is null) return false;
|
||||
OriginNode = Node2;
|
||||
ToWardNode1 = Node1;
|
||||
ToWardNode2 = Node3;
|
||||
ToWardEdge1 = Edge12;
|
||||
ToWardEdge2 = Edge23;
|
||||
break;
|
||||
case TStructureDirection.NODE2_NODE3_NODE1:
|
||||
if (Edge31 is null || Edge23 is null) return false;
|
||||
OriginNode = Node3;
|
||||
ToWardNode1 = Node1;
|
||||
ToWardNode2 = Node2;
|
||||
ToWardEdge1 = Edge23;
|
||||
ToWardEdge2 = Edge31;
|
||||
break;
|
||||
case TStructureDirection.NODE1_NODE3_NODE2:
|
||||
if (Edge13 is null || Edge32 is null) return false;
|
||||
OriginNode = Node3;
|
||||
ToWardNode1 = Node1;
|
||||
ToWardNode2 = Node2;
|
||||
ToWardEdge1 = Edge13;
|
||||
ToWardEdge2 = Edge32;
|
||||
break;
|
||||
case TStructureDirection.NODE3_NODE1_NODE2:
|
||||
if (Edge31 is null || Edge12 is null) return false;
|
||||
OriginNode = Node1;
|
||||
ToWardNode1 = Node2;
|
||||
ToWardNode2 = Node3;
|
||||
ToWardEdge1 = Edge31;
|
||||
ToWardEdge2 = Edge12;
|
||||
break;
|
||||
case TStructureDirection.NODE2_NODE1_NODE3:
|
||||
if (Edge21 is null || Edge13 is null) return false;
|
||||
OriginNode = Node1;
|
||||
ToWardNode1 = Node2;
|
||||
ToWardNode2 = Node3;
|
||||
ToWardEdge1 = Edge21;
|
||||
ToWardEdge2 = Edge13;
|
||||
break;
|
||||
}
|
||||
|
||||
var NearToWardNode1 = MathExtensions.BezierPoint(Ratio, OriginNode, ToWardNode1, ToWardEdge1);
|
||||
var NearToWardNode3 = MathExtensions.BezierPoint(Ratio, OriginNode, ToWardNode2, ToWardEdge2);
|
||||
var angle = MathExtensions.GetAngle(OriginNode, NearToWardNode1, NearToWardNode3);
|
||||
if (angle < changeOrientationAngle) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool IsDriectionReverse(GlobalNode node1, GlobalNode node2, double changeOrientationAngle)
|
||||
{
|
||||
if (node1.Id == Node1.Id)
|
||||
{
|
||||
if (node2.Id == Node2.Id) return IsDriectionReverse(TStructureDirection.NODE1_NODE3_NODE2, changeOrientationAngle);
|
||||
else if (node2.Id == Node3.Id) return IsDriectionReverse(TStructureDirection.NODE1_NODE2_NODE3, changeOrientationAngle);
|
||||
}
|
||||
else if (node1.Id == Node2.Id)
|
||||
{
|
||||
if (node2.Id == Node1.Id) return IsDriectionReverse(TStructureDirection.NODE2_NODE3_NODE1, changeOrientationAngle);
|
||||
else if (node2.Id == Node3.Id) return IsDriectionReverse(TStructureDirection.NODE2_NODE1_NODE3, changeOrientationAngle);
|
||||
}
|
||||
else if (node1.Id == Node3.Id)
|
||||
{
|
||||
if (node2.Id == Node1.Id) return IsDriectionReverse(TStructureDirection.NODE3_NODE2_NODE1, changeOrientationAngle);
|
||||
else if (node2.Id == Node2.Id) return IsDriectionReverse(TStructureDirection.NODE3_NODE1_NODE2, changeOrientationAngle);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public GlobalNode? GetIntraNode(GlobalNode node1, GlobalNode node2)
|
||||
{
|
||||
if (node1.Id == Node1.Id)
|
||||
{
|
||||
if (node2.Id == Node2.Id) return Node3;
|
||||
else if (node2.Id == Node3.Id) return Node2;
|
||||
}
|
||||
else if (node1.Id == Node2.Id)
|
||||
{
|
||||
if (node2.Id == Node1.Id) return Node3;
|
||||
else if (node2.Id == Node3.Id) return Node1;
|
||||
}
|
||||
else if (node1.Id == Node3.Id)
|
||||
{
|
||||
if (node2.Id == Node1.Id) return Node2;
|
||||
else if (node2.Id == Node2.Id) return Node1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public GlobalEdge? GetEdge(GlobalNode node1, GlobalNode node2)
|
||||
{
|
||||
if (Edge12 is not null && Edge12.StartNodeId == node1.Id && Edge12.EndNodeId == node2.Id) return Edge12;
|
||||
if (Edge21 is not null && Edge21.StartNodeId == node1.Id && Edge21.EndNodeId == node2.Id) return Edge21;
|
||||
if (Edge13 is not null && Edge13.StartNodeId == node1.Id && Edge13.EndNodeId == node2.Id) return Edge13;
|
||||
if (Edge31 is not null && Edge31.StartNodeId == node1.Id && Edge31.EndNodeId == node2.Id) return Edge31;
|
||||
if (Edge23 is not null && Edge23.StartNodeId == node1.Id && Edge23.EndNodeId == node2.Id) return Edge23;
|
||||
if (Edge32 is not null && Edge32.StartNodeId == node1.Id && Edge32.EndNodeId == node2.Id) return Edge32;
|
||||
return null;
|
||||
}
|
||||
|
||||
public bool IsAccessDirection(GlobalNode startNode, GlobalNode endNode)
|
||||
{
|
||||
return GetEdge(startNode, endNode) is not null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
using RobotNet10.GlobalPathPlanner.AStar;
|
||||
using RobotNet10.GlobalPathPlanner.Forklift;
|
||||
using RobotNet10.GlobalPathPlanner.Model;
|
||||
|
||||
namespace RobotNet10.GlobalPathPlanner.ForkliftV2;
|
||||
|
||||
public class ForkLiftPathPlannerV2 : IPathPlanner
|
||||
{
|
||||
private List<GlobalNode> Nodes = [];
|
||||
private List<GlobalEdge> Edges = [];
|
||||
private const double Ratio = 0.1;
|
||||
|
||||
private PathPlannerOptions Options = new()
|
||||
{
|
||||
LimitDistanceToEdge = 1,
|
||||
LimitDistanceToNode = 0.3,
|
||||
ResolutionSplit = 0.1,
|
||||
ChangeOrientationAngle = 89
|
||||
};
|
||||
public void SetData(GlobalNode[] nodes, GlobalEdge[] edges)
|
||||
{
|
||||
Nodes = [.. nodes];
|
||||
Edges = [.. edges];
|
||||
}
|
||||
|
||||
public void SetOptions(PathPlannerOptions options)
|
||||
{
|
||||
Options = options;
|
||||
}
|
||||
|
||||
public (GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanning(double x, double y, double theta, Guid goalId, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
var goal = Nodes.FirstOrDefault(n => n.Id == goalId) ?? throw new Exception($"Destination {goalId} does not exist in the map");
|
||||
|
||||
using CancellationTokenSource cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken ?? CancellationToken.None);
|
||||
if (Options.TimeOut != null && Options.TimeOut.HasValue) cancellation.CancelAfter(Options.TimeOut.Value);
|
||||
try
|
||||
{
|
||||
var SSEAStarPlanner = new SSEAStarPlanner(Nodes, Edges, Options);
|
||||
(var path, var closesEdge) = SSEAStarPlanner.PlanningWithFinalDirection(x, y, theta,
|
||||
new GlobalNode() { Id = goal.Id, Name = goal.Name, X = goal.X, Y = goal.Y},
|
||||
Orientation.NONE,
|
||||
Options.LimitDistanceToEdge,
|
||||
Options.LimitDistanceToNode,
|
||||
cancellation.Token);
|
||||
|
||||
if (path is null || path.Length < 1)
|
||||
{
|
||||
var ForkliftV1 = new ForkliftPathPlanner();
|
||||
ForkliftV1.SetData([.. Nodes], [.. Edges]);
|
||||
return ForkliftV1.PathPlanning(x,y,theta,goal.Id, cancellation.Token);
|
||||
}
|
||||
if (path.Length == 1) return (path, []);
|
||||
var edgeplannings = MathExtensions.GetEdgesPlanning([.. path], [.. Edges], closesEdge);
|
||||
|
||||
Orientation CurrenDirection = MathExtensions.GetOrientationStart(path[0], path[1], edgeplannings[0], theta, Ratio, Options.ChangeOrientationAngle);
|
||||
var FinalDirection = MathExtensions.GetOrientations(CurrenDirection, path, edgeplannings, Ratio, Options.ChangeOrientationAngle);
|
||||
for (int i = 0; i < path.Length; i++)
|
||||
{
|
||||
path[i] = new GlobalNode
|
||||
{
|
||||
Id = path[i].Id,
|
||||
X = path[i].X,
|
||||
Y = path[i].Y,
|
||||
Name = path[i].Name,
|
||||
MapId = path[i].MapId,
|
||||
Orientation = FinalDirection[i]
|
||||
};
|
||||
}
|
||||
|
||||
return ([.. path], [.. edgeplannings]);
|
||||
}
|
||||
catch(OperationCanceledException)
|
||||
{
|
||||
if (cancellationToken is not null && cancellationToken.Value.IsCancellationRequested) throw new OperationCanceledException();
|
||||
else throw new TimeoutException();
|
||||
}
|
||||
}
|
||||
|
||||
public (GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanningWithStartDirection(double x, double y, double theta, Guid goalId, Orientation startDiretion = Orientation.NONE, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public (GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanningWithFinalDirection(double x, double y, double theta, Guid goalId, Orientation goalDirection = Orientation.NONE, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
var goal = Nodes.FirstOrDefault(n => n.Id == goalId) ?? throw new Exception($"Destination {goalId} does not exist in the map");
|
||||
|
||||
using CancellationTokenSource cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken ?? CancellationToken.None);
|
||||
if (Options.TimeOut != null && Options.TimeOut.HasValue) cancellation.CancelAfter(Options.TimeOut.Value);
|
||||
try
|
||||
{
|
||||
var SSEAStarPlanner = new SSEAStarPlanner(Nodes, Edges, Options);
|
||||
(var path, var closesEdge) = SSEAStarPlanner.PlanningWithFinalDirection(x, y, theta,
|
||||
new GlobalNode() { Id = goal.Id, Name = goal.Name, X = goal.X, Y = goal.Y },
|
||||
goalDirection,
|
||||
Options.LimitDistanceToEdge,
|
||||
Options.LimitDistanceToNode,
|
||||
cancellation.Token);
|
||||
|
||||
if (path is null || path.Length < 1)
|
||||
{
|
||||
var ForkliftV1 = new ForkliftPathPlanner();
|
||||
ForkliftV1.SetData([.. Nodes], [.. Edges]);
|
||||
return ForkliftV1.PathPlanningWithFinalDirection(x, y, theta, goal.Id, goalDirection, cancellation.Token);
|
||||
}
|
||||
if (path.Length == 1) return (path, []);
|
||||
var edgeplannings = MathExtensions.GetEdgesPlanning([.. path], [.. Edges], closesEdge);
|
||||
|
||||
Orientation CurrenDirection = MathExtensions.GetOrientationStart(path[0], path[1], edgeplannings[0], theta, Ratio, Options.ChangeOrientationAngle);
|
||||
var FinalDirection = MathExtensions.GetOrientations(CurrenDirection, path, edgeplannings, Ratio, Options.ChangeOrientationAngle);
|
||||
for (int i = 0; i < path.Length; i++)
|
||||
{
|
||||
path[i] = new GlobalNode
|
||||
{
|
||||
Id = path[i].Id,
|
||||
X = path[i].X,
|
||||
Y = path[i].Y,
|
||||
Name = path[i].Name,
|
||||
MapId = path[i].MapId,
|
||||
Orientation = FinalDirection[i]
|
||||
};
|
||||
}
|
||||
|
||||
return ([.. path], [.. edgeplannings]);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (cancellationToken is not null && cancellationToken.Value.IsCancellationRequested) throw new OperationCanceledException();
|
||||
else throw new TimeoutException();
|
||||
}
|
||||
}
|
||||
|
||||
public (GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanningWithAngle(double x, double y, double theta, Guid goalId, double goalAngle, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
var goal = Nodes.FirstOrDefault(n => n.Id == goalId) ?? throw new Exception($"Destination {goalId} does not exist in the map");
|
||||
|
||||
using CancellationTokenSource cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken ?? CancellationToken.None);
|
||||
if (Options.TimeOut != null && Options.TimeOut.HasValue) cancellation.CancelAfter(Options.TimeOut.Value);
|
||||
try
|
||||
{
|
||||
var SSEAStarPlanner = new SSEAStarPlanner(Nodes, Edges, Options);
|
||||
(var path, var closesEdge) = SSEAStarPlanner.PlanningWithGoalAngle(x, y, theta,
|
||||
new GlobalNode() { Id = goal.Id, Name = goal.Name, X = goal.X, Y = goal.Y },
|
||||
goalAngle,
|
||||
Options.LimitDistanceToEdge,
|
||||
Options.LimitDistanceToNode,
|
||||
cancellation.Token);
|
||||
|
||||
if (path is null || path.Length < 1)
|
||||
{
|
||||
var ForkliftV1 = new ForkliftPathPlanner();
|
||||
ForkliftV1.SetData([.. Nodes], [.. Edges]);
|
||||
return ForkliftV1.PathPlanningWithAngle(x, y, theta, goal.Id, goalAngle, cancellation.Token);
|
||||
}
|
||||
if (path.Length == 1) return (path, []);
|
||||
var edgeplannings = MathExtensions.GetEdgesPlanning([.. path], [.. Edges], closesEdge);
|
||||
|
||||
Orientation CurrenDirection = MathExtensions.GetOrientationStart(path[0], path[1], edgeplannings[0], theta, Ratio, Options.ChangeOrientationAngle);
|
||||
var FinalDirection = MathExtensions.GetOrientations(CurrenDirection, path, edgeplannings, Ratio, Options.ChangeOrientationAngle);
|
||||
for (int i = 0; i < path.Length; i++)
|
||||
{
|
||||
path[i] = new GlobalNode
|
||||
{
|
||||
Id = path[i].Id,
|
||||
X = path[i].X,
|
||||
Y = path[i].Y,
|
||||
Name = path[i].Name,
|
||||
MapId = path[i].MapId,
|
||||
Orientation = FinalDirection[i]
|
||||
};
|
||||
}
|
||||
|
||||
return ([.. path], [.. edgeplannings]);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (cancellationToken is not null && cancellationToken.Value.IsCancellationRequested) throw new OperationCanceledException();
|
||||
else throw new TimeoutException();
|
||||
}
|
||||
}
|
||||
|
||||
public (GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanning(Guid startNodeId, double theta, Guid goalId, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
var goal = Nodes.FirstOrDefault(n => n.Id == goalId) ?? throw new Exception($"Destination {goalId} does not exist in the map");
|
||||
var startNode = Nodes.FirstOrDefault(n => n.Id == startNodeId) ?? throw new Exception($"Starting node {startNodeId} does not exist in the map");
|
||||
|
||||
using CancellationTokenSource cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken ?? CancellationToken.None);
|
||||
if (Options.TimeOut != null && Options.TimeOut.HasValue) cancellation.CancelAfter(Options.TimeOut.Value);
|
||||
try
|
||||
{
|
||||
var SSEAStarPlanner = new SSEAStarPlanner(Nodes, Edges, Options);
|
||||
(var path, var closesEdge) = SSEAStarPlanner.PlanningWithFinalDirection(startNode, theta,
|
||||
new GlobalNode() { Id = goal.Id, Name = goal.Name, X = goal.X, Y = goal.Y },
|
||||
Orientation.NONE,
|
||||
cancellation.Token);
|
||||
|
||||
if (path is null || path.Length < 1)
|
||||
{
|
||||
var ForkliftV1 = new ForkliftPathPlanner();
|
||||
ForkliftV1.SetData([.. Nodes], [.. Edges]);
|
||||
return ForkliftV1.PathPlanningWithFinalDirection(startNodeId, theta, goal.Id, Orientation.NONE, cancellation.Token);
|
||||
}
|
||||
if (path.Length == 1) return (path, []);
|
||||
var edgeplannings = MathExtensions.GetEdgesPlanning([.. path], [.. Edges], closesEdge);
|
||||
|
||||
Orientation CurrenDirection = MathExtensions.GetOrientationStart(path[0], path[1], edgeplannings[0], theta, Ratio, Options.ChangeOrientationAngle);
|
||||
var FinalDirection = MathExtensions.GetOrientations(CurrenDirection, path, edgeplannings, Ratio, Options.ChangeOrientationAngle);
|
||||
for (int i = 0; i < path.Length; i++)
|
||||
{
|
||||
path[i] = new GlobalNode
|
||||
{
|
||||
Id = path[i].Id,
|
||||
X = path[i].X,
|
||||
Y = path[i].Y,
|
||||
Name = path[i].Name,
|
||||
MapId = path[i].MapId,
|
||||
Orientation = FinalDirection[i]
|
||||
};
|
||||
}
|
||||
|
||||
return ([.. path], [.. edgeplannings]);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (cancellationToken is not null && cancellationToken.Value.IsCancellationRequested) throw new OperationCanceledException();
|
||||
else throw new TimeoutException();
|
||||
}
|
||||
}
|
||||
|
||||
public (GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanningWithStartDirection(Guid startNodeId, double theta, Guid goalId, Orientation startDiretion = Orientation.NONE, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public (GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanningWithFinalDirection(Guid startNodeId, double theta, Guid goalId, Orientation goalDirection = Orientation.NONE, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
var goal = Nodes.FirstOrDefault(n => n.Id == goalId) ?? throw new Exception($"Destination {goalId} does not exist in the map");
|
||||
var startNode = Nodes.FirstOrDefault(n => n.Id == startNodeId) ?? throw new Exception($"Starting node {startNodeId} does not exist in the map");
|
||||
|
||||
using CancellationTokenSource cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken ?? CancellationToken.None);
|
||||
if (Options.TimeOut != null && Options.TimeOut.HasValue) cancellation.CancelAfter(Options.TimeOut.Value);
|
||||
try
|
||||
{
|
||||
var SSEAStarPlanner = new SSEAStarPlanner(Nodes, Edges, Options);
|
||||
(var path, var closesEdge) = SSEAStarPlanner.PlanningWithFinalDirection(startNode, theta,
|
||||
new GlobalNode() { Id = goal.Id, Name = goal.Name, X = goal.X, Y = goal.Y },
|
||||
goalDirection,
|
||||
cancellation.Token);
|
||||
|
||||
if (path is null || path.Length < 1)
|
||||
{
|
||||
var ForkliftV1 = new ForkliftPathPlanner();
|
||||
ForkliftV1.SetData([.. Nodes], [.. Edges]);
|
||||
return ForkliftV1.PathPlanningWithFinalDirection(startNodeId, theta, goal.Id, goalDirection, cancellation.Token);
|
||||
}
|
||||
if (path.Length == 1) return (path, []);
|
||||
var edgeplannings = MathExtensions.GetEdgesPlanning([.. path], [.. Edges], closesEdge);
|
||||
|
||||
Orientation CurrenDirection = MathExtensions.GetOrientationStart(path[0], path[1], edgeplannings[0], theta, Ratio, Options.ChangeOrientationAngle);
|
||||
var FinalDirection = MathExtensions.GetOrientations(CurrenDirection, path, edgeplannings, Ratio, Options.ChangeOrientationAngle);
|
||||
for (int i = 0; i < path.Length; i++)
|
||||
{
|
||||
path[i] = new GlobalNode
|
||||
{
|
||||
Id = path[i].Id,
|
||||
X = path[i].X,
|
||||
Y = path[i].Y,
|
||||
Name = path[i].Name,
|
||||
MapId = path[i].MapId,
|
||||
Orientation = FinalDirection[i]
|
||||
};
|
||||
}
|
||||
|
||||
return ([.. path], [.. edgeplannings]);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (cancellationToken is not null && cancellationToken.Value.IsCancellationRequested) throw new OperationCanceledException();
|
||||
else throw new TimeoutException();
|
||||
}
|
||||
}
|
||||
|
||||
public (GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanningWithAngle(Guid startNodeId, double theta, Guid goalId, double goalAngle, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
var goal = Nodes.FirstOrDefault(n => n.Id == goalId) ?? throw new Exception($"Destination {goalId} does not exist in the map");
|
||||
var startNode = Nodes.FirstOrDefault(n => n.Id == startNodeId) ?? throw new Exception($"Starting node {startNodeId} does not exist in the map");
|
||||
|
||||
using CancellationTokenSource cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken ?? CancellationToken.None);
|
||||
if (Options.TimeOut != null && Options.TimeOut.HasValue) cancellation.CancelAfter(Options.TimeOut.Value);
|
||||
try
|
||||
{
|
||||
var SSEAStarPlanner = new SSEAStarPlanner(Nodes, Edges, Options);
|
||||
(var path, var closesEdge) = SSEAStarPlanner.PlanningWithGoalAngle(startNode, theta,
|
||||
new GlobalNode() { Id = goal.Id, Name = goal.Name, X = goal.X, Y = goal.Y },
|
||||
goalAngle,
|
||||
cancellation.Token);
|
||||
|
||||
if (path is null || path.Length < 1)
|
||||
{
|
||||
var ForkliftV1 = new ForkliftPathPlanner();
|
||||
ForkliftV1.SetData([.. Nodes], [.. Edges]);
|
||||
return ForkliftV1.PathPlanningWithAngle(startNodeId, theta, goal.Id, goalAngle, cancellation.Token);
|
||||
}
|
||||
if (path.Length == 1) return (path, []);
|
||||
var edgeplannings = MathExtensions.GetEdgesPlanning([.. path], [.. Edges], closesEdge);
|
||||
|
||||
Orientation CurrenDirection = MathExtensions.GetOrientationStart(path[0], path[1], edgeplannings[0], theta, Ratio, Options.ChangeOrientationAngle);
|
||||
var FinalDirection = MathExtensions.GetOrientations(CurrenDirection, path, edgeplannings, Ratio, Options.ChangeOrientationAngle);
|
||||
for (int i = 0; i < path.Length; i++)
|
||||
{
|
||||
path[i] = new GlobalNode
|
||||
{
|
||||
Id = path[i].Id,
|
||||
X = path[i].X,
|
||||
Y = path[i].Y,
|
||||
Name = path[i].Name,
|
||||
MapId = path[i].MapId,
|
||||
Orientation = FinalDirection[i]
|
||||
};
|
||||
}
|
||||
|
||||
return ([.. path], [.. edgeplannings]);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (cancellationToken is not null && cancellationToken.Value.IsCancellationRequested) throw new OperationCanceledException();
|
||||
else throw new TimeoutException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
using RobotNet10.GlobalPathPlanner.Model;
|
||||
using RobotNet10.GlobalPathPlanner.Space;
|
||||
|
||||
namespace RobotNet10.GlobalPathPlanner.ForkliftV2;
|
||||
|
||||
public class SSEAStarPlanner(List<GlobalNode> Nodes, List<GlobalEdge> Edges, PathPlannerOptions Options)
|
||||
{
|
||||
private const double Ratio = 0.01;
|
||||
private GlobalNode? GetOnNode(double x, double y, double limitDistance, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
if (cancellationToken?.IsCancellationRequested == true) return null;
|
||||
KDTree KDTree = new(Nodes);
|
||||
return KDTree.FindNearest(x, y, limitDistance);
|
||||
}
|
||||
|
||||
private GlobalEdge[]? GetClosesEdges(GlobalNode nodeRef, double limitDistance)
|
||||
{
|
||||
double minDistance = double.MaxValue;
|
||||
List<GlobalEdge> edgesResult = [];
|
||||
foreach (var edge in Edges)
|
||||
{
|
||||
var startNode = Nodes.FirstOrDefault(node => node.Id == edge.StartNodeId);
|
||||
var endNode = Nodes.FirstOrDefault(node => node.Id == edge.EndNodeId);
|
||||
if (startNode is null || endNode is null) continue;
|
||||
|
||||
var distance = MathExtensions.DistanceToEdge(nodeRef, startNode, endNode, edge);
|
||||
if (distance < minDistance)
|
||||
{
|
||||
minDistance = distance;
|
||||
edgesResult = [edge];
|
||||
var reverseEdge = Edges.FirstOrDefault(e => e.EndNodeId == startNode.Id && e.StartNodeId == endNode.Id);
|
||||
if (reverseEdge != null) edgesResult = [.. edgesResult, reverseEdge];
|
||||
}
|
||||
}
|
||||
if (minDistance <= limitDistance) return [.. edgesResult];
|
||||
else return null;
|
||||
}
|
||||
|
||||
public List<GlobalNode> GetNegativeNodes(Guid nodeId)
|
||||
{
|
||||
var node = Nodes.FirstOrDefault(p => p.Id == nodeId);
|
||||
if (node is null) return [];
|
||||
|
||||
var listNodesNegative = new List<GlobalNode>();
|
||||
var listPaths = Edges.Where(p => p.StartNodeId == nodeId);
|
||||
foreach (var path in listPaths)
|
||||
{
|
||||
var negativeNode = Nodes.FirstOrDefault(p => p.Id == path.EndNodeId);
|
||||
if (negativeNode != null) listNodesNegative.Add(negativeNode);
|
||||
}
|
||||
return listNodesNegative;
|
||||
}
|
||||
|
||||
private double GetNegativeCost(SSEAStarNode currenNode, SSEAStarNode negativeNode)
|
||||
{
|
||||
var negativeEdges = Edges.Where(e => e.StartNodeId == currenNode.Id && e.EndNodeId == negativeNode.Id || e.StartNodeId == negativeNode.Id && e.EndNodeId == currenNode.Id).ToList();
|
||||
double minDistance = double.MaxValue;
|
||||
foreach (var edge in negativeEdges)
|
||||
{
|
||||
var startNode = Nodes.FirstOrDefault(n => n.Id == edge.StartNodeId);
|
||||
var endNode = Nodes.FirstOrDefault(n => n.Id == edge.EndNodeId);
|
||||
if (startNode is null || endNode is null) return 0;
|
||||
var distance = MathExtensions.GetEdgeLength(startNode, endNode, edge);
|
||||
if (distance < minDistance) minDistance = distance;
|
||||
}
|
||||
return minDistance != double.MaxValue ? minDistance : 0;
|
||||
}
|
||||
|
||||
private List<SSEAStarNode> GetNegativeAStarNodes(SSEAStarNode nodeCurrent, GlobalNode endNode)
|
||||
{
|
||||
var possiblePointNegative = new List<SSEAStarNode>();
|
||||
if (nodeCurrent.Id == endNode.Id) return possiblePointNegative;
|
||||
|
||||
var listNodesNegative = GetNegativeNodes(nodeCurrent.Id);
|
||||
|
||||
foreach (var negativeNode in listNodesNegative)
|
||||
{
|
||||
if (nodeCurrent.Parent is null) continue;
|
||||
var nodeDtoCurrent = Nodes.FirstOrDefault(n => n.Id == nodeCurrent.Id);
|
||||
var nodeDtoNegative = Nodes.FirstOrDefault(n => n.Id == negativeNode.Id);
|
||||
var nodeDtoParent = Nodes.FirstOrDefault(n => n.Id == nodeCurrent.Parent.Id);
|
||||
var negativeEdge = Edges.FirstOrDefault(e => e.StartNodeId == nodeCurrent.Id && e.EndNodeId == negativeNode.Id);
|
||||
var parentEdge = Edges.FirstOrDefault(e => e.EndNodeId == nodeCurrent.Id && e.StartNodeId == nodeCurrent.Parent.Id);
|
||||
|
||||
if (nodeDtoCurrent is null || nodeDtoNegative is null || negativeEdge is null) continue;
|
||||
|
||||
var nearNodeNevgative = MathExtensions.BezierPoint(Ratio, nodeDtoCurrent, nodeDtoNegative, negativeEdge);
|
||||
var nearNodeParent = nodeDtoParent is not null && parentEdge is not null ? MathExtensions.BezierPoint(Ratio, nodeDtoParent, nodeDtoCurrent, parentEdge) :
|
||||
new()
|
||||
{
|
||||
Id = nodeCurrent.Parent.Id,
|
||||
X = nodeCurrent.Parent.X,
|
||||
Y = nodeCurrent.Parent.Y,
|
||||
Name = nodeCurrent.Parent.Name
|
||||
};
|
||||
|
||||
var angle = MathExtensions.GetAngle(nodeDtoCurrent, nearNodeNevgative, nearNodeParent);
|
||||
Orientation orientation = angle < Options.ChangeOrientationAngle ? nodeCurrent.Orientation == Orientation.FORWARD ? Orientation.BACKWARD : Orientation.FORWARD : nodeCurrent.Orientation;
|
||||
|
||||
var nodeNegative = new SSEAStarNode
|
||||
{
|
||||
Id = negativeNode.Id,
|
||||
X = negativeNode.X,
|
||||
Y = negativeNode.Y,
|
||||
Name = negativeNode.Name,
|
||||
Orientation = orientation,
|
||||
Parent = nodeCurrent
|
||||
};
|
||||
|
||||
var cost = GetNegativeCost(nodeCurrent, nodeNegative);
|
||||
cost = cost > 0 ? cost : Math.Sqrt(Math.Pow(nodeCurrent.X - nodeNegative.X, 2) + Math.Pow(nodeCurrent.Y - nodeNegative.Y, 2));
|
||||
nodeNegative.Cost = cost + nodeCurrent.Cost + (orientation == Orientation.BACKWARD ? cost * Math.Sqrt(2) / 2 : 0.0);
|
||||
var distance = Math.Abs(endNode.X - nodeNegative.X) + Math.Abs(endNode.Y - nodeNegative.Y);
|
||||
nodeNegative.Heuristic = distance * (1 + (orientation == Orientation.BACKWARD ? Math.Sqrt(2) / 2 : 0.0));
|
||||
possiblePointNegative.Add(nodeNegative);
|
||||
}
|
||||
if (nodeCurrent.NegativeNodes is not null && nodeCurrent.NegativeNodes.Count > 0) possiblePointNegative.AddRange(nodeCurrent.NegativeNodes);
|
||||
return possiblePointNegative;
|
||||
}
|
||||
|
||||
public List<SSEAStarNode> Find(SSEAStarNode startNode, GlobalNode goal, Orientation goalDirection, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var activeNodes = new PriorityQueue<SSEAStarNode>((a, b) => a.TotalCost.CompareTo(b.TotalCost));
|
||||
var visitedNodes = new HashSet<SSEAStarNode>();
|
||||
var path = new List<SSEAStarNode>();
|
||||
var shortestPath = new HashSet<SSEAStarNode>();
|
||||
|
||||
activeNodes.Enqueue(startNode);
|
||||
|
||||
while (activeNodes.Count > 0 && (!cancellationToken.HasValue || !cancellationToken.Value.IsCancellationRequested))
|
||||
{
|
||||
var checkNode = activeNodes.Dequeue();
|
||||
if (checkNode.Id == goal.Id)
|
||||
{
|
||||
if (checkNode.Orientation == goalDirection || goalDirection == Orientation.NONE)
|
||||
{
|
||||
var node = checkNode;
|
||||
while (node != null)
|
||||
{
|
||||
if (cancellationToken.HasValue && cancellationToken.Value.IsCancellationRequested) return [];
|
||||
path.Add(node);
|
||||
node = node.Parent;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
else
|
||||
{
|
||||
var node = checkNode;
|
||||
while (node != null)
|
||||
{
|
||||
if (cancellationToken.HasValue && cancellationToken.Value.IsCancellationRequested) return [];
|
||||
shortestPath.Add(node);
|
||||
node = node.Parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visitedNodes.Add(checkNode);
|
||||
|
||||
var listNodeNegative = GetNegativeAStarNodes(checkNode, goal);
|
||||
foreach (var node in listNodeNegative)
|
||||
{
|
||||
if (visitedNodes.TryGetValue(node, out SSEAStarNode? value) && value is not null)
|
||||
{
|
||||
if (value.TotalCost > node.TotalCost || shortestPath.Any(n => n.Id == node.Id) && value.Parent is not null && value.Parent.Heuristic < checkNode.Heuristic)
|
||||
{
|
||||
visitedNodes.Remove(value);
|
||||
activeNodes.Enqueue(node);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
var activeNode = activeNodes.Items.FirstOrDefault(n => n.Id == node.Id && n.Orientation == node.Orientation);
|
||||
if (activeNode is not null && activeNode.TotalCost > node.TotalCost)
|
||||
{
|
||||
activeNodes.Items.Remove(activeNode);
|
||||
activeNodes.Enqueue(node);
|
||||
}
|
||||
else if (activeNode is null)
|
||||
{
|
||||
activeNodes.Enqueue(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public List<SSEAStarNode> Find(SSEAStarNode startNode, GlobalNode goal, double goalAngle, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var activeNodes = new PriorityQueue<SSEAStarNode>((a, b) => a.TotalCost.CompareTo(b.TotalCost));
|
||||
var visitedNodes = new HashSet<SSEAStarNode>();
|
||||
var path = new List<SSEAStarNode>();
|
||||
var shortestPath = new HashSet<SSEAStarNode>();
|
||||
|
||||
activeNodes.Enqueue(startNode);
|
||||
|
||||
while (activeNodes.Count > 0 && (!cancellationToken.HasValue || !cancellationToken.Value.IsCancellationRequested))
|
||||
{
|
||||
var checkNode = activeNodes.Dequeue();
|
||||
if (checkNode.Id == goal.Id)
|
||||
{
|
||||
if (checkNode.Parent is not null)
|
||||
{
|
||||
var nodeParentDto = Nodes.FirstOrDefault(n => n.Id == checkNode.Parent.Id);
|
||||
var edge = Edges.FirstOrDefault(e => e.EndNodeId == checkNode.Id && e.StartNodeId == checkNode.Parent.Id);
|
||||
if (edge is not null && nodeParentDto is not null)
|
||||
{
|
||||
var nearParent = MathExtensions.BezierPoint(Ratio, nodeParentDto, goal, edge);
|
||||
var nearGoalNode = new GlobalNode()
|
||||
{
|
||||
X = goal.X + Math.Cos(goalAngle * Math.PI / 180),
|
||||
Y = goal.Y + Math.Sin(goalAngle * Math.PI / 180),
|
||||
};
|
||||
|
||||
var angle = MathExtensions.GetAngle(goal, nearParent, nearGoalNode);
|
||||
Orientation goalDirection = angle < Options.ChangeOrientationAngle ? Orientation.BACKWARD : Orientation.FORWARD;
|
||||
if (checkNode.Orientation == goalDirection)
|
||||
{
|
||||
var returnNode = checkNode;
|
||||
while (returnNode != null)
|
||||
{
|
||||
if (cancellationToken.HasValue && cancellationToken.Value.IsCancellationRequested) return [];
|
||||
path.Add(returnNode);
|
||||
returnNode = returnNode.Parent;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var node = checkNode;
|
||||
while (node != null)
|
||||
{
|
||||
if (cancellationToken.HasValue && cancellationToken.Value.IsCancellationRequested) return [];
|
||||
shortestPath.Add(node);
|
||||
node = node.Parent;
|
||||
}
|
||||
}
|
||||
|
||||
visitedNodes.Add(checkNode);
|
||||
|
||||
var listNodeNegative = GetNegativeAStarNodes(checkNode, goal);
|
||||
foreach (var node in listNodeNegative)
|
||||
{
|
||||
if (visitedNodes.TryGetValue(node, out SSEAStarNode? value) && value is not null)
|
||||
{
|
||||
if (value.TotalCost > node.TotalCost || shortestPath.Any(n => n.Id == node.Id) && value.Parent is not null && value.Parent.Heuristic < checkNode.Heuristic)
|
||||
{
|
||||
visitedNodes.Remove(value);
|
||||
activeNodes.Enqueue(node);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
var activeNode = activeNodes.Items.FirstOrDefault(n => n.Id == node.Id && n.Orientation == node.Orientation);
|
||||
if (activeNode is not null && activeNode.TotalCost > node.TotalCost)
|
||||
{
|
||||
activeNodes.Items.Remove(activeNode);
|
||||
activeNodes.Enqueue(node);
|
||||
}
|
||||
else if (activeNode is null)
|
||||
{
|
||||
activeNodes.Enqueue(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private SSEAStarNode GetClosesNode(GlobalNode closesNode, GlobalNode goal, double theta)
|
||||
{
|
||||
SSEAStarNode closesAStarNode = new()
|
||||
{
|
||||
Id = closesNode.Id,
|
||||
X = closesNode.X,
|
||||
Y = closesNode.Y,
|
||||
Name = closesNode.Name,
|
||||
};
|
||||
foreach (var negativeNode in GetNegativeNodes(closesAStarNode.Id))
|
||||
{
|
||||
SSEAStarNode closesAStarNodeParent = new()
|
||||
{
|
||||
Id = closesNode.Id,
|
||||
X = closesNode.X,
|
||||
Y = closesNode.Y,
|
||||
Name = closesNode.Name,
|
||||
};
|
||||
var RobotNearNode = new GlobalNode()
|
||||
{
|
||||
X = closesAStarNode.X + Math.Cos(theta * Math.PI / 180),
|
||||
Y = closesAStarNode.Y + Math.Sin(theta * Math.PI / 180),
|
||||
};
|
||||
|
||||
var angle = MathExtensions.GetAngle(closesNode, negativeNode, RobotNearNode);
|
||||
Orientation orientation = angle < 91 ? Orientation.FORWARD : Orientation.BACKWARD;
|
||||
|
||||
var cost = GetNegativeCost(closesAStarNode, new() { Id = negativeNode.Id, X = negativeNode.X, Y = negativeNode.Y });
|
||||
cost = cost > 0 ? cost : Math.Sqrt(Math.Pow(closesAStarNode.X - negativeNode.X, 2) + Math.Pow(closesAStarNode.Y - negativeNode.Y, 2));
|
||||
cost += orientation == Orientation.BACKWARD ? cost * Math.Sqrt(2) / 2 : 0.0;
|
||||
closesAStarNodeParent.Orientation = orientation;
|
||||
closesAStarNode.NegativeNodes.Add(new()
|
||||
{
|
||||
Id = negativeNode.Id,
|
||||
X = negativeNode.X,
|
||||
Y = negativeNode.Y,
|
||||
Name = negativeNode.Name,
|
||||
Orientation = orientation,
|
||||
Cost = cost,
|
||||
Heuristic = Math.Abs(goal.X - negativeNode.X) + Math.Abs(goal.Y - negativeNode.Y),
|
||||
Parent = closesAStarNodeParent,
|
||||
});
|
||||
}
|
||||
return closesAStarNode;
|
||||
}
|
||||
|
||||
private SSEAStarNode[] GetStartNegativeNodes(GlobalEdge[] closesEdges, GlobalNode goal, SSEAStarNode robotNode, double theta)
|
||||
{
|
||||
List<SSEAStarNode> negativeNodes = [];
|
||||
foreach(var edge in closesEdges)
|
||||
{
|
||||
var endNode = Nodes.FirstOrDefault(n => n.Id == edge.EndNodeId);
|
||||
if (endNode == null) continue;
|
||||
SSEAStarNode closesAStarNodeParent = new()
|
||||
{
|
||||
Id = robotNode.Id,
|
||||
X = robotNode.X,
|
||||
Y = robotNode.Y,
|
||||
Name = robotNode.Name,
|
||||
};
|
||||
var RobotNearNode = new GlobalNode()
|
||||
{
|
||||
X = robotNode.X + Math.Cos(theta * Math.PI / 180),
|
||||
Y = robotNode.Y + Math.Sin(theta * Math.PI / 180),
|
||||
};
|
||||
var angle = MathExtensions.GetAngle(new() { X = robotNode.X, Y = robotNode.Y}, endNode, RobotNearNode);
|
||||
Orientation orientation = angle < Options.ChangeOrientationAngle ? Orientation.FORWARD : Orientation.BACKWARD;
|
||||
|
||||
double cost = Math.Sqrt(Math.Pow(robotNode.X - endNode.X, 2) + Math.Pow(robotNode.Y - endNode.Y, 2));
|
||||
cost += orientation == Orientation.BACKWARD ? cost * Math.Sqrt(2) / 2 : 0.0;
|
||||
closesAStarNodeParent.Orientation = orientation;
|
||||
negativeNodes.Add(new()
|
||||
{
|
||||
Id = endNode.Id,
|
||||
X = endNode.X,
|
||||
Y = endNode.Y,
|
||||
Name = endNode.Name,
|
||||
Orientation = orientation,
|
||||
Cost = cost,
|
||||
Heuristic = Math.Abs(goal.X - endNode.X) + Math.Abs(goal.Y - endNode.Y),
|
||||
Parent = closesAStarNodeParent,
|
||||
});
|
||||
}
|
||||
return [.. negativeNodes];
|
||||
}
|
||||
|
||||
public (GlobalNode[] pathNodes, GlobalEdge? closeEdges) PlanningWithFinalDirection(double x, double y, double theta, GlobalNode goal, Orientation goalDirection, double maxDistanceToEdge, double maxDistanceToNode, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
var Path = new List<GlobalNode>();
|
||||
|
||||
SSEAStarNode RobotNode = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
X = x,
|
||||
Y = y,
|
||||
Name = "RobotCurrentNode",
|
||||
};
|
||||
var closesNode = GetOnNode(x, y, maxDistanceToNode);
|
||||
if (closesNode is not null)
|
||||
{
|
||||
if (closesNode.Id == goal.Id) return ([goal], null);
|
||||
RobotNode = GetClosesNode(closesNode, goal, theta);
|
||||
}
|
||||
else
|
||||
{
|
||||
var closesEdges = GetClosesEdges(new() { X = x, Y = y }, maxDistanceToEdge);
|
||||
if (closesEdges is null || closesEdges.Length == 0) throw new Exception("The robot is too far from the route");
|
||||
|
||||
var edgeToGoal = closesEdges.FirstOrDefault(e => e.EndNodeId == goal.Id);
|
||||
if (edgeToGoal != null)
|
||||
{
|
||||
var endNode = Nodes.FirstOrDefault(n => n.Id == edgeToGoal.EndNodeId);
|
||||
if (endNode != null) return ([new() {
|
||||
Id = RobotNode.Id,
|
||||
X = RobotNode.X,
|
||||
Y = RobotNode.Y,
|
||||
Name = RobotNode.Name,
|
||||
MapId = endNode.MapId,
|
||||
}, endNode], edgeToGoal);
|
||||
}
|
||||
|
||||
RobotNode.NegativeNodes.AddRange(GetStartNegativeNodes(closesEdges, goal, RobotNode, theta));
|
||||
}
|
||||
|
||||
if (RobotNode.NegativeNodes.Count < 1) throw new Exception($"The path to {goal.Name} - {goal.Id} does not exist from [{x}, {y}]");
|
||||
|
||||
var path = Find(RobotNode, goal, goalDirection, cancellationToken);
|
||||
if (cancellationToken is not null && cancellationToken.Value.IsCancellationRequested) throw new OperationCanceledException();
|
||||
if (path is null || path.Count < 1) throw new Exception($"The path to {goal.Name} - {goal.Id} does not exist from [{x}, {y}]");
|
||||
path.Reverse();
|
||||
foreach (var node in path)
|
||||
{
|
||||
if (node.Id == path.First().Id)
|
||||
{
|
||||
Path.Add(new()
|
||||
{
|
||||
Id = node.Id,
|
||||
Name = node.Name,
|
||||
X = node.X,
|
||||
Y = node.Y,
|
||||
Orientation = node.Orientation,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
var nodedb = Nodes.FirstOrDefault(p => p.Id == node.Id) ?? throw new Exception($"Map data error: Node {node.Id} does not exist in the map");
|
||||
Path.Add(new GlobalNode
|
||||
{
|
||||
Id = nodedb.Id,
|
||||
X = nodedb.X,
|
||||
Y = nodedb.Y,
|
||||
Name = nodedb.Name,
|
||||
MapId = nodedb.MapId,
|
||||
Orientation = node.Orientation
|
||||
});
|
||||
}
|
||||
var startEdge = path.Count > 1 ? Edges.FirstOrDefault(e => e.StartNodeId == path[0].Id && e.EndNodeId == path[1].Id) : null;
|
||||
return ([.. Path], startEdge);
|
||||
}
|
||||
|
||||
public (GlobalNode[] pathNodes, GlobalEdge? closeEdges) PlanningWithGoalAngle(double x, double y, double theta, GlobalNode goal, double goalAngle, double maxDistanceToEdge, double maxDistanceToNode, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
var Path = new List<GlobalNode>();
|
||||
|
||||
SSEAStarNode RobotNode = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
X = x,
|
||||
Y = y,
|
||||
Name = "RobotCurrentNode",
|
||||
};
|
||||
var closesNode = GetOnNode(x, y, maxDistanceToNode);
|
||||
if (closesNode is not null)
|
||||
{
|
||||
if (closesNode.Id == goal.Id) return ([goal], null);
|
||||
RobotNode = GetClosesNode(closesNode, goal, theta);
|
||||
}
|
||||
else
|
||||
{
|
||||
var closesEdges = GetClosesEdges(new() { X = x, Y = y }, maxDistanceToEdge);
|
||||
if (closesEdges is null || closesEdges.Length == 0) throw new Exception("The robot is too far from the route");
|
||||
|
||||
var edgeToGoal = closesEdges.FirstOrDefault(e => e.EndNodeId == goal.Id);
|
||||
if (edgeToGoal != null)
|
||||
{
|
||||
var endNode = Nodes.FirstOrDefault(n => n.Id == edgeToGoal.EndNodeId);
|
||||
if (endNode != null) return ([new() {
|
||||
Id = RobotNode.Id,
|
||||
X = RobotNode.X,
|
||||
Y = RobotNode.Y,
|
||||
Name = RobotNode.Name,
|
||||
MapId = endNode.MapId,
|
||||
}, endNode], edgeToGoal);
|
||||
}
|
||||
|
||||
RobotNode.NegativeNodes.AddRange(GetStartNegativeNodes(closesEdges, goal, RobotNode, theta));
|
||||
}
|
||||
|
||||
if (RobotNode.NegativeNodes.Count < 1) throw new Exception($"The path to {goal.Name} - {goal.Id} does not exist from [{x}, {y}]");
|
||||
|
||||
var path = Find(RobotNode, goal, goalAngle, cancellationToken);
|
||||
if (cancellationToken is not null && cancellationToken.Value.IsCancellationRequested) throw new OperationCanceledException();
|
||||
if (path is null || path.Count < 1) throw new Exception($"The path to {goal.Name} - {goal.Id} does not exist from [{x}, {y}]");
|
||||
path.Reverse();
|
||||
foreach (var node in path)
|
||||
{
|
||||
if (node.Id == path.First().Id)
|
||||
{
|
||||
Path.Add(new()
|
||||
{
|
||||
Id = node.Id,
|
||||
Name = node.Name,
|
||||
X = node.X,
|
||||
Y = node.Y,
|
||||
Orientation = node.Orientation,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
var nodedb = Nodes.FirstOrDefault(p => p.Id == node.Id) ?? throw new Exception($"Map data error: Node {node.Id} does not exist in the map");
|
||||
Path.Add(new GlobalNode
|
||||
{
|
||||
Id = nodedb.Id,
|
||||
X = nodedb.X,
|
||||
Y = nodedb.Y,
|
||||
Name = nodedb.Name,
|
||||
MapId = nodedb.MapId,
|
||||
Orientation = node.Orientation
|
||||
});
|
||||
}
|
||||
var startEdge = path.Count > 1 ? Edges.FirstOrDefault(e => e.StartNodeId == path[0].Id && e.EndNodeId == path[1].Id) : null;
|
||||
return ([.. Path], startEdge);
|
||||
}
|
||||
|
||||
public (GlobalNode[] pathNodes, GlobalEdge? closeEdges) PlanningWithFinalDirection(GlobalNode startNode, double theta, GlobalNode goal, Orientation goalOrientation, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
var Path = new List<GlobalNode>();
|
||||
SSEAStarNode RobotNode = GetClosesNode(startNode, goal, theta);
|
||||
var path = Find(RobotNode, goal, goalOrientation, cancellationToken);
|
||||
if (cancellationToken is not null && cancellationToken.Value.IsCancellationRequested) throw new OperationCanceledException();
|
||||
if (path is null || path.Count < 1) throw new Exception($"The path to {goal.Name} - {goal.Id} does not exist from [{startNode.Name} - {startNode.Id}]");
|
||||
path.Reverse();
|
||||
foreach (var node in path)
|
||||
{
|
||||
var nodedb = Nodes.FirstOrDefault(p => p.Id == node.Id) ?? throw new Exception($"Map data error: Node {node.Id} does not exist in the map");
|
||||
Path.Add(new GlobalNode
|
||||
{
|
||||
Id = nodedb.Id,
|
||||
X = nodedb.X,
|
||||
Y = nodedb.Y,
|
||||
Name = nodedb.Name,
|
||||
MapId = nodedb.MapId,
|
||||
Orientation = node.Orientation
|
||||
});
|
||||
}
|
||||
var startEdge = path.Count > 1 ? Edges.FirstOrDefault(e => e.StartNodeId == path[0].Id && e.EndNodeId == path[1].Id) : null;
|
||||
return ([.. Path], startEdge);
|
||||
}
|
||||
|
||||
public (GlobalNode[] pathNodes, GlobalEdge? closeEdges) PlanningWithGoalAngle(GlobalNode startNode, double theta, GlobalNode goal, double goalAngle, CancellationToken? cancellationToken = null)
|
||||
{
|
||||
var Path = new List<GlobalNode>();
|
||||
SSEAStarNode RobotNode = GetClosesNode(startNode, goal, theta);
|
||||
var path = Find(RobotNode, goal, goalAngle, cancellationToken);
|
||||
if (cancellationToken is not null && cancellationToken.Value.IsCancellationRequested) throw new OperationCanceledException();
|
||||
if (path is null || path.Count < 1) throw new Exception($"The path to {goal.Name} - {goal.Id} does not exist from [{startNode.Name} - {startNode.Id}]");
|
||||
path.Reverse();
|
||||
foreach (var node in path)
|
||||
{
|
||||
var nodedb = Nodes.FirstOrDefault(p => p.Id == node.Id) ?? throw new Exception($"Map data error: Node {node.Id} does not exist in the map");
|
||||
Path.Add(new GlobalNode
|
||||
{
|
||||
Id = nodedb.Id,
|
||||
X = nodedb.X,
|
||||
Y = nodedb.Y,
|
||||
Name = nodedb.Name,
|
||||
MapId = nodedb.MapId,
|
||||
Orientation = node.Orientation
|
||||
});
|
||||
}
|
||||
var startEdge = path.Count > 1 ? Edges.FirstOrDefault(e => e.StartNodeId == path[0].Id && e.EndNodeId == path[1].Id) : null;
|
||||
return ([.. Path], startEdge);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using RobotNet10.GlobalPathPlanner.Model;
|
||||
|
||||
namespace RobotNet10.GlobalPathPlanner;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for path planning algorithms that calculate optimal routes between nodes in a graph.
|
||||
/// Supports various robot types (differential drive, forklift, omni-drive) with different planning strategies.
|
||||
/// </summary>
|
||||
public interface IPathPlanner
|
||||
{
|
||||
/// <summary>
|
||||
/// Sets the graph data (nodes and edges) that will be used for path planning.
|
||||
/// This method must be called before any path planning operations.
|
||||
/// </summary>
|
||||
/// <param name="nodes">Array of nodes representing waypoints in the map.</param>
|
||||
/// <param name="edges">Array of edges representing connections between nodes.</param>
|
||||
void SetData(GlobalNode[] nodes, GlobalEdge[] edges);
|
||||
|
||||
/// <summary>
|
||||
/// Configures the path planner with custom options such as distance limits, resolution, and timeout.
|
||||
/// This method is optional; if not called, default options will be used.
|
||||
/// </summary>
|
||||
/// <param name="options">Configuration options for the path planner.</param>
|
||||
void SetOptions(PathPlannerOptions options);
|
||||
|
||||
/// <summary>
|
||||
/// Calculates a path from the specified starting coordinates to the goal node.
|
||||
/// Uses A* algorithm to find the optimal route through the graph.
|
||||
/// </summary>
|
||||
/// <param name="x">Starting X coordinate.</param>
|
||||
/// <param name="y">Starting Y coordinate.</param>
|
||||
/// <param name="theta">Starting orientation angle in degrees.</param>
|
||||
/// <param name="goalId">Unique identifier of the goal node.</param>
|
||||
/// <param name="cancellationToken">Optional cancellation token to cancel the operation.</param>
|
||||
/// <returns>A tuple containing the sequence of nodes and edges that form the path from start to goal.</returns>
|
||||
/// <exception cref="Exception">Thrown when the goal node does not exist or no path can be found.</exception>
|
||||
/// <exception cref="OperationCanceledException">Thrown when the operation is cancelled.</exception>
|
||||
/// <exception cref="TimeoutException">Thrown when the operation times out.</exception>
|
||||
(GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanning(double x, double y, double theta, Guid goalId, CancellationToken? cancellationToken = null);
|
||||
|
||||
/// <summary>
|
||||
/// Calculates a path with a specified starting direction constraint.
|
||||
/// The planner will attempt to ensure the robot starts moving in the specified direction.
|
||||
/// </summary>
|
||||
/// <param name="x">Starting X coordinate.</param>
|
||||
/// <param name="y">Starting Y coordinate.</param>
|
||||
/// <param name="theta">Starting orientation angle in degrees.</param>
|
||||
/// <param name="goalId">Unique identifier of the goal node.</param>
|
||||
/// <param name="startDiretion">Desired starting direction (FORWARD, BACKWARD, or NONE to use default).</param>
|
||||
/// <param name="cancellationToken">Optional cancellation token to cancel the operation.</param>
|
||||
/// <returns>A tuple containing the sequence of nodes and edges that form the path from start to goal.</returns>
|
||||
/// <exception cref="Exception">Thrown when the goal node does not exist or no path can be found.</exception>
|
||||
/// <exception cref="OperationCanceledException">Thrown when the operation is cancelled.</exception>
|
||||
/// <exception cref="TimeoutException">Thrown when the operation times out.</exception>
|
||||
(GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanningWithStartDirection(double x, double y, double theta, Guid goalId, Orientation startDiretion = Orientation.NONE, CancellationToken? cancellationToken = null);
|
||||
|
||||
/// <summary>
|
||||
/// Calculates a path with a specified final direction constraint.
|
||||
/// The planner will attempt to ensure the robot arrives at the goal facing the specified direction.
|
||||
/// </summary>
|
||||
/// <param name="x">Starting X coordinate.</param>
|
||||
/// <param name="y">Starting Y coordinate.</param>
|
||||
/// <param name="theta">Starting orientation angle in degrees.</param>
|
||||
/// <param name="goalId">Unique identifier of the goal node.</param>
|
||||
/// <param name="goalDirection">Desired final direction at the goal (FORWARD, BACKWARD, or NONE to use default).</param>
|
||||
/// <param name="cancellationToken">Optional cancellation token to cancel the operation.</param>
|
||||
/// <returns>A tuple containing the sequence of nodes and edges that form the path from start to goal.</returns>
|
||||
/// <exception cref="Exception">Thrown when the goal node does not exist or no path can be found.</exception>
|
||||
/// <exception cref="OperationCanceledException">Thrown when the operation is cancelled.</exception>
|
||||
/// <exception cref="TimeoutException">Thrown when the operation times out.</exception>
|
||||
(GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanningWithFinalDirection(double x, double y, double theta, Guid goalId, Orientation goalDirection = Orientation.NONE, CancellationToken? cancellationToken = null);
|
||||
|
||||
/// <summary>
|
||||
/// Calculates a path with a specified final angle constraint.
|
||||
/// The planner will attempt to ensure the robot arrives at the goal with the specified orientation angle.
|
||||
/// </summary>
|
||||
/// <param name="x">Starting X coordinate.</param>
|
||||
/// <param name="y">Starting Y coordinate.</param>
|
||||
/// <param name="theta">Starting orientation angle in degrees.</param>
|
||||
/// <param name="goalId">Unique identifier of the goal node.</param>
|
||||
/// <param name="goalAngle">Desired final orientation angle in degrees at the goal.</param>
|
||||
/// <param name="cancellationToken">Optional cancellation token to cancel the operation.</param>
|
||||
/// <returns>A tuple containing the sequence of nodes and edges that form the path from start to goal.</returns>
|
||||
/// <exception cref="Exception">Thrown when the goal node does not exist or no path can be found.</exception>
|
||||
/// <exception cref="OperationCanceledException">Thrown when the operation is cancelled.</exception>
|
||||
/// <exception cref="TimeoutException">Thrown when the operation times out.</exception>
|
||||
(GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanningWithAngle(double x, double y, double theta, Guid goalId, double goalAngle, CancellationToken? cancellationToken = null);
|
||||
|
||||
/// <summary>
|
||||
/// Calculates a path from the specified starting node to the goal node.
|
||||
/// This overload uses node IDs instead of coordinates, which is more efficient when the robot is already at a known node.
|
||||
/// </summary>
|
||||
/// <param name="startNodeId">Unique identifier of the starting node.</param>
|
||||
/// <param name="theta">Current orientation angle in degrees at the start node.</param>
|
||||
/// <param name="goalId">Unique identifier of the goal node.</param>
|
||||
/// <param name="cancellationToken">Optional cancellation token to cancel the operation.</param>
|
||||
/// <returns>A tuple containing the sequence of nodes and edges that form the path from start to goal.</returns>
|
||||
/// <exception cref="Exception">Thrown when the start or goal node does not exist or no path can be found.</exception>
|
||||
/// <exception cref="OperationCanceledException">Thrown when the operation is cancelled.</exception>
|
||||
/// <exception cref="TimeoutException">Thrown when the operation times out.</exception>
|
||||
(GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanning(Guid startNodeId, double theta, Guid goalId, CancellationToken? cancellationToken = null);
|
||||
|
||||
/// <summary>
|
||||
/// Calculates a path from a starting node with a specified starting direction constraint.
|
||||
/// </summary>
|
||||
/// <param name="startNodeId">Unique identifier of the starting node.</param>
|
||||
/// <param name="theta">Current orientation angle in degrees at the start node.</param>
|
||||
/// <param name="goalId">Unique identifier of the goal node.</param>
|
||||
/// <param name="startDiretion">Desired starting direction (FORWARD, BACKWARD, or NONE to use default).</param>
|
||||
/// <param name="cancellationToken">Optional cancellation token to cancel the operation.</param>
|
||||
/// <returns>A tuple containing the sequence of nodes and edges that form the path from start to goal.</returns>
|
||||
/// <exception cref="Exception">Thrown when the start or goal node does not exist or no path can be found.</exception>
|
||||
/// <exception cref="OperationCanceledException">Thrown when the operation is cancelled.</exception>
|
||||
/// <exception cref="TimeoutException">Thrown when the operation times out.</exception>
|
||||
(GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanningWithStartDirection(Guid startNodeId, double theta, Guid goalId, Orientation startDiretion = Orientation.NONE, CancellationToken? cancellationToken = null);
|
||||
|
||||
/// <summary>
|
||||
/// Calculates a path from a starting node with a specified final direction constraint.
|
||||
/// </summary>
|
||||
/// <param name="startNodeId">Unique identifier of the starting node.</param>
|
||||
/// <param name="theta">Current orientation angle in degrees at the start node.</param>
|
||||
/// <param name="goalId">Unique identifier of the goal node.</param>
|
||||
/// <param name="goalDirection">Desired final direction at the goal (FORWARD, BACKWARD, or NONE to use default).</param>
|
||||
/// <param name="cancellationToken">Optional cancellation token to cancel the operation.</param>
|
||||
/// <returns>A tuple containing the sequence of nodes and edges that form the path from start to goal.</returns>
|
||||
/// <exception cref="Exception">Thrown when the start or goal node does not exist or no path can be found.</exception>
|
||||
/// <exception cref="OperationCanceledException">Thrown when the operation is cancelled.</exception>
|
||||
/// <exception cref="TimeoutException">Thrown when the operation times out.</exception>
|
||||
(GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanningWithFinalDirection(Guid startNodeId, double theta, Guid goalId, Orientation goalDirection = Orientation.NONE, CancellationToken? cancellationToken = null);
|
||||
|
||||
/// <summary>
|
||||
/// Calculates a path from a starting node with a specified final angle constraint.
|
||||
/// </summary>
|
||||
/// <param name="startNodeId">Unique identifier of the starting node.</param>
|
||||
/// <param name="theta">Current orientation angle in degrees at the start node.</param>
|
||||
/// <param name="goalId">Unique identifier of the goal node.</param>
|
||||
/// <param name="goalAngle">Desired final orientation angle in degrees at the goal.</param>
|
||||
/// <param name="cancellationToken">Optional cancellation token to cancel the operation.</param>
|
||||
/// <returns>A tuple containing the sequence of nodes and edges that form the path from start to goal.</returns>
|
||||
/// <exception cref="Exception">Thrown when the start or goal node does not exist or no path can be found.</exception>
|
||||
/// <exception cref="OperationCanceledException">Thrown when the operation is cancelled.</exception>
|
||||
/// <exception cref="TimeoutException">Thrown when the operation times out.</exception>
|
||||
(GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanningWithAngle(Guid startNodeId, double theta, Guid goalId, double goalAngle, CancellationToken? cancellationToken = null);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace RobotNet10.GlobalPathPlanner;
|
||||
|
||||
/// <summary>
|
||||
/// Factory interface for creating path planner instances optimized for different robot types.
|
||||
/// Each planner type uses algorithms and strategies tailored to the specific kinematics and constraints of the robot.
|
||||
/// </summary>
|
||||
public interface IPathPlannerFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a path planner optimized for differential drive robots.
|
||||
/// Differential drive robots have two independently driven wheels on a common axis.
|
||||
/// </summary>
|
||||
/// <returns>An instance of <see cref="IPathPlanner"/> configured for differential drive robots.</returns>
|
||||
IPathPlanner CreateDifferentialPlanner();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a path planner optimized for forklift robots (version 1).
|
||||
/// Forklift robots have specific constraints related to their lifting mechanism and turning radius.
|
||||
/// </summary>
|
||||
/// <returns>An instance of <see cref="IPathPlanner"/> configured for forklift robots.</returns>
|
||||
IPathPlanner CreateForkliftPlanner();
|
||||
|
||||
/// <summary>
|
||||
/// Creates an enhanced path planner optimized for forklift robots (version 2).
|
||||
/// This version uses improved algorithms (SSE A*) for better performance and path quality.
|
||||
/// </summary>
|
||||
/// <returns>An instance of <see cref="IPathPlanner"/> configured for forklift robots with enhanced algorithms.</returns>
|
||||
IPathPlanner CreateForkliftPlannerV2();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a path planner optimized for omni-directional drive robots.
|
||||
/// Omni-drive robots can move in any direction without changing orientation.
|
||||
/// Note: Currently uses the same planner as differential drive.
|
||||
/// </summary>
|
||||
/// <returns>An instance of <see cref="IPathPlanner"/> configured for omni-directional drive robots.</returns>
|
||||
IPathPlanner CreateOmniDrivePlanner();
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
using RobotNet10.GlobalPathPlanner.Model;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace RobotNet10.GlobalPathPlanner;
|
||||
|
||||
public class MathExtensions
|
||||
{
|
||||
public static GlobalNode BezierPoint([Range(0, 1)] double t, GlobalNode startNode, GlobalNode endNode, GlobalEdge edge)
|
||||
{
|
||||
t = Math.Clamp(t, 0.0, 1.0);
|
||||
if (edge.Degree == 1)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
X = startNode.X + t * (endNode.X - startNode.X),
|
||||
Y = startNode.Y + t * (endNode.Y - startNode.Y)
|
||||
};
|
||||
}
|
||||
else if (edge.Degree == 2)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
X = (1 - t) * (1 - t) * startNode.X + 2 * t * (1 - t) * edge.ControlPoint1X + t * t * endNode.X,
|
||||
Y = (1 - t) * (1 - t) * startNode.Y + 2 * t * (1 - t) * edge.ControlPoint1Y + t * t * endNode.Y
|
||||
};
|
||||
}
|
||||
else if (edge.Degree == 3)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
X = Math.Pow(1 - t, 3) * startNode.X + 3 * Math.Pow(1 - t, 2) * t * edge.ControlPoint1X + 3 * Math.Pow(t, 2) * (1 - t) * edge.ControlPoint2X + Math.Pow(t, 3) * endNode.X,
|
||||
Y = Math.Pow(1 - t, 3) * startNode.Y + 3 * Math.Pow(1 - t, 2) * t * edge.ControlPoint1Y + 3 * Math.Pow(t, 2) * (1 - t) * edge.ControlPoint2Y + Math.Pow(t, 3) * endNode.Y,
|
||||
};
|
||||
}
|
||||
return endNode;
|
||||
}
|
||||
|
||||
public static double GetEdgeLength(GlobalNode startNode, GlobalNode endNode, GlobalEdge edge)
|
||||
{
|
||||
var lineLength = Math.Sqrt(Math.Pow(startNode.X - endNode.X, 2) + Math.Pow(startNode.Y - endNode.Y, 2));
|
||||
if (edge.Degree == 1)
|
||||
{
|
||||
return lineLength;
|
||||
}
|
||||
else if (edge.Degree == 2)
|
||||
{
|
||||
if (lineLength <= 0) return 0;
|
||||
double step = 0.1 / lineLength;
|
||||
double distance = 0;
|
||||
|
||||
for (double t = step; t <= 1.001; t += step)
|
||||
{
|
||||
var timePoint = BezierPoint(t - step, startNode, endNode, edge);
|
||||
var lastTimePoint = BezierPoint(t, startNode, endNode, edge);
|
||||
distance += Math.Sqrt(Math.Pow(timePoint.X - lastTimePoint.X, 2) + Math.Pow(timePoint.Y - lastTimePoint.Y, 2));
|
||||
}
|
||||
|
||||
return Math.Round(distance, 3);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (lineLength <= 0) return 0;
|
||||
double step = 0.1 / lineLength;
|
||||
double distance = 0;
|
||||
for (double t = step; t <= 1.001; t += step)
|
||||
{
|
||||
var sTime = t - step;
|
||||
var timePoint = BezierPoint(1 - sTime, startNode, endNode, edge);
|
||||
sTime = t;
|
||||
var lastTimePoint = BezierPoint(1 - sTime, startNode, endNode, edge);
|
||||
|
||||
distance += Math.Sqrt(Math.Pow(timePoint.X - lastTimePoint.X, 2) + Math.Pow(timePoint.Y - lastTimePoint.Y, 2));
|
||||
}
|
||||
return Math.Round(distance, 3);
|
||||
}
|
||||
}
|
||||
|
||||
public static double GetEdgesLength(GlobalEdge[] edges, GlobalNode[] Nodes)
|
||||
{
|
||||
if (edges.Length == 0) return -1;
|
||||
double distance = 0;
|
||||
for (int i = 0; i < edges.Length; i++)
|
||||
{
|
||||
var edge = edges[i];
|
||||
var startNode = Nodes.FirstOrDefault(n => n.Id == edge.StartNodeId);
|
||||
var endNode = Nodes.FirstOrDefault(n => n.Id == edge.EndNodeId);
|
||||
if (startNode is null || endNode is null) return 999;
|
||||
distance += GetEdgeLength(startNode, endNode, edge);
|
||||
}
|
||||
return distance;
|
||||
}
|
||||
|
||||
// Phương pháp chính xác hơn sử dụng giải phương trình bậc 3
|
||||
public static double DistanceToQuadraticBezier(GlobalNode nodeRef, GlobalNode startNode, GlobalNode endNode, GlobalEdge edge)
|
||||
{
|
||||
// Đạo hàm của hàm khoảng cách bình phương theo t
|
||||
// Giải phương trình bậc 3: d/dt[|P(t) - G|²] = 0
|
||||
|
||||
double ax = startNode.X - 2 * edge.ControlPoint1X + endNode.X;
|
||||
double ay = startNode.Y - 2 * edge.ControlPoint1Y + endNode.Y;
|
||||
double bx = 2 * (edge.ControlPoint1X - startNode.X);
|
||||
double by = 2 * (edge.ControlPoint1Y - startNode.Y);
|
||||
double cx = startNode.X - nodeRef.X;
|
||||
double cy = startNode.Y - nodeRef.Y;
|
||||
|
||||
// Hệ số của phương trình bậc 3: At³ + Bt² + Ct + D = 0
|
||||
// Từ: (P(t) - G) · P'(t) = 0
|
||||
// Với P(t) = P₀ + bt + at², P'(t) = b + 2at, c = P₀ - G
|
||||
// Khai triển: c·b + (2c·a + b·b)·t + 3a·b·t² + 2a·a·t³ = 0
|
||||
// Vậy: A = 2a·a, B = 3a·b, C = 2c·a + b·b, D = c·b
|
||||
double A = 2 * (ax * ax + ay * ay);
|
||||
double B = 3 * (ax * bx + ay * by);
|
||||
double C = 2 * (ax * cx + ay * cy) + (bx * bx + by * by);
|
||||
double D = bx * cx + by * cy;
|
||||
|
||||
// Tìm các nghiệm của phương trình bậc 3
|
||||
var roots = SolveCubic(A, B, C, D);
|
||||
|
||||
double minDist = double.MaxValue;
|
||||
|
||||
// Kiểm tra khoảng cách tại các điểm tới hạn
|
||||
foreach (double t in roots)
|
||||
{
|
||||
if (t >= 0 && t <= 1)
|
||||
{
|
||||
GlobalNode p = BezierPoint(t, startNode, endNode, edge);
|
||||
double dist = nodeRef.DistanceTo(p);
|
||||
minDist = Math.Min(minDist, dist);
|
||||
}
|
||||
}
|
||||
|
||||
// Kiểm tra khoảng cách tại 2 đầu mút
|
||||
minDist = Math.Min(minDist, nodeRef.DistanceTo(startNode));
|
||||
minDist = Math.Min(minDist, nodeRef.DistanceTo(endNode));
|
||||
|
||||
return minDist;
|
||||
}
|
||||
|
||||
// Giải phương trình bậc 3: ax³ + bx² + cx + d = 0
|
||||
public static double[] SolveCubic(double a, double b, double c, double d)
|
||||
{
|
||||
if (Math.Abs(a) < 1e-10)
|
||||
{
|
||||
// Phương trình bậc 2
|
||||
return SolveQuadratic(b, c, d);
|
||||
}
|
||||
|
||||
// Chuẩn hóa về dạng x³ + px + q = 0
|
||||
b /= a;
|
||||
c /= a;
|
||||
d /= a;
|
||||
|
||||
double p = (3 * c - b * b) / 3;
|
||||
double q = (2 * b * b * b - 9 * b * c + 27 * d) / 27;
|
||||
|
||||
double discriminant = q * q / 4 + p * p * p / 27;
|
||||
|
||||
var roots = new List<double>();
|
||||
|
||||
if (discriminant >= 0)
|
||||
{
|
||||
// Một nghiệm thực
|
||||
double sqrtD = Math.Sqrt(discriminant);
|
||||
double term1 = -q / 2 + sqrtD;
|
||||
double term2 = -q / 2 - sqrtD;
|
||||
|
||||
// Tính căn bậc 3, xử lý số âm
|
||||
// Math.Pow(negative, 1.0/3) trả về NaN trong C#, cần xử lý riêng
|
||||
double u = term1 >= 0
|
||||
? Math.Pow(term1, 1.0 / 3)
|
||||
: -Math.Pow(-term1, 1.0 / 3);
|
||||
double v = term2 >= 0
|
||||
? Math.Pow(term2, 1.0 / 3)
|
||||
: -Math.Pow(-term2, 1.0 / 3);
|
||||
|
||||
double root = u + v - b / 3;
|
||||
if (!double.IsNaN(root) && !double.IsInfinity(root))
|
||||
{
|
||||
roots.Add(root);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Ba nghiệm thực (trường hợp lượng giác)
|
||||
double r = Math.Sqrt(-p * p * p / 27);
|
||||
|
||||
if (r > 1e-10)
|
||||
{
|
||||
double acosArg = -q / (2 * r);
|
||||
// Clamp acosArg vào [-1, 1] để tránh NaN
|
||||
acosArg = Math.Max(-1.0, Math.Min(1.0, acosArg));
|
||||
double phi = Math.Acos(acosArg);
|
||||
double temp = 2 * Math.Pow(r, 1.0 / 3);
|
||||
|
||||
roots.Add(temp * Math.Cos(phi / 3) - b / 3);
|
||||
roots.Add(temp * Math.Cos((phi + 2 * Math.PI) / 3) - b / 3);
|
||||
roots.Add(temp * Math.Cos((phi + 4 * Math.PI) / 3) - b / 3);
|
||||
}
|
||||
}
|
||||
|
||||
return [.. roots];
|
||||
}
|
||||
|
||||
// Giải phương trình bậc 2: ax² + bx + c = 0
|
||||
public static double[] SolveQuadratic(double a, double b, double c)
|
||||
{
|
||||
var roots = new List<double>();
|
||||
|
||||
if (Math.Abs(a) < 1e-10)
|
||||
{
|
||||
if (Math.Abs(b) > 1e-10)
|
||||
{
|
||||
roots.Add(-c / b);
|
||||
}
|
||||
return [.. roots];
|
||||
}
|
||||
|
||||
double discriminant = b * b - 4 * a * c;
|
||||
|
||||
if (discriminant >= 0)
|
||||
{
|
||||
double sqrtD = Math.Sqrt(discriminant);
|
||||
roots.Add((-b + sqrtD) / (2 * a));
|
||||
roots.Add((-b - sqrtD) / (2 * a));
|
||||
}
|
||||
|
||||
return [.. roots];
|
||||
}
|
||||
|
||||
// Phương pháp lấy mẫu - Đơn giản nhưng chậm hơn
|
||||
public static double DistanceToCubicBezier(GlobalNode nodeRef, GlobalNode startNode, GlobalNode endNode, GlobalEdge edge)
|
||||
{
|
||||
double bestT = 0;
|
||||
double minDistance = Math.Sqrt(Math.Pow(nodeRef.X - startNode.X, 2) + Math.Pow(nodeRef.Y - startNode.Y, 2));
|
||||
var length = GetEdgeLength(startNode, endNode, edge);
|
||||
double step = 0.1 / (length == 0 ? 0.1 : length);
|
||||
|
||||
// Bước 1: Lấy mẫu thô
|
||||
for (double t = 0; t <= 1; t += step)
|
||||
{
|
||||
GlobalNode p = BezierPoint(t, startNode, endNode, edge);
|
||||
double dist = nodeRef.DistanceTo(p);
|
||||
|
||||
if (dist < minDistance)
|
||||
{
|
||||
minDistance = dist;
|
||||
bestT = t;
|
||||
}
|
||||
}
|
||||
|
||||
// Bước 2: Tối ưu hóa chính xác hơn
|
||||
double epsilon = 1e-6;
|
||||
step = 0.01;
|
||||
|
||||
for (int iter = 0; iter < 10; iter++)
|
||||
{
|
||||
double t1 = Math.Max(0, bestT - step);
|
||||
double t2 = Math.Min(1, bestT + step);
|
||||
|
||||
double d0 = nodeRef.DistanceTo(BezierPoint(t1, startNode, endNode, edge));
|
||||
double d1 = nodeRef.DistanceTo(BezierPoint(bestT, startNode, endNode, edge));
|
||||
double d2 = nodeRef.DistanceTo(BezierPoint(t2, startNode, endNode, edge));
|
||||
|
||||
if (d0 < d1)
|
||||
{
|
||||
bestT = t1;
|
||||
minDistance = d0;
|
||||
}
|
||||
else if (d2 < d1)
|
||||
{
|
||||
bestT = t2;
|
||||
minDistance = d2;
|
||||
}
|
||||
else
|
||||
{
|
||||
step *= 0.5;
|
||||
}
|
||||
|
||||
if (step < epsilon) break;
|
||||
}
|
||||
|
||||
return minDistance;
|
||||
}
|
||||
|
||||
public static double DistanceToEdge(GlobalNode nodeRef, GlobalNode startNode, GlobalNode endNode, GlobalEdge edge)
|
||||
{
|
||||
if (edge.Degree == 2)
|
||||
{
|
||||
return DistanceToQuadraticBezier(nodeRef, startNode, endNode, edge);
|
||||
}
|
||||
else if (edge.Degree == 3)
|
||||
{
|
||||
return DistanceToCubicBezier(nodeRef, startNode, endNode, edge);
|
||||
}
|
||||
else
|
||||
{
|
||||
double time = 0;
|
||||
var edgeLengthSquared = Math.Pow(startNode.X - endNode.X, 2) + Math.Pow(startNode.Y - endNode.Y, 2);
|
||||
if (edgeLengthSquared > 0)
|
||||
{
|
||||
time = Math.Max(0, Math.Min(1, ((nodeRef.X - startNode.X) * (endNode.X - startNode.X) + (nodeRef.Y - startNode.Y) * (endNode.Y - startNode.Y)) / edgeLengthSquared));
|
||||
}
|
||||
|
||||
double nearestX = startNode.X + time * (endNode.X - startNode.X);
|
||||
double nearestY = startNode.Y + time * (endNode.Y - startNode.Y);
|
||||
|
||||
return Math.Sqrt(Math.Pow(nodeRef.X - nearestX, 2) + Math.Pow(nodeRef.Y - nearestY, 2));
|
||||
}
|
||||
}
|
||||
|
||||
public static double GetAngle(GlobalNode originNode, GlobalNode Node1, GlobalNode Node2)
|
||||
{
|
||||
double BA_x = Node1.X - originNode.X;
|
||||
double BA_y = Node1.Y - originNode.Y;
|
||||
double BC_x = Node2.X - originNode.X;
|
||||
double BC_y = Node2.Y - originNode.Y;
|
||||
// Tính độ dài của các vector AB và BC
|
||||
double lengthAB = Math.Sqrt(BA_x * BA_x + BA_y * BA_y);
|
||||
double lengthBC = Math.Sqrt(BC_x * BC_x + BC_y * BC_y);
|
||||
// Tính tích vô hướng của AB và BC
|
||||
double dotProduct = BA_x * BC_x + BA_y * BC_y;
|
||||
if (lengthAB * lengthBC == 0) return 0;
|
||||
if (dotProduct / (lengthAB * lengthBC) > 1) return 0;
|
||||
if (dotProduct / (lengthAB * lengthBC) < -1) return 180;
|
||||
return Math.Acos(dotProduct / (lengthAB * lengthBC)) * (180.0 / Math.PI);
|
||||
}
|
||||
|
||||
public static double GetStartAngle(GlobalNode startNode, GlobalNode endNode, GlobalEdge edge, double ratio)
|
||||
{
|
||||
GlobalNode NearNode = BezierPoint(ratio, startNode, endNode, edge);
|
||||
return Math.Atan2(NearNode.Y - startNode.Y, NearNode.X - startNode.X) * 180 / Math.PI;
|
||||
}
|
||||
|
||||
public static double GetEndAngle(GlobalNode startNode, GlobalNode endNode, GlobalEdge edge, double ratio)
|
||||
{
|
||||
GlobalNode NearNode = BezierPoint(ratio, startNode, endNode, edge);
|
||||
return Math.Atan2(endNode.Y - NearNode.Y, endNode.X - NearNode.X) * 180 / Math.PI;
|
||||
}
|
||||
|
||||
public static Orientation[] GetOrientations(Orientation currentDirection, GlobalNode[] nodes, GlobalEdge[] edges, double ratio, double changeOrientationAngle)
|
||||
{
|
||||
Orientation[] Orientations = new Orientation[nodes.Length];
|
||||
if (nodes.Length > 0) Orientations[0] = currentDirection;
|
||||
if (nodes.Length > 2)
|
||||
{
|
||||
for (int i = 1; i < nodes.Length - 1; i++)
|
||||
{
|
||||
GlobalNode startNode = BezierPoint(1 - ratio, nodes[i - 1], nodes[i], edges[i - 1]);
|
||||
GlobalNode endNode = BezierPoint(ratio, nodes[i], nodes[i + 1], edges[i]);
|
||||
var angle = GetAngle(nodes[i], startNode, endNode);
|
||||
if (angle < changeOrientationAngle) Orientations[i] = Orientations[i - 1] == Orientation.FORWARD ? Orientation.BACKWARD : Orientation.FORWARD;
|
||||
else Orientations[i] = Orientations[i - 1];
|
||||
}
|
||||
}
|
||||
if (nodes.Length > 1) Orientations[^1] = Orientations[^2];
|
||||
return Orientations;
|
||||
}
|
||||
|
||||
public static Orientation GetOrientationStart(GlobalNode nodeRef, GlobalNode nearNode, GlobalEdge edge, double InNodeAngle, double ratio, double changeOrientationAngle)
|
||||
{
|
||||
GlobalNode NearNode = BezierPoint(ratio, nodeRef, nearNode, edge);
|
||||
|
||||
var RobotNearNode = new GlobalNode()
|
||||
{
|
||||
X = nodeRef.X + Math.Cos(InNodeAngle * Math.PI / 180),
|
||||
Y = nodeRef.Y + Math.Sin(InNodeAngle * Math.PI / 180),
|
||||
};
|
||||
var angle = GetAngle(nodeRef, NearNode, RobotNearNode);
|
||||
return angle > changeOrientationAngle ? Orientation.BACKWARD : Orientation.FORWARD;
|
||||
}
|
||||
|
||||
public static Orientation GetOrientationEnd(GlobalNode nodeRef, GlobalNode nearNode, GlobalEdge edge, double InNodeAngle, double ratio, double changeOrientationAngle)
|
||||
{
|
||||
GlobalNode NearNode = BezierPoint(1 - ratio, nearNode, nodeRef, edge);
|
||||
|
||||
var RobotNearNode = new GlobalNode()
|
||||
{
|
||||
X = nodeRef.X + Math.Cos(InNodeAngle * Math.PI / 180),
|
||||
Y = nodeRef.Y + Math.Sin(InNodeAngle * Math.PI / 180),
|
||||
};
|
||||
var angle = GetAngle(nodeRef, NearNode, RobotNearNode);
|
||||
return angle > changeOrientationAngle ? Orientation.FORWARD : Orientation.BACKWARD;
|
||||
}
|
||||
|
||||
public static GlobalEdge[] GetEdgesPlanning(GlobalNode[] path, GlobalEdge[] edges, GlobalEdge? closesEdge)
|
||||
{
|
||||
var EdgesPlanning = new List<GlobalEdge>();
|
||||
for (int i = 0; i < path.Length - 1; i++)
|
||||
{
|
||||
var edge = edges.FirstOrDefault(e => e.StartNodeId == path[i].Id && e.EndNodeId == path[i + 1].Id);
|
||||
if (edge is null)
|
||||
{
|
||||
if (i != 0) return [];
|
||||
EdgesPlanning.Add(new GlobalEdge()
|
||||
{
|
||||
Id = closesEdge is null ? Guid.NewGuid() : closesEdge.Id,
|
||||
StartNodeId = path[i].Id,
|
||||
EndNodeId = path[i + 1].Id,
|
||||
Degree = 1,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
EdgesPlanning.Add(new()
|
||||
{
|
||||
Id = edge.Id,
|
||||
StartNodeId = path[i].Id,
|
||||
EndNodeId = path[i + 1].Id,
|
||||
Degree = edge.Degree,
|
||||
ControlPoint1X = edge.ControlPoint1X,
|
||||
ControlPoint1Y = edge.ControlPoint1Y,
|
||||
ControlPoint2X = edge.ControlPoint2X,
|
||||
ControlPoint2Y = edge.ControlPoint2Y
|
||||
});
|
||||
}
|
||||
return [.. EdgesPlanning];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace RobotNet10.GlobalPathPlanner.Model;
|
||||
|
||||
public class AStarNode
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public double X { get; set; }
|
||||
public double Y { get; set; }
|
||||
public double Cost { get; set; }
|
||||
public double Heuristic { get; set; }
|
||||
public double TotalCost => Cost + Heuristic;
|
||||
public string? Name { get; set; }
|
||||
public AStarNode? Parent { get; set; }
|
||||
public List<AStarNode> NegativeNodes { get; set; } = [];
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
if (obj is AStarNode other)
|
||||
return Id == other.Id;
|
||||
return false;
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return HashCode.Combine(Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace RobotNet10.GlobalPathPlanner.Model;
|
||||
|
||||
public record GlobalEdge
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid MapId { get; set; }
|
||||
public Guid StartNodeId { get; set; }
|
||||
public Guid EndNodeId { get; set; }
|
||||
public int Degree { get; set; }
|
||||
public double ControlPoint1X { get; set; }
|
||||
public double ControlPoint1Y { get; set; }
|
||||
public double ControlPoint2X { get; set; }
|
||||
public double ControlPoint2Y { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace RobotNet10.GlobalPathPlanner.Model;
|
||||
|
||||
public class GlobalNode
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid MapId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public double X { get; set; }
|
||||
public double Y { get; set; }
|
||||
public Orientation Orientation { get; set; }
|
||||
public override string ToString() => Name ?? typeof(GlobalNode).ToString();
|
||||
// Tính khoảng cách giữa 2 điểm
|
||||
public double DistanceTo(GlobalNode other)
|
||||
{
|
||||
double dx = X - other.X;
|
||||
double dy = Y - other.Y;
|
||||
return Math.Sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace RobotNet10.GlobalPathPlanner.Model;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a node in the KD-Tree structure.
|
||||
/// Immutable to prevent structural corruption.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Initializes a new KD-Tree node.
|
||||
/// </remarks>
|
||||
public class KDTreeNode(GlobalNode node, int axis, KDTreeNode? left = null, KDTreeNode? right = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// The spatial node stored at this tree node.
|
||||
/// </summary>
|
||||
public GlobalNode Node { get; } = node ?? throw new ArgumentNullException(nameof(node));
|
||||
|
||||
/// <summary>
|
||||
/// Left child (contains points with smaller values along the split axis).
|
||||
/// </summary>
|
||||
public KDTreeNode? Left { get; } = left;
|
||||
|
||||
/// <summary>
|
||||
/// Right child (contains points with larger values along the split axis).
|
||||
/// </summary>
|
||||
public KDTreeNode? Right { get; } = right;
|
||||
|
||||
/// <summary>
|
||||
/// The axis used for splitting: 0 for X, 1 for Y.
|
||||
/// </summary>
|
||||
public int Axis { get; } = axis;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace RobotNet10.GlobalPathPlanner.Model;
|
||||
|
||||
public enum Orientation
|
||||
{
|
||||
FORWARD,
|
||||
BACKWARD,
|
||||
NONE
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace RobotNet10.GlobalPathPlanner.Model;
|
||||
|
||||
public class SSEAStarNode
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public double X { get; set; }
|
||||
public double Y { get; set; }
|
||||
public Orientation Orientation { get; set; }
|
||||
public double Cost { get; set; }
|
||||
public double Heuristic { get; set; }
|
||||
public double TotalCost => Cost + Heuristic;
|
||||
public string? Name { get; set; }
|
||||
public SSEAStarNode? Parent { get; set; }
|
||||
public List<SSEAStarNode> NegativeNodes { get; set; } = [];
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
if (obj is SSEAStarNode other)
|
||||
return Id == other.Id && Orientation == other.Orientation;
|
||||
return false;
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return HashCode.Combine(Id, Orientation);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using RobotNet10.GlobalPathPlanner.Differential;
|
||||
using RobotNet10.GlobalPathPlanner.Forklift;
|
||||
using RobotNet10.GlobalPathPlanner.ForkliftV2;
|
||||
|
||||
namespace RobotNet10.GlobalPathPlanner;
|
||||
|
||||
public class PathPlannerFactory : IPathPlannerFactory
|
||||
{
|
||||
public IPathPlanner CreateDifferentialPlanner() => new DifferentialPlanner();
|
||||
|
||||
public IPathPlanner CreateForkliftPlanner() => new ForkliftPathPlanner();
|
||||
|
||||
public IPathPlanner CreateForkliftPlannerV2() => new ForkLiftPathPlannerV2();
|
||||
|
||||
public IPathPlanner CreateOmniDrivePlanner() => new DifferentialPlanner();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace RobotNet10.GlobalPathPlanner;
|
||||
|
||||
public record PathPlannerOptions
|
||||
{
|
||||
public double LimitDistanceToEdge { get; set; }
|
||||
public double LimitDistanceToNode { get; set; }
|
||||
public double ResolutionSplit { get; set; }
|
||||
public TimeSpan? TimeOut { get; set; }
|
||||
public double ChangeOrientationAngle { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace RobotNet10.GlobalPathPlanner;
|
||||
|
||||
public class PriorityQueue<T>(Comparison<T> comparison)
|
||||
{
|
||||
public List<T> Items => items;
|
||||
private readonly List<T> items = [];
|
||||
private readonly IComparer<T> comparer = Comparer<T>.Create(comparison);
|
||||
|
||||
public void Enqueue(T item)
|
||||
{
|
||||
int index = items.BinarySearch(item, comparer);
|
||||
if (index < 0) index = ~index;
|
||||
items.Insert(index, item);
|
||||
}
|
||||
|
||||
public T Dequeue()
|
||||
{
|
||||
if (items.Count == 0) throw new InvalidOperationException("Queue is empty");
|
||||
var item = items[0];
|
||||
items.RemoveAt(0);
|
||||
return item;
|
||||
}
|
||||
|
||||
public int Count => items.Count;
|
||||
}
|
||||
818
srcs/RobotNet10/Commons/RobotNet10.GlobalPathPlanner/README.md
Normal file
818
srcs/RobotNet10/Commons/RobotNet10.GlobalPathPlanner/README.md
Normal file
@@ -0,0 +1,818 @@
|
||||
# RobotNet10.GlobalPathPlanner
|
||||
|
||||
Thư viện path planning cho hệ thống robot, cung cấp các thuật toán tìm đường tối ưu trên graph với hỗ trợ nhiều loại robot khác nhau.
|
||||
|
||||
## 📋 Mục lục
|
||||
|
||||
- [Tổng quan](#tổng-quan)
|
||||
- [Cài đặt](#cài-đặt)
|
||||
- [Bắt đầu nhanh](#bắt-đầu-nhanh)
|
||||
- [Hướng dẫn sử dụng](#hướng-dẫn-sử-dụng)
|
||||
- [Các loại Path Planner](#các-loại-path-planner)
|
||||
- [Configuration Options](#configuration-options)
|
||||
- [API Reference](#api-reference)
|
||||
- [Ví dụ nâng cao](#ví-dụ-nâng-cao)
|
||||
- [Best Practices](#best-practices)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
## 🎯 Tổng quan
|
||||
|
||||
`RobotNet10.GlobalPathPlanner` là một thư viện .NET cung cấp các thuật toán path planning cho robot, bao gồm:
|
||||
|
||||
- **A* Algorithm**: Thuật toán tìm đường tối ưu trên graph
|
||||
- **SSE A* Algorithm**: State-Space Enhanced A* cho forklift robots
|
||||
- **Hỗ trợ nhiều loại robot**: Differential drive, Forklift, Omni-directional drive
|
||||
- **Bezier Curve Support**: Hỗ trợ edges với curves bậc 1, 2, hoặc 3
|
||||
- **KD-Tree Optimization**: Tối ưu hóa tìm kiếm nearest neighbor
|
||||
- **Cancellation & Timeout**: Hỗ trợ hủy bỏ và timeout cho operations
|
||||
|
||||
## 📦 Cài đặt
|
||||
|
||||
### Thêm Project Reference
|
||||
|
||||
Thêm reference đến project trong file `.csproj`:
|
||||
|
||||
```xml
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="path/to/RobotNet10.GlobalPathPlanner.csproj" />
|
||||
</ItemGroup>
|
||||
```
|
||||
|
||||
### Using Statements
|
||||
|
||||
```csharp
|
||||
using RobotNet10.GlobalPathPlanner;
|
||||
using RobotNet10.GlobalPathPlanner.Model;
|
||||
```
|
||||
|
||||
## 🚀 Bắt đầu nhanh
|
||||
|
||||
### Ví dụ cơ bản
|
||||
|
||||
```csharp
|
||||
using RobotNet10.GlobalPathPlanner;
|
||||
using RobotNet10.GlobalPathPlanner.Model;
|
||||
|
||||
// 1. Tạo factory và planner
|
||||
var factory = new PathPlannerFactory();
|
||||
var planner = factory.CreateDifferentialPlanner();
|
||||
|
||||
// 2. Chuẩn bị dữ liệu map (nodes và edges)
|
||||
var nodes = new GlobalNode[]
|
||||
{
|
||||
new GlobalNode { Id = Guid.NewGuid(), X = 0, Y = 0, Name = "Start" },
|
||||
new GlobalNode { Id = Guid.NewGuid(), X = 10, Y = 10, Name = "Goal" }
|
||||
};
|
||||
|
||||
var edges = new GlobalEdge[]
|
||||
{
|
||||
new GlobalEdge
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
StartNodeId = nodes[0].Id,
|
||||
EndNodeId = nodes[1].Id,
|
||||
Degree = 1 // Linear edge
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Set data cho planner
|
||||
planner.SetData(nodes, edges);
|
||||
|
||||
// 4. Tính toán đường đi
|
||||
var (pathNodes, pathEdges) = planner.PathPlanning(
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
theta: 0.0,
|
||||
goalId: nodes[1].Id
|
||||
);
|
||||
|
||||
// 5. Sử dụng kết quả
|
||||
foreach (var node in pathNodes)
|
||||
{
|
||||
Console.WriteLine($"Node: {node.Name} at ({node.X}, {node.Y})");
|
||||
}
|
||||
```
|
||||
|
||||
## 📖 Hướng dẫn sử dụng
|
||||
|
||||
### Workflow cơ bản
|
||||
|
||||
1. **Tạo Planner**: Sử dụng `PathPlannerFactory` để tạo planner phù hợp với loại robot
|
||||
2. **Set Data**: Gọi `SetData()` để load map data (nodes và edges)
|
||||
3. **Configure Options** (Optional): Gọi `SetOptions()` để cấu hình
|
||||
4. **Path Planning**: Gọi các method path planning để tính toán đường đi
|
||||
5. **Xử lý kết quả**: Sử dụng mảng nodes và edges trả về
|
||||
|
||||
### Tạo Path Planner
|
||||
|
||||
```csharp
|
||||
var factory = new PathPlannerFactory();
|
||||
|
||||
// Cho differential drive robot
|
||||
var differentialPlanner = factory.CreateDifferentialPlanner();
|
||||
|
||||
// Cho forklift robot (version 1)
|
||||
var forkliftPlanner = factory.CreateForkliftPlanner();
|
||||
|
||||
// Cho forklift robot (version 2 - enhanced)
|
||||
var forkliftPlannerV2 = factory.CreateForkliftPlannerV2();
|
||||
|
||||
// Cho omni-directional drive robot
|
||||
var omniPlanner = factory.CreateOmniDrivePlanner();
|
||||
```
|
||||
|
||||
### Load Map Data
|
||||
|
||||
```csharp
|
||||
// Chuẩn bị nodes (waypoints)
|
||||
var nodes = new GlobalNode[]
|
||||
{
|
||||
new GlobalNode
|
||||
{
|
||||
Id = Guid.Parse("..."),
|
||||
MapId = Guid.Parse("..."),
|
||||
X = 0.0,
|
||||
Y = 0.0,
|
||||
Name = "Node1",
|
||||
Orientation = Orientation.FORWARD
|
||||
},
|
||||
// ... thêm các nodes khác
|
||||
};
|
||||
|
||||
// Chuẩn bị edges (connections)
|
||||
var edges = new GlobalEdge[]
|
||||
{
|
||||
new GlobalEdge
|
||||
{
|
||||
Id = Guid.Parse("..."),
|
||||
MapId = Guid.Parse("..."),
|
||||
StartNodeId = nodes[0].Id,
|
||||
EndNodeId = nodes[1].Id,
|
||||
Degree = 2, // Bezier curve bậc 2
|
||||
ControlPoint1X = 5.0,
|
||||
ControlPoint1Y = 5.0,
|
||||
ControlPoint2X = 0.0,
|
||||
ControlPoint2Y = 0.0
|
||||
},
|
||||
// ... thêm các edges khác
|
||||
};
|
||||
|
||||
// Set data cho planner
|
||||
planner.SetData(nodes, edges);
|
||||
```
|
||||
|
||||
### Path Planning Methods
|
||||
|
||||
#### 1. Path Planning cơ bản (từ tọa độ)
|
||||
|
||||
```csharp
|
||||
var (nodes, edges) = planner.PathPlanning(
|
||||
x: 5.0, // Starting X coordinate
|
||||
y: 5.0, // Starting Y coordinate
|
||||
theta: 45.0, // Starting orientation (degrees)
|
||||
goalId: goalNodeId // Goal node ID
|
||||
);
|
||||
```
|
||||
|
||||
#### 2. Path Planning từ Node ID
|
||||
|
||||
```csharp
|
||||
var (nodes, edges) = planner.PathPlanning(
|
||||
startNodeId: startNodeId, // Starting node ID
|
||||
theta: 45.0, // Current orientation
|
||||
goalId: goalNodeId // Goal node ID
|
||||
);
|
||||
```
|
||||
|
||||
#### 3. Path Planning với Starting Direction
|
||||
|
||||
```csharp
|
||||
var (nodes, edges) = planner.PathPlanningWithStartDirection(
|
||||
x: 5.0,
|
||||
y: 5.0,
|
||||
theta: 45.0,
|
||||
goalId: goalNodeId,
|
||||
startDiretion: Orientation.FORWARD // FORWARD, BACKWARD, or NONE
|
||||
);
|
||||
```
|
||||
|
||||
#### 4. Path Planning với Final Direction
|
||||
|
||||
```csharp
|
||||
var (nodes, edges) = planner.PathPlanningWithFinalDirection(
|
||||
x: 5.0,
|
||||
y: 5.0,
|
||||
theta: 45.0,
|
||||
goalId: goalNodeId,
|
||||
goalDirection: Orientation.BACKWARD // FORWARD, BACKWARD, or NONE
|
||||
);
|
||||
```
|
||||
|
||||
#### 5. Path Planning với Final Angle
|
||||
|
||||
```csharp
|
||||
var (nodes, edges) = planner.PathPlanningWithAngle(
|
||||
x: 5.0,
|
||||
y: 5.0,
|
||||
theta: 45.0,
|
||||
goalId: goalNodeId,
|
||||
goalAngle: 90.0 // Desired final angle in degrees
|
||||
);
|
||||
```
|
||||
|
||||
### Sử dụng Cancellation Token
|
||||
|
||||
```csharp
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Set timeout
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(5));
|
||||
|
||||
try
|
||||
{
|
||||
var (nodes, edges) = planner.PathPlanning(
|
||||
x: 5.0,
|
||||
y: 5.0,
|
||||
theta: 45.0,
|
||||
goalId: goalNodeId,
|
||||
cancellationToken: cts.Token
|
||||
);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Console.WriteLine("Path planning was cancelled");
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
Console.WriteLine("Path planning timed out");
|
||||
}
|
||||
```
|
||||
|
||||
## 🤖 Các loại Path Planner
|
||||
|
||||
### 1. DifferentialPlanner
|
||||
|
||||
**Sử dụng cho:**
|
||||
- Differential drive robots (2 bánh độc lập)
|
||||
- Omni-directional drive robots
|
||||
|
||||
**Đặc điểm:**
|
||||
- Sử dụng A* algorithm cơ bản
|
||||
- Tính toán orientation (FORWARD/BACKWARD) tự động
|
||||
- Default options phù hợp cho hầu hết các trường hợp
|
||||
|
||||
**Ví dụ:**
|
||||
```csharp
|
||||
var planner = factory.CreateDifferentialPlanner();
|
||||
```
|
||||
|
||||
### 2. ForkliftPathPlanner
|
||||
|
||||
**Sử dụng cho:**
|
||||
- Forklift robots (phiên bản 1)
|
||||
|
||||
**Đặc điểm:**
|
||||
- Xử lý các ràng buộc đặc thù của forklift
|
||||
- Tính toán turning radius phù hợp
|
||||
|
||||
**Ví dụ:**
|
||||
```csharp
|
||||
var planner = factory.CreateForkliftPlanner();
|
||||
```
|
||||
|
||||
### 3. ForkLiftPathPlannerV2
|
||||
|
||||
**Sử dụng cho:**
|
||||
- Forklift robots (phiên bản 2 - enhanced)
|
||||
|
||||
**Đặc điểm:**
|
||||
- Sử dụng SSE A* algorithm (State-Space Enhanced A*)
|
||||
- Hiệu năng và chất lượng đường đi tốt hơn
|
||||
- Khuyến nghị sử dụng cho forklift
|
||||
|
||||
**Ví dụ:**
|
||||
```csharp
|
||||
var planner = factory.CreateForkliftPlannerV2();
|
||||
```
|
||||
|
||||
### 4. OmniDrivePlanner
|
||||
|
||||
**Sử dụng cho:**
|
||||
- Omni-directional drive robots
|
||||
|
||||
**Lưu ý:** Hiện tại sử dụng cùng planner với DifferentialPlanner
|
||||
|
||||
**Ví dụ:**
|
||||
```csharp
|
||||
var planner = factory.CreateOmniDrivePlanner();
|
||||
```
|
||||
|
||||
## ⚙️ Configuration Options
|
||||
|
||||
### PathPlannerOptions
|
||||
|
||||
```csharp
|
||||
var options = new PathPlannerOptions
|
||||
{
|
||||
// Khoảng cách tối đa đến edge để được coi là "gần" edge
|
||||
LimitDistanceToEdge = 1.0,
|
||||
|
||||
// Khoảng cách tối đa đến node để được coi là "tại" node
|
||||
LimitDistanceToNode = 0.3,
|
||||
|
||||
// Độ phân giải khi split path (khoảng cách giữa các điểm)
|
||||
ResolutionSplit = 0.1,
|
||||
|
||||
// Timeout cho path planning operation
|
||||
TimeOut = TimeSpan.FromSeconds(10),
|
||||
|
||||
// Góc (degrees) để quyết định đổi hướng (FORWARD/BACKWARD)
|
||||
ChangeOrientationAngle = 89.0
|
||||
};
|
||||
|
||||
planner.SetOptions(options);
|
||||
```
|
||||
|
||||
### Giải thích các tham số
|
||||
|
||||
| Tham số | Mô tả | Giá trị mặc định | Đơn vị |
|
||||
|---------|-------|------------------|--------|
|
||||
| `LimitDistanceToEdge` | Khoảng cách tối đa để được coi là gần edge | 1.0 | meters |
|
||||
| `LimitDistanceToNode` | Khoảng cách tối đa để được coi là tại node | 0.3 | meters |
|
||||
| `ResolutionSplit` | Độ phân giải khi chia nhỏ path | 0.1 | meters |
|
||||
| `TimeOut` | Timeout cho operation | null (no timeout) | TimeSpan |
|
||||
| `ChangeOrientationAngle` | Góc để quyết định đổi hướng | 89.0 | degrees |
|
||||
|
||||
## 📚 API Reference
|
||||
|
||||
### IPathPlanner Interface
|
||||
|
||||
#### SetData
|
||||
|
||||
```csharp
|
||||
void SetData(GlobalNode[] nodes, GlobalEdge[] edges)
|
||||
```
|
||||
|
||||
Thiết lập dữ liệu graph (nodes và edges) cho planner. **Phải gọi trước khi thực hiện path planning.**
|
||||
|
||||
#### SetOptions
|
||||
|
||||
```csharp
|
||||
void SetOptions(PathPlannerOptions options)
|
||||
```
|
||||
|
||||
Cấu hình options cho planner. **Optional**, nếu không gọi sẽ dùng default options.
|
||||
|
||||
#### PathPlanning (từ tọa độ)
|
||||
|
||||
```csharp
|
||||
(GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanning(
|
||||
double x,
|
||||
double y,
|
||||
double theta,
|
||||
Guid goalId,
|
||||
CancellationToken? cancellationToken = null
|
||||
)
|
||||
```
|
||||
|
||||
Tính toán đường đi từ tọa độ (x, y) đến goal node.
|
||||
|
||||
#### PathPlanning (từ Node ID)
|
||||
|
||||
```csharp
|
||||
(GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanning(
|
||||
Guid startNodeId,
|
||||
double theta,
|
||||
Guid goalId,
|
||||
CancellationToken? cancellationToken = null
|
||||
)
|
||||
```
|
||||
|
||||
Tính toán đường đi từ start node đến goal node. **Hiệu quả hơn** khi robot đã ở tại một node đã biết.
|
||||
|
||||
#### PathPlanningWithStartDirection
|
||||
|
||||
```csharp
|
||||
(GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanningWithStartDirection(
|
||||
double x,
|
||||
double y,
|
||||
double theta,
|
||||
Guid goalId,
|
||||
Orientation startDiretion = Orientation.NONE,
|
||||
CancellationToken? cancellationToken = null
|
||||
)
|
||||
```
|
||||
|
||||
Tính toán đường đi với ràng buộc hướng bắt đầu.
|
||||
|
||||
#### PathPlanningWithFinalDirection
|
||||
|
||||
```csharp
|
||||
(GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanningWithFinalDirection(
|
||||
double x,
|
||||
double y,
|
||||
double theta,
|
||||
Guid goalId,
|
||||
Orientation goalDirection = Orientation.NONE,
|
||||
CancellationToken? cancellationToken = null
|
||||
)
|
||||
```
|
||||
|
||||
Tính toán đường đi với ràng buộc hướng kết thúc.
|
||||
|
||||
#### PathPlanningWithAngle
|
||||
|
||||
```csharp
|
||||
(GlobalNode[] Nodes, GlobalEdge[] Edges) PathPlanningWithAngle(
|
||||
double x,
|
||||
double y,
|
||||
double theta,
|
||||
Guid goalId,
|
||||
double goalAngle,
|
||||
CancellationToken? cancellationToken = null
|
||||
)
|
||||
```
|
||||
|
||||
Tính toán đường đi với ràng buộc góc kết thúc.
|
||||
|
||||
### Data Models
|
||||
|
||||
#### GlobalNode
|
||||
|
||||
```csharp
|
||||
public class GlobalNode
|
||||
{
|
||||
public Guid Id { get; set; } // Unique identifier
|
||||
public Guid MapId { get; set; } // Map identifier
|
||||
public string? Name { get; set; } // Node name
|
||||
public double X { get; set; } // X coordinate
|
||||
public double Y { get; set; } // Y coordinate
|
||||
public Orientation Orientation { get; set; } // FORWARD, BACKWARD, or NONE
|
||||
|
||||
public double DistanceTo(GlobalNode other); // Calculate distance to another node
|
||||
}
|
||||
```
|
||||
|
||||
#### GlobalEdge
|
||||
|
||||
```csharp
|
||||
public record GlobalEdge
|
||||
{
|
||||
public Guid Id { get; set; } // Unique identifier
|
||||
public Guid MapId { get; set; } // Map identifier
|
||||
public Guid StartNodeId { get; set; } // Start node ID
|
||||
public Guid EndNodeId { get; set; } // End node ID
|
||||
public int Degree { get; set; } // Curve degree (1, 2, or 3)
|
||||
public double ControlPoint1X { get; set; } // Control point 1 X (for Bezier)
|
||||
public double ControlPoint1Y { get; set; } // Control point 1 Y (for Bezier)
|
||||
public double ControlPoint2X { get; set; } // Control point 2 X (for Bezier)
|
||||
public double ControlPoint2Y { get; set; } // Control point 2 Y (for Bezier)
|
||||
}
|
||||
```
|
||||
|
||||
#### Orientation Enum
|
||||
|
||||
```csharp
|
||||
public enum Orientation
|
||||
{
|
||||
FORWARD, // Di chuyển tiến
|
||||
BACKWARD, // Di chuyển lùi
|
||||
NONE // Không ràng buộc
|
||||
}
|
||||
```
|
||||
|
||||
## 💡 Ví dụ nâng cao
|
||||
|
||||
### Ví dụ 1: Path Planning với Error Handling
|
||||
|
||||
```csharp
|
||||
try
|
||||
{
|
||||
var factory = new PathPlannerFactory();
|
||||
var planner = factory.CreateDifferentialPlanner();
|
||||
|
||||
planner.SetData(nodes, edges);
|
||||
|
||||
var options = new PathPlannerOptions
|
||||
{
|
||||
LimitDistanceToEdge = 1.0,
|
||||
LimitDistanceToNode = 0.3,
|
||||
ResolutionSplit = 0.1,
|
||||
TimeOut = TimeSpan.FromSeconds(5),
|
||||
ChangeOrientationAngle = 89.0
|
||||
};
|
||||
planner.SetOptions(options);
|
||||
|
||||
var (pathNodes, pathEdges) = planner.PathPlanning(
|
||||
x: currentX,
|
||||
y: currentY,
|
||||
theta: currentTheta,
|
||||
goalId: goalNodeId
|
||||
);
|
||||
|
||||
Console.WriteLine($"Path found with {pathNodes.Length} nodes");
|
||||
}
|
||||
catch (Exception ex) when (ex.Message.Contains("does not exist"))
|
||||
{
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
// Handle case when goal node doesn't exist or no path found
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
Console.WriteLine("Path planning timed out");
|
||||
// Handle timeout
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Console.WriteLine("Path planning was cancelled");
|
||||
// Handle cancellation
|
||||
}
|
||||
```
|
||||
|
||||
### Ví dụ 2: Sử dụng với Async/Await
|
||||
|
||||
```csharp
|
||||
public async Task<(GlobalNode[] Nodes, GlobalEdge[] Edges)> PlanPathAsync(
|
||||
IPathPlanner planner,
|
||||
double x,
|
||||
double y,
|
||||
double theta,
|
||||
Guid goalId)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
return planner.PathPlanning(x, y, theta, goalId, cts.Token);
|
||||
}, cts.Token);
|
||||
}
|
||||
|
||||
// Usage
|
||||
var planner = factory.CreateDifferentialPlanner();
|
||||
planner.SetData(nodes, edges);
|
||||
|
||||
var (pathNodes, pathEdges) = await PlanPathAsync(
|
||||
planner,
|
||||
currentX,
|
||||
currentY,
|
||||
currentTheta,
|
||||
goalNodeId
|
||||
);
|
||||
```
|
||||
|
||||
### Ví dụ 3: Path Planning với Direction Constraints
|
||||
|
||||
```csharp
|
||||
// Robot cần bắt đầu di chuyển lùi
|
||||
var (nodes, edges) = planner.PathPlanningWithStartDirection(
|
||||
x: 5.0,
|
||||
y: 5.0,
|
||||
theta: 180.0,
|
||||
goalId: goalNodeId,
|
||||
startDiretion: Orientation.BACKWARD
|
||||
);
|
||||
|
||||
// Robot cần đến đích và quay mặt về phía trước
|
||||
var (nodes2, edges2) = planner.PathPlanningWithFinalDirection(
|
||||
x: 5.0,
|
||||
y: 5.0,
|
||||
theta: 0.0,
|
||||
goalId: goalNodeId,
|
||||
goalDirection: Orientation.FORWARD
|
||||
);
|
||||
|
||||
// Robot cần đến đích với góc cụ thể (90 độ)
|
||||
var (nodes3, edges3) = planner.PathPlanningWithAngle(
|
||||
x: 5.0,
|
||||
y: 5.0,
|
||||
theta: 0.0,
|
||||
goalId: goalNodeId,
|
||||
goalAngle: 90.0
|
||||
);
|
||||
```
|
||||
|
||||
### Ví dụ 4: Xử lý kết quả Path
|
||||
|
||||
```csharp
|
||||
var (pathNodes, pathEdges) = planner.PathPlanning(
|
||||
startNodeId: startNodeId,
|
||||
theta: currentTheta,
|
||||
goalId: goalNodeId
|
||||
);
|
||||
|
||||
// Kiểm tra kết quả
|
||||
if (pathNodes.Length == 0)
|
||||
{
|
||||
Console.WriteLine("No path found");
|
||||
return;
|
||||
}
|
||||
|
||||
// In thông tin path
|
||||
Console.WriteLine($"Path contains {pathNodes.Length} nodes and {pathEdges.Length} edges");
|
||||
|
||||
for (int i = 0; i < pathNodes.Length; i++)
|
||||
{
|
||||
var node = pathNodes[i];
|
||||
Console.WriteLine($"Node {i}: {node.Name} at ({node.X:F2}, {node.Y:F2}) " +
|
||||
$"Orientation: {node.Orientation}");
|
||||
|
||||
if (i < pathEdges.Length)
|
||||
{
|
||||
var edge = pathEdges[i];
|
||||
Console.WriteLine($" Edge {i}: {edge.Id} (Degree: {edge.Degree})");
|
||||
}
|
||||
}
|
||||
|
||||
// Tính tổng khoảng cách
|
||||
double totalDistance = 0;
|
||||
for (int i = 0; i < pathNodes.Length - 1; i++)
|
||||
{
|
||||
totalDistance += pathNodes[i].DistanceTo(pathNodes[i + 1]);
|
||||
}
|
||||
Console.WriteLine($"Total path distance: {totalDistance:F2} meters");
|
||||
```
|
||||
|
||||
## ✅ Best Practices
|
||||
|
||||
### 1. Chọn đúng Planner cho Robot Type
|
||||
|
||||
```csharp
|
||||
// ✅ Đúng: Sử dụng ForkliftPlannerV2 cho forklift
|
||||
var forkliftPlanner = factory.CreateForkliftPlannerV2();
|
||||
|
||||
// ❌ Sai: Không dùng DifferentialPlanner cho forklift
|
||||
var wrongPlanner = factory.CreateDifferentialPlanner(); // Không phù hợp
|
||||
```
|
||||
|
||||
### 2. Luôn Set Data trước khi Planning
|
||||
|
||||
```csharp
|
||||
// ✅ Đúng
|
||||
planner.SetData(nodes, edges);
|
||||
var (nodes, edges) = planner.PathPlanning(...);
|
||||
|
||||
// ❌ Sai: Quên set data
|
||||
var (nodes, edges) = planner.PathPlanning(...); // Sẽ lỗi
|
||||
```
|
||||
|
||||
### 3. Sử dụng Node ID khi có thể
|
||||
|
||||
```csharp
|
||||
// ✅ Tốt hơn: Sử dụng Node ID khi robot đã ở tại node
|
||||
var (nodes, edges) = planner.PathPlanning(startNodeId, theta, goalId);
|
||||
|
||||
// ⚠️ Chấp nhận được: Sử dụng tọa độ khi robot không ở node
|
||||
var (nodes, edges) = planner.PathPlanning(x, y, theta, goalId);
|
||||
```
|
||||
|
||||
### 4. Cấu hình Timeout cho Operations dài
|
||||
|
||||
```csharp
|
||||
var options = new PathPlannerOptions
|
||||
{
|
||||
TimeOut = TimeSpan.FromSeconds(10) // Tránh hang
|
||||
};
|
||||
planner.SetOptions(options);
|
||||
```
|
||||
|
||||
### 5. Xử lý Exceptions đúng cách
|
||||
|
||||
```csharp
|
||||
try
|
||||
{
|
||||
var (nodes, edges) = planner.PathPlanning(...);
|
||||
}
|
||||
catch (Exception ex) when (ex.Message.Contains("does not exist"))
|
||||
{
|
||||
// Handle: Goal không tồn tại hoặc không tìm thấy đường đi
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
// Handle: Timeout
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Handle: Cancellation
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Reuse Planner Instance
|
||||
|
||||
```csharp
|
||||
// ✅ Tốt: Tạo một lần, dùng nhiều lần
|
||||
var planner = factory.CreateDifferentialPlanner();
|
||||
planner.SetData(nodes, edges);
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var (pathNodes, pathEdges) = planner.PathPlanning(...);
|
||||
// Process path
|
||||
}
|
||||
|
||||
// ❌ Không hiệu quả: Tạo mới mỗi lần
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var planner = factory.CreateDifferentialPlanner(); // Không cần thiết
|
||||
planner.SetData(nodes, edges);
|
||||
var (pathNodes, pathEdges) = planner.PathPlanning(...);
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Vấn đề: "Destination does not exist in the map"
|
||||
|
||||
**Nguyên nhân:** Goal node ID không có trong danh sách nodes đã set.
|
||||
|
||||
**Giải pháp:**
|
||||
```csharp
|
||||
// Kiểm tra goal node có tồn tại không
|
||||
var goalNode = nodes.FirstOrDefault(n => n.Id == goalId);
|
||||
if (goalNode == null)
|
||||
{
|
||||
throw new ArgumentException($"Goal node {goalId} not found in map");
|
||||
}
|
||||
```
|
||||
|
||||
### Vấn đề: "The path does not exist"
|
||||
|
||||
**Nguyên nhân:** Không có đường đi từ start đến goal (graph không liên thông).
|
||||
|
||||
**Giải pháp:**
|
||||
- Kiểm tra edges có kết nối start và goal không
|
||||
- Đảm bảo graph liên thông
|
||||
- Kiểm tra `LimitDistanceToEdge` và `LimitDistanceToNode` có quá nhỏ không
|
||||
|
||||
### Vấn đề: Timeout thường xuyên
|
||||
|
||||
**Nguyên nhân:** Graph quá lớn hoặc phức tạp.
|
||||
|
||||
**Giải pháp:**
|
||||
```csharp
|
||||
// Tăng timeout
|
||||
var options = new PathPlannerOptions
|
||||
{
|
||||
TimeOut = TimeSpan.FromSeconds(30) // Tăng từ 10s lên 30s
|
||||
};
|
||||
planner.SetOptions(options);
|
||||
```
|
||||
|
||||
### Vấn đề: Path không mượt
|
||||
|
||||
**Nguyên nhân:** `ResolutionSplit` quá lớn.
|
||||
|
||||
**Giải pháp:**
|
||||
```csharp
|
||||
// Giảm ResolutionSplit để có nhiều điểm hơn
|
||||
var options = new PathPlannerOptions
|
||||
{
|
||||
ResolutionSplit = 0.05 // Giảm từ 0.1 xuống 0.05
|
||||
};
|
||||
planner.SetOptions(options);
|
||||
```
|
||||
|
||||
### Vấn đề: Robot không đổi hướng đúng
|
||||
|
||||
**Nguyên nhân:** `ChangeOrientationAngle` không phù hợp.
|
||||
|
||||
**Giải pháp:**
|
||||
```csharp
|
||||
// Điều chỉnh góc đổi hướng
|
||||
var options = new PathPlannerOptions
|
||||
{
|
||||
ChangeOrientationAngle = 85.0 // Thử các giá trị khác nhau
|
||||
};
|
||||
planner.SetOptions(options);
|
||||
```
|
||||
|
||||
## 📝 Lưu ý
|
||||
|
||||
- **Thread Safety**: Planner instances không thread-safe. Mỗi thread nên có planner riêng.
|
||||
- **Memory**: Planner lưu toàn bộ nodes và edges trong memory. Với map lớn, cần xem xét memory usage.
|
||||
- **Performance**: Path planning với Node ID nhanh hơn so với tọa độ vì không cần tìm nearest node/edge.
|
||||
|
||||
## 🤝 Đóng góp
|
||||
|
||||
Nếu bạn phát hiện bug hoặc có đề xuất cải thiện, vui lòng tạo issue hoặc pull request.
|
||||
|
||||
## 📄 License
|
||||
|
||||
[Thêm thông tin license nếu có]
|
||||
|
||||
---
|
||||
|
||||
**Version**: 1.0
|
||||
**Last Updated**: 2024
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>true</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,299 @@
|
||||
using RobotNet10.GlobalPathPlanner.Model;
|
||||
|
||||
namespace RobotNet10.GlobalPathPlanner.Space;
|
||||
|
||||
/// <summary>
|
||||
/// KD-Tree implementation for efficient 2D spatial search of nodes.
|
||||
/// Thread-safe for read operations after construction.
|
||||
/// Time complexity: O(n log n) build, O(log n) search average case.
|
||||
/// </summary>
|
||||
public class KDTree
|
||||
{
|
||||
private readonly KDTreeNode? _root;
|
||||
private readonly List<GlobalNode> _nodes;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new KD-Tree from a collection of nodes.
|
||||
/// </summary>
|
||||
/// <param name="nodes">The nodes to index. Original collection is not modified.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when nodes is null.</exception>
|
||||
public KDTree(IEnumerable<GlobalNode> nodes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nodes);
|
||||
|
||||
// Create a copy to avoid mutating input
|
||||
_nodes = [.. nodes];
|
||||
|
||||
if (_nodes.Count == 0)
|
||||
{
|
||||
_root = null;
|
||||
return;
|
||||
}
|
||||
|
||||
_root = BuildTree(0, _nodes.Count - 1, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of nodes in the tree.
|
||||
/// </summary>
|
||||
public int Count => _nodes.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the KD-Tree using index-based recursion to avoid memory allocation overhead.
|
||||
/// Time complexity: O(n log n)
|
||||
/// </summary>
|
||||
private KDTreeNode? BuildTree(int start, int end, int depth)
|
||||
{
|
||||
if (start > end)
|
||||
return null;
|
||||
|
||||
int axis = depth % 2;
|
||||
|
||||
// Use QuickSelect to find median without full sort
|
||||
int medianIndex = QuickSelect(start, end, (start + end) / 2, axis);
|
||||
|
||||
return new KDTreeNode(
|
||||
node: _nodes[medianIndex],
|
||||
axis: axis,
|
||||
left: BuildTree(start, medianIndex - 1, depth + 1),
|
||||
right: BuildTree(medianIndex + 1, end, depth + 1)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// QuickSelect algorithm to find the k-th smallest element.
|
||||
/// Time complexity: O(n) average, O(n²) worst case.
|
||||
/// </summary>
|
||||
private int QuickSelect(int left, int right, int k, int axis)
|
||||
{
|
||||
while (left < right)
|
||||
{
|
||||
int pivotIndex = Partition(left, right, axis);
|
||||
|
||||
if (pivotIndex == k)
|
||||
return k;
|
||||
else if (k < pivotIndex)
|
||||
right = pivotIndex - 1;
|
||||
else
|
||||
left = pivotIndex + 1;
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Partitions the array for QuickSelect using median-of-three pivot selection.
|
||||
/// This is the CORRECTED version that handles all edge cases properly.
|
||||
/// </summary>
|
||||
private int Partition(int left, int right, int axis)
|
||||
{
|
||||
// Handle small subarrays
|
||||
if (right - left < 2)
|
||||
{
|
||||
if (right > left && CompareNodes(_nodes[right], _nodes[left], axis) < 0)
|
||||
Swap(left, right);
|
||||
return left;
|
||||
}
|
||||
|
||||
// Use median-of-three pivot selection for better performance
|
||||
int mid = left + (right - left) / 2; // Overflow-safe
|
||||
|
||||
// Sort left, mid, right to get median as pivot
|
||||
if (CompareNodes(_nodes[mid], _nodes[left], axis) < 0)
|
||||
Swap(left, mid);
|
||||
if (CompareNodes(_nodes[right], _nodes[left], axis) < 0)
|
||||
Swap(left, right);
|
||||
if (CompareNodes(_nodes[right], _nodes[mid], axis) < 0)
|
||||
Swap(mid, right);
|
||||
|
||||
// Now: nodes[left] <= nodes[mid] <= nodes[right]
|
||||
// Use mid as pivot and hide it at right-1
|
||||
GlobalNode pivot = _nodes[mid];
|
||||
Swap(mid, right - 1);
|
||||
|
||||
// Partition with pivot at right-1
|
||||
int i = left + 1; // Start after left (which is already <= pivot)
|
||||
int j = right - 2; // Start before pivot position
|
||||
|
||||
while (i <= j)
|
||||
{
|
||||
// Find element >= pivot from left
|
||||
while (i <= j && CompareNodes(_nodes[i], pivot, axis) < 0)
|
||||
i++;
|
||||
|
||||
// Find element <= pivot from right
|
||||
while (i <= j && CompareNodes(_nodes[j], pivot, axis) > 0)
|
||||
j--;
|
||||
|
||||
if (i < j)
|
||||
{
|
||||
Swap(i, j);
|
||||
i++;
|
||||
j--;
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Put pivot in its final position
|
||||
Swap(i, right - 1);
|
||||
return i;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares two nodes along the specified axis.
|
||||
/// </summary>
|
||||
private static int CompareNodes(GlobalNode a, GlobalNode b, int axis)
|
||||
{
|
||||
return axis == 0 ? a.X.CompareTo(b.X) : a.Y.CompareTo(b.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Swaps two elements in the nodes list using tuple deconstruction.
|
||||
/// </summary>
|
||||
private void Swap(int i, int j)
|
||||
{
|
||||
if (i != j)
|
||||
{
|
||||
(_nodes[j], _nodes[i]) = (_nodes[i], _nodes[j]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the nearest node to the given coordinates within the specified distance limit.
|
||||
/// Time complexity: O(log n) average, O(n) worst case.
|
||||
/// </summary>
|
||||
/// <param name="x">X coordinate</param>
|
||||
/// <param name="y">Y coordinate</param>
|
||||
/// <param name="limitDistance">Maximum search distance</param>
|
||||
/// <returns>The nearest node within limit, or null if none found</returns>
|
||||
/// <exception cref="ArgumentOutOfRangeException">Thrown when limitDistance is negative.</exception>
|
||||
public GlobalNode? FindNearest(double x, double y, double limitDistance)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(limitDistance);
|
||||
|
||||
if (_root == null)
|
||||
return null;
|
||||
|
||||
double limitDistSquared = limitDistance * limitDistance;
|
||||
var result = FindNearestRecursive(_root, x, y, null, double.MaxValue, limitDistSquared);
|
||||
|
||||
return result.BestNode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively finds the nearest node using squared distances to avoid sqrt operations.
|
||||
/// </summary>
|
||||
private static SearchResult FindNearestRecursive(
|
||||
KDTreeNode? node,
|
||||
double x,
|
||||
double y,
|
||||
GlobalNode? bestNode,
|
||||
double bestDistSquared,
|
||||
double limitDistSquared)
|
||||
{
|
||||
if (node == null)
|
||||
return new SearchResult(bestNode, bestDistSquared);
|
||||
|
||||
// Calculate squared distance (avoid sqrt for performance)
|
||||
double dx = node.Node.X - x;
|
||||
double dy = node.Node.Y - y;
|
||||
double distSquared = dx * dx + dy * dy;
|
||||
|
||||
// Update best if this node is closer and within limit
|
||||
if (distSquared < bestDistSquared && distSquared <= limitDistSquared)
|
||||
{
|
||||
bestNode = node.Node;
|
||||
bestDistSquared = distSquared;
|
||||
}
|
||||
|
||||
// Determine which side to search first
|
||||
double delta = node.Axis == 0 ? x - node.Node.X : y - node.Node.Y;
|
||||
KDTreeNode? nearSide = delta < 0 ? node.Left : node.Right;
|
||||
KDTreeNode? farSide = delta < 0 ? node.Right : node.Left;
|
||||
|
||||
// Search near side
|
||||
var result = FindNearestRecursive(nearSide, x, y, bestNode, bestDistSquared, limitDistSquared);
|
||||
bestNode = result.BestNode;
|
||||
bestDistSquared = result.BestDistSquared;
|
||||
|
||||
// Only search far side if it could contain a closer point
|
||||
double deltaSquared = delta * delta;
|
||||
if (deltaSquared < bestDistSquared)
|
||||
{
|
||||
result = FindNearestRecursive(farSide, x, y, bestNode, bestDistSquared, limitDistSquared);
|
||||
bestNode = result.BestNode;
|
||||
bestDistSquared = result.BestDistSquared;
|
||||
}
|
||||
|
||||
return new SearchResult(bestNode, bestDistSquared);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds all nodes within the specified radius from the given coordinates.
|
||||
/// </summary>
|
||||
/// <param name="x">X coordinate</param>
|
||||
/// <param name="y">Y coordinate</param>
|
||||
/// <param name="radius">Search radius</param>
|
||||
/// <returns>List of all nodes within the radius</returns>
|
||||
public List<GlobalNode> FindInRadius(double x, double y, double radius)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(radius);
|
||||
|
||||
if (_root == null)
|
||||
return [];
|
||||
|
||||
var result = new List<GlobalNode>();
|
||||
double radiusSquared = radius * radius;
|
||||
FindInRadiusRecursive(_root, x, y, radiusSquared, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively finds all nodes within radius.
|
||||
/// </summary>
|
||||
private static void FindInRadiusRecursive(
|
||||
KDTreeNode? node,
|
||||
double x,
|
||||
double y,
|
||||
double radiusSquared,
|
||||
List<GlobalNode> result)
|
||||
{
|
||||
if (node == null)
|
||||
return;
|
||||
|
||||
double dx = node.Node.X - x;
|
||||
double dy = node.Node.Y - y;
|
||||
double distSquared = dx * dx + dy * dy;
|
||||
|
||||
if (distSquared <= radiusSquared)
|
||||
result.Add(node.Node);
|
||||
|
||||
double delta = node.Axis == 0 ? x - node.Node.X : y - node.Node.Y;
|
||||
double deltaSquared = delta * delta;
|
||||
|
||||
// Search both sides if sphere intersects splitting plane
|
||||
if (delta < 0)
|
||||
{
|
||||
FindInRadiusRecursive(node.Left, x, y, radiusSquared, result);
|
||||
if (deltaSquared <= radiusSquared)
|
||||
FindInRadiusRecursive(node.Right, x, y, radiusSquared, result);
|
||||
}
|
||||
else
|
||||
{
|
||||
FindInRadiusRecursive(node.Right, x, y, radiusSquared, result);
|
||||
if (deltaSquared <= radiusSquared)
|
||||
FindInRadiusRecursive(node.Left, x, y, radiusSquared, result);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal struct to return search results without allocations.
|
||||
/// </summary>
|
||||
private readonly struct SearchResult(GlobalNode? bestNode, double bestDistSquared)
|
||||
{
|
||||
public readonly GlobalNode? BestNode = bestNode;
|
||||
public readonly double BestDistSquared = bestDistSquared;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Edge;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Node;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Requests;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Responses;
|
||||
using RobotNet10.MapManager.Services;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace RobotNet10.MapManager.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for managing edges with complex node detection and cascade delete logic
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/edges")]
|
||||
[Authorize]
|
||||
public class EdgesController(
|
||||
IEdgeService edgeService,
|
||||
ILogger<EdgesController> logger) : ControllerBase
|
||||
{
|
||||
private readonly IEdgeService _edgeService = edgeService;
|
||||
private readonly ILogger<EdgesController> _logger = logger;
|
||||
|
||||
/// <summary>
|
||||
/// Get all edges for a layout level
|
||||
/// </summary>
|
||||
/// <param name="layoutLevelId">Layout level ID</param>
|
||||
/// <returns>List of edges with nodes and vehicle properties</returns>
|
||||
[HttpGet("level/{layoutLevelId}")]
|
||||
[ProducesResponseType(typeof(List<EdgeDto>), 200)]
|
||||
public async Task<ActionResult<List<EdgeDto>>> GetEdgesByLevel(Guid layoutLevelId)
|
||||
{
|
||||
var edges = await _edgeService.GetEdgesByLevelAsync(layoutLevelId, includeNodes: true, includeVehicleProperties: true);
|
||||
var dtos = edges.Select(MapToDto).ToList();
|
||||
|
||||
return Ok(dtos);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get edge by ID
|
||||
/// </summary>
|
||||
/// <param name="edgeId">Edge database ID</param>
|
||||
/// <returns>Edge details with nodes and vehicle properties</returns>
|
||||
[HttpGet("{edgeId}")]
|
||||
[ProducesResponseType(typeof(EdgeDto), 200)]
|
||||
[ProducesResponseType(404)]
|
||||
public async Task<ActionResult<EdgeDto>> GetEdge(Guid edgeId)
|
||||
{
|
||||
var edge = await _edgeService.GetByIdAsync(edgeId, includeNodes: true, includeVehicleProperties: true);
|
||||
|
||||
if (edge == null)
|
||||
{
|
||||
return NotFound(CreateErrorResponse($"Edge with ID '{edgeId}' not found", "EDGE_NOT_FOUND"));
|
||||
}
|
||||
|
||||
return Ok(MapToDto(edge));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create edge with automatic node detection/creation
|
||||
/// If start/end point is within NodeProximityRadius (default 0.35m) of existing node, connect to that node
|
||||
/// Otherwise, create new node at exact coordinates
|
||||
/// </summary>
|
||||
/// <param name="request">Edge creation request with coordinates in METERS</param>
|
||||
/// <returns>Created edge with connected nodes</returns>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(EdgeDto), 201)]
|
||||
[ProducesResponseType(400)]
|
||||
public async Task<ActionResult<EdgeDto>> CreateEdge([FromBody] CreateEdgeRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var edge = await _edgeService.CreateAsync(request);
|
||||
var dto = MapToDto(edge);
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetEdge),
|
||||
new { edgeId = edge.Id },
|
||||
dto);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to create edge");
|
||||
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update edge properties
|
||||
/// </summary>
|
||||
/// <param name="edgeId">Edge database ID</param>
|
||||
/// <param name="request">Update request</param>
|
||||
/// <returns>Updated edge</returns>
|
||||
[HttpPut("{edgeId}")]
|
||||
[ProducesResponseType(typeof(EdgeDto), 200)]
|
||||
[ProducesResponseType(400)]
|
||||
[ProducesResponseType(404)]
|
||||
public async Task<ActionResult<EdgeDto>> UpdateEdge(
|
||||
Guid edgeId,
|
||||
[FromBody] UpdateEdgeRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var edge = await _edgeService.UpdateAsync(edgeId, request);
|
||||
|
||||
return Ok(MapToDto(edge));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
if(_logger.IsEnabled(LogLevel.Warning))_logger.LogWarning(ex, "Failed to update edge: {edgeId}", edgeId);
|
||||
|
||||
if (ex.Message.Contains("not found"))
|
||||
return NotFound(CreateErrorResponse(ex.Message, "EDGE_NOT_FOUND"));
|
||||
|
||||
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete edge
|
||||
/// Cascade deletes orphan nodes (nodes not connected to any other edge)
|
||||
/// Also deletes StationInteractionNodes referencing orphan nodes
|
||||
/// </summary>
|
||||
/// <param name="edgeId">Edge database ID</param>
|
||||
/// <returns>No content on success</returns>
|
||||
[HttpDelete("{edgeId}")]
|
||||
[ProducesResponseType(204)]
|
||||
[ProducesResponseType(404)]
|
||||
public async Task<IActionResult> DeleteEdge(Guid edgeId)
|
||||
{
|
||||
var deleted = await _edgeService.DeleteAsync(edgeId);
|
||||
|
||||
if (!deleted)
|
||||
{
|
||||
return NotFound(CreateErrorResponse($"Edge with ID '{edgeId}' not found", "EDGE_NOT_FOUND"));
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete multiple edges in a transaction
|
||||
/// All edges are deleted or none (transaction)
|
||||
/// Cascade deletes orphan nodes and StationInteractionNodes
|
||||
/// </summary>
|
||||
/// <param name="request">Batch delete request with edge IDs</param>
|
||||
/// <returns>No content on success</returns>
|
||||
[HttpDelete("batch")]
|
||||
[ProducesResponseType(204)]
|
||||
[ProducesResponseType(400)]
|
||||
public async Task<IActionResult> DeleteEdgesBatch([FromBody] DeleteEdgesRequest request)
|
||||
{
|
||||
if (request.EdgeIds == null || request.EdgeIds.Count == 0)
|
||||
{
|
||||
return BadRequest(CreateErrorResponse("No edge IDs provided", "VALIDATION_ERROR"));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _edgeService.DeleteBatchAsync(request.EdgeIds);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to batch delete edges");
|
||||
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to batch delete edges");
|
||||
return StatusCode(500, CreateErrorResponse("Internal server error while deleting edges", "INTERNAL_ERROR"));
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to map entity to DTO
|
||||
private static EdgeDto MapToDto(Data.Edge edge)
|
||||
{
|
||||
return new EdgeDto
|
||||
{
|
||||
Id = edge.Id,
|
||||
LevelId = edge.LevelId,
|
||||
EdgeId = edge.EdgeId,
|
||||
EdgeName = edge.EdgeName,
|
||||
EdgeDescription = edge.EdgeDescription,
|
||||
StartNodeId = edge.StartNodeId,
|
||||
EndNodeId = edge.EndNodeId,
|
||||
StartNode = edge.StartNode != null ? new NodeDto
|
||||
{
|
||||
Id = edge.StartNode.Id,
|
||||
NodeId = edge.StartNode.NodeId,
|
||||
NodeName = edge.StartNode.NodeName,
|
||||
X = edge.StartNode.X,
|
||||
Y = edge.StartNode.Y
|
||||
} : null,
|
||||
EndNode = edge.EndNode != null ? new NodeDto
|
||||
{
|
||||
Id = edge.EndNode.Id,
|
||||
NodeId = edge.EndNode.NodeId,
|
||||
NodeName = edge.EndNode.NodeName,
|
||||
X = edge.EndNode.X,
|
||||
Y = edge.EndNode.Y
|
||||
} : null,
|
||||
VehicleProperties = edge.VehicleProperties?.Select(vp => new EdgeVehiclePropertyDto
|
||||
{
|
||||
Id = vp.Id,
|
||||
EdgeId = vp.EdgeId,
|
||||
VehicleTypeId = vp.VehicleTypeId,
|
||||
VehicleTypeIdString = vp.VehicleType?.VehicleTypeId,
|
||||
VehicleOrientation = vp.VehicleOrientation,
|
||||
OrientationType = vp.OrientationType,
|
||||
RotationAllowed = vp.RotationAllowed,
|
||||
RotationAtStartNodeAllowed = vp.RotationAtStartNodeAllowed,
|
||||
RotationAtEndNodeAllowed = vp.RotationAtEndNodeAllowed,
|
||||
MaxSpeed = vp.MaxSpeed,
|
||||
MaxRotationSpeed = vp.MaxRotationSpeed,
|
||||
MinHeight = vp.MinHeight,
|
||||
MaxHeight = vp.MaxHeight,
|
||||
LoadRestriction = (vp.LoadRestriction_Unloaded.HasValue || vp.LoadRestriction_Loaded.HasValue || !string.IsNullOrWhiteSpace(vp.LoadRestriction_LoadSetNames))
|
||||
? new LoadRestrictionDto
|
||||
{
|
||||
Unloaded = vp.LoadRestriction_Unloaded,
|
||||
Loaded = vp.LoadRestriction_Loaded,
|
||||
LoadSetNames = SafeDeserializeLoadSetNames(vp.LoadRestriction_LoadSetNames)
|
||||
}
|
||||
: null,
|
||||
TrajectoryDegree = vp.TrajectoryDegree,
|
||||
TrajectoryControlPoint1X = vp.TrajectoryControlPoint1X,
|
||||
TrajectoryControlPoint1Y = vp.TrajectoryControlPoint1Y,
|
||||
TrajectoryControlPoint2X = vp.TrajectoryControlPoint2X,
|
||||
TrajectoryControlPoint2Y = vp.TrajectoryControlPoint2Y,
|
||||
CorridorLeftWidth = vp.CorridorLeftWidth,
|
||||
CorridorRightWidth = vp.CorridorRightWidth,
|
||||
CorridorRefPoint = vp.CorridorRefPoint
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static List<string>? SafeDeserializeLoadSetNames(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return null;
|
||||
try { return System.Text.Json.JsonSerializer.Deserialize<List<string>>(json); }
|
||||
catch (System.Text.Json.JsonException) { return null; }
|
||||
}
|
||||
|
||||
private static ErrorResponseDto CreateErrorResponse(
|
||||
string error,
|
||||
string? errorCode = null,
|
||||
Dictionary<string, object>? details = null)
|
||||
{
|
||||
return new ErrorResponseDto
|
||||
{
|
||||
Error = error,
|
||||
ErrorCode = errorCode,
|
||||
Details = details
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Responses;
|
||||
using RobotNet10.MapManager.Services;
|
||||
|
||||
namespace RobotNet10.MapManager.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for managing background images for layout levels
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/images")]
|
||||
[Authorize]
|
||||
public class ImagesController(
|
||||
IImageStorageService imageStorageService,
|
||||
ILogger<ImagesController> logger) : ControllerBase
|
||||
{
|
||||
private readonly IImageStorageService _imageStorageService = imageStorageService;
|
||||
private readonly ILogger<ImagesController> _logger = logger;
|
||||
|
||||
/// <summary>
|
||||
/// Get background image for a layout level
|
||||
/// </summary>
|
||||
/// <param name="layoutLevelId">Layout level ID</param>
|
||||
/// <returns>Image file (PNG)</returns>
|
||||
[HttpGet("layout/{layoutLevelId}")]
|
||||
[ProducesResponseType(typeof(FileStreamResult), 200)]
|
||||
[ProducesResponseType(404)]
|
||||
public async Task<IActionResult> GetLayoutImage(Guid layoutLevelId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var imageStream = await _imageStorageService.GetImageAsync(layoutLevelId);
|
||||
|
||||
if (imageStream == null)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning("Image not found for layout level {LevelId}", layoutLevelId);
|
||||
return NotFound(CreateErrorResponse($"Image not found for layout level '{layoutLevelId}'", "IMAGE_NOT_FOUND"));
|
||||
}
|
||||
|
||||
return File(imageStream, "image/png", $"{layoutLevelId}.png");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Error)) _logger.LogError(ex, "Error retrieving image for layout level {LevelId}", layoutLevelId);
|
||||
return StatusCode(500, CreateErrorResponse("Internal server error while retrieving image", "INTERNAL_ERROR"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upload or replace background image for a layout level
|
||||
/// </summary>
|
||||
/// <param name="layoutLevelId">Layout level ID</param>
|
||||
/// <param name="file">Image file (PNG format)</param>
|
||||
/// <returns>Success message</returns>
|
||||
[HttpPost("layout/{layoutLevelId}")]
|
||||
[ProducesResponseType(200)]
|
||||
[ProducesResponseType(400)]
|
||||
public async Task<IActionResult> UploadLayoutImage(Guid layoutLevelId, IFormFile file)
|
||||
{
|
||||
// Validate file
|
||||
if (file == null || file.Length == 0)
|
||||
{
|
||||
return BadRequest(CreateErrorResponse("No file provided", "VALIDATION_ERROR"));
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!file.ContentType.Equals("image/png", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return BadRequest(CreateErrorResponse("Only PNG images are supported", "VALIDATION_ERROR"));
|
||||
}
|
||||
|
||||
// Validate file size (max 10MB)
|
||||
const long maxFileSize = 10 * 1024 * 1024; // 10MB
|
||||
if (file.Length > maxFileSize)
|
||||
{
|
||||
return BadRequest(CreateErrorResponse($"File size exceeds maximum of {maxFileSize / 1024 / 1024}MB", "VALIDATION_ERROR"));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = file.OpenReadStream();
|
||||
await _imageStorageService.SaveImageAsync(layoutLevelId, stream);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
message = "Image uploaded successfully",
|
||||
layoutLevelId,
|
||||
fileName = $"{layoutLevelId}.png",
|
||||
size = file.Length
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Error)) _logger.LogError(ex, "Error uploading image for layout level {LevelId}", layoutLevelId);
|
||||
return StatusCode(500, CreateErrorResponse("Internal server error while uploading image", "INTERNAL_ERROR"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete background image for a layout level
|
||||
/// </summary>
|
||||
/// <param name="layoutLevelId">Layout level ID</param>
|
||||
/// <returns>No content on success</returns>
|
||||
[HttpDelete("layout/{layoutLevelId}")]
|
||||
[ProducesResponseType(204)]
|
||||
[ProducesResponseType(404)]
|
||||
public async Task<IActionResult> DeleteLayoutImage(Guid layoutLevelId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var deleted = await _imageStorageService.DeleteImageAsync(layoutLevelId);
|
||||
|
||||
if (!deleted)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning("Image not found for deletion: {LevelId}", layoutLevelId);
|
||||
return NotFound(CreateErrorResponse($"Image not found for layout level '{layoutLevelId}'", "IMAGE_NOT_FOUND"));
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Error)) _logger.LogError(ex, "Error deleting image for layout level {LevelId}", layoutLevelId);
|
||||
return StatusCode(500, CreateErrorResponse("Internal server error while deleting image", "INTERNAL_ERROR"));
|
||||
}
|
||||
}
|
||||
|
||||
private static ErrorResponseDto CreateErrorResponse(
|
||||
string error,
|
||||
string? errorCode = null,
|
||||
Dictionary<string, object>? details = null)
|
||||
{
|
||||
return new ErrorResponseDto
|
||||
{
|
||||
Error = error,
|
||||
ErrorCode = errorCode,
|
||||
Details = details
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.LayoutData;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Requests;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Responses;
|
||||
using RobotNet10.MapManager.Services;
|
||||
|
||||
namespace RobotNet10.MapManager.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for retrieving complete layout data and merge/split operations
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/layout-data")]
|
||||
[Authorize]
|
||||
public class LayoutDataController(
|
||||
ILayoutDataService layoutDataService,
|
||||
ILogger<LayoutDataController> logger) : ControllerBase
|
||||
{
|
||||
private readonly ILayoutDataService _layoutDataService = layoutDataService;
|
||||
private readonly ILogger<LayoutDataController> _logger = logger;
|
||||
|
||||
/// <summary>
|
||||
/// Get complete layout data for a layout level
|
||||
/// Returns all nodes, edges, and stations with full nested properties
|
||||
/// </summary>
|
||||
/// <param name="layoutLevelId">Layout level ID</param>
|
||||
/// <returns>Complete layout data</returns>
|
||||
[HttpGet("{layoutLevelId}")]
|
||||
[ProducesResponseType(typeof(LayoutDataDto), 200)]
|
||||
public async Task<ActionResult<LayoutDataDto>> GetLayoutData(Guid layoutLevelId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = await _layoutDataService.GetLayoutDataAsync(layoutLevelId);
|
||||
|
||||
return Ok(data);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Error)) _logger.LogError(ex, "Error retrieving layout data for level {LevelId}", layoutLevelId);
|
||||
return StatusCode(500, CreateErrorResponse("Internal server error while retrieving layout data", "INTERNAL_ERROR"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merge multiple nodes into one node at center position
|
||||
/// </summary>
|
||||
/// <param name="request">Merge nodes request</param>
|
||||
/// <returns>Merge result with merged node, updated edges, and deleted node IDs</returns>
|
||||
[HttpPost("merge-nodes")]
|
||||
[ProducesResponseType(typeof(MergeNodesResponse), 200)]
|
||||
[ProducesResponseType(400)]
|
||||
public async Task<ActionResult<MergeNodesResponse>> MergeNodes([FromBody] MergeNodesRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _layoutDataService.MergeNodesAsync(request);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to merge nodes");
|
||||
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error merging nodes");
|
||||
return StatusCode(500, CreateErrorResponse("Internal server error while merging nodes", "INTERNAL_ERROR"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Split a node into multiple nodes (one for each connected edge)
|
||||
/// </summary>
|
||||
/// <param name="request">Split node request</param>
|
||||
/// <returns>Split result with new nodes, updated edges, and deleted node ID</returns>
|
||||
[HttpPost("split-node")]
|
||||
[ProducesResponseType(typeof(SplitNodeResponse), 200)]
|
||||
[ProducesResponseType(400)]
|
||||
public async Task<ActionResult<SplitNodeResponse>> SplitNode([FromBody] SplitNodeRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _layoutDataService.SplitNodeAsync(request);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to split node");
|
||||
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error splitting node");
|
||||
return StatusCode(500, CreateErrorResponse("Internal server error while splitting node", "INTERNAL_ERROR"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save all layout changes (nodes and edges) in a batch operation
|
||||
/// Uses transaction to ensure atomicity
|
||||
/// </summary>
|
||||
/// <param name="request">Save request with nodes and edges to update</param>
|
||||
/// <returns>Save result with counts and any skipped items</returns>
|
||||
[HttpPost("save")]
|
||||
[ProducesResponseType(typeof(SaveLayoutDataResponse), 200)]
|
||||
[ProducesResponseType(400)]
|
||||
public async Task<ActionResult<SaveLayoutDataResponse>> SaveLayoutData([FromBody] SaveLayoutDataRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _layoutDataService.SaveLayoutDataAsync(request);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return BadRequest(result);
|
||||
}
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error saving layout data");
|
||||
return StatusCode(500, CreateErrorResponse("Internal server error while saving layout data", "INTERNAL_ERROR"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copy selected nodes and edges with an offset
|
||||
/// Creates new nodes and edges at offset positions
|
||||
/// </summary>
|
||||
/// <param name="request">Copy request with node IDs, edge IDs, and offset</param>
|
||||
/// <returns>Copy result with newly created nodes and edges</returns>
|
||||
[HttpPost("copy-nodes")]
|
||||
[ProducesResponseType(typeof(CopyNodesResponse), 200)]
|
||||
[ProducesResponseType(400)]
|
||||
public async Task<ActionResult<CopyNodesResponse>> CopyNodes([FromBody] CopyNodesRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _layoutDataService.CopyNodesAsync(request);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return BadRequest(result);
|
||||
}
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error copying nodes");
|
||||
return StatusCode(500, CreateErrorResponse("Internal server error while copying nodes", "INTERNAL_ERROR"));
|
||||
}
|
||||
}
|
||||
|
||||
private static ErrorResponseDto CreateErrorResponse(
|
||||
string error,
|
||||
string? errorCode = null,
|
||||
Dictionary<string, object>? details = null)
|
||||
{
|
||||
return new ErrorResponseDto
|
||||
{
|
||||
Error = error,
|
||||
ErrorCode = errorCode,
|
||||
Details = details
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Layout;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Requests;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Responses;
|
||||
using RobotNet10.MapEditor.Shared.Models;
|
||||
using RobotNet10.MapManager.Services;
|
||||
|
||||
namespace RobotNet10.MapManager.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for managing layouts, versions, and levels
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/layouts")]
|
||||
[Authorize]
|
||||
public class LayoutManagerController(
|
||||
ILayoutService layoutService,
|
||||
IImageStorageService imageStorageService,
|
||||
ILogger<LayoutManagerController> logger) : ControllerBase
|
||||
{
|
||||
private readonly ILayoutService _layoutService = layoutService;
|
||||
private readonly IImageStorageService _imageStorageService = imageStorageService;
|
||||
private readonly ILogger<LayoutManagerController> _logger = logger;
|
||||
|
||||
// ==========================================
|
||||
// LAYOUT OPERATIONS
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Create a new layout
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(LayoutDto), 201)]
|
||||
[ProducesResponseType(400)]
|
||||
public async Task<ActionResult<LayoutDto>> CreateLayout([FromBody] CreateLayoutRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var layout = await _layoutService.CreateLayoutAsync(request);
|
||||
var dto = MapLayoutToDto(layout);
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetLayout),
|
||||
new { layoutId = layout.Id },
|
||||
dto);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to create layout: {LayoutId}", request.LayoutId);
|
||||
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Search layouts by text
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(List<LayoutDto>), 200)]
|
||||
public async Task<ActionResult<List<LayoutDto>>> SearchLayouts([FromQuery] string? search)
|
||||
{
|
||||
var layouts = await _layoutService.SearchLayoutsAsync(search);
|
||||
var dtos = layouts.Select(MapLayoutToDto).ToList();
|
||||
|
||||
return Ok(dtos);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get layout by database ID
|
||||
/// </summary>
|
||||
[HttpGet("{layoutId:guid}")]
|
||||
[ProducesResponseType(typeof(LayoutDto), 200)]
|
||||
[ProducesResponseType(404)]
|
||||
public async Task<ActionResult<LayoutDto>> GetLayout(Guid layoutId)
|
||||
{
|
||||
var layout = await _layoutService.GetLayoutByIdAsync(layoutId);
|
||||
|
||||
if (layout == null)
|
||||
{
|
||||
return NotFound(CreateErrorResponse($"Layout with ID '{layoutId}' not found", "LAYOUT_NOT_FOUND"));
|
||||
}
|
||||
|
||||
return Ok(MapLayoutToDto(layout));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get layout by layout ID string
|
||||
/// </summary>
|
||||
[HttpGet("by-id/{layoutId}")]
|
||||
[ProducesResponseType(typeof(LayoutDto), 200)]
|
||||
[ProducesResponseType(404)]
|
||||
public async Task<ActionResult<LayoutDto>> GetLayoutByLayoutId(string layoutId)
|
||||
{
|
||||
var layout = await _layoutService.GetLayoutByLayoutIdAsync(layoutId);
|
||||
|
||||
if (layout == null)
|
||||
{
|
||||
return NotFound(CreateErrorResponse($"Layout with ID '{layoutId}' not found", "LAYOUT_NOT_FOUND"));
|
||||
}
|
||||
|
||||
return Ok(MapLayoutToDto(layout));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get layout by name
|
||||
/// </summary>
|
||||
[HttpGet("by-name/{layoutName}")]
|
||||
[ProducesResponseType(typeof(LayoutDto), 200)]
|
||||
[ProducesResponseType(404)]
|
||||
public async Task<ActionResult<LayoutDto>> GetLayoutByName(string layoutName)
|
||||
{
|
||||
var layout = await _layoutService.GetLayoutByNameAsync(layoutName);
|
||||
|
||||
if (layout == null)
|
||||
{
|
||||
return NotFound(CreateErrorResponse($"Layout with name '{layoutName}' not found", "LAYOUT_NOT_FOUND"));
|
||||
}
|
||||
|
||||
return Ok(MapLayoutToDto(layout));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update layout
|
||||
/// </summary>
|
||||
[HttpPut("{layoutId:guid}")]
|
||||
[ProducesResponseType(typeof(LayoutDto), 200)]
|
||||
[ProducesResponseType(400)]
|
||||
[ProducesResponseType(404)]
|
||||
public async Task<ActionResult<LayoutDto>> UpdateLayout(
|
||||
Guid layoutId,
|
||||
[FromBody] UpdateLayoutRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var layout = await _layoutService.UpdateLayoutAsync(layoutId, request);
|
||||
|
||||
return Ok(MapLayoutToDto(layout));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to update layout: {LayoutId}", layoutId);
|
||||
|
||||
if (ex.Message.Contains("not found"))
|
||||
return NotFound(CreateErrorResponse(ex.Message, "LAYOUT_NOT_FOUND"));
|
||||
|
||||
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete layout (must be deactivated first)
|
||||
/// Hard delete with cascade
|
||||
/// </summary>
|
||||
[HttpDelete("{layoutId:guid}")]
|
||||
[ProducesResponseType(204)]
|
||||
[ProducesResponseType(400)]
|
||||
[ProducesResponseType(404)]
|
||||
public async Task<IActionResult> DeleteLayout(Guid layoutId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var deleted = await _layoutService.DeleteLayoutAsync(layoutId);
|
||||
|
||||
if (!deleted)
|
||||
{
|
||||
return NotFound(CreateErrorResponse($"Layout with ID '{layoutId}' not found", "LAYOUT_NOT_FOUND"));
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to delete layout: {LayoutId}", layoutId);
|
||||
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Activate layout
|
||||
/// </summary>
|
||||
[HttpPost("{layoutId:guid}/activate")]
|
||||
[ProducesResponseType(typeof(LayoutDto), 200)]
|
||||
[ProducesResponseType(404)]
|
||||
public async Task<ActionResult<LayoutDto>> ActivateLayout(Guid layoutId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var layout = await _layoutService.ActivateLayoutAsync(layoutId);
|
||||
|
||||
return Ok(MapLayoutToDto(layout));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to activate layout: {LayoutId}", layoutId);
|
||||
return NotFound(CreateErrorResponse(ex.Message, "LAYOUT_NOT_FOUND"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deactivate layout
|
||||
/// </summary>
|
||||
[HttpPost("{layoutId:guid}/deactivate")]
|
||||
[ProducesResponseType(typeof(LayoutDto), 200)]
|
||||
[ProducesResponseType(404)]
|
||||
public async Task<ActionResult<LayoutDto>> DeactivateLayout(Guid layoutId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var layout = await _layoutService.DeactivateLayoutAsync(layoutId);
|
||||
|
||||
return Ok(MapLayoutToDto(layout));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to deactivate layout: {LayoutId}", layoutId);
|
||||
return NotFound(CreateErrorResponse(ex.Message, "LAYOUT_NOT_FOUND"));
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// VERSION OPERATIONS
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Create new version for a layout
|
||||
/// </summary>
|
||||
[HttpPost("{layoutId:guid}/versions")]
|
||||
[ProducesResponseType(typeof(LayoutVersionDto), 201)]
|
||||
[ProducesResponseType(400)]
|
||||
[ProducesResponseType(404)]
|
||||
public async Task<ActionResult<LayoutVersionDto>> CreateVersion(
|
||||
Guid layoutId,
|
||||
[FromBody] CreateLayoutVersionRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var version = await _layoutService.CreateVersionAsync(layoutId, request);
|
||||
var dto = MapVersionToDto(version);
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetVersion),
|
||||
new { versionId = version.Id },
|
||||
dto);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to create version for layout: {LayoutId}", layoutId);
|
||||
|
||||
if (ex.Message.Contains("not found"))
|
||||
return NotFound(CreateErrorResponse(ex.Message, "LAYOUT_NOT_FOUND"));
|
||||
|
||||
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all versions for a layout
|
||||
/// </summary>
|
||||
[HttpGet("{layoutId:guid}/versions")]
|
||||
[ProducesResponseType(typeof(List<LayoutVersionDto>), 200)]
|
||||
public async Task<ActionResult<List<LayoutVersionDto>>> GetVersions(Guid layoutId)
|
||||
{
|
||||
var versions = await _layoutService.GetVersionsAsync(layoutId);
|
||||
var dtos = versions.Select(MapVersionToDto).ToList();
|
||||
|
||||
return Ok(dtos);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get version by ID
|
||||
/// </summary>
|
||||
[HttpGet("versions/{versionId:guid}")]
|
||||
[ProducesResponseType(typeof(LayoutVersionDto), 200)]
|
||||
[ProducesResponseType(404)]
|
||||
public async Task<ActionResult<LayoutVersionDto>> GetVersion(Guid versionId)
|
||||
{
|
||||
var version = await _layoutService.GetVersionAsync(versionId);
|
||||
|
||||
if (version == null)
|
||||
{
|
||||
return NotFound(CreateErrorResponse($"Version with ID '{versionId}' not found", "VERSION_NOT_FOUND"));
|
||||
}
|
||||
|
||||
return Ok(MapVersionToDto(version));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update version
|
||||
/// </summary>
|
||||
[HttpPut("versions/{versionId:guid}")]
|
||||
[ProducesResponseType(typeof(LayoutVersionDto), 200)]
|
||||
[ProducesResponseType(400)]
|
||||
[ProducesResponseType(404)]
|
||||
public async Task<ActionResult<LayoutVersionDto>> UpdateVersion(
|
||||
Guid versionId,
|
||||
[FromBody] UpdateLayoutRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var version = await _layoutService.UpdateVersionAsync(versionId, request);
|
||||
|
||||
return Ok(MapVersionToDto(version));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to update version: {VersionId}", versionId);
|
||||
|
||||
if (ex.Message.Contains("not found"))
|
||||
return NotFound(CreateErrorResponse(ex.Message, "VERSION_NOT_FOUND"));
|
||||
|
||||
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete version (layout must be deactivated first)
|
||||
/// </summary>
|
||||
[HttpDelete("versions/{versionId:guid}")]
|
||||
[ProducesResponseType(204)]
|
||||
[ProducesResponseType(400)]
|
||||
[ProducesResponseType(404)]
|
||||
public async Task<IActionResult> DeleteVersion(Guid versionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var deleted = await _layoutService.DeleteVersionAsync(versionId);
|
||||
|
||||
if (!deleted)
|
||||
{
|
||||
return NotFound(CreateErrorResponse($"Version with ID '{versionId}' not found", "VERSION_NOT_FOUND"));
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to delete version: {VersionId}", versionId);
|
||||
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// LEVEL OPERATIONS
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Create new level for a version
|
||||
/// </summary>
|
||||
[HttpPost("versions/{versionId:guid}/levels")]
|
||||
[ProducesResponseType(typeof(LayoutLevelDto), 201)]
|
||||
[ProducesResponseType(400)]
|
||||
[ProducesResponseType(404)]
|
||||
public async Task<ActionResult<LayoutLevelDto>> CreateLevel(
|
||||
Guid versionId,
|
||||
[FromBody] CreateLayoutLevelRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var level = await _layoutService.CreateLevelAsync(versionId, request);
|
||||
var dto = MapLevelToDto(level);
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetLevel),
|
||||
new { levelId = level.Id },
|
||||
dto);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to create level for version: {VersionId}", versionId);
|
||||
|
||||
if (ex.Message.Contains("not found"))
|
||||
return NotFound(CreateErrorResponse(ex.Message, "VERSION_NOT_FOUND"));
|
||||
|
||||
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new layout level with background image in a single request
|
||||
/// </summary>
|
||||
/// <param name="versionId">Version ID</param>
|
||||
/// <param name="layoutLevelId">Layout level identifier string</param>
|
||||
/// <param name="levelOrder">Level order</param>
|
||||
/// <param name="resolution">Resolution in meters per pixel</param>
|
||||
/// <param name="originX">Origin X coordinate in meters</param>
|
||||
/// <param name="originY">Origin Y coordinate in meters</param>
|
||||
/// <param name="file">Background image file (PNG format, required)</param>
|
||||
/// <returns>Created level with image metadata</returns>
|
||||
[HttpPost("versions/{versionId:guid}/levels/with-image")]
|
||||
[ProducesResponseType(typeof(LayoutLevelDto), 201)]
|
||||
[ProducesResponseType(400)]
|
||||
[ProducesResponseType(404)]
|
||||
public async Task<ActionResult<LayoutLevelDto>> CreateLevelWithImage(
|
||||
Guid versionId,
|
||||
[FromForm] string layoutLevelId,
|
||||
[FromForm] int levelOrder,
|
||||
[FromForm] double resolution,
|
||||
[FromForm] double originX,
|
||||
[FromForm] double originY,
|
||||
IFormFile file)
|
||||
{
|
||||
// Validate file
|
||||
if (file == null || file.Length == 0)
|
||||
{
|
||||
return BadRequest(CreateErrorResponse("Image file is required", "VALIDATION_ERROR"));
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!file.ContentType.Equals("image/png", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return BadRequest(CreateErrorResponse("Only PNG images are supported", "VALIDATION_ERROR"));
|
||||
}
|
||||
|
||||
// Validate file size (max 10MB)
|
||||
const long maxFileSize = 10 * 1024 * 1024;
|
||||
if (file.Length > maxFileSize)
|
||||
{
|
||||
return BadRequest(CreateErrorResponse($"File size exceeds maximum of {maxFileSize / 1024 / 1024}MB", "VALIDATION_ERROR"));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Step 1: Extract image dimensions
|
||||
int imageWidth, imageHeight;
|
||||
using (var stream = file.OpenReadStream())
|
||||
{
|
||||
(imageWidth, imageHeight) = await _imageStorageService.GetImageDimensionsAsync(stream);
|
||||
}
|
||||
|
||||
// Step 2: Create level with complete coordinate system info
|
||||
var request = new CreateLayoutLevelRequest
|
||||
{
|
||||
LayoutLevelId = layoutLevelId,
|
||||
LevelOrder = levelOrder,
|
||||
CoordinateSystem = new CoordinateSystemInfo
|
||||
{
|
||||
Resolution = resolution,
|
||||
OriginX = originX,
|
||||
OriginY = originY,
|
||||
ImageWidth = imageWidth,
|
||||
ImageHeight = imageHeight,
|
||||
// Calculate bounds based on image size
|
||||
BoundsMinX = originX,
|
||||
BoundsMaxX = imageWidth * resolution + originX,
|
||||
BoundsMinY = originY,
|
||||
BoundsMaxY = imageHeight * resolution + originY
|
||||
}
|
||||
};
|
||||
|
||||
var level = await _layoutService.CreateLevelAsync(versionId, request);
|
||||
|
||||
// Step 3: Upload image
|
||||
try
|
||||
{
|
||||
using var stream = file.OpenReadStream();
|
||||
await _imageStorageService.SaveImageAsync(level.Id, stream);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Error)) _logger.LogError(ex, "Failed to save image after creating level {LevelId}, attempting rollback", level.Id);
|
||||
|
||||
// Attempt to delete the created level to maintain consistency
|
||||
try
|
||||
{
|
||||
await _layoutService.DeleteLevelAsync(level.Id);
|
||||
}
|
||||
catch (Exception rollbackEx)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Error)) _logger.LogError(rollbackEx, "Failed to rollback level creation for {LevelId}", level.Id);
|
||||
}
|
||||
|
||||
return StatusCode(500, CreateErrorResponse("Failed to save image. Level creation was rolled back.", "INTERNAL_ERROR"));
|
||||
}
|
||||
|
||||
var dto = MapLevelToDto(level);
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetLevel),
|
||||
new { levelId = level.Id },
|
||||
dto);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to create level with image for version: {VersionId}", versionId);
|
||||
|
||||
if (ex.Message.Contains("not found"))
|
||||
return NotFound(CreateErrorResponse(ex.Message, "VERSION_NOT_FOUND"));
|
||||
|
||||
if (ex.Message.Contains("Invalid image") || ex.Message.Contains("corrupted"))
|
||||
return BadRequest(CreateErrorResponse("Invalid or corrupted image file", "VALIDATION_ERROR"));
|
||||
|
||||
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Error)) _logger.LogError(ex, "Unexpected error creating level with image for version {VersionId}", versionId);
|
||||
return StatusCode(500, CreateErrorResponse("Internal server error while creating level with image", "INTERNAL_ERROR"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all levels for a version
|
||||
/// </summary>
|
||||
[HttpGet("versions/{versionId:guid}/levels")]
|
||||
[ProducesResponseType(typeof(List<LayoutLevelDto>), 200)]
|
||||
public async Task<ActionResult<List<LayoutLevelDto>>> GetLevels(Guid versionId)
|
||||
{
|
||||
var levels = await _layoutService.GetLevelsAsync(versionId);
|
||||
var dtos = levels.Select(MapLevelToDto).ToList();
|
||||
|
||||
return Ok(dtos);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get level by ID
|
||||
/// </summary>
|
||||
[HttpGet("levels/{levelId:guid}")]
|
||||
[ProducesResponseType(typeof(LayoutLevelDto), 200)]
|
||||
[ProducesResponseType(404)]
|
||||
public async Task<ActionResult<LayoutLevelDto>> GetLevel(Guid levelId)
|
||||
{
|
||||
var level = await _layoutService.GetLevelAsync(levelId);
|
||||
|
||||
if (level == null)
|
||||
{
|
||||
return NotFound(CreateErrorResponse($"Level with ID '{levelId}' not found", "LEVEL_NOT_FOUND"));
|
||||
}
|
||||
|
||||
return Ok(MapLevelToDto(level));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update level
|
||||
/// </summary>
|
||||
[HttpPut("levels/{levelId:guid}")]
|
||||
[ProducesResponseType(typeof(LayoutLevelDto), 200)]
|
||||
[ProducesResponseType(400)]
|
||||
[ProducesResponseType(404)]
|
||||
public async Task<ActionResult<LayoutLevelDto>> UpdateLevel(
|
||||
Guid levelId,
|
||||
[FromBody] UpdateLayoutLevelRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var level = await _layoutService.UpdateLevelAsync(levelId, request);
|
||||
|
||||
return Ok(MapLevelToDto(level));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to update level: {LevelId}", levelId);
|
||||
|
||||
if (ex.Message.Contains("not found"))
|
||||
return NotFound(CreateErrorResponse(ex.Message, "LEVEL_NOT_FOUND"));
|
||||
|
||||
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete level (layout must be deactivated first)
|
||||
/// </summary>
|
||||
[HttpDelete("levels/{levelId:guid}")]
|
||||
[ProducesResponseType(204)]
|
||||
[ProducesResponseType(400)]
|
||||
[ProducesResponseType(404)]
|
||||
public async Task<IActionResult> DeleteLevel(Guid levelId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var deleted = await _layoutService.DeleteLevelAsync(levelId);
|
||||
|
||||
if (!deleted)
|
||||
{
|
||||
return NotFound(CreateErrorResponse($"Level with ID '{levelId}' not found", "LEVEL_NOT_FOUND"));
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to delete level: {LevelId}", levelId);
|
||||
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// HELPER METHODS
|
||||
// ==========================================
|
||||
|
||||
private static LayoutDto MapLayoutToDto(Data.Layout layout)
|
||||
{
|
||||
return new LayoutDto
|
||||
{
|
||||
Id = layout.Id,
|
||||
LayoutId = layout.LayoutId,
|
||||
LayoutName = layout.LayoutName,
|
||||
Description = layout.Description,
|
||||
IsActive = layout.IsActive,
|
||||
CreatedDate = layout.CreatedDate,
|
||||
ModifiedDate = layout.ModifiedDate,
|
||||
CreatedBy = layout.CreatedBy,
|
||||
ModifiedBy = layout.ModifiedBy,
|
||||
Versions = layout.Versions?.Select(MapVersionToDto).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static LayoutVersionDto MapVersionToDto(Data.LayoutVersion version)
|
||||
{
|
||||
return new LayoutVersionDto
|
||||
{
|
||||
Id = version.Id,
|
||||
LayoutId = version.LayoutId,
|
||||
Version = version.Version,
|
||||
LayoutDescription = version.LayoutDescription,
|
||||
CreatedBy = version.CreatedBy,
|
||||
CreatedDate = version.CreatedDate,
|
||||
IsActive = version.IsActive,
|
||||
Levels = version.Levels?.Select(MapLevelToDto).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static LayoutLevelDto MapLevelToDto(Data.LayoutLevel level)
|
||||
{
|
||||
return new LayoutLevelDto
|
||||
{
|
||||
Id = level.Id,
|
||||
VersionId = level.VersionId,
|
||||
LayoutLevelId = level.LayoutLevelId,
|
||||
LevelOrder = level.LevelOrder,
|
||||
EditorSettings = level.EditorSettings != null ? MapEditorSettingsToDto(level.EditorSettings) : null
|
||||
};
|
||||
}
|
||||
|
||||
private static LayoutLevelEditorSettingsDto MapEditorSettingsToDto(Data.LayoutLevelEditorSettings settings)
|
||||
{
|
||||
return new LayoutLevelEditorSettingsDto
|
||||
{
|
||||
Id = settings.Id,
|
||||
LevelId = settings.LevelId,
|
||||
EdgeMinLengthCreate = settings.EdgeMinLengthCreate,
|
||||
EdgeNameAutoGenerate = settings.EdgeNameAutoGenerate,
|
||||
NodeNameAutoGenerate = settings.NodeNameAutoGenerate,
|
||||
NodeProximityRadius = settings.NodeProximityRadius,
|
||||
OriginX = settings.OriginX,
|
||||
OriginY = settings.OriginY,
|
||||
Resolution = settings.Resolution,
|
||||
BoundsMinX = settings.BoundsMinX,
|
||||
BoundsMaxX = settings.BoundsMaxX,
|
||||
BoundsMinY = settings.BoundsMinY,
|
||||
BoundsMaxY = settings.BoundsMaxY,
|
||||
ImageWidth = settings.ImageWidth,
|
||||
ImageHeight = settings.ImageHeight,
|
||||
CreatedDate = settings.CreatedDate,
|
||||
ModifiedDate = settings.ModifiedDate
|
||||
};
|
||||
}
|
||||
|
||||
private static ErrorResponseDto CreateErrorResponse(
|
||||
string error,
|
||||
string? errorCode = null,
|
||||
Dictionary<string, object>? details = null)
|
||||
{
|
||||
return new ErrorResponseDto
|
||||
{
|
||||
Error = error,
|
||||
ErrorCode = errorCode,
|
||||
Details = details
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Node;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Requests;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Responses;
|
||||
using RobotNet10.MapManager.Services;
|
||||
|
||||
namespace RobotNet10.MapManager.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for managing nodes
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/nodes")]
|
||||
[Authorize]
|
||||
public class NodesController(
|
||||
INodeService nodeService,
|
||||
ILogger<NodesController> logger) : ControllerBase
|
||||
{
|
||||
private readonly INodeService _nodeService = nodeService;
|
||||
private readonly ILogger<NodesController> _logger = logger;
|
||||
|
||||
/// <summary>
|
||||
/// Get all nodes for a layout level
|
||||
/// </summary>
|
||||
/// <param name="layoutLevelId">Layout level ID</param>
|
||||
/// <returns>List of nodes with vehicle properties</returns>
|
||||
[HttpGet("level/{layoutLevelId}")]
|
||||
[ProducesResponseType(typeof(List<NodeDto>), 200)]
|
||||
public async Task<ActionResult<List<NodeDto>>> GetNodesByLevel(Guid layoutLevelId)
|
||||
{
|
||||
var nodes = await _nodeService.GetNodesByLevelAsync(layoutLevelId, includeVehicleProperties: true);
|
||||
var dtos = nodes.Select(MapToDto).ToList();
|
||||
|
||||
return Ok(dtos);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get node by ID
|
||||
/// </summary>
|
||||
/// <param name="nodeId">Node database ID</param>
|
||||
/// <returns>Node details with vehicle properties</returns>
|
||||
[HttpGet("{nodeId}")]
|
||||
[ProducesResponseType(typeof(NodeDto), 200)]
|
||||
[ProducesResponseType(404)]
|
||||
public async Task<ActionResult<NodeDto>> GetNode(Guid nodeId)
|
||||
{
|
||||
var node = await _nodeService.GetByIdAsync(nodeId, includeVehicleProperties: true);
|
||||
|
||||
if (node == null)
|
||||
{
|
||||
return NotFound(CreateErrorResponse($"Node with ID '{nodeId}' not found", "NODE_NOT_FOUND"));
|
||||
}
|
||||
|
||||
return Ok(MapToDto(node));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update node
|
||||
/// </summary>
|
||||
/// <param name="nodeId">Node database ID</param>
|
||||
/// <param name="request">Update request</param>
|
||||
/// <returns>Updated node</returns>
|
||||
[HttpPut("{nodeId}")]
|
||||
[ProducesResponseType(typeof(NodeDto), 200)]
|
||||
[ProducesResponseType(400)]
|
||||
[ProducesResponseType(404)]
|
||||
public async Task<ActionResult<NodeDto>> UpdateNode(
|
||||
Guid nodeId,
|
||||
[FromBody] UpdateNodeRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var node = await _nodeService.UpdateAsync(nodeId, request);
|
||||
|
||||
return Ok(MapToDto(node));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to update node: {NodeId}", nodeId);
|
||||
|
||||
if (ex.Message.Contains("not found"))
|
||||
return NotFound(CreateErrorResponse(ex.Message, "NODE_NOT_FOUND"));
|
||||
|
||||
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to map entity to DTO
|
||||
private static NodeDto MapToDto(Data.Node node)
|
||||
{
|
||||
return new NodeDto
|
||||
{
|
||||
Id = node.Id,
|
||||
LevelId = node.LevelId,
|
||||
NodeId = node.NodeId,
|
||||
NodeName = node.NodeName,
|
||||
NodeDescription = node.NodeDescription,
|
||||
MapId = node.MapId,
|
||||
X = node.X,
|
||||
Y = node.Y,
|
||||
VehicleProperties = node.VehicleProperties?.Select(vp => new NodeVehiclePropertyDto
|
||||
{
|
||||
Id = vp.Id,
|
||||
NodeId = vp.NodeId,
|
||||
VehicleTypeId = vp.VehicleTypeId,
|
||||
VehicleTypeIdString = vp.VehicleType?.VehicleTypeId,
|
||||
Theta = vp.Theta,
|
||||
Actions = vp.Actions,
|
||||
AllowedDeviationXY = vp.AllowedDeviationXY,
|
||||
AllowedDeviationTheta = vp.AllowedDeviationTheta
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static ErrorResponseDto CreateErrorResponse(
|
||||
string error,
|
||||
string? errorCode = null,
|
||||
Dictionary<string, object>? details = null)
|
||||
{
|
||||
return new ErrorResponseDto
|
||||
{
|
||||
Error = error,
|
||||
ErrorCode = errorCode,
|
||||
Details = details
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Node;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Requests;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Responses;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Station;
|
||||
using RobotNet10.MapManager.Services;
|
||||
|
||||
namespace RobotNet10.MapManager.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for managing stations
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/stations")]
|
||||
[Authorize]
|
||||
public class StationsController(
|
||||
IStationService stationService,
|
||||
ILogger<StationsController> logger) : ControllerBase
|
||||
{
|
||||
private readonly IStationService _stationService = stationService;
|
||||
private readonly ILogger<StationsController> _logger = logger;
|
||||
|
||||
/// <summary>
|
||||
/// Create a new station
|
||||
/// </summary>
|
||||
/// <param name="request">Station creation request</param>
|
||||
/// <returns>Created station</returns>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(StationDto), 201)]
|
||||
[ProducesResponseType(400)]
|
||||
public async Task<ActionResult<StationDto>> CreateStation([FromBody] CreateStationRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var station = await _stationService.CreateAsync(request);
|
||||
var dto = MapToDto(station);
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetStation),
|
||||
new { stationId = station.Id },
|
||||
dto);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to create station: {StationId}", request.StationId);
|
||||
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all stations for a layout level
|
||||
/// </summary>
|
||||
/// <param name="layoutLevelId">Layout level ID</param>
|
||||
/// <returns>List of stations with interaction nodes</returns>
|
||||
[HttpGet("level/{layoutLevelId}")]
|
||||
[ProducesResponseType(typeof(List<StationDto>), 200)]
|
||||
public async Task<ActionResult<List<StationDto>>> GetStationsByLevel(Guid layoutLevelId)
|
||||
{
|
||||
var stations = await _stationService.GetStationsByLevelAsync(layoutLevelId, includeInteractionNodes: true);
|
||||
var dtos = stations.Select(MapToDto).ToList();
|
||||
|
||||
return Ok(dtos);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get station by ID
|
||||
/// </summary>
|
||||
/// <param name="stationId">Station database ID</param>
|
||||
/// <returns>Station details with interaction nodes</returns>
|
||||
[HttpGet("{stationId}")]
|
||||
[ProducesResponseType(typeof(StationDto), 200)]
|
||||
[ProducesResponseType(404)]
|
||||
public async Task<ActionResult<StationDto>> GetStation(Guid stationId)
|
||||
{
|
||||
var station = await _stationService.GetByIdAsync(stationId, includeInteractionNodes: true);
|
||||
|
||||
if (station == null)
|
||||
{
|
||||
return NotFound(CreateErrorResponse($"Station with ID '{stationId}' not found", "STATION_NOT_FOUND"));
|
||||
}
|
||||
|
||||
return Ok(MapToDto(station));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update station
|
||||
/// </summary>
|
||||
/// <param name="stationId">Station database ID</param>
|
||||
/// <param name="request">Update request</param>
|
||||
/// <returns>Updated station</returns>
|
||||
[HttpPut("{stationId}")]
|
||||
[ProducesResponseType(typeof(StationDto), 200)]
|
||||
[ProducesResponseType(400)]
|
||||
[ProducesResponseType(404)]
|
||||
public async Task<ActionResult<StationDto>> UpdateStation(
|
||||
Guid stationId,
|
||||
[FromBody] UpdateStationRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var station = await _stationService.UpdateAsync(stationId, request);
|
||||
|
||||
return Ok(MapToDto(station));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to update station: {StationId}", stationId);
|
||||
|
||||
if (ex.Message.Contains("not found"))
|
||||
return NotFound(CreateErrorResponse(ex.Message, "STATION_NOT_FOUND"));
|
||||
|
||||
return BadRequest(CreateErrorResponse(ex.Message, "VALIDATION_ERROR"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete station
|
||||
/// Deletes station and cascade deletes interaction nodes (but NOT the linked nodes)
|
||||
/// </summary>
|
||||
/// <param name="stationId">Station database ID</param>
|
||||
/// <returns>No content on success</returns>
|
||||
[HttpDelete("{stationId}")]
|
||||
[ProducesResponseType(204)]
|
||||
[ProducesResponseType(404)]
|
||||
public async Task<IActionResult> DeleteStation(Guid stationId)
|
||||
{
|
||||
var deleted = await _stationService.DeleteAsync(stationId);
|
||||
|
||||
if (!deleted)
|
||||
{
|
||||
return NotFound(CreateErrorResponse($"Station with ID '{stationId}' not found", "STATION_NOT_FOUND"));
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// Helper method to map entity to DTO
|
||||
private static StationDto MapToDto(Data.Station station)
|
||||
{
|
||||
return new StationDto
|
||||
{
|
||||
Id = station.Id,
|
||||
LevelId = station.LevelId,
|
||||
StationId = station.StationId,
|
||||
StationName = station.StationName,
|
||||
StationDescription = station.StationDescription,
|
||||
StationHeight = station.StationHeight,
|
||||
X = station.X,
|
||||
Y = station.Y,
|
||||
Theta = station.Theta,
|
||||
InteractionNodes = station.InteractionNodes?.Select(sin => new StationInteractionNodeDto
|
||||
{
|
||||
Id = sin.Id,
|
||||
StationId = sin.StationId,
|
||||
NodeId = sin.NodeId,
|
||||
Node = sin.Node != null ? new NodeDto
|
||||
{
|
||||
Id = sin.Node.Id,
|
||||
NodeId = sin.Node.NodeId,
|
||||
NodeName = sin.Node.NodeName,
|
||||
X = sin.Node.X,
|
||||
Y = sin.Node.Y,
|
||||
VehicleProperties = sin.Node.VehicleProperties?.Select(vp => new NodeVehiclePropertyDto
|
||||
{
|
||||
Id = vp.Id,
|
||||
VehicleTypeId = vp.VehicleTypeId,
|
||||
Theta = vp.Theta,
|
||||
Actions = vp.Actions
|
||||
}).ToList()
|
||||
} : null
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static ErrorResponseDto CreateErrorResponse(
|
||||
string error,
|
||||
string? errorCode = null,
|
||||
Dictionary<string, object>? details = null)
|
||||
{
|
||||
return new ErrorResponseDto
|
||||
{
|
||||
Error = error,
|
||||
ErrorCode = errorCode,
|
||||
Details = details
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.VehicleType;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Requests;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Responses;
|
||||
using RobotNet10.MapManager.Services;
|
||||
|
||||
namespace RobotNet10.MapManager.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for managing vehicle types
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/vehicles")]
|
||||
[Authorize]
|
||||
public class VehiclesManagerController(
|
||||
IVehicleTypeService vehicleTypeService,
|
||||
ILogger<VehiclesManagerController> logger) : ControllerBase
|
||||
{
|
||||
private readonly IVehicleTypeService _vehicleTypeService = vehicleTypeService;
|
||||
private readonly ILogger<VehiclesManagerController> _logger = logger;
|
||||
|
||||
/// <summary>
|
||||
/// Create a new vehicle type
|
||||
/// </summary>
|
||||
/// <param name="request">Vehicle type creation request</param>
|
||||
/// <returns>Created vehicle type</returns>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(VehicleTypeDto), 201)]
|
||||
[ProducesResponseType(400)]
|
||||
public async Task<ActionResult<VehicleTypeDto>> CreateVehicleType([FromBody] CreateVehicleTypeRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var vehicleType = await _vehicleTypeService.CreateAsync(
|
||||
request.VehicleTypeId,
|
||||
request.VehicleTypeName,
|
||||
request.Description,
|
||||
request.Specifications,
|
||||
request.Actions);
|
||||
|
||||
var dto = MapToDto(vehicleType);
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetVehicleType),
|
||||
new { vehicleTypeId = vehicleType.Id },
|
||||
dto);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to create vehicle type: {VehicleTypeId}", request.VehicleTypeId);
|
||||
var errorCode = ex.Message.Contains("already exists")
|
||||
? "VEHICLE_TYPE_ALREADY_EXISTS"
|
||||
: "VALIDATION_ERROR";
|
||||
return BadRequest(CreateErrorResponse(ex.Message, errorCode));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all vehicle types
|
||||
/// </summary>
|
||||
/// <param name="isActive">Optional filter by active status</param>
|
||||
/// <returns>List of vehicle types</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(List<VehicleTypeDto>), 200)]
|
||||
public async Task<ActionResult<List<VehicleTypeDto>>> GetAllVehicleTypes([FromQuery] bool? isActive)
|
||||
{
|
||||
List<Data.VehicleType> vehicleTypes;
|
||||
|
||||
if (isActive.HasValue)
|
||||
{
|
||||
vehicleTypes = await _vehicleTypeService.GetByActiveStatusAsync(isActive.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
vehicleTypes = await _vehicleTypeService.GetAllAsync();
|
||||
}
|
||||
|
||||
var dtos = vehicleTypes.Select(MapToDto).ToList();
|
||||
|
||||
return Ok(dtos);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get vehicle type by database ID
|
||||
/// </summary>
|
||||
/// <param name="vehicleTypeId">Vehicle type database ID</param>
|
||||
/// <returns>Vehicle type details</returns>
|
||||
[HttpGet("{vehicleTypeId}")]
|
||||
[ProducesResponseType(typeof(VehicleTypeDto), 200)]
|
||||
[ProducesResponseType(404)]
|
||||
public async Task<ActionResult<VehicleTypeDto>> GetVehicleType(Guid vehicleTypeId)
|
||||
{
|
||||
var vehicleType = await _vehicleTypeService.GetByIdAsync(vehicleTypeId);
|
||||
|
||||
if (vehicleType == null)
|
||||
{
|
||||
return NotFound(CreateErrorResponse(
|
||||
$"Vehicle type with ID '{vehicleTypeId}' not found",
|
||||
"VEHICLE_TYPE_NOT_FOUND"));
|
||||
}
|
||||
|
||||
return Ok(MapToDto(vehicleType));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get vehicle type by VehicleTypeId string
|
||||
/// </summary>
|
||||
/// <param name="vehicleTypeId">Vehicle type identifier string (e.g., "AMR-T800")</param>
|
||||
/// <returns>Vehicle type details</returns>
|
||||
[HttpGet("vehicleTypeId/{vehicleTypeId}")]
|
||||
[ProducesResponseType(typeof(VehicleTypeDto), 200)]
|
||||
[ProducesResponseType(404)]
|
||||
public async Task<ActionResult<VehicleTypeDto>> GetVehicleTypeByStringId(string vehicleTypeId)
|
||||
{
|
||||
var vehicleType = await _vehicleTypeService.GetByVehicleTypeIdAsync(vehicleTypeId);
|
||||
|
||||
if (vehicleType == null)
|
||||
{
|
||||
return NotFound(CreateErrorResponse(
|
||||
$"Vehicle type with VehicleTypeId '{vehicleTypeId}' not found",
|
||||
"VEHICLE_TYPE_NOT_FOUND"));
|
||||
}
|
||||
|
||||
return Ok(MapToDto(vehicleType));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Search vehicle types by query string
|
||||
/// Searches in VehicleTypeId and VehicleTypeName (case-insensitive)
|
||||
/// </summary>
|
||||
/// <param name="query">Search query</param>
|
||||
/// <returns>List of matching vehicle types</returns>
|
||||
[HttpGet("search")]
|
||||
[ProducesResponseType(typeof(List<VehicleTypeDto>), 200)]
|
||||
public async Task<ActionResult<List<VehicleTypeDto>>> SearchVehicleTypes([FromQuery] string query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return BadRequest(CreateErrorResponse(
|
||||
"Query parameter is required",
|
||||
"VALIDATION_ERROR"));
|
||||
}
|
||||
|
||||
var vehicleTypes = await _vehicleTypeService.SearchAsync(query);
|
||||
var dtos = vehicleTypes.Select(MapToDto).ToList();
|
||||
|
||||
return Ok(dtos);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get usage information for a vehicle type
|
||||
/// </summary>
|
||||
/// <param name="id">Vehicle type database ID</param>
|
||||
/// <returns>Usage information</returns>
|
||||
[HttpGet("{id}/usage")]
|
||||
[ProducesResponseType(typeof(VehicleTypeUsageInfoDto), 200)]
|
||||
[ProducesResponseType(404)]
|
||||
public async Task<ActionResult<VehicleTypeUsageInfoDto>> GetVehicleTypeUsage(Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var usageInfo = await _vehicleTypeService.GetUsageInfoAsync(id);
|
||||
var dto = MapUsageInfoToDto(usageInfo);
|
||||
|
||||
return Ok(dto);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get usage info for vehicle type: {Id}", id);
|
||||
return NotFound(CreateErrorResponse(ex.Message, "VEHICLE_TYPE_NOT_FOUND"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update vehicle type
|
||||
/// </summary>
|
||||
/// <param name="vehicleTypeId">Vehicle type database ID</param>
|
||||
/// <param name="request">Update request</param>
|
||||
/// <returns>Updated vehicle type</returns>
|
||||
[HttpPut("{vehicleTypeId}")]
|
||||
[ProducesResponseType(typeof(VehicleTypeDto), 200)]
|
||||
[ProducesResponseType(404)]
|
||||
public async Task<ActionResult<VehicleTypeDto>> UpdateVehicleType(
|
||||
Guid vehicleTypeId,
|
||||
[FromBody] UpdateVehicleTypeRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var vehicleType = await _vehicleTypeService.UpdateAsync(
|
||||
vehicleTypeId,
|
||||
request.VehicleTypeName,
|
||||
request.Description,
|
||||
request.Specifications,
|
||||
request.Actions,
|
||||
request.IsActive);
|
||||
|
||||
return Ok(MapToDto(vehicleType));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to update vehicle type: {VehicleTypeId}", vehicleTypeId);
|
||||
return NotFound(CreateErrorResponse(ex.Message, "VEHICLE_TYPE_NOT_FOUND"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete vehicle type
|
||||
/// </summary>
|
||||
/// <param name="vehicleTypeId">Vehicle type database ID</param>
|
||||
/// <returns>No content on success</returns>
|
||||
[HttpDelete("{vehicleTypeId}")]
|
||||
[ProducesResponseType(204)]
|
||||
[ProducesResponseType(404)]
|
||||
[ProducesResponseType(400)]
|
||||
public async Task<IActionResult> DeleteVehicleType(Guid vehicleTypeId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var deleted = await _vehicleTypeService.DeleteAsync(vehicleTypeId);
|
||||
|
||||
if (!deleted)
|
||||
{
|
||||
return NotFound(CreateErrorResponse(
|
||||
$"Vehicle type with ID '{vehicleTypeId}' not found",
|
||||
"VEHICLE_TYPE_NOT_FOUND"));
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning(ex, "Failed to delete vehicle type: {VehicleTypeId}", vehicleTypeId);
|
||||
|
||||
var errorCode = ex.Message.Contains("referenced")
|
||||
? "VEHICLE_TYPE_IN_USE"
|
||||
: "VALIDATION_ERROR";
|
||||
|
||||
var details = new Dictionary<string, object>();
|
||||
if (errorCode == "VEHICLE_TYPE_IN_USE")
|
||||
{
|
||||
// Try to get usage info for details
|
||||
try
|
||||
{
|
||||
var usageInfo = await _vehicleTypeService.GetUsageInfoAsync(vehicleTypeId);
|
||||
details["nodePropertiesCount"] = usageInfo.NodePropertiesCount;
|
||||
details["edgePropertiesCount"] = usageInfo.EdgePropertiesCount;
|
||||
details["totalUsageCount"] = usageInfo.TotalUsageCount;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore if we can't get usage info
|
||||
}
|
||||
}
|
||||
|
||||
return BadRequest(CreateErrorResponse(ex.Message, errorCode, details));
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to map entity to DTO
|
||||
private static VehicleTypeDto MapToDto(Data.VehicleType vehicleType)
|
||||
{
|
||||
return new VehicleTypeDto
|
||||
{
|
||||
Id = vehicleType.Id,
|
||||
VehicleTypeId = vehicleType.VehicleTypeId,
|
||||
VehicleTypeName = vehicleType.VehicleTypeName,
|
||||
Description = vehicleType.Description,
|
||||
Specifications = vehicleType.Specifications,
|
||||
Actions = vehicleType.Actions,
|
||||
IsActive = vehicleType.IsActive,
|
||||
CreatedDate = vehicleType.CreatedDate
|
||||
};
|
||||
}
|
||||
|
||||
// Helper method to map usage info to DTO
|
||||
private static VehicleTypeUsageInfoDto MapUsageInfoToDto(VehicleTypeUsageInfo usageInfo)
|
||||
{
|
||||
return new VehicleTypeUsageInfoDto
|
||||
{
|
||||
VehicleTypeId = usageInfo.VehicleTypeId,
|
||||
VehicleTypeIdString = usageInfo.VehicleTypeIdString,
|
||||
VehicleTypeName = usageInfo.VehicleTypeName,
|
||||
NodePropertiesCount = usageInfo.NodePropertiesCount,
|
||||
EdgePropertiesCount = usageInfo.EdgePropertiesCount,
|
||||
TotalUsageCount = usageInfo.TotalUsageCount,
|
||||
CanDelete = usageInfo.CanDelete
|
||||
};
|
||||
}
|
||||
|
||||
// Helper method to create error response
|
||||
private static ErrorResponseDto CreateErrorResponse(
|
||||
string error,
|
||||
string? errorCode = null,
|
||||
Dictionary<string, object>? details = null)
|
||||
{
|
||||
return new ErrorResponseDto
|
||||
{
|
||||
Error = error,
|
||||
ErrorCode = errorCode,
|
||||
Details = details
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
68
srcs/RobotNet10/Commons/RobotNet10.MapManager/Data/Edge.cs
Normal file
68
srcs/RobotNet10/Commons/RobotNet10.MapManager/Data/Edge.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace RobotNet10.MapManager.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Edge entity - Paths between nodes
|
||||
/// Maps to VDMA LIF: layouts[].edges[]
|
||||
/// </summary>
|
||||
[Table("Edges")]
|
||||
public class Edge
|
||||
{
|
||||
[Key]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Parent level reference
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid LevelId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Edge identifier (VDMA LIF: edgeId) - Required
|
||||
/// Unique within level
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(128)]
|
||||
public string EdgeId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Start node reference (VDMA LIF: startNodeId) - Required
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid StartNodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// End node reference (VDMA LIF: endNodeId) - Required
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid EndNodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Edge name - Extension field (NOT in VDMA LIF)
|
||||
/// For UI/display purposes only
|
||||
/// </summary>
|
||||
[StringLength(256)]
|
||||
public string? EdgeName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Edge description - Extension field (NOT in VDMA LIF)
|
||||
/// For UI/display purposes only
|
||||
/// </summary>
|
||||
public string? EdgeDescription { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
[ForeignKey(nameof(LevelId))]
|
||||
public virtual LayoutLevel Level { get; set; } = null!;
|
||||
|
||||
[ForeignKey(nameof(StartNodeId))]
|
||||
public virtual Node StartNode { get; set; } = null!;
|
||||
|
||||
[ForeignKey(nameof(EndNodeId))]
|
||||
public virtual Node EndNode { get; set; } = null!;
|
||||
|
||||
public virtual ICollection<EdgeVehicleProperty> VehicleProperties { get; set; } = new List<EdgeVehicleProperty>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using RobotNet.VDA5050.Type;
|
||||
|
||||
namespace RobotNet10.MapManager.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Edge vehicle property - Vehicle-specific properties for edges
|
||||
/// Maps to VDMA LIF: edge.vehicleTypeEdgeProperties[]
|
||||
/// </summary>
|
||||
[Table("EdgeVehicleProperties")]
|
||||
public class EdgeVehicleProperty
|
||||
{
|
||||
[Key]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Edge reference
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid EdgeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Vehicle type reference
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid VehicleTypeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Vehicle orientation while traversing edge (VDMA LIF: vehicleOrientation) - Optional
|
||||
/// In degrees, range: [0.0 ... 360.0]
|
||||
/// </summary>
|
||||
public double? VehicleOrientation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of orientation (VDMA LIF: orientationType) - Optional
|
||||
/// Values: GLOBAL, TANGENTIAL
|
||||
/// </summary>
|
||||
public OrientationType? OrientationType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Is rotation allowed while on edge (VDMA LIF: rotationAllowed) - Optional
|
||||
/// </summary>
|
||||
public bool? RotationAllowed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Rotation allowed at start node (VDMA LIF: rotationAtStartNodeAllowed) - Optional
|
||||
/// Values: NONE, CCW, CW, BOTH
|
||||
/// </summary>
|
||||
public RotationDirection? RotationAtStartNodeAllowed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Rotation allowed at end node (VDMA LIF: rotationAtEndNodeAllowed) - Optional
|
||||
/// Values: NONE, CCW, CW, BOTH
|
||||
/// </summary>
|
||||
public RotationDirection? RotationAtEndNodeAllowed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum speed allowed on edge (VDMA LIF: maxSpeed) - Optional
|
||||
/// In meters per second, range: [0.0 ... double.MaxValue]
|
||||
/// </summary>
|
||||
public double? MaxSpeed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum rotation speed allowed (VDMA LIF: maxRotationSpeed) - Optional
|
||||
/// In radians per second, range: [0.0 ... double.MaxValue]
|
||||
/// </summary>
|
||||
public double? MaxRotationSpeed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum height of vehicle on edge (VDMA LIF: minHeight) - Optional
|
||||
/// In meters, range: [0.0 ... double.MaxValue]
|
||||
/// </summary>
|
||||
public double? MinHeight { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum height of vehicle on edge (VDMA LIF: maxHeight) - Optional
|
||||
/// In meters, range: [0.0 ... double.MaxValue]
|
||||
/// </summary>
|
||||
public double? MaxHeight { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Can edge be traversed without load (VDMA LIF: loadRestriction.unloaded) - Optional
|
||||
/// </summary>
|
||||
public bool? LoadRestriction_Unloaded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Can edge be traversed with load (VDMA LIF: loadRestriction.loaded) - Optional
|
||||
/// </summary>
|
||||
public bool? LoadRestriction_Loaded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Load set names allowed on edge (VDMA LIF: loadRestriction.loadSetNames) - Optional
|
||||
/// JSON array of strings: ["pallet", "box"]
|
||||
/// </summary>
|
||||
public string? LoadRestriction_LoadSetNames { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Trajectory degree (NURBS curve degree) - Optional
|
||||
/// Values: 1 (linear), 2 (quadratic), 3 (cubic)
|
||||
/// </summary>
|
||||
public int? TrajectoryDegree { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Trajectory control point 1 X coordinate (meters) - Optional
|
||||
/// Used for degree 2 and 3 curves
|
||||
/// </summary>
|
||||
public double? TrajectoryControlPoint1X { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Trajectory control point 1 Y coordinate (meters) - Optional
|
||||
/// Used for degree 2 and 3 curves
|
||||
/// </summary>
|
||||
public double? TrajectoryControlPoint1Y { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Trajectory control point 2 X coordinate (meters) - Optional
|
||||
/// Used for degree 3 curves only
|
||||
/// </summary>
|
||||
public double? TrajectoryControlPoint2X { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Trajectory control point 2 Y coordinate (meters) - Optional
|
||||
/// Used for degree 3 curves only
|
||||
/// </summary>
|
||||
public double? TrajectoryControlPoint2Y { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Actions that vehicle can perform at this edge (VDMA LIF: actions) - Optional
|
||||
/// JSON array format:
|
||||
/// [
|
||||
/// {
|
||||
/// "actionType": "pick",
|
||||
/// "actionDescription": "...",
|
||||
/// "requirementType": "REQUIRED",
|
||||
/// "blockingType": "HARD",
|
||||
/// "actionParameters": [{"key": "...", "value": "..."}]
|
||||
/// }
|
||||
/// ]
|
||||
/// </summary>
|
||||
public string? Actions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Corridor left width (meters) - Optional
|
||||
/// Defines the width of the corridor to the left related to the trajectory
|
||||
/// </summary>
|
||||
public double? CorridorLeftWidth { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Corridor right width (meters) - Optional
|
||||
/// Defines the width of the corridor to the right related to the trajectory
|
||||
/// </summary>
|
||||
public double? CorridorRightWidth { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Corridor reference point (VDA5050: corridorRefPoint) - Optional
|
||||
/// Defines whether the boundaries are valid for the kinematic center or the contour of the vehicle
|
||||
/// Values: KINEMATICCENTER, CONTOUR
|
||||
/// </summary>
|
||||
public CorridorRefPoint? CorridorRefPoint { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
[ForeignKey(nameof(EdgeId))]
|
||||
public virtual Edge Edge { get; set; } = null!;
|
||||
|
||||
[ForeignKey(nameof(VehicleTypeId))]
|
||||
public virtual VehicleType VehicleType { get; set; } = null!;
|
||||
}
|
||||
|
||||
69
srcs/RobotNet10/Commons/RobotNet10.MapManager/Data/Layout.cs
Normal file
69
srcs/RobotNet10/Commons/RobotNet10.MapManager/Data/Layout.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace RobotNet10.MapManager.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Layout entity - Root level representing a Building/Facility
|
||||
/// Maps to VDMA LIF: layouts[].layoutId
|
||||
/// </summary>
|
||||
[Table("Layouts")]
|
||||
public class Layout
|
||||
{
|
||||
[Key]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Unique layout identifier (VDMA LIF: layoutId)
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(128)]
|
||||
public string LayoutId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Layout name (VDMA LIF: layoutName)
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(256)]
|
||||
public string LayoutName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Description
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Is layout currently active/operational
|
||||
/// </summary>
|
||||
[Required]
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Created date
|
||||
/// </summary>
|
||||
[Required]
|
||||
public DateTime CreatedDate { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Last modified date
|
||||
/// </summary>
|
||||
[Required]
|
||||
public DateTime ModifiedDate { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Creator
|
||||
/// </summary>
|
||||
[StringLength(256)]
|
||||
public string? CreatedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Last modifier
|
||||
/// </summary>
|
||||
[StringLength(256)]
|
||||
public string? ModifiedBy { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
public virtual ICollection<LayoutVersion> Versions { get; set; } = new List<LayoutVersion>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace RobotNet10.MapManager.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Layout level entity - Represents floors/levels within a version
|
||||
/// Maps to VDMA LIF: layouts[].layoutLevelId
|
||||
/// </summary>
|
||||
[Table("LayoutLevels")]
|
||||
public class LayoutLevel
|
||||
{
|
||||
[Key]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Parent version reference
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid VersionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Level identifier (VDMA LIF: layoutLevelId)
|
||||
/// Example: "floor_1", "floor_2", "basement"
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(64)]
|
||||
public string LayoutLevelId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Display order (for sorting in UI)
|
||||
/// Example: Basement=-1, Floor1=0, Floor2=1
|
||||
/// </summary>
|
||||
[Required]
|
||||
public int LevelOrder { get; set; } = 0;
|
||||
|
||||
// Navigation properties
|
||||
[ForeignKey(nameof(VersionId))]
|
||||
public virtual LayoutVersion Version { get; set; } = null!;
|
||||
|
||||
public virtual ICollection<Node> Nodes { get; set; } = new List<Node>();
|
||||
public virtual ICollection<Edge> Edges { get; set; } = new List<Edge>();
|
||||
public virtual ICollection<Station> Stations { get; set; } = new List<Station>();
|
||||
|
||||
/// <summary>
|
||||
/// Editor-specific settings (UI extensions, not part of VDMA LIF)
|
||||
/// </summary>
|
||||
public virtual LayoutLevelEditorSettings? EditorSettings { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace RobotNet10.MapManager.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Editor-specific settings for each layout level
|
||||
/// NOT part of VDMA LIF standard - UI/Editor extensions only
|
||||
/// </summary>
|
||||
[Table("LayoutLevelEditorSettings")]
|
||||
public class LayoutLevelEditorSettings
|
||||
{
|
||||
[Key]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Parent layout level reference (1-to-1 relationship)
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid LevelId { get; set; }
|
||||
|
||||
// ==========================================
|
||||
// EDGE SETTINGS
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Minimum edge length (in METERS) required to create an edge
|
||||
/// Prevents accidental creation of very short edges
|
||||
/// Default: 0.1 meters (10 cm)
|
||||
/// </summary>
|
||||
[Required]
|
||||
public double EdgeMinLengthCreate { get; set; } = 0.5;
|
||||
|
||||
/// <summary>
|
||||
/// Auto-generate edge names when creating new edges
|
||||
/// Uses 8-character GUID: "Edge_a7f2e3b1"
|
||||
/// </summary>
|
||||
[Required]
|
||||
public bool EdgeNameAutoGenerate { get; set; } = false;
|
||||
|
||||
// ==========================================
|
||||
// NODE SETTINGS
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Auto-generate node names when creating new nodes
|
||||
/// Uses 8-character GUID: "Node_a7f2e3b1"
|
||||
/// </summary>
|
||||
[Required]
|
||||
public bool NodeNameAutoGenerate { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Node proximity radius (in METERS) for edge creation
|
||||
/// When creating an edge, if start/end point is within this radius of an existing node,
|
||||
/// the edge will connect to that existing node instead of creating a new one
|
||||
/// Default: 0.35 meters (35 cm)
|
||||
/// </summary>
|
||||
[Required]
|
||||
public double NodeProximityRadius { get; set; } = 0.35;
|
||||
|
||||
// ==========================================
|
||||
// COORDINATE SYSTEM SETTINGS
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// World coordinate origin X in METERS
|
||||
/// Defines where the world coordinate system (0, 0) is located
|
||||
/// Default: 0.0
|
||||
/// </summary>
|
||||
[Required]
|
||||
public double OriginX { get; set; } = 0.0;
|
||||
|
||||
/// <summary>
|
||||
/// World coordinate origin Y in METERS
|
||||
/// Defines where the world coordinate system (0, 0) is located
|
||||
/// Default: 0.0
|
||||
/// </summary>
|
||||
[Required]
|
||||
public double OriginY { get; set; } = 0.0;
|
||||
|
||||
/// <summary>
|
||||
/// Resolution: Meters per pixel
|
||||
/// Conversion factor between world coordinates (meters) and image coordinates (pixels)
|
||||
/// Example: 0.05 means 1 pixel = 5 cm in real world
|
||||
/// Default: 0.05 (5 cm per pixel)
|
||||
/// </summary>
|
||||
[Required]
|
||||
public double Resolution { get; set; } = 0.05;
|
||||
|
||||
// ==========================================
|
||||
// COORDINATE BOUNDS (World Coordinates)
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Minimum X boundary in METERS (world coordinates)
|
||||
/// Defines the leftmost valid coordinate for this level
|
||||
/// Optional: null means no limit
|
||||
/// </summary>
|
||||
public double? BoundsMinX { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum X boundary in METERS (world coordinates)
|
||||
/// Defines the rightmost valid coordinate for this level
|
||||
/// Optional: null means no limit
|
||||
/// </summary>
|
||||
public double? BoundsMaxX { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum Y boundary in METERS (world coordinates)
|
||||
/// Defines the bottom valid coordinate for this level
|
||||
/// Optional: null means no limit
|
||||
/// </summary>
|
||||
public double? BoundsMinY { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum Y boundary in METERS (world coordinates)
|
||||
/// Defines the top valid coordinate for this level
|
||||
/// Optional: null means no limit
|
||||
/// </summary>
|
||||
public double? BoundsMaxY { get; set; }
|
||||
|
||||
// ==========================================
|
||||
// BACKGROUND IMAGE SETTINGS
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// Background image width in PIXELS
|
||||
/// Represents the actual pixel dimensions of the image file
|
||||
/// Used for rendering and coordinate conversion
|
||||
/// </summary>
|
||||
public double? ImageWidth { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Background image height in PIXELS
|
||||
/// Represents the actual pixel dimensions of the image file
|
||||
/// Used for rendering and coordinate conversion
|
||||
/// </summary>
|
||||
public double? ImageHeight { get; set; }
|
||||
|
||||
// ==========================================
|
||||
// METADATA
|
||||
// ==========================================
|
||||
|
||||
/// <summary>
|
||||
/// When these settings were created
|
||||
/// </summary>
|
||||
[Required]
|
||||
public DateTime CreatedDate { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Last time these settings were modified
|
||||
/// </summary>
|
||||
[Required]
|
||||
public DateTime ModifiedDate { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// ==========================================
|
||||
// NAVIGATION PROPERTIES
|
||||
// ==========================================
|
||||
|
||||
[ForeignKey(nameof(LevelId))]
|
||||
public virtual LayoutLevel Level { get; set; } = null!;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace RobotNet10.MapManager.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Layout version entity - Version history for each layout
|
||||
/// Maps to VDMA LIF: layouts[].layoutVersion
|
||||
/// </summary>
|
||||
[Table("LayoutVersions")]
|
||||
public class LayoutVersion
|
||||
{
|
||||
[Key]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Parent layout reference
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid LayoutId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Version number (VDMA LIF: layoutVersion)
|
||||
/// Suggested: "1", "2", "3" or "1.0", "1.1"
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(32)]
|
||||
public string Version { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Version description (VDMA LIF: layoutDescription)
|
||||
/// </summary>
|
||||
public string? LayoutDescription { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creator of this version
|
||||
/// </summary>
|
||||
[StringLength(256)]
|
||||
public string? CreatedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creation date
|
||||
/// </summary>
|
||||
[Required]
|
||||
public DateTime CreatedDate { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Is this the active version?
|
||||
/// Only ONE version can be active per layout
|
||||
/// Active version is READ-ONLY
|
||||
/// </summary>
|
||||
[Required]
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
// Navigation properties
|
||||
[ForeignKey(nameof(LayoutId))]
|
||||
public virtual Layout Layout { get; set; } = null!;
|
||||
|
||||
public virtual ICollection<LayoutLevel> Levels { get; set; } = new List<LayoutLevel>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace RobotNet10.MapManager.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Database context for VDMA LIF 1.0.0 Map Manager
|
||||
/// </summary>
|
||||
public class MapDbContext(DbContextOptions<MapDbContext> options) : DbContext(options)
|
||||
{
|
||||
|
||||
// DbSets
|
||||
public DbSet<Layout> Layouts { get; set; } = null!;
|
||||
public DbSet<LayoutVersion> LayoutVersions { get; set; } = null!;
|
||||
public DbSet<LayoutLevel> LayoutLevels { get; set; } = null!;
|
||||
public DbSet<VehicleType> VehicleTypes { get; set; } = null!;
|
||||
public DbSet<Node> Nodes { get; set; } = null!;
|
||||
public DbSet<Edge> Edges { get; set; } = null!;
|
||||
public DbSet<Station> Stations { get; set; } = null!;
|
||||
public DbSet<StationInteractionNode> StationInteractionNodes { get; set; } = null!;
|
||||
public DbSet<NodeVehicleProperty> NodeVehicleProperties { get; set; } = null!;
|
||||
public DbSet<EdgeVehicleProperty> EdgeVehicleProperties { get; set; } = null!;
|
||||
public DbSet<LayoutLevelEditorSettings> LayoutLevelEditorSettings { get; set; } = null!;
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// Layout configuration
|
||||
modelBuilder.Entity<Layout>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => e.LayoutId).IsUnique();
|
||||
entity.HasIndex(e => e.IsActive);
|
||||
|
||||
entity.HasMany(e => e.Versions)
|
||||
.WithOne(e => e.Layout)
|
||||
.HasForeignKey(e => e.LayoutId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// LayoutVersion configuration
|
||||
modelBuilder.Entity<LayoutVersion>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => new { e.LayoutId, e.Version }).IsUnique();
|
||||
entity.HasIndex(e => e.IsActive);
|
||||
|
||||
entity.HasMany(e => e.Levels)
|
||||
.WithOne(e => e.Version)
|
||||
.HasForeignKey(e => e.VersionId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// LayoutLevel configuration
|
||||
modelBuilder.Entity<LayoutLevel>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => new { e.VersionId, e.LayoutLevelId }).IsUnique();
|
||||
entity.HasIndex(e => e.LevelOrder);
|
||||
|
||||
entity.HasMany(e => e.Nodes)
|
||||
.WithOne(e => e.Level)
|
||||
.HasForeignKey(e => e.LevelId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasMany(e => e.Edges)
|
||||
.WithOne(e => e.Level)
|
||||
.HasForeignKey(e => e.LevelId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasMany(e => e.Stations)
|
||||
.WithOne(e => e.Level)
|
||||
.HasForeignKey(e => e.LevelId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// VehicleType configuration
|
||||
modelBuilder.Entity<VehicleType>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => e.VehicleTypeId).IsUnique();
|
||||
entity.HasIndex(e => e.IsActive);
|
||||
|
||||
entity.HasMany(e => e.NodeVehicleProperties)
|
||||
.WithOne(e => e.VehicleType)
|
||||
.HasForeignKey(e => e.VehicleTypeId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasMany(e => e.EdgeVehicleProperties)
|
||||
.WithOne(e => e.VehicleType)
|
||||
.HasForeignKey(e => e.VehicleTypeId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// Node configuration
|
||||
modelBuilder.Entity<Node>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => new { e.LevelId, e.NodeId }).IsUnique();
|
||||
entity.HasIndex(e => e.NodeId);
|
||||
entity.HasIndex(e => e.MapId);
|
||||
entity.HasIndex(e => new { e.X, e.Y });
|
||||
|
||||
entity.HasMany(e => e.VehicleProperties)
|
||||
.WithOne(e => e.Node)
|
||||
.HasForeignKey(e => e.NodeId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasMany(e => e.OutgoingEdges)
|
||||
.WithOne(e => e.StartNode)
|
||||
.HasForeignKey(e => e.StartNodeId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
entity.HasMany(e => e.IncomingEdges)
|
||||
.WithOne(e => e.EndNode)
|
||||
.HasForeignKey(e => e.EndNodeId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
entity.HasMany(e => e.StationInteractions)
|
||||
.WithOne(e => e.Node)
|
||||
.HasForeignKey(e => e.NodeId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
// Edge configuration
|
||||
modelBuilder.Entity<Edge>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => new { e.LevelId, e.EdgeId }).IsUnique();
|
||||
entity.HasIndex(e => e.EdgeId);
|
||||
entity.HasIndex(e => e.StartNodeId);
|
||||
entity.HasIndex(e => e.EndNodeId);
|
||||
|
||||
entity.ToTable(t => t.HasCheckConstraint("CK_Edges_DifferentNodes", "[StartNodeId] <> [EndNodeId]"));
|
||||
|
||||
entity.HasMany(e => e.VehicleProperties)
|
||||
.WithOne(e => e.Edge)
|
||||
.HasForeignKey(e => e.EdgeId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// Station configuration
|
||||
modelBuilder.Entity<Station>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => new { e.LevelId, e.StationId }).IsUnique();
|
||||
entity.HasIndex(e => e.StationId);
|
||||
|
||||
entity.HasMany(e => e.InteractionNodes)
|
||||
.WithOne(e => e.Station)
|
||||
.HasForeignKey(e => e.StationId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// StationInteractionNode configuration
|
||||
modelBuilder.Entity<StationInteractionNode>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => new { e.StationId, e.NodeId }).IsUnique();
|
||||
entity.HasIndex(e => e.StationId);
|
||||
entity.HasIndex(e => e.NodeId);
|
||||
});
|
||||
|
||||
// NodeVehicleProperty configuration
|
||||
modelBuilder.Entity<NodeVehicleProperty>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => new { e.NodeId, e.VehicleTypeId }).IsUnique();
|
||||
entity.HasIndex(e => e.NodeId);
|
||||
entity.HasIndex(e => e.VehicleTypeId);
|
||||
});
|
||||
|
||||
// EdgeVehicleProperty configuration
|
||||
modelBuilder.Entity<EdgeVehicleProperty>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => new { e.EdgeId, e.VehicleTypeId }).IsUnique();
|
||||
entity.HasIndex(e => e.EdgeId);
|
||||
entity.HasIndex(e => e.VehicleTypeId);
|
||||
});
|
||||
|
||||
// LayoutLevelEditorSettings configuration (UI extensions - not VDMA LIF)
|
||||
modelBuilder.Entity<LayoutLevelEditorSettings>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
|
||||
// 1-to-1 relationship with LayoutLevel (unique index on LevelId)
|
||||
entity.HasIndex(e => e.LevelId).IsUnique();
|
||||
|
||||
entity.HasOne(e => e.Level)
|
||||
.WithOne(l => l.EditorSettings)
|
||||
.HasForeignKey<LayoutLevelEditorSettings>(e => e.LevelId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
70
srcs/RobotNet10/Commons/RobotNet10.MapManager/Data/Node.cs
Normal file
70
srcs/RobotNet10/Commons/RobotNet10.MapManager/Data/Node.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace RobotNet10.MapManager.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Node entity - Waypoints/nodes on the map
|
||||
/// Maps to VDMA LIF: layouts[].nodes[]
|
||||
/// </summary>
|
||||
[Table("Nodes")]
|
||||
public class Node
|
||||
{
|
||||
[Key]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Parent level reference
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid LevelId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Node identifier (VDMA LIF: nodeId) - Required
|
||||
/// Unique within level
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(128)]
|
||||
public string NodeId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Node name (VDMA LIF: nodeName) - Optional
|
||||
/// </summary>
|
||||
[StringLength(256)]
|
||||
public string? NodeName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Node description (VDMA LIF: nodeDescription) - Optional
|
||||
/// </summary>
|
||||
public string? NodeDescription { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Map identifier (VDMA LIF: mapId) - Optional
|
||||
/// Reference to map image or data
|
||||
/// </summary>
|
||||
[StringLength(128)]
|
||||
public string? MapId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// X coordinate in meters (VDMA LIF: nodePosition.x) - Required
|
||||
/// </summary>
|
||||
[Required]
|
||||
public double X { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Y coordinate in meters (VDMA LIF: nodePosition.y) - Required
|
||||
/// </summary>
|
||||
[Required]
|
||||
public double Y { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
[ForeignKey(nameof(LevelId))]
|
||||
public virtual LayoutLevel Level { get; set; } = null!;
|
||||
|
||||
public virtual ICollection<NodeVehicleProperty> VehicleProperties { get; set; } = new List<NodeVehicleProperty>();
|
||||
public virtual ICollection<Edge> OutgoingEdges { get; set; } = new List<Edge>();
|
||||
public virtual ICollection<Edge> IncomingEdges { get; set; } = new List<Edge>();
|
||||
public virtual ICollection<StationInteractionNode> StationInteractions { get; set; } = new List<StationInteractionNode>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace RobotNet10.MapManager.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Node vehicle property - Vehicle-specific properties for nodes
|
||||
/// Maps to VDMA LIF: node.vehicleTypeNodeProperties[]
|
||||
/// </summary>
|
||||
[Table("NodeVehicleProperties")]
|
||||
public class NodeVehicleProperty
|
||||
{
|
||||
[Key]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Node reference
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid NodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Vehicle type reference
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid VehicleTypeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Absolute orientation of vehicle on node (VDMA LIF: theta) - Optional
|
||||
/// In radians, range: [-Pi ... Pi]
|
||||
/// </summary>
|
||||
public double? Theta { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Actions that vehicle can perform at this node (VDMA LIF: actions) - Optional
|
||||
/// JSON array format:
|
||||
/// [
|
||||
/// {
|
||||
/// "actionType": "pick",
|
||||
/// "actionDescription": "...",
|
||||
/// "requirementType": "REQUIRED",
|
||||
/// "blockingType": "HARD",
|
||||
/// "actionParameters": [{"key": "...", "value": "..."}]
|
||||
/// }
|
||||
/// ]
|
||||
/// </summary>
|
||||
public string? Actions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Allowed deviation radius in meters (VDA5050: allowedDeviationXY) - Optional
|
||||
/// Indicates how exact an AGV has to drive over a node in order for it to count as traversed.
|
||||
/// If = 0: no deviation is allowed (no deviation means within the normal tolerance of the AGV manufacturer).
|
||||
/// If > 0: allowed deviation-radius in meters. If the AGV passes a node within the deviation-radius, the node is considered to have been traversed.
|
||||
/// Minimum: 0
|
||||
/// </summary>
|
||||
public double? AllowedDeviationXY { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Allowed deviation of theta angle in radians (VDA5050: allowedDeviationTheta) - Optional
|
||||
/// Indicates how big the deviation of theta angle can be.
|
||||
/// The lowest acceptable angle is theta - allowedDeviationTheta and the highest acceptable angle is theta + allowedDeviationTheta.
|
||||
/// Range: [0.0 ... 3.141592654] (0 to Pi)
|
||||
/// </summary>
|
||||
public double? AllowedDeviationTheta { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
[ForeignKey(nameof(NodeId))]
|
||||
public virtual Node Node { get; set; } = null!;
|
||||
|
||||
[ForeignKey(nameof(VehicleTypeId))]
|
||||
public virtual VehicleType VehicleType { get; set; } = null!;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace RobotNet10.MapManager.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Station entity - Interaction points for vehicles
|
||||
/// Maps to VDMA LIF: layouts[].stations[]
|
||||
/// </summary>
|
||||
[Table("Stations")]
|
||||
public class Station
|
||||
{
|
||||
[Key]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Parent level reference
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid LevelId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Station identifier (VDMA LIF: stationId) - Required
|
||||
/// Unique within level
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(128)]
|
||||
public string StationId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Station name (VDMA LIF: stationName) - Optional
|
||||
/// </summary>
|
||||
[StringLength(256)]
|
||||
public string? StationName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Station description (VDMA LIF: stationDescription) - Optional
|
||||
/// </summary>
|
||||
public string? StationDescription { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Station height in meters (VDMA LIF: stationHeight) - Optional
|
||||
/// </summary>
|
||||
public double? StationHeight { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// X coordinate in meters (VDMA LIF: stationPosition.x) - Required
|
||||
/// </summary>
|
||||
[Required]
|
||||
public double X { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Y coordinate in meters (VDMA LIF: stationPosition.y) - Required
|
||||
/// </summary>
|
||||
[Required]
|
||||
public double Y { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Theta orientation in radians (VDMA LIF: stationPosition.theta) - Optional
|
||||
/// Range: [-Pi ... Pi]
|
||||
/// </summary>
|
||||
public double? Theta { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
[ForeignKey(nameof(LevelId))]
|
||||
public virtual LayoutLevel Level { get; set; } = null!;
|
||||
|
||||
public virtual ICollection<StationInteractionNode> InteractionNodes { get; set; } = new List<StationInteractionNode>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace RobotNet10.MapManager.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Station interaction node - Junction table linking stations to nodes
|
||||
/// Maps to VDMA LIF: station.interactionNodeIds[]
|
||||
/// </summary>
|
||||
[Table("StationInteractionNodes")]
|
||||
public class StationInteractionNode
|
||||
{
|
||||
[Key]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Station reference
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid StationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Node reference
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid NodeId { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
[ForeignKey(nameof(StationId))]
|
||||
public virtual Station Station { get; set; } = null!;
|
||||
|
||||
[ForeignKey(nameof(NodeId))]
|
||||
public virtual Node Node { get; set; } = null!;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace RobotNet10.MapManager.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Vehicle type entity - Master data for vehicle types
|
||||
/// Used in vehicleTypeNodeProperties and vehicleTypeEdgeProperties
|
||||
/// </summary>
|
||||
[Table("VehicleTypes")]
|
||||
public class VehicleType
|
||||
{
|
||||
[Key]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Vehicle type identifier (VDMA LIF: vehicleTypeId)
|
||||
/// Example: "AMR-T800", "AMR-F100", "Forklift-X1"
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(64)]
|
||||
public string VehicleTypeId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Vehicle type name
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(256)]
|
||||
public string VehicleTypeName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Description
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional specifications (JSON)
|
||||
/// For future extensions
|
||||
/// </summary>
|
||||
public string? Specifications { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Is vehicle type active
|
||||
/// </summary>
|
||||
[Required]
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Actions default that vehicle can perform (VDMA LIF: actions) - Optional
|
||||
/// JSON array format:
|
||||
/// [
|
||||
/// {
|
||||
/// "actionType": "pick",
|
||||
/// "actionDescription": "...",
|
||||
/// "requirementType": "REQUIRED", // Enum: REQUIRED, CONDITIONAL, OPTIONAL (RequirementType enum)
|
||||
/// "blockingType": "HARD",
|
||||
/// "actionParameters": [{"key": "...", "value": "..."}]
|
||||
/// }
|
||||
/// ]
|
||||
/// </summary>
|
||||
public string? Actions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Created date
|
||||
/// </summary>
|
||||
[Required]
|
||||
public DateTime CreatedDate { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Navigation properties
|
||||
public virtual ICollection<NodeVehicleProperty> NodeVehicleProperties { get; set; } = new List<NodeVehicleProperty>();
|
||||
public virtual ICollection<EdgeVehicleProperty> EdgeVehicleProperties { get; set; } = new List<EdgeVehicleProperty>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RobotNet10.MapManager.Data;
|
||||
using RobotNet10.MapManager.Services;
|
||||
using RobotNet10.StorageManager;
|
||||
|
||||
namespace RobotNet10.MapManager.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering MapManager services
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Add MapManager services to the DI container
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection</param>
|
||||
/// <param name="configuration">Configuration</param>
|
||||
/// <param name="dbContextOptions">action register DbContext</param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="NotImplementedException"></exception>
|
||||
public static IServiceCollection AddMapManager(this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
Action<DbContextOptionsBuilder> dbContextOptions,
|
||||
string configSectionName = "LayoutImage")
|
||||
{
|
||||
// Register DbContext
|
||||
services.AddDbContext<MapDbContext>(dbContextOptions);
|
||||
|
||||
services.Configure<StorageConfig>("LayoutImages", options =>
|
||||
{
|
||||
configuration.GetSection(configSectionName).Bind(options);
|
||||
});
|
||||
services.AddSingleton<IImageStorageService, FileSystemImageStorageService>();
|
||||
// Register Business Services
|
||||
services.AddScoped<ILayoutService, LayoutService>();
|
||||
services.AddScoped<IVehicleTypeService, VehicleTypeService>();
|
||||
services.AddScoped<INodeService, NodeService>();
|
||||
services.AddScoped<IEdgeService, EdgeService>();
|
||||
services.AddScoped<IStationService, StationService>();
|
||||
services.AddScoped<ILayoutDataService, LayoutDataService>();
|
||||
services.AddScoped<IMapQueryService, MapQueryService>();
|
||||
services.AddScoped<LayoutLevelNamingService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks MapManager database connectivity
|
||||
/// Call this after app.Build() to verify database is accessible
|
||||
/// Note: Does NOT apply migrations or seed data - only checks connectivity
|
||||
/// </summary>
|
||||
/// <param name="services">Service provider</param>
|
||||
/// <returns>Task</returns>
|
||||
public static async Task SeedMapManagerAsync(this IServiceProvider services)
|
||||
{
|
||||
using var scope = services.CreateScope();
|
||||
var serviceProvider = scope.ServiceProvider;
|
||||
var logger = serviceProvider.GetRequiredService<ILogger<MapDbContext>>();
|
||||
|
||||
try
|
||||
{
|
||||
var context = serviceProvider.GetRequiredService<MapDbContext>();
|
||||
logger.LogInformation("Skipping automatic MapManager migration at startup. Running connectivity check only.");
|
||||
|
||||
// Check if database can be connected
|
||||
logger.LogInformation("Checking MapManager database connection...");
|
||||
var canConnect = await context.Database.CanConnectAsync();
|
||||
|
||||
if (canConnect)
|
||||
{
|
||||
logger.LogInformation("MapManager database is accessible");
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning("MapManager database is not accessible. Please ensure database is created.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error occurred while checking MapManager database");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
181
srcs/RobotNet10/Commons/RobotNet10.MapManager/README.md
Normal file
181
srcs/RobotNet10/Commons/RobotNet10.MapManager/README.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# RobotNet10.MapManager
|
||||
|
||||
## Overview
|
||||
|
||||
Entity Framework Core module for managing AGV/AMR maps according to **VDMA LIF (Layout Interchange Format) 1.0.0** standard.
|
||||
|
||||
## Status
|
||||
|
||||
✅ **Phase 1 Complete: Database Foundation**
|
||||
- [x] Entity classes generated (10 entities)
|
||||
- [x] DbContext created with full configurations
|
||||
- [x] EF Core packages installed (v9.0.0)
|
||||
- [x] Design documentation finalized
|
||||
|
||||
## Entity Classes
|
||||
|
||||
### Hierarchy (Layout → Version → Level)
|
||||
|
||||
1. **Layout.cs** - Root entity (Building/Facility)
|
||||
2. **LayoutVersion.cs** - Version history
|
||||
3. **LayoutLevel.cs** - Floors/Levels
|
||||
|
||||
### Master Data
|
||||
|
||||
4. **VehicleType.cs** - Vehicle types (AMR-T800, etc.)
|
||||
|
||||
### Map Elements
|
||||
|
||||
5. **Node.cs** - Waypoints (VDMA LIF compliant)
|
||||
6. **Edge.cs** - Paths (with EdgeName/EdgeDescription extensions)
|
||||
7. **Station.cs** - Interaction points
|
||||
|
||||
### Junction Tables
|
||||
|
||||
8. **StationInteractionNode.cs** - Station ↔ Node mapping
|
||||
9. **NodeVehicleProperty.cs** - Node × VehicleType properties
|
||||
10. **EdgeVehicleProperty.cs** - Edge × VehicleType properties
|
||||
|
||||
### Database Context
|
||||
|
||||
- **MapDbContext.cs** - EF Core DbContext with all configurations
|
||||
|
||||
## Key Features
|
||||
|
||||
### VDMA LIF Compliance
|
||||
|
||||
- ✅ 100% adherence to lif-schema.json
|
||||
- ✅ Proper JSON serialization for import/export
|
||||
- ✅ Support for vehicleTypeNodeProperties and vehicleTypeEdgeProperties
|
||||
|
||||
### Version Control
|
||||
|
||||
- ✅ Multiple versions per layout
|
||||
- ✅ Only ONE active version per layout
|
||||
- ✅ Active version is READ-ONLY
|
||||
|
||||
### Multi-Level Support
|
||||
|
||||
- ✅ Multiple levels (floors) per version
|
||||
- ✅ LevelOrder for flexible sorting
|
||||
- ✅ Each level exports as separate layout entry in JSON
|
||||
|
||||
### VehicleType Customization
|
||||
|
||||
- ✅ Per-vehicle properties for nodes and edges
|
||||
- ✅ Actions stored as JSON in NodeVehicleProperties
|
||||
- ✅ NURBS trajectory support in EdgeVehicleProperties
|
||||
|
||||
## Database Schema
|
||||
|
||||
```
|
||||
Layouts (10 tables)
|
||||
├── Layouts → LayoutVersions → LayoutLevels
|
||||
├── VehicleTypes
|
||||
├── Nodes (with NodeVehicleProperties)
|
||||
├── Edges (with EdgeVehicleProperties)
|
||||
└── Stations (with StationInteractionNodes)
|
||||
```
|
||||
|
||||
**Total Indexes:** ~25 (PKs, FKs, composite indexes)
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Phase 2: Migrations
|
||||
|
||||
```bash
|
||||
# Add EF Core tools (if not installed)
|
||||
dotnet tool install --global dotnet-ef
|
||||
|
||||
# Create initial migration
|
||||
dotnet ef migrations add InitialCreate --project srcs/RobotNet10/Commons/RobotNet10.MapManager
|
||||
|
||||
# Update database
|
||||
dotnet ef database update --project srcs/RobotNet10/Commons/RobotNet10.MapManager
|
||||
```
|
||||
|
||||
### Phase 3: Import/Export Services
|
||||
|
||||
- [ ] Create VDMA LIF JSON models
|
||||
- [ ] Implement Export service (DB → JSON)
|
||||
- [ ] Implement Import service (JSON → DB)
|
||||
- [ ] Validation against lif-schema.json
|
||||
|
||||
### Phase 4: Integration
|
||||
|
||||
- [ ] API endpoints for MapEditor
|
||||
- [ ] Version management
|
||||
- [ ] VehicleType management
|
||||
|
||||
## Documentation
|
||||
|
||||
- **DATABASE_DESIGN.md** - Complete schema documentation
|
||||
- **docs/MapEditor/V2-DangNV/DATABASE_DESIGN_DISCUSSION.md** - Design discussion summary
|
||||
- **lif-schema.json** - VDMA LIF JSON schema reference
|
||||
|
||||
## Configuration
|
||||
|
||||
### Connection Strings
|
||||
|
||||
**SQL Server:**
|
||||
```json
|
||||
"ConnectionStrings": {
|
||||
"MapManagerDb": "Server=localhost;Database=RobotNetMaps;Trusted_Connection=True;TrustServerCertificate=True;"
|
||||
}
|
||||
```
|
||||
|
||||
**SQLite:**
|
||||
```json
|
||||
"ConnectionStrings": {
|
||||
"MapManagerDb": "Data Source=robotnet_maps.db"
|
||||
}
|
||||
```
|
||||
|
||||
### Program.cs
|
||||
|
||||
```csharp
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RobotNet10.MapManager.Data;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add MapDbContext
|
||||
builder.Services.AddDbContext<MapDbContext>(options =>
|
||||
options.UseSqlServer(builder.Configuration.GetConnectionString("MapManagerDb"))
|
||||
// or options.UseSqlite(builder.Configuration.GetConnectionString("MapManagerDb"))
|
||||
);
|
||||
|
||||
var app = builder.Build();
|
||||
app.Run();
|
||||
```
|
||||
|
||||
## Scale Targets
|
||||
|
||||
- 50 Layouts
|
||||
- 10 Versions per Layout
|
||||
- 10 Levels per Version
|
||||
- 1,000 Nodes per Level
|
||||
- 999 Edges per Level
|
||||
- 10 VehicleTypes
|
||||
|
||||
**Total:** ~5M Nodes, ~5M Edges
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
- ✅ Strategic indexing (25+ indexes)
|
||||
- ✅ Designed for partitioning (by LayoutId or LevelId)
|
||||
- ✅ Caching strategy identified
|
||||
- ✅ Pagination support planned
|
||||
|
||||
## References
|
||||
|
||||
- **VDMA LIF Specification:** FuI_Guideline_LIF_GB_final.pdf
|
||||
- **Entity Framework Core:** https://docs.microsoft.com/ef/core/
|
||||
- **VDMA LIF Schema:** lif-schema.json
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.0
|
||||
**Date:** 2024-11-26
|
||||
**Status:** Phase 1 Complete ✅
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.3">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.3" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\RobotNet10.StorageManager\RobotNet10.StorageManager.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\RobotNet10.MapEditor.Shared\RobotNet10.MapEditor.Shared.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\RobotNet.VDA5050\RobotNet.VDA5050.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,363 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RobotNet.VDA5050;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Requests;
|
||||
using RobotNet10.MapManager.Data;
|
||||
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service implementation for managing edges with complex node detection logic
|
||||
/// </summary>
|
||||
public class EdgeService(
|
||||
MapDbContext context,
|
||||
INodeService nodeService,
|
||||
LayoutLevelNamingService namingService) : IEdgeService
|
||||
{
|
||||
private readonly MapDbContext _context = context;
|
||||
private readonly INodeService _nodeService = nodeService;
|
||||
private readonly LayoutLevelNamingService _namingService = namingService;
|
||||
|
||||
public async Task<Edge> CreateAsync(CreateEdgeRequest request)
|
||||
{
|
||||
// Get editor settings for validation and proximity radius
|
||||
var settings = await _context.LayoutLevelEditorSettings
|
||||
.FirstOrDefaultAsync(s => s.LevelId == request.LayoutLevelId);
|
||||
|
||||
var proximityRadius = settings?.NodeProximityRadius ?? 0.35;
|
||||
var minEdgeLength = settings?.EdgeMinLengthCreate ?? 0.1;
|
||||
|
||||
// Calculate edge length
|
||||
var dx = request.X2 - request.X1;
|
||||
var dy = request.Y2 - request.Y1;
|
||||
var edgeLength = Math.Sqrt(dx * dx + dy * dy);
|
||||
|
||||
// Validate edge length
|
||||
if (edgeLength < minEdgeLength)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Edge length ({edgeLength:F3}m) is less than minimum required ({minEdgeLength:F3}m)");
|
||||
}
|
||||
|
||||
using var transaction = await _context.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
// Find or create start node
|
||||
var (startNode, startNodeIsNew) = await FindOrCreateNodeAsync(
|
||||
request.LayoutLevelId,
|
||||
request.X1,
|
||||
request.Y1,
|
||||
proximityRadius);
|
||||
|
||||
// Find or create end node
|
||||
var (endNode, endNodeIsNew) = await FindOrCreateNodeAsync(
|
||||
request.LayoutLevelId,
|
||||
request.X2,
|
||||
request.Y2,
|
||||
proximityRadius);
|
||||
|
||||
// Validate coordinates within bounds
|
||||
if (!await _nodeService.ValidateCoordinatesAsync(request.LayoutLevelId, startNode.X, startNode.Y))
|
||||
{
|
||||
await CleanupNewNodesAsync(startNode, startNodeIsNew, endNode, endNodeIsNew);
|
||||
throw new InvalidOperationException($"Start coordinates ({startNode.X}, {startNode.Y}) are outside valid bounds");
|
||||
}
|
||||
|
||||
if (!await _nodeService.ValidateCoordinatesAsync(request.LayoutLevelId, endNode.X, endNode.Y))
|
||||
{
|
||||
await CleanupNewNodesAsync(startNode, startNodeIsNew, endNode, endNodeIsNew);
|
||||
throw new InvalidOperationException($"End coordinates ({endNode.X}, {endNode.Y}) are outside valid bounds");
|
||||
}
|
||||
|
||||
if (await _context.Edges.AnyAsync(e => e.StartNodeId == startNode.Id && e.EndNodeId == endNode.Id))
|
||||
{
|
||||
await CleanupNewNodesAsync(startNode, startNodeIsNew, endNode, endNodeIsNew);
|
||||
throw new InvalidOperationException($"Edge with StartNode {startNode.NodeId} and EndNode {endNode.NodeId} already exists");
|
||||
}
|
||||
|
||||
// Generate edge name if not provided
|
||||
var edgeName = request.EdgeName;
|
||||
if (string.IsNullOrEmpty(edgeName) && settings?.EdgeNameAutoGenerate == true)
|
||||
{
|
||||
edgeName = await _namingService.GenerateEdgeNameAsync(request.LayoutLevelId);
|
||||
}
|
||||
|
||||
// Generate unique EdgeId
|
||||
var edgeId = Guid.NewGuid().ToString("N")[..16];
|
||||
|
||||
// Create edge
|
||||
var edge = new Edge
|
||||
{
|
||||
LevelId = request.LayoutLevelId,
|
||||
EdgeId = edgeId,
|
||||
EdgeName = edgeName,
|
||||
EdgeDescription = request.EdgeDescription,
|
||||
StartNodeId = startNode.Id,
|
||||
EndNodeId = endNode.Id,
|
||||
};
|
||||
|
||||
_context.Edges.Add(edge);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Add vehicle properties if provided
|
||||
if (request.VehicleProperties != null && request.VehicleProperties.Count != 0)
|
||||
{
|
||||
foreach (var propDto in request.VehicleProperties)
|
||||
{
|
||||
var prop = new EdgeVehicleProperty
|
||||
{
|
||||
EdgeId = edge.Id,
|
||||
VehicleTypeId = propDto.VehicleTypeId,
|
||||
VehicleOrientation = propDto.VehicleOrientation,
|
||||
OrientationType = propDto.OrientationType,
|
||||
RotationAllowed = propDto.RotationAllowed,
|
||||
RotationAtStartNodeAllowed = propDto.RotationAtStartNodeAllowed,
|
||||
RotationAtEndNodeAllowed = propDto.RotationAtEndNodeAllowed,
|
||||
MaxSpeed = propDto.MaxSpeed,
|
||||
MaxRotationSpeed = propDto.MaxRotationSpeed,
|
||||
MinHeight = propDto.MinHeight,
|
||||
MaxHeight = propDto.MaxHeight,
|
||||
LoadRestriction_Unloaded = propDto.LoadRestriction?.Unloaded,
|
||||
LoadRestriction_Loaded = propDto.LoadRestriction?.Loaded,
|
||||
LoadRestriction_LoadSetNames = propDto.LoadRestriction?.LoadSetNames != null && propDto.LoadRestriction.LoadSetNames.Count > 0
|
||||
? System.Text.Json.JsonSerializer.Serialize(propDto.LoadRestriction.LoadSetNames)
|
||||
: null,
|
||||
TrajectoryDegree = propDto.TrajectoryDegree,
|
||||
TrajectoryControlPoint1X = propDto.TrajectoryControlPoint1X,
|
||||
TrajectoryControlPoint1Y = propDto.TrajectoryControlPoint1Y,
|
||||
TrajectoryControlPoint2X = propDto.TrajectoryControlPoint2X,
|
||||
TrajectoryControlPoint2Y = propDto.TrajectoryControlPoint2Y,
|
||||
CorridorLeftWidth = propDto.CorridorLeftWidth,
|
||||
CorridorRightWidth = propDto.CorridorRightWidth,
|
||||
CorridorRefPoint = propDto.CorridorRefPoint
|
||||
};
|
||||
_context.EdgeVehicleProperties.Add(prop);
|
||||
}
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await transaction.CommitAsync();
|
||||
|
||||
// Reload with full details
|
||||
return (await GetByIdAsync(edge.Id, includeNodes: true, includeVehicleProperties: true))!;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(Node Node, bool IsNew)> FindOrCreateNodeAsync(Guid layoutLevelId, double x, double y, double proximityRadius)
|
||||
{
|
||||
// Find nodes within proximity radius
|
||||
var nearbyNodes = await _nodeService.FindNodesNearCoordinatesAsync(layoutLevelId, x, y, proximityRadius);
|
||||
|
||||
if (nearbyNodes.Count != 0)
|
||||
{
|
||||
// Use closest existing node
|
||||
return (nearbyNodes.First(), false);
|
||||
}
|
||||
|
||||
// Create new node at exact coordinates
|
||||
var nodeId = Guid.NewGuid().ToString("N")[..16];
|
||||
var nodeName = await _namingService.GenerateNodeNameAsync(layoutLevelId);
|
||||
|
||||
var newNode = new Node
|
||||
{
|
||||
LevelId = layoutLevelId,
|
||||
NodeId = nodeId,
|
||||
NodeName = nodeName,
|
||||
X = x,
|
||||
Y = y
|
||||
};
|
||||
|
||||
_context.Nodes.Add(newNode);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return (newNode, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Only remove nodes that were newly created during this operation, not pre-existing ones.
|
||||
/// </summary>
|
||||
private async Task CleanupNewNodesAsync(Node startNode, bool startNodeIsNew, Node endNode, bool endNodeIsNew)
|
||||
{
|
||||
if (startNodeIsNew) _context.Nodes.Remove(startNode);
|
||||
if (endNodeIsNew) _context.Nodes.Remove(endNode);
|
||||
if (startNodeIsNew || endNodeIsNew) await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<List<Edge>> GetEdgesByLevelAsync(Guid layoutLevelId, bool includeNodes = true, bool includeVehicleProperties = true)
|
||||
{
|
||||
var query = _context.Edges.Where(e => e.LevelId == layoutLevelId);
|
||||
|
||||
if (includeNodes)
|
||||
{
|
||||
query = query.Include(e => e.StartNode).Include(e => e.EndNode);
|
||||
}
|
||||
|
||||
if (includeVehicleProperties)
|
||||
{
|
||||
query = query.Include(e => e.VehicleProperties).ThenInclude(vp => vp.VehicleType);
|
||||
}
|
||||
|
||||
return await query.OrderBy(e => e.EdgeId).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<Edge?> GetByIdAsync(Guid edgeId, bool includeNodes = true, bool includeVehicleProperties = true)
|
||||
{
|
||||
var query = _context.Edges.Where(e => e.Id == edgeId);
|
||||
|
||||
if (includeNodes)
|
||||
{
|
||||
query = query.Include(e => e.StartNode).Include(e => e.EndNode);
|
||||
}
|
||||
|
||||
if (includeVehicleProperties)
|
||||
{
|
||||
query = query.Include(e => e.VehicleProperties).ThenInclude(vp => vp.VehicleType);
|
||||
}
|
||||
|
||||
return await query.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<Edge> UpdateAsync(Guid edgeId, UpdateEdgeRequest request)
|
||||
{
|
||||
var edge = await GetByIdAsync(edgeId, includeNodes: false, includeVehicleProperties: true) ??
|
||||
throw new InvalidOperationException($"Edge with ID '{edgeId}' not found");
|
||||
|
||||
// Update properties
|
||||
if (request.EdgeName != null)
|
||||
edge.EdgeName = request.EdgeName;
|
||||
|
||||
if (request.EdgeDescription != null)
|
||||
edge.EdgeDescription = request.EdgeDescription;
|
||||
|
||||
// Update vehicle properties if provided
|
||||
if (request.VehicleProperties != null)
|
||||
{
|
||||
// Remove existing properties
|
||||
var existingProps = await _context.EdgeVehicleProperties
|
||||
.Where(evp => evp.EdgeId == edgeId)
|
||||
.ToListAsync();
|
||||
_context.EdgeVehicleProperties.RemoveRange(existingProps);
|
||||
|
||||
// Add new properties
|
||||
foreach (var propDto in request.VehicleProperties)
|
||||
{
|
||||
var prop = new EdgeVehicleProperty
|
||||
{
|
||||
EdgeId = edgeId,
|
||||
VehicleTypeId = propDto.VehicleTypeId,
|
||||
VehicleOrientation = propDto.VehicleOrientation,
|
||||
OrientationType = propDto.OrientationType,
|
||||
RotationAllowed = propDto.RotationAllowed,
|
||||
RotationAtStartNodeAllowed = propDto.RotationAtStartNodeAllowed,
|
||||
RotationAtEndNodeAllowed = propDto.RotationAtEndNodeAllowed,
|
||||
MaxSpeed = propDto.MaxSpeed,
|
||||
MaxRotationSpeed = propDto.MaxRotationSpeed,
|
||||
MinHeight = propDto.MinHeight,
|
||||
MaxHeight = propDto.MaxHeight,
|
||||
LoadRestriction_Unloaded = propDto.LoadRestriction?.Unloaded,
|
||||
LoadRestriction_Loaded = propDto.LoadRestriction?.Loaded,
|
||||
LoadRestriction_LoadSetNames = propDto.LoadRestriction?.LoadSetNames != null && propDto.LoadRestriction.LoadSetNames.Count > 0
|
||||
? System.Text.Json.JsonSerializer.Serialize(propDto.LoadRestriction.LoadSetNames)
|
||||
: null,
|
||||
TrajectoryDegree = propDto.TrajectoryDegree,
|
||||
TrajectoryControlPoint1X = propDto.TrajectoryControlPoint1X,
|
||||
TrajectoryControlPoint1Y = propDto.TrajectoryControlPoint1Y,
|
||||
TrajectoryControlPoint2X = propDto.TrajectoryControlPoint2X,
|
||||
TrajectoryControlPoint2Y = propDto.TrajectoryControlPoint2Y,
|
||||
CorridorLeftWidth = propDto.CorridorLeftWidth,
|
||||
CorridorRightWidth = propDto.CorridorRightWidth,
|
||||
CorridorRefPoint = propDto.CorridorRefPoint
|
||||
};
|
||||
_context.EdgeVehicleProperties.Add(prop);
|
||||
}
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return (await GetByIdAsync(edgeId, includeNodes: true, includeVehicleProperties: true))!;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid edgeId)
|
||||
{
|
||||
var edge = await GetByIdAsync(edgeId, includeNodes: false, includeVehicleProperties: false);
|
||||
if (edge == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delete edge
|
||||
_context.Edges.Remove(edge);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Check and delete orphan nodes
|
||||
await DeleteOrphanNodeAsync(edge.StartNodeId);
|
||||
await DeleteOrphanNodeAsync(edge.EndNodeId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task DeleteOrphanNodeAsync(Guid nodeId)
|
||||
{
|
||||
// Check if node is still referenced by any edge
|
||||
var hasEdges = await _context.Edges
|
||||
.AnyAsync(e => e.StartNodeId == nodeId || e.EndNodeId == nodeId);
|
||||
|
||||
if (!hasEdges)
|
||||
{
|
||||
// Node is orphan, delete it and its interaction nodes
|
||||
var stationInteractions = await _context.StationInteractionNodes
|
||||
.Where(sin => sin.NodeId == nodeId)
|
||||
.ToListAsync();
|
||||
_context.StationInteractionNodes.RemoveRange(stationInteractions);
|
||||
|
||||
var node = await _context.Nodes.FindAsync(nodeId);
|
||||
if (node != null)
|
||||
{
|
||||
_context.Nodes.Remove(node);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteBatchAsync(List<Guid> edgeIds)
|
||||
{
|
||||
using var transaction = await _context.Database.BeginTransactionAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var nodesToCheck = new HashSet<Guid>();
|
||||
|
||||
foreach (var edgeId in edgeIds)
|
||||
{
|
||||
var edge = await _context.Edges.FindAsync(edgeId);
|
||||
if (edge != null)
|
||||
{
|
||||
nodesToCheck.Add(edge.StartNodeId);
|
||||
nodesToCheck.Add(edge.EndNodeId);
|
||||
|
||||
_context.Edges.Remove(edge);
|
||||
}
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Check and delete orphan nodes
|
||||
foreach (var nodeId in nodesToCheck)
|
||||
{
|
||||
await DeleteOrphanNodeAsync(nodeId);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using RobotNet10.StorageManager;
|
||||
using SixLabors.ImageSharp;
|
||||
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// FileSystem-based image storage implementation using StorageManager
|
||||
/// Stores images in local folder with naming: {layoutLevelId}.png
|
||||
/// </summary>
|
||||
public class FileSystemImageStorageService : IImageStorageService, IDisposable
|
||||
{
|
||||
private readonly ILogger<FileSystemImageStorageService> _logger;
|
||||
private readonly StorageManager.StorageManager _storageManager;
|
||||
private const string ImagePath = "layoutImages"; // Empty path means files are stored directly in LocalFolder
|
||||
private const string ContentType = "image/png";
|
||||
|
||||
public FileSystemImageStorageService(IOptionsMonitor<StorageConfig> optionsSnapshot, ILogger<FileSystemImageStorageService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
|
||||
var config = optionsSnapshot.Get("LayoutImages");
|
||||
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
_storageManager = new StorageManager.StorageManager(config);
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
_logger.LogInformation("FileSystemImageStorageService initialized");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetObjectName(Guid layoutLevelId) => layoutLevelId.ToString();
|
||||
|
||||
public async Task SaveImageAsync(Guid layoutLevelId, Stream imageStream, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var objectName = GetObjectName(layoutLevelId);
|
||||
|
||||
try
|
||||
{
|
||||
// Reset stream position if seekable
|
||||
if (imageStream.CanSeek)
|
||||
{
|
||||
imageStream.Position = 0;
|
||||
}
|
||||
|
||||
// Get stream size - handle cases where Length might not be available
|
||||
long size = imageStream.Length;
|
||||
|
||||
// If size is 0 or stream doesn't support Length, copy to MemoryStream
|
||||
if (size == 0 || !imageStream.CanSeek)
|
||||
{
|
||||
using var memoryStream = new MemoryStream();
|
||||
await imageStream.CopyToAsync(memoryStream, cancellationToken);
|
||||
size = memoryStream.Length;
|
||||
memoryStream.Position = 0;
|
||||
|
||||
await _storageManager.UploadAsync(ImagePath, objectName, memoryStream, size, ContentType, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Stream has valid length and is seekable, use directly
|
||||
await _storageManager.UploadAsync(ImagePath, objectName, imageStream, size, ContentType, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Error)) _logger.LogError(ex, "Failed to save image for layout level {LevelId}", layoutLevelId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Stream?> GetImageAsync(Guid layoutLevelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var objectName = GetObjectName(layoutLevelId);
|
||||
|
||||
try
|
||||
{
|
||||
var exists = await _storageManager.ExistsAsync(ImagePath, objectName, cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning("Image not found for layout level {LevelId}", layoutLevelId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var stream = await _storageManager.GetFileAsync(ImagePath, objectName, cancellationToken);
|
||||
return stream;
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning("Image not found for layout level {LevelId}", layoutLevelId);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Error)) _logger.LogError(ex, "Failed to read image for layout level {LevelId}", layoutLevelId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteImageAsync(Guid layoutLevelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var objectName = GetObjectName(layoutLevelId);
|
||||
|
||||
try
|
||||
{
|
||||
var exists = await _storageManager.ExistsAsync(ImagePath, objectName, cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning("Image not found for deletion: {LevelId}", layoutLevelId);
|
||||
return false;
|
||||
}
|
||||
|
||||
await _storageManager.DeleteAsync(ImagePath, objectName, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Error)) _logger.LogError(ex, "Failed to delete image for layout level {LevelId}", layoutLevelId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> ImageExistsAsync(Guid layoutLevelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var objectName = GetObjectName(layoutLevelId);
|
||||
return await _storageManager.ExistsAsync(ImagePath, objectName, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<(int width, int height)> GetImageDimensionsAsync(Stream imageStream, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Reset stream position if seekable
|
||||
if (imageStream.CanSeek)
|
||||
{
|
||||
imageStream.Position = 0;
|
||||
}
|
||||
|
||||
// Load image to get dimensions
|
||||
using var image = await Image.LoadAsync(imageStream, cancellationToken);
|
||||
|
||||
return (image.Width, image.Height);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to extract image dimensions");
|
||||
throw new InvalidOperationException("Invalid image format or corrupted file", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_storageManager?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Requests;
|
||||
using RobotNet10.MapManager.Data;
|
||||
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing edges
|
||||
/// </summary>
|
||||
public interface IEdgeService
|
||||
{
|
||||
/// <summary>
|
||||
/// Create edge with automatic node detection/creation
|
||||
/// </summary>
|
||||
Task<Edge> CreateAsync(CreateEdgeRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Get all edges for a layout level
|
||||
/// </summary>
|
||||
Task<List<Edge>> GetEdgesByLevelAsync(Guid layoutLevelId, bool includeNodes = true, bool includeVehicleProperties = true);
|
||||
|
||||
/// <summary>
|
||||
/// Get edge by ID
|
||||
/// </summary>
|
||||
Task<Edge?> GetByIdAsync(Guid edgeId, bool includeNodes = true, bool includeVehicleProperties = true);
|
||||
|
||||
/// <summary>
|
||||
/// Update edge
|
||||
/// </summary>
|
||||
Task<Edge> UpdateAsync(Guid edgeId, UpdateEdgeRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Delete edge (cascade delete orphan nodes)
|
||||
/// </summary>
|
||||
Task<bool> DeleteAsync(Guid edgeId);
|
||||
|
||||
/// <summary>
|
||||
/// Delete multiple edges in a transaction
|
||||
/// </summary>
|
||||
Task DeleteBatchAsync(List<Guid> edgeIds);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for image storage operations (Minio or FileSystem)
|
||||
/// </summary>
|
||||
public interface IImageStorageService
|
||||
{
|
||||
/// <summary>
|
||||
/// Save image for a layout level
|
||||
/// </summary>
|
||||
/// <param name="layoutLevelId">Layout level ID</param>
|
||||
/// <param name="imageStream">Image stream (PNG format)</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
Task SaveImageAsync(Guid layoutLevelId, Stream imageStream, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get image for a layout level
|
||||
/// </summary>
|
||||
/// <param name="layoutLevelId">Layout level ID</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Image stream or null if not found</returns>
|
||||
Task<Stream?> GetImageAsync(Guid layoutLevelId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Delete image for a layout level
|
||||
/// </summary>
|
||||
/// <param name="layoutLevelId">Layout level ID</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
Task<bool> DeleteImageAsync(Guid layoutLevelId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Check if image exists for a layout level
|
||||
/// </summary>
|
||||
/// <param name="layoutLevelId">Layout level ID</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
Task<bool> ImageExistsAsync(Guid layoutLevelId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Extract image dimensions from stream
|
||||
/// </summary>
|
||||
/// <param name="imageStream">Image stream (PNG format)</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Tuple of (width, height) in pixels</returns>
|
||||
Task<(int width, int height)> GetImageDimensionsAsync(Stream imageStream, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using RobotNet10.MapEditor.Shared.DTOs.LayoutData;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Requests;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Responses;
|
||||
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for retrieving complete layout data (nodes, edges, stations)
|
||||
/// </summary>
|
||||
public interface ILayoutDataService
|
||||
{
|
||||
/// <summary>
|
||||
/// Get complete layout data for a layout level
|
||||
/// Includes all nodes, edges, and stations with full nested properties
|
||||
/// </summary>
|
||||
Task<LayoutDataDto> GetLayoutDataAsync(Guid layoutLevelId);
|
||||
|
||||
/// <summary>
|
||||
/// Merge multiple nodes into one node at center position
|
||||
/// </summary>
|
||||
Task<MergeNodesResponse> MergeNodesAsync(MergeNodesRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Split a node into multiple nodes (one for each connected edge)
|
||||
/// </summary>
|
||||
Task<SplitNodeResponse> SplitNodeAsync(SplitNodeRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Save all layout changes (nodes and edges) in a batch operation
|
||||
/// Uses transaction to ensure atomicity
|
||||
/// </summary>
|
||||
Task<SaveLayoutDataResponse> SaveLayoutDataAsync(SaveLayoutDataRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Copy selected nodes and edges with an offset
|
||||
/// Creates new nodes and edges at offset positions
|
||||
/// </summary>
|
||||
Task<CopyNodesResponse> CopyNodesAsync(CopyNodesRequest request);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Requests;
|
||||
using RobotNet10.MapManager.Data;
|
||||
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing layouts, versions, and levels
|
||||
/// </summary>
|
||||
public interface ILayoutService
|
||||
{
|
||||
// ==========================================
|
||||
// LAYOUT OPERATIONS
|
||||
// ==========================================
|
||||
|
||||
Task<Layout> CreateLayoutAsync(CreateLayoutRequest request);
|
||||
Task<List<Layout>> SearchLayoutsAsync(string? searchText);
|
||||
Task<Layout?> GetLayoutByIdAsync(Guid layoutId);
|
||||
Task<Layout?> GetLayoutByLayoutIdAsync(string layoutId);
|
||||
Task<Layout?> GetLayoutByNameAsync(string layoutName);
|
||||
Task<Layout> UpdateLayoutAsync(Guid layoutId, UpdateLayoutRequest request);
|
||||
Task<bool> DeleteLayoutAsync(Guid layoutId);
|
||||
Task<Layout> ActivateLayoutAsync(Guid layoutId);
|
||||
Task<Layout> DeactivateLayoutAsync(Guid layoutId);
|
||||
|
||||
// ==========================================
|
||||
// VERSION OPERATIONS
|
||||
// ==========================================
|
||||
|
||||
Task<LayoutVersion> CreateVersionAsync(Guid layoutId, CreateLayoutVersionRequest request);
|
||||
Task<List<LayoutVersion>> GetVersionsAsync(Guid layoutId);
|
||||
Task<LayoutVersion?> GetVersionAsync(Guid versionId);
|
||||
Task<LayoutVersion> UpdateVersionAsync(Guid versionId, UpdateLayoutRequest request);
|
||||
Task<bool> DeleteVersionAsync(Guid versionId);
|
||||
|
||||
// ==========================================
|
||||
// LEVEL OPERATIONS
|
||||
// ==========================================
|
||||
|
||||
Task<LayoutLevel> CreateLevelAsync(Guid versionId, CreateLayoutLevelRequest request);
|
||||
Task<List<LayoutLevel>> GetLevelsAsync(Guid versionId);
|
||||
Task<LayoutLevel?> GetLevelAsync(Guid levelId);
|
||||
Task<LayoutLevel> UpdateLevelAsync(Guid levelId, UpdateLayoutLevelRequest request);
|
||||
Task<bool> DeleteLevelAsync(Guid levelId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using RobotNet10.MapManager.Data;
|
||||
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for querying map data (nodes and edges) by VehicleType
|
||||
/// </summary>
|
||||
public interface IMapQueryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Get nodes filtered by VehicleType
|
||||
/// Returns only nodes that have NodeVehicleProperties for the specified VehicleType
|
||||
/// </summary>
|
||||
Task<List<Node>> GetNodesByVehicleTypeAsync(Guid vehicleTypeId);
|
||||
|
||||
/// <summary>
|
||||
/// Get edges filtered by VehicleType
|
||||
/// Returns only edges that have EdgeVehicleProperties for the specified VehicleType
|
||||
/// </summary>
|
||||
Task<List<Edge>> GetEdgesByVehicleTypeAsync(Guid vehicleTypeId);
|
||||
|
||||
/// <summary>
|
||||
/// Get nodes filtered by VehicleType and LevelId
|
||||
/// Returns only nodes that have NodeVehicleProperties for the specified VehicleType and belong to the specified level
|
||||
/// </summary>
|
||||
Task<List<Node>> GetNodesByVehicleTypeAndLevelAsync(Guid vehicleTypeId, Guid levelId);
|
||||
|
||||
/// <summary>
|
||||
/// Get edges filtered by VehicleType and LevelId
|
||||
/// Returns only edges that have EdgeVehicleProperties for the specified VehicleType and belong to the specified level
|
||||
/// </summary>
|
||||
Task<List<Edge>> GetEdgesByVehicleTypeAndLevelAsync(Guid vehicleTypeId, Guid levelId);
|
||||
|
||||
/// <summary>
|
||||
/// Get total count of nodes across all levels
|
||||
/// </summary>
|
||||
Task<int> GetTotalNodesCountAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Get total count of edges across all levels
|
||||
/// </summary>
|
||||
Task<int> GetTotalEdgesCountAsync();
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Node;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Requests;
|
||||
using RobotNet10.MapManager.Data;
|
||||
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing nodes
|
||||
/// </summary>
|
||||
public interface INodeService
|
||||
{
|
||||
/// <summary>
|
||||
/// Get all nodes for a layout level
|
||||
/// </summary>
|
||||
Task<List<Node>> GetNodesByLevelAsync(Guid layoutLevelId, bool includeVehicleProperties = true);
|
||||
|
||||
/// <summary>
|
||||
/// Get node by ID
|
||||
/// </summary>
|
||||
Task<Node?> GetByIdAsync(Guid nodeId, bool includeVehicleProperties = true);
|
||||
|
||||
/// <summary>
|
||||
/// Update node
|
||||
/// </summary>
|
||||
Task<Node> UpdateAsync(Guid nodeId, UpdateNodeRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Validate if coordinates are within bounds
|
||||
/// </summary>
|
||||
Task<bool> ValidateCoordinatesAsync(Guid layoutLevelId, double x, double y);
|
||||
|
||||
/// <summary>
|
||||
/// Find nodes within proximity radius of given coordinates
|
||||
/// </summary>
|
||||
Task<List<Node>> FindNodesNearCoordinatesAsync(Guid layoutLevelId, double x, double y, double radius);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Requests;
|
||||
using RobotNet10.MapManager.Data;
|
||||
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing stations
|
||||
/// </summary>
|
||||
public interface IStationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a new station
|
||||
/// </summary>
|
||||
Task<Station> CreateAsync(CreateStationRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Get all stations for a layout level
|
||||
/// </summary>
|
||||
Task<List<Station>> GetStationsByLevelAsync(Guid layoutLevelId, bool includeInteractionNodes = true);
|
||||
|
||||
/// <summary>
|
||||
/// Get station by ID
|
||||
/// </summary>
|
||||
Task<Station?> GetByIdAsync(Guid stationId, bool includeInteractionNodes = true);
|
||||
|
||||
/// <summary>
|
||||
/// Update station
|
||||
/// </summary>
|
||||
Task<Station> UpdateAsync(Guid stationId, UpdateStationRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Delete station (cascade delete interaction nodes, but NOT the linked nodes)
|
||||
/// </summary>
|
||||
Task<bool> DeleteAsync(Guid stationId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using RobotNet10.MapManager.Data;
|
||||
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing vehicle types
|
||||
/// </summary>
|
||||
public interface IVehicleTypeService
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a new vehicle type
|
||||
/// </summary>
|
||||
Task<VehicleType> CreateAsync(string vehicleTypeId, string vehicleTypeName, string? description, string? specifications, string? actions);
|
||||
|
||||
/// <summary>
|
||||
/// Get all vehicle types
|
||||
/// </summary>
|
||||
Task<List<VehicleType>> GetAllAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Get vehicle type by database ID
|
||||
/// </summary>
|
||||
Task<VehicleType?> GetByIdAsync(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// Get vehicle type by VehicleTypeId string
|
||||
/// </summary>
|
||||
Task<VehicleType?> GetByVehicleTypeIdAsync(string vehicleTypeId);
|
||||
|
||||
/// <summary>
|
||||
/// Update vehicle type
|
||||
/// </summary>
|
||||
Task<VehicleType> UpdateAsync(Guid id, string? vehicleTypeName, string? description, string? specifications, string? actions, bool? isActive);
|
||||
|
||||
/// <summary>
|
||||
/// Delete vehicle type
|
||||
/// </summary>
|
||||
/// <returns>True if deleted, false if not found or has references</returns>
|
||||
Task<bool> DeleteAsync(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// Check if vehicle type ID already exists
|
||||
/// </summary>
|
||||
Task<bool> ExistsAsync(string vehicleTypeId);
|
||||
|
||||
/// <summary>
|
||||
/// Search vehicle types by query string
|
||||
/// Searches in VehicleTypeId and VehicleTypeName (case-insensitive, contains)
|
||||
/// </summary>
|
||||
Task<List<VehicleType>> SearchAsync(string query);
|
||||
|
||||
/// <summary>
|
||||
/// Get vehicle types filtered by active status
|
||||
/// </summary>
|
||||
Task<List<VehicleType>> GetByActiveStatusAsync(bool isActive);
|
||||
|
||||
/// <summary>
|
||||
/// Get usage information for a vehicle type
|
||||
/// </summary>
|
||||
Task<VehicleTypeUsageInfo> GetUsageInfoAsync(Guid id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,999 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RobotNet.VDA5050;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Edge;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.LayoutData;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Node;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Requests;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Responses;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Station;
|
||||
using RobotNet10.MapManager.Data;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service implementation for retrieving complete layout data
|
||||
/// </summary>
|
||||
public class LayoutDataService(
|
||||
MapDbContext context,
|
||||
LayoutLevelNamingService namingService,
|
||||
IEdgeService edgeService,
|
||||
INodeService nodeService) : ILayoutDataService
|
||||
{
|
||||
private readonly MapDbContext _context = context;
|
||||
private readonly LayoutLevelNamingService _namingService = namingService;
|
||||
private readonly IEdgeService _edgeService = edgeService;
|
||||
private readonly INodeService _nodeService = nodeService;
|
||||
|
||||
public async Task<LayoutDataDto> GetLayoutDataAsync(Guid layoutLevelId)
|
||||
{
|
||||
// Get all nodes with vehicle properties
|
||||
var nodes = await _context.Nodes
|
||||
.Where(n => n.LevelId == layoutLevelId)
|
||||
.Include(n => n.VehicleProperties)
|
||||
.ThenInclude(vp => vp.VehicleType)
|
||||
.OrderBy(n => n.NodeId)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync();
|
||||
|
||||
// Get all edges with vehicle properties and related nodes
|
||||
var edges = await _context.Edges
|
||||
.Where(e => e.LevelId == layoutLevelId)
|
||||
.Include(e => e.StartNode)
|
||||
.Include(e => e.EndNode)
|
||||
.Include(e => e.VehicleProperties)
|
||||
.ThenInclude(vp => vp.VehicleType)
|
||||
.OrderBy(e => e.EdgeId)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync();
|
||||
|
||||
// Get all stations with interaction nodes
|
||||
var stations = await _context.Stations
|
||||
.Where(s => s.LevelId == layoutLevelId)
|
||||
.Include(s => s.InteractionNodes)
|
||||
.ThenInclude(sin => sin.Node)
|
||||
.ThenInclude(n => n.VehicleProperties)
|
||||
.OrderBy(s => s.StationId)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync();
|
||||
|
||||
var dto = new LayoutDataDto
|
||||
{
|
||||
LayoutLevelId = layoutLevelId,
|
||||
Nodes = [.. nodes.Select(MapNodeToDto)],
|
||||
Edges = [.. edges.Select(MapEdgeToDto)],
|
||||
Stations = [.. stations.Select(MapStationToDto)]
|
||||
};
|
||||
return dto;
|
||||
}
|
||||
|
||||
// Helper methods to map entities to DTOs
|
||||
private static NodeDto MapNodeToDto(Node node)
|
||||
{
|
||||
return new NodeDto
|
||||
{
|
||||
Id = node.Id,
|
||||
LevelId = node.LevelId,
|
||||
NodeId = node.NodeId,
|
||||
NodeName = node.NodeName,
|
||||
NodeDescription = node.NodeDescription,
|
||||
MapId = node.MapId,
|
||||
X = node.X,
|
||||
Y = node.Y,
|
||||
VehicleProperties = node.VehicleProperties?.Select(vp => new NodeVehiclePropertyDto
|
||||
{
|
||||
Id = vp.Id,
|
||||
NodeId = vp.NodeId,
|
||||
VehicleTypeId = vp.VehicleTypeId,
|
||||
VehicleTypeIdString = vp.VehicleType?.VehicleTypeId,
|
||||
Theta = vp.Theta,
|
||||
Actions = vp.Actions,
|
||||
AllowedDeviationXY = vp.AllowedDeviationXY,
|
||||
AllowedDeviationTheta = vp.AllowedDeviationTheta
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static EdgeDto MapEdgeToDto(Edge edge)
|
||||
{
|
||||
return new EdgeDto
|
||||
{
|
||||
Id = edge.Id,
|
||||
LevelId = edge.LevelId,
|
||||
EdgeId = edge.EdgeId,
|
||||
EdgeName = edge.EdgeName,
|
||||
EdgeDescription = edge.EdgeDescription,
|
||||
StartNodeId = edge.StartNodeId,
|
||||
EndNodeId = edge.EndNodeId,
|
||||
StartNode = edge.StartNode != null ? MapNodeToDto(edge.StartNode) : null,
|
||||
EndNode = edge.EndNode != null ? MapNodeToDto(edge.EndNode) : null,
|
||||
VehicleProperties = edge.VehicleProperties?.Select(vp => new EdgeVehiclePropertyDto
|
||||
{
|
||||
Id = vp.Id,
|
||||
EdgeId = vp.EdgeId,
|
||||
VehicleTypeId = vp.VehicleTypeId,
|
||||
VehicleTypeIdString = vp.VehicleType?.VehicleTypeId,
|
||||
VehicleOrientation = vp.VehicleOrientation,
|
||||
OrientationType = vp.OrientationType,
|
||||
RotationAllowed = vp.RotationAllowed,
|
||||
RotationAtStartNodeAllowed = vp.RotationAtStartNodeAllowed,
|
||||
RotationAtEndNodeAllowed = vp.RotationAtEndNodeAllowed,
|
||||
MaxSpeed = vp.MaxSpeed,
|
||||
MaxRotationSpeed = vp.MaxRotationSpeed,
|
||||
MinHeight = vp.MinHeight,
|
||||
MaxHeight = vp.MaxHeight,
|
||||
LoadRestriction = (vp.LoadRestriction_Unloaded.HasValue || vp.LoadRestriction_Loaded.HasValue || !string.IsNullOrWhiteSpace(vp.LoadRestriction_LoadSetNames))
|
||||
? new LoadRestrictionDto
|
||||
{
|
||||
Unloaded = vp.LoadRestriction_Unloaded,
|
||||
Loaded = vp.LoadRestriction_Loaded,
|
||||
LoadSetNames = SafeDeserializeLoadSetNames(vp.LoadRestriction_LoadSetNames)
|
||||
}
|
||||
: null,
|
||||
TrajectoryDegree = vp.TrajectoryDegree,
|
||||
TrajectoryControlPoint1X = vp.TrajectoryControlPoint1X,
|
||||
TrajectoryControlPoint1Y = vp.TrajectoryControlPoint1Y,
|
||||
TrajectoryControlPoint2X = vp.TrajectoryControlPoint2X,
|
||||
TrajectoryControlPoint2Y = vp.TrajectoryControlPoint2Y,
|
||||
CorridorLeftWidth = vp.CorridorLeftWidth,
|
||||
CorridorRightWidth = vp.CorridorRightWidth,
|
||||
CorridorRefPoint = vp.CorridorRefPoint
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static StationDto MapStationToDto(Station station)
|
||||
{
|
||||
return new StationDto
|
||||
{
|
||||
Id = station.Id,
|
||||
LevelId = station.LevelId,
|
||||
StationId = station.StationId,
|
||||
StationName = station.StationName,
|
||||
StationDescription = station.StationDescription,
|
||||
StationHeight = station.StationHeight,
|
||||
X = station.X,
|
||||
Y = station.Y,
|
||||
Theta = station.Theta,
|
||||
InteractionNodes = station.InteractionNodes?.Select(sin => new StationInteractionNodeDto
|
||||
{
|
||||
Id = sin.Id,
|
||||
StationId = sin.StationId,
|
||||
NodeId = sin.NodeId,
|
||||
Node = sin.Node != null ? MapNodeToDto(sin.Node) : null
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// MERGE/SPLIT OPERATIONS
|
||||
// ==========================================
|
||||
|
||||
public async Task<MergeNodesResponse> MergeNodesAsync(MergeNodesRequest request)
|
||||
{
|
||||
if (request.NodeIds.Count < 2)
|
||||
{
|
||||
throw new InvalidOperationException("Need at least 2 nodes to merge");
|
||||
}
|
||||
|
||||
// Load nodes with all related data
|
||||
var nodesToMerge = await _context.Nodes
|
||||
.Where(n => request.NodeIds.Contains(n.Id) && n.LevelId == request.LevelId)
|
||||
.Include(n => n.VehicleProperties)
|
||||
.Include(n => n.StationInteractions)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync();
|
||||
|
||||
if (nodesToMerge.Count != request.NodeIds.Count)
|
||||
{
|
||||
throw new InvalidOperationException("Some nodes not found or belong to different level");
|
||||
}
|
||||
|
||||
// Get editor settings for validation
|
||||
var settings = await _context.LayoutLevelEditorSettings
|
||||
.FirstOrDefaultAsync(s => s.LevelId == request.LevelId);
|
||||
var proximityRadius = settings?.NodeProximityRadius ?? 0.35;
|
||||
|
||||
// Check distances between nodes
|
||||
var maxDistance = 0.0;
|
||||
for (int i = 0; i < nodesToMerge.Count; i++)
|
||||
{
|
||||
for (int j = i + 1; j < nodesToMerge.Count; j++)
|
||||
{
|
||||
var dx = nodesToMerge[i].X - nodesToMerge[j].X;
|
||||
var dy = nodesToMerge[i].Y - nodesToMerge[j].Y;
|
||||
var distance = Math.Sqrt(dx * dx + dy * dy);
|
||||
maxDistance = Math.Max(maxDistance, distance);
|
||||
}
|
||||
}
|
||||
|
||||
// If distance exceeds proximity radius, throw exception (frontend will show confirmation)
|
||||
if (maxDistance > proximityRadius)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Maximum distance between nodes ({maxDistance:F3}m) exceeds proximity radius ({proximityRadius:F3}m). " +
|
||||
"Please confirm merge operation.");
|
||||
}
|
||||
|
||||
// Check stations: if multiple nodes have stations, throw error
|
||||
var nodesWithStations = nodesToMerge
|
||||
.Where(n => n.StationInteractions.Count != 0)
|
||||
.ToList();
|
||||
|
||||
if (nodesWithStations.Count > 1)
|
||||
{
|
||||
var stationIds = nodesWithStations
|
||||
.SelectMany(n => n.StationInteractions.Select(sin => sin.StationId))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot merge nodes: Multiple nodes have stations. " +
|
||||
$"Found {nodesWithStations.Count} nodes with {stationIds.Count} different station(s). " +
|
||||
"Please remove stations from some nodes before merging.");
|
||||
}
|
||||
|
||||
// Calculate center position
|
||||
var centerX = request.CenterX ?? nodesToMerge.Average(n => n.X);
|
||||
var centerY = request.CenterY ?? nodesToMerge.Average(n => n.Y);
|
||||
|
||||
// Get all edges connected to these nodes
|
||||
var connectedEdges = await _context.Edges
|
||||
.Where(e => e.LevelId == request.LevelId &&
|
||||
(request.NodeIds.Contains(e.StartNodeId) || request.NodeIds.Contains(e.EndNodeId)))
|
||||
.Include(e => e.VehicleProperties)
|
||||
.ToListAsync();
|
||||
|
||||
// Start transaction
|
||||
using var transaction = await _context.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
// Create merged node
|
||||
var nodeId = Guid.NewGuid().ToString("N")[..16];
|
||||
var nodeName = await _namingService.GenerateNodeNameAsync(request.LevelId);
|
||||
|
||||
var mergedNode = new Node
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
LevelId = request.LevelId,
|
||||
NodeId = nodeId,
|
||||
NodeName = nodeName,
|
||||
X = centerX,
|
||||
Y = centerY,
|
||||
NodeDescription = $"Merged from {nodesToMerge.Count} nodes"
|
||||
};
|
||||
|
||||
_context.Nodes.Add(mergedNode);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Merge vehicle properties from all nodes
|
||||
var allVehicleProperties = nodesToMerge
|
||||
.SelectMany(n => n.VehicleProperties)
|
||||
.GroupBy(vp => vp.VehicleTypeId)
|
||||
.Select(g => g.First()) // Take first property for each vehicle type (or merge logic can be enhanced)
|
||||
.ToList();
|
||||
|
||||
foreach (var vp in allVehicleProperties)
|
||||
{
|
||||
var newVp = new NodeVehicleProperty
|
||||
{
|
||||
NodeId = mergedNode.Id,
|
||||
VehicleTypeId = vp.VehicleTypeId,
|
||||
Theta = vp.Theta,
|
||||
Actions = vp.Actions,
|
||||
AllowedDeviationXY = vp.AllowedDeviationXY,
|
||||
AllowedDeviationTheta = vp.AllowedDeviationTheta
|
||||
};
|
||||
_context.NodeVehicleProperties.Add(newVp);
|
||||
}
|
||||
|
||||
// Update edges: change StartNodeId or EndNodeId to merged node
|
||||
var updatedEdges = new List<Edge>();
|
||||
foreach (var edge in connectedEdges)
|
||||
{
|
||||
var wasStartNode = request.NodeIds.Contains(edge.StartNodeId);
|
||||
var wasEndNode = request.NodeIds.Contains(edge.EndNodeId);
|
||||
|
||||
if (wasStartNode && wasEndNode)
|
||||
{
|
||||
// Both nodes are being merged - this becomes a self-loop, delete it
|
||||
_context.Edges.Remove(edge);
|
||||
}
|
||||
else if (wasStartNode)
|
||||
{
|
||||
edge.StartNodeId = mergedNode.Id;
|
||||
updatedEdges.Add(edge);
|
||||
}
|
||||
else if (wasEndNode)
|
||||
{
|
||||
edge.EndNodeId = mergedNode.Id;
|
||||
updatedEdges.Add(edge);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle station: if one node had station, assign to merged node
|
||||
if (nodesWithStations.Count == 1)
|
||||
{
|
||||
var nodeWithStation = nodesWithStations[0];
|
||||
var stationInteractions = nodeWithStation.StationInteractions.ToList();
|
||||
|
||||
foreach (var sin in stationInteractions)
|
||||
{
|
||||
// Update StationInteractionNode to point to merged node
|
||||
sin.NodeId = mergedNode.Id;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete old nodes (they will be orphaned after edge updates)
|
||||
_context.Nodes.RemoveRange(nodesToMerge);
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
|
||||
// Reload merged node with all properties for response
|
||||
var reloadedMergedNode = await _context.Nodes
|
||||
.Where(n => n.Id == mergedNode.Id)
|
||||
.Include(n => n.VehicleProperties)
|
||||
.ThenInclude(vp => vp.VehicleType)
|
||||
.AsSplitQuery()
|
||||
.FirstAsync();
|
||||
|
||||
// Reload updated edges for response
|
||||
var reloadedEdges = await _context.Edges
|
||||
.Where(e => updatedEdges.Select(ue => ue.Id).Contains(e.Id))
|
||||
.Include(e => e.StartNode)
|
||||
.Include(e => e.EndNode)
|
||||
.Include(e => e.VehicleProperties)
|
||||
.ThenInclude(vp => vp.VehicleType)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync();
|
||||
|
||||
return new MergeNodesResponse
|
||||
{
|
||||
MergedNode = MapNodeToDto(reloadedMergedNode),
|
||||
UpdatedEdges = [.. reloadedEdges.Select(MapEdgeToDto)],
|
||||
DeletedNodeIds = [.. nodesToMerge.Select(n => n.Id)]
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SplitNodeResponse> SplitNodeAsync(SplitNodeRequest request)
|
||||
{
|
||||
// Load node with all related data
|
||||
var nodeToSplit = await _context.Nodes
|
||||
.Where(n => n.Id == request.NodeId && n.LevelId == request.LevelId)
|
||||
.Include(n => n.VehicleProperties)
|
||||
.Include(n => n.StationInteractions)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync() ?? throw new InvalidOperationException($"Node with ID '{request.NodeId}' not found");
|
||||
|
||||
// Get all edges connected to this node
|
||||
var connectedEdges = await _context.Edges
|
||||
.Where(e => e.LevelId == request.LevelId &&
|
||||
(e.StartNodeId == request.NodeId || e.EndNodeId == request.NodeId))
|
||||
.Include(e => e.VehicleProperties)
|
||||
.Include(e => e.StartNode)
|
||||
.Include(e => e.EndNode)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync();
|
||||
|
||||
// Validate: node must have at least 2 edges
|
||||
if (connectedEdges.Count < 2)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot split node: Node must have at least 2 connected edges. " +
|
||||
$"Found {connectedEdges.Count} edge(s).");
|
||||
}
|
||||
|
||||
var offsetDistance = request.OffsetDistance ?? 0.1; // Default 10cm
|
||||
|
||||
// Start transaction
|
||||
using var transaction = await _context.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
var newNodes = new List<Node>();
|
||||
var updatedEdges = new List<Edge>();
|
||||
|
||||
// Create a new node for each edge
|
||||
foreach (var (edge, index) in connectedEdges.Select((e, i) => (e, i)))
|
||||
{
|
||||
// Calculate offset position (perpendicular to edge direction)
|
||||
var otherNodeId = edge.StartNodeId == request.NodeId ? edge.EndNodeId : edge.StartNodeId;
|
||||
var otherNode = edge.StartNodeId == request.NodeId ? edge.EndNode : edge.StartNode;
|
||||
|
||||
double offsetX, offsetY;
|
||||
if (otherNode != null)
|
||||
{
|
||||
// Calculate perpendicular offset
|
||||
var dx = otherNode.X - nodeToSplit.X;
|
||||
var dy = otherNode.Y - nodeToSplit.Y;
|
||||
var length = Math.Sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (length > 0.001)
|
||||
{
|
||||
// Perpendicular vector (rotate 90 degrees counter-clockwise)
|
||||
var perpX = -dy / length * offsetDistance;
|
||||
var perpY = dx / length * offsetDistance;
|
||||
offsetX = nodeToSplit.X + perpX;
|
||||
offsetY = nodeToSplit.Y + perpY;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback: circular offset
|
||||
var angle = (2 * Math.PI * index) / connectedEdges.Count;
|
||||
offsetX = nodeToSplit.X + offsetDistance * Math.Cos(angle);
|
||||
offsetY = nodeToSplit.Y + offsetDistance * Math.Sin(angle);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Circular offset
|
||||
var angle = (2 * Math.PI * index) / connectedEdges.Count;
|
||||
offsetX = nodeToSplit.X + offsetDistance * Math.Cos(angle);
|
||||
offsetY = nodeToSplit.Y + offsetDistance * Math.Sin(angle);
|
||||
}
|
||||
|
||||
// Create new node
|
||||
var newNodeId = Guid.NewGuid().ToString("N")[..16];
|
||||
var newNodeName = await _namingService.GenerateNodeNameAsync(request.LevelId);
|
||||
|
||||
var newNode = new Node
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
LevelId = request.LevelId,
|
||||
NodeId = newNodeId,
|
||||
NodeName = newNodeName,
|
||||
X = offsetX,
|
||||
Y = offsetY,
|
||||
NodeDescription = $"Split from node {nodeToSplit.NodeId}"
|
||||
};
|
||||
|
||||
_context.Nodes.Add(newNode);
|
||||
await _context.SaveChangesAsync(); // Save to get ID
|
||||
|
||||
// Copy vehicle properties from original node
|
||||
foreach (var vp in nodeToSplit.VehicleProperties)
|
||||
{
|
||||
var newVp = new NodeVehicleProperty
|
||||
{
|
||||
NodeId = newNode.Id,
|
||||
VehicleTypeId = vp.VehicleTypeId,
|
||||
Theta = vp.Theta,
|
||||
Actions = vp.Actions,
|
||||
AllowedDeviationXY = vp.AllowedDeviationXY,
|
||||
AllowedDeviationTheta = vp.AllowedDeviationTheta
|
||||
};
|
||||
_context.NodeVehicleProperties.Add(newVp);
|
||||
}
|
||||
|
||||
newNodes.Add(newNode);
|
||||
|
||||
// Update edge to point to new node
|
||||
if (edge.StartNodeId == request.NodeId)
|
||||
{
|
||||
edge.StartNodeId = newNode.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
edge.EndNodeId = newNode.Id;
|
||||
}
|
||||
updatedEdges.Add(edge);
|
||||
}
|
||||
|
||||
// Handle station: assign to specified node or first node
|
||||
if (nodeToSplit.StationInteractions.Count != 0)
|
||||
{
|
||||
var targetNodeId = request.StationNodeId ?? newNodes[0].Id;
|
||||
var targetNode = newNodes.FirstOrDefault(n => n.Id == targetNodeId) ?? throw new InvalidOperationException($"Target node ID '{request.StationNodeId}' not found in new nodes");
|
||||
var stationInteractions = nodeToSplit.StationInteractions.ToList();
|
||||
foreach (var sin in stationInteractions)
|
||||
{
|
||||
sin.NodeId = targetNode.Id;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete original node
|
||||
_context.Nodes.Remove(nodeToSplit);
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
|
||||
// Reload new nodes with all properties for response
|
||||
var reloadedNewNodes = await _context.Nodes
|
||||
.Where(n => newNodes.Select(nn => nn.Id).Contains(n.Id))
|
||||
.Include(n => n.VehicleProperties)
|
||||
.ThenInclude(vp => vp.VehicleType)
|
||||
.ToListAsync();
|
||||
|
||||
// Reload updated edges for response
|
||||
var reloadedEdges = await _context.Edges
|
||||
.Where(e => updatedEdges.Select(ue => ue.Id).Contains(e.Id))
|
||||
.Include(e => e.StartNode)
|
||||
.Include(e => e.EndNode)
|
||||
.Include(e => e.VehicleProperties)
|
||||
.ThenInclude(vp => vp.VehicleType)
|
||||
.ToListAsync();
|
||||
|
||||
return new SplitNodeResponse
|
||||
{
|
||||
NewNodes = [.. reloadedNewNodes.Select(MapNodeToDto)],
|
||||
UpdatedEdges = [.. reloadedEdges.Select(MapEdgeToDto)],
|
||||
DeletedNodeId = nodeToSplit.Id
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SaveLayoutDataResponse> SaveLayoutDataAsync(SaveLayoutDataRequest request)
|
||||
{
|
||||
var response = new SaveLayoutDataResponse
|
||||
{
|
||||
Success = true,
|
||||
NodesUpdated = 0,
|
||||
EdgesUpdated = 0
|
||||
};
|
||||
|
||||
using var transaction = await _context.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
// Batch load all needed nodes in ONE query
|
||||
var nodeIds = request.Nodes.Select(n => n.Id).ToList();
|
||||
var existingNodes = await _context.Nodes
|
||||
.Where(n => nodeIds.Contains(n.Id) && n.LevelId == request.LayoutLevelId)
|
||||
.ToDictionaryAsync(n => n.Id);
|
||||
|
||||
// Batch load all node vehicle properties in ONE query
|
||||
var existingNodeVehicleProps = await _context.NodeVehicleProperties
|
||||
.Where(nvp => nodeIds.Contains(nvp.NodeId))
|
||||
.ToListAsync();
|
||||
var nodeVehiclePropsLookup = existingNodeVehicleProps.GroupBy(p => p.NodeId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
// Update nodes
|
||||
foreach (var nodeItem in request.Nodes)
|
||||
{
|
||||
if (!existingNodes.TryGetValue(nodeItem.Id, out var node))
|
||||
{
|
||||
// Node not found - skip (Option D: Force Overwrite)
|
||||
response.SkippedNodeIds.Add(nodeItem.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update position if provided
|
||||
if (nodeItem.X.HasValue)
|
||||
node.X = nodeItem.X.Value;
|
||||
if (nodeItem.Y.HasValue)
|
||||
node.Y = nodeItem.Y.Value;
|
||||
|
||||
// Update other properties
|
||||
if (nodeItem.NodeName != null)
|
||||
node.NodeName = nodeItem.NodeName;
|
||||
if (nodeItem.NodeDescription != null)
|
||||
node.NodeDescription = nodeItem.NodeDescription;
|
||||
if (nodeItem.MapId != null)
|
||||
node.MapId = nodeItem.MapId;
|
||||
|
||||
// Update vehicle properties if provided
|
||||
if (nodeItem.VehicleProperties != null)
|
||||
{
|
||||
// Remove existing properties
|
||||
if (nodeVehiclePropsLookup.TryGetValue(nodeItem.Id, out var existingProps))
|
||||
{
|
||||
_context.NodeVehicleProperties.RemoveRange(existingProps);
|
||||
}
|
||||
|
||||
// Add new properties
|
||||
foreach (var propDto in nodeItem.VehicleProperties)
|
||||
{
|
||||
var prop = new NodeVehicleProperty
|
||||
{
|
||||
NodeId = nodeItem.Id,
|
||||
VehicleTypeId = propDto.VehicleTypeId,
|
||||
Theta = propDto.Theta,
|
||||
Actions = propDto.Actions,
|
||||
AllowedDeviationXY = propDto.AllowedDeviationXY,
|
||||
AllowedDeviationTheta = propDto.AllowedDeviationTheta
|
||||
};
|
||||
_context.NodeVehicleProperties.Add(prop);
|
||||
}
|
||||
}
|
||||
|
||||
response.NodesUpdated++;
|
||||
}
|
||||
|
||||
// Batch load all needed edges in ONE query
|
||||
var edgeIds = request.Edges.Select(e => e.Id).ToList();
|
||||
var existingEdges = await _context.Edges
|
||||
.Where(e => edgeIds.Contains(e.Id) && e.LevelId == request.LayoutLevelId)
|
||||
.ToDictionaryAsync(e => e.Id);
|
||||
|
||||
// Batch load all edge vehicle properties in ONE query
|
||||
var existingEdgeVehicleProps = await _context.EdgeVehicleProperties
|
||||
.Where(evp => edgeIds.Contains(evp.EdgeId))
|
||||
.ToListAsync();
|
||||
var edgeVehiclePropsLookup = existingEdgeVehicleProps.GroupBy(p => p.EdgeId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
// Update edges
|
||||
foreach (var edgeItem in request.Edges)
|
||||
{
|
||||
if (!existingEdges.TryGetValue(edgeItem.Id, out var edge))
|
||||
{
|
||||
// Edge not found - skip (Option D: Force Overwrite)
|
||||
response.SkippedEdgeIds.Add(edgeItem.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update properties
|
||||
if (edgeItem.EdgeName != null)
|
||||
edge.EdgeName = edgeItem.EdgeName;
|
||||
if (edgeItem.EdgeDescription != null)
|
||||
edge.EdgeDescription = edgeItem.EdgeDescription;
|
||||
|
||||
// Update vehicle properties if provided
|
||||
if (edgeItem.VehicleProperties != null)
|
||||
{
|
||||
// Remove existing properties (from batch-loaded lookup)
|
||||
if (edgeVehiclePropsLookup.TryGetValue(edgeItem.Id, out var existingProps))
|
||||
{
|
||||
_context.EdgeVehicleProperties.RemoveRange(existingProps);
|
||||
}
|
||||
|
||||
// Add new properties
|
||||
foreach (var propDto in edgeItem.VehicleProperties)
|
||||
{
|
||||
var prop = new EdgeVehicleProperty
|
||||
{
|
||||
EdgeId = edgeItem.Id,
|
||||
VehicleTypeId = propDto.VehicleTypeId,
|
||||
VehicleOrientation = propDto.VehicleOrientation,
|
||||
OrientationType = propDto.OrientationType,
|
||||
RotationAllowed = propDto.RotationAllowed,
|
||||
RotationAtStartNodeAllowed = propDto.RotationAtStartNodeAllowed,
|
||||
RotationAtEndNodeAllowed = propDto.RotationAtEndNodeAllowed,
|
||||
MaxSpeed = propDto.MaxSpeed,
|
||||
MaxRotationSpeed = propDto.MaxRotationSpeed,
|
||||
MinHeight = propDto.MinHeight,
|
||||
MaxHeight = propDto.MaxHeight,
|
||||
LoadRestriction_Unloaded = propDto.LoadRestriction?.Unloaded,
|
||||
LoadRestriction_Loaded = propDto.LoadRestriction?.Loaded,
|
||||
LoadRestriction_LoadSetNames = propDto.LoadRestriction?.LoadSetNames != null && propDto.LoadRestriction.LoadSetNames.Count > 0
|
||||
? System.Text.Json.JsonSerializer.Serialize(propDto.LoadRestriction.LoadSetNames)
|
||||
: null,
|
||||
TrajectoryDegree = propDto.TrajectoryDegree,
|
||||
TrajectoryControlPoint1X = propDto.TrajectoryControlPoint1X,
|
||||
TrajectoryControlPoint1Y = propDto.TrajectoryControlPoint1Y,
|
||||
TrajectoryControlPoint2X = propDto.TrajectoryControlPoint2X,
|
||||
TrajectoryControlPoint2Y = propDto.TrajectoryControlPoint2Y,
|
||||
CorridorLeftWidth = propDto.CorridorLeftWidth,
|
||||
CorridorRightWidth = propDto.CorridorRightWidth,
|
||||
CorridorRefPoint = propDto.CorridorRefPoint
|
||||
};
|
||||
_context.EdgeVehicleProperties.Add(prop);
|
||||
}
|
||||
}
|
||||
|
||||
response.EdgesUpdated++;
|
||||
}
|
||||
|
||||
// Save all changes in transaction
|
||||
await _context.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
response.Success = false;
|
||||
response.ErrorMessage = ex.Message;
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CopyNodesResponse> CopyNodesAsync(CopyNodesRequest request)
|
||||
{
|
||||
var response = new CopyNodesResponse
|
||||
{
|
||||
Success = true
|
||||
};
|
||||
|
||||
using var transaction = await _context.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
// Load source nodes and edges from database
|
||||
var sourceNodes = await _context.Nodes
|
||||
.Where(n => request.NodeIds.Contains(n.Id) && n.LevelId == request.LayoutLevelId)
|
||||
.Include(n => n.VehicleProperties)
|
||||
.ThenInclude(vp => vp.VehicleType)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync();
|
||||
|
||||
var sourceEdges = await _context.Edges
|
||||
.Where(e => request.EdgeIds.Contains(e.Id) && e.LevelId == request.LayoutLevelId)
|
||||
.Include(e => e.VehicleProperties)
|
||||
.ThenInclude(vp => vp.VehicleType)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync();
|
||||
|
||||
if (sourceNodes.Count == 0)
|
||||
{
|
||||
response.Success = false;
|
||||
response.ErrorMessage = "No nodes found to copy";
|
||||
return response;
|
||||
}
|
||||
|
||||
// Get editor settings for validation
|
||||
var settings = await _context.LayoutLevelEditorSettings
|
||||
.FirstOrDefaultAsync(s => s.LevelId == request.LayoutLevelId);
|
||||
|
||||
// Step 1: Create all new nodes with offset
|
||||
var nodeIdMapping = new Dictionary<Guid, Guid>();
|
||||
|
||||
foreach (var sourceNode in sourceNodes)
|
||||
{
|
||||
var newX = sourceNode.X + request.OffsetX;
|
||||
var newY = sourceNode.Y + request.OffsetY;
|
||||
|
||||
// Validate coordinates (same validation as in CreateEdge)
|
||||
if (!await _nodeService.ValidateCoordinatesAsync(request.LayoutLevelId, newX, newY))
|
||||
{
|
||||
throw new InvalidOperationException($"Coordinates ({newX}, {newY}) are outside valid bounds");
|
||||
}
|
||||
|
||||
// Generate NodeId and NodeName (same as in FindOrCreateNodeAsync)
|
||||
var nodeId = Guid.NewGuid().ToString("N")[..16];
|
||||
var nodeName = sourceNode.NodeName;
|
||||
if (string.IsNullOrEmpty(nodeName) && settings?.NodeNameAutoGenerate == true)
|
||||
{
|
||||
nodeName = await _namingService.GenerateNodeNameAsync(request.LayoutLevelId);
|
||||
}
|
||||
|
||||
// Create new node directly
|
||||
var newNode = new Node
|
||||
{
|
||||
LevelId = request.LayoutLevelId,
|
||||
NodeId = nodeId,
|
||||
NodeName = nodeName,
|
||||
NodeDescription = sourceNode.NodeDescription,
|
||||
MapId = sourceNode.MapId,
|
||||
X = newX,
|
||||
Y = newY
|
||||
};
|
||||
|
||||
_context.Nodes.Add(newNode);
|
||||
await _context.SaveChangesAsync(); // Save to get the new node's Id
|
||||
|
||||
// Copy vehicle properties
|
||||
if (sourceNode.VehicleProperties != null && sourceNode.VehicleProperties.Count > 0)
|
||||
{
|
||||
foreach (var sourceProp in sourceNode.VehicleProperties)
|
||||
{
|
||||
var newProp = new NodeVehicleProperty
|
||||
{
|
||||
NodeId = newNode.Id,
|
||||
VehicleTypeId = sourceProp.VehicleTypeId,
|
||||
Theta = sourceProp.Theta,
|
||||
Actions = sourceProp.Actions,
|
||||
AllowedDeviationXY = sourceProp.AllowedDeviationXY,
|
||||
AllowedDeviationTheta = sourceProp.AllowedDeviationTheta
|
||||
};
|
||||
_context.NodeVehicleProperties.Add(newProp);
|
||||
}
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Store mapping
|
||||
nodeIdMapping[sourceNode.Id] = newNode.Id;
|
||||
|
||||
// Reload node with vehicle properties for response
|
||||
var reloadedNode = await _nodeService.GetByIdAsync(newNode.Id, includeVehicleProperties: true);
|
||||
if (reloadedNode != null)
|
||||
{
|
||||
response.NewNodes.Add(MapNodeToDto(reloadedNode));
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Create all new edges using the node ID mapping
|
||||
var processedEdges = new HashSet<Guid>();
|
||||
|
||||
foreach (var sourceEdge in sourceEdges)
|
||||
{
|
||||
// Skip if already processed
|
||||
if (processedEdges.Contains(sourceEdge.Id))
|
||||
continue;
|
||||
|
||||
// Only copy edges where both start and end nodes are in the selection
|
||||
if (nodeIdMapping.TryGetValue(sourceEdge.StartNodeId, out var newStartNodeId) &&
|
||||
nodeIdMapping.TryGetValue(sourceEdge.EndNodeId, out var newEndNodeId))
|
||||
{
|
||||
// Get new nodes to calculate edge length for validation
|
||||
var newStartNode = await _nodeService.GetByIdAsync(newStartNodeId, includeVehicleProperties: false);
|
||||
var newEndNode = await _nodeService.GetByIdAsync(newEndNodeId, includeVehicleProperties: false);
|
||||
|
||||
if (newStartNode != null && newEndNode != null)
|
||||
{
|
||||
// Validate edge length (same validation as in CreateEdge)
|
||||
var dx = newEndNode.X - newStartNode.X;
|
||||
var dy = newEndNode.Y - newStartNode.Y;
|
||||
var edgeLength = Math.Sqrt(dx * dx + dy * dy);
|
||||
var minEdgeLength = settings?.EdgeMinLengthCreate ?? 0.1;
|
||||
|
||||
if (edgeLength < minEdgeLength)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Edge length ({edgeLength:F3}m) is less than minimum required ({minEdgeLength:F3}m)");
|
||||
}
|
||||
|
||||
// Check if this edge has a reverse edge (2-way edge)
|
||||
var reverseEdge = sourceEdges.FirstOrDefault(e =>
|
||||
e.Id != sourceEdge.Id &&
|
||||
e.StartNodeId == sourceEdge.EndNodeId &&
|
||||
e.EndNodeId == sourceEdge.StartNodeId);
|
||||
|
||||
// Copy the forward edge
|
||||
var edgeId = Guid.NewGuid().ToString("N")[..16];
|
||||
var edgeName = sourceEdge.EdgeName;
|
||||
if (string.IsNullOrEmpty(edgeName) && settings?.EdgeNameAutoGenerate == true)
|
||||
{
|
||||
edgeName = await _namingService.GenerateEdgeNameAsync(request.LayoutLevelId);
|
||||
}
|
||||
|
||||
var newEdge = new Edge
|
||||
{
|
||||
LevelId = request.LayoutLevelId,
|
||||
EdgeId = edgeId,
|
||||
EdgeName = edgeName,
|
||||
EdgeDescription = sourceEdge.EdgeDescription,
|
||||
StartNodeId = newStartNodeId,
|
||||
EndNodeId = newEndNodeId
|
||||
};
|
||||
|
||||
_context.Edges.Add(newEdge);
|
||||
await _context.SaveChangesAsync(); // Save to get the new edge's Id
|
||||
|
||||
// Copy vehicle properties
|
||||
if (sourceEdge.VehicleProperties != null && sourceEdge.VehicleProperties.Count > 0)
|
||||
{
|
||||
foreach (var sourceProp in sourceEdge.VehicleProperties)
|
||||
{
|
||||
var newProp = new EdgeVehicleProperty
|
||||
{
|
||||
EdgeId = newEdge.Id,
|
||||
VehicleTypeId = sourceProp.VehicleTypeId,
|
||||
VehicleOrientation = sourceProp.VehicleOrientation,
|
||||
OrientationType = sourceProp.OrientationType,
|
||||
RotationAllowed = sourceProp.RotationAllowed,
|
||||
RotationAtStartNodeAllowed = sourceProp.RotationAtStartNodeAllowed,
|
||||
RotationAtEndNodeAllowed = sourceProp.RotationAtEndNodeAllowed,
|
||||
MaxSpeed = sourceProp.MaxSpeed,
|
||||
MaxRotationSpeed = sourceProp.MaxRotationSpeed,
|
||||
MinHeight = sourceProp.MinHeight,
|
||||
MaxHeight = sourceProp.MaxHeight,
|
||||
LoadRestriction_Unloaded = sourceProp.LoadRestriction_Unloaded,
|
||||
LoadRestriction_Loaded = sourceProp.LoadRestriction_Loaded,
|
||||
LoadRestriction_LoadSetNames = sourceProp.LoadRestriction_LoadSetNames,
|
||||
TrajectoryDegree = sourceProp.TrajectoryDegree,
|
||||
TrajectoryControlPoint1X = sourceProp.TrajectoryControlPoint1X + request.OffsetX,
|
||||
TrajectoryControlPoint1Y = sourceProp.TrajectoryControlPoint1Y + request.OffsetY,
|
||||
TrajectoryControlPoint2X = sourceProp.TrajectoryControlPoint2X + request.OffsetX,
|
||||
TrajectoryControlPoint2Y = sourceProp.TrajectoryControlPoint2Y + request.OffsetY,
|
||||
CorridorLeftWidth = sourceProp.CorridorLeftWidth,
|
||||
CorridorRightWidth = sourceProp.CorridorRightWidth,
|
||||
CorridorRefPoint = sourceProp.CorridorRefPoint,
|
||||
};
|
||||
_context.EdgeVehicleProperties.Add(newProp);
|
||||
}
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Reload edge with full details for response
|
||||
var reloadedEdge = await _edgeService.GetByIdAsync(newEdge.Id, includeNodes: true, includeVehicleProperties: true);
|
||||
if (reloadedEdge != null)
|
||||
{
|
||||
response.NewEdges.Add(MapEdgeToDto(reloadedEdge));
|
||||
}
|
||||
processedEdges.Add(sourceEdge.Id);
|
||||
|
||||
// If it's a 2-way edge, copy the reverse edge too
|
||||
if (reverseEdge != null && !processedEdges.Contains(reverseEdge.Id))
|
||||
{
|
||||
var reverseEdgeId = Guid.NewGuid().ToString("N")[..16];
|
||||
var reverseEdgeName = reverseEdge.EdgeName;
|
||||
if (string.IsNullOrEmpty(reverseEdgeName) && settings?.EdgeNameAutoGenerate == true)
|
||||
{
|
||||
reverseEdgeName = await _namingService.GenerateEdgeNameAsync(request.LayoutLevelId);
|
||||
}
|
||||
|
||||
var newReverseEdge = new Edge
|
||||
{
|
||||
LevelId = request.LayoutLevelId,
|
||||
EdgeId = reverseEdgeId,
|
||||
EdgeName = reverseEdgeName,
|
||||
EdgeDescription = reverseEdge.EdgeDescription,
|
||||
StartNodeId = newEndNodeId,
|
||||
EndNodeId = newStartNodeId
|
||||
};
|
||||
|
||||
_context.Edges.Add(newReverseEdge);
|
||||
await _context.SaveChangesAsync(); // Save to get the new edge's Id
|
||||
|
||||
// Copy vehicle properties for reverse edge
|
||||
if (reverseEdge.VehicleProperties != null && reverseEdge.VehicleProperties.Count > 0)
|
||||
{
|
||||
foreach (var sourceProp in reverseEdge.VehicleProperties)
|
||||
{
|
||||
var newProp = new EdgeVehicleProperty
|
||||
{
|
||||
EdgeId = newReverseEdge.Id,
|
||||
VehicleTypeId = sourceProp.VehicleTypeId,
|
||||
VehicleOrientation = sourceProp.VehicleOrientation,
|
||||
OrientationType = sourceProp.OrientationType,
|
||||
RotationAllowed = sourceProp.RotationAllowed,
|
||||
RotationAtStartNodeAllowed = sourceProp.RotationAtStartNodeAllowed,
|
||||
RotationAtEndNodeAllowed = sourceProp.RotationAtEndNodeAllowed,
|
||||
MaxSpeed = sourceProp.MaxSpeed,
|
||||
MaxRotationSpeed = sourceProp.MaxRotationSpeed,
|
||||
MinHeight = sourceProp.MinHeight,
|
||||
MaxHeight = sourceProp.MaxHeight,
|
||||
LoadRestriction_Unloaded = sourceProp.LoadRestriction_Unloaded,
|
||||
LoadRestriction_Loaded = sourceProp.LoadRestriction_Loaded,
|
||||
LoadRestriction_LoadSetNames = sourceProp.LoadRestriction_LoadSetNames,
|
||||
TrajectoryDegree = sourceProp.TrajectoryDegree,
|
||||
TrajectoryControlPoint1X = sourceProp.TrajectoryControlPoint1X + request.OffsetX,
|
||||
TrajectoryControlPoint1Y = sourceProp.TrajectoryControlPoint1Y + request.OffsetY,
|
||||
TrajectoryControlPoint2X = sourceProp.TrajectoryControlPoint2X + request.OffsetX,
|
||||
TrajectoryControlPoint2Y = sourceProp.TrajectoryControlPoint2Y + request.OffsetY,
|
||||
CorridorLeftWidth = sourceProp.CorridorLeftWidth,
|
||||
CorridorRightWidth = sourceProp.CorridorRightWidth,
|
||||
CorridorRefPoint = sourceProp.CorridorRefPoint,
|
||||
};
|
||||
_context.EdgeVehicleProperties.Add(newProp);
|
||||
}
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Reload reverse edge with full details for response
|
||||
var reloadedReverseEdge = await _edgeService.GetByIdAsync(newReverseEdge.Id, includeNodes: true, includeVehicleProperties: true);
|
||||
if (reloadedReverseEdge != null)
|
||||
{
|
||||
response.NewEdges.Add(MapEdgeToDto(reloadedReverseEdge));
|
||||
}
|
||||
processedEdges.Add(reverseEdge.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set node ID mapping in response
|
||||
response.NodeIdMapping = nodeIdMapping;
|
||||
|
||||
await transaction.CommitAsync();
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
response.Success = false;
|
||||
response.ErrorMessage = ex.Message;
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string>? SafeDeserializeLoadSetNames(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return null;
|
||||
try { return System.Text.Json.JsonSerializer.Deserialize<List<string>>(json); }
|
||||
catch (System.Text.Json.JsonException) { return null; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RobotNet10.MapManager.Data;
|
||||
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for generating unique node and edge names using 8-character GUIDs
|
||||
/// Concurrent-safe and optimized for Import/Export scenarios
|
||||
/// </summary>
|
||||
public class LayoutLevelNamingService(MapDbContext context)
|
||||
{
|
||||
private readonly MapDbContext _context = context;
|
||||
|
||||
private const int GUID_LENGTH = 8;
|
||||
private const int MAX_RETRIES = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Generate unique node name using 8-character GUID
|
||||
/// Format: "Node_a7f2e3b1"
|
||||
/// </summary>
|
||||
/// <param name="levelId">Layout level ID</param>
|
||||
/// <returns>Generated node name or empty string if auto-generate is disabled</returns>
|
||||
/// <exception cref="InvalidOperationException">If cannot generate unique name after retries</exception>
|
||||
public async Task<string> GenerateNodeNameAsync(Guid levelId)
|
||||
{
|
||||
var settings = await GetOrCreateSettingsAsync(levelId);
|
||||
|
||||
if (!settings.NodeNameAutoGenerate)
|
||||
return string.Empty;
|
||||
|
||||
// Try to generate unique name with retries
|
||||
for (int attempt = 0; attempt < MAX_RETRIES; attempt++)
|
||||
{
|
||||
var guid = Guid.NewGuid().ToString("N"); // No hyphens
|
||||
var shortGuid = guid[..GUID_LENGTH];
|
||||
var nodeName = $"N_{shortGuid}";
|
||||
|
||||
// Check uniqueness (indexed query, very fast)
|
||||
bool exists = await _context.Nodes
|
||||
.AnyAsync(n => n.LevelId == levelId && n.NodeName == nodeName);
|
||||
|
||||
if (!exists) return nodeName;
|
||||
}
|
||||
|
||||
// Extremely unlikely to reach here (probability < 0.00001%)
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to generate unique node name after {MAX_RETRIES} attempts. " +
|
||||
"This is extremely unlikely. Please contact system administrator.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate unique edge name using 8-character GUID
|
||||
/// Format: "Edge_a7f2e3b1"
|
||||
/// </summary>
|
||||
/// <param name="levelId">Layout level ID</param>
|
||||
/// <returns>Generated edge name or empty string if auto-generate is disabled</returns>
|
||||
/// <exception cref="InvalidOperationException">If cannot generate unique name after retries</exception>
|
||||
public async Task<string> GenerateEdgeNameAsync(Guid levelId)
|
||||
{
|
||||
var settings = await GetOrCreateSettingsAsync(levelId);
|
||||
|
||||
if (!settings.EdgeNameAutoGenerate)
|
||||
return string.Empty;
|
||||
|
||||
// Try to generate unique name with retries
|
||||
for (int attempt = 0; attempt < MAX_RETRIES; attempt++)
|
||||
{
|
||||
var guid = Guid.NewGuid().ToString("N"); // No hyphens
|
||||
var shortGuid = guid.Substring(0, GUID_LENGTH);
|
||||
var edgeName = $"E_{shortGuid}";
|
||||
|
||||
// Check uniqueness (indexed query, very fast)
|
||||
bool exists = await _context.Edges
|
||||
.AnyAsync(e => e.LevelId == levelId && e.EdgeName == edgeName);
|
||||
|
||||
if (!exists) return edgeName;
|
||||
}
|
||||
|
||||
// Extremely unlikely to reach here (probability < 0.00001%)
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to generate unique edge name after {MAX_RETRIES} attempts. " +
|
||||
"This is extremely unlikely. Please contact system administrator.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Preview example generated names
|
||||
/// </summary>
|
||||
/// <param name="count">Number of examples to generate</param>
|
||||
/// <returns>Array of example names</returns>
|
||||
public static string[] PreviewNodeNames(int count = 5)
|
||||
{
|
||||
var examples = new string[count];
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var guid = Guid.NewGuid().ToString("N");
|
||||
examples[i] = $"Node_{guid.Substring(0, GUID_LENGTH)}";
|
||||
}
|
||||
return examples;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Preview example generated edge names
|
||||
/// </summary>
|
||||
/// <param name="count">Number of examples to generate</param>
|
||||
/// <returns>Array of example names</returns>
|
||||
public static string[] PreviewEdgeNames(int count = 5)
|
||||
{
|
||||
var examples = new string[count];
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var guid = Guid.NewGuid().ToString("N");
|
||||
examples[i] = $"Edge_{guid.Substring(0, GUID_LENGTH)}";
|
||||
}
|
||||
return examples;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get or create editor settings for a layout level
|
||||
/// </summary>
|
||||
private async Task<LayoutLevelEditorSettings> GetOrCreateSettingsAsync(Guid levelId)
|
||||
{
|
||||
var settings = await _context.LayoutLevelEditorSettings
|
||||
.FirstOrDefaultAsync(s => s.LevelId == levelId);
|
||||
|
||||
if (settings == null)
|
||||
{
|
||||
// Auto-create settings with defaults if not exists
|
||||
settings = new LayoutLevelEditorSettings
|
||||
{
|
||||
LevelId = levelId,
|
||||
// Defaults are set in the entity class
|
||||
};
|
||||
|
||||
_context.LayoutLevelEditorSettings.Add(settings);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update editor settings for a layout level
|
||||
/// </summary>
|
||||
public async Task UpdateSettingsAsync(Guid levelId, Action<LayoutLevelEditorSettings> updateAction)
|
||||
{
|
||||
var settings = await GetOrCreateSettingsAsync(levelId);
|
||||
updateAction(settings);
|
||||
settings.ModifiedDate = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current editor settings (read-only)
|
||||
/// </summary>
|
||||
public async Task<LayoutLevelEditorSettings?> GetSettingsAsync(Guid levelId)
|
||||
{
|
||||
return await _context.LayoutLevelEditorSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.LevelId == levelId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get collision statistics (for monitoring)
|
||||
/// </summary>
|
||||
public async Task<(int TotalNodes, int TotalEdges)> GetLevelStatisticsAsync(Guid levelId)
|
||||
{
|
||||
var nodeCount = await _context.Nodes.CountAsync(n => n.LevelId == levelId);
|
||||
var edgeCount = await _context.Edges.CountAsync(e => e.LevelId == levelId);
|
||||
return (nodeCount, edgeCount);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Requests;
|
||||
using RobotNet10.MapManager.Data;
|
||||
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service implementation for managing layouts, versions, and levels
|
||||
/// </summary>
|
||||
public class LayoutService(MapDbContext context) : ILayoutService
|
||||
{
|
||||
private readonly MapDbContext _context = context;
|
||||
|
||||
// ==========================================
|
||||
// LAYOUT OPERATIONS
|
||||
// ==========================================
|
||||
|
||||
public async Task<Layout> CreateLayoutAsync(CreateLayoutRequest request)
|
||||
{
|
||||
// Check if LayoutId already exists
|
||||
var exists = await _context.Layouts.AnyAsync(l => l.LayoutId == request.LayoutId);
|
||||
if (exists)
|
||||
{
|
||||
throw new InvalidOperationException($"Layout with ID '{request.LayoutId}' already exists");
|
||||
}
|
||||
|
||||
var layout = new Layout
|
||||
{
|
||||
LayoutId = request.LayoutId,
|
||||
LayoutName = request.LayoutName,
|
||||
Description = request.Description,
|
||||
IsActive = false,
|
||||
CreatedDate = DateTime.UtcNow,
|
||||
ModifiedDate = DateTime.UtcNow,
|
||||
CreatedBy = request.CreatedBy
|
||||
};
|
||||
|
||||
_context.Layouts.Add(layout);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
public async Task<List<Layout>> SearchLayoutsAsync(string? searchText)
|
||||
{
|
||||
var query = _context.Layouts.Include(l => l.Versions)
|
||||
.ThenInclude(v => v.Levels)
|
||||
.ThenInclude(l => l.EditorSettings)
|
||||
.AsSplitQuery()
|
||||
.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchText))
|
||||
{
|
||||
var search = searchText.ToLower();
|
||||
query = query.Where(l =>
|
||||
l.LayoutId.ToLower().Contains(search) ||
|
||||
l.LayoutName.ToLower().Contains(search));
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderByDescending(l => l.ModifiedDate)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<Layout?> GetLayoutByIdAsync(Guid layoutId)
|
||||
{
|
||||
return await _context.Layouts
|
||||
.Include(l => l.Versions)
|
||||
.ThenInclude(v => v.Levels)
|
||||
.ThenInclude(l => l.EditorSettings)
|
||||
.FirstOrDefaultAsync(l => l.Id == layoutId);
|
||||
}
|
||||
|
||||
public async Task<Layout?> GetLayoutByLayoutIdAsync(string layoutId)
|
||||
{
|
||||
return await _context.Layouts
|
||||
.Include(l => l.Versions)
|
||||
.ThenInclude(v => v.Levels)
|
||||
.ThenInclude(l => l.EditorSettings)
|
||||
.FirstOrDefaultAsync(l => l.LayoutId == layoutId);
|
||||
}
|
||||
|
||||
public async Task<Layout?> GetLayoutByNameAsync(string layoutName)
|
||||
{
|
||||
return await _context.Layouts
|
||||
.Include(l => l.Versions)
|
||||
.ThenInclude(v => v.Levels)
|
||||
.ThenInclude(l => l.EditorSettings)
|
||||
.FirstOrDefaultAsync(l => l.LayoutName == layoutName);
|
||||
}
|
||||
|
||||
public async Task<Layout> UpdateLayoutAsync(Guid layoutId, UpdateLayoutRequest request)
|
||||
{
|
||||
var layout = await GetLayoutByIdAsync(layoutId) ??
|
||||
throw new InvalidOperationException($"Layout with ID '{layoutId}' not found");
|
||||
|
||||
layout.LayoutName = request.LayoutName;
|
||||
layout.Description = request.Description;
|
||||
layout.ModifiedDate = DateTime.UtcNow;
|
||||
layout.ModifiedBy = request.ModifiedBy;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteLayoutAsync(Guid layoutId)
|
||||
{
|
||||
var layout = await GetLayoutByIdAsync(layoutId);
|
||||
if (layout == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if layout is active
|
||||
if (layout.IsActive)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot delete active layout '{layout.LayoutId}'. Deactivate it first.");
|
||||
}
|
||||
|
||||
// Hard delete - EF Core cascade will handle:
|
||||
// Layout → Versions → Levels → Nodes/Edges/Stations → Properties
|
||||
_context.Layouts.Remove(layout);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<Layout> ActivateLayoutAsync(Guid layoutId)
|
||||
{
|
||||
var layout = await GetLayoutByIdAsync(layoutId) ??
|
||||
throw new InvalidOperationException($"Layout with ID '{layoutId}' not found");
|
||||
|
||||
layout.IsActive = true;
|
||||
layout.ModifiedDate = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
public async Task<Layout> DeactivateLayoutAsync(Guid layoutId)
|
||||
{
|
||||
var layout = await GetLayoutByIdAsync(layoutId) ??
|
||||
throw new InvalidOperationException($"Layout with ID '{layoutId}' not found");
|
||||
layout.IsActive = false;
|
||||
layout.ModifiedDate = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// VERSION OPERATIONS
|
||||
// ==========================================
|
||||
|
||||
public async Task<LayoutVersion> CreateVersionAsync(Guid layoutId, CreateLayoutVersionRequest request)
|
||||
{
|
||||
var layout = await GetLayoutByIdAsync(layoutId) ??
|
||||
throw new InvalidOperationException($"Layout with ID '{layoutId}' not found");
|
||||
|
||||
// Check if version already exists
|
||||
var exists = await _context.LayoutVersions
|
||||
.AnyAsync(v => v.LayoutId == layoutId && v.Version == request.Version);
|
||||
if (exists)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Version '{request.Version}' already exists for layout '{layout.LayoutId}'");
|
||||
}
|
||||
|
||||
var version = new LayoutVersion
|
||||
{
|
||||
LayoutId = layoutId,
|
||||
Version = request.Version,
|
||||
LayoutDescription = request.LayoutDescription,
|
||||
CreatedBy = request.CreatedBy,
|
||||
CreatedDate = DateTime.UtcNow,
|
||||
IsActive = false // New versions start as inactive
|
||||
};
|
||||
|
||||
_context.LayoutVersions.Add(version);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
public async Task<List<LayoutVersion>> GetVersionsAsync(Guid layoutId)
|
||||
{
|
||||
return await _context.LayoutVersions
|
||||
.Where(v => v.LayoutId == layoutId)
|
||||
.Include(v => v.Levels)
|
||||
.ThenInclude(l => l.EditorSettings)
|
||||
.OrderByDescending(v => v.CreatedDate)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<LayoutVersion?> GetVersionAsync(Guid versionId)
|
||||
{
|
||||
return await _context.LayoutVersions
|
||||
.Include(v => v.Layout)
|
||||
.Include(v => v.Levels)
|
||||
.ThenInclude(l => l.EditorSettings)
|
||||
.FirstOrDefaultAsync(v => v.Id == versionId);
|
||||
}
|
||||
|
||||
public async Task<LayoutVersion> UpdateVersionAsync(Guid versionId, UpdateLayoutRequest request)
|
||||
{
|
||||
var version = await GetVersionAsync(versionId) ??
|
||||
throw new InvalidOperationException($"Version with ID '{versionId}' not found");
|
||||
version.LayoutDescription = request.Description;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteVersionAsync(Guid versionId)
|
||||
{
|
||||
var version = await GetVersionAsync(versionId);
|
||||
if (version == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if parent layout is active
|
||||
if (version.Layout.IsActive)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot delete version from active layout '{version.Layout.LayoutId}'. " +
|
||||
"Deactivate the layout first.");
|
||||
}
|
||||
|
||||
// Hard delete - cascade will handle levels and all nested data
|
||||
_context.LayoutVersions.Remove(version);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// LEVEL OPERATIONS
|
||||
// ==========================================
|
||||
|
||||
public async Task<LayoutLevel> CreateLevelAsync(Guid versionId, CreateLayoutLevelRequest request)
|
||||
{
|
||||
var version = await GetVersionAsync(versionId) ??
|
||||
throw new InvalidOperationException($"Version with ID '{versionId}' not found");
|
||||
|
||||
// Check if level already exists
|
||||
var exists = await _context.LayoutLevels
|
||||
.AnyAsync(l => l.VersionId == versionId && l.LayoutLevelId == request.LayoutLevelId);
|
||||
if (exists)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Level '{request.LayoutLevelId}' already exists in this version");
|
||||
}
|
||||
|
||||
var level = new LayoutLevel
|
||||
{
|
||||
VersionId = versionId,
|
||||
LayoutLevelId = request.LayoutLevelId,
|
||||
LevelOrder = request.LevelOrder
|
||||
};
|
||||
|
||||
_context.LayoutLevels.Add(level);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Create editor settings if coordinate system provided
|
||||
if (request.CoordinateSystem != null)
|
||||
{
|
||||
var settings = new LayoutLevelEditorSettings
|
||||
{
|
||||
LevelId = level.Id,
|
||||
OriginX = request.CoordinateSystem.OriginX,
|
||||
OriginY = request.CoordinateSystem.OriginY,
|
||||
Resolution = request.CoordinateSystem.Resolution,
|
||||
BoundsMinX = request.CoordinateSystem.BoundsMinX,
|
||||
BoundsMaxX = request.CoordinateSystem.BoundsMaxX,
|
||||
BoundsMinY = request.CoordinateSystem.BoundsMinY,
|
||||
BoundsMaxY = request.CoordinateSystem.BoundsMaxY,
|
||||
ImageWidth = request.CoordinateSystem.ImageWidth,
|
||||
ImageHeight = request.CoordinateSystem.ImageHeight,
|
||||
CreatedDate = DateTime.UtcNow,
|
||||
ModifiedDate = DateTime.UtcNow
|
||||
};
|
||||
_context.LayoutLevelEditorSettings.Add(settings);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return level;
|
||||
}
|
||||
|
||||
public async Task<List<LayoutLevel>> GetLevelsAsync(Guid versionId)
|
||||
{
|
||||
return await _context.LayoutLevels
|
||||
.Where(l => l.VersionId == versionId)
|
||||
.Include(l => l.EditorSettings)
|
||||
.OrderBy(l => l.LevelOrder)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<LayoutLevel?> GetLevelAsync(Guid levelId)
|
||||
{
|
||||
return await _context.LayoutLevels
|
||||
.Include(l => l.Version)
|
||||
.ThenInclude(v => v.Layout)
|
||||
.Include(l => l.EditorSettings)
|
||||
.FirstOrDefaultAsync(l => l.Id == levelId);
|
||||
}
|
||||
|
||||
public async Task<LayoutLevel> UpdateLevelAsync(Guid levelId, UpdateLayoutLevelRequest request)
|
||||
{
|
||||
var level = await GetLevelAsync(levelId) ?? throw new InvalidOperationException($"Level with ID '{levelId}' not found");
|
||||
|
||||
// Update level properties
|
||||
if (request.LayoutLevelId != null)
|
||||
level.LayoutLevelId = request.LayoutLevelId;
|
||||
|
||||
if (request.LevelOrder.HasValue)
|
||||
level.LevelOrder = request.LevelOrder.Value;
|
||||
|
||||
// Get or create editor settings
|
||||
var settings = await _context.LayoutLevelEditorSettings
|
||||
.FirstOrDefaultAsync(s => s.LevelId == levelId);
|
||||
|
||||
if (settings == null)
|
||||
{
|
||||
// Create new settings
|
||||
settings = new LayoutLevelEditorSettings
|
||||
{
|
||||
LevelId = levelId,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
};
|
||||
_context.LayoutLevelEditorSettings.Add(settings);
|
||||
}
|
||||
|
||||
// Update coordinate system if provided
|
||||
if (request.CoordinateSystem != null)
|
||||
{
|
||||
settings.OriginX = request.CoordinateSystem.OriginX;
|
||||
settings.OriginY = request.CoordinateSystem.OriginY;
|
||||
settings.Resolution = request.CoordinateSystem.Resolution;
|
||||
settings.BoundsMinX = request.CoordinateSystem.BoundsMinX;
|
||||
settings.BoundsMaxX = request.CoordinateSystem.BoundsMaxX;
|
||||
settings.BoundsMinY = request.CoordinateSystem.BoundsMinY;
|
||||
settings.BoundsMaxY = request.CoordinateSystem.BoundsMaxY;
|
||||
settings.ImageWidth = request.CoordinateSystem.ImageWidth;
|
||||
settings.ImageHeight = request.CoordinateSystem.ImageHeight;
|
||||
}
|
||||
|
||||
// Update editor settings if provided
|
||||
if (request.EditorSettings != null)
|
||||
{
|
||||
if (request.EditorSettings.EdgeMinLengthCreate.HasValue)
|
||||
settings.EdgeMinLengthCreate = request.EditorSettings.EdgeMinLengthCreate.Value;
|
||||
|
||||
if (request.EditorSettings.EdgeNameAutoGenerate.HasValue)
|
||||
settings.EdgeNameAutoGenerate = request.EditorSettings.EdgeNameAutoGenerate.Value;
|
||||
|
||||
if (request.EditorSettings.NodeNameAutoGenerate.HasValue)
|
||||
settings.NodeNameAutoGenerate = request.EditorSettings.NodeNameAutoGenerate.Value;
|
||||
|
||||
if (request.EditorSettings.NodeProximityRadius.HasValue)
|
||||
settings.NodeProximityRadius = request.EditorSettings.NodeProximityRadius.Value;
|
||||
}
|
||||
|
||||
// Update modified date if any settings were changed
|
||||
if (request.CoordinateSystem != null || request.EditorSettings != null)
|
||||
{
|
||||
settings.ModifiedDate = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return level;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteLevelAsync(Guid levelId)
|
||||
{
|
||||
var level = await GetLevelAsync(levelId);
|
||||
if (level == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if parent layout is active
|
||||
if (level.Version.Layout.IsActive)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot delete level from active layout '{level.Version.Layout.LayoutId}'. " +
|
||||
"Deactivate the layout first.");
|
||||
}
|
||||
|
||||
// Hard delete - cascade will handle all nested data
|
||||
_context.LayoutLevels.Remove(level);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RobotNet10.MapManager.Data;
|
||||
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service implementation for querying map data (nodes and edges) by VehicleType
|
||||
/// </summary>
|
||||
public class MapQueryService(MapDbContext context) : IMapQueryService
|
||||
{
|
||||
private readonly MapDbContext _context = context;
|
||||
|
||||
public async Task<List<Node>> GetNodesByVehicleTypeAsync(Guid vehicleTypeId)
|
||||
{
|
||||
var filteredNodes = await _context.Nodes
|
||||
.Include(n => n.VehicleProperties)
|
||||
.ThenInclude(vp => vp.VehicleType)
|
||||
.Where(n => n.VehicleProperties.Any(vp => vp.VehicleTypeId == vehicleTypeId))
|
||||
.OrderBy(n => n.NodeId)
|
||||
.ToListAsync();
|
||||
|
||||
return filteredNodes;
|
||||
}
|
||||
|
||||
public async Task<List<Edge>> GetEdgesByVehicleTypeAsync(Guid vehicleTypeId)
|
||||
{
|
||||
var filteredEdges = await _context.Edges
|
||||
.Include(e => e.VehicleProperties)
|
||||
.ThenInclude(vp => vp.VehicleType)
|
||||
.Include(e => e.StartNode)
|
||||
.Include(e => e.EndNode)
|
||||
.Where(e => e.VehicleProperties.Any(vp => vp.VehicleTypeId == vehicleTypeId))
|
||||
.OrderBy(e => e.EdgeId)
|
||||
.ToListAsync();
|
||||
|
||||
return filteredEdges;
|
||||
}
|
||||
|
||||
public async Task<List<Node>> GetNodesByVehicleTypeAndLevelAsync(Guid vehicleTypeId, Guid levelId)
|
||||
{
|
||||
var filteredNodes = await _context.Nodes
|
||||
.Include(n => n.VehicleProperties)
|
||||
.ThenInclude(vp => vp.VehicleType)
|
||||
.Where(n => n.LevelId == levelId &&
|
||||
n.VehicleProperties.Any(vp => vp.VehicleTypeId == vehicleTypeId))
|
||||
.OrderBy(n => n.NodeId)
|
||||
.ToListAsync();
|
||||
|
||||
return filteredNodes;
|
||||
}
|
||||
|
||||
public async Task<List<Edge>> GetEdgesByVehicleTypeAndLevelAsync(Guid vehicleTypeId, Guid levelId)
|
||||
{
|
||||
var filteredEdges = await _context.Edges
|
||||
.Include(e => e.VehicleProperties)
|
||||
.ThenInclude(vp => vp.VehicleType)
|
||||
.Include(e => e.StartNode)
|
||||
.Include(e => e.EndNode)
|
||||
.Where(e => e.LevelId == levelId &&
|
||||
e.VehicleProperties.Any(vp => vp.VehicleTypeId == vehicleTypeId))
|
||||
.OrderBy(e => e.EdgeId)
|
||||
.ToListAsync();
|
||||
|
||||
return filteredEdges;
|
||||
}
|
||||
|
||||
public async Task<int> GetTotalNodesCountAsync()
|
||||
{
|
||||
return await _context.Nodes.CountAsync();
|
||||
}
|
||||
|
||||
public async Task<int> GetTotalEdgesCountAsync()
|
||||
{
|
||||
return await _context.Edges.CountAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Requests;
|
||||
using RobotNet10.MapManager.Data;
|
||||
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service implementation for managing nodes
|
||||
/// </summary>
|
||||
public class NodeService(MapDbContext context) : INodeService
|
||||
{
|
||||
private readonly MapDbContext _context = context;
|
||||
|
||||
public async Task<List<Node>> GetNodesByLevelAsync(Guid layoutLevelId, bool includeVehicleProperties = true)
|
||||
{
|
||||
var query = _context.Nodes.Where(n => n.LevelId == layoutLevelId);
|
||||
|
||||
if (includeVehicleProperties)
|
||||
{
|
||||
query = query.Include(n => n.VehicleProperties)
|
||||
.ThenInclude(vp => vp.VehicleType);
|
||||
}
|
||||
|
||||
return await query.OrderBy(n => n.NodeId).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<Node?> GetByIdAsync(Guid nodeId, bool includeVehicleProperties = true)
|
||||
{
|
||||
var query = _context.Nodes.Where(n => n.Id == nodeId);
|
||||
|
||||
if (includeVehicleProperties)
|
||||
{
|
||||
query = query.Include(n => n.VehicleProperties)
|
||||
.ThenInclude(vp => vp.VehicleType);
|
||||
}
|
||||
|
||||
return await query.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<Node> UpdateAsync(Guid nodeId, UpdateNodeRequest request)
|
||||
{
|
||||
var node = await GetByIdAsync(nodeId, includeVehicleProperties: true) ??
|
||||
throw new InvalidOperationException($"Node with ID '{nodeId}' not found");
|
||||
|
||||
// Update coordinates if provided
|
||||
if (request.X.HasValue)
|
||||
{
|
||||
// Validate bounds
|
||||
if (!await ValidateCoordinatesAsync(node.LevelId, request.X.Value, node.Y))
|
||||
{
|
||||
throw new InvalidOperationException($"Coordinates ({request.X.Value}, {node.Y}) are outside valid bounds");
|
||||
}
|
||||
node.X = request.X.Value;
|
||||
}
|
||||
|
||||
if (request.Y.HasValue)
|
||||
{
|
||||
// Validate bounds
|
||||
if (!await ValidateCoordinatesAsync(node.LevelId, node.X, request.Y.Value))
|
||||
{
|
||||
throw new InvalidOperationException($"Coordinates ({node.X}, {request.Y.Value}) are outside valid bounds");
|
||||
}
|
||||
node.Y = request.Y.Value;
|
||||
}
|
||||
|
||||
// Update other properties
|
||||
if (request.NodeName != null)
|
||||
node.NodeName = request.NodeName;
|
||||
|
||||
if (request.NodeDescription != null)
|
||||
node.NodeDescription = request.NodeDescription;
|
||||
|
||||
if (request.MapId != null)
|
||||
node.MapId = request.MapId;
|
||||
|
||||
// Update vehicle properties if provided
|
||||
if (request.VehicleProperties != null)
|
||||
{
|
||||
// Remove existing properties
|
||||
var existingProps = await _context.NodeVehicleProperties
|
||||
.Where(nvp => nvp.NodeId == nodeId)
|
||||
.ToListAsync();
|
||||
_context.NodeVehicleProperties.RemoveRange(existingProps);
|
||||
|
||||
// Add new properties
|
||||
foreach (var propDto in request.VehicleProperties)
|
||||
{
|
||||
var prop = new NodeVehicleProperty
|
||||
{
|
||||
NodeId = nodeId,
|
||||
VehicleTypeId = propDto.VehicleTypeId,
|
||||
Theta = propDto.Theta,
|
||||
Actions = propDto.Actions,
|
||||
AllowedDeviationXY = propDto.AllowedDeviationXY,
|
||||
AllowedDeviationTheta = propDto.AllowedDeviationTheta
|
||||
};
|
||||
_context.NodeVehicleProperties.Add(prop);
|
||||
}
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Reload with vehicle properties
|
||||
return (await GetByIdAsync(nodeId, includeVehicleProperties: true))!;
|
||||
}
|
||||
|
||||
public async Task<bool> ValidateCoordinatesAsync(Guid layoutLevelId, double x, double y)
|
||||
{
|
||||
var settings = await _context.LayoutLevelEditorSettings
|
||||
.FirstOrDefaultAsync(s => s.LevelId == layoutLevelId);
|
||||
|
||||
if (settings == null)
|
||||
return true; // No bounds set, allow any coordinates
|
||||
|
||||
// Check bounds
|
||||
if (settings.BoundsMinX.HasValue && x < settings.BoundsMinX.Value)
|
||||
return false;
|
||||
|
||||
if (settings.BoundsMaxX.HasValue && x > settings.BoundsMaxX.Value)
|
||||
return false;
|
||||
|
||||
if (settings.BoundsMinY.HasValue && y < settings.BoundsMinY.Value)
|
||||
return false;
|
||||
|
||||
if (settings.BoundsMaxY.HasValue && y > settings.BoundsMaxY.Value)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<List<Node>> FindNodesNearCoordinatesAsync(Guid layoutLevelId, double x, double y, double radius)
|
||||
{
|
||||
// Pre-filter with bounding box at database level, then refine with Euclidean distance in-memory
|
||||
var nodes = await _context.Nodes
|
||||
.Where(n => n.LevelId == layoutLevelId
|
||||
&& n.X >= x - radius && n.X <= x + radius
|
||||
&& n.Y >= y - radius && n.Y <= y + radius)
|
||||
.ToListAsync();
|
||||
|
||||
return [.. nodes
|
||||
.Where(n =>
|
||||
{
|
||||
var dx = n.X - x;
|
||||
var dy = n.Y - y;
|
||||
return dx * dx + dy * dy <= radius * radius;
|
||||
})
|
||||
.OrderBy(n => (n.X - x) * (n.X - x) + (n.Y - y) * (n.Y - y))];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RobotNet10.MapEditor.Shared.DTOs.Requests;
|
||||
using RobotNet10.MapManager.Data;
|
||||
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service implementation for managing stations
|
||||
/// </summary>
|
||||
public class StationService(MapDbContext context) : IStationService
|
||||
{
|
||||
private readonly MapDbContext _context = context;
|
||||
|
||||
public async Task<Station> CreateAsync(CreateStationRequest request)
|
||||
{
|
||||
// Check if station ID already exists in this level
|
||||
var exists = await _context.Stations
|
||||
.AnyAsync(s => s.LevelId == request.LayoutLevelId && s.StationId == request.StationId);
|
||||
|
||||
if (exists)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Station with ID '{request.StationId}' already exists in this layout level");
|
||||
}
|
||||
|
||||
using var transaction = await _context.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
var station = new Station
|
||||
{
|
||||
LevelId = request.LayoutLevelId,
|
||||
StationId = request.StationId,
|
||||
StationName = request.StationName,
|
||||
StationDescription = request.StationDescription,
|
||||
StationHeight = request.StationHeight,
|
||||
X = request.X,
|
||||
Y = request.Y,
|
||||
Theta = request.Theta
|
||||
};
|
||||
|
||||
_context.Stations.Add(station);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Add interaction nodes if provided
|
||||
if (request.InteractionNodeIds != null && request.InteractionNodeIds.Count != 0)
|
||||
{
|
||||
foreach (var nodeId in request.InteractionNodeIds)
|
||||
{
|
||||
var interactionNode = new StationInteractionNode
|
||||
{
|
||||
StationId = station.Id,
|
||||
NodeId = nodeId
|
||||
};
|
||||
_context.StationInteractionNodes.Add(interactionNode);
|
||||
}
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await transaction.CommitAsync();
|
||||
|
||||
// Reload with interaction nodes
|
||||
return (await GetByIdAsync(station.Id, includeInteractionNodes: true))!;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<Station>> GetStationsByLevelAsync(Guid layoutLevelId, bool includeInteractionNodes = true)
|
||||
{
|
||||
var query = _context.Stations.Where(s => s.LevelId == layoutLevelId);
|
||||
|
||||
if (includeInteractionNodes)
|
||||
{
|
||||
query = query
|
||||
.Include(s => s.InteractionNodes)
|
||||
.ThenInclude(sin => sin.Node);
|
||||
}
|
||||
|
||||
return await query.OrderBy(s => s.StationId).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<Station?> GetByIdAsync(Guid stationId, bool includeInteractionNodes = true)
|
||||
{
|
||||
var query = _context.Stations.Where(s => s.Id == stationId);
|
||||
|
||||
if (includeInteractionNodes)
|
||||
{
|
||||
query = query
|
||||
.Include(s => s.InteractionNodes)
|
||||
.ThenInclude(sin => sin.Node)
|
||||
.ThenInclude(n => n.VehicleProperties);
|
||||
}
|
||||
|
||||
return await query.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<Station> UpdateAsync(Guid stationId, UpdateStationRequest request)
|
||||
{
|
||||
var station = await GetByIdAsync(stationId, includeInteractionNodes: true) ??
|
||||
throw new InvalidOperationException($"Station with ID '{stationId}' not found");
|
||||
|
||||
// Update properties
|
||||
if (request.StationName != null)
|
||||
station.StationName = request.StationName;
|
||||
|
||||
if (request.StationDescription != null)
|
||||
station.StationDescription = request.StationDescription;
|
||||
|
||||
if (request.StationHeight.HasValue)
|
||||
station.StationHeight = request.StationHeight.Value;
|
||||
|
||||
if (request.X.HasValue)
|
||||
station.X = request.X.Value;
|
||||
|
||||
if (request.Y.HasValue)
|
||||
station.Y = request.Y.Value;
|
||||
|
||||
if (request.Theta.HasValue)
|
||||
station.Theta = request.Theta.Value;
|
||||
|
||||
// Update interaction nodes if provided
|
||||
if (request.InteractionNodeIds != null)
|
||||
{
|
||||
// Remove existing interaction nodes
|
||||
var existingInteractionNodes = await _context.StationInteractionNodes
|
||||
.Where(sin => sin.StationId == stationId)
|
||||
.ToListAsync();
|
||||
_context.StationInteractionNodes.RemoveRange(existingInteractionNodes);
|
||||
|
||||
// Add new interaction nodes
|
||||
foreach (var nodeId in request.InteractionNodeIds)
|
||||
{
|
||||
// Verify node exists
|
||||
var nodeExists = await _context.Nodes.AnyAsync(n => n.Id == nodeId);
|
||||
if (!nodeExists)
|
||||
{
|
||||
throw new InvalidOperationException($"Node with ID '{nodeId}' not found");
|
||||
}
|
||||
|
||||
var interactionNode = new StationInteractionNode
|
||||
{
|
||||
StationId = stationId,
|
||||
NodeId = nodeId
|
||||
};
|
||||
_context.StationInteractionNodes.Add(interactionNode);
|
||||
}
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Reload with interaction nodes
|
||||
return (await GetByIdAsync(stationId, includeInteractionNodes: true))!;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid stationId)
|
||||
{
|
||||
var station = await GetByIdAsync(stationId, includeInteractionNodes: false);
|
||||
if (station == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delete interaction nodes (cascade will handle this, but explicit for clarity)
|
||||
var interactionNodes = await _context.StationInteractionNodes
|
||||
.Where(sin => sin.StationId == stationId)
|
||||
.ToListAsync();
|
||||
_context.StationInteractionNodes.RemoveRange(interactionNodes);
|
||||
|
||||
// Delete station
|
||||
_context.Stations.Remove(station);
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RobotNet10.MapManager.Data;
|
||||
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service implementation for managing vehicle types
|
||||
/// </summary>
|
||||
public class VehicleTypeService(MapDbContext context) : IVehicleTypeService
|
||||
{
|
||||
private readonly MapDbContext _context = context;
|
||||
|
||||
public async Task<VehicleType> CreateAsync(string vehicleTypeId, string vehicleTypeName, string? description, string? specifications, string? actions)
|
||||
{
|
||||
// Check if already exists
|
||||
if (await ExistsAsync(vehicleTypeId))
|
||||
{
|
||||
throw new InvalidOperationException($"Vehicle type with ID '{vehicleTypeId}' already exists");
|
||||
}
|
||||
|
||||
var vehicleType = new VehicleType
|
||||
{
|
||||
VehicleTypeId = vehicleTypeId,
|
||||
VehicleTypeName = vehicleTypeName,
|
||||
Description = description,
|
||||
Specifications = specifications,
|
||||
Actions = actions,
|
||||
IsActive = true,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_context.VehicleTypes.Add(vehicleType);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return vehicleType;
|
||||
}
|
||||
|
||||
public async Task<List<VehicleType>> GetAllAsync()
|
||||
{
|
||||
return await _context.VehicleTypes
|
||||
.OrderBy(v => v.VehicleTypeName)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<VehicleType?> GetByIdAsync(Guid id)
|
||||
{
|
||||
return await _context.VehicleTypes
|
||||
.FirstOrDefaultAsync(v => v.Id == id);
|
||||
}
|
||||
|
||||
public async Task<VehicleType?> GetByVehicleTypeIdAsync(string vehicleTypeId)
|
||||
{
|
||||
return await _context.VehicleTypes
|
||||
.FirstOrDefaultAsync(v => v.VehicleTypeId == vehicleTypeId);
|
||||
}
|
||||
|
||||
public async Task<VehicleType> UpdateAsync(Guid id, string? vehicleTypeName, string? description, string? specifications, string? actions, bool? isActive)
|
||||
{
|
||||
var vehicleType = await GetByIdAsync(id) ??
|
||||
throw new InvalidOperationException($"Vehicle type with ID '{id}' not found");
|
||||
if (vehicleTypeName != null)
|
||||
vehicleType.VehicleTypeName = vehicleTypeName;
|
||||
|
||||
if (description != null)
|
||||
vehicleType.Description = description;
|
||||
|
||||
if (specifications != null)
|
||||
vehicleType.Specifications = specifications;
|
||||
|
||||
if (actions != null)
|
||||
vehicleType.Actions = actions;
|
||||
|
||||
if (isActive.HasValue)
|
||||
vehicleType.IsActive = isActive.Value;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return vehicleType;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id)
|
||||
{
|
||||
var vehicleType = await GetByIdAsync(id);
|
||||
if (vehicleType == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if vehicle type is referenced
|
||||
var hasNodeReferences = await _context.NodeVehicleProperties
|
||||
.AnyAsync(nvp => nvp.VehicleTypeId == id);
|
||||
|
||||
var hasEdgeReferences = await _context.EdgeVehicleProperties
|
||||
.AnyAsync(evp => evp.VehicleTypeId == id);
|
||||
|
||||
if (hasNodeReferences || hasEdgeReferences)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot delete vehicle type '{vehicleType.VehicleTypeId}' because it is referenced by nodes or edges");
|
||||
}
|
||||
|
||||
_context.VehicleTypes.Remove(vehicleType);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsAsync(string vehicleTypeId)
|
||||
{
|
||||
return await _context.VehicleTypes
|
||||
.AnyAsync(v => v.VehicleTypeId == vehicleTypeId);
|
||||
}
|
||||
|
||||
public async Task<List<VehicleType>> SearchAsync(string query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return await GetAllAsync();
|
||||
}
|
||||
|
||||
var lowerQuery = query.ToLowerInvariant();
|
||||
|
||||
return await _context.VehicleTypes
|
||||
.Where(v =>
|
||||
v.VehicleTypeId.ToLower().Contains(lowerQuery) ||
|
||||
v.VehicleTypeName.ToLower().Contains(lowerQuery))
|
||||
.OrderBy(v => v.VehicleTypeName)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<VehicleType>> GetByActiveStatusAsync(bool isActive)
|
||||
{
|
||||
return await _context.VehicleTypes
|
||||
.Where(v => v.IsActive == isActive)
|
||||
.OrderBy(v => v.VehicleTypeName)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<VehicleTypeUsageInfo> GetUsageInfoAsync(Guid id)
|
||||
{
|
||||
var vehicleType = await GetByIdAsync(id);
|
||||
if (vehicleType == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Vehicle type with ID '{id}' not found");
|
||||
}
|
||||
|
||||
var nodePropertiesCount = await _context.NodeVehicleProperties
|
||||
.CountAsync(nvp => nvp.VehicleTypeId == id);
|
||||
|
||||
var edgePropertiesCount = await _context.EdgeVehicleProperties
|
||||
.CountAsync(evp => evp.VehicleTypeId == id);
|
||||
|
||||
return new VehicleTypeUsageInfo
|
||||
{
|
||||
VehicleTypeId = vehicleType.Id,
|
||||
VehicleTypeIdString = vehicleType.VehicleTypeId,
|
||||
VehicleTypeName = vehicleType.VehicleTypeName,
|
||||
NodePropertiesCount = nodePropertiesCount,
|
||||
EdgePropertiesCount = edgePropertiesCount
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace RobotNet10.MapManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Vehicle type usage information
|
||||
/// </summary>
|
||||
public class VehicleTypeUsageInfo
|
||||
{
|
||||
public Guid VehicleTypeId { get; set; }
|
||||
public string VehicleTypeIdString { get; set; } = string.Empty;
|
||||
public string VehicleTypeName { get; set; } = string.Empty;
|
||||
public int NodePropertiesCount { get; set; }
|
||||
public int EdgePropertiesCount { get; set; }
|
||||
public int TotalUsageCount => NodePropertiesCount + EdgePropertiesCount;
|
||||
public bool CanDelete => TotalUsageCount == 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"MapDatabase": "Data Source=maps.db"
|
||||
},
|
||||
"ImageStorage": {
|
||||
"StorageType": "FileSystem",
|
||||
"Minio": {
|
||||
"Endpoint": "localhost:9000",
|
||||
"AccessKey": "minioadmin",
|
||||
"SecretKey": "minioadmin",
|
||||
"BucketName": "map-images",
|
||||
"UseSSL": false
|
||||
},
|
||||
"FileSystem": {
|
||||
"FolderName": "MapImages"
|
||||
}
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning",
|
||||
"RobotNet10.MapManager": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
404
srcs/RobotNet10/Commons/RobotNet10.MapManager/lif-schema.json
Normal file
404
srcs/RobotNet10/Commons/RobotNet10.MapManager/lif-schema.json
Normal file
@@ -0,0 +1,404 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "LIF Layout Interchange Format",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"metaInformation": {
|
||||
"type": "object",
|
||||
"description": "Contains metadata about the project and the LIF file.",
|
||||
"properties": {
|
||||
"projectIdentification": {
|
||||
"type": "string",
|
||||
"description": "Human-readable name of the project (e.g., for display purposes)."
|
||||
},
|
||||
"creator": {
|
||||
"type": "string",
|
||||
"description": "Creator of the LIF file (e.g., name of company or person)."
|
||||
},
|
||||
"exportTimestamp": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The timestamp at which this LIF file was created/updated/modified. Format is ISO8601 in UTC."
|
||||
},
|
||||
"lifVersion": {
|
||||
"type": "string",
|
||||
"description": "Version of the LIF file format. Follows semantic versioning (Major.Minor.Patch)."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"projectIdentification",
|
||||
"creator",
|
||||
"exportTimestamp",
|
||||
"lifVersion"
|
||||
]
|
||||
},
|
||||
"layouts": {
|
||||
"type": "array",
|
||||
"description": "Collection of layouts used in the facility by the driverless transport system. All layouts refer to the same global origin.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"layoutId": {
|
||||
"type": "string",
|
||||
"description": "Unique identifier for the layout."
|
||||
},
|
||||
"layoutName": {
|
||||
"type": "string",
|
||||
"description": "Name of the layout."
|
||||
},
|
||||
"layoutVersion": {
|
||||
"type": "string",
|
||||
"description": "Version number of the layout. It is suggested that this be an integer, represented as a string, incremented with each change, starting at 1."
|
||||
},
|
||||
"layoutLevelId": {
|
||||
"type": "string",
|
||||
"description": "Unique identifier for the layout level."
|
||||
},
|
||||
"layoutDescription": {
|
||||
"type": "string",
|
||||
"description": "Description of the layout. *Optional*."
|
||||
},
|
||||
"nodes": {
|
||||
"type": "array",
|
||||
"description": "List of nodes in the layout. Nodes are locations where vehicles can navigate to.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"nodeId": {
|
||||
"type": "string",
|
||||
"description": "Unique identifier for the node."
|
||||
},
|
||||
"nodeName": {
|
||||
"type": "string",
|
||||
"description": "Name of the node. *Optional*."
|
||||
},
|
||||
"nodeDescription": {
|
||||
"type": "string",
|
||||
"description": "Description of the node. *Optional*."
|
||||
},
|
||||
"mapId": {
|
||||
"type": "string",
|
||||
"description": "Identifier for the map that this node belongs to. *Optional*."
|
||||
},
|
||||
"nodePosition": {
|
||||
"type": "object",
|
||||
"description": "Position of the node on the map (in meters).",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"description": "X coordinate of the node in meters. Range: [float64.min ... float64.max]"
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"description": "Y coordinate of the node in meters. Range: [float64.min... float64.max]"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y"
|
||||
]
|
||||
},
|
||||
"vehicleTypeNodeProperties": {
|
||||
"type": "array",
|
||||
"description": "Vehicle-specific properties related to the node.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"vehicleTypeId": {
|
||||
"type": "string",
|
||||
"description": "Identifier for the vehicle type."
|
||||
},
|
||||
"theta": {
|
||||
"type": "number",
|
||||
"description": "Absolute orientation of the vehicle on the node in reference to the global origin’s rotation. Range: [-Pi ... Pi]"
|
||||
},
|
||||
"actions": {
|
||||
"type": "array",
|
||||
"description": "List of actions that the vehicle can perform at the node. *Optional*.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"actionType": {
|
||||
"type": "string",
|
||||
"description": "Type of action (e.g., move, load, unload)."
|
||||
},
|
||||
"actionDescription": {
|
||||
"type": "string",
|
||||
"description": "Description of the action. *Optional*."
|
||||
},
|
||||
"required": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the action is mandatory."
|
||||
},
|
||||
"blockingType": {
|
||||
"type": "string",
|
||||
"description": "Specifies if the action is blocking (HARD or SOFT)."
|
||||
},
|
||||
"actionParameters": {
|
||||
"type": "array",
|
||||
"description": "Parameters associated with the action. *Optional*.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "Key of the action parameter."
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"description": "Value of the action parameter."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"key",
|
||||
"value"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"actionType",
|
||||
"blockingType"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"vehicleTypeId"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"nodeId",
|
||||
"nodePosition",
|
||||
"vehicleTypeNodeProperties"
|
||||
]
|
||||
}
|
||||
},
|
||||
"edges": {
|
||||
"type": "array",
|
||||
"description": "List of edges in the layout. Edges represent paths between nodes.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"edgeId": {
|
||||
"type": "string",
|
||||
"description": "Unique identifier for the edge."
|
||||
},
|
||||
"startNodeId": {
|
||||
"type": "string",
|
||||
"description": "ID of the starting node for this edge."
|
||||
},
|
||||
"endNodeId": {
|
||||
"type": "string",
|
||||
"description": "ID of the ending node for this edge."
|
||||
},
|
||||
"vehicleTypeEdgeProperties": {
|
||||
"type": "array",
|
||||
"description": "Vehicle-specific properties for the edge.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"vehicleTypeId": {
|
||||
"type": "string",
|
||||
"description": "Identifier for the vehicle type."
|
||||
},
|
||||
"vehicleOrientation": {
|
||||
"type": "number",
|
||||
"description": "Orientation of the vehicle while traversing the edge, in degrees. Range: [0.0 ... 360.0]"
|
||||
},
|
||||
"orientationType": {
|
||||
"type": "string",
|
||||
"description": "Type of orientation (e.g., TANGENTIAL)."
|
||||
},
|
||||
"rotationAllowed": {
|
||||
"type": "boolean",
|
||||
"description": "Indicates if rotation is allowed while on the edge. *Optional*."
|
||||
},
|
||||
"rotationAtStartNodeAllowed": {
|
||||
"type": "string",
|
||||
"description": "Specifies if rotation is allowed at the start node. *Optional*."
|
||||
},
|
||||
"rotationAtEndNodeAllowed": {
|
||||
"type": "string",
|
||||
"description": "Specifies if rotation is allowed at the end node. *Optional*."
|
||||
},
|
||||
"maxSpeed": {
|
||||
"type": "number",
|
||||
"description": "Maximum speed allowed on this edge in meters per second. Range: [0.0 ... float64.max]"
|
||||
},
|
||||
"maxRotationSpeed": {
|
||||
"type": "number",
|
||||
"description": "Maximum rotation speed allowed on this edge in radians per second. *Optional*. Range: [0.0 ... float64.max]"
|
||||
},
|
||||
"minHeight": {
|
||||
"type": "number",
|
||||
"description": "Minimum height of the vehicle on this edge in meters. *Optional*. Range: [0.0 ... float64.max]"
|
||||
},
|
||||
"maxHeight": {
|
||||
"type": "number",
|
||||
"description": "Maximum height of the vehicle on this edge in meters. *Optional*. Range: [0.0 ... float64.max]"
|
||||
},
|
||||
"loadRestriction": {
|
||||
"type": "object",
|
||||
"description": "Load restrictions for this edge. *Optional*.",
|
||||
"properties": {
|
||||
"unloaded": {
|
||||
"type": "boolean",
|
||||
"description": "Indicates if the edge can be traversed without a load."
|
||||
},
|
||||
"loaded": {
|
||||
"type": "boolean",
|
||||
"description": "Indicates if the edge can be traversed with a load."
|
||||
},
|
||||
"loadSetNames": {
|
||||
"type": "array",
|
||||
"description": "Names of the load sets allowed on this edge. *Optional*.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"unloaded",
|
||||
"loaded"
|
||||
]
|
||||
},
|
||||
"trajectory": {
|
||||
"type": "object",
|
||||
"description": "Trajectory information for this edge, if applicable. *Optional*.",
|
||||
"properties": {
|
||||
"degree": {
|
||||
"type": "integer",
|
||||
"description": "Degree of the trajectory curve. Default is 3. Range: [1 ... 3]",
|
||||
"default": 3
|
||||
},
|
||||
"knotVector": {
|
||||
"type": "array",
|
||||
"description": "Knot vector for the trajectory.",
|
||||
"items": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"controlPoints": {
|
||||
"type": "array",
|
||||
"description": "Control points defining the trajectory.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"description": "X coordinate of the control point in meters."
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"description": "Y coordinate of the control point in meters."
|
||||
},
|
||||
"weight": {
|
||||
"type": "number",
|
||||
"description": "The weight with which this control point pulls on the curve. When not defined, the default is 1.0. Range: [0.0 ... float64.max]",
|
||||
"default": 1.0
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"knotVector",
|
||||
"controlPoints"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"vehicleTypeId"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"edgeId",
|
||||
"startNodeId",
|
||||
"endNodeId",
|
||||
"vehicleTypeEdgeProperties"
|
||||
]
|
||||
}
|
||||
},
|
||||
"stations": {
|
||||
"type": "array",
|
||||
"description": "List of stations in the layout where vehicles perform specific actions.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"stationId": {
|
||||
"type": "string",
|
||||
"description": "Unique identifier for the station."
|
||||
},
|
||||
"interactionNodeIds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "List of node IDs where the station interacts."
|
||||
},
|
||||
"stationName": {
|
||||
"type": "string",
|
||||
"description": "Name of the station. *Optional*."
|
||||
},
|
||||
"stationDescription": {
|
||||
"type": "string",
|
||||
"description": "Description of the station. *Optional*."
|
||||
},
|
||||
"stationHeight": {
|
||||
"type": "number",
|
||||
"description": "Height of the station, if applicable, in meters. *Optional*. Range: [0.0 ... float64.max]"
|
||||
},
|
||||
"stationPosition": {
|
||||
"type": "object",
|
||||
"description": "Position of the station on the map (in meters).",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"description": "X coordinate of the station in meters. Range: [float64.min ... float64.max]"
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"description": "Y coordinate of the station in meters. Range: [float64.min ... float64.max]"
|
||||
},
|
||||
"theta": {
|
||||
"type": "number",
|
||||
"description": "Orientation of the station. Unit: radians. Range: [-Pi ... Pi]"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"stationId",
|
||||
"interactionNodeIds"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"layoutId",
|
||||
"layoutVersion",
|
||||
"nodes",
|
||||
"edges",
|
||||
"stations"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"metaInformation",
|
||||
"layouts"
|
||||
]
|
||||
}
|
||||
355
srcs/RobotNet10/Commons/RobotNet10.MqttConnection/MQTTClient.cs
Normal file
355
srcs/RobotNet10/Commons/RobotNet10.MqttConnection/MQTTClient.cs
Normal file
@@ -0,0 +1,355 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MQTTnet;
|
||||
using MQTTnet.Packets;
|
||||
using MQTTnet.Protocol;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace RobotNet10.MqttConnection;
|
||||
|
||||
public class MQTTClient : IAsyncDisposable
|
||||
{
|
||||
private readonly MqttClientFactory MqttClientFactory;
|
||||
private MqttClientOptions? MqttClientOptions;
|
||||
private readonly MqttClientSubscribeOptions MqttClientSubscribeOptions;
|
||||
private IMqttClient? MqttClient;
|
||||
private readonly ILogger<MQTTClient> Logger;
|
||||
|
||||
private readonly MQTTConfig MQTTConfig;
|
||||
private readonly SemaphoreSlim ReconnectionSemaphore = new(1, 1);
|
||||
private volatile bool IsDisposed;
|
||||
private CancellationTokenSource? cancellationConnectingTokenSource;
|
||||
private CancellationTokenSource? cancellationReconnectingTokenSource;
|
||||
|
||||
public bool IsConnected
|
||||
{
|
||||
get
|
||||
{
|
||||
var client = MqttClient;
|
||||
return !IsDisposed && client is not null && client.IsConnected;
|
||||
}
|
||||
}
|
||||
public event Func<MqttApplicationMessageReceivedEventArgs, Task>? MessageUpdated;
|
||||
|
||||
public MQTTClient(MQTTConfig config, MqttTopicFilter[] topics, ILogger<MQTTClient> logger)
|
||||
{
|
||||
MQTTConfig = config;
|
||||
Logger = logger;
|
||||
|
||||
MqttClientFactory = new MqttClientFactory();
|
||||
var SubscribeOptionsBuilder = MqttClientFactory.CreateSubscribeOptionsBuilder();
|
||||
foreach (var topic in topics)
|
||||
{
|
||||
SubscribeOptionsBuilder = SubscribeOptionsBuilder.WithTopicFilter(topic);
|
||||
}
|
||||
MqttClientSubscribeOptions = SubscribeOptionsBuilder.Build();
|
||||
}
|
||||
|
||||
private async Task OnDisconnected(MqttClientDisconnectedEventArgs args)
|
||||
{
|
||||
if (IsDisposed || !args.ClientWasConnected) return;
|
||||
|
||||
if (!await ReconnectionSemaphore.WaitAsync(10))
|
||||
{
|
||||
if (Logger.IsEnabled(LogLevel.Information)) Logger.LogInformation("Reconnect is already being handled by another thread");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (Logger.IsEnabled(LogLevel.Warning)) Logger.LogWarning("Lost connection to the broker. Reconnection in progress...");
|
||||
|
||||
await CleanupCurrentClient();
|
||||
|
||||
await ReconnectWithRetry();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (Logger.IsEnabled(LogLevel.Error)) Logger.LogError("Reconnection failed: {ex}", ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReconnectionSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private Task OnMessageReceived(MqttApplicationMessageReceivedEventArgs args)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (IsDisposed) return Task.CompletedTask;
|
||||
MessageUpdated?.Invoke(args);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (Logger.IsEnabled(LogLevel.Error)) Logger.LogError("Message Receive is failed: {Message}", ex.Message);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task CleanupCurrentClient()
|
||||
{
|
||||
if (MqttClient is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
MqttClient.DisconnectedAsync -= OnDisconnected;
|
||||
MqttClient.ApplicationMessageReceivedAsync -= OnMessageReceived;
|
||||
cancellationConnectingTokenSource?.Cancel();
|
||||
cancellationReconnectingTokenSource?.Cancel();
|
||||
if (MqttClient.IsConnected)
|
||||
{
|
||||
await MqttClient.DisconnectAsync();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (Logger.IsEnabled(LogLevel.Warning)) Logger.LogWarning("Cleanup client failed: {Message}", ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
MqttClient.Dispose();
|
||||
MqttClient = null;
|
||||
cancellationConnectingTokenSource?.Dispose();
|
||||
cancellationReconnectingTokenSource?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReconnectWithRetry()
|
||||
{
|
||||
const int maxRetries = 5;
|
||||
const int retryDelayMs = 3000;
|
||||
|
||||
for (int attempt = 1; attempt <= maxRetries && !IsDisposed; attempt++)
|
||||
{
|
||||
cancellationReconnectingTokenSource?.Dispose();
|
||||
cancellationReconnectingTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
try
|
||||
{
|
||||
if (Logger.IsEnabled(LogLevel.Information)) Logger.LogInformation("Reconnection attempt {attempt}/{maxRetries}", attempt, maxRetries);
|
||||
|
||||
await ConnectAsync(cancellationReconnectingTokenSource.Token);
|
||||
|
||||
if (IsConnected)
|
||||
{
|
||||
await SubscribeAsync(cancellationReconnectingTokenSource.Token);
|
||||
if (Logger.IsEnabled(LogLevel.Information)) Logger.LogInformation("Reconnection successfully");
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (Logger.IsEnabled(LogLevel.Warning)) Logger.LogWarning("Reconnection attempt {tempt} timed out", attempt);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (Logger.IsEnabled(LogLevel.Error)) Logger.LogError("Reconnect attempt {tempt} failed: {Message}", attempt, ex.Message);
|
||||
}
|
||||
|
||||
if (attempt < maxRetries && !IsDisposed)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(retryDelayMs * attempt, cancellationReconnectingTokenSource.Token);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
Logger.LogError("Không thể reconnect sau tất cả các attempts");
|
||||
}
|
||||
|
||||
private bool ValidateCertificates(MqttClientCertificateValidationEventArgs arg)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(MQTTConfig.CaCertificatesPath))
|
||||
{
|
||||
if (File.Exists(MQTTConfig.CaCertificatesPath))
|
||||
{
|
||||
var caCert = X509CertificateLoader.LoadCertificateFromFile(MQTTConfig.CaCertificatesPath);
|
||||
arg.Chain.ChainPolicy.ExtraStore.Add(caCert);
|
||||
arg.Chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||||
arg.Chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
|
||||
|
||||
return arg.Chain.Build((X509Certificate2)arg.Certificate);
|
||||
}
|
||||
}
|
||||
return !MQTTConfig.EnableCA;
|
||||
}
|
||||
|
||||
private void BuildMqttClientOptions()
|
||||
{
|
||||
var builder = MqttClientFactory.CreateClientOptionsBuilder()
|
||||
.WithTcpServer(MQTTConfig.Host, MQTTConfig.Port)
|
||||
.WithClientId($"{MQTTConfig.ClientId}_{Guid.NewGuid()}")
|
||||
.WithCleanSession(true);
|
||||
if (MQTTConfig.EnablePassword)
|
||||
{
|
||||
builder = builder.WithCredentials(MQTTConfig.Username, MQTTConfig.Password);
|
||||
}
|
||||
|
||||
if (MQTTConfig.EnableTls)
|
||||
{
|
||||
var tlsOptionsBuilder = new MqttClientTlsOptionsBuilder()
|
||||
.UseTls(true)
|
||||
.WithCertificateValidationHandler(ValidateCertificates)
|
||||
.WithClientCertificatesProvider(new MQTTClientCertificatesProvider(MQTTConfig.ClientCertificatePath, MQTTConfig.ClientKeyPath));
|
||||
builder = builder.WithTlsOptions(tlsOptionsBuilder.Build());
|
||||
}
|
||||
MqttClientOptions = builder.Build();
|
||||
}
|
||||
|
||||
public async Task ConnectAsync(CancellationToken? cancellationToken)
|
||||
{
|
||||
if (!IsDisposed)
|
||||
{
|
||||
BuildMqttClientOptions();
|
||||
await CleanupCurrentClient();
|
||||
|
||||
MqttClient = MqttClientFactory.CreateMqttClient();
|
||||
|
||||
MqttClient.ApplicationMessageReceivedAsync -= OnMessageReceived;
|
||||
MqttClient.ApplicationMessageReceivedAsync += OnMessageReceived;
|
||||
MqttClient.DisconnectedAsync -= OnDisconnected;
|
||||
MqttClient.DisconnectedAsync += OnDisconnected;
|
||||
|
||||
cancellationConnectingTokenSource?.Dispose();
|
||||
cancellationConnectingTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken ?? CancellationToken.None);
|
||||
while (!cancellationConnectingTokenSource.IsCancellationRequested && !IsDisposed)
|
||||
{
|
||||
try
|
||||
{
|
||||
var connection = await MqttClient.ConnectAsync(MqttClientOptions, cancellationConnectingTokenSource.Token);
|
||||
if (connection.ResultCode != MqttClientConnectResultCode.Success || !MqttClient.IsConnected)
|
||||
{
|
||||
if (Logger.IsEnabled(LogLevel.Warning)) Logger.LogWarning("Connection to broker failed: {ReasonString}", connection.ReasonString);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (Logger.IsEnabled(LogLevel.Information)) Logger.LogInformation("Connected to {Host} successfully", MQTTConfig.Host);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (Logger.IsEnabled(LogLevel.Error)) Logger.LogError("Create MQTT Client failed: {ex}", ex.Message);
|
||||
}
|
||||
try
|
||||
{
|
||||
await Task.Delay(3000, cancellationConnectingTokenSource.Token);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
else throw new ObjectDisposedException(nameof(MQTTClient));
|
||||
}
|
||||
|
||||
public async Task SubscribeAsync(CancellationToken? cancellationToken)
|
||||
{
|
||||
if (!IsDisposed)
|
||||
{
|
||||
if (MqttClient is null) throw new Exception("Attempted to subscribe before broker connection was initialized");
|
||||
if (!MqttClient.IsConnected) throw new Exception("Attempted to subscribe while connection to broker is not successful");
|
||||
|
||||
cancellationConnectingTokenSource?.Dispose();
|
||||
cancellationConnectingTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken ?? CancellationToken.None);
|
||||
while (!cancellationConnectingTokenSource.IsCancellationRequested && !IsDisposed)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await MqttClient.SubscribeAsync(MqttClientSubscribeOptions, cancellationConnectingTokenSource.Token);
|
||||
bool isSuccess = true;
|
||||
foreach (var item in response.Items)
|
||||
{
|
||||
if (item.ResultCode == MqttClientSubscribeResultCode.GrantedQoS0 ||
|
||||
item.ResultCode == MqttClientSubscribeResultCode.GrantedQoS1 ||
|
||||
item.ResultCode == MqttClientSubscribeResultCode.GrantedQoS2)
|
||||
{
|
||||
if (Logger.IsEnabled(LogLevel.Information)) Logger.LogInformation("Subscribed to topic '{Topic}' with granted QoS {ResultCode}", item.TopicFilter.Topic, item.ResultCode);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (Logger.IsEnabled(LogLevel.Warning)) Logger.LogWarning("Subscribe to {Topic} failed with reason: {res}", item.TopicFilter.Topic, response.ReasonString);
|
||||
isSuccess = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isSuccess) break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (Logger.IsEnabled(LogLevel.Error)) Logger.LogError("Subscribe failed: {ex}", ex.Message);
|
||||
}
|
||||
if (!cancellationConnectingTokenSource.IsCancellationRequested && !IsDisposed)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(3000, cancellationConnectingTokenSource.Token);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
else throw new ObjectDisposedException(nameof(MQTTClient));
|
||||
}
|
||||
|
||||
public async Task PublishAsync(
|
||||
string topic,
|
||||
string data,
|
||||
MqttQualityOfServiceLevel QoS = MqttQualityOfServiceLevel.AtLeastOnce,
|
||||
bool retain = false)
|
||||
{
|
||||
if (IsDisposed) throw new Exception("Client has been disposed");
|
||||
var repeat = MQTTConfig.PublishRepeat;
|
||||
while (repeat-- > 0 && !IsDisposed)
|
||||
{
|
||||
try
|
||||
{
|
||||
var applicationMessage = MqttClientFactory.CreateApplicationMessageBuilder()
|
||||
.WithTopic(topic)
|
||||
.WithPayload(data)
|
||||
.WithQualityOfServiceLevel(QoS)
|
||||
.WithRetainFlag(retain)
|
||||
.Build();
|
||||
if (MqttClient is null || !IsConnected) throw new Exception("Not connected to the broker");
|
||||
var publish = await MqttClient.PublishAsync(applicationMessage);
|
||||
if (!publish.IsSuccess)
|
||||
{
|
||||
await Task.Delay(500);
|
||||
continue;
|
||||
}
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (Logger.IsEnabled(LogLevel.Error)) Logger.LogError("Publish failed: {ex}", ex.Message);
|
||||
}
|
||||
}
|
||||
throw new Exception("Cannot publish message to broker");
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (IsDisposed) return;
|
||||
IsDisposed = true;
|
||||
cancellationConnectingTokenSource?.Cancel();
|
||||
cancellationReconnectingTokenSource?.Cancel();
|
||||
try
|
||||
{
|
||||
if (!await ReconnectionSemaphore.WaitAsync(TimeSpan.FromSeconds(2)))
|
||||
{
|
||||
Logger.LogWarning("Failed to acquire semaphore during dispose, forcing cleanup");
|
||||
return;
|
||||
}
|
||||
|
||||
await CleanupCurrentClient();
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReconnectionSemaphore.Release();
|
||||
ReconnectionSemaphore.Dispose();
|
||||
cancellationConnectingTokenSource?.Dispose();
|
||||
cancellationReconnectingTokenSource?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using MQTTnet;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace RobotNet10.MqttConnection;
|
||||
|
||||
public class MQTTClientCertificatesProvider(string? CerPath, string? KeyPath) : IMqttClientCertificatesProvider
|
||||
{
|
||||
public X509CertificateCollection? GetCertificates()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(CerPath) && !string.IsNullOrEmpty(KeyPath))
|
||||
{
|
||||
if (File.Exists(CerPath) && File.Exists(KeyPath))
|
||||
{
|
||||
var cert = X509Certificate2.CreateFromPem(File.ReadAllText(CerPath), File.ReadAllText(KeyPath));
|
||||
var pfxBytes = cert.Export(X509ContentType.Pfx);
|
||||
var pfxCert = X509CertificateLoader.LoadPkcs12(pfxBytes, "", X509KeyStorageFlags.Exportable | X509KeyStorageFlags.PersistKeySet);
|
||||
return [pfxCert];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user