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
@@ -1,412 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
using MultiWheelC.TrajectoryPlanning.CoarsePath;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Processing;
using MultiWheelC.TrajectoryPlanning.Utils;
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Algorithms;
/// <summary>按方向段独立生成夹持三次 B 样条原始几何候选。</summary>
internal sealed class CubicBSplineSmoother : IPathSmoother
{
private const int Degree = 3;
private const int SamplesPerSpan = 64;
private const double StraightToleranceMeters = 1e-9d;
private const double EndpointProbeParameter = 1e-6d;
private const double MinimumTangentHandleLengthMeters = 1e-10d;
/// <inheritdoc />
public SmoothingMethod Method => SmoothingMethod.CubicBSpline;
/// <inheritdoc />
public SmoothingCandidate Smooth(
SmoothingAlgorithmInput input,
double effectiveStrength,
CancellationToken cancellationToken)
{
if (input == null || input.OriginalPath == null ||
input.Options == null ||
!NumericGuard.IsPositiveFinite(effectiveStrength) ||
!NumericGuard.IsFinite(input.MinimumClearanceReserveMeters) ||
input.MinimumClearanceReserveMeters < 0d)
{
return SmoothingCandidate.Failed("B 样条输入、强度或净空预留无效。");
}
var candidateSegments = new List<PreparedDirectionSegment>(input.OriginalPath.Segments.Count);
for (int segmentIndex = 0; segmentIndex < input.OriginalPath.Segments.Count; segmentIndex++)
{
cancellationToken.ThrowIfCancellationRequested();
PreparedDirectionSegment sourceSegment = input.OriginalPath.Segments[segmentIndex];
if (!TrySmoothSegment(
sourceSegment,
effectiveStrength,
input.MinimumClearanceReserveMeters,
input.Options.CubicBSplineEndpointTangentScale,
cancellationToken,
out IReadOnlyList<SmoothingPoint2D> points,
out string reason,
out SmoothingCandidateStatus status))
{
return status == SmoothingCandidateStatus.RetryableInfeasible
? SmoothingCandidate.RetryableInfeasible(reason)
: SmoothingCandidate.Failed(reason);
}
candidateSegments.Add(new PreparedDirectionSegment(
sourceSegment.SegmentIndex,
sourceSegment.Direction,
points,
sourceSegment.StartsAtGearSwitch,
sourceSegment.EndsAtGearSwitch,
sourceSegment.StartVehicleCurvaturePerMeter));
}
return SmoothingCandidate.Success(candidateSegments);
}
private static bool TrySmoothSegment(
PreparedDirectionSegment sourceSegment,
double strength,
double reserveMeters,
double endpointTangentScale,
CancellationToken cancellationToken,
out IReadOnlyList<SmoothingPoint2D> result,
out string reason,
out SmoothingCandidateStatus status)
{
result = null;
reason = string.Empty;
status = SmoothingCandidateStatus.Failed;
if (sourceSegment == null || sourceSegment.Points == null || sourceSegment.Points.Count == 0)
{
reason = "B 样条方向段为空。";
return false;
}
IReadOnlyList<SmoothingPoint2D> anchors = sourceSegment.Points;
for (int index = 0; index < anchors.Count; index++)
{
cancellationToken.ThrowIfCancellationRequested();
if (!IsValidAnchor(anchors[index]))
{
reason = "B 样条方向段包含非法锚点。";
return false;
}
}
if (anchors.Count <= Degree || IsStraight(anchors))
{
result = anchors;
return true;
}
if (!TryCreateControls(anchors, sourceSegment.Direction, strength, reserveMeters, endpointTangentScale,
cancellationToken, out Point2D[] controls, out reason))
{
return false;
}
double[] knots = CreateClampedKnots(controls.Length);
var sampled = new List<SmoothingPoint2D>();
int spanCount = controls.Length - Degree;
int uniformIntervals = spanCount * SamplesPerSpan;
if (!TryAddSample(0d, anchors, controls, knots, reserveMeters, sampled, cancellationToken, out reason, out status))
return false;
if (!TryAddSample(EndpointProbeParameter, anchors, controls, knots, reserveMeters,
sampled, cancellationToken, out reason, out status))
return false;
for (int index = 1; index < uniformIntervals; index++)
{
if (!TryAddSample((double)index / uniformIntervals, anchors, controls, knots, reserveMeters,
sampled, cancellationToken, out reason, out status))
{
return false;
}
}
if (!TryAddSample(1d - EndpointProbeParameter, anchors, controls, knots, reserveMeters,
sampled, cancellationToken, out reason, out status))
return false;
if (!TryAddSample(1d, anchors, controls, knots, reserveMeters, sampled, cancellationToken, out reason, out status))
return false;
result = sampled;
return true;
}
private static bool TryCreateControls(
IReadOnlyList<SmoothingPoint2D> anchors,
TravelDirection direction,
double strength,
double reserveMeters,
double endpointTangentScale,
CancellationToken cancellationToken,
out Point2D[] controls,
out string reason)
{
reason = string.Empty;
controls = new Point2D[anchors.Count];
controls[0] = Point2D.FromAnchor(anchors[0]);
controls[controls.Length - 1] = Point2D.FromAnchor(anchors[anchors.Count - 1]);
double startHandleLength = Distance(anchors[0], anchors[1]) * endpointTangentScale * strength;
double startTravelHeading = GetTravelHeading(anchors[0], direction);
if (!TryConstrainTangentHandle(
Point2D.FromAnchor(anchors[0]),
Math.Cos(startTravelHeading),
Math.Sin(startTravelHeading),
startHandleLength,
anchors[1],
GetAllowedRadius(anchors[1], reserveMeters),
out controls[1]))
{
reason = "B 样条起点切向手柄无法同时满足相邻锚点移动范围。";
return false;
}
int finalIndex = anchors.Count - 1;
double endHandleLength = Distance(anchors[finalIndex - 1], anchors[finalIndex]) * endpointTangentScale * strength;
double endTravelHeading = GetTravelHeading(anchors[finalIndex], direction);
if (!TryConstrainTangentHandle(
Point2D.FromAnchor(anchors[finalIndex]),
-Math.Cos(endTravelHeading),
-Math.Sin(endTravelHeading),
endHandleLength,
anchors[finalIndex - 1],
GetAllowedRadius(anchors[finalIndex - 1], reserveMeters),
out controls[finalIndex - 1]))
{
reason = "B 样条终点切向手柄无法同时满足相邻锚点移动范围。";
return false;
}
for (int index = 2; index < finalIndex - 1; index++)
{
cancellationToken.ThrowIfCancellationRequested();
SmoothingPoint2D previous = anchors[index - 1];
SmoothingPoint2D current = anchors[index];
SmoothingPoint2D next = anchors[index + 1];
Point2D target = new Point2D(
(previous.X + current.X + next.X) / 3d,
(previous.Y + current.Y + next.Y) / 3d);
Point2D proposed = new Point2D(
current.X + strength * (target.X - current.X),
current.Y + strength * (target.Y - current.Y));
controls[index] = ClampDisplacement(current, proposed, GetAllowedRadius(current, reserveMeters));
}
for (int index = 0; index < controls.Length; index++)
{
if (!NumericGuard.IsFinite(controls[index].X) || !NumericGuard.IsFinite(controls[index].Y))
{
reason = "B 样条控制点构造产生非法数值。";
return false;
}
}
return true;
}
private static bool TryAddSample(
double parameter,
IReadOnlyList<SmoothingPoint2D> anchors,
Point2D[] controls,
double[] knots,
double reserveMeters,
List<SmoothingPoint2D> output,
CancellationToken cancellationToken,
out string reason,
out SmoothingCandidateStatus status)
{
reason = string.Empty;
status = SmoothingCandidateStatus.Failed;
cancellationToken.ThrowIfCancellationRequested();
Point2D evaluated = Evaluate(controls, knots, parameter);
if (!NumericGuard.IsFinite(evaluated.X) || !NumericGuard.IsFinite(evaluated.Y))
{
reason = "B 样条评估产生非法数值。";
return false;
}
double targetArcLength = parameter * anchors[anchors.Count - 1].ArcLength;
if (!PathReferenceInterpolator.TryInterpolateByArcLength(anchors, targetArcLength,
out SmoothingPoint2D reference, out reason))
{
return false;
}
double displacement = Distance(evaluated, reference);
if (!NumericGuard.IsFinite(displacement))
{
reason = "B 样条评估点位移产生非法数值。";
return false;
}
if (displacement > GetAllowedRadius(reference, reserveMeters))
{
reason = "B 样条评估点超过对应原始参考点的允许移动范围。";
status = SmoothingCandidateStatus.RetryableInfeasible;
return false;
}
bool endpoint = parameter == 0d || parameter == 1d;
output.Add(new SmoothingPoint2D(
evaluated.X,
evaluated.Y,
reference.ArcLength,
reference.Heading,
reference.UnwrappedHeading,
reference.BodyClearance,
endpoint && reference.IsGearSwitchPoint,
endpoint ? reference.Source : SmoothedPathPointSource.Interpolated));
return true;
}
private static bool TryConstrainTangentHandle(
Point2D endpoint,
double rayDirectionX,
double rayDirectionY,
double desiredLength,
SmoothingPoint2D adjacentAnchor,
double allowedRadius,
out Point2D control)
{
control = default;
double offsetX = adjacentAnchor.X - endpoint.X;
double offsetY = adjacentAnchor.Y - endpoint.Y;
double projectedLength = offsetX * rayDirectionX + offsetY * rayDirectionY;
double perpendicularX = offsetX - projectedLength * rayDirectionX;
double perpendicularY = offsetY - projectedLength * rayDirectionY;
double discriminant = allowedRadius * allowedRadius -
(perpendicularX * perpendicularX + perpendicularY * perpendicularY);
if (!NumericGuard.IsFinite(discriminant) || discriminant < 0d) return false;
double halfInterval = Math.Sqrt(discriminant);
double minimumLength = Math.Max(MinimumTangentHandleLengthMeters, projectedLength - halfInterval);
double maximumLength = projectedLength + halfInterval;
if (!NumericGuard.IsFinite(maximumLength) || maximumLength < minimumLength) return false;
double constrainedLength = Math.Max(minimumLength, Math.Min(desiredLength, maximumLength));
control = new Point2D(
endpoint.X + constrainedLength * rayDirectionX,
endpoint.Y + constrainedLength * rayDirectionY);
return NumericGuard.IsFinite(control.X) && NumericGuard.IsFinite(control.Y);
}
private static Point2D Evaluate(Point2D[] controls, double[] knots, double parameter)
{
if (parameter <= 0d) return controls[0];
if (parameter >= 1d) return controls[controls.Length - 1];
var point = new Point2D(0d, 0d);
for (int index = 0; index < controls.Length; index++)
{
double basis = EvaluateBasis(index, Degree, parameter, knots);
point = new Point2D(point.X + basis * controls[index].X, point.Y + basis * controls[index].Y);
}
return point;
}
private static double EvaluateBasis(int index, int degree, double parameter, double[] knots)
{
if (degree == 0)
return knots[index] <= parameter && parameter < knots[index + 1] ? 1d : 0d;
double left = 0d;
double leftDenominator = knots[index + degree] - knots[index];
if (leftDenominator > 0d)
left = (parameter - knots[index]) / leftDenominator * EvaluateBasis(index, degree - 1, parameter, knots);
double right = 0d;
double rightDenominator = knots[index + degree + 1] - knots[index + 1];
if (rightDenominator > 0d)
right = (knots[index + degree + 1] - parameter) / rightDenominator *
EvaluateBasis(index + 1, degree - 1, parameter, knots);
return left + right;
}
private static double[] CreateClampedKnots(int controlCount)
{
var knots = new double[controlCount + Degree + 1];
for (int index = Degree + 1; index < controlCount; index++)
knots[index] = (double)(index - Degree) / (controlCount - Degree);
for (int index = controlCount; index < knots.Length; index++) knots[index] = 1d;
return knots;
}
private static Point2D ClampDisplacement(SmoothingPoint2D anchor, Point2D proposed, double allowedRadius)
{
double deltaX = proposed.X - anchor.X;
double deltaY = proposed.Y - anchor.Y;
double distance = Math.Sqrt(deltaX * deltaX + deltaY * deltaY);
if (!NumericGuard.IsFinite(distance) || distance <= allowedRadius) return proposed;
if (distance == 0d || allowedRadius == 0d) return Point2D.FromAnchor(anchor);
double scale = allowedRadius / distance;
return new Point2D(anchor.X + deltaX * scale, anchor.Y + deltaY * scale);
}
private static bool IsStraight(IReadOnlyList<SmoothingPoint2D> anchors)
{
if (anchors.Count < 3) return true;
SmoothingPoint2D first = anchors[0];
SmoothingPoint2D last = anchors[anchors.Count - 1];
double directionX = last.X - first.X;
double directionY = last.Y - first.Y;
double length = Math.Sqrt(directionX * directionX + directionY * directionY);
if (!NumericGuard.IsFinite(length) || length <= StraightToleranceMeters) return false;
for (int index = 1; index < anchors.Count - 1; index++)
{
double offsetX = anchors[index].X - first.X;
double offsetY = anchors[index].Y - first.Y;
double perpendicularDeviation = Math.Abs(directionX * offsetY - directionY * offsetX) / length;
if (perpendicularDeviation >= StraightToleranceMeters) return false;
}
return true;
}
private static bool IsValidAnchor(SmoothingPoint2D point)
{
return point != null && NumericGuard.IsFinite(point.X) && NumericGuard.IsFinite(point.Y) &&
NumericGuard.IsFinite(point.ArcLength) && NumericGuard.IsFinite(point.Heading) &&
NumericGuard.IsFinite(point.UnwrappedHeading) && NumericGuard.IsFinite(point.BodyClearance) &&
point.BodyClearance >= 0d;
}
private static double GetAllowedRadius(SmoothingPoint2D anchor, double reserveMeters)
{
return Math.Max(0d, anchor.BodyClearance - reserveMeters);
}
private static double GetTravelHeading(SmoothingPoint2D point, TravelDirection direction)
{
return direction == TravelDirection.Forward ? point.Heading : point.Heading - Math.PI;
}
private static double Distance(SmoothingPoint2D left, SmoothingPoint2D right)
{
double deltaX = right.X - left.X;
double deltaY = right.Y - left.Y;
return Math.Sqrt(deltaX * deltaX + deltaY * deltaY);
}
private static double Distance(Point2D left, SmoothingPoint2D right)
{
double deltaX = right.X - left.X;
double deltaY = right.Y - left.Y;
return Math.Sqrt(deltaX * deltaX + deltaY * deltaY);
}
private readonly struct Point2D
{
internal Point2D(double x, double y)
{
X = x;
Y = y;
}
internal double X { get; }
internal double Y { get; }
internal static Point2D FromAnchor(SmoothingPoint2D anchor)
{
return new Point2D(anchor.X, anchor.Y);
}
}
}
@@ -1,14 +0,0 @@
using System.Threading;
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Algorithms;
/// <summary>单一平滑方法生成原始几何候选的内部契约。</summary>
internal interface IPathSmoother
{
SmoothingMethod Method { get; }
SmoothingCandidate Smooth(
SmoothingAlgorithmInput input,
double effectiveStrength,
CancellationToken cancellationToken);
}
@@ -1,427 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Processing;
using MultiWheelC.TrajectoryPlanning.Utils;
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Algorithms;
/// <summary>在方向段内以局部三次 Bézier 连接替换明显转角。</summary>
internal sealed class LocalCubicBezierSmoother : IPathSmoother
{
private const double WindowToleranceMeters = 1e-9d;
/// <inheritdoc />
public SmoothingMethod Method => SmoothingMethod.LocalCubicBezier;
/// <inheritdoc />
public SmoothingCandidate Smooth(
SmoothingAlgorithmInput input,
double effectiveStrength,
CancellationToken cancellationToken)
{
if (input == null || input.OriginalPath == null || input.Options == null ||
!NumericGuard.IsPositiveFinite(effectiveStrength) ||
!NumericGuard.IsFinite(input.MinimumClearanceReserveMeters) ||
input.MinimumClearanceReserveMeters < 0d)
{
return SmoothingCandidate.Failed("Bézier 输入、强度或净空预留无效。");
}
var candidateSegments = new List<PreparedDirectionSegment>(input.OriginalPath.Segments.Count);
for (int segmentIndex = 0; segmentIndex < input.OriginalPath.Segments.Count; segmentIndex++)
{
cancellationToken.ThrowIfCancellationRequested();
PreparedDirectionSegment sourceSegment = input.OriginalPath.Segments[segmentIndex];
if (!TrySmoothSegment(
sourceSegment,
input.Options.BezierCornerHeadingThresholdRadians,
input.Options.BezierMaximumWindowLengthMeters,
input.Options.BezierHandleLengthRatio,
effectiveStrength,
input.MinimumClearanceReserveMeters,
cancellationToken,
out IReadOnlyList<SmoothingPoint2D> points,
out string reason,
out SmoothingCandidateStatus status))
{
return status == SmoothingCandidateStatus.RetryableInfeasible
? SmoothingCandidate.RetryableInfeasible(reason)
: SmoothingCandidate.Failed(reason);
}
candidateSegments.Add(new PreparedDirectionSegment(
sourceSegment.SegmentIndex,
sourceSegment.Direction,
points,
sourceSegment.StartsAtGearSwitch,
sourceSegment.EndsAtGearSwitch,
sourceSegment.StartVehicleCurvaturePerMeter));
}
return SmoothingCandidate.Success(candidateSegments);
}
private static bool TrySmoothSegment(
PreparedDirectionSegment sourceSegment,
double cornerThresholdRadians,
double maximumWindowLengthMeters,
double handleLengthRatio,
double strength,
double reserveMeters,
CancellationToken cancellationToken,
out IReadOnlyList<SmoothingPoint2D> result,
out string reason,
out SmoothingCandidateStatus status)
{
result = null;
reason = string.Empty;
status = SmoothingCandidateStatus.Failed;
if (sourceSegment == null || sourceSegment.Points == null || sourceSegment.Points.Count == 0)
{
reason = "Bézier 方向段为空。";
return false;
}
IReadOnlyList<SmoothingPoint2D> anchors = sourceSegment.Points;
if (!ValidateAnchors(anchors, cancellationToken, out reason)) return false;
if (anchors.Count < 3)
{
result = anchors;
return true;
}
if (!TryCreateMergedWindows(
anchors,
cornerThresholdRadians,
maximumWindowLengthMeters,
cancellationToken,
out List<Window> windows,
out reason))
{
return false;
}
if (windows.Count == 0)
{
result = anchors;
return true;
}
var output = new List<SmoothingPoint2D>(anchors.Count);
int anchorIndex = 0;
for (int windowIndex = 0; windowIndex < windows.Count; windowIndex++)
{
cancellationToken.ThrowIfCancellationRequested();
Window window = windows[windowIndex];
while (anchorIndex <= window.StartIndex)
{
output.Add(anchors[anchorIndex]);
anchorIndex++;
}
if (!TryAppendWindowInterior(
anchors,
window,
handleLengthRatio,
strength,
reserveMeters,
output,
cancellationToken,
out reason,
out status))
{
return false;
}
output.Add(anchors[window.EndIndex]);
anchorIndex = window.EndIndex + 1;
}
while (anchorIndex < anchors.Count)
{
output.Add(anchors[anchorIndex]);
anchorIndex++;
}
result = output;
return true;
}
private static bool ValidateAnchors(
IReadOnlyList<SmoothingPoint2D> anchors,
CancellationToken cancellationToken,
out string reason)
{
reason = string.Empty;
for (int index = 0; index < anchors.Count; index++)
{
cancellationToken.ThrowIfCancellationRequested();
SmoothingPoint2D point = anchors[index];
if (point == null || !NumericGuard.IsFinite(point.X) || !NumericGuard.IsFinite(point.Y) ||
!NumericGuard.IsFinite(point.ArcLength) || point.ArcLength < 0d ||
!NumericGuard.IsFinite(point.Heading) || !NumericGuard.IsFinite(point.UnwrappedHeading) ||
!NumericGuard.IsFinite(point.BodyClearance) || point.BodyClearance < 0d ||
(index > 0 && point.ArcLength <= anchors[index - 1].ArcLength))
{
reason = "Bézier 方向段包含非有限、非递增弧长或无效净空的锚点。";
return false;
}
}
return true;
}
private static bool TryCreateMergedWindows(
IReadOnlyList<SmoothingPoint2D> anchors,
double cornerThresholdRadians,
double maximumWindowLengthMeters,
CancellationToken cancellationToken,
out List<Window> windows,
out string reason)
{
windows = new List<Window>();
var candidates = new List<Window>();
reason = string.Empty;
for (int cornerIndex = 1; cornerIndex < anchors.Count - 1; cornerIndex++)
{
cancellationToken.ThrowIfCancellationRequested();
if (!TryGetTravelTangent(anchors[cornerIndex - 1], anchors[cornerIndex], out Point2D entryTangent) ||
!TryGetTravelTangent(anchors[cornerIndex], anchors[cornerIndex + 1], out Point2D exitTangent))
{
reason = "Bézier 转角包含零长度或非有限行进切向。";
return false;
}
double cross = entryTangent.X * exitTangent.Y - entryTangent.Y * exitTangent.X;
double dot = entryTangent.X * exitTangent.X + entryTangent.Y * exitTangent.Y;
double turnRadians = Math.Atan2(Math.Abs(cross), dot);
if (!NumericGuard.IsFinite(turnRadians))
{
reason = "Bézier 转角计算产生非有限数值。";
return false;
}
if (turnRadians < cornerThresholdRadians) continue;
int startIndex = cornerIndex - 1;
int endIndex = cornerIndex + 1;
double windowLength = anchors[endIndex].ArcLength - anchors[startIndex].ArcLength;
if (!NumericGuard.IsPositiveFinite(windowLength))
{
reason = "Bézier 局部窗口弧长无效。";
return false;
}
if (windowLength > maximumWindowLengthMeters + WindowToleranceMeters) continue;
if (ContainsGearSwitch(anchors, startIndex, endIndex)) continue;
candidates.Add(new Window(startIndex, endIndex));
}
MergeBoundedConnectedWindows(anchors, candidates, maximumWindowLengthMeters, windows);
return true;
}
private static void MergeBoundedConnectedWindows(
IReadOnlyList<SmoothingPoint2D> anchors,
IReadOnlyList<Window> candidates,
double maximumWindowLengthMeters,
List<Window> windows)
{
int candidateIndex = 0;
while (candidateIndex < candidates.Count)
{
Window merged = candidates[candidateIndex];
candidateIndex++;
while (candidateIndex < candidates.Count &&
candidates[candidateIndex].StartIndex <= merged.EndIndex + 1)
{
merged = new Window(merged.StartIndex,
Math.Max(merged.EndIndex, candidates[candidateIndex].EndIndex));
candidateIndex++;
}
double mergedLength = anchors[merged.EndIndex].ArcLength - anchors[merged.StartIndex].ArcLength;
if (mergedLength <= maximumWindowLengthMeters + WindowToleranceMeters)
{
windows.Add(merged);
}
// A connected group that exceeds the cap is declined as a whole. Splitting it into
// adjacent local curves would introduce unrequested joins; accepting it would violate
// the maximum-window contract. Its original anchors therefore remain unchanged.
}
}
private static bool TryAppendWindowInterior(
IReadOnlyList<SmoothingPoint2D> anchors,
Window window,
double handleLengthRatio,
double strength,
double reserveMeters,
List<SmoothingPoint2D> output,
CancellationToken cancellationToken,
out string reason,
out SmoothingCandidateStatus status)
{
reason = string.Empty;
status = SmoothingCandidateStatus.Failed;
SmoothingPoint2D p0 = anchors[window.StartIndex];
SmoothingPoint2D p3 = anchors[window.EndIndex];
if (!TryGetTravelTangent(p0, anchors[window.StartIndex + 1], out Point2D entryTangent) ||
!TryGetTravelTangent(anchors[window.EndIndex - 1], p3, out Point2D exitTangent))
{
reason = "Bézier 窗口端点包含无效行进切向。";
return false;
}
double arcLength = p3.ArcLength - p0.ArcLength;
double chordLength = Distance(p0, p3);
double handleLength = chordLength * handleLengthRatio * strength;
if (!NumericGuard.IsPositiveFinite(arcLength) || !NumericGuard.IsPositiveFinite(chordLength) ||
!NumericGuard.IsPositiveFinite(handleLength))
{
reason = "Bézier 窗口弧长、端点弦长或控制柄长度无效。";
return false;
}
Point2D control1 = new Point2D(
p0.X + entryTangent.X * handleLength,
p0.Y + entryTangent.Y * handleLength);
Point2D control2 = new Point2D(
p3.X - exitTangent.X * handleLength,
p3.Y - exitTangent.Y * handleLength);
if (!IsFinite(control1) || !IsFinite(control2))
{
reason = "Bézier 控制点产生非有限数值。";
return false;
}
for (int index = window.StartIndex + 1; index < window.EndIndex; index++)
{
cancellationToken.ThrowIfCancellationRequested();
SmoothingPoint2D anchor = anchors[index];
double parameter = (anchor.ArcLength - p0.ArcLength) / arcLength;
if (!NumericGuard.IsFinite(parameter) || parameter <= 0d || parameter >= 1d)
{
reason = "Bézier 窗口参数无效。";
return false;
}
Point2D evaluated = Evaluate(p0, control1, control2, p3, parameter);
if (!IsFinite(evaluated))
{
reason = "Bézier 评估产生非有限数值。";
return false;
}
double referenceArcLength = p0.ArcLength + parameter * arcLength;
if (!PathReferenceInterpolator.TryInterpolateByArcLength(
anchors,
referenceArcLength,
out SmoothingPoint2D reference,
out reason))
{
return false;
}
double displacement = Distance(evaluated, reference);
if (!NumericGuard.IsFinite(displacement))
{
reason = "Bézier 评估点位移产生非有限数值。";
return false;
}
double allowedDisplacement = Math.Max(0d, reference.BodyClearance - reserveMeters);
if (displacement > allowedDisplacement)
{
reason = "Bézier 评估点超过对应原始弧长参考点的允许移动范围。";
status = SmoothingCandidateStatus.RetryableInfeasible;
return false;
}
output.Add(new SmoothingPoint2D(
evaluated.X,
evaluated.Y,
reference.ArcLength,
reference.Heading,
reference.UnwrappedHeading,
reference.BodyClearance,
false,
SmoothedPathPointSource.Interpolated));
}
return true;
}
private static bool ContainsGearSwitch(IReadOnlyList<SmoothingPoint2D> anchors, int startIndex, int endIndex)
{
for (int index = startIndex; index <= endIndex; index++)
{
if (anchors[index].IsGearSwitchPoint) return true;
}
return false;
}
private static bool TryGetTravelTangent(SmoothingPoint2D start, SmoothingPoint2D end, out Point2D tangent)
{
tangent = default;
double deltaX = end.X - start.X;
double deltaY = end.Y - start.Y;
double length = Math.Sqrt(deltaX * deltaX + deltaY * deltaY);
if (!NumericGuard.IsPositiveFinite(length)) return false;
tangent = new Point2D(deltaX / length, deltaY / length);
return IsFinite(tangent);
}
private static Point2D Evaluate(SmoothingPoint2D p0, Point2D p1, Point2D p2, SmoothingPoint2D p3, double parameter)
{
double oneMinusParameter = 1d - parameter;
double p0Weight = oneMinusParameter * oneMinusParameter * oneMinusParameter;
double p1Weight = 3d * oneMinusParameter * oneMinusParameter * parameter;
double p2Weight = 3d * oneMinusParameter * parameter * parameter;
double p3Weight = parameter * parameter * parameter;
return new Point2D(
p0Weight * p0.X + p1Weight * p1.X + p2Weight * p2.X + p3Weight * p3.X,
p0Weight * p0.Y + p1Weight * p1.Y + p2Weight * p2.Y + p3Weight * p3.Y);
}
private static bool IsFinite(Point2D point)
{
return NumericGuard.IsFinite(point.X) && NumericGuard.IsFinite(point.Y);
}
private static double Distance(Point2D point, SmoothingPoint2D reference)
{
double deltaX = point.X - reference.X;
double deltaY = point.Y - reference.Y;
return Math.Sqrt(deltaX * deltaX + deltaY * deltaY);
}
private static double Distance(SmoothingPoint2D left, SmoothingPoint2D right)
{
double deltaX = left.X - right.X;
double deltaY = left.Y - right.Y;
return Math.Sqrt(deltaX * deltaX + deltaY * deltaY);
}
private readonly struct Window
{
internal Window(int startIndex, int endIndex)
{
StartIndex = startIndex;
EndIndex = endIndex;
}
internal int StartIndex { get; }
internal int EndIndex { get; }
}
private readonly struct Point2D
{
internal Point2D(double x, double y)
{
X = x;
Y = y;
}
internal double X { get; }
internal double Y { get; }
}
}
@@ -1,557 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
using MultiWheelC.TrajectoryPlanning.CoarsePath;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Processing;
using MultiWheelC.TrajectoryPlanning.Utils;
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Algorithms;
/// <summary>按方向段局部弧长构造 C2 连续的分段五次 Hermite 原始几何候选。</summary>
internal sealed class PiecewiseQuinticSmoother : IPathSmoother
{
private const int SamplesPerInterval = 8;
private const double DoubleMachineEpsilon = 2.2204460492503131e-16d;
private const double EndpointNormalizationUlps = 32d;
/// <inheritdoc />
public SmoothingMethod Method => SmoothingMethod.PiecewiseQuintic;
/// <inheritdoc />
public SmoothingCandidate Smooth(
SmoothingAlgorithmInput input,
double effectiveStrength,
CancellationToken cancellationToken)
{
if (input == null || input.OriginalPath == null || input.Options == null ||
!NumericGuard.IsPositiveFinite(effectiveStrength) ||
!NumericGuard.IsFinite(input.MinimumClearanceReserveMeters) ||
input.MinimumClearanceReserveMeters < 0d ||
!NumericGuard.IsPositiveFinite(input.Options.QuinticKnotSpacingMeters) ||
!NumericGuard.IsPositiveFinite(input.Options.QuinticMinimumKnotSpacingMeters) ||
input.Options.QuinticKnotSpacingMeters < input.Options.QuinticMinimumKnotSpacingMeters)
{
return SmoothingCandidate.Failed("五次 Hermite 输入、强度、净空预留或结点间距无效。");
}
var candidateSegments = new List<PreparedDirectionSegment>(input.OriginalPath.Segments.Count);
for (int segmentIndex = 0; segmentIndex < input.OriginalPath.Segments.Count; segmentIndex++)
{
cancellationToken.ThrowIfCancellationRequested();
PreparedDirectionSegment sourceSegment = input.OriginalPath.Segments[segmentIndex];
if (!TrySmoothSegment(
sourceSegment,
effectiveStrength,
input.MinimumClearanceReserveMeters,
input.Options.QuinticKnotSpacingMeters,
input.Options.QuinticMinimumKnotSpacingMeters,
cancellationToken,
out IReadOnlyList<SmoothingPoint2D> points,
out string reason,
out SmoothingCandidateStatus status))
{
return status == SmoothingCandidateStatus.RetryableInfeasible
? SmoothingCandidate.RetryableInfeasible(reason)
: SmoothingCandidate.Failed(reason);
}
candidateSegments.Add(new PreparedDirectionSegment(
sourceSegment.SegmentIndex,
sourceSegment.Direction,
points,
sourceSegment.StartsAtGearSwitch,
sourceSegment.EndsAtGearSwitch,
sourceSegment.StartVehicleCurvaturePerMeter));
}
return SmoothingCandidate.Success(candidateSegments);
}
private static bool TrySmoothSegment(
PreparedDirectionSegment sourceSegment,
double effectiveStrength,
double reserveMeters,
double knotSpacingMeters,
double minimumKnotSpacingMeters,
CancellationToken cancellationToken,
out IReadOnlyList<SmoothingPoint2D> result,
out string reason,
out SmoothingCandidateStatus status)
{
result = null;
reason = string.Empty;
status = SmoothingCandidateStatus.Failed;
if (sourceSegment == null || sourceSegment.Points == null || sourceSegment.Points.Count < 2)
{
reason = "五次 Hermite 方向段至少需要两个锚点。";
return false;
}
IReadOnlyList<SmoothingPoint2D> anchors = sourceSegment.Points;
if (!ValidateAnchors(anchors, cancellationToken, out reason)) return false;
if (!TryCreateKnots(
anchors,
sourceSegment.Direction,
effectiveStrength,
knotSpacingMeters,
minimumKnotSpacingMeters,
cancellationToken,
out List<Knot> knots,
out reason))
{
return false;
}
// Each physical acceleration is blended once into its shared knot and then scaled by
// the left/right local interval independently. Reusing this value is what makes the
// curve C2 with respect to local arc length, even for nonuniform final intervals.
if (!TryAssignSharedAccelerations(knots, out reason)) return false;
if (!TryCreateIntervals(knots, out List<QuinticInterval> intervals, out reason)) return false;
var sampled = new List<SmoothingPoint2D>(1 + intervals.Count * SamplesPerInterval);
for (int intervalIndex = 0; intervalIndex < intervals.Count; intervalIndex++)
{
cancellationToken.ThrowIfCancellationRequested();
QuinticInterval interval = intervals[intervalIndex];
int firstSample = intervalIndex == 0 ? 0 : 1;
for (int sampleIndex = firstSample; sampleIndex <= SamplesPerInterval; sampleIndex++)
{
cancellationToken.ThrowIfCancellationRequested();
double parameter = (double)sampleIndex / SamplesPerInterval;
double referenceArcLength = interval.Start.ArcLength + parameter * interval.Length;
if (!NumericGuard.IsFinite(referenceArcLength))
{
reason = "五次 Hermite 采样参考弧长无效。";
return false;
}
if (!PathReferenceInterpolator.TryInterpolateByArcLength(
anchors,
referenceArcLength,
out SmoothingPoint2D reference,
out reason))
{
return false;
}
Point2D evaluated;
if (sampleIndex == 0)
evaluated = interval.Start.Position;
else if (sampleIndex == SamplesPerInterval)
evaluated = interval.End.Position;
else if (!interval.TryEvaluate(parameter, out evaluated, out Point2D derivative, out Point2D secondDerivative))
{
reason = "五次 Hermite 采样产生非有限位置或导数。";
return false;
}
double displacement = Distance(evaluated, reference);
if (!NumericGuard.IsFinite(displacement))
{
reason = "五次 Hermite 采样位移无效。";
return false;
}
double allowedDisplacement = Math.Max(0d, reference.BodyClearance - reserveMeters);
if (displacement > allowedDisplacement)
{
reason = "五次 Hermite 采样点超过对应局部弧长参考点的允许移动范围。";
status = SmoothingCandidateStatus.RetryableInfeasible;
return false;
}
bool firstEndpoint = intervalIndex == 0 && sampleIndex == 0;
bool lastEndpoint = intervalIndex == intervals.Count - 1 && sampleIndex == SamplesPerInterval;
if (firstEndpoint)
{
sampled.Add(anchors[0]);
}
else if (lastEndpoint)
{
sampled.Add(anchors[anchors.Count - 1]);
}
else
{
sampled.Add(new SmoothingPoint2D(
evaluated.X,
evaluated.Y,
reference.ArcLength,
reference.Heading,
reference.UnwrappedHeading,
reference.BodyClearance,
false,
SmoothedPathPointSource.Interpolated));
}
}
}
result = sampled;
return true;
}
private static bool ValidateAnchors(
IReadOnlyList<SmoothingPoint2D> anchors,
CancellationToken cancellationToken,
out string reason)
{
reason = string.Empty;
for (int index = 0; index < anchors.Count; index++)
{
cancellationToken.ThrowIfCancellationRequested();
SmoothingPoint2D point = anchors[index];
if (point == null || !NumericGuard.IsFinite(point.X) || !NumericGuard.IsFinite(point.Y) ||
!NumericGuard.IsFinite(point.ArcLength) || !NumericGuard.IsFinite(point.Heading) ||
!NumericGuard.IsFinite(point.UnwrappedHeading) || !NumericGuard.IsFinite(point.BodyClearance) ||
point.ArcLength < 0d || point.BodyClearance < 0d ||
(index > 0 && point.ArcLength <= anchors[index - 1].ArcLength))
{
reason = "五次 Hermite 方向段包含非有限、非递增弧长或无效净空的锚点。";
return false;
}
}
return true;
}
private static bool TryCreateKnots(
IReadOnlyList<SmoothingPoint2D> anchors,
TravelDirection direction,
double effectiveStrength,
double knotSpacingMeters,
double minimumKnotSpacingMeters,
CancellationToken cancellationToken,
out List<Knot> knots,
out string reason)
{
knots = new List<Knot>();
reason = string.Empty;
double startArcLength = anchors[0].ArcLength;
double endArcLength = anchors[anchors.Count - 1].ArcLength;
double totalLength = endArcLength - startArcLength;
if (!NumericGuard.IsPositiveFinite(totalLength) || totalLength < minimumKnotSpacingMeters)
{
reason = "五次 Hermite 方向段短于配置的最小结点间距。";
return false;
}
if (!TryCreateKnot(anchors[0], direction, effectiveStrength, out Knot first))
{
reason = "五次 Hermite 起点结点或行进切向无效。";
return false;
}
knots.Add(first);
double endpointTolerance = GetEndpointNormalizationTolerance(
startArcLength,
endArcLength,
knotSpacingMeters);
double previousArcLength = startArcLength;
for (long knotOrdinal = 1L; ; knotOrdinal++)
{
cancellationToken.ThrowIfCancellationRequested();
double targetArcLength = startArcLength + knotOrdinal * knotSpacingMeters;
if (!NumericGuard.IsFinite(targetArcLength))
{
reason = "五次 Hermite 内部结点弧长无效。";
return false;
}
if (targetArcLength >= endArcLength - endpointTolerance) break;
if (targetArcLength <= previousArcLength)
{
reason = "五次 Hermite 内部结点无法在浮点弧长尺度上保持递增。";
return false;
}
if (!PathReferenceInterpolator.TryInterpolateByArcLength(
anchors,
targetArcLength,
out SmoothingPoint2D reference,
out reason) ||
!TryCreateKnot(reference, direction, effectiveStrength, out Knot knot))
{
if (string.IsNullOrEmpty(reason)) reason = "五次 Hermite 内部结点或行进切向无效。";
return false;
}
knots.Add(knot);
previousArcLength = targetArcLength;
}
if (!TryCreateKnot(anchors[anchors.Count - 1], direction, effectiveStrength, out Knot last))
{
reason = "五次 Hermite 终点结点或行进切向无效。";
return false;
}
knots.Add(last);
for (int index = 1; index < knots.Count; index++)
{
double intervalLength = knots[index].ArcLength - knots[index - 1].ArcLength;
if (!NumericGuard.IsPositiveFinite(intervalLength) || intervalLength < minimumKnotSpacingMeters)
{
reason = "五次 Hermite 结点间隔无效或短于配置的最小间距。";
return false;
}
}
return true;
}
private static double GetEndpointNormalizationTolerance(
double startArcLength,
double endArcLength,
double knotSpacingMeters)
{
double magnitude = Math.Max(
Math.Abs(startArcLength),
Math.Max(Math.Abs(endArcLength), Math.Abs(knotSpacingMeters)));
return EndpointNormalizationUlps * DoubleMachineEpsilon * magnitude;
}
private static bool TryCreateKnot(
SmoothingPoint2D reference,
TravelDirection direction,
double effectiveStrength,
out Knot knot)
{
knot = default;
if (reference == null || !NumericGuard.IsFinite(reference.X) || !NumericGuard.IsFinite(reference.Y) ||
!NumericGuard.IsFinite(reference.ArcLength) || !NumericGuard.IsFinite(reference.Heading))
{
return false;
}
double travelHeading = direction == TravelDirection.Forward
? reference.Heading
: reference.Heading - Math.PI;
double tangentX = Math.Cos(travelHeading);
double tangentY = Math.Sin(travelHeading);
if (!NumericGuard.IsFinite(tangentX) || !NumericGuard.IsFinite(tangentY)) return false;
var velocity = new Point2D(tangentX * effectiveStrength, tangentY * effectiveStrength);
if (!velocity.IsFinite) return false;
knot = new Knot(reference.ArcLength, new Point2D(reference.X, reference.Y), velocity);
return true;
}
private static bool TryAssignSharedAccelerations(List<Knot> knots, out string reason)
{
reason = string.Empty;
for (int index = 0; index < knots.Count; index++)
{
Point2D acceleration;
if (index == 0)
{
if (!TryAcceleration(knots[0], knots[1], out acceleration))
{
reason = "五次 Hermite 起点加速度无效。";
return false;
}
}
else if (index == knots.Count - 1)
{
if (!TryAcceleration(knots[index - 1], knots[index], out acceleration))
{
reason = "五次 Hermite 终点加速度无效。";
return false;
}
}
else
{
if (!TryAcceleration(knots[index - 1], knots[index], out Point2D left) ||
!TryAcceleration(knots[index], knots[index + 1], out Point2D right))
{
reason = "五次 Hermite 共享结点加速度无效。";
return false;
}
acceleration = new Point2D((left.X + right.X) / 2d, (left.Y + right.Y) / 2d);
if (!acceleration.IsFinite)
{
reason = "五次 Hermite 共享结点加速度混合产生非有限数值。";
return false;
}
}
knots[index] = knots[index].WithAcceleration(acceleration);
}
return true;
}
private static bool TryAcceleration(Knot start, Knot end, out Point2D acceleration)
{
acceleration = default;
double intervalLength = end.ArcLength - start.ArcLength;
if (!NumericGuard.IsPositiveFinite(intervalLength)) return false;
acceleration = new Point2D(
(end.Velocity.X - start.Velocity.X) / intervalLength,
(end.Velocity.Y - start.Velocity.Y) / intervalLength);
return acceleration.IsFinite;
}
private static bool TryCreateIntervals(
IReadOnlyList<Knot> knots,
out List<QuinticInterval> intervals,
out string reason)
{
intervals = new List<QuinticInterval>(knots.Count - 1);
reason = string.Empty;
for (int index = 1; index < knots.Count; index++)
{
if (!QuinticInterval.TryCreate(knots[index - 1], knots[index], out QuinticInterval interval))
{
reason = "五次 Hermite 系数、端点导数或结点区间无效。";
return false;
}
intervals.Add(interval);
}
return true;
}
private static double Distance(Point2D point, SmoothingPoint2D reference)
{
double deltaX = point.X - reference.X;
double deltaY = point.Y - reference.Y;
return Math.Sqrt(deltaX * deltaX + deltaY * deltaY);
}
private readonly struct Knot
{
internal Knot(double arcLength, Point2D position, Point2D velocity)
{
ArcLength = arcLength;
Position = position;
Velocity = velocity;
Acceleration = default;
}
internal double ArcLength { get; }
internal Point2D Position { get; }
internal Point2D Velocity { get; }
internal Point2D Acceleration { get; }
internal Knot WithAcceleration(Point2D acceleration)
{
return new Knot(ArcLength, Position, Velocity, acceleration);
}
private Knot(double arcLength, Point2D position, Point2D velocity, Point2D acceleration)
{
ArcLength = arcLength;
Position = position;
Velocity = velocity;
Acceleration = acceleration;
}
}
private readonly struct QuinticInterval
{
private QuinticInterval(Knot start, Knot end, Point2D c0, Point2D c1, Point2D c2, Point2D c3, Point2D c4, Point2D c5)
{
Start = start;
End = end;
Length = end.ArcLength - start.ArcLength;
_c0 = c0;
_c1 = c1;
_c2 = c2;
_c3 = c3;
_c4 = c4;
_c5 = c5;
}
private readonly Point2D _c0;
private readonly Point2D _c1;
private readonly Point2D _c2;
private readonly Point2D _c3;
private readonly Point2D _c4;
private readonly Point2D _c5;
internal Knot Start { get; }
internal Knot End { get; }
internal double Length { get; }
internal static bool TryCreate(Knot start, Knot end, out QuinticInterval interval)
{
interval = default;
double length = end.ArcLength - start.ArcLength;
if (!NumericGuard.IsPositiveFinite(length) || !start.Position.IsFinite || !end.Position.IsFinite ||
!start.Velocity.IsFinite || !end.Velocity.IsFinite ||
!start.Acceleration.IsFinite || !end.Acceleration.IsFinite)
{
return false;
}
Point2D c0 = start.Position;
Point2D c1 = Scale(start.Velocity, length);
Point2D c2 = Scale(start.Acceleration, length * length / 2d);
Point2D difference = Subtract(end.Position, start.Position);
Point2D endVelocity = Scale(end.Velocity, length);
Point2D startAcceleration = Scale(start.Acceleration, length * length);
Point2D endAcceleration = Scale(end.Acceleration, length * length);
Point2D c3 = Add(
Add(Scale(difference, 10d), Scale(c1, -6d)),
Add(Scale(endVelocity, -4d), Add(Scale(startAcceleration, -1.5d), Scale(endAcceleration, 0.5d))));
Point2D c4 = Add(
Add(Scale(difference, -15d), Scale(c1, 8d)),
Add(Scale(endVelocity, 7d), Add(Scale(startAcceleration, 1.5d), Scale(endAcceleration, -1d))));
Point2D c5 = Add(
Add(Scale(difference, 6d), Add(Scale(c1, -3d), Scale(endVelocity, -3d))),
Add(Scale(startAcceleration, -0.5d), Scale(endAcceleration, 0.5d)));
if (!c0.IsFinite || !c1.IsFinite || !c2.IsFinite || !c3.IsFinite || !c4.IsFinite || !c5.IsFinite)
return false;
interval = new QuinticInterval(start, end, c0, c1, c2, c3, c4, c5);
return interval.TryEvaluate(0d, out _, out _, out _) && interval.TryEvaluate(1d, out _, out _, out _);
}
internal bool TryEvaluate(double parameter, out Point2D position, out Point2D derivative, out Point2D secondDerivative)
{
position = default;
derivative = default;
secondDerivative = default;
if (!NumericGuard.IsFinite(parameter) || parameter < 0d || parameter > 1d) return false;
double t2 = parameter * parameter;
double t3 = t2 * parameter;
double t4 = t3 * parameter;
double t5 = t4 * parameter;
position = Add(Add(Add(_c0, Scale(_c1, parameter)), Add(Scale(_c2, t2), Scale(_c3, t3))),
Add(Scale(_c4, t4), Scale(_c5, t5)));
derivative = Add(Add(_c1, Scale(_c2, 2d * parameter)),
Add(Scale(_c3, 3d * t2), Add(Scale(_c4, 4d * t3), Scale(_c5, 5d * t4))));
secondDerivative = Add(Scale(_c2, 2d),
Add(Scale(_c3, 6d * parameter), Add(Scale(_c4, 12d * t2), Scale(_c5, 20d * t3))));
return position.IsFinite && derivative.IsFinite && secondDerivative.IsFinite;
}
}
private readonly struct Point2D
{
internal Point2D(double x, double y)
{
X = x;
Y = y;
}
internal double X { get; }
internal double Y { get; }
internal bool IsFinite => NumericGuard.IsFinite(X) && NumericGuard.IsFinite(Y);
}
private static Point2D Add(Point2D left, Point2D right)
{
return new Point2D(left.X + right.X, left.Y + right.Y);
}
private static Point2D Subtract(Point2D left, Point2D right)
{
return new Point2D(left.X - right.X, left.Y - right.Y);
}
private static Point2D Scale(Point2D point, double scale)
{
return new Point2D(point.X * scale, point.Y * scale);
}
}
@@ -1,44 +0,0 @@
using System;
using MultiWheelC.TrajectoryPlanning.CoarsePath;
using MultiWheelC.TrajectoryPlanning.Mapping;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Processing;
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Algorithms;
/// <summary>单次算法运行共享的已预处理路径和独立复核上下文。</summary>
internal sealed class SmoothingAlgorithmInput
{
internal SmoothingAlgorithmInput(
PreparedPath originalPath,
PlanningGridMap map,
VehicleParameters vehicle,
double maximumCollisionCheckStepMeters,
double minimumClearanceReserveMeters,
SmoothingOptionsSnapshot options)
{
OriginalPath = originalPath ?? throw new ArgumentNullException(nameof(originalPath));
Map = map ?? throw new ArgumentNullException(nameof(map));
Vehicle = vehicle ?? throw new ArgumentNullException(nameof(vehicle));
MaximumCollisionCheckStepMeters = maximumCollisionCheckStepMeters;
MinimumClearanceReserveMeters = minimumClearanceReserveMeters;
Options = options ?? throw new ArgumentNullException(nameof(options));
}
/// <summary>已校验并按方向分段的原始路径。</summary>
internal PreparedPath OriginalPath { get; }
/// <summary>用于完整车体复核的不可变规划地图。</summary>
internal PlanningGridMap Map { get; }
/// <summary>用于曲率和足迹复核的车辆参数快照。</summary>
internal VehicleParameters Vehicle { get; }
/// <summary>连续车体碰撞检查的最大步长,单位 m。</summary>
internal double MaximumCollisionCheckStepMeters { get; }
/// <summary>候选几何必须从原始保守净空中预留的最小安全余量,单位 m。</summary>
internal double MinimumClearanceReserveMeters { get; }
/// <summary>本次算法运行使用的已验证方法选项快照。</summary>
internal SmoothingOptionsSnapshot Options { get; }
}
@@ -1,443 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using MultiWheelC.TrajectoryPlanning.CoarsePath;
using MultiWheelC.TrajectoryPlanning.Mapping;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Processing;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Validation;
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Algorithms;
/// <summary>在有限强度计划内运行单一算法,并以共享分析和安全复核决定是否接受候选。</summary>
internal sealed class SmoothingAlgorithmRunner
{
private static readonly double[] RetryStrengthScales = { 1d, 0.75d, 0.50d, 0.25d };
private readonly PathGeometryAnalyzer _analyzer;
private readonly SmoothedPathValidator _validator;
internal SmoothingAlgorithmRunner()
: this(new PathGeometryAnalyzer(), new SmoothedPathValidator())
{
}
internal SmoothingAlgorithmRunner(PathGeometryAnalyzer analyzer, SmoothedPathValidator validator)
{
_analyzer = analyzer ?? throw new ArgumentNullException(nameof(analyzer));
_validator = validator ?? throw new ArgumentNullException(nameof(validator));
}
/// <summary>
/// 依次尝试配置的有限强度比例。取消直接向上传播,由门面转换为最终状态;
/// 只有算法明确标记为可重试的不可行性才会使用较低强度;终止失败和统一复核失败均不重试。
/// </summary>
internal AlgorithmRunResult Run(
IPathSmoother smoother,
SmoothingAlgorithmInput input,
PathSmoothingConfiguration configuration,
CancellationToken cancellationToken)
{
var attemptedStrengths = new List<double>();
var failureReasons = new List<string>();
if (smoother == null || input == null || input.Options == null || configuration == null)
return AlgorithmRunResult.Failed("平滑算法、输入或配置无效。", attemptedStrengths, failureReasons);
foreach (double scale in RetryStrengthScales)
{
cancellationToken.ThrowIfCancellationRequested();
double effectiveStrength = configuration.SmoothingStrength * scale;
if (!IsPositiveFinite(effectiveStrength))
return AlgorithmRunResult.Failed("平滑强度或重试比例无效。", attemptedStrengths, failureReasons);
attemptedStrengths.Add(effectiveStrength);
SmoothingCandidate candidate = smoother.Smooth(input, effectiveStrength, cancellationToken);
if (candidate == null)
return AlgorithmRunResult.Failed("平滑算法未返回候选。", attemptedStrengths, failureReasons);
if (candidate.Status == SmoothingCandidateStatus.RetryableInfeasible)
{
failureReasons.Add(candidate.Reason);
continue;
}
if (candidate.Status == SmoothingCandidateStatus.Failed)
{
failureReasons.Add(candidate.Reason);
return AlgorithmRunResult.Failed(candidate.Reason, attemptedStrengths, failureReasons);
}
if (candidate.Status != SmoothingCandidateStatus.Success)
{
const string unknownStatusReason = "平滑算法返回未知候选状态。";
failureReasons.Add(unknownStatusReason);
return AlgorithmRunResult.Failed(unknownStatusReason, attemptedStrengths, failureReasons);
}
if (!_analyzer.TryAnalyze(candidate.Segments, configuration.OutputSpacingMeters,
out PathGeometryAnalysis analysis, out string reason))
{
failureReasons.Add(reason);
return AlgorithmRunResult.Failed(reason, attemptedStrengths, failureReasons);
}
if (_validator.TryValidate(analysis.Path, analysis.Segments, input.OriginalPath, input.Map, input.Vehicle,
input.MaximumCollisionCheckStepMeters, out IReadOnlyList<SmoothedPathPoint> safePath,
out double minimumClearanceMeters, out reason))
{
return AlgorithmRunResult.Success(safePath, analysis.Segments,
CreateMetrics(analysis, minimumClearanceMeters), effectiveStrength,
attemptedStrengths, failureReasons);
}
failureReasons.Add(reason);
return AlgorithmRunResult.Failed(reason, attemptedStrengths, failureReasons);
}
return AlgorithmRunResult.Infeasible(null, attemptedStrengths, failureReasons);
}
private static PathQualityMetrics CreateMetrics(PathGeometryAnalysis analysis, double minimumClearanceMeters)
{
return new PathQualityMetrics(
true,
analysis.PathLengthMeters,
analysis.MaximumAbsoluteVehicleCurvaturePerMeter,
analysis.MaximumAbsoluteVehicleCurvatureDerivativePerSquareMeter,
analysis.RootMeanSquareVehicleCurvaturePerMeter,
analysis.TotalAbsoluteCurvatureVariationPerMeter,
analysis.CurvatureVariationEnergy,
minimumClearanceMeters,
0d,
0d,
0d,
0d);
}
private static bool IsPositiveFinite(double value)
{
return !double.IsNaN(value) && !double.IsInfinity(value) && value > 0d;
}
/// <summary>
/// Reflection-only deterministic coverage seam. It is nested in an internal runner and intentionally
/// does not construct or register a production smoothing method.
/// </summary>
public static class TestHooks
{
/// <summary>执行一个固定的内部假平滑器场景并返回可反射读取的快照。</summary>
public static RunnerTestSnapshot Execute(string scenario)
{
if (string.IsNullOrWhiteSpace(scenario)) throw new ArgumentException("A scenario is required.", nameof(scenario));
var cancellationSource = new CancellationTokenSource();
var smoother = new DeterministicTestSmoother(ParseScenario(scenario), cancellationSource);
var runner = new SmoothingAlgorithmRunner();
SmoothingAlgorithmInput input = CreateTestInput();
var configuration = new PathSmoothingConfiguration();
try
{
AlgorithmRunResult result = runner.Run(smoother, input, configuration, cancellationSource.Token);
return RunnerTestSnapshot.FromResult(result, false);
}
catch (OperationCanceledException)
{
return new RunnerTestSnapshot(
"OperationCanceledException",
smoother.AttemptedStrengths,
0,
0,
0,
true);
}
finally
{
cancellationSource.Dispose();
}
}
/// <summary>供 PowerShell 断言使用的不可变执行摘要。</summary>
public sealed class RunnerTestSnapshot
{
internal RunnerTestSnapshot(
string status,
IReadOnlyList<double> attemptedStrengths,
int acceptedPathPointCount,
int rejectedComparisonCandidatePointCount,
int failureCount,
bool cancellationPropagated)
{
Status = status ?? string.Empty;
AttemptedStrengths = CopyReadOnly(attemptedStrengths);
AcceptedPathPointCount = acceptedPathPointCount;
RejectedComparisonCandidatePointCount = rejectedComparisonCandidatePointCount;
FailureCount = failureCount;
CancellationPropagated = cancellationPropagated;
}
public string Status { get; }
public IReadOnlyList<double> AttemptedStrengths { get; }
public int AcceptedPathPointCount { get; }
public int RejectedComparisonCandidatePointCount { get; }
public int FailureCount { get; }
public bool CancellationPropagated { get; }
internal static RunnerTestSnapshot FromResult(AlgorithmRunResult result, bool cancellationPropagated)
{
int rejectedPointCount = result.RejectedComparisonCandidate == null
? 0
: CountPoints(result.RejectedComparisonCandidate.Segments);
return new RunnerTestSnapshot(
result.Status.ToString(),
result.AttemptedStrengths,
result.Path.Count,
rejectedPointCount,
result.FailureReasons.Count,
cancellationPropagated);
}
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);
}
}
private enum TestScenario
{
RetryableInfeasible,
AcceptFirst,
TerminalFailed,
CancelBeforeNextAttempt,
}
private sealed class DeterministicTestSmoother : IPathSmoother
{
private readonly TestScenario _scenario;
private readonly CancellationTokenSource _cancellationSource;
internal DeterministicTestSmoother(TestScenario scenario, CancellationTokenSource cancellationSource)
{
_scenario = scenario;
_cancellationSource = cancellationSource;
AttemptedStrengths = new List<double>();
}
public SmoothingMethod Method => SmoothingMethod.CubicBSpline;
internal List<double> AttemptedStrengths { get; }
public SmoothingCandidate Smooth(
SmoothingAlgorithmInput input,
double effectiveStrength,
CancellationToken cancellationToken)
{
AttemptedStrengths.Add(effectiveStrength);
if (_scenario == TestScenario.TerminalFailed)
return SmoothingCandidate.Failed("确定性数值退化。");
if (_scenario == TestScenario.AcceptFirst)
return CreateAcceptedCandidate();
if (_scenario == TestScenario.CancelBeforeNextAttempt)
_cancellationSource.Cancel();
return SmoothingCandidate.RetryableInfeasible("确定性可重试不可行。" );
}
}
private static TestScenario ParseScenario(string scenario)
{
if (string.Equals(scenario, nameof(TestScenario.RetryableInfeasible), StringComparison.Ordinal)) return TestScenario.RetryableInfeasible;
if (string.Equals(scenario, nameof(TestScenario.AcceptFirst), StringComparison.Ordinal)) return TestScenario.AcceptFirst;
if (string.Equals(scenario, nameof(TestScenario.TerminalFailed), StringComparison.Ordinal)) return TestScenario.TerminalFailed;
if (string.Equals(scenario, nameof(TestScenario.CancelBeforeNextAttempt), StringComparison.Ordinal)) return TestScenario.CancelBeforeNextAttempt;
throw new ArgumentOutOfRangeException(nameof(scenario));
}
private static SmoothingAlgorithmInput CreateTestInput()
{
var mapRequest = new PlanningMapRequest
{
Bounds = new MapBoundsMm(0f, 5000f, 0f, 5000f),
ResolutionMm = 50f,
AllowExplicitEmptyMap = true,
};
PlanningMapBuildResult mapResult = new PlanningMapFactory().Create(mapRequest);
if (!mapResult.Succeeded || mapResult.Map == null)
throw new InvalidOperationException("The runner test hook could not create its empty map.");
var originalSegments = new List<PreparedDirectionSegment>
{
new PreparedDirectionSegment(
0,
TravelDirection.Forward,
new List<SmoothingPoint2D>
{
CreatePoint(0.5d, 0.5d, 0d),
CreatePoint(1.5d, 0.5d, 1d),
},
false,
false),
};
var vehicle = new VehicleParameters
{
LengthMeters = 0.20d,
WidthMeters = 0.20d,
SafetyMarginMeters = 0d,
MaximumCurvaturePerMeter = 100d,
MinimumTurningRadiusMeters = 0.01d,
};
return new SmoothingAlgorithmInput(
new PreparedPath(originalSegments),
mapResult.Map,
vehicle,
0.05d,
0.02d,
new SmoothingOptionsSnapshot(new PathSmoothingConfiguration()));
}
private static SmoothingCandidate CreateAcceptedCandidate()
{
return SmoothingCandidate.Success(new List<PreparedDirectionSegment>
{
new PreparedDirectionSegment(
0,
TravelDirection.Forward,
new List<SmoothingPoint2D>
{
CreatePoint(0.5d, 0.5d, 0d),
CreatePoint(1.5d, 0.5d, 1d),
},
false,
false),
});
}
private static SmoothingPoint2D CreatePoint(double x, double y, double arcLength)
{
return new SmoothingPoint2D(
x,
y,
arcLength,
0d,
0d,
1d,
false,
SmoothedPathPointSource.Anchor);
}
private static int CountPoints(IReadOnlyList<PreparedDirectionSegment> segments)
{
int count = 0;
for (int index = 0; index < segments.Count; index++) count += segments[index].Points.Count;
return count;
}
}
/// <summary>内部运行结果;只有成功路径可被正式门面发布,拒绝候选仅供比较诊断读取。</summary>
internal sealed class AlgorithmRunResult
{
private static readonly IReadOnlyList<SmoothedPathPoint> EmptyPath =
new ReadOnlyCollection<SmoothedPathPoint>(new List<SmoothedPathPoint>());
private static readonly IReadOnlyList<SmoothedPathSegment> EmptySegments =
new ReadOnlyCollection<SmoothedPathSegment>(new List<SmoothedPathSegment>());
private AlgorithmRunResult(
PathSmoothingStatus status,
IReadOnlyList<SmoothedPathPoint> path,
IReadOnlyList<SmoothedPathSegment> segments,
PathQualityMetrics metrics,
double acceptedStrength,
SmoothingCandidate rejectedComparisonCandidate,
IReadOnlyList<double> attemptedStrengths,
IReadOnlyList<string> failureReasons,
string reason)
{
Status = status;
Path = path ?? EmptyPath;
Segments = segments ?? EmptySegments;
Metrics = metrics ?? new PathQualityMetrics();
AcceptedStrength = acceptedStrength;
RejectedComparisonCandidate = rejectedComparisonCandidate;
AttemptedStrengths = CopyReadOnly(attemptedStrengths);
FailureReasons = CopyReadOnly(failureReasons);
Reason = reason ?? string.Empty;
}
internal PathSmoothingStatus Status { get; }
internal IReadOnlyList<SmoothedPathPoint> Path { get; }
internal IReadOnlyList<SmoothedPathSegment> Segments { get; }
internal PathQualityMetrics Metrics { get; }
internal double AcceptedStrength { get; }
internal SmoothingCandidate RejectedComparisonCandidate { get; }
internal IReadOnlyList<double> AttemptedStrengths { get; }
internal IReadOnlyList<string> FailureReasons { get; }
internal string Reason { get; }
internal static AlgorithmRunResult Success(
IReadOnlyList<SmoothedPathPoint> path,
IReadOnlyList<SmoothedPathSegment> segments,
PathQualityMetrics metrics,
double acceptedStrength,
IReadOnlyList<double> attemptedStrengths,
IReadOnlyList<string> failureReasons)
{
return new AlgorithmRunResult(
PathSmoothingStatus.Success,
CopyReadOnly(path),
CopyReadOnly(segments),
metrics,
acceptedStrength,
null,
attemptedStrengths,
failureReasons,
string.Empty);
}
internal static AlgorithmRunResult Infeasible(
SmoothingCandidate rejectedComparisonCandidate,
IReadOnlyList<double> attemptedStrengths,
IReadOnlyList<string> failureReasons)
{
string reason = failureReasons == null || failureReasons.Count == 0
? "所有有限平滑尝试均未通过复核。"
: failureReasons[failureReasons.Count - 1];
return new AlgorithmRunResult(
PathSmoothingStatus.Infeasible,
null,
null,
null,
0d,
rejectedComparisonCandidate,
attemptedStrengths,
failureReasons,
reason);
}
internal static AlgorithmRunResult Failed(
string reason,
IReadOnlyList<double> attemptedStrengths,
IReadOnlyList<string> failureReasons)
{
return new AlgorithmRunResult(
PathSmoothingStatus.Failed,
null,
null,
null,
0d,
null,
attemptedStrengths,
failureReasons,
reason);
}
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);
}
}
}
@@ -1,83 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Processing;
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Algorithms;
internal enum SmoothingCandidateStatus
{
Success,
RetryableInfeasible,
Failed,
}
/// <summary>平滑方法产生的原始方向段候选,尚未经过统一几何或安全复核。</summary>
internal sealed class SmoothingCandidate
{
private SmoothingCandidate(
SmoothingCandidateStatus status,
IReadOnlyList<PreparedDirectionSegment> segments,
string reason)
{
Status = status;
if (status == SmoothingCandidateStatus.Success)
{
if (segments == null || segments.Count == 0)
throw new ArgumentException("A successful smoothing candidate requires direction segments.", nameof(segments));
for (int index = 0; index < segments.Count; index++)
{
if (segments[index] == null || segments[index].Points == null || segments[index].Points.Count == 0)
throw new ArgumentException("A successful smoothing candidate requires complete direction segments.", nameof(segments));
}
Segments = CopyReadOnly(segments);
Reason = string.Empty;
return;
}
if (string.IsNullOrWhiteSpace(reason))
throw new ArgumentException("An unsuccessful smoothing candidate requires a reason.", nameof(reason));
Segments = CopyReadOnly<PreparedDirectionSegment>(null);
Reason = reason;
}
/// <summary>候选是否成功产生有限的原始几何。</summary>
internal bool Succeeded => Status == SmoothingCandidateStatus.Success;
/// <summary>候选的可重试性和终止性状态。</summary>
internal SmoothingCandidateStatus Status { get; }
/// <summary>候选方向段;失败候选始终为空。</summary>
internal IReadOnlyList<PreparedDirectionSegment> Segments { get; }
/// <summary>失败或退化时的稳定说明;成功时为空。</summary>
internal string Reason { get; }
/// <summary>创建待统一分析和验证的成功候选。</summary>
internal static SmoothingCandidate Success(IReadOnlyList<PreparedDirectionSegment> segments)
{
return new SmoothingCandidate(SmoothingCandidateStatus.Success, segments, string.Empty);
}
/// <summary>创建可由较低平滑强度重新尝试的不可行候选。</summary>
internal static SmoothingCandidate RetryableInfeasible(string reason)
{
return new SmoothingCandidate(SmoothingCandidateStatus.RetryableInfeasible, null, reason);
}
/// <summary>创建不应重试的数值或构造失败候选。</summary>
internal static SmoothingCandidate Failed(string reason)
{
return new SmoothingCandidate(SmoothingCandidateStatus.Failed, null, reason);
}
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);
}
}
@@ -1,50 +0,0 @@
using System;
using MultiWheelC.TrajectoryPlanning.Utils;
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Algorithms;
/// <summary>供单次平滑算法运行使用的、已校验的不可变方法选项快照。</summary>
internal sealed class SmoothingOptionsSnapshot
{
internal SmoothingOptionsSnapshot(PathSmoothingConfiguration configuration)
{
if (configuration == null) throw new ArgumentNullException(nameof(configuration));
CubicBSplineEndpointTangentScale = configuration.CubicBSpline.EndpointTangentScale;
BezierCornerHeadingThresholdRadians = configuration.LocalCubicBezier.CornerHeadingThresholdRadians;
BezierMaximumWindowLengthMeters = configuration.LocalCubicBezier.MaximumWindowLengthMeters;
BezierHandleLengthRatio = configuration.LocalCubicBezier.HandleLengthRatio;
QuinticKnotSpacingMeters = configuration.PiecewiseQuintic.KnotSpacingMeters;
QuinticMinimumKnotSpacingMeters = configuration.PiecewiseQuintic.MinimumKnotSpacingMeters;
ValidatePositiveFinite(CubicBSplineEndpointTangentScale, nameof(CubicBSplineEndpointTangentScale));
if (!NumericGuard.IsFinite(BezierCornerHeadingThresholdRadians) ||
BezierCornerHeadingThresholdRadians <= 0d || BezierCornerHeadingThresholdRadians > Math.PI)
{
throw new ArgumentOutOfRangeException(nameof(BezierCornerHeadingThresholdRadians));
}
ValidatePositiveFinite(BezierMaximumWindowLengthMeters, nameof(BezierMaximumWindowLengthMeters));
ValidatePositiveFinite(BezierHandleLengthRatio, nameof(BezierHandleLengthRatio));
ValidatePositiveFinite(QuinticKnotSpacingMeters, nameof(QuinticKnotSpacingMeters));
ValidatePositiveFinite(QuinticMinimumKnotSpacingMeters, nameof(QuinticMinimumKnotSpacingMeters));
if (QuinticKnotSpacingMeters < QuinticMinimumKnotSpacingMeters)
throw new ArgumentOutOfRangeException(nameof(QuinticKnotSpacingMeters));
}
internal double CubicBSplineEndpointTangentScale { get; }
internal double BezierCornerHeadingThresholdRadians { get; }
internal double BezierMaximumWindowLengthMeters { get; }
internal double BezierHandleLengthRatio { get; }
internal double QuinticKnotSpacingMeters { get; }
internal double QuinticMinimumKnotSpacingMeters { get; }
private static void ValidatePositiveFinite(double value, string name)
{
if (!NumericGuard.IsPositiveFinite(value)) throw new ArgumentOutOfRangeException(name);
}
}
@@ -1,60 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Comparison;
/// <summary>同一粗路径的离线平滑比较请求。</summary>
public sealed class PathSmoothingComparisonRequest
{
private static readonly SmoothingMethod[] DefaultMethods =
{
SmoothingMethod.CubicBSpline,
SmoothingMethod.LocalCubicBezier,
SmoothingMethod.PiecewiseQuintic,
};
/// <summary>创建比较请求,并固定原始输入与方法顺序。</summary>
public PathSmoothingComparisonRequest(
PathSmoothingRequest smoothingRequest,
IReadOnlyList<SmoothingMethod> methods = null)
{
SmoothingRequest = CopyRequest(smoothingRequest);
Methods = CopyMethods(methods ?? DefaultMethods);
}
/// <summary>所有方法共享的不可变粗路径、地图、车辆和配置快照。</summary>
public PathSmoothingRequest SmoothingRequest { get; }
/// <summary>按调用方指定稳定顺序运行的方法集合。</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);
}
private static IReadOnlyList<SmoothingMethod> CopyMethods(IReadOnlyList<SmoothingMethod> source)
{
var copy = new List<SmoothingMethod>(source == null ? 0 : source.Count);
if (source != null)
{
for (int index = 0; index < source.Count; index++)
{
SmoothingMethod method = source[index];
if (!Enum.IsDefined(typeof(SmoothingMethod), method))
throw new ArgumentOutOfRangeException(nameof(source), "比较方法无效。");
if (copy.Contains(method))
throw new ArgumentException("比较方法不能重复。", nameof(source));
copy.Add(method);
}
}
return new ReadOnlyCollection<SmoothingMethod>(copy);
}
}
@@ -1,60 +0,0 @@
using System;
using System.Collections.Generic;
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Comparison;
/// <summary>按公开字典序选择唯一的推荐平滑方法。</summary>
public static class SmoothingMethodRanker
{
/// <summary>从当前场景的可行且确定性候选中选择最佳方法;没有合格候选时返回空。</summary>
public static SmoothingMethod? Rank(IReadOnlyList<PathSmoothingComparisonEntry> entries)
{
PathSmoothingComparisonEntry best = null;
if (entries == null) return null;
for (int index = 0; index < entries.Count; index++)
{
PathSmoothingComparisonEntry candidate = entries[index];
if (candidate == null || !candidate.IsEligibleForRecommendation) continue;
if (best == null || Compare(candidate, best) < 0) best = candidate;
}
return best == null ? (SmoothingMethod?)null : best.Method;
}
private static int Compare(PathSmoothingComparisonEntry left, PathSmoothingComparisonEntry right)
{
int comparison = CompareAscending(left.Metrics.CurvatureVariationEnergy, right.Metrics.CurvatureVariationEnergy);
if (comparison != 0) return comparison;
comparison = CompareAscending(
left.Metrics.MaximumAbsoluteVehicleCurvaturePerMeter,
right.Metrics.MaximumAbsoluteVehicleCurvaturePerMeter);
if (comparison != 0) return comparison;
comparison = CompareDescending(left.Metrics.MinimumBodyClearanceMeters, right.Metrics.MinimumBodyClearanceMeters);
if (comparison != 0) return comparison;
comparison = CompareAscending(left.Metrics.LengthChangePercent, right.Metrics.LengthChangePercent);
if (comparison != 0) return comparison;
comparison = CompareAscending(left.Timing.MedianElapsedMilliseconds, right.Timing.MedianElapsedMilliseconds);
if (comparison != 0) return comparison;
return ((int)left.Method.Value).CompareTo((int)right.Method.Value);
}
private static int CompareAscending(double left, double right)
{
return Normalize(left).CompareTo(Normalize(right));
}
private static int CompareDescending(double left, double right)
{
return Normalize(right).CompareTo(Normalize(left));
}
private static double Normalize(double value)
{
return double.IsNaN(value) || double.IsInfinity(value) ? double.PositiveInfinity : value;
}
}
@@ -1,8 +0,0 @@
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing;
/// <summary>三次 B 样条平滑参数。</summary>
public sealed class CubicBSplineOptions
{
/// <summary>端点切向控制柄相对于相邻弦长的比例。</summary>
public double EndpointTangentScale { get; set; } = 1d / 3d;
}
@@ -1,16 +0,0 @@
using System;
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing;
/// <summary>局部三次 Bézier 平滑参数。</summary>
public sealed class LocalCubicBezierOptions
{
/// <summary>判定为明显转角的最小航向变化,单位 rad。</summary>
public double CornerHeadingThresholdRadians { get; set; } = Math.PI / 18d;
/// <summary>单个局部平滑窗口的最大弧长,单位 m。</summary>
public double MaximumWindowLengthMeters { get; set; } = 0.60d;
/// <summary>控制柄相对于窗口局部弦长的比例。</summary>
public double HandleLengthRatio { get; set; } = 1d / 3d;
}
@@ -1,53 +1,29 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing;
/// <summary>路径平滑的公共配置;所有距离使用 m。</summary>
/// <summary>Local G2 路径平滑管线共享配置;所有距离单位为 m,曲率相关限制使用 1/m。</summary>
public sealed class PathSmoothingConfiguration
{
/// <summary>创建带有安全默认值的平滑配置。</summary>
/// <summary>创建采用首版安全采样、碰撞步长、净空和曲率容差默认值的可编辑配置。</summary>
public PathSmoothingConfiguration()
{
OutputSpacingMeters = 0.05d;
OutputSpacingMeters = 0.025d;
MaximumCollisionCheckStepMeters = 0.025d;
MinimumClearanceReserveMeters = 0.02d;
SmoothingStrength = 1d;
AllowFallbackToCoarsePath = true;
RetryStrengthScales = new ReadOnlyCollection<double>(
new List<double> { 1d, 0.75d, 0.50d, 0.25d });
MinimumClearanceReserveMeters = 0d;
CurvatureLimitRadiusToleranceMeters = 0.002d;
}
/// <summary>正式单算法入口使用的方法。</summary>
public SmoothingMethod Method { get; set; }
/// <summary>输出路径的目标弧长采样间距,单位 m。</summary>
/// <summary>发布路径沿弧长的目标采样间距,单位 m;必须为有限正数。</summary>
public double OutputSpacingMeters { get; set; }
/// <summary>扫掠碰撞检查的最大步长,单位 m。</summary>
/// <summary>连续车体扫掠复核允许的最大中心步长,单位 m;较大值会降低碰撞检查分辨率。</summary>
public double MaximumCollisionCheckStepMeters { get; set; }
/// <summary>平滑候选必须在最小净空之外保留的额外余量,单位 m。</summary>
/// <summary>除无碰撞外还要求保留的最小额外净空,单位 m。</summary>
public double MinimumClearanceReserveMeters { get; set; }
/// <summary>算法初始平滑强度。</summary>
public double SmoothingStrength { get; set; }
/// <summary>曲率上限复核时允许相对名义最小转弯半径的缩减容差,单位 m。</summary>
public double CurvatureLimitRadiusToleranceMeters { get; set; }
/// <summary>所有平滑尝试失败时是否允许发布经过复核的原粗路径。</summary>
public bool AllowFallbackToCoarsePath { get; set; }
/// <summary>有限且严格递减的平滑强度重试比例。</summary>
public IReadOnlyList<double> RetryStrengthScales { get; }
/// <summary>三次 B 样条专用参数。</summary>
public CubicBSplineOptions CubicBSpline { get; } = new CubicBSplineOptions();
/// <summary>局部三次 Bézier 专用参数。</summary>
public LocalCubicBezierOptions LocalCubicBezier { get; } = new LocalCubicBezierOptions();
/// <summary>分段五次多项式专用参数。</summary>
public PiecewiseQuinticOptions PiecewiseQuintic { get; } = new PiecewiseQuinticOptions();
/// <summary>局部 G2 五次过渡专用参数。</summary>
/// <summary>局部 G2 五次过渡窗口与候选筛选选项;返回同一配置对象的可编辑子配置。</summary>
public LocalG2QuinticOptions LocalG2Quintic { get; } = new LocalG2QuinticOptions();
}
@@ -2,79 +2,35 @@ using System;
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing;
/// <summary>一次平滑尝试的不可变诊断信息。</summary>
/// <summary>一次 Local G2 平滑尝试的不可变诊断快照,适用于成功、取消和失败结果。</summary>
public sealed class PathSmoothingDiagnostics
{
/// <summary>创建不含路径指标的默认诊断信息。</summary>
/// <summary>创建不可行、零耗时且没有终止原因的默认诊断快照。</summary>
public PathSmoothingDiagnostics()
: this(new PathQualityMetrics(), TimeSpan.Zero, 0, 0d, string.Empty)
: this(new PathQualityMetrics(), TimeSpan.Zero, string.Empty)
{
}
/// <summary>创建完整的平滑诊断快照。</summary>
/// <summary>创建平滑诊断快照。</summary>
/// <param name="metrics">路径可行性、曲率、净空和相对变化的质量指标。</param>
/// <param name="elapsed">从服务入口到终止的累计耗时。</param>
/// <param name="terminationReason">可供调用方记录的成功、取消或失败原因;为 <see langword="null"/> 时为空字符串。</param>
public PathSmoothingDiagnostics(
PathQualityMetrics metrics,
TimeSpan elapsed,
int retryCount,
double acceptedStrength,
string terminationReason = null)
{
Metrics = metrics ?? new PathQualityMetrics();
Elapsed = elapsed;
RetryCount = retryCount;
AcceptedStrength = acceptedStrength;
TerminationReason = terminationReason ?? string.Empty;
}
/// <summary>使用所有测量量创建平滑诊断快照。</summary>
public PathSmoothingDiagnostics(
bool isFeasible,
double pathLengthMeters,
double maximumAbsoluteVehicleCurvaturePerMeter,
double rootMeanSquareVehicleCurvaturePerMeter,
double totalAbsoluteCurvatureVariationPerMeter,
double curvatureVariationEnergy,
double minimumBodyClearanceMeters,
double lengthChangePercent,
double peakCurvatureChangePercent,
double curvatureVariationChangePercent,
double minimumClearanceChangeMeters,
TimeSpan elapsed,
int retryCount,
double acceptedStrength,
string terminationReason = null)
: this(
new PathQualityMetrics(
isFeasible,
pathLengthMeters,
maximumAbsoluteVehicleCurvaturePerMeter,
rootMeanSquareVehicleCurvaturePerMeter,
totalAbsoluteCurvatureVariationPerMeter,
curvatureVariationEnergy,
minimumBodyClearanceMeters,
lengthChangePercent,
peakCurvatureChangePercent,
curvatureVariationChangePercent,
minimumClearanceChangeMeters),
elapsed,
retryCount,
acceptedStrength,
terminationReason)
{
}
/// <summary>路径质量指标;始终非空。</summary>
/// <summary>本次尝试的质量指标;未提供时为全零且不可行的默认指标。</summary>
public PathQualityMetrics Metrics { get; }
/// <summary>从算法入口到返回诊断的耗时。</summary>
/// <summary>本次尝试的累计耗时,类型为 <see cref="TimeSpan"/>。</summary>
public TimeSpan Elapsed { get; }
/// <summary>已执行的安全强度重试次数。</summary>
public int RetryCount { get; }
/// <summary>通过复核的平滑强度;未接受候选时为零。</summary>
public double AcceptedStrength { get; }
/// <summary>面向调用方的稳定终止说明。</summary>
/// <summary>终止原因文本;不承载可消费的部分路径。</summary>
public string TerminationReason { get; }
}
@@ -5,13 +5,23 @@ using MultiWheelC.TrajectoryPlanning.Mapping;
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing;
/// <summary>路径平滑所需的原始粗路径、复核上下文和配置。</summary>
/// <summary>
/// 一次路径平滑所需的不可变输入快照。
/// 粗路径、方向段、车辆和配置均在构造时复制;地图保留为调用方已冻结的 <see cref="PlanningGridMap"/> 快照。
/// </summary>
public sealed class PathSmoothingRequest
{
private readonly VehicleParameters _vehicle;
private readonly PathSmoothingConfiguration _configuration;
/// <summary>创建路径平滑请求,并复制粗路径和方向分段集合。</summary>
/// <summary>
/// 创建路径平滑请求,并防御性复制可变的路径、分段、车辆和配置输入。
/// </summary>
/// <param name="coarsePath">按起点到终点顺序排列的粗路径点集合;位置与弧长单位为 m,航向单位为 rad。</param>
/// <param name="segments">与 <paramref name="coarsePath"/> 对应的前进/倒车包含式方向分段集合。</param>
/// <param name="map">用于完整车体碰撞与净空复核的已准备不可变规划地图快照。</param>
/// <param name="vehicle">车辆长宽、安全余量和曲率约束;长度单位为 m,曲率单位为 1/m。</param>
/// <param name="configuration">本次平滑的采样、净空、曲率和 Local G2 配置;构造后不会与调用方共享可变实例。</param>
public PathSmoothingRequest(
IReadOnlyList<CoarsePathPoint> coarsePath,
IReadOnlyList<PathSegment> segments,
@@ -26,13 +36,13 @@ public sealed class PathSmoothingRequest
_configuration = CopyConfiguration(configuration);
}
/// <summary>原始粗路径的不可变快照。</summary>
/// <summary>原始粗路径的只读快照,按起点到终点顺序排列;位置与弧长单位为 m,航向单位为 rad。</summary>
public IReadOnlyList<CoarsePathPoint> CoarsePath { get; }
/// <summary>原始粗路径方向分段的不可变快照。</summary>
/// <summary>原始粗路径方向分段的只读快照;每段标识连续前进或倒车区间。</summary>
public IReadOnlyList<PathSegment> Segments { get; }
/// <summary>用于平滑后完整车体复核的规划栅格地图。</summary>
/// <summary>用于平滑后完整车体碰撞和净空复核的已冻结规划栅格地图快照。</summary>
public PlanningGridMap Map { get; }
/// <summary>车辆几何与最大曲率约束的不可变快照副本。</summary>
@@ -70,19 +80,11 @@ public sealed class PathSmoothingRequest
if (source == null) return null;
var copy = new PathSmoothingConfiguration
{
Method = source.Method,
OutputSpacingMeters = source.OutputSpacingMeters,
MaximumCollisionCheckStepMeters = source.MaximumCollisionCheckStepMeters,
MinimumClearanceReserveMeters = source.MinimumClearanceReserveMeters,
SmoothingStrength = source.SmoothingStrength,
AllowFallbackToCoarsePath = source.AllowFallbackToCoarsePath,
CurvatureLimitRadiusToleranceMeters = source.CurvatureLimitRadiusToleranceMeters,
};
copy.CubicBSpline.EndpointTangentScale = source.CubicBSpline.EndpointTangentScale;
copy.LocalCubicBezier.CornerHeadingThresholdRadians = source.LocalCubicBezier.CornerHeadingThresholdRadians;
copy.LocalCubicBezier.MaximumWindowLengthMeters = source.LocalCubicBezier.MaximumWindowLengthMeters;
copy.LocalCubicBezier.HandleLengthRatio = source.LocalCubicBezier.HandleLengthRatio;
copy.PiecewiseQuintic.KnotSpacingMeters = source.PiecewiseQuintic.KnotSpacingMeters;
copy.PiecewiseQuintic.MinimumKnotSpacingMeters = source.PiecewiseQuintic.MinimumKnotSpacingMeters;
copy.LocalG2Quintic.MinimumWindowLengthMeters = source.LocalG2Quintic.MinimumWindowLengthMeters;
copy.LocalG2Quintic.PreferredWindowLengthMeters = source.LocalG2Quintic.PreferredWindowLengthMeters;
copy.LocalG2Quintic.MaximumWindowLengthMeters = source.LocalG2Quintic.MaximumWindowLengthMeters;
@@ -4,7 +4,10 @@ using System.Collections.ObjectModel;
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing;
/// <summary>路径平滑的最终不可变结果。</summary>
/// <summary>
/// Local G2 路径平滑管线产生的不可变结果。
/// 只有可发布状态携带完整、已验证的路径和方向段;失败、取消和无效输入结果始终提供空集合,不能被当作部分路径消费。
/// </summary>
public sealed class PathSmoothingResult
{
private static readonly IReadOnlyList<SmoothedPathPoint> EmptyPath =
@@ -30,64 +33,34 @@ public sealed class PathSmoothingResult
Diagnostics = diagnostics ?? new PathSmoothingDiagnostics(
new PathQualityMetrics(),
TimeSpan.Zero,
0,
0d,
"No smoothing diagnostics were supplied.");
}
/// <summary>最终发布状态。</summary>
/// <summary>本次平滑的终止状态;决定 <see cref="Path"/> 和 <see cref="Segments"/> 是否可消费。</summary>
public PathSmoothingStatus Status { get; }
/// <summary>成功或回退时的实际(或尝试)平滑方法;失败时为空。</summary>
/// <summary>成功发布路径时实际采用的平滑方法;失败结果为 <see langword="null"/>。</summary>
public SmoothingMethod? Method { get; }
/// <summary>成功或经过复核的回退路径;其他状态始终为空且不可变。</summary>
/// <summary>成功时按起点到终点排列的不可变平滑路径;位置和弧长单位为 m,航向单位为 rad;失败时为空。</summary>
public IReadOnlyList<SmoothedPathPoint> Path { get; }
/// <summary>覆盖 <see cref="Path"/> 的方向分段;其他状态始终为空且不可变。</summary>
/// <summary>成功时覆盖 <see cref="Path"/> 的不可变方向段集合;失败时为空。</summary>
public IReadOnlyList<SmoothedPathSegment> Segments { get; }
/// <summary>局部 G2 各检测区域的不可变报告;传统算法结果为空。</summary>
/// <summary>各 Local G2 区域的不可变处理报告;失败结果不包含区域发布记录。</summary>
public IReadOnlyList<PathSmoothingRegionReport> RegionReports { get; }
/// <summary>本次平滑的质量和终止诊断;始终非空。</summary>
/// <summary>质量指标、耗时和终止原因;始终存在,供调用方诊断成功或失败。</summary>
public PathSmoothingDiagnostics Diagnostics { get; }
/// <summary>创建已通过所有复核的平滑结果。</summary>
public static PathSmoothingResult Success(
SmoothingMethod method,
IReadOnlyList<SmoothedPathPoint> path,
IReadOnlyList<SmoothedPathSegment> segments,
PathSmoothingDiagnostics diagnostics)
{
ValidatePublishedResult(method, path, segments, diagnostics);
return new PathSmoothingResult(
PathSmoothingStatus.Success,
method,
CopyReadOnly(path),
CopyReadOnly(segments),
diagnostics,
EmptyRegionReports);
}
/// <summary>创建经过完整复核的原始粗路径回退结果。</summary>
public static PathSmoothingResult Fallback(
SmoothingMethod attemptedMethod,
IReadOnlyList<SmoothedPathPoint> path,
IReadOnlyList<SmoothedPathSegment> segments,
PathSmoothingDiagnostics diagnostics)
{
ValidatePublishedResult(attemptedMethod, path, segments, diagnostics);
return new PathSmoothingResult(
PathSmoothingStatus.FallbackToCoarsePath,
attemptedMethod,
CopyReadOnly(path),
CopyReadOnly(segments),
diagnostics,
EmptyRegionReports);
}
/// <summary>发布经过完整复核的局部 G2 预平滑结果。</summary>
/// <summary>发布已经过独立几何、曲率、净空和连续碰撞复核的 Local G2 结果。</summary>
/// <param name="status">可发布状态,只能是完整、部分改进、不需要平滑或保持原样之一。</param>
/// <param name="path">按起点到终点顺序排列的完整平滑路径;位置与弧长单位为 m,航向单位为 rad。</param>
/// <param name="segments">覆盖完整路径的前进/倒车方向段集合。</param>
/// <param name="diagnostics">包含可行质量指标和终止说明的诊断快照。</param>
/// <param name="regionReports">每个局部区域的不可变处理报告集合,不能为 <see langword="null"/>。</param>
/// <returns>携带防御性复制路径、分段和区域报告的不可变可消费结果。</returns>
public static PathSmoothingResult PublishLocalG2(
PathSmoothingStatus status,
IReadOnlyList<SmoothedPathPoint> path,
@@ -99,11 +72,13 @@ public sealed class PathSmoothingResult
status != PathSmoothingStatus.PartialImprovement &&
status != PathSmoothingStatus.NotNeeded &&
status != PathSmoothingStatus.Unchanged)
{
throw new ArgumentException("Use a Local G2 publication status.", nameof(status));
}
if (regionReports == null)
throw new ArgumentNullException(nameof(regionReports));
ValidatePublishedResult(SmoothingMethod.LocalG2Quintic, path, segments, diagnostics);
ValidatePublishedResult(path, segments, diagnostics);
return new PathSmoothingResult(
status,
SmoothingMethod.LocalG2Quintic,
@@ -113,29 +88,29 @@ public sealed class PathSmoothingResult
CopyReadOnly(regionReports));
}
/// <summary>创建不发布路径的失败、不可行、取消或输入无效结果。</summary>
/// <summary>创建明确不发布路径的失败、取消或无效输入结果。</summary>
/// <param name="status">非可发布的终止状态。</param>
/// <param name="diagnostics">失败原因、质量指标和耗时;为 <see langword="null"/> 时生成默认诊断。</param>
/// <returns>路径、方向段和区域报告均为空的不可变结果,不能作为部分路径使用。</returns>
public static PathSmoothingResult Failure(PathSmoothingStatus status, PathSmoothingDiagnostics diagnostics)
{
if (status == PathSmoothingStatus.Success ||
status == PathSmoothingStatus.FallbackToCoarsePath ||
status == PathSmoothingStatus.Complete ||
if (status == PathSmoothingStatus.Complete ||
status == PathSmoothingStatus.PartialImprovement ||
status == PathSmoothingStatus.NotNeeded ||
status == PathSmoothingStatus.Unchanged)
throw new ArgumentException("Use Success or Fallback to publish a path.", nameof(status));
{
throw new ArgumentException("Use PublishLocalG2 to publish a path.", nameof(status));
}
if (!Enum.IsDefined(typeof(PathSmoothingStatus), status))
throw new ArgumentOutOfRangeException(nameof(status));
return new PathSmoothingResult(status, null, EmptyPath, EmptySegments, diagnostics, EmptyRegionReports);
}
private static void ValidatePublishedResult(
SmoothingMethod method,
IReadOnlyList<SmoothedPathPoint> path,
IReadOnlyList<SmoothedPathSegment> segments,
PathSmoothingDiagnostics diagnostics)
{
if (!Enum.IsDefined(typeof(SmoothingMethod), method))
throw new ArgumentOutOfRangeException(nameof(method));
if (path == null || path.Count == 0)
throw new ArgumentException("Published smoothing results require a non-empty path.", nameof(path));
if (segments == null || segments.Count == 0)
@@ -147,8 +122,7 @@ public sealed class PathSmoothingResult
private static IReadOnlyList<T> CopyReadOnly<T>(IReadOnlyList<T> source)
{
var copy = new List<T>(source.Count);
for (int index = 0; index < source.Count; index++)
copy.Add(source[index]);
for (int index = 0; index < source.Count; index++) copy.Add(source[index]);
return new ReadOnlyCollection<T>(copy);
}
}
@@ -1,10 +1,8 @@
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing;
/// <summary>路径平滑的最终发布状态。</summary>
/// <summary>路径平滑的最终发布状态;只有 Complete、PartialImprovement、NotNeeded 和 Unchanged 可携带完整路径。</summary>
public enum PathSmoothingStatus
{
Success,
FallbackToCoarsePath,
InvalidInput,
Infeasible,
Failed,
@@ -1,11 +0,0 @@
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing;
/// <summary>分段五次 Hermite 平滑参数。</summary>
public sealed class PiecewiseQuinticOptions
{
/// <summary>相邻内部结点的目标距离,单位 m。</summary>
public double KnotSpacingMeters { get; set; } = 0.50d;
/// <summary>允许创建内部结点的最小间距,单位 m。</summary>
public double MinimumKnotSpacingMeters { get; set; } = 0.10d;
}
@@ -1,11 +1,14 @@
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing;
/// <summary>平滑路径点的来源。</summary>
/// <summary>平滑路径点的来源;用于区分保留锚点、常规重采样、换向锚点和 Local G2 过渡几何。</summary>
public enum SmoothedPathPointSource
{
/// <summary>直接保留的原始路径锚点。</summary>
Anchor,
/// <summary>在同一方向段内按弧长插值得到的常规采样点。</summary>
Interpolated,
/// <summary>精确保留的换向边界点;不能与相邻方向段按坐标合并。</summary>
GearSwitch,
CoarsePathFallback,
LocalG2Transition,
/// <summary>由 Local G2 五次过渡曲线生成的候选或发布点。</summary>
LocalG2Transition = 4,
}
@@ -1,10 +1,7 @@
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing;
/// <summary>支持的粗路径平滑方法。</summary>
/// <summary>本模块可以发布的路径平滑方法枚举;当前仅支持 Local G2 五次过渡。</summary>
public enum SmoothingMethod
{
CubicBSpline,
LocalCubicBezier,
PiecewiseQuintic,
LocalG2Quintic,
}
@@ -2,13 +2,16 @@ using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Comparison;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Comparison;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Processing;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Validation;
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Facade;
/// <summary>以固定预热和五次测量隔离比较所有请求平滑方法的离线入口。</summary>
/// <summary>
/// 原始路径基线与一次 Local G2 平滑的离线比较入口。
/// 此类仅汇总已冻结请求的质量、确定性摘要和耗时,不参与实时控制或修改平滑结果。
/// </summary>
public sealed class PathSmoothingComparisonService
{
private readonly PathSmoothingService _smoothingService = new PathSmoothingService();
@@ -16,13 +19,16 @@ public sealed class PathSmoothingComparisonService
private readonly PathGeometryAnalyzer _analyzer = new PathGeometryAnalyzer();
private readonly SmoothedPathValidator _validator = new SmoothedPathValidator();
/// <summary>比较所有请求方法;一个方法的失败不会阻止其他方法,取消会停止后续启动。</summary>
/// <summary>创建原始基线并比较一次 Local G2 运行的质量、耗时和推荐资格。</summary>
/// <param name="request">比较请求,包含待平滑的不可变请求快照和比较设置;为 <see langword="null"/> 时返回带失败基线的结果。</param>
/// <param name="cancellationToken">取消比较和重复计时的调用方令牌。</param>
/// <returns>包含原始基线、候选条目和推荐方法的不可变比较结果;取消时 <c>Cancelled</c> 为 <see langword="true"/>。</returns>
public PathSmoothingComparisonResult Compare(
PathSmoothingComparisonRequest request,
CancellationToken cancellationToken = default)
{
PathSmoothingComparisonEntry baseline = CreateRawPathBaseline(request, out string baselineReason);
var entries = new List<PathSmoothingComparisonEntry>();
var entries = new List<PathSmoothingComparisonEntry>(1);
if (cancellationToken.IsCancellationRequested)
return Cancelled(baseline, entries);
@@ -30,33 +36,26 @@ public sealed class PathSmoothingComparisonService
if (request == null || request.SmoothingRequest == null)
return new PathSmoothingComparisonResult(baseline, entries, null, false, baselineReason);
for (int methodIndex = 0; methodIndex < request.Methods.Count; methodIndex++)
if (!TryCompareLocalG2(
request.SmoothingRequest,
baseline.Metrics,
cancellationToken,
out PathSmoothingComparisonEntry entry))
{
if (cancellationToken.IsCancellationRequested)
return Cancelled(baseline, entries);
SmoothingMethod method = request.Methods[methodIndex];
if (!TryCompareMethod(
request.SmoothingRequest,
method,
baseline.Metrics,
cancellationToken,
out PathSmoothingComparisonEntry entry))
return Cancelled(baseline, entries);
entries.Add(entry);
return Cancelled(baseline, entries);
}
entries.Add(entry);
return new PathSmoothingComparisonResult(
baseline,
entries,
SmoothingMethodRanker.Rank(entries),
entry.IsEligibleForRecommendation ? SmoothingMethod.LocalG2Quintic : null,
false,
baselineReason);
}
private bool TryCompareMethod(
PathSmoothingRequest sourceRequest,
SmoothingMethod method,
private bool TryCompareLocalG2(
PathSmoothingRequest smoothingRequest,
PathQualityMetrics rawMetrics,
CancellationToken cancellationToken,
out PathSmoothingComparisonEntry entry)
@@ -64,9 +63,8 @@ public sealed class PathSmoothingComparisonService
entry = null;
try
{
PathSmoothingRequest methodRequest = CreateMethodRequest(sourceRequest, method);
cancellationToken.ThrowIfCancellationRequested();
PathSmoothingResult warmup = _smoothingService.Smooth(methodRequest, cancellationToken);
PathSmoothingResult warmup = _smoothingService.Smooth(smoothingRequest, cancellationToken);
if (warmup.Status == PathSmoothingStatus.Cancelled || cancellationToken.IsCancellationRequested) return false;
var timings = new List<double>(5);
@@ -75,7 +73,7 @@ public sealed class PathSmoothingComparisonService
{
cancellationToken.ThrowIfCancellationRequested();
Stopwatch stopwatch = Stopwatch.StartNew();
PathSmoothingResult result = _smoothingService.Smooth(methodRequest, cancellationToken);
PathSmoothingResult result = _smoothingService.Smooth(smoothingRequest, cancellationToken);
stopwatch.Stop();
if (result.Status == PathSmoothingStatus.Cancelled || cancellationToken.IsCancellationRequested) return false;
timings.Add(stopwatch.Elapsed.TotalMilliseconds);
@@ -88,12 +86,11 @@ public sealed class PathSmoothingComparisonService
string diagnostic = string.IsNullOrWhiteSpace(timing.Diagnostic)
? canonical.Diagnostics.TerminationReason
: timing.Diagnostic;
PathQualityMetrics metrics = canonical.Status == PathSmoothingStatus.Success
PathQualityMetrics metrics = PathSmoothingComparisonEntry.IsPublishedLocalG2Status(canonical.Status)
? NormalizeMetrics(canonical.Diagnostics.Metrics, rawMetrics)
: new PathQualityMetrics();
entry = PathSmoothingComparisonEntry.CreateCandidate(
method,
SmoothingMethod.LocalG2Quintic,
canonical.Status,
metrics,
timing,
@@ -110,7 +107,7 @@ public sealed class PathSmoothingComparisonService
catch (Exception exception)
{
entry = PathSmoothingComparisonEntry.CreateCandidate(
method,
SmoothingMethod.LocalG2Quintic,
PathSmoothingStatus.Failed,
new PathQualityMetrics(),
new SmoothingTimingSummary(new double[] { 0d, 0d, 0d, 0d, 0d }, false, exception.GetType().Name),
@@ -128,12 +125,17 @@ public sealed class PathSmoothingComparisonService
{
reason = string.Empty;
if (request == null || request.SmoothingRequest == null)
return FailedBaseline(PathSmoothingStatus.InvalidInput, "比较请求为空。", out reason);
return FailedBaseline(PathSmoothingStatus.InvalidInput, "Comparison request is required.", out reason);
PathSmoothingRequest smoothingRequest = request.SmoothingRequest;
PathSmoothingConfiguration configuration = smoothingRequest.Configuration;
if (configuration == null || smoothingRequest.Map == null || smoothingRequest.Vehicle == null)
return FailedBaseline(PathSmoothingStatus.InvalidInput, "比较请求缺少可用的地图、车辆或配置。", out reason);
{
return FailedBaseline(
PathSmoothingStatus.InvalidInput,
"A planning-ready map, vehicle, and configuration are required.",
out reason);
}
if (!_preprocessor.TryPrepare(smoothingRequest, out PreparedPath preparedPath, out reason))
return FailedBaseline(PathSmoothingStatus.InvalidInput, reason, out reason);
@@ -146,11 +148,13 @@ public sealed class PathSmoothingComparisonService
configuration.MaximumCollisionCheckStepMeters,
out RawPathBaseline rawPath,
out reason))
{
return FailedBaseline(PathSmoothingStatus.InvalidInput, reason, out reason);
}
string digest = StableGeometryDigest.Compute(PathSmoothingStatus.Success, null, rawPath.Path, rawPath.Segments);
string digest = StableGeometryDigest.Compute(PathSmoothingStatus.NotNeeded, null, rawPath.Path, rawPath.Segments);
return PathSmoothingComparisonEntry.CreateRawPathBaseline(
PathSmoothingStatus.Success,
PathSmoothingStatus.NotNeeded,
rawPath.Metrics,
digest,
string.Empty,
@@ -177,20 +181,7 @@ public sealed class PathSmoothingComparisonService
PathSmoothingComparisonEntry baseline,
IReadOnlyList<PathSmoothingComparisonEntry> entries)
{
return new PathSmoothingComparisonResult(baseline, entries, null, true, "路径平滑比较已取消。");
}
private static PathSmoothingRequest CreateMethodRequest(PathSmoothingRequest source, SmoothingMethod method)
{
PathSmoothingConfiguration configuration = source.Configuration;
configuration.Method = method;
configuration.AllowFallbackToCoarsePath = false;
return new PathSmoothingRequest(
source.CoarsePath,
source.Segments,
source.Map,
source.Vehicle,
configuration);
return new PathSmoothingComparisonResult(baseline, entries, null, true, "Path smoothing comparison was cancelled.");
}
private static PathQualityMetrics NormalizeMetrics(
@@ -1,28 +1,30 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using MultiWheelC.TrajectoryPlanning.CoarsePath;
using MultiWheelC.TrajectoryPlanning.CoarsePath.Vehicle;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Algorithms;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.LocalG2;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Processing;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Validation;
using MultiWheelC.TrajectoryPlanning.Utils;
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Facade;
/// <summary>正式单算法路径平滑入口,负责输入校验、有限重试与经过复核的粗路径回退。</summary>
/// <summary>
/// Local G2 路径平滑的公开、同步业务入口。
/// 它只消费调用方冻结的请求,依次执行输入校验、预处理、原始基线、局部 G2 和独立复核;从不发布未经验证的部分路径。
/// </summary>
public sealed class PathSmoothingService
{
private readonly PathSmoothingPreprocessor _preprocessor = new PathSmoothingPreprocessor();
private readonly PathGeometryAnalyzer _analyzer = new PathGeometryAnalyzer();
private readonly SmoothingAlgorithmRunner _runner = new SmoothingAlgorithmRunner();
private readonly SmoothedPathValidator _validator = new SmoothedPathValidator();
private readonly IPathSmoother _bSpline = new CubicBSplineSmoother();
private readonly IPathSmoother _bezier = new LocalCubicBezierSmoother();
private readonly IPathSmoother _quintic = new PiecewiseQuinticSmoother();
private readonly LocalG2PreSmoothingPipeline _localG2Pipeline = new LocalG2PreSmoothingPipeline();
/// <summary>执行一次经过完整安全复核的单算法平滑。</summary>
/// <summary>在请求携带的不可变粗路径、地图和车辆快照上执行一次 Local G2 平滑。</summary>
/// <param name="request">平滑输入快照,包含粗路径/方向段、规划地图、车辆和配置;路径单位为 m/rad,曲率单位为 1/m。</param>
/// <param name="cancellationToken">调用方取消令牌;取消时返回 <see cref="PathSmoothingStatus.Cancelled"/>,不会发布部分路径。</param>
/// <returns>成功时携带完整独立复核路径的结果;输入、预处理、碰撞、曲率或运行失败时路径和分段为空,原因位于诊断中。</returns>
public PathSmoothingResult Smooth(
PathSmoothingRequest request,
CancellationToken cancellationToken = default)
@@ -32,10 +34,10 @@ public sealed class PathSmoothingService
{
cancellationToken.ThrowIfCancellationRequested();
if (!TryValidateRequest(request, out PathSmoothingConfiguration configuration, out string reason))
return Failure(PathSmoothingStatus.InvalidInput, stopwatch, 0, 0d, reason);
return Failure(PathSmoothingStatus.InvalidInput, stopwatch, reason);
if (!_preprocessor.TryPrepare(request, out PreparedPath preparedPath, out reason))
return Failure(PathSmoothingStatus.InvalidInput, stopwatch, 0, 0d, reason);
return Failure(PathSmoothingStatus.InvalidInput, stopwatch, reason);
if (!RawPathBaselineBuilder.TryCreate(
request,
@@ -44,126 +46,24 @@ public sealed class PathSmoothingService
configuration.OutputSpacingMeters,
_validator,
configuration.MaximumCollisionCheckStepMeters,
out _,
out RawPathBaseline rawBaseline,
out reason))
{
return Failure(PathSmoothingStatus.InvalidInput, stopwatch, 0, 0d, reason);
return Failure(PathSmoothingStatus.InvalidInput, stopwatch, reason);
}
cancellationToken.ThrowIfCancellationRequested();
var input = new SmoothingAlgorithmInput(
preparedPath,
request.Map,
request.Vehicle,
configuration.MaximumCollisionCheckStepMeters,
configuration.MinimumClearanceReserveMeters,
new SmoothingOptionsSnapshot(configuration));
SmoothingAlgorithmRunner.AlgorithmRunResult runResult = _runner.Run(
Resolve(configuration.Method), input, configuration, cancellationToken);
int retryCount = GetRetryCount(runResult.AttemptedStrengths);
PathSmoothingDiagnostics diagnostics = new PathSmoothingDiagnostics(
runResult.Metrics,
stopwatch.Elapsed,
retryCount,
runResult.AcceptedStrength,
runResult.Reason);
if (runResult.Status == PathSmoothingStatus.Success)
{
return PathSmoothingResult.Success(
configuration.Method,
runResult.Path,
runResult.Segments,
diagnostics);
}
if (!configuration.AllowFallbackToCoarsePath)
return PathSmoothingResult.Failure(runResult.Status, diagnostics);
cancellationToken.ThrowIfCancellationRequested();
if (!TryCreateVerifiedFallback(
request,
configuration,
cancellationToken,
out IReadOnlyList<SmoothedPathPoint> fallbackPath,
out IReadOnlyList<SmoothedPathSegment> fallbackSegments,
out PathQualityMetrics fallbackMetrics,
out reason))
{
return PathSmoothingResult.Failure(runResult.Status, diagnostics);
}
var fallbackDiagnostics = new PathSmoothingDiagnostics(
fallbackMetrics,
stopwatch.Elapsed,
retryCount,
runResult.AcceptedStrength,
runResult.Reason);
return PathSmoothingResult.Fallback(
configuration.Method,
fallbackPath,
fallbackSegments,
fallbackDiagnostics);
return _localG2Pipeline.Smooth(request, preparedPath, rawBaseline, cancellationToken);
}
catch (OperationCanceledException)
{
return Failure(PathSmoothingStatus.Cancelled, stopwatch, 0, 0d, "路径平滑已取消。");
return Failure(PathSmoothingStatus.Cancelled, stopwatch, "Path smoothing was cancelled.");
}
catch (Exception exception)
{
return Failure(PathSmoothingStatus.Failed, stopwatch, 0, 0d, exception.Message);
return Failure(PathSmoothingStatus.Failed, stopwatch, exception.Message);
}
}
private bool TryCreateVerifiedFallback(
PathSmoothingRequest request,
PathSmoothingConfiguration configuration,
CancellationToken cancellationToken,
out IReadOnlyList<SmoothedPathPoint> fallbackPath,
out IReadOnlyList<SmoothedPathSegment> fallbackSegments,
out PathQualityMetrics fallbackMetrics,
out string reason)
{
fallbackPath = null;
fallbackSegments = null;
fallbackMetrics = null;
reason = string.Empty;
// Reprepare from the immutable request instead of reusing the algorithm input: fallback is a
// separately published output and must repeat the coarse-path contract validation.
if (!_preprocessor.TryPrepare(request, out PreparedPath revalidatedPath, out reason)) return false;
cancellationToken.ThrowIfCancellationRequested();
if (!RawPathBaselineBuilder.TryCreate(
request,
revalidatedPath,
_analyzer,
configuration.OutputSpacingMeters,
_validator,
configuration.MaximumCollisionCheckStepMeters,
out RawPathBaseline rawPath,
out reason))
{
return false;
}
fallbackPath = ToFallbackPoints(rawPath.Path);
fallbackSegments = rawPath.Segments;
fallbackMetrics = rawPath.Metrics;
return true;
}
private IPathSmoother Resolve(SmoothingMethod method)
{
return method switch
{
SmoothingMethod.CubicBSpline => _bSpline,
SmoothingMethod.LocalCubicBezier => _bezier,
SmoothingMethod.PiecewiseQuintic => _quintic,
_ => throw new ArgumentOutOfRangeException(nameof(method)),
};
}
private static bool TryValidateRequest(
PathSmoothingRequest request,
out PathSmoothingConfiguration configuration,
@@ -173,7 +73,7 @@ public sealed class PathSmoothingService
reason = string.Empty;
if (request == null)
{
reason = "平滑请求为空。";
reason = "Path smoothing request is required.";
return false;
}
@@ -181,7 +81,7 @@ public sealed class PathSmoothingService
VehicleParameters vehicle = request.Vehicle;
if (configuration == null || request.Map == null || !request.Map.PlanningReady || vehicle == null)
{
reason = "平滑请求缺少可用的地图、车辆或配置。";
reason = "A planning-ready map, vehicle, and configuration are required.";
return false;
}
@@ -190,13 +90,7 @@ public sealed class PathSmoothingService
!NumericGuard.IsFinite(vehicle.SafetyMarginMeters) || vehicle.SafetyMarginMeters < 0d ||
!VehicleKinematics.TryGetMaximumCurvaturePerMeter(vehicle, out _))
{
reason = "平滑请求中的车辆几何或曲率约束无效。";
return false;
}
if (!Enum.IsDefined(typeof(SmoothingMethod), configuration.Method))
{
reason = "平滑方法无效。";
reason = "Vehicle geometry or curvature constraints are invalid.";
return false;
}
@@ -204,63 +98,48 @@ public sealed class PathSmoothingService
!NumericGuard.IsPositiveFinite(configuration.MaximumCollisionCheckStepMeters) ||
!NumericGuard.IsFinite(configuration.MinimumClearanceReserveMeters) ||
configuration.MinimumClearanceReserveMeters < 0d ||
!NumericGuard.IsPositiveFinite(configuration.SmoothingStrength) ||
!NumericGuard.IsPositiveFinite(configuration.CubicBSpline.EndpointTangentScale) ||
!IsValidBezierThreshold(configuration.LocalCubicBezier.CornerHeadingThresholdRadians) ||
!NumericGuard.IsPositiveFinite(configuration.LocalCubicBezier.MaximumWindowLengthMeters) ||
!NumericGuard.IsPositiveFinite(configuration.LocalCubicBezier.HandleLengthRatio) ||
!NumericGuard.IsPositiveFinite(configuration.PiecewiseQuintic.KnotSpacingMeters) ||
!NumericGuard.IsPositiveFinite(configuration.PiecewiseQuintic.MinimumKnotSpacingMeters) ||
configuration.PiecewiseQuintic.KnotSpacingMeters < configuration.PiecewiseQuintic.MinimumKnotSpacingMeters)
!CurvatureLimitPolicy.TryGetAllowedMaximumVehicleCurvaturePerMeter(
vehicle, configuration.CurvatureLimitRadiusToleranceMeters, out _))
{
reason = "平滑配置包含非法数值或不满足方法契约。";
reason = "Shared Local G2 configuration values are invalid.";
return false;
}
return true;
return IsValidLocalG2Options(configuration.LocalG2Quintic, out reason);
}
private static bool IsValidBezierThreshold(double thresholdRadians)
private static bool IsValidLocalG2Options(LocalG2QuinticOptions options, out string reason)
{
return NumericGuard.IsFinite(thresholdRadians) && thresholdRadians > 0d && thresholdRadians <= Math.PI;
}
private static IReadOnlyList<SmoothedPathPoint> ToFallbackPoints(IReadOnlyList<SmoothedPathPoint> path)
{
var points = new List<SmoothedPathPoint>(path.Count);
for (int index = 0; index < path.Count; index++)
reason = string.Empty;
if (options != null &&
NumericGuard.IsPositiveFinite(options.MinimumWindowLengthMeters) &&
NumericGuard.IsFinite(options.PreferredWindowLengthMeters) &&
options.PreferredWindowLengthMeters >= options.MinimumWindowLengthMeters &&
NumericGuard.IsFinite(options.MaximumWindowLengthMeters) &&
options.MaximumWindowLengthMeters >= options.PreferredWindowLengthMeters &&
NumericGuard.IsPositiveFinite(options.MaximumDeviationMeters) &&
NumericGuard.IsPositiveFinite(options.AbsoluteCurvatureJumpFloorPerMeter) &&
NumericGuard.IsFinite(options.CurvatureJumpRatioOfMaximum) &&
options.CurvatureJumpRatioOfMaximum > 0d && options.CurvatureJumpRatioOfMaximum <= 1d &&
NumericGuard.IsFinite(options.MinimumPeakGradientImprovementRatio) &&
options.MinimumPeakGradientImprovementRatio > 0d && options.MinimumPeakGradientImprovementRatio < 1d &&
NumericGuard.IsFinite(options.MaximumVariationCostRegressionRatio) &&
options.MaximumVariationCostRegressionRatio >= 0d && options.MaximumCandidatesPerRegion >= 1)
{
SmoothedPathPoint point = path[index];
points.Add(new SmoothedPathPoint(
point.X,
point.Y,
point.Heading,
point.UnwrappedHeading,
point.ArcLength,
point.Direction,
point.GeometricCurvature,
point.VehicleCurvature,
point.VehicleCurvatureDerivative,
point.BodyClearance,
point.IsGearSwitchPoint,
SmoothedPathPointSource.CoarsePathFallback));
return true;
}
return points;
}
private static int GetRetryCount(IReadOnlyList<double> attemptedStrengths)
{
return attemptedStrengths == null || attemptedStrengths.Count == 0 ? 0 : attemptedStrengths.Count - 1;
reason = "Local G2 configuration values are invalid.";
return false;
}
private static PathSmoothingResult Failure(
PathSmoothingStatus status,
Stopwatch stopwatch,
int retryCount,
double acceptedStrength,
string reason)
{
return PathSmoothingResult.Failure(
status,
new PathSmoothingDiagnostics(new PathQualityMetrics(), stopwatch.Elapsed, retryCount, acceptedStrength, reason));
new PathSmoothingDiagnostics(new PathQualityMetrics(), stopwatch.Elapsed, reason));
}
}
@@ -12,7 +12,7 @@ using MultiWheelC.TrajectoryPlanning.Utils;
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.LocalG2;
/// <summary>对一个局部 G2 替换候选执行区域质量门和完整路径安全复核。</summary>
/// <summary>对局部 G2 替换候选执行区域质量门、整条路径几何重算及碰撞安全复核。</summary>
internal sealed class LocalG2CandidateEvaluator
{
private const double CurvatureRangeTolerance = 1e-6d;
@@ -25,11 +25,16 @@ internal sealed class LocalG2CandidateEvaluator
private readonly LocalG2PathSplicer _splicer;
private readonly SmoothedPathValidator _validator;
/// <summary>使用默认几何分析、路径拼接和安全校验器创建候选评估器。</summary>
internal LocalG2CandidateEvaluator()
: this(new PathGeometryAnalyzer(), new LocalG2PathSplicer(), new SmoothedPathValidator())
{
}
/// <summary>使用注入的依赖创建候选评估器,便于复用统一的几何与碰撞判定规则。</summary>
/// <param name="analyzer">将预处理路径转换为带曲率指标的 <see cref="PathGeometryAnalysis"/> 的分析器。</param>
/// <param name="splicer">将候选窗口替换回当前路径并维持换向拓扑的拼接器。</param>
/// <param name="validator">对完整候选路径执行碰撞、净空和轨迹不变量检查的校验器。</param>
internal LocalG2CandidateEvaluator(
PathGeometryAnalyzer analyzer,
LocalG2PathSplicer splicer,
@@ -40,6 +45,15 @@ internal sealed class LocalG2CandidateEvaluator
_validator = validator ?? throw new ArgumentNullException(nameof(validator));
}
/// <summary>评估一个局部 G2 候选,仅在全部质量门与完整路径安全复核通过时接受。</summary>
/// <param name="rawPath">未经局部替换的基线路径;用于约束曲率范围和总长度变化。</param>
/// <param name="currentPath">已接受先前窗口替换的当前路径;本候选将在其指定方向段内拼接。</param>
/// <param name="region">候选所属的单一行驶方向平滑区域。</param>
/// <param name="candidate">待评估候选的窗口边界与几何点;坐标和弧长单位为 m。</param>
/// <param name="request">平滑请求,提供地图、车辆模型、采样间距 m 和安全阈值。</param>
/// <param name="options">局部 G2 质量门选项,包括偏差 m、曲率导数 1/m² 和代价回退阈值。</param>
/// <param name="cancellationToken">取消令牌;取消时抛出 <see cref="OperationCanceledException"/>。</param>
/// <returns>包含接受状态、失败原因和度量值的 <see cref="LocalG2CandidateEvaluation"/>;拒绝时不发布候选路径。</returns>
internal LocalG2CandidateEvaluation Evaluate(
PreparedPath rawPath,
PreparedPath currentPath,
@@ -67,7 +81,9 @@ internal sealed class LocalG2CandidateEvaluator
string.IsNullOrEmpty(reason) ? "局部 G2 区域几何分析失败。" : reason);
}
if (!VehicleKinematics.TryGetMaximumCurvaturePerMeter(request.Vehicle, out double vehicleMaximumCurvature))
if (!CurvatureLimitPolicy.TryGetAllowedMaximumVehicleCurvaturePerMeter(
request.Vehicle, request.Configuration.CurvatureLimitRadiusToleranceMeters,
out double vehicleMaximumCurvature))
return Rejected(candidateIndex, PathSmoothingRegionFailureReason.CandidateGenerationFailed, "车辆曲率约束无效。");
if (candidateAnalysis.MaximumAbsoluteVehicleCurvaturePerMeter > vehicleMaximumCurvature + CurvatureRangeTolerance)
return Rejected(candidateIndex, PathSmoothingRegionFailureReason.CurvatureExceeded, "局部 G2 候选超过车辆曲率上限。");
@@ -91,7 +107,9 @@ internal sealed class LocalG2CandidateEvaluator
}
if (!_validator.TryValidate(fullAnalysis.Path, fullAnalysis.Segments, rawPath, request.Map, request.Vehicle,
request.Configuration.MaximumCollisionCheckStepMeters, out IReadOnlyList<SmoothedPathPoint> safePath,
request.Configuration.MaximumCollisionCheckStepMeters,
request.Configuration.CurvatureLimitRadiusToleranceMeters,
out IReadOnlyList<SmoothedPathPoint> safePath,
out double minimumClearance, out reason))
{
return Rejected(candidateIndex, PathSmoothingRegionFailureReason.Collision,
@@ -145,6 +163,9 @@ internal sealed class LocalG2CandidateEvaluator
"Accepted");
}
/// <summary>在已通过质量门的候选中按偏差、峰值曲率导数、变化代价、长度变化和索引稳定选优。</summary>
/// <param name="evaluations">待比较的候选评估结果集合;仅 <c>Accepted</c> 为 <see langword="true"/> 的项可入选。</param>
/// <returns>最优的已接受评估;若没有合格项,返回带失败原因的拒绝评估。</returns>
internal static LocalG2CandidateEvaluation SelectBest(IReadOnlyList<LocalG2CandidateEvaluation> evaluations)
{
if (evaluations == null) throw new ArgumentNullException(nameof(evaluations));
@@ -0,0 +1,242 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Threading;
using MultiWheelC.TrajectoryPlanning.CoarsePath.Vehicle;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Processing;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Validation;
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.LocalG2;
/// <summary>通过专用服务路径生成、回滚并发布经独立验证的 Local G2 局部区域替换;不发布未经最终复核的候选。</summary>
internal sealed class LocalG2PreSmoothingPipeline
{
private readonly CurvatureTransitionDetector _detector = new CurvatureTransitionDetector();
private readonly LocalG2WindowPlanner _windowPlanner = new LocalG2WindowPlanner();
private readonly LocalG2CandidateBuilder _builder = new LocalG2CandidateBuilder();
private readonly LocalG2CandidateEvaluator _evaluator = new LocalG2CandidateEvaluator();
private readonly LocalG2RegionWorkOrder _workOrder = new LocalG2RegionWorkOrder();
private readonly PathGeometryAnalyzer _analyzer = new PathGeometryAnalyzer();
private readonly SmoothedPathValidator _validator = new SmoothedPathValidator();
/// <summary>在预处理路径上检测曲率事件、评估候选、按确定顺序拼接,并执行全局回滚复核。</summary>
/// <param name="request">不可变平滑请求,提供地图、车辆、配置和路径上下文。</param>
/// <param name="preparedPath">按方向段准备并重采样的原始路径;局部弧长单位为 m。</param>
/// <param name="rawBaseline">已独立复核的原始路径质量基线。</param>
/// <param name="cancellationToken">调用方取消令牌;取消时返回空路径的 Cancelled 结果。</param>
/// <returns>完整、部分、无需或保持原样时均只发布最终复核路径;失败/取消时不发布部分候选。</returns>
internal PathSmoothingResult Smooth(
PathSmoothingRequest request,
PreparedPath preparedPath,
RawPathBaseline rawBaseline,
CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
try
{
cancellationToken.ThrowIfCancellationRequested();
if (request == null || preparedPath == null || rawBaseline == null ||
!VehicleKinematics.TryGetMaximumCurvaturePerMeter(request.Vehicle, out double maximumCurvature))
{
return Failure(PathSmoothingStatus.Failed, stopwatch, "局部 G2 预平滑输入无效。");
}
var options = new LocalG2OptionsSnapshot(request.Configuration);
if (!_detector.TryDetect(request, maximumCurvature, options, out IReadOnlyList<CurvatureTransition> transitions, out string reason) ||
!_windowPlanner.TryPlan(preparedPath, transitions, options, out IReadOnlyList<LocalG2SmoothingRegion> regions, out reason))
{
return Failure(PathSmoothingStatus.Failed, stopwatch, reason);
}
IReadOnlyList<LocalG2SmoothingRegion> reportOrder =
new ReadOnlyCollection<LocalG2SmoothingRegion>(new List<LocalG2SmoothingRegion>(regions));
if (!_workOrder.TryCreate(reportOrder, out IReadOnlyList<LocalG2SmoothingRegion> workRegions, out string orderReason))
return Failure(PathSmoothingStatus.Failed, stopwatch, orderReason);
PreparedPath current = preparedPath;
var reportsByRegion = new Dictionary<LocalG2SmoothingRegion, PathSmoothingRegionReport>();
var accepted = new List<AcceptedRegion>();
int improvedCount = 0;
foreach (LocalG2SmoothingRegion region in workRegions)
{
cancellationToken.ThrowIfCancellationRequested();
IReadOnlyList<LocalG2CandidateGeometry> candidates = _builder.Build(
preparedPath.Segments[region.SegmentIndex], region,
request.Configuration.OutputSpacingMeters, options, cancellationToken);
var evaluations = new List<LocalG2CandidateEvaluation>();
for (int candidateIndex = 0; candidateIndex < candidates.Count; candidateIndex++)
evaluations.Add(_evaluator.Evaluate(
preparedPath, current, region, candidates[candidateIndex], request, options, cancellationToken));
LocalG2CandidateEvaluation best = LocalG2CandidateEvaluator.SelectBest(evaluations);
if (best.Accepted)
{
accepted.Add(new AcceptedRegion(region, current, best));
current = best.SplicedPreparedPath;
improvedCount++;
reportsByRegion.Add(region, CreateImprovedReport(region, candidates.Count, best));
}
else
{
reportsByRegion.Add(region, CreateRetainedReport(region, candidates.Count, best));
}
}
if (!TryValidateFinal(current, preparedPath, rawBaseline, request, options, out IReadOnlyList<SmoothedPathPoint> path,
out IReadOnlyList<SmoothedPathSegment> segments, out PathQualityMetrics metrics, out reason))
{
for (int index = accepted.Count - 1; index >= 0; index--)
{
AcceptedRegion rollback = accepted[index];
current = rollback.Before;
reportsByRegion[rollback.Region] = CreateRollbackReport(rollback.Region, rollback.Evaluation);
improvedCount--;
if (TryValidateFinal(current, preparedPath, rawBaseline, request, options, out path, out segments, out metrics, out reason))
break;
}
}
if (metrics == null)
return Failure(PathSmoothingStatus.Failed, stopwatch, reason);
var reports = new List<PathSmoothingRegionReport>(reportOrder.Count);
for (int reportIndex = 0; reportIndex < reportOrder.Count; reportIndex++)
reports.Add(reportsByRegion[reportOrder[reportIndex]]);
PathSmoothingStatus status;
if (transitions.Count == 0) status = PathSmoothingStatus.NotNeeded;
else if (improvedCount == regions.Count) status = PathSmoothingStatus.Complete;
else if (improvedCount > 0) status = PathSmoothingStatus.PartialImprovement;
else status = PathSmoothingStatus.Unchanged;
return PathSmoothingResult.PublishLocalG2(
status,
path,
segments,
new PathSmoothingDiagnostics(metrics, stopwatch.Elapsed, reason ?? string.Empty),
reports);
}
catch (OperationCanceledException)
{
return Failure(PathSmoothingStatus.Cancelled, stopwatch, "路径平滑已取消。");
}
catch (Exception exception)
{
return Failure(PathSmoothingStatus.Failed, stopwatch, exception.Message);
}
}
private bool TryValidateFinal(
PreparedPath current,
PreparedPath rawPath,
RawPathBaseline rawBaseline,
PathSmoothingRequest request,
LocalG2OptionsSnapshot options,
out IReadOnlyList<SmoothedPathPoint> path,
out IReadOnlyList<SmoothedPathSegment> segments,
out PathQualityMetrics metrics,
out string reason)
{
path = null;
segments = null;
metrics = null;
reason = string.Empty;
if (!_analyzer.TryAnalyze(current.Segments, request.Configuration.OutputSpacingMeters, out PathGeometryAnalysis analysis, out reason) ||
!_validator.TryValidate(analysis.Path, analysis.Segments, rawPath, request.Map, request.Vehicle,
request.Configuration.MaximumCollisionCheckStepMeters,
request.Configuration.CurvatureLimitRadiusToleranceMeters,
out IReadOnlyList<SmoothedPathPoint> safePath,
out double minimumClearance, out reason) ||
minimumClearance < request.Configuration.MinimumClearanceReserveMeters ||
analysis.CurvatureVariationCost > rawBaseline.Metrics.CurvatureVariationCost *
(1d + options.MaximumVariationCostRegressionRatio))
{
if (string.IsNullOrEmpty(reason)) reason = "局部 G2 完整路径复核未通过。";
return false;
}
path = safePath;
segments = analysis.Segments;
metrics = new PathQualityMetrics(
true,
analysis.PathLengthMeters,
analysis.MaximumAbsoluteVehicleCurvaturePerMeter,
analysis.MaximumAbsoluteVehicleCurvatureDerivativePerSquareMeter,
analysis.RootMeanSquareVehicleCurvaturePerMeter,
analysis.TotalAbsoluteCurvatureVariationPerMeter,
analysis.CurvatureVariationCost,
minimumClearance,
0d, 0d, 0d, 0d);
return true;
}
private static PathSmoothingRegionReport CreateImprovedReport(
LocalG2SmoothingRegion region,
int candidateCount,
LocalG2CandidateEvaluation evaluation) =>
CreateReport(region, candidateCount, evaluation, PathSmoothingRegionStatus.Improved, PathSmoothingRegionFailureReason.None);
private static PathSmoothingRegionReport CreateRetainedReport(
LocalG2SmoothingRegion region,
int candidateCount,
LocalG2CandidateEvaluation evaluation) =>
CreateReport(region, candidateCount, evaluation, PathSmoothingRegionStatus.RetainedOriginal, evaluation.FailureReason);
private static PathSmoothingRegionReport CreateRollbackReport(
LocalG2SmoothingRegion region,
LocalG2CandidateEvaluation evaluation) =>
CreateReport(region, region.WindowVariants.Count, evaluation, PathSmoothingRegionStatus.RetainedOriginal,
PathSmoothingRegionFailureReason.GlobalValidationRollback);
private static PathSmoothingRegionReport CreateReport(
LocalG2SmoothingRegion region,
int candidateCount,
LocalG2CandidateEvaluation evaluation,
PathSmoothingRegionStatus status,
PathSmoothingRegionFailureReason failureReason)
{
LocalG2WindowVariant window = region.WindowVariants[0];
var jumps = new List<double>(region.Transitions.Count);
for (int index = 0; index < region.Transitions.Count; index++)
jumps.Add(region.Transitions[index].RightVehicleCurvaturePerMeter - region.Transitions[index].LeftVehicleCurvaturePerMeter);
return new PathSmoothingRegionReport(
region.SegmentIndex,
window.StartArcLengthMeters,
window.EndArcLengthMeters,
jumps,
window.EndArcLengthMeters - window.StartArcLengthMeters,
window.EndArcLengthMeters - window.StartArcLengthMeters,
window.LeftWindowLengthMeters,
window.RightWindowLengthMeters,
candidateCount,
evaluation.CandidateIndex,
status,
failureReason,
evaluation.RawPeakCurvatureDerivativePerSquareMeter,
evaluation.ResultPeakCurvatureDerivativePerSquareMeter,
evaluation.RawCurvatureVariationCost,
evaluation.ResultCurvatureVariationCost,
evaluation.MaximumDeviationMeters,
evaluation.MinimumBodyClearanceMeters,
evaluation.MaximumAbsoluteVehicleCurvaturePerMeter);
}
private static PathSmoothingResult Failure(PathSmoothingStatus status, Stopwatch stopwatch, string reason) =>
PathSmoothingResult.Failure(status,
new PathSmoothingDiagnostics(new PathQualityMetrics(), stopwatch.Elapsed, reason));
private sealed class AcceptedRegion
{
internal AcceptedRegion(LocalG2SmoothingRegion region, PreparedPath before, LocalG2CandidateEvaluation evaluation)
{
Region = region;
Before = before;
Evaluation = evaluation;
}
internal LocalG2SmoothingRegion Region { get; }
internal PreparedPath Before { get; }
internal LocalG2CandidateEvaluation Evaluation { get; }
}
}
@@ -2,12 +2,11 @@ using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Comparison;
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Comparison;
/// <summary>原始基线或一种平滑方法的不可变比较条目。</summary>
/// <summary>Immutable raw-baseline or Local G2 report entry.</summary>
public sealed class PathSmoothingComparisonEntry
{
/// <summary>为测试、离线分析和排序创建不携带路径几何的候选条目。</summary>
public PathSmoothingComparisonEntry(
SmoothingMethod method,
PathSmoothingStatus status,
@@ -34,47 +33,45 @@ public sealed class PathSmoothingComparisonEntry
IsRawPathBaseline = isRawPathBaseline;
Status = status;
Metrics = metrics ?? new PathQualityMetrics();
Timing = timing ?? new SmoothingTimingSummary(new double[] { 0d, 0d, 0d, 0d, 0d }, false, "未提供计时结果。");
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);
}
/// <summary>候选所代表的方法;原始粗路径基线为空。</summary>
public SmoothingMethod? Method { get; }
/// <summary>是否为单独分析的原始粗路径基线。</summary>
public bool IsRawPathBaseline { get; }
/// <summary>本条目的最终状态。</summary>
public PathSmoothingStatus Status { get; }
/// <summary>使用原始基线规范化后的质量指标。</summary>
public PathQualityMetrics Metrics { get; }
/// <summary>方法的五次测量计时;基线不参与计时排名。</summary>
public SmoothingTimingSummary Timing { get; }
/// <summary>由状态、分段元数据和完整路径 IEEE 754 位模式生成的 SHA-256 摘要。</summary>
public string StableGeometryDigest { get; }
/// <summary>面向报告和诊断的稳定说明。</summary>
public string Diagnostic { get; }
/// <summary>仅供比较与报告读取的正式路径;失败时为空。</summary>
public IReadOnlyList<SmoothedPathPoint> Path { get; }
/// <summary>覆盖 <see cref="Path"/> 的方向段;失败时为空。</summary>
public IReadOnlyList<SmoothedPathSegment> Segments { get; }
/// <summary>条目能否参与方法推荐。</summary>
public bool IsEligibleForRecommendation =>
!IsRawPathBaseline &&
Status == PathSmoothingStatus.Success &&
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,
@@ -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);
}
}
@@ -2,9 +2,9 @@ using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Comparison;
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Comparison;
/// <summary>一次离线比较的不可变基线、方法条目和推荐结论。</summary>
/// <summary>Immutable raw-path and Local G2 comparison result.</summary>
public sealed class PathSmoothingComparisonResult
{
internal PathSmoothingComparisonResult(
@@ -21,19 +21,15 @@ public sealed class PathSmoothingComparisonResult
Diagnostic = diagnostic ?? string.Empty;
}
/// <summary>独立分析的原始粗路径;不属于任何候选方法。</summary>
public PathSmoothingComparisonEntry RawPathBaseline { get; }
/// <summary>每个请求方法恰有一个条目;取消时可能只包含已完成的方法。</summary>
public IReadOnlyList<PathSmoothingComparisonEntry> Entries { get; }
/// <summary>按公开字典序选择的方法;没有合格方法或取消时为空。</summary>
/// <summary>Local G2 when its published result is deterministic; otherwise null.</summary>
public SmoothingMethod? RecommendedMethod { get; }
/// <summary>比较是否在启动后续方法前被取消。</summary>
public bool IsCancelled { get; }
/// <summary>整个比较的稳定状态说明。</summary>
public string Diagnostic { get; }
private static IReadOnlyList<PathSmoothingComparisonEntry> CopyReadOnly(
@@ -2,7 +2,7 @@ using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Comparison;
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Comparison;
/// <summary>一个方法的固定五次计时样本和确定性结论。</summary>
public sealed class SmoothingTimingSummary
@@ -4,7 +4,7 @@ using System.IO;
using System.Security.Cryptography;
using System.Text;
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Comparison;
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Comparison;
/// <summary>为重复执行结果生成与进程无关的稳定几何 SHA-256 摘要。</summary>
public static class StableGeometryDigest
@@ -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;"); }
}
@@ -37,6 +37,7 @@ internal static class RawPathBaselineBuilder
request.Map,
request.Vehicle,
maximumCollisionCheckStepMeters,
request.Configuration.CurvatureLimitRadiusToleranceMeters,
out IReadOnlyList<SmoothedPathPoint> safePath,
out double minimumClearanceMeters,
out reason))
@@ -55,6 +56,7 @@ internal static class RawPathBaselineBuilder
request.Map,
request.Vehicle,
maximumCollisionCheckStepMeters,
request.Configuration.CurvatureLimitRadiusToleranceMeters,
out safePath,
out minimumClearanceMeters,
out reason))
@@ -146,7 +148,7 @@ internal static class RawPathBaselineBuilder
double geometricCurvature = directionSign * point.VehicleCurvature;
SmoothedPathPointSource source = point.IsGearSwitchPoint
? SmoothedPathPointSource.GearSwitch
: SmoothedPathPointSource.CoarsePathFallback;
: SmoothedPathPointSource.Anchor;
path.Add(new SmoothedPathPoint(
point.X,
point.Y,
@@ -0,0 +1,137 @@
# PathSmoothing 路径平滑(Local G2
## 模块说明(Module Overview
`PathSmoothing` 是 Hybrid A* 成功输出后的空间参考路径后处理模块。它只保留 Local G2 五次过渡算法:在同一行驶方向的粗路径段内检测车辆曲率跳变,尝试以局部五次曲线替换,并对整条结果进行车辆曲率、全车体碰撞、净空和曲率变化代价复核。
该模块不参与粗路径搜索接受条件,也不做时间参数化、速度、加速度、转向速率或动态障碍物处理。下游只能消费本模块已经发布的空间路径。
## 文件结构(File Structure
```text
PathSmoothing/
├── Contracts/ # 请求、结果、配置、状态、质量指标与区域报告
├── Facade/ # 正式平滑入口和离线比较入口
├── LocalG2/ # 跳变检测、窗口规划、候选生成、评估、拼接
├── Processing/ # 粗路径预处理、原始基线和几何分析
├── Validation/ # 全车体碰撞与车辆曲率复核
├── Output/
│ ├── Comparison/ # 原始路径与 Local G2 的稳定比较模型和摘要
│ └── Visualization/ # SVG、PNG、CSV 报告模型和导出器
├── Test/ # 固定夹具、演示入口和 Local G2 诊断证据
│ └── Fixtures/ # 版本化的成功粗路径快照
└── README.md
```
`Algorithms/`、旧的 `Comparison/`、旧的 `Visualization/` 以及 B-spline、局部 Bezier、分段五次选项均已移除。输出相关代码统一归入 `Output/`,与 `CoarsePath/Output/` 的组织方式保持一致。
## 平滑数据流(Smoothing Data Flow
```text
成功的 CoarsePath
-> PathSmoothingRequest 快照
-> 输入校验与 PathSmoothingPreprocessor
-> RawPathBaselineBuilder 原始路径全局复核
-> CurvatureTransitionDetector(仅同向 PathSegment 内)
-> LocalG2WindowPlanner
-> LocalG2CandidateBuilder / LocalG2CandidateEvaluator
-> LocalG2PathSplicer
-> 全路径曲率、碰撞、净空、代价复核
-> PathSmoothingResult.PublishLocalG2
```
Local G2 不跨换向段建立窗口。它处理的是同一前进或倒退段内、由运动原语摆角变化等原因形成的车辆曲率跳变,而不是把换向点作为平滑对象。
## 结果状态与发布规则(Result Status and Publication Rules
下列四个状态都会发布已复核的 `Path``Segments`,可由下游消费:
| 状态 | 含义 |
| --- | --- |
| `Complete` | 检测到的所有 Local G2 区域都被接受并替换。 |
| `PartialImprovement` | 至少一个区域被替换,其余区域保留原始几何。 |
| `NotNeeded` | 没有检测到同向段内的曲率跳变;发布已复核的原始路径。 |
| `Unchanged` | 检测到区域,但所有候选都被拒绝;发布已复核的原始路径,并保留 `RegionReports`。 |
`InvalidInput``Cancelled``Failed``Infeasible` 不发布可消费路径。`Unchanged` 不是失败,它表示“安全门或质量门没有接受新候选”,不是“原始粗路径未经验证地回退”。
### 区域报告与拒绝原因
`PathSmoothingRegionReport` 按检测顺序记录每个 Local G2 区域。区域状态为 `Improved``RetainedOriginal`;后者可进一步给出 `WindowUnavailable``CandidateGenerationFailed``Collision``InsufficientClearance``CurvatureExceeded``CurvatureOvershoot``DeviationExceeded``InsufficientImprovement``VariationCostRegression``GlobalValidationRollback`
候选先进行全车体碰撞复核,再检查净空余量。`MinimumClearanceReserveMeters = 0d` 只取消额外净空储备,不会取消车辆曲率、碰撞、数值有效性、偏差或曲率变化代价门。
## 坐标与单位(Coordinates and Units
- 路径位置、弧长、窗口长度、偏差、净空和碰撞检查步长:m
- 航向:rad
- 车辆曲率:`1/m`
- 车辆曲率导数:`1/m^2`
- 地图构建输入仍沿用 mm`PlanningGridMap` 的路径复核查询使用 m
发布路径保留成功粗路径的首尾位姿,因此末端误差继承粗规划器已接受的目标容差。
## 最小调用示例(Minimal Call Example
```csharp
if (coarseResult.PlanningResult.Status != PlanningStatus.Success)
return;
var request = new PathSmoothingRequest(
coarseResult.PlanningResult.Path,
coarseResult.PlanningResult.Segments,
coarseResult.MapResult.Map,
job.Vehicle,
new PathSmoothingConfiguration());
PathSmoothingResult result = new PathSmoothingService().Smooth(request, cancellationToken);
bool published = result.Status == PathSmoothingStatus.Complete ||
result.Status == PathSmoothingStatus.PartialImprovement ||
result.Status == PathSmoothingStatus.NotNeeded ||
result.Status == PathSmoothingStatus.Unchanged;
if (published)
ConsumeSpatialReference(result.Path, result.Segments);
```
`PathSmoothingConfiguration` 没有算法选择开关;正式入口始终执行 Local G2。
## 详细使用指南(Detailed Usage Guide
1. 长期持有 `PathSmoothingService`,只向它传入成功的粗路径、方向分段、规划地图和车辆参数。
2. 使用 `PathSmoothingConfiguration` 调整输出采样、碰撞检查步长和 Local G2 窗口/质量门参数。
3. `MinimumClearanceReserveMeters` 当前默认 `0d`。需要额外净空时显式设置正值;碰撞约束始终存在。
4. 按上表判断结果是否已发布,随后消费 `Path``Segments`;不要以 `Failed``Cancelled` 的空路径继续规划。
5.`PartialImprovement``Unchanged`,读取 `RegionReports` 判断被保留的区域以及拒绝原因。
Local G2 关键选项位于 `LocalG2Quintic`:最小、首选和最大窗口长度,最大偏差,曲率跳变阈值,峰值曲率导数改善比,曲率变化代价回退比,以及每区候选数。
## 固定夹具报告与可视化(Fixture Reports and Visualization
`SmoothingScenarioFixtureLoader.LoadAndVerify` 会校验 JSON 夹具的版本和指纹;夹具报告不重新运行 Hybrid A*。离线比较始终输出“原始粗路径 + Local G2”两组数据。
`SmoothingReportExporter.Export(SmoothingReportExportRequest)` 为每个场景写入 CSV、SVG 和 PNG。常规图固定为四张:
1. `01-coarse-path-overview`
2. `02-all-paths-comparison`
3. `03-local-g2-overview`
4. `04-curvature-comparison`
当模型显式附带诊断候选时,额外写入 `05-local-g2-diagnostic-candidate`。诊断候选仅用于解释拒绝,不是发布路径。
报告默认写入 `ClumsyPilot/obj/path_smoothing_reports` 的调用方指定子目录。PNG 需要 Windows 的 `SimSun``Times New Roman`;若字体不可用,导出返回 `FontUnavailable`SVG 和 CSV 仍可单独使用。
## 常见错误(Common Errors
- 将未成功的 CoarsePath 直接传入平滑:应先检查 `PlanningStatus.Success`
-`Unchanged` 当作失败:它仍然包含已复核的原始路径和区域报告。
- 认为净空余量为 0 会关闭碰撞检查:它只取消额外储备,不能绕过全车体碰撞和车辆曲率门。
- 在换向点两侧强行连续平滑:当前窗口检测明确不跨方向段。
- 使用过期夹具:修改粗路径场景或规划配置后,应按夹具生成/验证脚本更新快照。
## 第一版限制(First-Version Limits
- 仅平滑同向段内的车辆曲率跳变;不跨换向点。
- 仅包含 Local G2 五次局部过渡,不提供多算法比较或算法回退。
- 只输出几何空间路径;没有时间、速度、加速度、转向速率和动态障碍物约束。
- Local G2 候选必须通过严格的全路径复核;在当前夹具上,`Unchanged``Failed` 仍是有价值的诊断结果,而不是自动放宽安全约束的信号。
@@ -4,7 +4,7 @@
{
"id": "straight",
"fixtureVersion": 1,
"configurationFingerprint": "sha256:d8c625157c21789a8bd33052a7f5927e4b1f697a2567cf3644b37ce00e164c51",
"configurationFingerprint": "sha256:d42ce997e1e51d421fe923c5819a332706007d900cc74a39d4b0ac88c6ac4a21",
"map": {
"xMinMm": 0.0,
"xMaxMm": 6000.0,
@@ -1914,7 +1914,7 @@
{
"id": "single-turn",
"fixtureVersion": 1,
"configurationFingerprint": "sha256:a0218dbe66746b347139161d545656d6a55516f8e87769b4f9f17c5cf8d5d84f",
"configurationFingerprint": "sha256:49571e2eeb2e58f0a5dc0a56e61e530700c3da7d60e2a9c66809721963e5d9f8",
"map": {
"xMinMm": -7000.0,
"xMaxMm": 11000.0,
@@ -3020,7 +3020,7 @@
{
"id": "s-bend",
"fixtureVersion": 1,
"configurationFingerprint": "sha256:aa1ae45d914e3ddc181bef415d71659fb357f9e2288e3eecec52376e6ca663bc",
"configurationFingerprint": "sha256:810b601e9090644292e4eb5652379f5f0db6fa7158aaf6e4faeae4b337ad5ec5",
"map": {
"xMinMm": 0.0,
"xMaxMm": 6000.0,
@@ -6715,7 +6715,7 @@
{
"id": "large-heading-change",
"fixtureVersion": 1,
"configurationFingerprint": "sha256:5a5b66530a8782e81b456102602112d3e9f41f031d976e7daad035f9fa70311b",
"configurationFingerprint": "sha256:44b6c95a3b553bb04df3912d5cf7c9110f4d629e37551b2c130d8d7f22706fce",
"map": {
"xMinMm": -7000.0,
"xMaxMm": 11000.0,
@@ -8253,7 +8253,7 @@
{
"id": "rectangle-detour",
"fixtureVersion": 1,
"configurationFingerprint": "sha256:de5a9261b3b073e4324526ecaf0dfc9251aa4b02f85e3c07abc0c63cfe72c85c",
"configurationFingerprint": "sha256:6ad46b2782bc519b17d62dac9e238542a88f4265fb7dd3a1ebe84c04313cf6ce",
"map": {
"xMinMm": 0.0,
"xMaxMm": 6000.0,
@@ -12158,7 +12158,7 @@
{
"id": "multi-obstacle-detour",
"fixtureVersion": 1,
"configurationFingerprint": "sha256:278442046751c251baea0961aeb6d1bb4976498b2abb321754e5a5e941b5d2ce",
"configurationFingerprint": "sha256:d062cfcf4fec6c0119575bb0c139697d8e4d5164e958cf511c0200d0f8d9fb5d",
"map": {
"xMinMm": 0.0,
"xMaxMm": 6000.0,
@@ -15589,7 +15589,7 @@
{
"id": "narrow-corridor",
"fixtureVersion": 1,
"configurationFingerprint": "sha256:4ddf1423d67649faa9f52ae5ca8455c594e79ddf48374a4759a214cb5075c1b6",
"configurationFingerprint": "sha256:5ec976b32eec84abdd781b8cedeb21d07dfd6809ea61c108bcce8cff0b9d3a37",
"map": {
"xMinMm": 0.0,
"xMaxMm": 6000.0,
@@ -17520,7 +17520,7 @@
{
"id": "forward-reverse-switch",
"fixtureVersion": 1,
"configurationFingerprint": "sha256:68563bf572a2fb3d9db25e7639bf488e36f83c990dbf5578373e48f3ed6270ed",
"configurationFingerprint": "sha256:86e1436370027ad30a7a205a78da291f81345e6acff2c0bb4f2256803ad9d962",
"map": {
"xMinMm": 0.0,
"xMaxMm": 6000.0,
@@ -0,0 +1,171 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
using System.Threading;
using MultiWheelC.TrajectoryPlanning.CoarsePath;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Comparison;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Facade;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.LocalG2;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Processing;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Visualization;
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Test;
public sealed class LocalG2DiagnosticVisualizationDemo
{
private readonly LocalG2DiagnosticEvidenceLoader _evidenceLoader = new LocalG2DiagnosticEvidenceLoader();
private readonly PathSmoothingComparisonService _comparisonService = new PathSmoothingComparisonService();
private readonly PathSmoothingPreprocessor _preprocessor = new PathSmoothingPreprocessor();
private readonly LocalG2PathSplicer _splicer = new LocalG2PathSplicer();
private readonly PathGeometryAnalyzer _analyzer = new PathGeometryAnalyzer();
private readonly SmoothingFigureModelBuilder _figureBuilder = new SmoothingFigureModelBuilder();
private readonly SmoothingReportExporter _exporter = new SmoothingReportExporter();
public SmoothingReportExportResult Export(
string fixturePath,
string evidencePath,
string outputDirectory,
CancellationToken cancellationToken = default)
{
LocalG2DiagnosticEvidence evidence = _evidenceLoader.LoadAndVerify(evidencePath);
RequireFixtureHash(fixturePath, evidence.FixtureSha256);
PathSmoothingComparisonRequest request = FindFixtureRequest(fixturePath, evidence.ScenarioId);
PathSmoothingComparisonResult comparison = _comparisonService.Compare(request, cancellationToken);
PathSmoothingComparisonEntry localG2 = FindEntry(comparison, SmoothingMethod.LocalG2Quintic);
Require(localG2 != null && localG2.Status == PathSmoothingStatus.Unchanged,
"Strict Local G2 result must be Unchanged for the diagnostic evidence.");
RequireSameGeometry(comparison.RawPathBaseline.Path, localG2.Path);
Require(_preprocessor.TryPrepare(request.SmoothingRequest, out PreparedPath prepared, out string reason), reason);
LocalG2CandidateGeometry candidate = CreateCandidate(evidence);
Require(_splicer.TryReplace(prepared, candidate, out PreparedPath spliced, out reason), reason);
Require(_analyzer.TryAnalyze(spliced.Segments, request.SmoothingRequest.Configuration.OutputSpacingMeters,
out PathGeometryAnalysis analysis, out reason), reason);
CoarsePathPoint first = request.SmoothingRequest.CoarsePath[0];
CoarsePathPoint last = request.SmoothingRequest.CoarsePath[request.SmoothingRequest.CoarsePath.Count - 1];
SmoothingFigureModel normal = _figureBuilder.Build(
comparison,
request.SmoothingRequest.Map,
new Pose2D(first.X, first.Y, first.Heading),
new Pose2D(last.X, last.Y, last.Heading),
evidence.ScenarioId,
evidence.ScenarioId);
SmoothingFigureModel augmented = normal.WithAdditionalSeries(CreateDiagnosticSeries(analysis.Path));
return _exporter.Export(new SmoothingReportExportRequest
{
Model = augmented,
OutputDirectory = outputDirectory,
FileStem = "comparison",
});
}
private static void RequireFixtureHash(string fixturePath, string expectedFixtureSha256)
{
byte[] fixtureBytes = File.ReadAllBytes(fixturePath);
byte[] hash;
using (SHA256 sha256 = SHA256.Create())
hash = sha256.ComputeHash(fixtureBytes);
string actualFixtureSha256 = BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant();
Require(string.Equals(actualFixtureSha256, expectedFixtureSha256, StringComparison.Ordinal),
"Diagnostic evidence fixture SHA-256 does not match the supplied fixture.");
}
private static PathSmoothingComparisonRequest FindFixtureRequest(string fixturePath, string scenarioId)
{
IReadOnlyList<SmoothingScenarioFixture> fixtures = SmoothingScenarioFixtureLoader.LoadAndVerify(fixturePath);
IReadOnlyList<PathSmoothingComparisonRequest> requests = SmoothingScenarioFactory.CreateFixtureRequests(fixturePath);
Require(fixtures.Count == requests.Count, "Fixture requests do not match fixture records.");
int fixtureIndex = -1;
for (int index = 0; index < fixtures.Count; index++)
{
if (!string.Equals(fixtures[index].Id, scenarioId, StringComparison.Ordinal)) continue;
Require(fixtureIndex < 0, "Diagnostic fixture ID must be unique: " + scenarioId);
fixtureIndex = index;
}
Require(fixtureIndex >= 0, "Diagnostic fixture ID was not found: " + scenarioId);
return requests[fixtureIndex];
}
private static PathSmoothingComparisonEntry FindEntry(PathSmoothingComparisonResult comparison, SmoothingMethod method)
{
if (comparison == null) return null;
for (int index = 0; index < comparison.Entries.Count; index++)
{
if (comparison.Entries[index].Method == method) return comparison.Entries[index];
}
return null;
}
private static LocalG2CandidateGeometry CreateCandidate(LocalG2DiagnosticEvidence evidence)
{
Require(evidence != null && evidence.CandidatePoints != null, "Diagnostic evidence is required.");
var points = new List<SmoothingPoint2D>(evidence.CandidatePoints.Count);
for (int index = 0; index < evidence.CandidatePoints.Count; index++)
{
LocalG2DiagnosticEvidencePoint point = evidence.CandidatePoints[index];
points.Add(new SmoothingPoint2D(
point.X.Value, point.Y.Value, point.ReferenceArcLengthMeters.Value,
point.HeadingRadians.Value, point.UnwrappedHeadingRadians.Value,
0d, false, SmoothedPathPointSource.LocalG2Transition));
}
return new LocalG2CandidateGeometry(
evidence.CandidateIndex.Value,
evidence.SegmentIndex.Value,
evidence.WindowStartArcLengthMeters.Value,
evidence.WindowEndArcLengthMeters.Value,
0d,
0d,
points,
evidence.StartVehicleCurvaturePerMeter.Value,
evidence.EndVehicleCurvaturePerMeter.Value,
evidence.StartGeometricCurvaturePerMeter.Value,
evidence.EndGeometricCurvaturePerMeter.Value,
true);
}
private static SmoothingFigureSeries CreateDiagnosticSeries(IReadOnlyList<SmoothedPathPoint> path)
{
var points = new List<SmoothingFigurePoint>(path == null ? 0 : path.Count);
if (path != null)
{
for (int index = 0; index < path.Count; index++)
{
SmoothedPathPoint point = path[index];
points.Add(new SmoothingFigurePoint(point.X, point.Y, point.ArcLength, point.VehicleCurvature));
}
}
return new SmoothingFigureSeries(
null,
"local-g2-diagnostic",
"G2 诊断候选(净空拒绝,未发布)",
PathSmoothingStatus.Infeasible,
IeeeFigureStyle.LocalG2DiagnosticColor,
string.Empty,
false,
points,
Array.Empty<SmoothingFigurePoint>());
}
private static void RequireSameGeometry(IReadOnlyList<SmoothedPathPoint> expected, IReadOnlyList<SmoothedPathPoint> actual)
{
Require(expected != null && actual != null && expected.Count == actual.Count,
"Strict Local G2 output must retain raw-path geometry.");
for (int index = 0; index < expected.Count; index++)
{
SmoothedPathPoint left = expected[index];
SmoothedPathPoint right = actual[index];
Require(left.X == right.X && left.Y == right.Y && left.Heading == right.Heading &&
left.UnwrappedHeading == right.UnwrappedHeading && left.ArcLength == right.ArcLength &&
left.Direction == right.Direction && left.GeometricCurvature == right.GeometricCurvature &&
left.VehicleCurvature == right.VehicleCurvature && left.VehicleCurvatureDerivative == right.VehicleCurvatureDerivative &&
left.BodyClearance == right.BodyClearance && left.IsGearSwitchPoint == right.IsGearSwitchPoint && left.Source == right.Source,
"Strict Local G2 output must retain raw-path geometry.");
}
}
private static void Require(bool condition, string reason)
{
if (!condition) throw new InvalidOperationException(reason);
}
}
@@ -0,0 +1,164 @@
using System;
using System.Collections.Generic;
using System.Threading;
using MultiWheelC.TrajectoryPlanning.CoarsePath;
using MultiWheelC.TrajectoryPlanning.CoarsePath.Facade;
using MultiWheelC.TrajectoryPlanning.CoarsePath.Test;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Comparison;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Facade;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Visualization;
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Test;
/// <summary>开发人员离线导出八个快速夹具和四个 Hybrid A* 端到端场景的比较报告。</summary>
public sealed class PathSmoothingComparisonDemo
{
private readonly PathSmoothingService _smoothingService = new PathSmoothingService();
private readonly PathSmoothingComparisonService _comparisonService = new PathSmoothingComparisonService();
private readonly SmoothingFigureModelBuilder _figureBuilder = new SmoothingFigureModelBuilder();
private readonly SmoothingReportExporter _reportExporter = new SmoothingReportExporter();
/// <summary>导出八个已验证夹具;不运行 Hybrid A*。</summary>
public IReadOnlyList<PathSmoothingComparisonScenarioResult> ExportFixtureReports(
string fixturePath,
string outputDirectory,
CancellationToken cancellationToken = default)
{
IReadOnlyList<SmoothingScenarioFixture> fixtures = SmoothingScenarioFixtureLoader.LoadAndVerify(fixturePath);
IReadOnlyList<PathSmoothingComparisonRequest> requests = SmoothingScenarioFactory.CreateFixtureRequests(fixturePath);
var results = new List<PathSmoothingComparisonScenarioResult>(requests.Count);
for (int index = 0; index < requests.Count; index++)
{
cancellationToken.ThrowIfCancellationRequested();
results.Add(Export(fixtures[index].Id, requests[index], outputDirectory, cancellationToken));
}
return results.AsReadOnly();
}
/// <summary>运行四个固定 Hybrid A* 场景,并仅为成功粗路径导出平滑比较报告。</summary>
public IReadOnlyList<PathSmoothingComparisonScenarioResult> ExportEndToEndReports(
string outputDirectory,
CancellationToken cancellationToken = default)
{
CoarsePathTestScenario[] scenarios =
{
CoarsePathTestScenario.ExplicitEmpty,
CoarsePathTestScenario.RectangleDetour,
CoarsePathTestScenario.ManualAndTwoLeg,
CoarsePathTestScenario.ReverseGearSwitch,
};
var planner = new CoarsePathPlanningService();
var results = new List<PathSmoothingComparisonScenarioResult>(scenarios.Length);
for (int index = 0; index < scenarios.Length; index++)
{
cancellationToken.ThrowIfCancellationRequested();
CoarsePathPlanningJob job = CoarsePathScenarioFactory.Create(scenarios[index]);
CoarsePathPlanningJobResult coarse = planner.Plan(job, cancellationToken);
if (coarse.PlanningResult.Status != PlanningStatus.Success)
{
results.Add(PathSmoothingComparisonScenarioResult.CoarsePathFailure(
scenarios[index].ToString(), coarse.PlanningResult.Status, coarse.PlanningResult.Diagnostics.TerminationReason));
continue;
}
results.Add(Export(
scenarios[index].ToString(),
SmoothingScenarioFactory.CreateEndToEndRequest(job, coarse),
outputDirectory,
cancellationToken));
}
return results.AsReadOnly();
}
/// <summary>供业务调用示例使用:仅在已成功的粗路径上调用平滑服务。</summary>
public PathSmoothingResult SmoothSuccessfulCoarsePath(
CoarsePathPlanningJob job,
CoarsePathPlanningJobResult coarseResult,
PathSmoothingConfiguration configuration,
CancellationToken cancellationToken = default)
{
if (job == null || coarseResult == null || coarseResult.PlanningResult.Status != PlanningStatus.Success)
throw new ArgumentException("只有成功的粗路径能够进入平滑流程。", nameof(coarseResult));
return _smoothingService.Smooth(new PathSmoothingRequest(
coarseResult.PlanningResult.Path,
coarseResult.PlanningResult.Segments,
coarseResult.MapResult.Map,
job.Vehicle,
configuration), cancellationToken);
}
/// <summary>业务调用方只可消费成功平滑或显式回退的路径。</summary>
public static bool IsConsumable(PathSmoothingStatus status)
{
return status == PathSmoothingStatus.Complete ||
status == PathSmoothingStatus.PartialImprovement ||
status == PathSmoothingStatus.NotNeeded ||
status == PathSmoothingStatus.Unchanged;
}
private PathSmoothingComparisonScenarioResult Export(
string scenarioId,
PathSmoothingComparisonRequest request,
string outputDirectory,
CancellationToken cancellationToken)
{
if (request == null || request.SmoothingRequest == null || request.SmoothingRequest.CoarsePath.Count == 0)
throw new ArgumentException("比较请求必须包含粗路径。", nameof(request));
PathSmoothingComparisonResult comparison = _comparisonService.Compare(request, cancellationToken);
CoarsePathPoint first = request.SmoothingRequest.CoarsePath[0];
CoarsePathPoint last = request.SmoothingRequest.CoarsePath[request.SmoothingRequest.CoarsePath.Count - 1];
SmoothingFigureModel model = _figureBuilder.Build(
comparison,
request.SmoothingRequest.Map,
new Pose2D(first.X, first.Y, first.Heading),
new Pose2D(last.X, last.Y, last.Heading),
scenarioId,
scenarioId);
SmoothingReportExportResult report = _reportExporter.Export(new SmoothingReportExportRequest
{
Model = model,
OutputDirectory = System.IO.Path.Combine(outputDirectory, scenarioId),
FileStem = "comparison",
});
return PathSmoothingComparisonScenarioResult.ComparisonComplete(scenarioId, comparison, report);
}
}
/// <summary>批处理为一个场景发布的粗路径状态、平滑比较和报告结果。</summary>
public sealed class PathSmoothingComparisonScenarioResult
{
private PathSmoothingComparisonScenarioResult(
string scenarioId,
PlanningStatus coarsePathStatus,
string diagnostic,
PathSmoothingComparisonResult comparison,
SmoothingReportExportResult report)
{
ScenarioId = scenarioId ?? string.Empty;
CoarsePathStatus = coarsePathStatus;
Diagnostic = diagnostic ?? string.Empty;
Comparison = comparison;
Report = report;
}
public string ScenarioId { get; }
public PlanningStatus CoarsePathStatus { get; }
public string Diagnostic { get; }
public PathSmoothingComparisonResult Comparison { get; }
public SmoothingReportExportResult Report { get; }
internal static PathSmoothingComparisonScenarioResult CoarsePathFailure(
string scenarioId,
PlanningStatus status,
string diagnostic)
{
return new PathSmoothingComparisonScenarioResult(scenarioId, status, diagnostic, null, null);
}
internal static PathSmoothingComparisonScenarioResult ComparisonComplete(
string scenarioId,
PathSmoothingComparisonResult comparison,
SmoothingReportExportResult report)
{
return new PathSmoothingComparisonScenarioResult(scenarioId, PlanningStatus.Success, string.Empty, comparison, report);
}
}
@@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.Threading;
using MultiWheelC.TrajectoryPlanning.CoarsePath;
using MultiWheelC.TrajectoryPlanning.CoarsePath.Facade;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Comparison;
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Comparison;
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Test;
@@ -239,6 +239,20 @@ public static class SmoothingScenarioFixtureLoader
private static void Append(StringBuilder builder, string value) { builder.Append(value ?? string.Empty).Append('|'); }
private static void Append(StringBuilder builder, int value) { builder.Append(value.ToString(CultureInfo.InvariantCulture)).Append('|'); }
private static void Append(StringBuilder builder, float value) { builder.Append(value.ToString("R", CultureInfo.InvariantCulture)).Append('|'); }
private static void Append(StringBuilder builder, double value) { builder.Append(value.ToString("R", CultureInfo.InvariantCulture)).Append('|'); }
private static void Append(StringBuilder builder, float value)
{
AppendBits(builder, BitConverter.GetBytes(value));
}
private static void Append(StringBuilder builder, double value)
{
AppendBits(builder, BitConverter.GetBytes(value));
}
private static void AppendBits(StringBuilder builder, byte[] bytes)
{
for (int index = bytes.Length - 1; index >= 0; index--)
builder.Append(bytes[index].ToString("x2", CultureInfo.InvariantCulture));
builder.Append('|');
}
}
@@ -0,0 +1,29 @@
using System;
using MultiWheelC.TrajectoryPlanning.CoarsePath;
using MultiWheelC.TrajectoryPlanning.CoarsePath.Vehicle;
using MultiWheelC.TrajectoryPlanning.Utils;
namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.Validation;
internal static class CurvatureLimitPolicy
{
internal static bool TryGetAllowedMaximumVehicleCurvaturePerMeter(
VehicleParameters vehicle,
double radiusToleranceMeters,
out double allowedMaximumCurvaturePerMeter)
{
allowedMaximumCurvaturePerMeter = 0d;
if (!NumericGuard.IsFinite(radiusToleranceMeters) || radiusToleranceMeters < 0d ||
!VehicleKinematics.TryGetMaximumCurvaturePerMeter(vehicle, out double nominalMaximumCurvature))
{
return false;
}
double nominalMinimumRadius = 1d / nominalMaximumCurvature;
double toleratedMinimumRadius = nominalMinimumRadius - radiusToleranceMeters;
if (!NumericGuard.IsPositiveFinite(toleratedMinimumRadius)) return false;
allowedMaximumCurvaturePerMeter = 1d / toleratedMinimumRadius;
return NumericGuard.IsPositiveFinite(allowedMaximumCurvaturePerMeter);
}
}
@@ -22,15 +22,23 @@ public sealed class SmoothedPathValidator
}
/// <summary>创建使用指定连续车体碰撞检查器的平滑路径验证器。</summary>
/// <param name="collisionChecker">以车辆几何中心位姿执行完整车体和扫掠复核的检查器,不能为 <see langword="null"/>。</param>
public SmoothedPathValidator(FootprintCollisionChecker collisionChecker)
{
_collisionChecker = collisionChecker ?? throw new ArgumentNullException(nameof(collisionChecker));
}
/// <summary>
/// 复核候选平滑路径。每个候选方向段必须保持原始段的端点和换向拓扑;
/// 输出中的净空均由本次实际车体检查重新计算,绝不沿用候选声明值。
/// </summary>
/// <summary>复核候选平滑路径,并以默认零转弯半径容差重新计算所有净空。</summary>
/// <param name="candidatePath">待发布候选点序列;位置/弧长单位为 m、航向为 rad、曲率为 1/m。</param>
/// <param name="candidateSegments">覆盖候选点的前进/倒车方向段集合。</param>
/// <param name="originalPath">预处理原始路径,候选必须保留其端点与换向拓扑。</param>
/// <param name="map">已准备的规划地图快照。</param>
/// <param name="vehicle">车辆尺寸、安全余量和曲率约束。</param>
/// <param name="maximumCollisionCheckStepMeters">相邻点车体扫掠最大中心步长,单位 m。</param>
/// <param name="pathWithClearance">成功时为净空由真实复核重算的只读路径;失败时为空集合。</param>
/// <param name="minimumClearanceMeters">成功时为完整扩大车体的最小保守净空,单位 m;失败时为 0。</param>
/// <param name="reason">失败原因;成功时为空字符串。</param>
/// <returns>端点、换向、曲率、点碰撞和扫掠均通过时为 <see langword="true"/>;否则为 <see langword="false"/>,不发布部分路径。</returns>
public bool TryValidate(
IReadOnlyList<SmoothedPathPoint> candidatePath,
IReadOnlyList<SmoothedPathSegment> candidateSegments,
@@ -41,6 +49,35 @@ public sealed class SmoothedPathValidator
out IReadOnlyList<SmoothedPathPoint> pathWithClearance,
out double minimumClearanceMeters,
out string reason)
{
return TryValidate(
candidatePath, candidateSegments, originalPath, map, vehicle, maximumCollisionCheckStepMeters, 0d,
out pathWithClearance, out minimumClearanceMeters, out reason);
}
/// <summary>以只用于曲率上限的显式转弯半径容差复核候选路径。</summary>
/// <param name="candidatePath">待复核候选点序列。</param>
/// <param name="candidateSegments">候选方向段集合。</param>
/// <param name="originalPath">必须保持端点和换向拓扑的预处理原始路径。</param>
/// <param name="map">规划地图快照。</param>
/// <param name="vehicle">车辆几何和曲率约束。</param>
/// <param name="maximumCollisionCheckStepMeters">扫掠复核最大中心步长,单位 m。</param>
/// <param name="curvatureLimitRadiusToleranceMeters">仅曲率限制使用的最小转弯半径容差,单位 m。</param>
/// <param name="pathWithClearance">成功时为重新计算净空后的完整只读路径;失败时为空集合。</param>
/// <param name="minimumClearanceMeters">成功时最小保守净空,单位 m;失败时为 0。</param>
/// <param name="reason">失败诊断;成功时为空字符串。</param>
/// <returns>候选满足拓扑、严格弧长、曲率、点碰撞和扫掠约束时为 <see langword="true"/>;否则为 <see langword="false"/>。</returns>
public bool TryValidate(
IReadOnlyList<SmoothedPathPoint> candidatePath,
IReadOnlyList<SmoothedPathSegment> candidateSegments,
PreparedPath originalPath,
PlanningGridMap map,
VehicleParameters vehicle,
double maximumCollisionCheckStepMeters,
double curvatureLimitRadiusToleranceMeters,
out IReadOnlyList<SmoothedPathPoint> pathWithClearance,
out double minimumClearanceMeters,
out string reason)
{
pathWithClearance = EmptyPath();
minimumClearanceMeters = 0d;
@@ -52,7 +89,8 @@ public sealed class SmoothedPathValidator
return false;
}
if (!VehicleKinematics.TryGetMaximumCurvaturePerMeter(vehicle, out double maximumCurvatureMeters))
if (!CurvatureLimitPolicy.TryGetAllowedMaximumVehicleCurvaturePerMeter(
vehicle, curvatureLimitRadiusToleranceMeters, out double maximumCurvatureMeters))
{
reason = "车辆曲率约束无效。";
return false;