using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Threading; using MultiWheelC.TrajectoryPlanning.CoarsePath; using MultiWheelC.TrajectoryPlanning.PathSmoothing.Processing; using MultiWheelC.TrajectoryPlanning.Utils; namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.LocalG2; /// 以固定窗口和尺度顺序构造有限的局部五次 Hermite G2 候选。 internal sealed class LocalG2CandidateBuilder { private const double MinimumDerivativeNorm = 1e-10d; private const int MaximumDerivativeCertificationDepth = 40; private const int MaximumDerivativeCertificationIntervals = 8192; private const double DerivativeCertificationMargin = 1e-12d; private const int MaximumSubdivisionDepth = 32; private static readonly double[] DerivativeScaleMultipliers = { 1d, 0.85d, 1.15d }; internal IReadOnlyList Build( PreparedDirectionSegment originalSegment, LocalG2SmoothingRegion region, double outputSpacingMeters, LocalG2OptionsSnapshot options, CancellationToken cancellationToken) { if (!IsValidInput(originalSegment, region, outputSpacingMeters, options)) return Empty(); var candidates = new List(); for (int windowIndex = 0; windowIndex < region.WindowVariants.Count; windowIndex++) { LocalG2WindowVariant window = region.WindowVariants[windowIndex]; for (int scaleIndex = 0; scaleIndex < DerivativeScaleMultipliers.Length; scaleIndex++) { cancellationToken.ThrowIfCancellationRequested(); if (candidates.Count >= Math.Min(options.MaximumCandidatesPerRegion, 12)) return ReadOnly(candidates); if (TryBuildCandidate( candidates.Count, originalSegment, region, window, DerivativeScaleMultipliers[scaleIndex], outputSpacingMeters, cancellationToken, out LocalG2CandidateGeometry candidate)) { candidates.Add(candidate); } } } return ReadOnly(candidates); } private static bool TryBuildCandidate( int candidateIndex, PreparedDirectionSegment segment, LocalG2SmoothingRegion region, LocalG2WindowVariant window, double multiplier, double outputSpacingMeters, CancellationToken cancellationToken, out LocalG2CandidateGeometry candidate) { candidate = null; if (!PathReferenceInterpolator.TryInterpolateByArcLength(segment.Points, window.StartArcLengthMeters, out SmoothingPoint2D startReference, out _) || !PathReferenceInterpolator.TryInterpolateByArcLength(segment.Points, window.EndArcLengthMeters, out SmoothingPoint2D endReference, out _)) { return false; } double directionSign = segment.Direction == TravelDirection.Forward ? 1d : -1d; CurvatureTransition first = region.Transitions[0]; CurvatureTransition last = region.Transitions[region.Transitions.Count - 1]; bool firstAtStart = SameArc(first.LocalArcLengthMeters, window.StartArcLengthMeters); bool lastAtEnd = SameArc(last.LocalArcLengthMeters, window.EndArcLengthMeters); double startVehicleCurvature = firstAtStart && segment.StartVehicleCurvaturePerMeter.HasValue ? segment.StartVehicleCurvaturePerMeter.Value : first.LeftVehicleCurvaturePerMeter; double endVehicleCurvature = last.RightVehicleCurvaturePerMeter; if (!NumericGuard.IsFinite(startVehicleCurvature) || !NumericGuard.IsFinite(endVehicleCurvature)) return false; var nodes = new List(); nodes.Add(new BoundaryNode(window.StartArcLengthMeters, startReference.X, startReference.Y, startReference.Heading, startVehicleCurvature)); for (int transitionIndex = 0; transitionIndex < region.Transitions.Count; transitionIndex++) { CurvatureTransition transition = region.Transitions[transitionIndex]; bool atStart = SameArc(transition.LocalArcLengthMeters, window.StartArcLengthMeters); bool atEnd = SameArc(transition.LocalArcLengthMeters, window.EndArcLengthMeters); // A one-sided transition at a hard segment boundary has no left/right zero-length piece. if (atStart || atEnd) continue; double leftAvailable = transition.LocalArcLengthMeters - window.StartArcLengthMeters; double rightAvailable = window.EndArcLengthMeters - transition.LocalArcLengthMeters; double denominator = leftAvailable + rightAvailable; if (!NumericGuard.IsPositiveFinite(denominator)) return false; double sharedCurvature = transition.LeftVehicleCurvaturePerMeter + (transition.RightVehicleCurvaturePerMeter - transition.LeftVehicleCurvaturePerMeter) * leftAvailable / denominator; if (!NumericGuard.IsFinite(sharedCurvature)) return false; nodes.Add(new BoundaryNode(transition.LocalArcLengthMeters, transition.X, transition.Y, transition.VehicleHeadingRadians, sharedCurvature)); } nodes.Add(new BoundaryNode(window.EndArcLengthMeters, endReference.X, endReference.Y, endReference.Heading, endVehicleCurvature)); if (!TryAssignDerivativeScales(nodes, multiplier) || nodes.Count < 2) return false; var sampled = new List(); for (int nodeIndex = 1; nodeIndex < nodes.Count; nodeIndex++) { cancellationToken.ThrowIfCancellationRequested(); BoundaryNode left = nodes[nodeIndex - 1]; BoundaryNode right = nodes[nodeIndex]; if (!TryCreateCurve(left, right, directionSign, out QuinticHermiteCurve2D curve)) return false; if (!TryAppendCurveSamples( curve, left, right, segment, outputSpacingMeters, sampled, cancellationToken)) { return false; } } if (sampled.Count < 2) return false; candidate = new LocalG2CandidateGeometry( candidateIndex, segment.SegmentIndex, window.StartArcLengthMeters, window.EndArcLengthMeters, window.LeftWindowLengthMeters, window.RightWindowLengthMeters, sampled, startVehicleCurvature, endVehicleCurvature, directionSign * startVehicleCurvature, directionSign * endVehicleCurvature, true); return true; } private static bool TryAssignDerivativeScales(List nodes, double multiplier) { if (!NumericGuard.IsPositiveFinite(multiplier)) return false; for (int index = 0; index < nodes.Count; index++) { double scale; if (index == 0) scale = nodes[1].ArcLengthMeters - nodes[0].ArcLengthMeters; else if (index == nodes.Count - 1) scale = nodes[index].ArcLengthMeters - nodes[index - 1].ArcLengthMeters; else scale = ((nodes[index].ArcLengthMeters - nodes[index - 1].ArcLengthMeters) + (nodes[index + 1].ArcLengthMeters - nodes[index].ArcLengthMeters)) / 2d; scale *= multiplier; if (!NumericGuard.IsPositiveFinite(scale)) return false; nodes[index] = nodes[index].WithDerivativeScale(scale); } return true; } private static bool TryCreateCurve(BoundaryNode left, BoundaryNode right, double directionSign, out QuinticHermiteCurve2D curve) { curve = null; if (!TryGetDerivatives(left, directionSign, out double ldx, out double ldy, out double lddx, out double lddy) || !TryGetDerivatives(right, directionSign, out double rdx, out double rdy, out double rddx, out double rddy)) { return false; } return QuinticHermiteCurve2D.TryCreate(left.X, left.Y, ldx, ldy, lddx, lddy, right.X, right.Y, rdx, rdy, rddx, rddy, out curve, out _); } private static bool TryGetDerivatives(BoundaryNode node, double directionSign, out double dx, out double dy, out double ddx, out double ddy) { dx = dy = ddx = ddy = 0d; double travelHeading = directionSign > 0d ? node.VehicleHeadingRadians : node.VehicleHeadingRadians - Math.PI; double geometricCurvature = directionSign * node.VehicleCurvaturePerMeter; double tx = Math.Cos(travelHeading); double ty = Math.Sin(travelHeading); double nx = -ty; double ny = tx; dx = node.DerivativeScale * tx; dy = node.DerivativeScale * ty; ddx = node.DerivativeScale * node.DerivativeScale * geometricCurvature * nx; ddy = node.DerivativeScale * node.DerivativeScale * geometricCurvature * ny; return NumericGuard.IsFinite(dx) && NumericGuard.IsFinite(dy) && NumericGuard.IsFinite(ddx) && NumericGuard.IsFinite(ddy) && Math.Sqrt(dx * dx + dy * dy) >= MinimumDerivativeNorm; } private static bool TryAppendCurveSamples( QuinticHermiteCurve2D curve, BoundaryNode left, BoundaryNode right, PreparedDirectionSegment segment, double outputSpacingMeters, List output, CancellationToken cancellationToken) { // A short endpoint chord and a finite sample grid cannot rule out an interior cusp. // Certify a derivative-norm lower bound over the whole parameter interval first. if (!TryCertifyDerivativeLowerBound(curve)) return false; if (!TryEvaluate(curve, 0d, out CurveSample start) || !TryEvaluate(curve, 1d, out CurveSample end)) return false; if (output.Count == 0 && !TryAppendPoint(start, left, right, segment, output)) return false; return TrySubdivide(curve, left, right, segment, outputSpacingMeters, 0d, start, 1d, end, 0, output, cancellationToken); } private static bool TrySubdivide( QuinticHermiteCurve2D curve, BoundaryNode left, BoundaryNode right, PreparedDirectionSegment segment, double outputSpacingMeters, double u0, CurveSample sample0, double u1, CurveSample sample1, int depth, List output, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); if (Distance(sample0, sample1) <= outputSpacingMeters) { return TryAppendPoint(sample1, left, right, segment, output); } if (depth >= MaximumSubdivisionDepth) return false; double middle = (u0 + u1) / 2d; if (!TryEvaluate(curve, middle, out CurveSample middleSample)) return false; return TrySubdivide(curve, left, right, segment, outputSpacingMeters, u0, sample0, middle, middleSample, depth + 1, output, cancellationToken) && TrySubdivide(curve, left, right, segment, outputSpacingMeters, middle, middleSample, u1, sample1, depth + 1, output, cancellationToken); } private static bool TryAppendPoint( CurveSample sample, BoundaryNode left, BoundaryNode right, PreparedDirectionSegment segment, List output) { double referenceArcLength = left.ArcLengthMeters + (right.ArcLengthMeters - left.ArcLengthMeters) * sample.Parameter; if (!PathReferenceInterpolator.TryInterpolateByArcLength(segment.Points, referenceArcLength, out SmoothingPoint2D reference, out _)) return false; double x = SameArc(sample.Parameter, 0d) ? left.X : SameArc(sample.Parameter, 1d) ? right.X : sample.X; double y = SameArc(sample.Parameter, 0d) ? left.Y : SameArc(sample.Parameter, 1d) ? right.Y : sample.Y; double travelHeading = Math.Atan2(sample.Dy, sample.Dx); double vehicleHeading = segment.Direction == TravelDirection.Forward ? travelHeading : travelHeading + Math.PI; if (!NumericGuard.IsFinite(x) || !NumericGuard.IsFinite(y) || !NumericGuard.IsFinite(travelHeading) || !NumericGuard.IsFinite(vehicleHeading)) return false; if (output.Count > 0 && Distance(output[output.Count - 1], x, y) <= 1e-12d) return false; output.Add(new SmoothingPoint2D( x, y, referenceArcLength, AngleMath.NormalizeRadians(vehicleHeading), vehicleHeading, reference.BodyClearance, false, SmoothedPathPointSource.LocalG2Transition)); return true; } private static bool TryEvaluate(QuinticHermiteCurve2D curve, double parameter, out CurveSample sample) { sample = default; curve.Evaluate(parameter, out double x, out double y, out double dx, out double dy, out double ddx, out double ddy); double derivativeNorm = Math.Sqrt(dx * dx + dy * dy); if (!NumericGuard.IsFinite(x) || !NumericGuard.IsFinite(y) || !NumericGuard.IsFinite(dx) || !NumericGuard.IsFinite(dy) || !NumericGuard.IsFinite(ddx) || !NumericGuard.IsFinite(ddy) || !NumericGuard.IsFinite(derivativeNorm) || derivativeNorm < MinimumDerivativeNorm) { return false; } sample = new CurveSample(parameter, x, y, dx, dy); return true; } private static bool TryCertifyDerivativeLowerBound(QuinticHermiteCurve2D curve) { if (!TryCreateDerivativeBezierControls(curve, out DerivativeControlPoint d0, out DerivativeControlPoint d1, out DerivativeControlPoint d2, out DerivativeControlPoint d3, out DerivativeControlPoint d4)) { return false; } int visitedIntervals = 0; return TryCertifyDerivativeLowerBound(d0, d1, d2, d3, d4, 0, ref visitedIntervals); } private static bool TryCreateDerivativeBezierControls( QuinticHermiteCurve2D curve, out DerivativeControlPoint d0, out DerivativeControlPoint d1, out DerivativeControlPoint d2, out DerivativeControlPoint d3, out DerivativeControlPoint d4) { d0 = d1 = d2 = d3 = d4 = default; curve.Evaluate(0d, out _, out _, out double dx0, out double dy0, out double ddx0, out double ddy0); curve.Evaluate(0.5d, out _, out _, out double dxMiddle, out double dyMiddle, out _, out _); curve.Evaluate(1d, out _, out _, out double dxEnd, out double dyEnd, out double ddxEnd, out double ddyEnd); d0 = new DerivativeControlPoint(dx0, dy0); d1 = new DerivativeControlPoint(dx0 + ddx0 / 4d, dy0 + ddy0 / 4d); d4 = new DerivativeControlPoint(dxEnd, dyEnd); d3 = new DerivativeControlPoint(dxEnd - ddxEnd / 4d, dyEnd - ddyEnd / 4d); d2 = new DerivativeControlPoint( (16d * dxMiddle - d0.X - 4d * d1.X - 4d * d3.X - d4.X) / 6d, (16d * dyMiddle - d0.Y - 4d * d1.Y - 4d * d3.Y - d4.Y) / 6d); return d0.IsFinite && d1.IsFinite && d2.IsFinite && d3.IsFinite && d4.IsFinite; } private static bool TryCertifyDerivativeLowerBound( DerivativeControlPoint d0, DerivativeControlPoint d1, DerivativeControlPoint d2, DerivativeControlPoint d3, DerivativeControlPoint d4, int depth, ref int visitedIntervals) { if (++visitedIntervals > MaximumDerivativeCertificationIntervals) return false; if (!TryGetOriginToConvexHullDistance(d0, d1, d2, d3, d4, out double lowerBound, out double margin)) return false; if (lowerBound > MinimumDerivativeNorm + margin) return true; if (depth >= MaximumDerivativeCertificationDepth) return false; DerivativeControlPoint d01 = Midpoint(d0, d1); DerivativeControlPoint d12 = Midpoint(d1, d2); DerivativeControlPoint d23 = Midpoint(d2, d3); DerivativeControlPoint d34 = Midpoint(d3, d4); DerivativeControlPoint d012 = Midpoint(d01, d12); DerivativeControlPoint d123 = Midpoint(d12, d23); DerivativeControlPoint d234 = Midpoint(d23, d34); DerivativeControlPoint d0123 = Midpoint(d012, d123); DerivativeControlPoint d1234 = Midpoint(d123, d234); DerivativeControlPoint middle = Midpoint(d0123, d1234); if (!d01.IsFinite || !d12.IsFinite || !d23.IsFinite || !d34.IsFinite || !d012.IsFinite || !d123.IsFinite || !d234.IsFinite || !d0123.IsFinite || !d1234.IsFinite || !middle.IsFinite) { return false; } return TryCertifyDerivativeLowerBound(d0, d01, d012, d0123, middle, depth + 1, ref visitedIntervals) && TryCertifyDerivativeLowerBound(middle, d1234, d234, d34, d4, depth + 1, ref visitedIntervals); } private static bool TryGetOriginToConvexHullDistance( DerivativeControlPoint d0, DerivativeControlPoint d1, DerivativeControlPoint d2, DerivativeControlPoint d3, DerivativeControlPoint d4, out double distance, out double margin) { distance = 0d; margin = 0d; var controls = new[] { d0, d1, d2, d3, d4 }; double maximumCoordinate = 0d; for (int index = 0; index < controls.Length; index++) { if (!controls[index].IsFinite) return false; maximumCoordinate = Math.Max(maximumCoordinate, Math.Max(Math.Abs(controls[index].X), Math.Abs(controls[index].Y))); } if (!NumericGuard.IsFinite(maximumCoordinate)) return false; margin = DerivativeCertificationMargin * Math.Max(1d, maximumCoordinate); for (int first = 0; first < controls.Length - 2; first++) { for (int second = first + 1; second < controls.Length - 1; second++) { for (int third = second + 1; third < controls.Length; third++) { if (ContainsOrigin(controls[first], controls[second], controls[third], margin)) return true; } } } distance = double.PositiveInfinity; for (int first = 0; first < controls.Length; first++) { distance = Math.Min(distance, controls[first].Norm); for (int second = first + 1; second < controls.Length; second++) distance = Math.Min(distance, DistanceToSegment(controls[first], controls[second])); } return NumericGuard.IsFinite(distance); } private static bool ContainsOrigin(DerivativeControlPoint first, DerivativeControlPoint second, DerivativeControlPoint third, double margin) { double sideX = second.X - first.X; double sideY = second.Y - first.Y; double thirdOffsetX = third.X - first.X; double thirdOffsetY = third.Y - first.Y; double triangleArea = sideX * thirdOffsetY - sideY * thirdOffsetX; double crossFirstSecond = Cross(first, second); double crossSecondThird = Cross(second, third); double crossThirdFirst = Cross(third, first); if (!NumericGuard.IsFinite(triangleArea) || !NumericGuard.IsFinite(crossFirstSecond) || !NumericGuard.IsFinite(crossSecondThird) || !NumericGuard.IsFinite(crossThirdFirst)) { return true; } double areaMargin = margin * Math.Max(1d, Math.Max(first.Norm, Math.Max(second.Norm, third.Norm))); if (!NumericGuard.IsFinite(areaMargin)) return true; if (Math.Abs(triangleArea) <= areaMargin) { // A degenerate triangle is only a closed line segment (or a point), not a // two-dimensional region. Treat it as origin-containing only when the origin // lies on one of its actual closed segments; otherwise its distance is positive. return IsOriginOnSegment(first, second, margin) || IsOriginOnSegment(second, third, margin) || IsOriginOnSegment(third, first, margin); } return (crossFirstSecond >= -areaMargin && crossSecondThird >= -areaMargin && crossThirdFirst >= -areaMargin) || (crossFirstSecond <= areaMargin && crossSecondThird <= areaMargin && crossThirdFirst <= areaMargin); } private static bool IsOriginOnSegment(DerivativeControlPoint start, DerivativeControlPoint end, double margin) { double dx = end.X - start.X; double dy = end.Y - start.Y; double lengthSquared = dx * dx + dy * dy; if (!NumericGuard.IsFinite(lengthSquared)) return true; if (lengthSquared == 0d) return start.Norm <= margin; double length = Math.Sqrt(lengthSquared); double cross = Cross(start, end); double projection = -(start.X * dx + start.Y * dy); if (!NumericGuard.IsFinite(length) || !NumericGuard.IsFinite(cross) || !NumericGuard.IsFinite(projection)) return true; double lineMargin = margin * Math.Max(1d, length); double projectionMargin = margin * Math.Max(1d, length); if (!NumericGuard.IsFinite(lineMargin) || !NumericGuard.IsFinite(projectionMargin)) return true; return Math.Abs(cross) <= lineMargin && projection >= -projectionMargin && projection <= lengthSquared + projectionMargin; } private static double DistanceToSegment(DerivativeControlPoint start, DerivativeControlPoint end) { double dx = end.X - start.X; double dy = end.Y - start.Y; double denominator = dx * dx + dy * dy; if (!NumericGuard.IsFinite(denominator)) return double.NaN; if (denominator == 0d) return start.Norm; double parameter = -(start.X * dx + start.Y * dy) / denominator; if (!NumericGuard.IsFinite(parameter)) return double.NaN; parameter = Math.Max(0d, Math.Min(1d, parameter)); double x = start.X + parameter * dx; double y = start.Y + parameter * dy; return Math.Sqrt(x * x + y * y); } private static DerivativeControlPoint Midpoint(DerivativeControlPoint left, DerivativeControlPoint right) => new DerivativeControlPoint((left.X + right.X) / 2d, (left.Y + right.Y) / 2d); private static double Cross(DerivativeControlPoint left, DerivativeControlPoint right) => left.X * right.Y - left.Y * right.X; private static bool IsValidInput(PreparedDirectionSegment segment, LocalG2SmoothingRegion region, double outputSpacingMeters, LocalG2OptionsSnapshot options) { if (segment == null || region == null || options == null || segment.SegmentIndex != region.SegmentIndex || !NumericGuard.IsPositiveFinite(outputSpacingMeters) || segment.Points == null || segment.Points.Count < 2 || region.Transitions == null || region.Transitions.Count == 0 || region.WindowVariants == null) { return false; } for (int index = 0; index < region.Transitions.Count; index++) { CurvatureTransition transition = region.Transitions[index]; if (transition == null || transition.SegmentIndex != segment.SegmentIndex) return false; } return true; } private static double Distance(CurveSample left, CurveSample right) => Distance(left.X, left.Y, right.X, right.Y); private static double Distance(SmoothingPoint2D point, double x, double y) => Distance(point.X, point.Y, x, y); private static double Distance(double x0, double y0, double x1, double y1) { double dx = x1 - x0; double dy = y1 - y0; return Math.Sqrt(dx * dx + dy * dy); } private static bool SameArc(double left, double right) => Math.Abs(left - right) <= 1e-10d; private static IReadOnlyList Empty() => new ReadOnlyCollection(new List()); private static IReadOnlyList ReadOnly(List values) => new ReadOnlyCollection(values); private readonly struct BoundaryNode { internal BoundaryNode(double arcLengthMeters, double x, double y, double vehicleHeadingRadians, double vehicleCurvaturePerMeter) { ArcLengthMeters = arcLengthMeters; X = x; Y = y; VehicleHeadingRadians = vehicleHeadingRadians; VehicleCurvaturePerMeter = vehicleCurvaturePerMeter; DerivativeScale = 0d; } private BoundaryNode(double arcLengthMeters, double x, double y, double vehicleHeadingRadians, double vehicleCurvaturePerMeter, double derivativeScale) { ArcLengthMeters = arcLengthMeters; X = x; Y = y; VehicleHeadingRadians = vehicleHeadingRadians; VehicleCurvaturePerMeter = vehicleCurvaturePerMeter; DerivativeScale = derivativeScale; } internal double ArcLengthMeters { get; } internal double X { get; } internal double Y { get; } internal double VehicleHeadingRadians { get; } internal double VehicleCurvaturePerMeter { get; } internal double DerivativeScale { get; } internal BoundaryNode WithDerivativeScale(double derivativeScale) => new BoundaryNode( ArcLengthMeters, X, Y, VehicleHeadingRadians, VehicleCurvaturePerMeter, derivativeScale); } private readonly struct CurveSample { internal CurveSample(double parameter, double x, double y, double dx, double dy) { Parameter = parameter; X = x; Y = y; Dx = dx; Dy = dy; } internal double Parameter { get; } internal double X { get; } internal double Y { get; } internal double Dx { get; } internal double Dy { get; } } private readonly struct DerivativeControlPoint { internal DerivativeControlPoint(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); internal double Norm => Math.Sqrt(X * X + Y * Y); } /// 反射脚本使用的窄范围候选与拼接场景入口。 public static class TestHooks { public static CandidateTestSnapshot Execute(string scenario) { if (string.IsNullOrWhiteSpace(scenario)) throw new ArgumentException("A scenario is required.", nameof(scenario)); switch (scenario) { case "Isolated": return BuildIsolated(); case "Cluster": return BuildCluster(); case "Reverse": return BuildReverse(); case "Spliced": return BuildSpliced(); case "StartBoundary": return BuildStartBoundary(); case "InteriorStationaryCurve": return BuildInteriorStationaryCurve(); case "ConstantVelocityCurve": return BuildConstantVelocityCurve(); case "ExactSpliceEndpoints": return BuildExactSpliceEndpoints(); case "GearBoundary": return BuildGearBoundary(); case "TwoRegionWorkOrder": return BuildTwoRegionWorkOrder(); case "MultiSegmentWorkOrder": return BuildMultiSegmentWorkOrder(); default: throw new ArgumentOutOfRangeException(nameof(scenario)); } } public sealed class CandidateTestSnapshot { internal CandidateTestSnapshot(int candidateCount, double startPositionError, double endPositionError, double startCurvatureError, double endCurvatureError, bool containsLocalG2Source, int outputRegionCount, bool internalConnectionsAreG2, string direction, bool vehicleAndGeometricCurvatureSignsAreOpposite, bool noDuplicateNonGearPoints, bool endpointsUnchanged, bool rejected = false, bool endpointsAreExact = false, bool gearBoundaryMarkerPreserved = false, bool accepted = false, bool workOrderDescending = false, bool frontArcPreservedAfterBackReplacement = false, bool bothReplacementsRetained = false, bool forwardOrderRejected = false, bool deterministicWorkOrder = false, bool invalidWorkOrderRejected = false, bool deterministicReplacementGeometry = false, bool segmentOrderAscending = false, bool equalArcUsesReportOrder = false) { CandidateCount = candidateCount; StartPositionError = startPositionError; EndPositionError = endPositionError; StartCurvatureError = startCurvatureError; EndCurvatureError = endCurvatureError; ContainsLocalG2Source = containsLocalG2Source; OutputRegionCount = outputRegionCount; InternalConnectionsAreG2 = internalConnectionsAreG2; Direction = direction; VehicleAndGeometricCurvatureSignsAreOpposite = vehicleAndGeometricCurvatureSignsAreOpposite; NoDuplicateNonGearPoints = noDuplicateNonGearPoints; EndpointsUnchanged = endpointsUnchanged; Rejected = rejected; EndpointsAreExact = endpointsAreExact; GearBoundaryMarkerPreserved = gearBoundaryMarkerPreserved; Accepted = accepted; WorkOrderDescending = workOrderDescending; FrontArcPreservedAfterBackReplacement = frontArcPreservedAfterBackReplacement; BothReplacementsRetained = bothReplacementsRetained; ForwardOrderRejected = forwardOrderRejected; DeterministicWorkOrder = deterministicWorkOrder; InvalidWorkOrderRejected = invalidWorkOrderRejected; DeterministicReplacementGeometry = deterministicReplacementGeometry; SegmentOrderAscending = segmentOrderAscending; EqualArcUsesReportOrder = equalArcUsesReportOrder; } public int CandidateCount { get; } public double StartPositionError { get; } public double EndPositionError { get; } public double StartCurvatureError { get; } public double EndCurvatureError { get; } public bool ContainsLocalG2Source { get; } public int OutputRegionCount { get; } public bool InternalConnectionsAreG2 { get; } public string Direction { get; } public bool VehicleAndGeometricCurvatureSignsAreOpposite { get; } public bool NoDuplicateNonGearPoints { get; } public bool EndpointsUnchanged { get; } public bool Rejected { get; } public bool EndpointsAreExact { get; } public bool GearBoundaryMarkerPreserved { get; } public bool Accepted { get; } public bool WorkOrderDescending { get; } public bool FrontArcPreservedAfterBackReplacement { get; } public bool BothReplacementsRetained { get; } public bool ForwardOrderRejected { get; } public bool DeterministicWorkOrder { get; } public bool InvalidWorkOrderRejected { get; } public bool DeterministicReplacementGeometry { get; } public bool SegmentOrderAscending { get; } public bool EqualArcUsesReportOrder { get; } } private static CandidateTestSnapshot BuildIsolated() { PreparedDirectionSegment segment = CreateSegment(TravelDirection.Forward, null); LocalG2SmoothingRegion region = CreateRegion(new[] { new CurvatureTransition(0, 1, 2, 0.5d, 0.5d, 0d, 0d, 0d, 0.4d), }, 0.25d, 0.75d); IReadOnlyList candidates = new LocalG2CandidateBuilder().Build( segment, region, 0.025d, CreateOptions(), CancellationToken.None); if (candidates.Count == 0) throw new InvalidOperationException("The isolated candidate scenario produced no candidate."); LocalG2CandidateGeometry candidate = candidates[0]; PathReferenceInterpolator.TryInterpolateByArcLength(segment.Points, 0.25d, out SmoothingPoint2D start, out _); PathReferenceInterpolator.TryInterpolateByArcLength(segment.Points, 0.75d, out SmoothingPoint2D end, out _); return new CandidateTestSnapshot(candidates.Count, Distance(start, candidate.RegionPoints[0]), Distance(end, candidate.RegionPoints[candidate.RegionPoints.Count - 1]), Math.Abs(candidate.StartVehicleCurvaturePerMeter - 0d), Math.Abs(candidate.EndVehicleCurvaturePerMeter - 0.4d), ContainsLocalG2(candidate.RegionPoints), 0, false, string.Empty, false, false, false); } private static CandidateTestSnapshot BuildCluster() { PreparedDirectionSegment segment = CreateSegment(TravelDirection.Forward, null); var transitions = new[] { new CurvatureTransition(0, 1, 2, 0.4d, 0.4d, 0d, 0d, 0d, 0.4d), new CurvatureTransition(0, 2, 3, 0.6d, 0.6d, 0d, 0d, 0.4d, 0d), }; var planner = new LocalG2WindowPlanner(); if (!planner.TryPlan(new PreparedPath(new[] { segment }), transitions, CreateOptions(), out IReadOnlyList regions, out string reason)) { throw new InvalidOperationException(reason); } IReadOnlyList candidates = new LocalG2CandidateBuilder().Build( segment, regions[0], 0.025d, CreateOptions(), CancellationToken.None); if (candidates.Count == 0) throw new InvalidOperationException("The clustered candidate scenario produced no candidate."); return new CandidateTestSnapshot(0, 0d, 0d, 0d, 0d, false, regions.Count, candidates[0].InternalConnectionsAreG2, string.Empty, false, false, false); } private static CandidateTestSnapshot BuildReverse() { PreparedDirectionSegment segment = CreateSegment(TravelDirection.Reverse, null); LocalG2SmoothingRegion region = CreateRegion(new[] { new CurvatureTransition(0, 1, 2, 0.5d, 0.5d, 0d, Math.PI, 0.2d, 0.4d), }, 0.25d, 0.75d); IReadOnlyList candidates = new LocalG2CandidateBuilder().Build( segment, region, 0.025d, CreateOptions(), CancellationToken.None); if (candidates.Count == 0) throw new InvalidOperationException("The reverse candidate scenario produced no candidate."); LocalG2CandidateGeometry candidate = candidates[0]; bool opposite = candidate.StartVehicleCurvaturePerMeter * candidate.StartGeometricCurvaturePerMeter < 0d && candidate.EndVehicleCurvaturePerMeter * candidate.EndGeometricCurvaturePerMeter < 0d; return new CandidateTestSnapshot(0, 0d, 0d, 0d, 0d, false, 0, false, segment.Direction.ToString(), opposite, false, false); } private static CandidateTestSnapshot BuildSpliced() { PreparedDirectionSegment segment = CreateSegment(TravelDirection.Forward, 0.17d); LocalG2SmoothingRegion region = CreateRegion(new[] { new CurvatureTransition(0, 1, 2, 0.5d, 0.5d, 0d, 0d, 0.17d, 0.4d), }, 0.25d, 0.75d); IReadOnlyList candidates = new LocalG2CandidateBuilder().Build( segment, region, 0.025d, CreateOptions(), CancellationToken.None); if (candidates.Count == 0) throw new InvalidOperationException("The splice candidate scenario produced no candidate."); PreparedDirectionSegment untouched = CreateSegment(TravelDirection.Reverse, null); var path = new PreparedPath(new[] { segment, untouched }); if (!new LocalG2PathSplicer().TryReplace(path, candidates[0], out PreparedPath replaced, out string reason)) throw new InvalidOperationException(reason); PreparedDirectionSegment result = replaced.Segments[0]; bool noDuplicates = true; for (int index = 1; index < result.Points.Count; index++) { if (!result.Points[index - 1].IsGearSwitchPoint && !result.Points[index].IsGearSwitchPoint && Distance(result.Points[index - 1], result.Points[index]) <= 1e-12d) { noDuplicates = false; break; } } bool monotonicallyIncreasing = true; for (int index = 1; index < result.Points.Count; index++) { if (result.Points[index].ArcLength <= result.Points[index - 1].ArcLength) { monotonicallyIncreasing = false; break; } } bool endpoints = Distance(segment.Points[0], result.Points[0]) <= 1e-12d && Distance(segment.Points[segment.Points.Count - 1], result.Points[result.Points.Count - 1]) <= 1e-12d && result.StartVehicleCurvaturePerMeter == segment.StartVehicleCurvaturePerMeter && monotonicallyIncreasing && ReferenceEquals(untouched, replaced.Segments[1]); return new CandidateTestSnapshot(0, 0d, 0d, 0d, 0d, false, 0, false, string.Empty, false, noDuplicates, endpoints); } private static CandidateTestSnapshot BuildStartBoundary() { PreparedDirectionSegment segment = CreateSegment(TravelDirection.Forward, 0.17d); LocalG2SmoothingRegion region = CreateRegion(new[] { new CurvatureTransition(0, 0, 1, 0d, 0d, 0d, 0d, 0.17d, 0.4d), }, 0d, 0.5d); IReadOnlyList candidates = new LocalG2CandidateBuilder().Build( segment, region, 0.025d, CreateOptions(), CancellationToken.None); if (candidates.Count == 0) throw new InvalidOperationException("The start-boundary transition must not create a zero-length left curve."); LocalG2CandidateGeometry candidate = candidates[0]; return new CandidateTestSnapshot(candidates.Count, Math.Abs(candidate.StartVehicleCurvaturePerMeter - 0.17d), 0d, Math.Abs(candidate.StartVehicleCurvaturePerMeter - 0.17d), 0d, ContainsLocalG2(candidate.RegionPoints), 0, false, string.Empty, false, false, false); } private static CandidateTestSnapshot BuildInteriorStationaryCurve() { const double endpointX = 0.00325d; if (!QuinticHermiteCurve2D.TryCreate(0d, 0d, 0.01d, 0d, 0d, 0d, endpointX, 0d, 0.01d, 0d, 0d, 0d, out QuinticHermiteCurve2D curve, out string reason)) { throw new InvalidOperationException(reason); } var points = new[] { new SmoothingPoint2D(0d, 0d, 0d, 0d, 0d, 1d, false, SmoothedPathPointSource.Anchor), new SmoothingPoint2D(endpointX, 0d, endpointX, 0d, 0d, 1d, false, SmoothedPathPointSource.Anchor), }; var segment = new PreparedDirectionSegment(0, TravelDirection.Forward, points, false, false); var sampled = new List(); bool accepted = TryAppendCurveSamples(curve, new BoundaryNode(0d, 0d, 0d, 0d, 0d), new BoundaryNode(endpointX, endpointX, 0d, 0d, 0d), segment, 0.025d, sampled, CancellationToken.None); return new CandidateTestSnapshot(0, 0d, 0d, 0d, 0d, false, 0, false, string.Empty, false, false, false, !accepted); } private static CandidateTestSnapshot BuildConstantVelocityCurve() { if (!QuinticHermiteCurve2D.TryCreate(0d, 0d, 1d, 0d, 0d, 0d, 1d, 0d, 1d, 0d, 0d, 0d, out QuinticHermiteCurve2D curve, out string reason)) { throw new InvalidOperationException(reason); } var points = new[] { new SmoothingPoint2D(0d, 0d, 0d, 0d, 0d, 1d, false, SmoothedPathPointSource.Anchor), new SmoothingPoint2D(1d, 0d, 1d, 0d, 0d, 1d, false, SmoothedPathPointSource.Anchor), }; var segment = new PreparedDirectionSegment(0, TravelDirection.Forward, points, false, false); var sampled = new List(); bool accepted = TryAppendCurveSamples(curve, new BoundaryNode(0d, 0d, 0d, 0d, 0d), new BoundaryNode(1d, 1d, 0d, 0d, 0d), segment, 0.025d, sampled, CancellationToken.None); return new CandidateTestSnapshot(0, 0d, 0d, 0d, 0d, false, 0, false, string.Empty, false, false, false, false, false, false, accepted); } private static CandidateTestSnapshot BuildExactSpliceEndpoints() { PreparedDirectionSegment segment = CreateSegment(TravelDirection.Forward, null); var candidate = new LocalG2CandidateGeometry(0, 0, 0.25d, 0.75d, 0.25d, 0.25d, new[] { Point(0.2500000005d, 0d, 0.25d, false), Point(0.5d, 0d, 0.5d, false), Point(0.7499999995d, 0d, 0.75d, false), }, 0d, 0d, 0d, 0d, true); if (!new LocalG2PathSplicer().TryReplace(new PreparedPath(new[] { segment }), candidate, out PreparedPath replaced, out string reason)) { throw new InvalidOperationException(reason); } PathReferenceInterpolator.TryInterpolateByArcLength(segment.Points, 0.25d, out SmoothingPoint2D start, out _); PathReferenceInterpolator.TryInterpolateByArcLength(segment.Points, 0.75d, out SmoothingPoint2D end, out _); IReadOnlyList result = replaced.Segments[0].Points; bool exact = result[1].X == start.X && result[1].Y == start.Y && result[result.Count - 2].X == end.X && result[result.Count - 2].Y == end.Y; return new CandidateTestSnapshot(0, 0d, 0d, 0d, 0d, false, 0, false, string.Empty, false, false, false, false, exact); } private static CandidateTestSnapshot BuildGearBoundary() { var sourcePoints = new[] { Point(0d, 0d, 0d, true), Point(0.25d, 0d, 0.25d, false), Point(0.5d, 0d, 0.5d, false), }; var segment = new PreparedDirectionSegment(0, TravelDirection.Forward, sourcePoints, true, false); var candidate = new LocalG2CandidateGeometry(0, 0, 0d, 0.5d, 0d, 0.5d, new[] { Point(0d, 0d, 0d, false), Point(0.25d, 0d, 0.25d, false), Point(0.5d, 0d, 0.5d, false) }, 0d, 0d, 0d, 0d, true); if (!new LocalG2PathSplicer().TryReplace(new PreparedPath(new[] { segment }), candidate, out PreparedPath replaced, out string reason)) { throw new InvalidOperationException(reason); } bool preserved = replaced.Segments[0].StartsAtGearSwitch && replaced.Segments[0].Points[0].IsGearSwitchPoint; return new CandidateTestSnapshot(0, 0d, 0d, 0d, 0d, false, 0, false, string.Empty, false, false, false, false, false, preserved); } private static CandidateTestSnapshot BuildTwoRegionWorkOrder() { var points = new List(); for (int index = 0; index <= 12; index++) { double arc = index * 0.25d; points.Add(new SmoothingPoint2D( arc, 0d, arc, 0d, 0d, 1d, false, SmoothedPathPointSource.Anchor)); } var segment = new PreparedDirectionSegment( 0, TravelDirection.Forward, points, false, false); var original = new PreparedPath(new[] { segment }); LocalG2SmoothingRegion frontRegion = CreateOrderedRegion(0.75d, 0.5d, 1.0d, 0); LocalG2SmoothingRegion backRegion = CreateOrderedRegion(2.25d, 2.0d, 2.5d, 1); var reportOrder = new[] { frontRegion, backRegion }; var orderer = new LocalG2RegionWorkOrder(); if (!orderer.TryCreate( reportOrder, out IReadOnlyList workOrder, out string orderReason)) { throw new InvalidOperationException(orderReason); } if (!orderer.TryCreate( reportOrder, out IReadOnlyList repeatedOrder, out string repeatedReason)) { throw new InvalidOperationException(repeatedReason); } bool descending = ReferenceEquals(backRegion, workOrder[0]) && ReferenceEquals(frontRegion, workOrder[1]); bool deterministic = ReferenceEquals(workOrder[0], repeatedOrder[0]) && ReferenceEquals(workOrder[1], repeatedOrder[1]) && ReferenceEquals(frontRegion, reportOrder[0]) && ReferenceEquals(backRegion, reportOrder[1]); var wrongSegmentTransition = new CurvatureTransition( 1, 0, 1, 0.75d, 0.75d, 0d, 0d, 0d, 0.4d); var invalidRegion = new LocalG2SmoothingRegion( 0, new[] { wrongSegmentTransition }, 0.5d, 1.0d, new[] { new LocalG2WindowVariant(0, 0.5d, 1.0d, 0.25d, 0.25d), }); bool invalidWorkOrderRejected = !orderer.TryCreate( new[] { invalidRegion }, out _, out _) && !orderer.TryCreate( new LocalG2SmoothingRegion[] { null }, out _, out _); LocalG2CandidateGeometry frontCandidate = CreateLengthChangingCandidate(0, 0.5d, 1.0d); LocalG2CandidateGeometry backCandidate = CreateLengthChangingCandidate(1, 2.0d, 2.5d); var splicer = new LocalG2PathSplicer(); if (!splicer.TryReplace( original, backCandidate, out PreparedPath afterBack, out string backReason)) { throw new InvalidOperationException(backReason); } bool frontArcPreserved = PathReferenceInterpolator.TryInterpolateByArcLength( afterBack.Segments[0].Points, 0.5d, out SmoothingPoint2D frontStart, out _) && PathReferenceInterpolator.TryInterpolateByArcLength( afterBack.Segments[0].Points, 1.0d, out SmoothingPoint2D frontEnd, out _) && Math.Abs(frontStart.X - 0.5d) <= 1e-12d && Math.Abs(frontEnd.X - 1.0d) <= 1e-12d; if (!splicer.TryReplace( afterBack, frontCandidate, out PreparedPath afterBoth, out string frontReason)) { throw new InvalidOperationException(frontReason); } if (!splicer.TryReplace( original, backCandidate, out PreparedPath repeatedAfterBack, out string repeatedBackReason)) { throw new InvalidOperationException(repeatedBackReason); } if (!splicer.TryReplace( repeatedAfterBack, frontCandidate, out PreparedPath repeatedAfterBoth, out string repeatedFrontReason)) { throw new InvalidOperationException(repeatedFrontReason); } bool frontInteriorRetained = false; bool backInteriorRetained = false; int frontInteriorCount = 0; int backInteriorCount = 0; for (int index = 0; index < afterBoth.Segments[0].Points.Count; index++) { SmoothingPoint2D point = afterBoth.Segments[0].Points[index]; if (point.Source != SmoothedPathPointSource.LocalG2Transition) continue; if (point.X == 0.75d && point.Y == 0.20d) { frontInteriorCount++; frontInteriorRetained = true; } if (point.X == 2.25d && point.Y == 0.20d) { backInteriorCount++; backInteriorRetained = true; } } bool exactInteriorsRetained = frontInteriorRetained && backInteriorRetained && frontInteriorCount == 1 && backInteriorCount == 1; bool deterministicReplacementGeometry = HasSameReplacementGeometry(afterBoth, repeatedAfterBoth); if (!splicer.TryReplace( original, frontCandidate, out PreparedPath afterFront, out string firstReason)) { throw new InvalidOperationException(firstReason); } bool forwardOrderRejected = !splicer.TryReplace( afterFront, backCandidate, out _, out _); return new CandidateTestSnapshot( 0, 0d, 0d, 0d, 0d, false, 0, false, string.Empty, false, false, false, false, false, false, false, descending, frontArcPreserved, exactInteriorsRetained, forwardOrderRejected, deterministic, invalidWorkOrderRejected, deterministicReplacementGeometry); } private static CandidateTestSnapshot BuildMultiSegmentWorkOrder() { LocalG2SmoothingRegion laterSegment = CreateOrderedRegion(0.25d, 0d, 0.5d, 0, 1); LocalG2SmoothingRegion firstTie = CreateOrderedRegion(0.75d, 0.5d, 1.0d, 0); LocalG2SmoothingRegion secondTie = CreateOrderedRegion(0.75d, 0.5d, 1.0d, 1); var reportOrder = new[] { laterSegment, firstTie, secondTie }; if (!new LocalG2RegionWorkOrder().TryCreate( reportOrder, out IReadOnlyList workOrder, out string reason)) { throw new InvalidOperationException(reason); } bool segmentOrderAscending = workOrder.Count == 3 && workOrder[0].SegmentIndex == 0 && workOrder[1].SegmentIndex == 0 && workOrder[2].SegmentIndex == 1; bool equalArcUsesReportOrder = ReferenceEquals(firstTie, workOrder[0]) && ReferenceEquals(secondTie, workOrder[1]); return new CandidateTestSnapshot( 0, 0d, 0d, 0d, 0d, false, 0, false, string.Empty, false, false, false, segmentOrderAscending: segmentOrderAscending, equalArcUsesReportOrder: equalArcUsesReportOrder); } private static bool HasSameReplacementGeometry( PreparedPath first, PreparedPath second) { if (first == null || second == null || first.Points.Count != second.Points.Count || first.Segments.Count != second.Segments.Count) { return false; } for (int segmentIndex = 0; segmentIndex < first.Segments.Count; segmentIndex++) { PreparedDirectionSegment firstSegment = first.Segments[segmentIndex]; PreparedDirectionSegment secondSegment = second.Segments[segmentIndex]; if (firstSegment.Direction != secondSegment.Direction || firstSegment.Points.Count != secondSegment.Points.Count) { return false; } for (int pointIndex = 0; pointIndex < firstSegment.Points.Count; pointIndex++) { SmoothingPoint2D firstPoint = firstSegment.Points[pointIndex]; SmoothingPoint2D secondPoint = secondSegment.Points[pointIndex]; if (firstPoint.X != secondPoint.X || firstPoint.Y != secondPoint.Y || firstPoint.Source != secondPoint.Source || firstSegment.Direction != secondSegment.Direction) { return false; } } } return true; } private static LocalG2SmoothingRegion CreateOrderedRegion( double eventArc, double startArc, double endArc, int index, int segmentIndex = 0) { var transition = new CurvatureTransition( segmentIndex, index, index + 1, eventArc, eventArc, 0d, 0d, 0d, 0.4d); return new LocalG2SmoothingRegion( segmentIndex, new[] { transition }, startArc, endArc, new[] { new LocalG2WindowVariant( 0, startArc, endArc, eventArc - startArc, endArc - eventArc), }); } private static LocalG2CandidateGeometry CreateLengthChangingCandidate( int candidateIndex, double startArc, double endArc) { double middleArc = 0.5d * (startArc + endArc); var points = new[] { Point(startArc, 0d, startArc, false), Point(middleArc, 0.20d, middleArc, false), Point(endArc, 0d, endArc, false), }; return new LocalG2CandidateGeometry( candidateIndex, 0, startArc, endArc, middleArc - startArc, endArc - middleArc, points, 0d, 0d, 0d, 0d, true); } private static PreparedDirectionSegment CreateSegment(TravelDirection direction, double? startCurvature) { double heading = direction == TravelDirection.Forward ? 0d : Math.PI; var points = new List(); for (int index = 0; index <= 4; index++) { double arc = index * 0.25d; points.Add(new SmoothingPoint2D(arc, 0d, arc, heading, heading, 1d, false, SmoothedPathPointSource.Anchor)); } return new PreparedDirectionSegment(0, direction, points, false, false, startCurvature); } private static LocalG2SmoothingRegion CreateRegion( IReadOnlyList transitions, double startArcLengthMeters, double endArcLengthMeters) { return new LocalG2SmoothingRegion(0, transitions, startArcLengthMeters, endArcLengthMeters, new[] { new LocalG2WindowVariant(0, startArcLengthMeters, endArcLengthMeters, transitions[0].LocalArcLengthMeters - startArcLengthMeters, endArcLengthMeters - transitions[transitions.Count - 1].LocalArcLengthMeters) }); } private static LocalG2OptionsSnapshot CreateOptions() => new LocalG2OptionsSnapshot(new PathSmoothingConfiguration()); private static bool ContainsLocalG2(IReadOnlyList points) { for (int index = 0; index < points.Count; index++) if (points[index].Source == SmoothedPathPointSource.LocalG2Transition) return true; return false; } private static double Distance(SmoothingPoint2D left, SmoothingPoint2D right) => Math.Sqrt((right.X - left.X) * (right.X - left.X) + (right.Y - left.Y) * (right.Y - left.Y)); private static SmoothingPoint2D Point(double x, double y, double arcLength, bool isGearSwitch) => new SmoothingPoint2D(x, y, arcLength, 0d, 0d, 1d, isGearSwitch, isGearSwitch ? SmoothedPathPointSource.GearSwitch : SmoothedPathPointSource.LocalG2Transition); } }