chore: save current workspace progress

This commit is contained in:
梁薄云
2026-08-09 22:13:18 +08:00
parent 650c2ab0e3
commit 2f4fd15e52
449 changed files with 76593 additions and 971 deletions
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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";
}
@@ -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("\"", "\"\"") + "\"";
}
}
@@ -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; }
}
@@ -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,
}
@@ -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; }
}
@@ -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; }
}
}
@@ -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; }
}
@@ -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();
}
}
@@ -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); }
}
@@ -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";
}
@@ -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);
}
}
@@ -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; }
}
}
@@ -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("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;").Replace("\"", "&quot;").Replace("'", "&apos;"); }
}