chore: save current workspace progress
This commit is contained in:
+117
@@ -0,0 +1,117 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Comparison;
|
||||
|
||||
/// <summary>Immutable raw-baseline or Local G2 report entry.</summary>
|
||||
public sealed class PathSmoothingComparisonEntry
|
||||
{
|
||||
public PathSmoothingComparisonEntry(
|
||||
SmoothingMethod method,
|
||||
PathSmoothingStatus status,
|
||||
PathQualityMetrics metrics,
|
||||
SmoothingTimingSummary timing,
|
||||
string stableGeometryDigest,
|
||||
string diagnostic)
|
||||
: this(method, false, status, metrics, timing, stableGeometryDigest, diagnostic, Empty<SmoothedPathPoint>(), Empty<SmoothedPathSegment>())
|
||||
{
|
||||
}
|
||||
|
||||
private PathSmoothingComparisonEntry(
|
||||
SmoothingMethod? method,
|
||||
bool isRawPathBaseline,
|
||||
PathSmoothingStatus status,
|
||||
PathQualityMetrics metrics,
|
||||
SmoothingTimingSummary timing,
|
||||
string stableGeometryDigest,
|
||||
string diagnostic,
|
||||
IReadOnlyList<SmoothedPathPoint> path,
|
||||
IReadOnlyList<SmoothedPathSegment> segments)
|
||||
{
|
||||
Method = method;
|
||||
IsRawPathBaseline = isRawPathBaseline;
|
||||
Status = status;
|
||||
Metrics = metrics ?? new PathQualityMetrics();
|
||||
Timing = timing ?? new SmoothingTimingSummary(new double[] { 0d, 0d, 0d, 0d, 0d }, false, "No timing result was supplied.");
|
||||
StableGeometryDigest = stableGeometryDigest ?? string.Empty;
|
||||
Diagnostic = diagnostic ?? string.Empty;
|
||||
Path = CopyReadOnly(path);
|
||||
Segments = CopyReadOnly(segments);
|
||||
}
|
||||
|
||||
public SmoothingMethod? Method { get; }
|
||||
|
||||
public bool IsRawPathBaseline { get; }
|
||||
|
||||
public PathSmoothingStatus Status { get; }
|
||||
|
||||
public PathQualityMetrics Metrics { get; }
|
||||
|
||||
public SmoothingTimingSummary Timing { get; }
|
||||
|
||||
public string StableGeometryDigest { get; }
|
||||
|
||||
public string Diagnostic { get; }
|
||||
|
||||
public IReadOnlyList<SmoothedPathPoint> Path { get; }
|
||||
|
||||
public IReadOnlyList<SmoothedPathSegment> Segments { get; }
|
||||
|
||||
public bool IsEligibleForRecommendation =>
|
||||
!IsRawPathBaseline &&
|
||||
IsPublishedLocalG2Status(Status) &&
|
||||
Metrics.IsFeasible &&
|
||||
Timing.IsDeterministic;
|
||||
|
||||
internal static bool IsPublishedLocalG2Status(PathSmoothingStatus status)
|
||||
{
|
||||
return status == PathSmoothingStatus.Complete ||
|
||||
status == PathSmoothingStatus.PartialImprovement ||
|
||||
status == PathSmoothingStatus.NotNeeded ||
|
||||
status == PathSmoothingStatus.Unchanged;
|
||||
}
|
||||
|
||||
internal static PathSmoothingComparisonEntry CreateCandidate(
|
||||
SmoothingMethod method,
|
||||
PathSmoothingStatus status,
|
||||
PathQualityMetrics metrics,
|
||||
SmoothingTimingSummary timing,
|
||||
string stableGeometryDigest,
|
||||
string diagnostic,
|
||||
IReadOnlyList<SmoothedPathPoint> path,
|
||||
IReadOnlyList<SmoothedPathSegment> segments)
|
||||
{
|
||||
return new PathSmoothingComparisonEntry(
|
||||
method, false, status, metrics, timing, stableGeometryDigest, diagnostic, path, segments);
|
||||
}
|
||||
|
||||
internal static PathSmoothingComparisonEntry CreateRawPathBaseline(
|
||||
PathSmoothingStatus status,
|
||||
PathQualityMetrics metrics,
|
||||
string stableGeometryDigest,
|
||||
string diagnostic,
|
||||
IReadOnlyList<SmoothedPathPoint> path,
|
||||
IReadOnlyList<SmoothedPathSegment> segments)
|
||||
{
|
||||
return new PathSmoothingComparisonEntry(
|
||||
null, true, status, metrics,
|
||||
new SmoothingTimingSummary(new double[] { 0d, 0d, 0d, 0d, 0d }, true, string.Empty),
|
||||
stableGeometryDigest, diagnostic, path, segments);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<T> Empty<T>()
|
||||
{
|
||||
return new ReadOnlyCollection<T>(new List<T>());
|
||||
}
|
||||
|
||||
private static IReadOnlyList<T> CopyReadOnly<T>(IReadOnlyList<T> source)
|
||||
{
|
||||
var copy = new List<T>(source == null ? 0 : source.Count);
|
||||
if (source != null)
|
||||
{
|
||||
for (int index = 0; index < source.Count; index++) copy.Add(source[index]);
|
||||
}
|
||||
return new ReadOnlyCollection<T>(copy);
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Comparison;
|
||||
|
||||
/// <summary>Immutable request for comparing a coarse-path baseline with Local G2 output.</summary>
|
||||
public sealed class PathSmoothingComparisonRequest
|
||||
{
|
||||
private static readonly IReadOnlyList<SmoothingMethod> LocalG2OnlyMethods =
|
||||
new ReadOnlyCollection<SmoothingMethod>(new[] { SmoothingMethod.LocalG2Quintic });
|
||||
|
||||
public PathSmoothingComparisonRequest(PathSmoothingRequest smoothingRequest)
|
||||
{
|
||||
SmoothingRequest = CopyRequest(smoothingRequest);
|
||||
Methods = LocalG2OnlyMethods;
|
||||
}
|
||||
|
||||
public PathSmoothingRequest SmoothingRequest { get; }
|
||||
|
||||
/// <summary>The report always contains the sole supported method, Local G2 quintic.</summary>
|
||||
public IReadOnlyList<SmoothingMethod> Methods { get; }
|
||||
|
||||
private static PathSmoothingRequest CopyRequest(PathSmoothingRequest source)
|
||||
{
|
||||
if (source == null) return null;
|
||||
return new PathSmoothingRequest(
|
||||
source.CoarsePath,
|
||||
source.Segments,
|
||||
source.Map,
|
||||
source.Vehicle,
|
||||
source.Configuration);
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Comparison;
|
||||
|
||||
/// <summary>Immutable raw-path and Local G2 comparison result.</summary>
|
||||
public sealed class PathSmoothingComparisonResult
|
||||
{
|
||||
internal PathSmoothingComparisonResult(
|
||||
PathSmoothingComparisonEntry rawPathBaseline,
|
||||
IReadOnlyList<PathSmoothingComparisonEntry> entries,
|
||||
SmoothingMethod? recommendedMethod,
|
||||
bool isCancelled,
|
||||
string diagnostic)
|
||||
{
|
||||
RawPathBaseline = rawPathBaseline ?? throw new ArgumentNullException(nameof(rawPathBaseline));
|
||||
Entries = CopyReadOnly(entries);
|
||||
RecommendedMethod = recommendedMethod;
|
||||
IsCancelled = isCancelled;
|
||||
Diagnostic = diagnostic ?? string.Empty;
|
||||
}
|
||||
|
||||
public PathSmoothingComparisonEntry RawPathBaseline { get; }
|
||||
|
||||
public IReadOnlyList<PathSmoothingComparisonEntry> Entries { get; }
|
||||
|
||||
/// <summary>Local G2 when its published result is deterministic; otherwise null.</summary>
|
||||
public SmoothingMethod? RecommendedMethod { get; }
|
||||
|
||||
public bool IsCancelled { get; }
|
||||
|
||||
public string Diagnostic { get; }
|
||||
|
||||
private static IReadOnlyList<PathSmoothingComparisonEntry> CopyReadOnly(
|
||||
IReadOnlyList<PathSmoothingComparisonEntry> source)
|
||||
{
|
||||
var copy = new List<PathSmoothingComparisonEntry>(source == null ? 0 : source.Count);
|
||||
if (source != null)
|
||||
{
|
||||
for (int index = 0; index < source.Count; index++) copy.Add(source[index]);
|
||||
}
|
||||
return new ReadOnlyCollection<PathSmoothingComparisonEntry>(copy);
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Comparison;
|
||||
|
||||
/// <summary>一个方法的固定五次计时样本和确定性结论。</summary>
|
||||
public sealed class SmoothingTimingSummary
|
||||
{
|
||||
/// <summary>创建一个只接受五个已测量样本的计时汇总。</summary>
|
||||
public SmoothingTimingSummary(
|
||||
IReadOnlyList<double> measuredElapsedMilliseconds,
|
||||
bool isDeterministic,
|
||||
string diagnostic)
|
||||
{
|
||||
if (measuredElapsedMilliseconds == null || measuredElapsedMilliseconds.Count != 5)
|
||||
throw new ArgumentException("计时汇总必须包含五个已测量样本。", nameof(measuredElapsedMilliseconds));
|
||||
|
||||
var copy = new List<double>(measuredElapsedMilliseconds.Count);
|
||||
for (int index = 0; index < measuredElapsedMilliseconds.Count; index++)
|
||||
{
|
||||
double value = measuredElapsedMilliseconds[index];
|
||||
if (double.IsNaN(value) || double.IsInfinity(value) || value < 0d)
|
||||
throw new ArgumentOutOfRangeException(nameof(measuredElapsedMilliseconds), "计时样本必须为有限非负数。");
|
||||
copy.Add(value);
|
||||
}
|
||||
|
||||
MeasuredElapsedMilliseconds = new ReadOnlyCollection<double>(copy);
|
||||
MedianElapsedMilliseconds = Median(copy);
|
||||
IsDeterministic = isDeterministic;
|
||||
Diagnostic = diagnostic ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>不含预热执行的五个实测耗时,单位 ms。</summary>
|
||||
public IReadOnlyList<double> MeasuredElapsedMilliseconds { get; }
|
||||
|
||||
/// <summary>五个实测耗时的稳定中位数,单位 ms。</summary>
|
||||
public double MedianElapsedMilliseconds { get; }
|
||||
|
||||
/// <summary>五次输出是否具有相同的状态和稳定几何摘要。</summary>
|
||||
public bool IsDeterministic { get; }
|
||||
|
||||
/// <summary>非确定性或测量失败的稳定诊断说明。</summary>
|
||||
public string Diagnostic { get; }
|
||||
|
||||
/// <summary>从五次测量结果中生成计时汇总,并拒绝状态或几何不稳定的输出。</summary>
|
||||
public static SmoothingTimingSummary FromMeasurements(
|
||||
IReadOnlyList<double> measuredElapsedMilliseconds,
|
||||
IReadOnlyList<PathSmoothingResult> measuredResults)
|
||||
{
|
||||
if (measuredResults == null || measuredResults.Count != 5)
|
||||
throw new ArgumentException("确定性检查必须包含五个测量结果。", nameof(measuredResults));
|
||||
for (int index = 0; index < measuredResults.Count; index++)
|
||||
{
|
||||
if (measuredResults[index] == null)
|
||||
throw new ArgumentException("确定性检查不能包含空测量结果。", nameof(measuredResults));
|
||||
}
|
||||
|
||||
PathSmoothingResult canonical = measuredResults[0];
|
||||
string canonicalDigest = StableGeometryDigest.Compute(canonical);
|
||||
for (int index = 1; index < measuredResults.Count; index++)
|
||||
{
|
||||
PathSmoothingResult measured = measuredResults[index];
|
||||
string digest = StableGeometryDigest.Compute(measured);
|
||||
if (measured.Status != canonical.Status ||
|
||||
measured.Path.Count != canonical.Path.Count ||
|
||||
measured.Segments.Count != canonical.Segments.Count ||
|
||||
!string.Equals(digest, canonicalDigest, StringComparison.Ordinal))
|
||||
{
|
||||
return new SmoothingTimingSummary(
|
||||
measuredElapsedMilliseconds,
|
||||
false,
|
||||
"五次测量的状态、点数、分段数或稳定几何摘要不一致。");
|
||||
}
|
||||
}
|
||||
|
||||
return new SmoothingTimingSummary(measuredElapsedMilliseconds, true, string.Empty);
|
||||
}
|
||||
|
||||
internal static double Median(IReadOnlyList<double> values)
|
||||
{
|
||||
var sorted = new double[values.Count];
|
||||
for (int index = 0; index < values.Count; index++) sorted[index] = values[index];
|
||||
Array.Sort(sorted);
|
||||
int middle = sorted.Length / 2;
|
||||
return sorted.Length % 2 == 1
|
||||
? sorted[middle]
|
||||
: (sorted[middle - 1] + sorted[middle]) / 2d;
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Comparison;
|
||||
|
||||
/// <summary>为重复执行结果生成与进程无关的稳定几何 SHA-256 摘要。</summary>
|
||||
public static class StableGeometryDigest
|
||||
{
|
||||
/// <summary>计算正式平滑结果的状态、方法、分段和路径位模式摘要。</summary>
|
||||
public static string Compute(PathSmoothingResult result)
|
||||
{
|
||||
if (result == null) throw new ArgumentNullException(nameof(result));
|
||||
return Compute(result.Status, result.Method, result.Path, result.Segments);
|
||||
}
|
||||
|
||||
/// <summary>计算任意已分析路径的稳定摘要。</summary>
|
||||
public static string Compute(
|
||||
PathSmoothingStatus status,
|
||||
SmoothingMethod? method,
|
||||
IReadOnlyList<SmoothedPathPoint> path,
|
||||
IReadOnlyList<SmoothedPathSegment> segments)
|
||||
{
|
||||
using (var stream = new MemoryStream())
|
||||
{
|
||||
WriteInt32(stream, 1);
|
||||
WriteInt32(stream, (int)status);
|
||||
WriteBoolean(stream, method.HasValue);
|
||||
if (method.HasValue) WriteInt32(stream, (int)method.Value);
|
||||
|
||||
WriteInt32(stream, path == null ? 0 : path.Count);
|
||||
if (path != null)
|
||||
{
|
||||
for (int index = 0; index < path.Count; index++) WritePoint(stream, path[index]);
|
||||
}
|
||||
|
||||
WriteInt32(stream, segments == null ? 0 : segments.Count);
|
||||
if (segments != null)
|
||||
{
|
||||
for (int index = 0; index < segments.Count; index++) WriteSegment(stream, segments[index]);
|
||||
}
|
||||
|
||||
using (SHA256 sha256 = SHA256.Create())
|
||||
{
|
||||
byte[] hash = sha256.ComputeHash(stream.ToArray());
|
||||
var builder = new StringBuilder(hash.Length * 2);
|
||||
for (int index = 0; index < hash.Length; index++) builder.Append(hash[index].ToString("x2"));
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void WritePoint(Stream stream, SmoothedPathPoint point)
|
||||
{
|
||||
if (point == null) throw new ArgumentException("稳定路径摘要不能包含空点。", nameof(point));
|
||||
WriteDouble(stream, point.X);
|
||||
WriteDouble(stream, point.Y);
|
||||
WriteDouble(stream, point.Heading);
|
||||
WriteDouble(stream, point.UnwrappedHeading);
|
||||
WriteDouble(stream, point.ArcLength);
|
||||
WriteInt32(stream, (int)point.Direction);
|
||||
WriteDouble(stream, point.GeometricCurvature);
|
||||
WriteDouble(stream, point.VehicleCurvature);
|
||||
WriteDouble(stream, point.BodyClearance);
|
||||
WriteBoolean(stream, point.IsGearSwitchPoint);
|
||||
WriteInt32(stream, (int)point.Source);
|
||||
}
|
||||
|
||||
private static void WriteSegment(Stream stream, SmoothedPathSegment segment)
|
||||
{
|
||||
if (segment == null) throw new ArgumentException("稳定路径摘要不能包含空方向段。", nameof(segment));
|
||||
WriteInt32(stream, segment.SegmentIndex);
|
||||
WriteInt32(stream, (int)segment.Direction);
|
||||
WriteInt32(stream, segment.StartIndex);
|
||||
WriteInt32(stream, segment.EndIndex);
|
||||
WriteBoolean(stream, segment.StartsAtGearSwitch);
|
||||
WriteBoolean(stream, segment.EndsAtGearSwitch);
|
||||
}
|
||||
|
||||
private static void WriteDouble(Stream stream, double value)
|
||||
{
|
||||
WriteInt64(stream, BitConverter.DoubleToInt64Bits(value));
|
||||
}
|
||||
|
||||
private static void WriteBoolean(Stream stream, bool value)
|
||||
{
|
||||
stream.WriteByte(value ? (byte)1 : (byte)0);
|
||||
}
|
||||
|
||||
private static void WriteInt32(Stream stream, int value)
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
stream.WriteByte((byte)value);
|
||||
stream.WriteByte((byte)(value >> 8));
|
||||
stream.WriteByte((byte)(value >> 16));
|
||||
stream.WriteByte((byte)(value >> 24));
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteInt64(Stream stream, long value)
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
ulong bits = (ulong)value;
|
||||
for (int index = 0; index < 8; index++) stream.WriteByte((byte)(bits >> (index * 8)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Visualization;
|
||||
|
||||
/// <summary>Fixed dimensions and colors for Local G2 comparison reports.</summary>
|
||||
public static class IeeeFigureStyle
|
||||
{
|
||||
public const double FigureWidthPoints = 7.16d * 72d;
|
||||
public const double FigureHeightPoints = 5.20d * 72d;
|
||||
public const string RawColor = "#4D4D4D";
|
||||
public const string LocalG2Color = "#0072B2";
|
||||
public const string LocalG2DiagnosticColor = "#B1373E";
|
||||
public const string LimitColor = "#CC79A7";
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Visualization;
|
||||
|
||||
/// <summary>把共享图形模型的指标表写为带 BOM 的 UTF-8 CSV。</summary>
|
||||
public sealed class SmoothingCsvWriter
|
||||
{
|
||||
private const string Header = "ScenarioId,Method,Status,PathLengthMeters,MaximumAbsoluteVehicleCurvaturePerMeter,RootMeanSquareVehicleCurvaturePerMeter,TotalAbsoluteCurvatureVariationPerMeter,CurvatureVariationEnergy,MinimumBodyClearanceMeters,MedianElapsedMilliseconds,TimingSampleCount,IsDeterministic";
|
||||
|
||||
public byte[] Write(SmoothingFigureModel model)
|
||||
{
|
||||
if (model == null) throw new ArgumentNullException(nameof(model));
|
||||
var text = new StringBuilder(Header).Append("\r\n");
|
||||
for (int index = 0; index < model.MetricRows.Count; index++)
|
||||
{
|
||||
SmoothingFigureMetricRow row = model.MetricRows[index];
|
||||
PathQualityMetrics metrics = row.Metrics;
|
||||
text.Append(Field(model.ScenarioId)).Append(',').Append(Field(row.Method)).Append(',').Append(Field(row.Status.ToString())).Append(',')
|
||||
.Append(Number(metrics.PathLengthMeters)).Append(',').Append(Number(metrics.MaximumAbsoluteVehicleCurvaturePerMeter)).Append(',')
|
||||
.Append(Number(metrics.RootMeanSquareVehicleCurvaturePerMeter)).Append(',').Append(Number(metrics.TotalAbsoluteCurvatureVariationPerMeter)).Append(',')
|
||||
.Append(Number(metrics.CurvatureVariationEnergy)).Append(',').Append(Number(metrics.MinimumBodyClearanceMeters)).Append(',')
|
||||
.Append(Number(row.Timing == null ? 0d : row.Timing.MedianElapsedMilliseconds)).Append(',')
|
||||
.Append(row.Timing == null ? 0 : row.Timing.MeasuredElapsedMilliseconds.Count).Append(',')
|
||||
.Append(row.Timing != null && row.Timing.IsDeterministic ? "true" : "false").Append("\r\n");
|
||||
}
|
||||
byte[] body = new UTF8Encoding(false).GetBytes(text.ToString());
|
||||
byte[] preamble = new UTF8Encoding(true).GetPreamble();
|
||||
var output = new byte[preamble.Length + body.Length];
|
||||
Buffer.BlockCopy(preamble, 0, output, 0, preamble.Length);
|
||||
Buffer.BlockCopy(body, 0, output, preamble.Length, body.Length);
|
||||
return output;
|
||||
}
|
||||
|
||||
private static string Number(double value) { return value.ToString("0.#################", CultureInfo.InvariantCulture); }
|
||||
private static string Field(string value)
|
||||
{
|
||||
string text = value ?? string.Empty;
|
||||
return text.IndexOfAny(new[] { ',', '\"', '\r', '\n' }) < 0 ? text : "\"" + text.Replace("\"", "\"\"") + "\"";
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Visualization;
|
||||
|
||||
/// <summary>单张 SVG/PNG 共享的不可变绘图视图。</summary>
|
||||
public sealed class SmoothingFigureDefinition
|
||||
{
|
||||
internal SmoothingFigureDefinition(
|
||||
SmoothingFigureKind kind,
|
||||
string fileStem,
|
||||
string title,
|
||||
SmoothingFigureModel model,
|
||||
bool showsMapContext,
|
||||
IReadOnlyList<SmoothingFigureSeriesView> series,
|
||||
double worldXMinMeters,
|
||||
double worldXMaxMeters,
|
||||
double worldYMinMeters,
|
||||
double worldYMaxMeters,
|
||||
IReadOnlyList<double> xTicks,
|
||||
IReadOnlyList<double> yTicks,
|
||||
double curvatureArcLengthMaximumMeters,
|
||||
double curvatureMinimumPerMeter,
|
||||
double curvatureMaximumPerMeter,
|
||||
IReadOnlyList<double> curvatureArcLengthTicks,
|
||||
IReadOnlyList<double> curvatureTicks)
|
||||
{
|
||||
Kind = kind;
|
||||
FileStem = fileStem ?? string.Empty;
|
||||
Title = title ?? string.Empty;
|
||||
Model = model ?? throw new ArgumentNullException(nameof(model));
|
||||
ShowsMapContext = showsMapContext;
|
||||
Series = Copy(series);
|
||||
WorldXMinMeters = worldXMinMeters;
|
||||
WorldXMaxMeters = worldXMaxMeters;
|
||||
WorldYMinMeters = worldYMinMeters;
|
||||
WorldYMaxMeters = worldYMaxMeters;
|
||||
XTicks = Copy(xTicks);
|
||||
YTicks = Copy(yTicks);
|
||||
CurvatureArcLengthMaximumMeters = curvatureArcLengthMaximumMeters;
|
||||
CurvatureMinimumPerMeter = curvatureMinimumPerMeter;
|
||||
CurvatureMaximumPerMeter = curvatureMaximumPerMeter;
|
||||
CurvatureArcLengthTicks = Copy(curvatureArcLengthTicks);
|
||||
CurvatureTicks = Copy(curvatureTicks);
|
||||
}
|
||||
|
||||
public SmoothingFigureKind Kind { get; }
|
||||
public string FileStem { get; }
|
||||
public string Title { get; }
|
||||
public SmoothingFigureModel Model { get; }
|
||||
public bool ShowsMapContext { get; }
|
||||
public bool IsCurvatureFigure => Kind == SmoothingFigureKind.CurvatureComparison;
|
||||
public double FigureWidthPoints => Model.FigureWidthPoints;
|
||||
public double FigureHeightPoints => Model.FigureHeightPoints;
|
||||
public double PlotXPoints => 68d;
|
||||
public double PlotYPoints => 44d;
|
||||
public double PlotWidthPoints => 400d;
|
||||
public double PlotHeightPoints => 245d;
|
||||
public double LegendYPoints => LegendEntries.Count > 4 ? 332d : 340d;
|
||||
public IReadOnlyList<SmoothingFigureSeriesView> Series { get; }
|
||||
public IReadOnlyList<SmoothingFigureLegendEntry> LegendEntries => BuildLegend(Series);
|
||||
public double WorldXMinMeters { get; }
|
||||
public double WorldXMaxMeters { get; }
|
||||
public double WorldYMinMeters { get; }
|
||||
public double WorldYMaxMeters { get; }
|
||||
public double WorldScalePointsPerMeter => Math.Min(PlotWidthPoints / (WorldXMaxMeters - WorldXMinMeters), PlotHeightPoints / (WorldYMaxMeters - WorldYMinMeters));
|
||||
public IReadOnlyList<double> XTicks { get; }
|
||||
public IReadOnlyList<double> YTicks { get; }
|
||||
public double CurvatureArcLengthMaximumMeters { get; }
|
||||
public double CurvatureMinimumPerMeter { get; }
|
||||
public double CurvatureMaximumPerMeter { get; }
|
||||
public IReadOnlyList<double> CurvatureArcLengthTicks { get; }
|
||||
public IReadOnlyList<double> CurvatureTicks { get; }
|
||||
|
||||
private static IReadOnlyList<T> Copy<T>(IReadOnlyList<T> source)
|
||||
{
|
||||
var copy = new List<T>(source == null ? 0 : source.Count);
|
||||
if (source != null) for (int index = 0; index < source.Count; index++) copy.Add(source[index]);
|
||||
return new ReadOnlyCollection<T>(copy);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SmoothingFigureLegendEntry> BuildLegend(IReadOnlyList<SmoothingFigureSeriesView> views)
|
||||
{
|
||||
var entries = new List<SmoothingFigureLegendEntry>(views == null ? 0 : views.Count);
|
||||
if (views != null)
|
||||
{
|
||||
for (int index = 0; index < views.Count; index++)
|
||||
{
|
||||
SmoothingFigureSeries series = views[index].Series;
|
||||
entries.Add(new SmoothingFigureLegendEntry(series.Label + " (" + series.Status + ")", series.Color, string.Empty));
|
||||
}
|
||||
}
|
||||
return new ReadOnlyCollection<SmoothingFigureLegendEntry>(entries);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>某条路径在特定图中的显示透明度。</summary>
|
||||
public sealed class SmoothingFigureSeriesView
|
||||
{
|
||||
internal SmoothingFigureSeriesView(SmoothingFigureSeries series, double opacity, double pointRadiusPoints = 1.35d)
|
||||
{
|
||||
Series = series ?? throw new ArgumentNullException(nameof(series));
|
||||
Opacity = opacity < 0d ? 0d : (opacity > 1d ? 1d : opacity);
|
||||
PointRadiusPoints = pointRadiusPoints > 0d ? pointRadiusPoints : 1.35d;
|
||||
}
|
||||
|
||||
public SmoothingFigureSeries Series { get; }
|
||||
public double Opacity { get; }
|
||||
public double PointRadiusPoints { get; }
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Visualization;
|
||||
|
||||
/// <summary>Stable report figures for the raw-path and Local G2 workflow.</summary>
|
||||
public enum SmoothingFigureKind
|
||||
{
|
||||
CoarsePathOverview,
|
||||
AllPathsComparison,
|
||||
LocalG2Overview,
|
||||
CurvatureComparison,
|
||||
LocalG2DiagnosticCandidate,
|
||||
}
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Comparison;
|
||||
|
||||
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Visualization;
|
||||
|
||||
/// <summary>SVG 和 PNG 共享的不可变路径平滑报告图形模型。</summary>
|
||||
public sealed class SmoothingFigureModel
|
||||
{
|
||||
internal SmoothingFigureModel(
|
||||
string scenarioId,
|
||||
string scenarioLabel,
|
||||
double worldXMinMeters,
|
||||
double worldXMaxMeters,
|
||||
double worldYMinMeters,
|
||||
double worldYMaxMeters,
|
||||
double pathPanelX,
|
||||
double pathPanelY,
|
||||
double pathPanelWidth,
|
||||
double pathPanelHeight,
|
||||
double curvaturePanelX,
|
||||
double curvaturePanelY,
|
||||
double curvaturePanelWidth,
|
||||
double curvaturePanelHeight,
|
||||
double metricsPanelX,
|
||||
double metricsPanelY,
|
||||
double metricsPanelWidth,
|
||||
double metricsPanelHeight,
|
||||
IReadOnlyList<SmoothingFigureObstacle> obstacles,
|
||||
IReadOnlyList<SmoothingFigureSeries> series,
|
||||
IReadOnlyList<SmoothingFigureMetricRow> metricRows,
|
||||
SmoothingFigurePoint start,
|
||||
SmoothingFigurePoint goal)
|
||||
{
|
||||
ScenarioId = scenarioId ?? string.Empty;
|
||||
ScenarioLabel = scenarioLabel ?? string.Empty;
|
||||
WorldXMinMeters = worldXMinMeters;
|
||||
WorldXMaxMeters = worldXMaxMeters;
|
||||
WorldYMinMeters = worldYMinMeters;
|
||||
WorldYMaxMeters = worldYMaxMeters;
|
||||
PathPanelX = pathPanelX;
|
||||
PathPanelY = pathPanelY;
|
||||
PathPanelWidth = pathPanelWidth;
|
||||
PathPanelHeight = pathPanelHeight;
|
||||
CurvaturePanelX = curvaturePanelX;
|
||||
CurvaturePanelY = curvaturePanelY;
|
||||
CurvaturePanelWidth = curvaturePanelWidth;
|
||||
CurvaturePanelHeight = curvaturePanelHeight;
|
||||
MetricsPanelX = metricsPanelX;
|
||||
MetricsPanelY = metricsPanelY;
|
||||
MetricsPanelWidth = metricsPanelWidth;
|
||||
MetricsPanelHeight = metricsPanelHeight;
|
||||
Obstacles = Copy(obstacles);
|
||||
Series = Copy(series);
|
||||
MetricRows = Copy(metricRows);
|
||||
Start = start ?? throw new ArgumentNullException(nameof(start));
|
||||
Goal = goal ?? throw new ArgumentNullException(nameof(goal));
|
||||
}
|
||||
|
||||
public string ScenarioId { get; }
|
||||
public string ScenarioLabel { get; }
|
||||
public double FigureWidthPoints => IeeeFigureStyle.FigureWidthPoints;
|
||||
public double FigureHeightPoints => IeeeFigureStyle.FigureHeightPoints;
|
||||
public string PathPanelLabel => "(a)";
|
||||
public string CurvaturePanelLabel => "(b)";
|
||||
public string MetricsPanelLabel => "(c)";
|
||||
public double WorldXMinMeters { get; }
|
||||
public double WorldXMaxMeters { get; }
|
||||
public double WorldYMinMeters { get; }
|
||||
public double WorldYMaxMeters { get; }
|
||||
public double PathPanelX { get; }
|
||||
public double PathPanelY { get; }
|
||||
public double PathPanelWidth { get; }
|
||||
public double PathPanelHeight { get; }
|
||||
public double CurvaturePanelX { get; }
|
||||
public double CurvaturePanelY { get; }
|
||||
public double CurvaturePanelWidth { get; }
|
||||
public double CurvaturePanelHeight { get; }
|
||||
public double MetricsPanelX { get; }
|
||||
public double MetricsPanelY { get; }
|
||||
public double MetricsPanelWidth { get; }
|
||||
public double MetricsPanelHeight { get; }
|
||||
public double PathScaleX { get; internal set; }
|
||||
public double PathScaleY { get; internal set; }
|
||||
public IReadOnlyList<SmoothingFigureObstacle> Obstacles { get; }
|
||||
public IReadOnlyList<SmoothingFigureSeries> Series { get; }
|
||||
public IReadOnlyList<SmoothingFigureLegendEntry> LegendEntries => BuildLegend(Series);
|
||||
public IReadOnlyList<SmoothingFigureMetricRow> MetricRows { get; }
|
||||
public SmoothingFigurePoint Start { get; }
|
||||
public SmoothingFigurePoint Goal { get; }
|
||||
|
||||
internal SmoothingFigureModel WithAdditionalSeries(SmoothingFigureSeries series)
|
||||
{
|
||||
if (series == null) throw new ArgumentNullException(nameof(series));
|
||||
var combined = new List<SmoothingFigureSeries>(Series.Count + 1);
|
||||
for (int index = 0; index < Series.Count; index++)
|
||||
{
|
||||
if (Series[index].Key == series.Key)
|
||||
throw new ArgumentException("Figure series keys must be unique.", nameof(series));
|
||||
combined.Add(Series[index]);
|
||||
}
|
||||
combined.Add(series);
|
||||
var copy = new SmoothingFigureModel(
|
||||
ScenarioId, ScenarioLabel, WorldXMinMeters, WorldXMaxMeters, WorldYMinMeters, WorldYMaxMeters,
|
||||
PathPanelX, PathPanelY, PathPanelWidth, PathPanelHeight,
|
||||
CurvaturePanelX, CurvaturePanelY, CurvaturePanelWidth, CurvaturePanelHeight,
|
||||
MetricsPanelX, MetricsPanelY, MetricsPanelWidth, MetricsPanelHeight,
|
||||
Obstacles, combined, MetricRows, Start, Goal)
|
||||
{
|
||||
PathScaleX = PathScaleX,
|
||||
PathScaleY = PathScaleY,
|
||||
};
|
||||
return copy;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<T> Copy<T>(IReadOnlyList<T> source)
|
||||
{
|
||||
var copy = new List<T>(source == null ? 0 : source.Count);
|
||||
if (source != null) for (int index = 0; index < source.Count; index++) copy.Add(source[index]);
|
||||
return new ReadOnlyCollection<T>(copy);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SmoothingFigureLegendEntry> BuildLegend(IReadOnlyList<SmoothingFigureSeries> series)
|
||||
{
|
||||
var legend = new List<SmoothingFigureLegendEntry>(series == null ? 0 : series.Count);
|
||||
if (series != null)
|
||||
{
|
||||
for (int index = 0; index < series.Count; index++)
|
||||
legend.Add(new SmoothingFigureLegendEntry(series[index].Label, series[index].Color, series[index].DashArray));
|
||||
}
|
||||
return new ReadOnlyCollection<SmoothingFigureLegendEntry>(legend);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SmoothingFigurePoint
|
||||
{
|
||||
public SmoothingFigurePoint(double xMeters, double yMeters, double arcLengthMeters, double vehicleCurvaturePerMeter)
|
||||
{
|
||||
X = xMeters; Y = yMeters; ArcLength = arcLengthMeters; VehicleCurvature = vehicleCurvaturePerMeter;
|
||||
}
|
||||
public double X { get; }
|
||||
public double Y { get; }
|
||||
public double ArcLength { get; }
|
||||
public double VehicleCurvature { get; }
|
||||
}
|
||||
|
||||
public sealed class SmoothingFigureObstacle
|
||||
{
|
||||
public SmoothingFigureObstacle(double xMeters, double yMeters, double widthMeters, double heightMeters)
|
||||
{
|
||||
X = xMeters; Y = yMeters; Width = widthMeters; Height = heightMeters;
|
||||
}
|
||||
public double X { get; }
|
||||
public double Y { get; }
|
||||
public double Width { get; }
|
||||
public double Height { get; }
|
||||
}
|
||||
|
||||
public sealed class SmoothingFigureSeries
|
||||
{
|
||||
internal SmoothingFigureSeries(SmoothingMethod? method, string key, string label, PathSmoothingStatus status, string color, string dashArray,
|
||||
bool isRawPathBaseline, IReadOnlyList<SmoothingFigurePoint> points, IReadOnlyList<SmoothingFigurePoint> violationMarkers)
|
||||
{
|
||||
Method = method; Key = key ?? string.Empty; Label = label ?? string.Empty; Status = status; Color = color ?? string.Empty;
|
||||
DashArray = dashArray ?? string.Empty; IsRawPathBaseline = isRawPathBaseline; Points = Copy(points); ViolationMarkers = Copy(violationMarkers);
|
||||
}
|
||||
public SmoothingMethod? Method { get; }
|
||||
public string Key { get; }
|
||||
public string Label { get; }
|
||||
public PathSmoothingStatus Status { get; }
|
||||
public string Color { get; }
|
||||
public string DashArray { get; }
|
||||
public bool IsRawPathBaseline { get; }
|
||||
public bool IsCurveVisible => Points.Count > 0;
|
||||
public IReadOnlyList<SmoothingFigurePoint> Points { get; }
|
||||
public IReadOnlyList<SmoothingFigurePoint> ViolationMarkers { get; }
|
||||
private static IReadOnlyList<T> Copy<T>(IReadOnlyList<T> source)
|
||||
{
|
||||
var copy = new List<T>(source == null ? 0 : source.Count);
|
||||
if (source != null) for (int index = 0; index < source.Count; index++) copy.Add(source[index]);
|
||||
return new ReadOnlyCollection<T>(copy);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SmoothingFigureLegendEntry
|
||||
{
|
||||
internal SmoothingFigureLegendEntry(string label, string color, string dashArray) { Label = label ?? string.Empty; Color = color ?? string.Empty; DashArray = dashArray ?? string.Empty; }
|
||||
public string Label { get; }
|
||||
public string Color { get; }
|
||||
public string DashArray { get; }
|
||||
}
|
||||
|
||||
public sealed class SmoothingFigureMetricRow
|
||||
{
|
||||
internal SmoothingFigureMetricRow(string method, string label, PathSmoothingStatus status, PathQualityMetrics metrics,
|
||||
SmoothingTimingSummary timing)
|
||||
{
|
||||
Method = method ?? string.Empty; Label = label ?? string.Empty; Status = status; Metrics = metrics ?? new PathQualityMetrics();
|
||||
Timing = timing;
|
||||
}
|
||||
public string Method { get; }
|
||||
public string Label { get; }
|
||||
public PathSmoothingStatus Status { get; }
|
||||
public PathQualityMetrics Metrics { get; }
|
||||
public SmoothingTimingSummary Timing { get; }
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MultiWheelC.TrajectoryPlanning.CoarsePath;
|
||||
using MultiWheelC.TrajectoryPlanning.Mapping;
|
||||
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Comparison;
|
||||
|
||||
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Visualization;
|
||||
|
||||
/// <summary>Transforms the raw-path and Local G2 results into shared report-figure data.</summary>
|
||||
public sealed class SmoothingFigureModelBuilder
|
||||
{
|
||||
private const double MarginPoints = 18d;
|
||||
private const double PanelGapPoints = 12d;
|
||||
|
||||
public SmoothingFigureModel Build(
|
||||
PathSmoothingComparisonResult comparison,
|
||||
PlanningGridMap map,
|
||||
Pose2D start,
|
||||
Pose2D goal,
|
||||
string scenarioId,
|
||||
string scenarioLabel)
|
||||
{
|
||||
if (comparison == null) throw new ArgumentNullException(nameof(comparison));
|
||||
if (map == null) throw new ArgumentNullException(nameof(map));
|
||||
if (start == null) throw new ArgumentNullException(nameof(start));
|
||||
if (goal == null) throw new ArgumentNullException(nameof(goal));
|
||||
|
||||
double worldXMin = map.Bounds.XMin / 1000d;
|
||||
double worldXMax = map.Bounds.XMax / 1000d;
|
||||
double worldYMin = map.Bounds.YMin / 1000d;
|
||||
double worldYMax = map.Bounds.YMax / 1000d;
|
||||
double innerWidth = IeeeFigureStyle.FigureWidthPoints - 2d * MarginPoints;
|
||||
double innerHeight = IeeeFigureStyle.FigureHeightPoints - 2d * MarginPoints;
|
||||
double pathWidth = innerWidth * 0.60d;
|
||||
double rightWidth = innerWidth - pathWidth - PanelGapPoints;
|
||||
double rightHeight = (innerHeight - PanelGapPoints) / 2d;
|
||||
|
||||
var model = new SmoothingFigureModel(
|
||||
scenarioId, scenarioLabel, worldXMin, worldXMax, worldYMin, worldYMax,
|
||||
MarginPoints, MarginPoints, pathWidth, innerHeight,
|
||||
MarginPoints + pathWidth + PanelGapPoints, MarginPoints, rightWidth, rightHeight,
|
||||
MarginPoints + pathWidth + PanelGapPoints, MarginPoints + rightHeight + PanelGapPoints, rightWidth, rightHeight,
|
||||
BuildObstacles(map), BuildSeries(comparison, map), BuildMetricRows(comparison),
|
||||
new SmoothingFigurePoint(start.X, start.Y, 0d, 0d), new SmoothingFigurePoint(goal.X, goal.Y, 0d, 0d));
|
||||
|
||||
double worldWidth = worldXMax - worldXMin;
|
||||
double worldHeight = worldYMax - worldYMin;
|
||||
if (worldWidth <= 0d || worldHeight <= 0d)
|
||||
throw new ArgumentOutOfRangeException(nameof(map), "Map bounds must be finite and non-degenerate.");
|
||||
double scale = Math.Min(pathWidth / worldWidth, innerHeight / worldHeight);
|
||||
model.PathScaleX = scale;
|
||||
model.PathScaleY = scale;
|
||||
return model;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SmoothingFigureObstacle> BuildObstacles(PlanningGridMap map)
|
||||
{
|
||||
var runs = new List<ObstacleRun>();
|
||||
double resolution = map.ResolutionMeters;
|
||||
double xMin = map.Bounds.XMin / 1000d;
|
||||
double yMin = map.Bounds.YMin / 1000d;
|
||||
for (int row = 0; row < map.Rows; row++)
|
||||
{
|
||||
int runStart = -1;
|
||||
for (int column = 0; column <= map.Cols; column++)
|
||||
{
|
||||
bool occupied = column < map.Cols && map.IsOccupied(row, column);
|
||||
if (occupied && runStart < 0) { runStart = column; continue; }
|
||||
if (!occupied && runStart >= 0)
|
||||
{
|
||||
AddOrExtendRun(runs, xMin + runStart * resolution, yMin + row * resolution,
|
||||
(column - runStart) * resolution, resolution);
|
||||
runStart = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
var obstacles = new List<SmoothingFigureObstacle>(runs.Count);
|
||||
for (int index = 0; index < runs.Count; index++)
|
||||
{
|
||||
ObstacleRun run = runs[index];
|
||||
obstacles.Add(new SmoothingFigureObstacle(run.X, run.Y, run.Width, run.Height));
|
||||
}
|
||||
return obstacles;
|
||||
}
|
||||
|
||||
private static void AddOrExtendRun(IList<ObstacleRun> runs, double x, double y, double width, double height)
|
||||
{
|
||||
for (int index = runs.Count - 1; index >= 0; index--)
|
||||
{
|
||||
ObstacleRun candidate = runs[index];
|
||||
if (NearlyEqual(candidate.X, x) && NearlyEqual(candidate.Width, width) && NearlyEqual(candidate.Y + candidate.Height, y))
|
||||
{
|
||||
candidate.Height += height;
|
||||
return;
|
||||
}
|
||||
}
|
||||
runs.Add(new ObstacleRun(x, y, width, height));
|
||||
}
|
||||
|
||||
private static bool NearlyEqual(double first, double second) => Math.Abs(first - second) < 1e-9d;
|
||||
|
||||
private static IReadOnlyList<SmoothingFigureSeries> BuildSeries(PathSmoothingComparisonResult comparison, PlanningGridMap map)
|
||||
{
|
||||
return new List<SmoothingFigureSeries>(2)
|
||||
{
|
||||
CreateSeries(comparison.RawPathBaseline, null, "raw", "Raw coarse path", IeeeFigureStyle.RawColor, string.Empty, true, map),
|
||||
CreateSeries(FindLocalG2(comparison), SmoothingMethod.LocalG2Quintic, "local-g2", "Local G2", IeeeFigureStyle.LocalG2Color, string.Empty, false, map),
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SmoothingFigureMetricRow> BuildMetricRows(PathSmoothingComparisonResult comparison)
|
||||
{
|
||||
return new List<SmoothingFigureMetricRow>
|
||||
{
|
||||
CreateRow(comparison.RawPathBaseline, "RawPath", "Raw coarse path"),
|
||||
CreateRow(FindLocalG2(comparison), "LocalG2Quintic", "Local G2"),
|
||||
};
|
||||
}
|
||||
|
||||
private static PathSmoothingComparisonEntry FindLocalG2(PathSmoothingComparisonResult comparison)
|
||||
{
|
||||
for (int index = 0; index < comparison.Entries.Count; index++)
|
||||
{
|
||||
PathSmoothingComparisonEntry entry = comparison.Entries[index];
|
||||
if (entry.Method == SmoothingMethod.LocalG2Quintic) return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static SmoothingFigureMetricRow CreateRow(PathSmoothingComparisonEntry entry, string method, string label)
|
||||
{
|
||||
return entry == null
|
||||
? new SmoothingFigureMetricRow(method, label, PathSmoothingStatus.Failed, new PathQualityMetrics(), null)
|
||||
: new SmoothingFigureMetricRow(method, label, entry.Status, entry.Metrics, entry.Timing);
|
||||
}
|
||||
|
||||
private static SmoothingFigureSeries CreateSeries(
|
||||
PathSmoothingComparisonEntry entry,
|
||||
SmoothingMethod? method,
|
||||
string key,
|
||||
string label,
|
||||
string color,
|
||||
string dashArray,
|
||||
bool isRawPathBaseline,
|
||||
PlanningGridMap map)
|
||||
{
|
||||
var points = new List<SmoothingFigurePoint>();
|
||||
var violations = new List<SmoothingFigurePoint>();
|
||||
PathSmoothingStatus status = entry == null ? PathSmoothingStatus.Failed : entry.Status;
|
||||
if (entry != null)
|
||||
{
|
||||
for (int index = 0; index < entry.Path.Count; index++)
|
||||
{
|
||||
SmoothedPathPoint point = entry.Path[index];
|
||||
var figurePoint = new SmoothingFigurePoint(point.X, point.Y, point.ArcLength, point.VehicleCurvature);
|
||||
points.Add(figurePoint);
|
||||
if (status == PathSmoothingStatus.Infeasible && map.IsOccupiedWorld(point.X, point.Y)) violations.Add(figurePoint);
|
||||
}
|
||||
}
|
||||
if (status == PathSmoothingStatus.Infeasible && points.Count > 0 && violations.Count == 0)
|
||||
violations.Add(points[points.Count / 2]);
|
||||
return new SmoothingFigureSeries(method, key, label, status, color, dashArray, isRawPathBaseline, points, violations);
|
||||
}
|
||||
|
||||
private sealed class ObstacleRun
|
||||
{
|
||||
public ObstacleRun(double x, double y, double width, double height)
|
||||
{
|
||||
X = x;
|
||||
Y = y;
|
||||
Width = width;
|
||||
Height = height;
|
||||
}
|
||||
|
||||
public double X { get; }
|
||||
public double Y { get; }
|
||||
public double Width { get; }
|
||||
public double Height { get; set; }
|
||||
}
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Visualization;
|
||||
|
||||
/// <summary>Builds the stable four-figure Local G2 report set and an optional diagnostic figure.</summary>
|
||||
public sealed class SmoothingFigureSetBuilder
|
||||
{
|
||||
private const double MinimumExtentMeters = 0.25d;
|
||||
private const double PaddingFraction = 0.10d;
|
||||
|
||||
public SmoothingFigureSet Build(SmoothingFigureModel model)
|
||||
{
|
||||
if (model == null) throw new ArgumentNullException(nameof(model));
|
||||
SmoothingFigureSeries raw = Find(model, "raw");
|
||||
SmoothingFigureSeries localG2 = Find(model, "local-g2");
|
||||
var figures = new List<SmoothingFigureDefinition>
|
||||
{
|
||||
BuildOverhead(SmoothingFigureKind.CoarsePathOverview, "01-coarse-path-overview", "Raw coarse path", model, true, View(raw, 1d)),
|
||||
BuildOverhead(SmoothingFigureKind.AllPathsComparison, "02-all-paths-comparison", "Raw path and Local G2", model, false, View(raw, 1d), View(localG2, 1d)),
|
||||
BuildOverhead(SmoothingFigureKind.LocalG2Overview, "03-local-g2-overview", "Local G2 smoothing", model, true, View(raw, 0.28d), View(localG2, 1d)),
|
||||
BuildCurvature(model, View(raw, 1d), View(localG2, 1d)),
|
||||
};
|
||||
if (TryFind(model, "local-g2-diagnostic", out SmoothingFigureSeries diagnostic))
|
||||
{
|
||||
figures.Add(BuildOverhead(
|
||||
SmoothingFigureKind.LocalG2DiagnosticCandidate,
|
||||
"05-local-g2-diagnostic-candidate",
|
||||
"Local G2 diagnostic candidate (not published)",
|
||||
model,
|
||||
true,
|
||||
View(raw, 1d, 2.10d),
|
||||
View(diagnostic, 1d)));
|
||||
}
|
||||
return new SmoothingFigureSet(figures);
|
||||
}
|
||||
|
||||
private static SmoothingFigureDefinition BuildOverhead(
|
||||
SmoothingFigureKind kind,
|
||||
string stem,
|
||||
string title,
|
||||
SmoothingFigureModel model,
|
||||
bool mapContext,
|
||||
params SmoothingFigureSeriesView[] series)
|
||||
{
|
||||
Bounds bounds = CalculateWorldBounds(model, mapContext, series);
|
||||
return new SmoothingFigureDefinition(
|
||||
kind, stem, title, model, mapContext, series,
|
||||
bounds.XMin, bounds.XMax, bounds.YMin, bounds.YMax,
|
||||
BuildTicks(bounds.XMin, bounds.XMax), BuildTicks(bounds.YMin, bounds.YMax),
|
||||
1d, -1d, 1d, Array.Empty<double>(), Array.Empty<double>());
|
||||
}
|
||||
|
||||
private static SmoothingFigureDefinition BuildCurvature(
|
||||
SmoothingFigureModel model,
|
||||
params SmoothingFigureSeriesView[] series)
|
||||
{
|
||||
double arcMaximum = 0d;
|
||||
double curvatureMinimum = 0d;
|
||||
double curvatureMaximum = 0d;
|
||||
for (int viewIndex = 0; viewIndex < series.Length; viewIndex++)
|
||||
{
|
||||
IReadOnlyList<SmoothingFigurePoint> points = series[viewIndex].Series.Points;
|
||||
for (int pointIndex = 0; pointIndex < points.Count; pointIndex++)
|
||||
{
|
||||
SmoothingFigurePoint point = points[pointIndex];
|
||||
if (point.ArcLength > arcMaximum) arcMaximum = point.ArcLength;
|
||||
if (point.VehicleCurvature < curvatureMinimum) curvatureMinimum = point.VehicleCurvature;
|
||||
if (point.VehicleCurvature > curvatureMaximum) curvatureMaximum = point.VehicleCurvature;
|
||||
}
|
||||
}
|
||||
arcMaximum = ExpandMaximum(arcMaximum, MinimumExtentMeters);
|
||||
ExpandRange(ref curvatureMinimum, ref curvatureMaximum, MinimumExtentMeters);
|
||||
return new SmoothingFigureDefinition(
|
||||
SmoothingFigureKind.CurvatureComparison,
|
||||
"04-curvature-comparison",
|
||||
"Vehicle-curvature comparison",
|
||||
model,
|
||||
false,
|
||||
series,
|
||||
0d,
|
||||
1d,
|
||||
0d,
|
||||
1d,
|
||||
Array.Empty<double>(),
|
||||
Array.Empty<double>(),
|
||||
arcMaximum,
|
||||
curvatureMinimum,
|
||||
curvatureMaximum,
|
||||
BuildTicks(0d, arcMaximum),
|
||||
BuildTicks(curvatureMinimum, curvatureMaximum));
|
||||
}
|
||||
|
||||
private static Bounds CalculateWorldBounds(
|
||||
SmoothingFigureModel model,
|
||||
bool includesEndpoints,
|
||||
IReadOnlyList<SmoothingFigureSeriesView> series)
|
||||
{
|
||||
bool hasPoint = false;
|
||||
double xMin = 0d;
|
||||
double xMax = 0d;
|
||||
double yMin = 0d;
|
||||
double yMax = 0d;
|
||||
for (int viewIndex = 0; viewIndex < series.Count; viewIndex++)
|
||||
{
|
||||
IReadOnlyList<SmoothingFigurePoint> points = series[viewIndex].Series.Points;
|
||||
for (int pointIndex = 0; pointIndex < points.Count; pointIndex++)
|
||||
Include(points[pointIndex].X, points[pointIndex].Y, ref hasPoint, ref xMin, ref xMax, ref yMin, ref yMax);
|
||||
}
|
||||
if (includesEndpoints)
|
||||
{
|
||||
Include(model.Start.X, model.Start.Y, ref hasPoint, ref xMin, ref xMax, ref yMin, ref yMax);
|
||||
Include(model.Goal.X, model.Goal.Y, ref hasPoint, ref xMin, ref xMax, ref yMin, ref yMax);
|
||||
}
|
||||
if (!hasPoint)
|
||||
{
|
||||
xMin = model.WorldXMinMeters;
|
||||
xMax = model.WorldXMaxMeters;
|
||||
yMin = model.WorldYMinMeters;
|
||||
yMax = model.WorldYMaxMeters;
|
||||
}
|
||||
ExpandRange(ref xMin, ref xMax, MinimumExtentMeters);
|
||||
ExpandRange(ref yMin, ref yMax, MinimumExtentMeters);
|
||||
double xPadding = (xMax - xMin) * PaddingFraction;
|
||||
double yPadding = (yMax - yMin) * PaddingFraction;
|
||||
xMin -= xPadding;
|
||||
xMax += xPadding;
|
||||
yMin -= yPadding;
|
||||
yMax += yPadding;
|
||||
const double desiredAspect = 400d / 245d;
|
||||
double width = xMax - xMin;
|
||||
double height = yMax - yMin;
|
||||
if (width / height < desiredAspect)
|
||||
{
|
||||
double halfWidth = height * desiredAspect / 2d;
|
||||
double center = (xMin + xMax) / 2d;
|
||||
xMin = center - halfWidth;
|
||||
xMax = center + halfWidth;
|
||||
}
|
||||
else
|
||||
{
|
||||
double halfHeight = width / desiredAspect / 2d;
|
||||
double center = (yMin + yMax) / 2d;
|
||||
yMin = center - halfHeight;
|
||||
yMax = center + halfHeight;
|
||||
}
|
||||
return new Bounds(xMin, xMax, yMin, yMax);
|
||||
}
|
||||
|
||||
private static void Include(double x, double y, ref bool hasPoint, ref double xMin, ref double xMax, ref double yMin, ref double yMax)
|
||||
{
|
||||
if (!hasPoint)
|
||||
{
|
||||
hasPoint = true;
|
||||
xMin = x;
|
||||
xMax = x;
|
||||
yMin = y;
|
||||
yMax = y;
|
||||
return;
|
||||
}
|
||||
if (x < xMin) xMin = x;
|
||||
if (x > xMax) xMax = x;
|
||||
if (y < yMin) yMin = y;
|
||||
if (y > yMax) yMax = y;
|
||||
}
|
||||
|
||||
private static void ExpandRange(ref double minimum, ref double maximum, double minimumExtent)
|
||||
{
|
||||
double extent = maximum - minimum;
|
||||
if (extent >= minimumExtent) return;
|
||||
double center = (minimum + maximum) / 2d;
|
||||
minimum = center - minimumExtent / 2d;
|
||||
maximum = center + minimumExtent / 2d;
|
||||
}
|
||||
|
||||
private static double ExpandMaximum(double value, double minimum)
|
||||
{
|
||||
return value < minimum ? minimum : value * (1d + PaddingFraction);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<double> BuildTicks(double minimum, double maximum)
|
||||
{
|
||||
double span = maximum - minimum;
|
||||
if (span <= 0d) return new[] { minimum, maximum };
|
||||
double roughStep = span / 5d;
|
||||
double magnitude = Math.Pow(10d, Math.Floor(Math.Log10(roughStep)));
|
||||
double normalized = roughStep / magnitude;
|
||||
double nice = normalized <= 1d ? 1d : (normalized <= 2d ? 2d : (normalized <= 5d ? 5d : 10d));
|
||||
double step = nice * magnitude;
|
||||
var ticks = new List<double>();
|
||||
double first = Math.Ceiling(minimum / step) * step;
|
||||
for (double value = first; value <= maximum + step * 0.001d; value += step) ticks.Add(value);
|
||||
if (ticks.Count < 2)
|
||||
{
|
||||
ticks.Clear();
|
||||
ticks.Add(minimum);
|
||||
ticks.Add(maximum);
|
||||
}
|
||||
return new ReadOnlyCollection<double>(ticks);
|
||||
}
|
||||
|
||||
private static SmoothingFigureSeries Find(SmoothingFigureModel model, string key)
|
||||
{
|
||||
for (int index = 0; index < model.Series.Count; index++)
|
||||
{
|
||||
if (model.Series[index].Key == key) return model.Series[index];
|
||||
}
|
||||
throw new InvalidOperationException("Figure model is missing path series: " + key);
|
||||
}
|
||||
|
||||
private static bool TryFind(SmoothingFigureModel model, string key, out SmoothingFigureSeries series)
|
||||
{
|
||||
for (int index = 0; index < model.Series.Count; index++)
|
||||
{
|
||||
if (model.Series[index].Key == key)
|
||||
{
|
||||
series = model.Series[index];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
series = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static SmoothingFigureSeriesView View(
|
||||
SmoothingFigureSeries series,
|
||||
double opacity,
|
||||
double pointRadiusPoints = 1.35d)
|
||||
{
|
||||
return new SmoothingFigureSeriesView(series, opacity, pointRadiusPoints);
|
||||
}
|
||||
|
||||
private readonly struct Bounds
|
||||
{
|
||||
public Bounds(double xMin, double xMax, double yMin, double yMax)
|
||||
{
|
||||
XMin = xMin;
|
||||
XMax = xMax;
|
||||
YMin = yMin;
|
||||
YMax = yMax;
|
||||
}
|
||||
|
||||
public double XMin { get; }
|
||||
public double XMax { get; }
|
||||
public double YMin { get; }
|
||||
public double YMax { get; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stable normal figure order; an optional Local G2 diagnostic figure is appended.</summary>
|
||||
public sealed class SmoothingFigureSet
|
||||
{
|
||||
internal SmoothingFigureSet(IReadOnlyList<SmoothingFigureDefinition> figures)
|
||||
{
|
||||
var copy = new List<SmoothingFigureDefinition>(figures == null ? 0 : figures.Count);
|
||||
if (figures != null)
|
||||
{
|
||||
for (int index = 0; index < figures.Count; index++) copy.Add(figures[index]);
|
||||
}
|
||||
Figures = new ReadOnlyCollection<SmoothingFigureDefinition>(copy);
|
||||
}
|
||||
|
||||
public IReadOnlyList<SmoothingFigureDefinition> Figures { get; }
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Text;
|
||||
|
||||
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Visualization;
|
||||
|
||||
/// <summary>按准确族名解析报告所需字体,并提供混合中英文文本的共同基线度量。</summary>
|
||||
public sealed class SmoothingFontResolver
|
||||
{
|
||||
public bool TryResolve(
|
||||
string chineseFamilyName,
|
||||
string latinFamilyName,
|
||||
out SmoothingFontResolution resolution,
|
||||
out string reason)
|
||||
{
|
||||
resolution = null;
|
||||
reason = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(chineseFamilyName) || string.IsNullOrWhiteSpace(latinFamilyName))
|
||||
{
|
||||
reason = "中文和拉丁字体族名均不能为空。";
|
||||
return false;
|
||||
}
|
||||
|
||||
var collection = new InstalledFontCollection();
|
||||
FontFamily chinese = FindExact(collection.Families, chineseFamilyName);
|
||||
FontFamily latin = FindExact(collection.Families, latinFamilyName);
|
||||
if (chinese == null || latin == null)
|
||||
{
|
||||
collection.Dispose();
|
||||
reason = "未安装报告所需的精确字体族:" + (chinese == null ? chineseFamilyName : latinFamilyName) + "。";
|
||||
return false;
|
||||
}
|
||||
|
||||
resolution = new SmoothingFontResolution(collection, chinese, latin, chineseFamilyName, latinFamilyName);
|
||||
return true;
|
||||
}
|
||||
|
||||
public RectangleF MeasureMixedText(SmoothingFontResolution resolution, string text, float points)
|
||||
{
|
||||
if (resolution == null) throw new ArgumentNullException(nameof(resolution));
|
||||
if (string.IsNullOrEmpty(text) || points <= 0f) return RectangleF.Empty;
|
||||
using (var bitmap = new Bitmap(1, 1))
|
||||
using (Graphics graphics = Graphics.FromImage(bitmap))
|
||||
using (var format = (StringFormat)StringFormat.GenericTypographic.Clone())
|
||||
{
|
||||
format.FormatFlags |= StringFormatFlags.MeasureTrailingSpaces;
|
||||
float width = 0f, height = 0f;
|
||||
foreach (TextRun run in SplitRuns(text))
|
||||
{
|
||||
using (Font font = resolution.CreateFont(run.IsChinese ? resolution.ChineseFamily : resolution.LatinFamily, points))
|
||||
{
|
||||
SizeF size = graphics.MeasureString(run.Text, font, PointF.Empty, format);
|
||||
width += size.Width;
|
||||
height = Math.Max(height, size.Height);
|
||||
}
|
||||
}
|
||||
return new RectangleF(0f, 0f, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<TextRun> SplitRuns(string text)
|
||||
{
|
||||
var runs = new List<TextRun>();
|
||||
if (string.IsNullOrEmpty(text)) return runs;
|
||||
int start = 0;
|
||||
bool isChinese = IsChinese(text[0]);
|
||||
for (int index = 1; index < text.Length; index++)
|
||||
{
|
||||
bool currentIsChinese = IsChinese(text[index]);
|
||||
if (currentIsChinese == isChinese) continue;
|
||||
runs.Add(new TextRun(text.Substring(start, index - start), isChinese));
|
||||
start = index;
|
||||
isChinese = currentIsChinese;
|
||||
}
|
||||
runs.Add(new TextRun(text.Substring(start), isChinese));
|
||||
return runs;
|
||||
}
|
||||
|
||||
private static FontFamily FindExact(IReadOnlyList<FontFamily> families, string name)
|
||||
{
|
||||
for (int index = 0; index < families.Count; index++)
|
||||
{
|
||||
FontFamily family = families[index];
|
||||
if (string.Equals(family.Name, name, StringComparison.Ordinal) ||
|
||||
string.Equals(family.GetName(1033), name, StringComparison.Ordinal)) return family;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsChinese(char value)
|
||||
{
|
||||
return (value >= 0x3400 && value <= 0x4dbf) || (value >= 0x4e00 && value <= 0x9fff) ||
|
||||
(value >= 0xf900 && value <= 0xfaff) || value == 0x3002 || value == 0xff0c || value == 0xff1a;
|
||||
}
|
||||
|
||||
internal sealed class TextRun
|
||||
{
|
||||
public TextRun(string text, bool isChinese) { Text = text; IsChinese = isChinese; }
|
||||
public string Text { get; }
|
||||
public bool IsChinese { get; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>一次报告导出所使用的精确字体族;释放时同时释放字体集合。</summary>
|
||||
public sealed class SmoothingFontResolution : IDisposable
|
||||
{
|
||||
private readonly InstalledFontCollection _collection;
|
||||
private readonly string _chineseFamilyName;
|
||||
private readonly string _latinFamilyName;
|
||||
|
||||
internal SmoothingFontResolution(
|
||||
InstalledFontCollection collection,
|
||||
FontFamily chineseFamily,
|
||||
FontFamily latinFamily,
|
||||
string chineseFamilyName,
|
||||
string latinFamilyName)
|
||||
{
|
||||
_collection = collection ?? throw new ArgumentNullException(nameof(collection));
|
||||
ChineseFamily = chineseFamily ?? throw new ArgumentNullException(nameof(chineseFamily));
|
||||
LatinFamily = latinFamily ?? throw new ArgumentNullException(nameof(latinFamily));
|
||||
_chineseFamilyName = chineseFamilyName ?? throw new ArgumentNullException(nameof(chineseFamilyName));
|
||||
_latinFamilyName = latinFamilyName ?? throw new ArgumentNullException(nameof(latinFamilyName));
|
||||
}
|
||||
|
||||
public string ChineseFamilyName => _chineseFamilyName;
|
||||
public string LatinFamilyName => _latinFamilyName;
|
||||
internal FontFamily ChineseFamily { get; }
|
||||
internal FontFamily LatinFamily { get; }
|
||||
|
||||
internal Font CreateFont(FontFamily family, float points)
|
||||
{
|
||||
return new Font(family, points, FontStyle.Regular, GraphicsUnit.Point);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_collection.Dispose();
|
||||
}
|
||||
}
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using MultiWheelC.TrajectoryPlanning.Mapping;
|
||||
|
||||
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Visualization;
|
||||
|
||||
/// <summary>仅 Windows 运行时使用 GDI+ 将每张共享图形定义渲染为 600 dpi PNG;轨迹仅绘制离散点。</summary>
|
||||
public sealed class SmoothingPngRenderer
|
||||
{
|
||||
public const int WidthPixels = 4296;
|
||||
public const int HeightPixels = 3120;
|
||||
public const uint PixelsPerMeter = 23622u;
|
||||
private const float PixelsPerPoint = 600f / 72f;
|
||||
|
||||
public byte[] Render(SmoothingFigureModel model, SmoothingFontResolution fonts)
|
||||
{
|
||||
if (model == null) throw new ArgumentNullException(nameof(model));
|
||||
return Render(new SmoothingFigureSetBuilder().Build(model).Figures[1], fonts);
|
||||
}
|
||||
|
||||
public byte[] Render(SmoothingFigureDefinition figure, SmoothingFontResolution fonts)
|
||||
{
|
||||
if (figure == null) throw new ArgumentNullException(nameof(figure));
|
||||
if (fonts == null) throw new ArgumentNullException(nameof(fonts));
|
||||
using (var bitmap = new Bitmap(WidthPixels, HeightPixels, PixelFormat.Format32bppArgb))
|
||||
{
|
||||
bitmap.SetResolution(600f, 600f);
|
||||
using (Graphics graphics = Graphics.FromImage(bitmap))
|
||||
{
|
||||
graphics.Clear(Color.White);
|
||||
graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
|
||||
graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
|
||||
DrawFigure(graphics, figure, fonts);
|
||||
}
|
||||
return Encode(bitmap);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawFigure(Graphics graphics, SmoothingFigureDefinition figure, SmoothingFontResolution fonts)
|
||||
{
|
||||
DrawMixedText(graphics, fonts, figure.Title + " — " + figure.Model.ScenarioLabel, PointX(figure.PlotXPoints), PointY(12d), 12f, Color.Black);
|
||||
if (figure.IsCurvatureFigure) DrawCurvatureAxes(graphics, figure, fonts); else DrawOverheadAxes(graphics, figure, fonts);
|
||||
GraphicsState state = graphics.Save();
|
||||
graphics.SetClip(new RectangleF(PointX(figure.PlotXPoints), PointY(figure.PlotYPoints), PointX(figure.PlotWidthPoints), PointY(figure.PlotHeightPoints)));
|
||||
if (!figure.IsCurvatureFigure && figure.ShowsMapContext) DrawObstacles(graphics, figure);
|
||||
if (figure.IsCurvatureFigure) DrawCurvaturePoints(graphics, figure); else DrawPathPoints(graphics, figure);
|
||||
if (!figure.IsCurvatureFigure && figure.ShowsMapContext) DrawStartGoal(graphics, figure);
|
||||
graphics.Restore(state);
|
||||
DrawLegend(graphics, figure, fonts);
|
||||
}
|
||||
|
||||
private static void DrawOverheadAxes(Graphics graphics, SmoothingFigureDefinition figure, SmoothingFontResolution fonts)
|
||||
{
|
||||
DrawPlotFrame(graphics, figure);
|
||||
using (var grid = new Pen(Color.FromArgb(230, 230, 230), 0.5f * PixelsPerPoint))
|
||||
{
|
||||
for (int index = 0; index < figure.XTicks.Count; index++)
|
||||
{
|
||||
float x = WorldX(figure, figure.XTicks[index]);
|
||||
graphics.DrawLine(grid, x, PointY(figure.PlotYPoints), x, PointY(figure.PlotYPoints + figure.PlotHeightPoints));
|
||||
DrawMixedText(graphics, fonts, Number(figure.XTicks[index]), x - 10f * PixelsPerPoint, PointY(figure.PlotYPoints + figure.PlotHeightPoints + 5d), 8f, Color.Black);
|
||||
}
|
||||
for (int index = 0; index < figure.YTicks.Count; index++)
|
||||
{
|
||||
float y = WorldY(figure, figure.YTicks[index]);
|
||||
graphics.DrawLine(grid, PointX(figure.PlotXPoints), y, PointX(figure.PlotXPoints + figure.PlotWidthPoints), y);
|
||||
DrawMixedText(graphics, fonts, Number(figure.YTicks[index]), PointX(figure.PlotXPoints - 34d), y - 5f * PixelsPerPoint, 8f, Color.Black);
|
||||
}
|
||||
}
|
||||
DrawMixedText(graphics, fonts, "X (m)", PointX(figure.PlotXPoints + figure.PlotWidthPoints / 2d - 11d), PointY(figure.PlotYPoints + figure.PlotHeightPoints + 20d), 10f, Color.Black);
|
||||
DrawVerticalText(graphics, fonts, "Y (m)", PointX(12d), PointY(figure.PlotYPoints + figure.PlotHeightPoints / 2d + 17d), 10f, Color.Black);
|
||||
}
|
||||
|
||||
private static void DrawCurvatureAxes(Graphics graphics, SmoothingFigureDefinition figure, SmoothingFontResolution fonts)
|
||||
{
|
||||
DrawPlotFrame(graphics, figure);
|
||||
using (var grid = new Pen(Color.FromArgb(230, 230, 230), 0.5f * PixelsPerPoint))
|
||||
{
|
||||
for (int index = 0; index < figure.CurvatureArcLengthTicks.Count; index++)
|
||||
{
|
||||
float x = CurvatureX(figure, figure.CurvatureArcLengthTicks[index]);
|
||||
graphics.DrawLine(grid, x, PointY(figure.PlotYPoints), x, PointY(figure.PlotYPoints + figure.PlotHeightPoints));
|
||||
DrawMixedText(graphics, fonts, Number(figure.CurvatureArcLengthTicks[index]), x - 10f * PixelsPerPoint, PointY(figure.PlotYPoints + figure.PlotHeightPoints + 5d), 8f, Color.Black);
|
||||
}
|
||||
for (int index = 0; index < figure.CurvatureTicks.Count; index++)
|
||||
{
|
||||
float y = CurvatureY(figure, figure.CurvatureTicks[index]);
|
||||
graphics.DrawLine(grid, PointX(figure.PlotXPoints), y, PointX(figure.PlotXPoints + figure.PlotWidthPoints), y);
|
||||
DrawMixedText(graphics, fonts, Number(figure.CurvatureTicks[index]), PointX(figure.PlotXPoints - 34d), y - 5f * PixelsPerPoint, 8f, Color.Black);
|
||||
}
|
||||
}
|
||||
if (figure.CurvatureMinimumPerMeter < 0d && figure.CurvatureMaximumPerMeter > 0d)
|
||||
{
|
||||
float y = CurvatureY(figure, 0d);
|
||||
using (var zero = new Pen(Color.FromArgb(77, 77, 77), 0.65f * PixelsPerPoint))
|
||||
graphics.DrawLine(zero, PointX(figure.PlotXPoints), y, PointX(figure.PlotXPoints + figure.PlotWidthPoints), y);
|
||||
}
|
||||
DrawMixedText(graphics, fonts, "s (m)", PointX(figure.PlotXPoints + figure.PlotWidthPoints / 2d - 10d), PointY(figure.PlotYPoints + figure.PlotHeightPoints + 20d), 10f, Color.Black);
|
||||
DrawVerticalText(graphics, fonts, "κ (m⁻¹)", PointX(12d), PointY(figure.PlotYPoints + figure.PlotHeightPoints / 2d + 22d), 10f, Color.Black);
|
||||
}
|
||||
|
||||
private static void DrawPlotFrame(Graphics graphics, SmoothingFigureDefinition figure)
|
||||
{
|
||||
using (var border = new Pen(Color.Black, 0.75f * PixelsPerPoint))
|
||||
graphics.DrawRectangle(border, PointX(figure.PlotXPoints), PointY(figure.PlotYPoints), PointX(figure.PlotWidthPoints), PointY(figure.PlotHeightPoints));
|
||||
}
|
||||
|
||||
private static void DrawObstacles(Graphics graphics, SmoothingFigureDefinition figure)
|
||||
{
|
||||
using (var fill = new SolidBrush(Color.FromArgb(217, 217, 217)))
|
||||
using (var outline = new Pen(Color.FromArgb(128, 128, 128), 0.35f * PixelsPerPoint))
|
||||
{
|
||||
for (int index = 0; index < figure.Model.Obstacles.Count; index++)
|
||||
{
|
||||
SmoothingFigureObstacle obstacle = figure.Model.Obstacles[index];
|
||||
float x = WorldX(figure, obstacle.X);
|
||||
float y = WorldY(figure, obstacle.Y + obstacle.Height);
|
||||
float width = (float)(obstacle.Width * figure.WorldScalePointsPerMeter * PixelsPerPoint);
|
||||
float height = (float)(obstacle.Height * figure.WorldScalePointsPerMeter * PixelsPerPoint);
|
||||
graphics.FillRectangle(fill, x, y, width, height);
|
||||
graphics.DrawRectangle(outline, x, y, width, height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawPathPoints(Graphics graphics, SmoothingFigureDefinition figure)
|
||||
{
|
||||
for (int viewIndex = 0; viewIndex < figure.Series.Count; viewIndex++)
|
||||
{
|
||||
SmoothingFigureSeriesView view = figure.Series[viewIndex];
|
||||
DrawPoints(graphics, view, point => new PointF(WorldX(figure, point.X), WorldY(figure, point.Y)));
|
||||
DrawViolations(graphics, figure, view);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawCurvaturePoints(Graphics graphics, SmoothingFigureDefinition figure)
|
||||
{
|
||||
for (int viewIndex = 0; viewIndex < figure.Series.Count; viewIndex++)
|
||||
{
|
||||
SmoothingFigureSeriesView view = figure.Series[viewIndex];
|
||||
DrawPoints(graphics, view, point => new PointF(CurvatureX(figure, point.ArcLength), CurvatureY(figure, point.VehicleCurvature)));
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawPoints(Graphics graphics, SmoothingFigureSeriesView view, Func<SmoothingFigurePoint, PointF> transform)
|
||||
{
|
||||
if (!view.Series.IsCurveVisible) return;
|
||||
using (var fill = new SolidBrush(WithOpacity(ColorFromHex(view.Series.Color), view.Opacity)))
|
||||
{
|
||||
float radius = (float)(view.PointRadiusPoints * PixelsPerPoint);
|
||||
for (int index = 0; index < view.Series.Points.Count; index++)
|
||||
{
|
||||
PointF point = transform(view.Series.Points[index]);
|
||||
graphics.FillEllipse(fill, point.X - radius, point.Y - radius, radius * 2f, radius * 2f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawViolations(Graphics graphics, SmoothingFigureDefinition figure, SmoothingFigureSeriesView view)
|
||||
{
|
||||
if (view.Series.ViolationMarkers.Count == 0) return;
|
||||
using (var marker = new Pen(ColorFromHex(view.Series.Color), 1.1f * PixelsPerPoint))
|
||||
{
|
||||
float radius = 3f * PixelsPerPoint;
|
||||
for (int index = 0; index < view.Series.ViolationMarkers.Count; index++)
|
||||
{
|
||||
SmoothingFigurePoint point = view.Series.ViolationMarkers[index];
|
||||
float x = WorldX(figure, point.X), y = WorldY(figure, point.Y);
|
||||
graphics.DrawLine(marker, x - radius, y - radius, x + radius, y + radius);
|
||||
graphics.DrawLine(marker, x - radius, y + radius, x + radius, y - radius);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawStartGoal(Graphics graphics, SmoothingFigureDefinition figure)
|
||||
{
|
||||
float startX = WorldX(figure, figure.Model.Start.X), startY = WorldY(figure, figure.Model.Start.Y), radius = 3.2f * PixelsPerPoint;
|
||||
using (var startBrush = new SolidBrush(Color.FromArgb(240, 228, 66)))
|
||||
using (var border = new Pen(Color.Black, 0.8f * PixelsPerPoint))
|
||||
using (var goalBrush = new SolidBrush(ColorFromHex(IeeeFigureStyle.LimitColor)))
|
||||
{
|
||||
graphics.FillEllipse(startBrush, startX - radius, startY - radius, radius * 2f, radius * 2f);
|
||||
graphics.DrawEllipse(border, startX - radius, startY - radius, radius * 2f, radius * 2f);
|
||||
float goalX = WorldX(figure, figure.Model.Goal.X), goalY = WorldY(figure, figure.Model.Goal.Y), diamond = 4f * PixelsPerPoint;
|
||||
PointF[] points = { new PointF(goalX, goalY - diamond), new PointF(goalX + diamond, goalY), new PointF(goalX, goalY + diamond), new PointF(goalX - diamond, goalY) };
|
||||
graphics.FillPolygon(goalBrush, points);
|
||||
graphics.DrawPolygon(border, points);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawLegend(Graphics graphics, SmoothingFigureDefinition figure, SmoothingFontResolution fonts)
|
||||
{
|
||||
const double columnWidth = 198d;
|
||||
for (int index = 0; index < figure.LegendEntries.Count; index++)
|
||||
{
|
||||
SmoothingFigureLegendEntry entry = figure.LegendEntries[index];
|
||||
int column = index % 2, row = index / 2;
|
||||
float x = PointX(figure.PlotXPoints + column * columnWidth);
|
||||
float y = PointY(figure.LegendYPoints + row * 15d - 3d);
|
||||
using (var fill = new SolidBrush(ColorFromHex(entry.Color))) graphics.FillEllipse(fill, x, y - 2.2f * PixelsPerPoint, 4.4f * PixelsPerPoint, 4.4f * PixelsPerPoint);
|
||||
DrawMixedText(graphics, fonts, entry.Label, x + 10f * PixelsPerPoint, y - 5f * PixelsPerPoint, 8.5f, Color.Black);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawVerticalText(Graphics graphics, SmoothingFontResolution fonts, string text, float x, float y, float points, Color color)
|
||||
{
|
||||
GraphicsState state = graphics.Save();
|
||||
graphics.TranslateTransform(x, y);
|
||||
graphics.RotateTransform(-90f);
|
||||
DrawMixedText(graphics, fonts, text, 0f, 0f, points, color);
|
||||
graphics.Restore(state);
|
||||
}
|
||||
|
||||
private static void DrawMixedText(Graphics graphics, SmoothingFontResolution fonts, string text, float x, float top, float points, Color color)
|
||||
{
|
||||
var runs = SmoothingFontResolver.SplitRuns(text);
|
||||
float emPixels = points * PixelsPerPoint, maxAscent = 0f;
|
||||
for (int index = 0; index < runs.Count; index++)
|
||||
{
|
||||
FontFamily family = runs[index].IsChinese ? fonts.ChineseFamily : fonts.LatinFamily;
|
||||
maxAscent = Math.Max(maxAscent, family.GetCellAscent(FontStyle.Regular) * emPixels / family.GetEmHeight(FontStyle.Regular));
|
||||
}
|
||||
float baseline = top + maxAscent;
|
||||
using (var brush = new SolidBrush(color))
|
||||
using (var format = (StringFormat)StringFormat.GenericTypographic.Clone())
|
||||
{
|
||||
format.FormatFlags |= StringFormatFlags.MeasureTrailingSpaces;
|
||||
for (int index = 0; index < runs.Count; index++)
|
||||
{
|
||||
FontFamily family = runs[index].IsChinese ? fonts.ChineseFamily : fonts.LatinFamily;
|
||||
using (Font font = fonts.CreateFont(family, points))
|
||||
{
|
||||
float runTop = baseline - family.GetCellAscent(FontStyle.Regular) * emPixels / family.GetEmHeight(FontStyle.Regular);
|
||||
graphics.DrawString(runs[index].Text, font, brush, x, runTop, format);
|
||||
x += graphics.MeasureString(runs[index].Text, font, PointF.Empty, format).Width;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] Encode(Bitmap bitmap)
|
||||
{
|
||||
Rectangle rectangle = new Rectangle(0, 0, bitmap.Width, bitmap.Height);
|
||||
BitmapData data = bitmap.LockBits(rectangle, ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
|
||||
try
|
||||
{
|
||||
int sourceLength = checked(data.Stride * bitmap.Height);
|
||||
var source = new byte[sourceLength];
|
||||
Marshal.Copy(data.Scan0, source, 0, source.Length);
|
||||
var rgba = new byte[checked(bitmap.Width * bitmap.Height * 4)];
|
||||
for (int y = 0; y < bitmap.Height; y++)
|
||||
{
|
||||
int sourceRow = y * data.Stride, outputRow = y * bitmap.Width * 4;
|
||||
for (int x = 0; x < bitmap.Width; x++)
|
||||
{
|
||||
int sourceOffset = sourceRow + x * 4, outputOffset = outputRow + x * 4;
|
||||
rgba[outputOffset] = source[sourceOffset + 2];
|
||||
rgba[outputOffset + 1] = source[sourceOffset + 1];
|
||||
rgba[outputOffset + 2] = source[sourceOffset];
|
||||
rgba[outputOffset + 3] = source[sourceOffset + 3];
|
||||
}
|
||||
}
|
||||
using (var output = new MemoryStream())
|
||||
{
|
||||
ValidatedPngWriter.Write(rgba, bitmap.Width, bitmap.Height, output, PixelsPerMeter);
|
||||
return output.ToArray();
|
||||
}
|
||||
}
|
||||
finally { bitmap.UnlockBits(data); }
|
||||
}
|
||||
|
||||
private static Color ColorFromHex(string value) { return ColorTranslator.FromHtml(value); }
|
||||
private static Color WithOpacity(Color color, double opacity) { return Color.FromArgb((int)Math.Round(255d * opacity), color.R, color.G, color.B); }
|
||||
private static float PointX(double points) { return (float)(points * PixelsPerPoint); }
|
||||
private static float PointY(double points) { return (float)(points * PixelsPerPoint); }
|
||||
private static float WorldX(SmoothingFigureDefinition figure, double x) { return PointX(figure.PlotXPoints + (x - figure.WorldXMinMeters) * figure.WorldScalePointsPerMeter); }
|
||||
private static float WorldY(SmoothingFigureDefinition figure, double y) { return PointY(figure.PlotYPoints + figure.PlotHeightPoints - (y - figure.WorldYMinMeters) * figure.WorldScalePointsPerMeter); }
|
||||
private static float CurvatureX(SmoothingFigureDefinition figure, double arcLength) { return PointX(figure.PlotXPoints + arcLength / figure.CurvatureArcLengthMaximumMeters * figure.PlotWidthPoints); }
|
||||
private static float CurvatureY(SmoothingFigureDefinition figure, double curvature) { return PointY(figure.PlotYPoints + figure.PlotHeightPoints - (curvature - figure.CurvatureMinimumPerMeter) / (figure.CurvatureMaximumPerMeter - figure.CurvatureMinimumPerMeter) * figure.PlotHeightPoints); }
|
||||
private static string Number(double value) { return value.ToString("0.###", CultureInfo.InvariantCulture); }
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Visualization;
|
||||
|
||||
/// <summary>一次报告导出的共享图形模型、目标目录与精确字体要求。</summary>
|
||||
public sealed class SmoothingReportExportRequest
|
||||
{
|
||||
public SmoothingFigureModel Model { get; set; }
|
||||
public string OutputDirectory { get; set; }
|
||||
public string FileStem { get; set; }
|
||||
public string ChineseFontFamilyName { get; set; } = "SimSun";
|
||||
public string LatinFontFamilyName { get; set; } = "Times New Roman";
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Visualization;
|
||||
|
||||
/// <summary>报告导出结果的稳定状态。</summary>
|
||||
public enum SmoothingReportExportStatus
|
||||
{
|
||||
Success,
|
||||
InvalidInput,
|
||||
FontUnavailable,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// <summary>六张常规 SVG、六张常规 PNG 与一个 CSV 的发布结果;增强模型携带诊断序列时追加可选 07-local-g2-diagnostic-candidate,失败时不返回部分输出路径。</summary>
|
||||
public sealed class SmoothingReportExportResult
|
||||
{
|
||||
internal SmoothingReportExportResult(SmoothingReportExportStatus status, string reason, IReadOnlyList<string> svgPaths, IReadOnlyList<string> pngPaths, string csvPath)
|
||||
{
|
||||
Status = status;
|
||||
Reason = reason ?? string.Empty;
|
||||
SvgPaths = Copy(svgPaths);
|
||||
PngPaths = Copy(pngPaths);
|
||||
CsvPath = csvPath ?? string.Empty;
|
||||
}
|
||||
|
||||
public SmoothingReportExportStatus Status { get; }
|
||||
public string Reason { get; }
|
||||
public IReadOnlyList<string> SvgPaths { get; }
|
||||
public IReadOnlyList<string> PngPaths { get; }
|
||||
public string CsvPath { get; }
|
||||
|
||||
internal static SmoothingReportExportResult Success(IReadOnlyList<string> svgPaths, IReadOnlyList<string> pngPaths, string csvPath)
|
||||
{
|
||||
return new SmoothingReportExportResult(SmoothingReportExportStatus.Success, string.Empty, svgPaths, pngPaths, csvPath);
|
||||
}
|
||||
|
||||
internal static SmoothingReportExportResult Failure(SmoothingReportExportStatus status, string reason)
|
||||
{
|
||||
return new SmoothingReportExportResult(status, reason, new string[0], new string[0], string.Empty);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> Copy(IReadOnlyList<string> source)
|
||||
{
|
||||
var copy = new List<string>(source == null ? 0 : source.Count);
|
||||
if (source != null) for (int index = 0; index < source.Count; index++) copy.Add(source[index] ?? string.Empty);
|
||||
return new ReadOnlyCollection<string>(copy);
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Visualization;
|
||||
|
||||
/// <summary>以同级临时文件生成六张常规 SVG、六张常规 PNG 和 CSV 后成组发布;增强模型携带诊断序列时追加可选 07-local-g2-diagnostic-candidate。</summary>
|
||||
public sealed class SmoothingReportExporter
|
||||
{
|
||||
private readonly SmoothingFontResolver _fontResolver = new SmoothingFontResolver();
|
||||
private readonly SmoothingFigureSetBuilder _figureSetBuilder = new SmoothingFigureSetBuilder();
|
||||
private readonly SmoothingSvgRenderer _svgRenderer = new SmoothingSvgRenderer();
|
||||
private readonly SmoothingPngRenderer _pngRenderer = new SmoothingPngRenderer();
|
||||
private readonly SmoothingCsvWriter _csvWriter = new SmoothingCsvWriter();
|
||||
|
||||
public SmoothingReportExportResult Export(SmoothingReportExportRequest request)
|
||||
{
|
||||
if (request == null || request.Model == null || string.IsNullOrWhiteSpace(request.OutputDirectory) || !IsFileStem(request.FileStem))
|
||||
return SmoothingReportExportResult.Failure(SmoothingReportExportStatus.InvalidInput, "报告模型、输出目录或文件名无效。");
|
||||
if (!_fontResolver.TryResolve(request.ChineseFontFamilyName, request.LatinFontFamilyName, out SmoothingFontResolution availableFonts, out string fontReason))
|
||||
return SmoothingReportExportResult.Failure(SmoothingReportExportStatus.FontUnavailable, fontReason);
|
||||
availableFonts.Dispose();
|
||||
|
||||
var pending = new List<PendingFile>();
|
||||
var svgPaths = new List<string>();
|
||||
var pngPaths = new List<string>();
|
||||
string csvPath = Path.Combine(request.OutputDirectory, request.FileStem + ".csv");
|
||||
try
|
||||
{
|
||||
SmoothingFigureSet figures = _figureSetBuilder.Build(request.Model);
|
||||
for (int index = 0; index < figures.Figures.Count; index++)
|
||||
{
|
||||
SmoothingFigureDefinition figure = figures.Figures[index];
|
||||
string svgPath = Path.Combine(request.OutputDirectory, figure.FileStem + ".svg");
|
||||
string pngPath = Path.Combine(request.OutputDirectory, figure.FileStem + ".png");
|
||||
svgPaths.Add(svgPath);
|
||||
pngPaths.Add(pngPath);
|
||||
pending.Add(new PendingFile(svgPath, Encoding.UTF8.GetBytes(_svgRenderer.Render(figure))));
|
||||
if (!_fontResolver.TryResolve(request.ChineseFontFamilyName, request.LatinFontFamilyName, out SmoothingFontResolution renderFonts, out fontReason))
|
||||
throw new InvalidOperationException(fontReason);
|
||||
byte[] png;
|
||||
using (renderFonts) png = _pngRenderer.Render(figure, renderFonts);
|
||||
pending.Add(new PendingFile(pngPath, png));
|
||||
}
|
||||
pending.Add(new PendingFile(csvPath, _csvWriter.Write(request.Model)));
|
||||
Directory.CreateDirectory(request.OutputDirectory);
|
||||
for (int index = 0; index < pending.Count; index++) File.WriteAllBytes(pending[index].TemporaryPath, pending[index].Content);
|
||||
PublishAll(pending);
|
||||
DeleteIfExists(Path.Combine(request.OutputDirectory, "comparison.svg"));
|
||||
DeleteIfExists(Path.Combine(request.OutputDirectory, "comparison.png"));
|
||||
return SmoothingReportExportResult.Success(svgPaths, pngPaths, csvPath);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
RestorePublishedFiles(pending);
|
||||
return SmoothingReportExportResult.Failure(SmoothingReportExportStatus.Failed, exception.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
for (int index = 0; index < pending.Count; index++)
|
||||
{
|
||||
DeleteIfExists(pending[index].TemporaryPath);
|
||||
DeleteIfExists(pending[index].BackupPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void PublishAll(IReadOnlyList<PendingFile> files)
|
||||
{
|
||||
string transaction = Guid.NewGuid().ToString("N");
|
||||
for (int index = 0; index < files.Count; index++)
|
||||
{
|
||||
PendingFile file = files[index];
|
||||
file.ExistedBeforePublish = File.Exists(file.FinalPath);
|
||||
file.BackupPath = file.ExistedBeforePublish ? file.FinalPath + ".backup-" + transaction : string.Empty;
|
||||
if (file.ExistedBeforePublish) File.Replace(file.TemporaryPath, file.FinalPath, file.BackupPath);
|
||||
else File.Move(file.TemporaryPath, file.FinalPath);
|
||||
file.Published = true;
|
||||
}
|
||||
for (int index = 0; index < files.Count; index++) DeleteIfExists(files[index].BackupPath);
|
||||
}
|
||||
|
||||
private static void RestorePublishedFiles(IReadOnlyList<PendingFile> files)
|
||||
{
|
||||
for (int index = files.Count - 1; index >= 0; index--)
|
||||
{
|
||||
PendingFile file = files[index];
|
||||
if (!file.Published) continue;
|
||||
try
|
||||
{
|
||||
if (file.ExistedBeforePublish && File.Exists(file.BackupPath))
|
||||
{
|
||||
if (File.Exists(file.FinalPath)) File.Replace(file.BackupPath, file.FinalPath, null);
|
||||
else File.Move(file.BackupPath, file.FinalPath);
|
||||
}
|
||||
else if (!file.ExistedBeforePublish) DeleteIfExists(file.FinalPath);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsFileStem(string value)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(value) && value.IndexOfAny(Path.GetInvalidFileNameChars()) < 0 && value.IndexOf(Path.DirectorySeparatorChar) < 0 && value.IndexOf(Path.AltDirectorySeparatorChar) < 0;
|
||||
}
|
||||
|
||||
private static void DeleteIfExists(string path)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(path) && File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
|
||||
private sealed class PendingFile
|
||||
{
|
||||
public PendingFile(string finalPath, byte[] content)
|
||||
{
|
||||
FinalPath = finalPath;
|
||||
Content = content;
|
||||
TemporaryPath = finalPath + ".tmp";
|
||||
BackupPath = string.Empty;
|
||||
}
|
||||
|
||||
public string FinalPath { get; }
|
||||
public byte[] Content { get; }
|
||||
public string TemporaryPath { get; }
|
||||
public string BackupPath { get; set; }
|
||||
public bool ExistedBeforePublish { get; set; }
|
||||
public bool Published { get; set; }
|
||||
}
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Visualization;
|
||||
|
||||
/// <summary>把单张共享图形定义渲染为 UTF-8 XML 可编辑 SVG;轨迹仅由离散点组成。</summary>
|
||||
public sealed class SmoothingSvgRenderer
|
||||
{
|
||||
public string Render(SmoothingFigureModel model)
|
||||
{
|
||||
if (model == null) throw new ArgumentNullException(nameof(model));
|
||||
return Render(new SmoothingFigureSetBuilder().Build(model).Figures[1]);
|
||||
}
|
||||
|
||||
public string Render(SmoothingFigureDefinition figure)
|
||||
{
|
||||
if (figure == null) throw new ArgumentNullException(nameof(figure));
|
||||
var svg = new StringBuilder();
|
||||
svg.Append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"")
|
||||
.Append(Number(figure.FigureWidthPoints)).Append("pt\" height=\"").Append(Number(figure.FigureHeightPoints))
|
||||
.Append("pt\" viewBox=\"0 0 ").Append(Number(figure.FigureWidthPoints)).Append(' ').Append(Number(figure.FigureHeightPoints)).Append("\">\n")
|
||||
.Append("<rect width=\"100%\" height=\"100%\" fill=\"#FFFFFF\"/>\n")
|
||||
.Append("<clipPath id=\"plot-clip\"><rect x=\"").Append(Number(figure.PlotXPoints)).Append("\" y=\"").Append(Number(figure.PlotYPoints))
|
||||
.Append("\" width=\"").Append(Number(figure.PlotWidthPoints)).Append("\" height=\"").Append(Number(figure.PlotHeightPoints)).Append("\"/></clipPath>\n");
|
||||
AppendTitle(svg, figure);
|
||||
if (figure.IsCurvatureFigure) AppendCurvatureAxes(svg, figure); else AppendOverheadAxes(svg, figure);
|
||||
svg.Append("<g clip-path=\"url(#plot-clip)\">\n");
|
||||
if (!figure.IsCurvatureFigure && figure.ShowsMapContext) AppendObstacles(svg, figure);
|
||||
if (figure.IsCurvatureFigure) AppendCurvaturePoints(svg, figure); else AppendPathPoints(svg, figure);
|
||||
if (!figure.IsCurvatureFigure && figure.ShowsMapContext) AppendStartGoal(svg, figure);
|
||||
svg.Append("</g>\n");
|
||||
AppendLegend(svg, figure);
|
||||
svg.Append("</svg>");
|
||||
return svg.ToString();
|
||||
}
|
||||
|
||||
private static void AppendTitle(StringBuilder svg, SmoothingFigureDefinition figure)
|
||||
{
|
||||
svg.Append("<text x=\"").Append(Number(figure.PlotXPoints)).Append("\" y=\"23\" font-size=\"12\"><tspan font-family=\"SimSun\">")
|
||||
.Append(Escape(figure.Title)).Append("</tspan><tspan font-family=\"Times New Roman\"> — ").Append(Escape(figure.Model.ScenarioLabel)).Append("</tspan></text>\n");
|
||||
}
|
||||
|
||||
private static void AppendOverheadAxes(StringBuilder svg, SmoothingFigureDefinition figure)
|
||||
{
|
||||
AppendPlotFrame(svg, figure);
|
||||
for (int index = 0; index < figure.XTicks.Count; index++)
|
||||
{
|
||||
double x = WorldX(figure, figure.XTicks[index]);
|
||||
AppendGridLine(svg, x, figure.PlotYPoints, x, figure.PlotYPoints + figure.PlotHeightPoints);
|
||||
svg.Append("<text x=\"").Append(Number(x)).Append("\" y=\"").Append(Number(figure.PlotYPoints + figure.PlotHeightPoints + 15d))
|
||||
.Append("\" text-anchor=\"middle\" font-family=\"Times New Roman\" font-size=\"8\">").Append(Number(figure.XTicks[index])).Append("</text>\n");
|
||||
}
|
||||
for (int index = 0; index < figure.YTicks.Count; index++)
|
||||
{
|
||||
double y = WorldY(figure, figure.YTicks[index]);
|
||||
AppendGridLine(svg, figure.PlotXPoints, y, figure.PlotXPoints + figure.PlotWidthPoints, y);
|
||||
svg.Append("<text x=\"").Append(Number(figure.PlotXPoints - 8d)).Append("\" y=\"").Append(Number(y + 3d))
|
||||
.Append("\" text-anchor=\"end\" font-family=\"Times New Roman\" font-size=\"8\">").Append(Number(figure.YTicks[index])).Append("</text>\n");
|
||||
}
|
||||
svg.Append("<text x=\"").Append(Number(figure.PlotXPoints + figure.PlotWidthPoints / 2d)).Append("\" y=\"").Append(Number(figure.PlotYPoints + figure.PlotHeightPoints + 31d))
|
||||
.Append("\" text-anchor=\"middle\" font-family=\"Times New Roman\" font-size=\"10\">X (m)</text>\n")
|
||||
.Append("<text x=\"19\" y=\"").Append(Number(figure.PlotYPoints + figure.PlotHeightPoints / 2d)).Append("\" text-anchor=\"middle\" transform=\"rotate(-90 19 ")
|
||||
.Append(Number(figure.PlotYPoints + figure.PlotHeightPoints / 2d)).Append(")\" font-family=\"Times New Roman\" font-size=\"10\">Y (m)</text>\n");
|
||||
}
|
||||
|
||||
private static void AppendCurvatureAxes(StringBuilder svg, SmoothingFigureDefinition figure)
|
||||
{
|
||||
AppendPlotFrame(svg, figure);
|
||||
for (int index = 0; index < figure.CurvatureArcLengthTicks.Count; index++)
|
||||
{
|
||||
double x = CurvatureX(figure, figure.CurvatureArcLengthTicks[index]);
|
||||
AppendGridLine(svg, x, figure.PlotYPoints, x, figure.PlotYPoints + figure.PlotHeightPoints);
|
||||
svg.Append("<text x=\"").Append(Number(x)).Append("\" y=\"").Append(Number(figure.PlotYPoints + figure.PlotHeightPoints + 15d))
|
||||
.Append("\" text-anchor=\"middle\" font-family=\"Times New Roman\" font-size=\"8\">").Append(Number(figure.CurvatureArcLengthTicks[index])).Append("</text>\n");
|
||||
}
|
||||
for (int index = 0; index < figure.CurvatureTicks.Count; index++)
|
||||
{
|
||||
double y = CurvatureY(figure, figure.CurvatureTicks[index]);
|
||||
AppendGridLine(svg, figure.PlotXPoints, y, figure.PlotXPoints + figure.PlotWidthPoints, y);
|
||||
svg.Append("<text x=\"").Append(Number(figure.PlotXPoints - 8d)).Append("\" y=\"").Append(Number(y + 3d))
|
||||
.Append("\" text-anchor=\"end\" font-family=\"Times New Roman\" font-size=\"8\">").Append(Number(figure.CurvatureTicks[index])).Append("</text>\n");
|
||||
}
|
||||
if (figure.CurvatureMinimumPerMeter < 0d && figure.CurvatureMaximumPerMeter > 0d)
|
||||
{
|
||||
double zero = CurvatureY(figure, 0d);
|
||||
svg.Append("<line x1=\"").Append(Number(figure.PlotXPoints)).Append("\" y1=\"").Append(Number(zero)).Append("\" x2=\"")
|
||||
.Append(Number(figure.PlotXPoints + figure.PlotWidthPoints)).Append("\" y2=\"").Append(Number(zero)).Append("\" stroke=\"#4D4D4D\" stroke-width=\"0.65\"/>\n");
|
||||
}
|
||||
svg.Append("<text x=\"").Append(Number(figure.PlotXPoints + figure.PlotWidthPoints / 2d)).Append("\" y=\"").Append(Number(figure.PlotYPoints + figure.PlotHeightPoints + 31d))
|
||||
.Append("\" text-anchor=\"middle\" font-family=\"Times New Roman\" font-size=\"10\">s (m)</text>\n")
|
||||
.Append("<text x=\"19\" y=\"").Append(Number(figure.PlotYPoints + figure.PlotHeightPoints / 2d)).Append("\" text-anchor=\"middle\" transform=\"rotate(-90 19 ")
|
||||
.Append(Number(figure.PlotYPoints + figure.PlotHeightPoints / 2d)).Append(")\" font-family=\"Times New Roman\" font-size=\"10\">κ (m⁻¹)</text>\n");
|
||||
}
|
||||
|
||||
private static void AppendPlotFrame(StringBuilder svg, SmoothingFigureDefinition figure)
|
||||
{
|
||||
svg.Append("<rect x=\"").Append(Number(figure.PlotXPoints)).Append("\" y=\"").Append(Number(figure.PlotYPoints)).Append("\" width=\"")
|
||||
.Append(Number(figure.PlotWidthPoints)).Append("\" height=\"").Append(Number(figure.PlotHeightPoints)).Append("\" fill=\"#FFFFFF\" stroke=\"#000000\" stroke-width=\"0.75\"/>\n");
|
||||
}
|
||||
|
||||
private static void AppendGridLine(StringBuilder svg, double x1, double y1, double x2, double y2)
|
||||
{
|
||||
svg.Append("<line x1=\"").Append(Number(x1)).Append("\" y1=\"").Append(Number(y1)).Append("\" x2=\"").Append(Number(x2)).Append("\" y2=\"")
|
||||
.Append(Number(y2)).Append("\" stroke=\"#E6E6E6\" stroke-width=\"0.5\"/>\n");
|
||||
}
|
||||
|
||||
private static void AppendObstacles(StringBuilder svg, SmoothingFigureDefinition figure)
|
||||
{
|
||||
for (int index = 0; index < figure.Model.Obstacles.Count; index++)
|
||||
{
|
||||
SmoothingFigureObstacle obstacle = figure.Model.Obstacles[index];
|
||||
svg.Append("<rect class=\"obstacle\" x=\"").Append(Number(WorldX(figure, obstacle.X))).Append("\" y=\"")
|
||||
.Append(Number(WorldY(figure, obstacle.Y + obstacle.Height))).Append("\" width=\"").Append(Number(obstacle.Width * figure.WorldScalePointsPerMeter))
|
||||
.Append("\" height=\"").Append(Number(obstacle.Height * figure.WorldScalePointsPerMeter)).Append("\" fill=\"#D9D9D9\" stroke=\"#808080\" stroke-width=\"0.35\"/>\n");
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendPathPoints(StringBuilder svg, SmoothingFigureDefinition figure)
|
||||
{
|
||||
for (int viewIndex = 0; viewIndex < figure.Series.Count; viewIndex++)
|
||||
{
|
||||
SmoothingFigureSeriesView view = figure.Series[viewIndex];
|
||||
AppendPoints(svg, view, point => WorldX(figure, point.X), point => WorldY(figure, point.Y));
|
||||
for (int markerIndex = 0; markerIndex < view.Series.ViolationMarkers.Count; markerIndex++) AppendCross(svg, figure, view.Series.ViolationMarkers[markerIndex], view.Series.Color);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendCurvaturePoints(StringBuilder svg, SmoothingFigureDefinition figure)
|
||||
{
|
||||
for (int viewIndex = 0; viewIndex < figure.Series.Count; viewIndex++)
|
||||
{
|
||||
SmoothingFigureSeriesView view = figure.Series[viewIndex];
|
||||
AppendPoints(svg, view, point => CurvatureX(figure, point.ArcLength), point => CurvatureY(figure, point.VehicleCurvature));
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendPoints(StringBuilder svg, SmoothingFigureSeriesView view, Func<SmoothingFigurePoint, double> x, Func<SmoothingFigurePoint, double> y)
|
||||
{
|
||||
if (!view.Series.IsCurveVisible) return;
|
||||
svg.Append("<g class=\"trajectory-series\" data-series=\"").Append(Escape(view.Series.Key)).Append("\" fill=\"").Append(view.Series.Color).Append("\" opacity=\"").Append(Number(view.Opacity)).Append("\">\n");
|
||||
for (int index = 0; index < view.Series.Points.Count; index++)
|
||||
{
|
||||
SmoothingFigurePoint point = view.Series.Points[index];
|
||||
svg.Append("<circle class=\"trajectory-point\" cx=\"").Append(Number(x(point))).Append("\" cy=\"").Append(Number(y(point))).Append("\" r=\"").Append(Number(view.PointRadiusPoints)).Append("\"/>\n");
|
||||
}
|
||||
svg.Append("</g>\n");
|
||||
}
|
||||
|
||||
private static void AppendCross(StringBuilder svg, SmoothingFigureDefinition figure, SmoothingFigurePoint point, string color)
|
||||
{
|
||||
double x = WorldX(figure, point.X), y = WorldY(figure, point.Y), radius = 3d;
|
||||
svg.Append("<g class=\"violation-cross\" stroke=\"").Append(color).Append("\" stroke-width=\"1.1\"><line x1=\"").Append(Number(x - radius)).Append("\" y1=\"")
|
||||
.Append(Number(y - radius)).Append("\" x2=\"").Append(Number(x + radius)).Append("\" y2=\"").Append(Number(y + radius)).Append("\"/><line x1=\"")
|
||||
.Append(Number(x - radius)).Append("\" y1=\"").Append(Number(y + radius)).Append("\" x2=\"").Append(Number(x + radius)).Append("\" y2=\"").Append(Number(y - radius)).Append("\"/></g>\n");
|
||||
}
|
||||
|
||||
private static void AppendStartGoal(StringBuilder svg, SmoothingFigureDefinition figure)
|
||||
{
|
||||
svg.Append("<circle class=\"start-marker\" cx=\"").Append(Number(WorldX(figure, figure.Model.Start.X))).Append("\" cy=\"").Append(Number(WorldY(figure, figure.Model.Start.Y)))
|
||||
.Append("\" r=\"3.2\" fill=\"#F0E442\" stroke=\"#000000\" stroke-width=\"0.8\"/>\n");
|
||||
double x = WorldX(figure, figure.Model.Goal.X), y = WorldY(figure, figure.Model.Goal.Y), radius = 4d;
|
||||
svg.Append("<polygon class=\"goal-marker\" points=\"").Append(Number(x)).Append(',').Append(Number(y - radius)).Append(' ').Append(Number(x + radius)).Append(',').Append(Number(y)).Append(' ')
|
||||
.Append(Number(x)).Append(',').Append(Number(y + radius)).Append(' ').Append(Number(x - radius)).Append(',').Append(Number(y)).Append("\" fill=\"#CC79A7\" stroke=\"#000000\" stroke-width=\"0.8\"/>\n");
|
||||
}
|
||||
|
||||
private static void AppendLegend(StringBuilder svg, SmoothingFigureDefinition figure)
|
||||
{
|
||||
double columnWidth = 198d;
|
||||
for (int index = 0; index < figure.LegendEntries.Count; index++)
|
||||
{
|
||||
SmoothingFigureLegendEntry entry = figure.LegendEntries[index];
|
||||
int column = index % 2;
|
||||
int row = index / 2;
|
||||
double x = figure.PlotXPoints + column * columnWidth;
|
||||
double y = figure.LegendYPoints + row * 15d;
|
||||
svg.Append("<circle class=\"legend-point\" cx=\"").Append(Number(x + 3d)).Append("\" cy=\"").Append(Number(y - 3d)).Append("\" r=\"2.2\" fill=\"")
|
||||
.Append(entry.Color).Append("\"/>\n<text x=\"").Append(Number(x + 10d)).Append("\" y=\"").Append(Number(y)).Append("\" font-size=\"8.5\"><tspan font-family=\"SimSun\">")
|
||||
.Append(Escape(entry.Label)).Append("</tspan></text>\n");
|
||||
}
|
||||
}
|
||||
|
||||
private static double WorldX(SmoothingFigureDefinition figure, double x) { return figure.PlotXPoints + (x - figure.WorldXMinMeters) * figure.WorldScalePointsPerMeter; }
|
||||
private static double WorldY(SmoothingFigureDefinition figure, double y) { return figure.PlotYPoints + figure.PlotHeightPoints - (y - figure.WorldYMinMeters) * figure.WorldScalePointsPerMeter; }
|
||||
private static double CurvatureX(SmoothingFigureDefinition figure, double arcLength) { return figure.PlotXPoints + arcLength / figure.CurvatureArcLengthMaximumMeters * figure.PlotWidthPoints; }
|
||||
private static double CurvatureY(SmoothingFigureDefinition figure, double curvature) { return figure.PlotYPoints + figure.PlotHeightPoints - (curvature - figure.CurvatureMinimumPerMeter) / (figure.CurvatureMaximumPerMeter - figure.CurvatureMinimumPerMeter) * figure.PlotHeightPoints; }
|
||||
private static string Number(double value) { return value.ToString("0.###", CultureInfo.InvariantCulture); }
|
||||
private static string Escape(string value) { return (value ?? string.Empty).Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace("\"", """).Replace("'", "'"); }
|
||||
}
|
||||
Reference in New Issue
Block a user