diff --git a/ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2CandidateBuilder.cs b/ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2CandidateBuilder.cs new file mode 100644 index 0000000..2ab322a --- /dev/null +++ b/ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2CandidateBuilder.cs @@ -0,0 +1,553 @@ +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 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) + { + 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 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; } + } + + /// 反射脚本使用的窄范围候选与拼接场景入口。 + 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(); + 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) + { + CandidateCount = candidateCount; + StartPositionError = startPositionError; + EndPositionError = endPositionError; + StartCurvatureError = startCurvatureError; + EndCurvatureError = endCurvatureError; + ContainsLocalG2Source = containsLocalG2Source; + OutputRegionCount = outputRegionCount; + InternalConnectionsAreG2 = internalConnectionsAreG2; + Direction = direction; + VehicleAndGeometricCurvatureSignsAreOpposite = vehicleAndGeometricCurvatureSignsAreOpposite; + NoDuplicateNonGearPoints = noDuplicateNonGearPoints; + EndpointsUnchanged = endpointsUnchanged; + } + 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; } + } + + 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 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)); + } +} diff --git a/ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2CandidateGeometry.cs b/ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2CandidateGeometry.cs new file mode 100644 index 0000000..22161f7 --- /dev/null +++ b/ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2CandidateGeometry.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using MultiWheelC.TrajectoryPlanning.PathSmoothing.Processing; +using MultiWheelC.TrajectoryPlanning.Utils; + +namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.LocalG2; + +/// 一个尚未经过安全和质量评价的局部 G2 替换几何。 +internal sealed class LocalG2CandidateGeometry +{ + internal LocalG2CandidateGeometry( + int candidateIndex, + int segmentIndex, + double startArcLengthMeters, + double endArcLengthMeters, + double leftWindowLengthMeters, + double rightWindowLengthMeters, + IReadOnlyList regionPoints, + double startVehicleCurvaturePerMeter, + double endVehicleCurvaturePerMeter, + double startGeometricCurvaturePerMeter, + double endGeometricCurvaturePerMeter, + bool internalConnectionsAreG2) + { + if (candidateIndex < 0 || segmentIndex < 0 || !NumericGuard.IsFinite(startArcLengthMeters) || + !NumericGuard.IsFinite(endArcLengthMeters) || startArcLengthMeters < 0d || + endArcLengthMeters < startArcLengthMeters || !NumericGuard.IsFinite(leftWindowLengthMeters) || + !NumericGuard.IsFinite(rightWindowLengthMeters) || leftWindowLengthMeters < 0d || + rightWindowLengthMeters < 0d || regionPoints == null || regionPoints.Count < 2 || + !NumericGuard.IsFinite(startVehicleCurvaturePerMeter) || !NumericGuard.IsFinite(endVehicleCurvaturePerMeter) || + !NumericGuard.IsFinite(startGeometricCurvaturePerMeter) || !NumericGuard.IsFinite(endGeometricCurvaturePerMeter)) + { + throw new ArgumentOutOfRangeException(nameof(regionPoints)); + } + + CandidateIndex = candidateIndex; + SegmentIndex = segmentIndex; + StartArcLengthMeters = startArcLengthMeters; + EndArcLengthMeters = endArcLengthMeters; + LeftWindowLengthMeters = leftWindowLengthMeters; + RightWindowLengthMeters = rightWindowLengthMeters; + RegionPoints = Copy(regionPoints); + StartVehicleCurvaturePerMeter = startVehicleCurvaturePerMeter; + EndVehicleCurvaturePerMeter = endVehicleCurvaturePerMeter; + StartGeometricCurvaturePerMeter = startGeometricCurvaturePerMeter; + EndGeometricCurvaturePerMeter = endGeometricCurvaturePerMeter; + InternalConnectionsAreG2 = internalConnectionsAreG2; + } + + internal int CandidateIndex { get; } + internal int SegmentIndex { get; } + internal double StartArcLengthMeters { get; } + internal double EndArcLengthMeters { get; } + internal double LeftWindowLengthMeters { get; } + internal double RightWindowLengthMeters { get; } + internal IReadOnlyList RegionPoints { get; } + + // 这些边界状态供候选评价和窄范围反射验证使用,不能替代统一几何分析器的最终统计。 + internal double StartVehicleCurvaturePerMeter { get; } + internal double EndVehicleCurvaturePerMeter { get; } + internal double StartGeometricCurvaturePerMeter { get; } + internal double EndGeometricCurvaturePerMeter { get; } + internal bool InternalConnectionsAreG2 { get; } + + private static IReadOnlyList Copy(IReadOnlyList source) + { + var copy = new List(source.Count); + for (int index = 0; index < source.Count; index++) + { + if (source[index] == null) throw new ArgumentOutOfRangeException(nameof(source)); + copy.Add(source[index]); + } + return new ReadOnlyCollection(copy); + } +} diff --git a/ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2PathSplicer.cs b/ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2PathSplicer.cs new file mode 100644 index 0000000..55a4b02 --- /dev/null +++ b/ClumsyPilot/ParkrobTrajplanner/PathSmoothing/LocalG2/LocalG2PathSplicer.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using MultiWheelC.TrajectoryPlanning.PathSmoothing.Processing; +using MultiWheelC.TrajectoryPlanning.Utils; + +namespace MultiWheelC.TrajectoryPlanning.PathSmoothing.LocalG2; + +/// 只替换一个方向段内局部窗口,并保持其余方向拓扑不变。 +internal sealed class LocalG2PathSplicer +{ + internal bool TryReplace( + PreparedPath currentPath, + LocalG2CandidateGeometry candidate, + out PreparedPath replacedPath, + out string reason) + { + replacedPath = null; + reason = string.Empty; + if (currentPath == null || candidate == null || candidate.SegmentIndex < 0 || + candidate.SegmentIndex >= currentPath.Segments.Count || candidate.RegionPoints == null || + candidate.RegionPoints.Count < 2) + { + reason = "局部 G2 拼接输入无效。"; + return false; + } + + PreparedDirectionSegment source = currentPath.Segments[candidate.SegmentIndex]; + if (source == null || source.SegmentIndex != candidate.SegmentIndex || + !PathReferenceInterpolator.TryInterpolateByArcLength(source.Points, candidate.StartArcLengthMeters, out SmoothingPoint2D start, out reason) || + !PathReferenceInterpolator.TryInterpolateByArcLength(source.Points, candidate.EndArcLengthMeters, out SmoothingPoint2D end, out reason) || + !SamePosition(candidate.RegionPoints[0], start) || !SamePosition(candidate.RegionPoints[candidate.RegionPoints.Count - 1], end)) + { + if (string.IsNullOrEmpty(reason)) reason = "局部 G2 候选端点不匹配原始窗口。"; + return false; + } + + var combined = new List(); + for (int index = 0; index < source.Points.Count; index++) + { + SmoothingPoint2D point = source.Points[index]; + if (point.ArcLength < candidate.StartArcLengthMeters) AddWithoutNonGearDuplicates(combined, point); + } + for (int index = 0; index < candidate.RegionPoints.Count; index++) AddWithoutNonGearDuplicates(combined, candidate.RegionPoints[index]); + for (int index = 0; index < source.Points.Count; index++) + { + SmoothingPoint2D point = source.Points[index]; + if (point.ArcLength > candidate.EndArcLengthMeters) AddWithoutNonGearDuplicates(combined, point); + } + if (!TryRecalculateArcLengths(combined, out IReadOnlyList localPoints, out reason)) return false; + + var segments = new List(currentPath.Segments.Count); + for (int index = 0; index < currentPath.Segments.Count; index++) + { + PreparedDirectionSegment segment = currentPath.Segments[index]; + if (index != candidate.SegmentIndex) + { + segments.Add(segment); + continue; + } + segments.Add(new PreparedDirectionSegment(segment.SegmentIndex, segment.Direction, localPoints, + segment.StartsAtGearSwitch, segment.EndsAtGearSwitch, segment.StartVehicleCurvaturePerMeter)); + } + replacedPath = new PreparedPath(segments); + return true; + } + + private static bool TryRecalculateArcLengths( + IReadOnlyList points, + out IReadOnlyList recalculated, + out string reason) + { + recalculated = null; + reason = string.Empty; + if (points == null || points.Count < 2) + { + reason = "局部 G2 拼接后方向段没有足够点。"; + return false; + } + var output = new List(points.Count); + double arcLength = 0d; + for (int index = 0; index < points.Count; index++) + { + SmoothingPoint2D point = points[index]; + if (!IsValid(point)) + { + reason = "局部 G2 拼接点包含非法数值。"; + return false; + } + if (index > 0) + { + double distance = Distance(points[index - 1], point); + if (!NumericGuard.IsPositiveFinite(distance)) + { + reason = "局部 G2 拼接后存在重复非换向点。"; + return false; + } + arcLength += distance; + } + output.Add(new SmoothingPoint2D(point.X, point.Y, arcLength, point.Heading, point.UnwrappedHeading, + point.BodyClearance, point.IsGearSwitchPoint, point.Source)); + } + recalculated = output; + return true; + } + + private static void AddWithoutNonGearDuplicates(List output, SmoothingPoint2D point) + { + if (output.Count == 0) + { + output.Add(point); + return; + } + SmoothingPoint2D previous = output[output.Count - 1]; + if (!previous.IsGearSwitchPoint && !point.IsGearSwitchPoint && SamePosition(previous, point)) return; + output.Add(point); + } + + private static bool IsValid(SmoothingPoint2D point) => point != null && NumericGuard.IsFinite(point.X) && + NumericGuard.IsFinite(point.Y) && NumericGuard.IsFinite(point.Heading) && NumericGuard.IsFinite(point.UnwrappedHeading) && + NumericGuard.IsFinite(point.BodyClearance) && point.BodyClearance >= 0d; + private static bool SamePosition(SmoothingPoint2D left, SmoothingPoint2D right) => + left != null && right != null && Distance(left, right) <= 1e-9d; + private static double Distance(SmoothingPoint2D left, SmoothingPoint2D right) + { + double dx = right.X - left.X; + double dy = right.Y - left.Y; + return Math.Sqrt(dx * dx + dy * dy); + } +} diff --git a/ClumsyPilot/tests/verify_path_smoothing_local_g2_candidates.ps1 b/ClumsyPilot/tests/verify_path_smoothing_local_g2_candidates.ps1 new file mode 100644 index 0000000..12596d3 --- /dev/null +++ b/ClumsyPilot/tests/verify_path_smoothing_local_g2_candidates.ps1 @@ -0,0 +1,61 @@ +param([string]$AssemblyPath = (Join-Path $PSScriptRoot '..\bin\Debug\netstandard2.0\ClumsyPilot.dll')) + +$ErrorActionPreference = 'Stop' +$assembly = [Reflection.Assembly]::LoadFrom((Resolve-Path $AssemblyPath)) + +function Assert-True($Actual, [string]$Message) { + if (-not $Actual) { throw $Message } +} + +function Assert-Equal($Expected, $Actual, [string]$Message) { + if ($Expected -ne $Actual) { throw "$Message Expected=$Expected Actual=$Actual" } +} + +function Assert-Near([double]$Expected, [double]$Actual, [double]$Tolerance, [string]$Message) { + if ([Math]::Abs($Expected - $Actual) -gt $Tolerance) { + throw "$Message Expected=$Expected Actual=$Actual Tolerance=$Tolerance" + } +} + +function Get-RequiredType([string]$Name) { + return $assembly.GetType($Name, $true) +} + +$builderType = Get-RequiredType 'MultiWheelC.TrajectoryPlanning.PathSmoothing.LocalG2.LocalG2CandidateBuilder' +$hooksType = $builderType.GetNestedType('TestHooks', [Reflection.BindingFlags]'Public,NonPublic') +Assert-True ($null -ne $hooksType) 'LocalG2CandidateBuilder must expose narrowly scoped deterministic TestHooks.' +$executeMethod = $hooksType.GetMethod('Execute', [Reflection.BindingFlags]'Public,Static') +Assert-True ($null -ne $executeMethod) 'TestHooks must execute deterministic candidate scenarios.' + +function Invoke-Scenario([string]$Scenario) { + return $executeMethod.Invoke($null, @($Scenario)) +} + +$isolated = Invoke-Scenario 'Isolated' +Assert-True ($isolated.CandidateCount -gt 0 -and $isolated.CandidateCount -le 12) ` + 'An isolated event must produce a bounded non-empty candidate set.' +Assert-Near 0.0 $isolated.StartPositionError 1e-9 'Window start position must match.' +Assert-Near 0.0 $isolated.EndPositionError 1e-9 'Window end position must match.' +Assert-Near 0.0 $isolated.StartCurvatureError 1e-8 'Window start curvature must match.' +Assert-Near 0.0 $isolated.EndCurvatureError 1e-8 'Window end curvature must match.' +Assert-True $isolated.ContainsLocalG2Source 'Generated samples must identify their source.' + +$cluster = Invoke-Scenario 'Cluster' +Assert-Equal 1 $cluster.OutputRegionCount 'Overlapping events must be built as one region.' +Assert-True $cluster.InternalConnectionsAreG2 'Internal anchors must share tangent and curvature.' + +$reverse = Invoke-Scenario 'Reverse' +Assert-Equal 'Reverse' $reverse.Direction 'Reverse candidates must preserve segment direction.' +Assert-True $reverse.VehicleAndGeometricCurvatureSignsAreOpposite ` + 'Reverse candidates must convert vehicle curvature to geometric curvature exactly once.' + +$spliced = Invoke-Scenario 'Spliced' +Assert-True $spliced.NoDuplicateNonGearPoints 'Splicing must remove duplicate boundary samples.' +Assert-True $spliced.EndpointsUnchanged 'Splicing must preserve the complete segment endpoints.' + +$startBoundary = Invoke-Scenario 'StartBoundary' +Assert-True ($startBoundary.CandidateCount -gt 0) 'A start-boundary event must build a one-sided candidate.' +Assert-Near 0.0 $startBoundary.StartCurvatureError 1e-8 ` + 'A start-boundary candidate must use the preserved physical start vehicle curvature.' + +Write-Output 'Path smoothing Local G2 candidate checks passed.'