Compare commits
No commits in common. "b87d8a03002243c9d1a8e332e108d39309255be6" and "619cad61eed86e84b1cf7ca5637758e0a90289d3" have entirely different histories.
b87d8a0300
...
619cad61ee
@ -16,19 +16,20 @@ var app = builder.Build();
|
||||
|
||||
app.MapGet("/getRoute", (float latStart, float lonStart, float latEnd, float lonEnd, Tag.SpeedType vehicle, double stayOnSameRoadPriority, double useHigherLevelRoadsPriority, double useRoadsWithLessJunctionsPriority) =>
|
||||
{
|
||||
Pathfinder result = new Pathfinder("D:/stuttgart-regbez-latest").AStar(new Coordinates(latStart, lonStart),
|
||||
|
||||
PathResult result = Pathfinder.AStar("D:/stuttgart-regbez-latest", new Coordinates(latStart, lonStart),
|
||||
new Coordinates(latEnd, lonEnd), vehicle, useHigherLevelRoadsPriority, stayOnSameRoadPriority,
|
||||
useRoadsWithLessJunctionsPriority);
|
||||
return result.pathResult;
|
||||
return result;
|
||||
}
|
||||
);
|
||||
|
||||
app.MapGet("/getShortestRoute", (float latStart, float lonStart, float latEnd, float lonEnd) =>
|
||||
{
|
||||
Pathfinder result = new Pathfinder("D:/stuttgart-regbez-latest").AStar(new Coordinates(latStart, lonStart),
|
||||
PathResult result = Pathfinder.AStar("D:/stuttgart-regbez-latest", new Coordinates(latStart, lonStart),
|
||||
new Coordinates(latEnd, lonEnd), Tag.SpeedType.any, 0, 0,
|
||||
0);
|
||||
return result.pathResult;
|
||||
return result;
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using OSMDatastructure.Graph;
|
||||
|
||||
namespace Pathfinding;
|
||||
|
||||
@ -6,11 +7,41 @@ public class PathResult
|
||||
{
|
||||
[JsonInclude]public TimeSpan calcTime;
|
||||
[JsonInclude]public List<PathNode> pathNodes;
|
||||
[JsonInclude]public Dictionary<ulong, double>? gScore;
|
||||
[JsonInclude]public HashSet<OsmNode>? gScoreNodes;
|
||||
[JsonIgnore] public RegionManager? regionManager { get; set; }
|
||||
public string? name { get; set; }
|
||||
|
||||
[JsonConstructor]
|
||||
public PathResult(TimeSpan calcTime, List<PathNode> pathNodes, Dictionary<ulong, double>? gScore, HashSet<OsmNode>? gScoreNodes)
|
||||
{
|
||||
this.calcTime = calcTime;
|
||||
this.pathNodes = pathNodes;
|
||||
this.gScore = gScore;
|
||||
this.gScoreNodes = gScoreNodes;
|
||||
}
|
||||
|
||||
public PathResult(TimeSpan calcTime, List<PathNode> pathNodes)
|
||||
{
|
||||
this.calcTime = calcTime;
|
||||
this.pathNodes = pathNodes;
|
||||
}
|
||||
|
||||
public PathResult(TimeSpan calcTime, List<PathNode> pathNodes, Dictionary<OsmNode, double> gScore)
|
||||
{
|
||||
this.calcTime = calcTime;
|
||||
this.pathNodes = pathNodes;
|
||||
AddGScores(gScore);
|
||||
}
|
||||
|
||||
public void AddGScores(Dictionary<OsmNode, double> gScore)
|
||||
{
|
||||
this.gScore = new();
|
||||
this.gScoreNodes = new();
|
||||
foreach (KeyValuePair<OsmNode, double> kv in gScore)
|
||||
{
|
||||
this.gScore.Add(kv.Key.nodeId, kv.Value);
|
||||
this.gScoreNodes.Add(kv.Key);
|
||||
}
|
||||
}
|
||||
}
|
@ -6,41 +6,25 @@ using WayType = OSMDatastructure.Tag.WayType;
|
||||
|
||||
namespace Pathfinding;
|
||||
|
||||
//TODO check parameters for all functions and determine global fields
|
||||
public class Pathfinder
|
||||
public static class Pathfinder
|
||||
{
|
||||
|
||||
public RegionManager regionManager;
|
||||
public readonly string workingDir;
|
||||
public PathResult? pathResult;
|
||||
public Dictionary<OsmNode, double>? gScore;
|
||||
|
||||
public Pathfinder(string workingDirectory)
|
||||
{
|
||||
if (!Path.Exists(workingDirectory))
|
||||
throw new DirectoryNotFoundException(workingDirectory);
|
||||
regionManager = new(workingDirectory);
|
||||
workingDir = workingDirectory;
|
||||
}
|
||||
|
||||
public Pathfinder AStar(Coordinates startCoordinates, Coordinates goalCoordinates,
|
||||
public static PathResult AStar(string workingDir, Coordinates startCoordinates, Coordinates goalCoordinates,
|
||||
SpeedType vehicle, double heuristicRoadLevelPriority, double heuristicSameRoadPriority,
|
||||
double heuristicFewJunctionsPriority)
|
||||
{
|
||||
DateTime startCalc = DateTime.Now;
|
||||
regionManager = new RegionManager(workingDir);
|
||||
RegionManager regionManager = new RegionManager(workingDir);
|
||||
OsmNode? startNode = regionManager.ClosestNodeToCoordinates(startCoordinates, vehicle);
|
||||
OsmNode? goalNode = regionManager.ClosestNodeToCoordinates(goalCoordinates, vehicle);
|
||||
if (startNode is null || goalNode is null)
|
||||
{
|
||||
pathResult = new(DateTime.Now - startCalc, new List<PathNode>());
|
||||
return this;
|
||||
}
|
||||
return new PathResult(DateTime.Now - startCalc, new List<PathNode>());
|
||||
|
||||
PriorityQueue<OsmNode, double> openSetfScore = new();
|
||||
openSetfScore.Enqueue(startNode, 0);
|
||||
Dictionary<OsmNode, OsmNode> cameFromDict = new();
|
||||
gScore = new() { { startNode, 0 } };
|
||||
Dictionary<OsmNode, double> gScore = new();
|
||||
gScore.Add(startNode, 0);
|
||||
|
||||
while (openSetfScore.Count > 0)
|
||||
{
|
||||
@ -48,8 +32,14 @@ public class Pathfinder
|
||||
if (currentNode.Equals(goalNode))
|
||||
{
|
||||
Console.WriteLine("Path found.");
|
||||
this.pathResult = GetPath(cameFromDict, goalNode, DateTime.Now - startCalc);
|
||||
return this;
|
||||
PathResult path = GetPath(cameFromDict, goalNode, regionManager, DateTime.Now - startCalc);
|
||||
string fileName = $"{new DirectoryInfo(workingDir).Name}-{DateTime.Now.ToFileTime()}.result";
|
||||
string outputFilepath = Path.Join(Directory.GetParent(workingDir)!.FullName, fileName);
|
||||
path.name = outputFilepath;
|
||||
path.AddGScores(gScore);
|
||||
SaveGraph(path, outputFilepath);
|
||||
path.regionManager = regionManager;
|
||||
return path;
|
||||
}
|
||||
|
||||
foreach (OsmEdge edge in currentNode.edges)
|
||||
@ -58,7 +48,7 @@ public class Pathfinder
|
||||
if (neighbor is not null)
|
||||
{
|
||||
double tentativeGScore =
|
||||
gScore[currentNode] + Weight(currentNode, neighbor, edge, vehicle);
|
||||
gScore[currentNode] + Weight(currentNode, neighbor, edge, vehicle, regionManager);
|
||||
gScore.TryAdd(neighbor, double.MaxValue);
|
||||
if (tentativeGScore < gScore[neighbor])
|
||||
{
|
||||
@ -70,7 +60,7 @@ public class Pathfinder
|
||||
gScore[neighbor] = tentativeGScore;
|
||||
else
|
||||
gScore.Add(neighbor, tentativeGScore);
|
||||
double h = Heuristic(currentNode, neighbor, goalNode, edge, vehicle,
|
||||
double h = Heuristic(currentNode, neighbor, goalNode, edge, vehicle, regionManager,
|
||||
heuristicRoadLevelPriority, heuristicFewJunctionsPriority, heuristicSameRoadPriority);
|
||||
//Console.WriteLine($"Queue: {openSetfScore.Count:00000} Current Distance: {Utils.DistanceBetween(currentNode, goalNode):000000.00} Visited: {cameFromDict.Count:00000} Current heuristic: {h:00000.00}");
|
||||
openSetfScore.Enqueue(neighbor, tentativeGScore + h);
|
||||
@ -79,21 +69,20 @@ public class Pathfinder
|
||||
}
|
||||
}
|
||||
|
||||
pathResult = new(DateTime.Now - startCalc, new List<PathNode>());
|
||||
return this;
|
||||
return new PathResult(DateTime.Now - startCalc, new List<PathNode>());
|
||||
}
|
||||
|
||||
public void SaveResult(string path)
|
||||
private static void SaveGraph(PathResult pathResult, string outputFilepath)
|
||||
{
|
||||
FileStream fs = new (path, FileMode.CreateNew);
|
||||
FileStream fs = new FileStream(outputFilepath, FileMode.CreateNew);
|
||||
JsonSerializer.Serialize(fs, pathResult, JsonSerializerOptions.Default);
|
||||
fs.Dispose();
|
||||
Console.WriteLine($"Saved result to {path}");
|
||||
Console.WriteLine($"Saved result to {outputFilepath}");
|
||||
}
|
||||
|
||||
private PathResult GetPath(Dictionary<OsmNode, OsmNode> cameFromDict, OsmNode goalNode, TimeSpan calcFinished)
|
||||
private static PathResult GetPath(Dictionary<OsmNode, OsmNode> cameFromDict, OsmNode goalNode, RegionManager regionManager, TimeSpan calcFinished)
|
||||
{
|
||||
List<PathNode> path = new();
|
||||
List<PathNode> path = new List<PathNode>();
|
||||
OsmNode currentNode = goalNode;
|
||||
while (cameFromDict.ContainsKey(cameFromDict[currentNode]))
|
||||
{
|
||||
@ -111,15 +100,15 @@ public class Pathfinder
|
||||
return new PathResult(calcFinished, path);
|
||||
}
|
||||
|
||||
private double Weight(OsmNode fromNode, OsmNode neighborNode, OsmEdge edge, SpeedType vehicle)
|
||||
private static double Weight(OsmNode fromNode, OsmNode neighborNode, OsmEdge edge, SpeedType vehicle, RegionManager regionManager)
|
||||
{
|
||||
double distance = Utils.DistanceBetween(fromNode, neighborNode);
|
||||
double speed = regionManager.GetSpeedForEdge(fromNode, edge.wayId, vehicle);
|
||||
//double prio = GetPriorityVehicleRoad(edge, vehicle, regionManager.GetRegion(fromNode.coordinates)!);
|
||||
double prio = GetPriorityVehicleRoad(edge, vehicle, regionManager.GetRegion(fromNode.coordinates)!);
|
||||
return distance / speed;
|
||||
}
|
||||
|
||||
private double Heuristic(OsmNode fromNode, OsmNode neighborNode, OsmNode goalNode, OsmEdge edge, SpeedType vehicle, double roadPriorityFactor, double junctionFactor, double sameRoadFactor)
|
||||
private static double Heuristic(OsmNode fromNode, OsmNode neighborNode, OsmNode goalNode, OsmEdge edge, SpeedType vehicle, RegionManager regionManager, double roadPriorityFactor, double junctionFactor, double sameRoadFactor)
|
||||
{
|
||||
double roadPriority = GetPriorityVehicleRoad(edge, vehicle, regionManager.GetRegion(fromNode.coordinates)!) * roadPriorityFactor;
|
||||
|
||||
|
@ -11,10 +11,6 @@ public static class Renderer
|
||||
{
|
||||
private const int ImageMaxSize = 20000;
|
||||
private const float PenThickness = 4;
|
||||
private static readonly Color RouteColor = Color.Red;
|
||||
private static readonly Color WeightStartColor = Color.FromArgb(0, 0, 255);
|
||||
private static readonly Color WeightCenterColor = Color.FromArgb(255, 255, 0);
|
||||
private static readonly Color WeightEndColor = Color.FromArgb(0, 255, 0);
|
||||
|
||||
public class Bounds
|
||||
{
|
||||
@ -30,16 +26,17 @@ public static class Renderer
|
||||
}
|
||||
|
||||
[SuppressMessage("Interoperability", "CA1416:Plattformkompatibilität überprüfen")]
|
||||
public static Image DrawPathfinder(Pathfinder pathfinder)
|
||||
public static void DrawGraph(string resultPath, Image? area = null, Bounds? bounds = null)
|
||||
{
|
||||
Console.WriteLine("Rendering loaded Regions");
|
||||
ValueTuple<Image, Bounds> areaRender = DrawArea(pathfinder.regionManager);
|
||||
Console.WriteLine("Rendering gScores (Weights)");
|
||||
ValueTuple<Image, Bounds> areaGScoreRender = DrawGScores(pathfinder.gScore!, areaRender.Item1, areaRender.Item2);
|
||||
Console.WriteLine("Rendering path");
|
||||
ValueTuple<Image, Bounds> areaGScorePathRender = DrawPath(pathfinder.pathResult!, areaGScoreRender.Item1, areaGScoreRender.Item2);
|
||||
FileStream fs = new FileStream(resultPath, FileMode.Open);
|
||||
PathResult graph = JsonSerializer.Deserialize<PathResult>(fs)!;
|
||||
List<Coordinates> coords = new List<Coordinates>();
|
||||
foreach (PathNode node in graph.pathNodes)
|
||||
coords.Add(node.coordinates);
|
||||
string workingDir = new DirectoryInfo(resultPath).FullName;
|
||||
|
||||
return areaGScorePathRender.Item1;
|
||||
Image renderedImage = DrawLoadedNodes(graph.gScoreNodes!, graph.gScore!, coords, area, bounds);
|
||||
renderedImage.Save($"{workingDir}-routing.png");
|
||||
}
|
||||
|
||||
[SuppressMessage("Interoperability", "CA1416:Plattformkompatibilität überprüfen")]
|
||||
@ -66,7 +63,6 @@ public static class Renderer
|
||||
Graphics g = Graphics.FromImage(ret);
|
||||
g.Clear(Color.White);
|
||||
|
||||
//TODO Use road priority for roadcolor
|
||||
Color start = Color.FromArgb(255, 25, 25, 25);
|
||||
Color center = Color.FromArgb(255, 0, 0, 0);
|
||||
Color end = Color.FromArgb(255, 0, 255, 0);
|
||||
@ -93,54 +89,13 @@ public static class Renderer
|
||||
}
|
||||
|
||||
[SuppressMessage("Interoperability", "CA1416:Plattformkompatibilität überprüfen")]
|
||||
public static ValueTuple<Image, Bounds> DrawPath(PathResult pathResult, Image? renderOver = null, Bounds? bounds = null)
|
||||
public static Image DrawLoadedNodes(HashSet<OsmNode> nodes, Dictionary<ulong, double> gScoreDict,
|
||||
List<Coordinates> pathCoordinates, Image? renderOver = null, Bounds? bounds = null)
|
||||
{
|
||||
List<Coordinates> coordinates = new();
|
||||
foreach(PathNode node in pathResult.pathNodes)
|
||||
coordinates.Add(node.coordinates);
|
||||
|
||||
float minLat = bounds?.minLat ?? coordinates.Min(coords => coords.latitude);
|
||||
float minLon = bounds?.minLon ?? coordinates.Min(coords => coords.longitude);
|
||||
float maxLat = bounds?.maxLat ?? coordinates.Max(coords => coords.latitude);
|
||||
float maxLon = bounds?.maxLon ?? coordinates.Max(coords => coords.longitude);
|
||||
|
||||
float latDiff = maxLat - minLat;
|
||||
float lonDiff = maxLon - minLon;
|
||||
|
||||
float scaleFactor = latDiff > lonDiff ? ImageMaxSize / latDiff : ImageMaxSize / lonDiff;
|
||||
|
||||
int pixelsX = (int)(lonDiff * scaleFactor);
|
||||
int pixelsY = (int)(latDiff * scaleFactor);
|
||||
|
||||
Image ret = renderOver ?? new Bitmap(pixelsX, pixelsY, PixelFormat.Format32bppRgb);
|
||||
Graphics g = Graphics.FromImage(ret);
|
||||
if(renderOver is null)
|
||||
g.Clear(Color.White);
|
||||
|
||||
Pen p = new Pen(RouteColor, PenThickness);
|
||||
|
||||
for (int i = 0; i < coordinates.Count - 1; i++)
|
||||
{
|
||||
Coordinates c1 = coordinates[i];
|
||||
Coordinates c2 = coordinates[i + 1];
|
||||
Point p1 = new(Convert.ToInt32((c1.longitude - minLon) * scaleFactor),
|
||||
Convert.ToInt32((maxLat - c1.latitude) * scaleFactor));
|
||||
Point p2 = new(Convert.ToInt32((c2.longitude - minLon) * scaleFactor),
|
||||
Convert.ToInt32((maxLat - c2.latitude) * scaleFactor));
|
||||
g.DrawLine(p, p1, p2);
|
||||
}
|
||||
|
||||
return new ValueTuple<Image, Bounds>(ret, new Bounds(minLat,minLon,maxLat,maxLon));
|
||||
}
|
||||
|
||||
[SuppressMessage("Interoperability", "CA1416:Plattformkompatibilität überprüfen")]
|
||||
public static ValueTuple<Image, Bounds> DrawGScores(Dictionary<OsmNode, double> gScoreDict, Image? renderOver = null,
|
||||
Bounds? bounds = null)
|
||||
{
|
||||
float minLat = bounds?.minLat ?? gScoreDict.Min(kv => kv.Key.coordinates.latitude);
|
||||
float minLon = bounds?.minLon ?? gScoreDict.Min(kv => kv.Key.coordinates.longitude);
|
||||
float maxLat = bounds?.maxLat ?? gScoreDict.Max(kv => kv.Key.coordinates.latitude);
|
||||
float maxLon = bounds?.maxLon ?? gScoreDict.Max(kv => kv.Key.coordinates.longitude);
|
||||
float minLat = bounds?.minLat ?? nodes.Min(node => node.coordinates.latitude);
|
||||
float minLon = bounds?.minLon ?? nodes.Min(node => node.coordinates.longitude);
|
||||
float maxLat = bounds?.maxLat ?? nodes.Max(node => node.coordinates.latitude);
|
||||
float maxLon = bounds?.maxLon ?? nodes.Max(node => node.coordinates.longitude);
|
||||
|
||||
double minWeight = gScoreDict.Min(kv => kv.Value);
|
||||
double maxWeight = gScoreDict.Max(kv => kv.Value);
|
||||
@ -153,25 +108,52 @@ public static class Renderer
|
||||
int pixelsX = (int)(lonDiff * scaleFactor);
|
||||
int pixelsY = (int)(latDiff * scaleFactor);
|
||||
|
||||
Image ret = renderOver ?? new Bitmap(pixelsX, pixelsY, PixelFormat.Format32bppRgb);
|
||||
Graphics g = Graphics.FromImage(ret);
|
||||
if(renderOver is null)
|
||||
Image ret;
|
||||
Graphics g;
|
||||
if (renderOver is null)
|
||||
{
|
||||
ret = new Bitmap(pixelsX, pixelsY, PixelFormat.Format32bppRgb);
|
||||
g = Graphics.FromImage(ret);
|
||||
g.Clear(Color.White);
|
||||
}
|
||||
else
|
||||
{
|
||||
ret = renderOver;
|
||||
g = Graphics.FromImage(ret);
|
||||
}
|
||||
|
||||
foreach (KeyValuePair<OsmNode, double> kv in gScoreDict)
|
||||
Color start = Color.FromArgb(0, 0, 255);
|
||||
Color center = Color.FromArgb(255, 255, 0);
|
||||
Color end = Color.FromArgb(0, 255, 0);
|
||||
|
||||
foreach (KeyValuePair<ulong, double> kv in gScoreDict)
|
||||
{
|
||||
double percentage = (kv.Value - minWeight) / (maxWeight - minWeight);
|
||||
Brush b = new SolidBrush(GradientPick(percentage, WeightStartColor, WeightCenterColor, WeightEndColor));
|
||||
Brush b = new SolidBrush(GradientPick(percentage, start, center, end));
|
||||
OsmNode node = nodes.First(node => node.nodeId.Equals(kv.Key));
|
||||
|
||||
float x = (kv.Key.coordinates.longitude - minLon) * scaleFactor;
|
||||
float y = (maxLat - kv.Key.coordinates.latitude) * scaleFactor;
|
||||
float x = (node.coordinates.longitude - minLon) * scaleFactor;
|
||||
float y = (maxLat - node.coordinates.latitude) * scaleFactor;
|
||||
|
||||
x -= (PenThickness * 1.5f) / 2;
|
||||
y -= (PenThickness * 1.5f) / 2;
|
||||
g.FillEllipse(b, x, y, PenThickness * 1.5f, PenThickness * 1.5f);
|
||||
}
|
||||
|
||||
return new ValueTuple<Image, Bounds>(ret, new Bounds(minLat,minLon,maxLat,maxLon));
|
||||
Pen p = new Pen(Color.Red, PenThickness);
|
||||
|
||||
for (int i = 0; i < pathCoordinates.Count - 1; i++)
|
||||
{
|
||||
Coordinates c1 = pathCoordinates[i];
|
||||
Coordinates c2 = pathCoordinates[i + 1];
|
||||
Point p1 = new(Convert.ToInt32((c1.longitude - minLon) * scaleFactor),
|
||||
Convert.ToInt32((maxLat - c1.latitude) * scaleFactor));
|
||||
Point p2 = new(Convert.ToInt32((c2.longitude - minLon) * scaleFactor),
|
||||
Convert.ToInt32((maxLat - c2.latitude) * scaleFactor));
|
||||
g.DrawLine(p, p1, p2);
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
/*
|
||||
|
@ -1,5 +1,4 @@
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using OSMDatastructure;
|
||||
using OSMDatastructure.Graph;
|
||||
using Pathfinding;
|
||||
@ -12,30 +11,25 @@ public class Server
|
||||
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
ConsoleWriter newConsole = new();
|
||||
ConsoleWriter newConsole = new ConsoleWriter();
|
||||
Console.SetOut(newConsole);
|
||||
Console.SetError(newConsole);
|
||||
|
||||
string workingDir = "D:/stuttgart-regbez-latest";
|
||||
|
||||
//RegionConverter.ConvertXMLToRegions("D:/stuttgart-regbez-latest.osm", "D:/stuttgart-regbez-latest");
|
||||
//RegionConverter.ConvertXMLToRegions("D:/map.osm", "D:/map");
|
||||
//RegionConverter.ConvertXMLToRegions("D:/germany-latest.osm", "D:/germany-latest");
|
||||
|
||||
Coordinates start = new (48.7933798f, 9.8275859f);
|
||||
Coordinates finish = new (48.795918f, 9.021618f);
|
||||
Pathfinder result = new Pathfinder(workingDir).AStar(start,
|
||||
|
||||
Coordinates start = new Coordinates(48.7933798f, 9.8275859f);
|
||||
Coordinates finish = new Coordinates(48.795918f, 9.021618f);
|
||||
PathResult result = Pathfinder.AStar("D:/stuttgart-regbez-latest", start,
|
||||
finish, Tag.SpeedType.car, 0.01, 0.0001,
|
||||
0);
|
||||
|
||||
string parentFolder = new DirectoryInfo(workingDir).Parent!.FullName;
|
||||
string resultFileName = $"{new DirectoryInfo(workingDir).Name}-{DateTime.Now.ToFileTime()}.result";
|
||||
result.SaveResult(Path.Join(parentFolder, resultFileName));
|
||||
Console.WriteLine("Drawing area");
|
||||
ValueTuple<Image, Renderer.Bounds> area = Renderer.DrawArea(result.regionManager);
|
||||
|
||||
string renderFileName = $"{new DirectoryInfo(workingDir).Name}-{DateTime.Now.ToFileTime()}.render.png";
|
||||
Image render = Renderer.DrawPathfinder(result);
|
||||
#pragma warning disable CA1416
|
||||
render.Save(Path.Join(parentFolder, renderFileName), ImageFormat.Png);
|
||||
#pragma warning restore CA1416
|
||||
Console.WriteLine("Drawing route");
|
||||
Renderer.DrawGraph(result.name, area.Item1, area.Item2);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user