fix: harden local G2 candidate splicing

This commit is contained in:
梁薄云
2026-07-30 17:37:45 +08:00
parent 1c433458bc
commit 1662b68062
3 changed files with 107 additions and 2 deletions
@@ -12,6 +12,7 @@ namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.LocalG2;
internal sealed class LocalG2CandidateBuilder internal sealed class LocalG2CandidateBuilder
{ {
private const double MinimumDerivativeNorm = 1e-10d; private const double MinimumDerivativeNorm = 1e-10d;
private const int MinimumCurveEvaluationIntervals = 32;
private const int MaximumSubdivisionDepth = 32; private const int MaximumSubdivisionDepth = 32;
private static readonly double[] DerivativeScaleMultipliers = { 1d, 0.85d, 1.15d }; private static readonly double[] DerivativeScaleMultipliers = { 1d, 0.85d, 1.15d };
@@ -194,6 +195,13 @@ internal sealed class LocalG2CandidateBuilder
List<SmoothingPoint2D> output, List<SmoothingPoint2D> output,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
// A short endpoint chord does not prove that a fifth-degree curve has no interior cusp.
// Check a deterministic interior grid before the chord-driven subdivision below; every
// subsequently emitted subdivision parameter is checked again by TryEvaluate.
for (int index = 0; index <= MinimumCurveEvaluationIntervals; index++)
{
if (!TryEvaluate(curve, (double)index / MinimumCurveEvaluationIntervals, out _)) return false;
}
if (!TryEvaluate(curve, 0d, out CurveSample start) || !TryEvaluate(curve, 1d, out CurveSample end)) 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; 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); return TrySubdivide(curve, left, right, segment, outputSpacingMeters, 0d, start, 1d, end, 0, output, cancellationToken);
@@ -361,6 +369,9 @@ internal sealed class LocalG2CandidateBuilder
case "Reverse": return BuildReverse(); case "Reverse": return BuildReverse();
case "Spliced": return BuildSpliced(); case "Spliced": return BuildSpliced();
case "StartBoundary": return BuildStartBoundary(); case "StartBoundary": return BuildStartBoundary();
case "InteriorStationaryCurve": return BuildInteriorStationaryCurve();
case "ExactSpliceEndpoints": return BuildExactSpliceEndpoints();
case "GearBoundary": return BuildGearBoundary();
default: throw new ArgumentOutOfRangeException(nameof(scenario)); default: throw new ArgumentOutOfRangeException(nameof(scenario));
} }
} }
@@ -371,7 +382,8 @@ internal sealed class LocalG2CandidateBuilder
double startCurvatureError, double endCurvatureError, bool containsLocalG2Source, double startCurvatureError, double endCurvatureError, bool containsLocalG2Source,
int outputRegionCount, bool internalConnectionsAreG2, string direction, int outputRegionCount, bool internalConnectionsAreG2, string direction,
bool vehicleAndGeometricCurvatureSignsAreOpposite, bool noDuplicateNonGearPoints, bool vehicleAndGeometricCurvatureSignsAreOpposite, bool noDuplicateNonGearPoints,
bool endpointsUnchanged) bool endpointsUnchanged, bool rejected = false, bool endpointsAreExact = false,
bool gearBoundaryMarkerPreserved = false)
{ {
CandidateCount = candidateCount; CandidateCount = candidateCount;
StartPositionError = startPositionError; StartPositionError = startPositionError;
@@ -385,6 +397,9 @@ internal sealed class LocalG2CandidateBuilder
VehicleAndGeometricCurvatureSignsAreOpposite = vehicleAndGeometricCurvatureSignsAreOpposite; VehicleAndGeometricCurvatureSignsAreOpposite = vehicleAndGeometricCurvatureSignsAreOpposite;
NoDuplicateNonGearPoints = noDuplicateNonGearPoints; NoDuplicateNonGearPoints = noDuplicateNonGearPoints;
EndpointsUnchanged = endpointsUnchanged; EndpointsUnchanged = endpointsUnchanged;
Rejected = rejected;
EndpointsAreExact = endpointsAreExact;
GearBoundaryMarkerPreserved = gearBoundaryMarkerPreserved;
} }
public int CandidateCount { get; } public int CandidateCount { get; }
public double StartPositionError { get; } public double StartPositionError { get; }
@@ -398,6 +413,9 @@ internal sealed class LocalG2CandidateBuilder
public bool VehicleAndGeometricCurvatureSignsAreOpposite { get; } public bool VehicleAndGeometricCurvatureSignsAreOpposite { get; }
public bool NoDuplicateNonGearPoints { get; } public bool NoDuplicateNonGearPoints { get; }
public bool EndpointsUnchanged { get; } public bool EndpointsUnchanged { get; }
public bool Rejected { get; }
public bool EndpointsAreExact { get; }
public bool GearBoundaryMarkerPreserved { get; }
} }
private static CandidateTestSnapshot BuildIsolated() private static CandidateTestSnapshot BuildIsolated()
@@ -517,6 +535,73 @@ internal sealed class LocalG2CandidateBuilder
ContainsLocalG2(candidate.RegionPoints), 0, false, string.Empty, false, false, false); ContainsLocalG2(candidate.RegionPoints), 0, false, string.Empty, false, false, false);
} }
private static CandidateTestSnapshot BuildInteriorStationaryCurve()
{
const double endpointX = 7d / 1500d;
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<SmoothingPoint2D>();
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 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<SmoothingPoint2D> 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 PreparedDirectionSegment CreateSegment(TravelDirection direction, double? startCurvature) private static PreparedDirectionSegment CreateSegment(TravelDirection direction, double? startCurvature)
{ {
double heading = direction == TravelDirection.Forward ? 0d : Math.PI; double heading = direction == TravelDirection.Forward ? 0d : Math.PI;
@@ -549,5 +634,8 @@ internal sealed class LocalG2CandidateBuilder
} }
private static double Distance(SmoothingPoint2D left, SmoothingPoint2D right) => 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)); 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);
} }
} }
@@ -40,7 +40,12 @@ internal sealed class LocalG2PathSplicer
SmoothingPoint2D point = source.Points[index]; SmoothingPoint2D point = source.Points[index];
if (point.ArcLength < candidate.StartArcLengthMeters) AddWithoutNonGearDuplicates(combined, point); if (point.ArcLength < candidate.StartArcLengthMeters) AddWithoutNonGearDuplicates(combined, point);
} }
for (int index = 0; index < candidate.RegionPoints.Count; index++) AddWithoutNonGearDuplicates(combined, candidate.RegionPoints[index]); // Candidate endpoints are only validated within a numerical tolerance. The replacement
// itself must use the exact source-window endpoints, including a possible gear marker.
AddWithoutNonGearDuplicates(combined, start);
for (int index = 1; index < candidate.RegionPoints.Count - 1; index++)
AddWithoutNonGearDuplicates(combined, candidate.RegionPoints[index]);
AddWithoutNonGearDuplicates(combined, end);
for (int index = 0; index < source.Points.Count; index++) for (int index = 0; index < source.Points.Count; index++)
{ {
SmoothingPoint2D point = source.Points[index]; SmoothingPoint2D point = source.Points[index];
@@ -58,4 +58,16 @@ Assert-True ($startBoundary.CandidateCount -gt 0) 'A start-boundary event must b
Assert-Near 0.0 $startBoundary.StartCurvatureError 1e-8 ` Assert-Near 0.0 $startBoundary.StartCurvatureError 1e-8 `
'A start-boundary candidate must use the preserved physical start vehicle curvature.' 'A start-boundary candidate must use the preserved physical start vehicle curvature.'
$interiorDerivative = Invoke-Scenario 'InteriorStationaryCurve'
Assert-True $interiorDerivative.Rejected `
'A curve with a stationary interior derivative must be rejected even when its endpoint chord is short.'
$exactSplice = Invoke-Scenario 'ExactSpliceEndpoints'
Assert-True $exactSplice.EndpointsAreExact `
'Splicing must write interpolated exact endpoints instead of accepting approximate candidate endpoints.'
$gearBoundary = Invoke-Scenario 'GearBoundary'
Assert-True $gearBoundary.GearBoundaryMarkerPreserved `
'A window touching a gear-switch segment boundary must preserve its point-level gear marker.'
Write-Output 'Path smoothing Local G2 candidate checks passed.' Write-Output 'Path smoothing Local G2 candidate checks passed.'