fix: enforce b-spline displacement constraints

This commit is contained in:
梁薄云
2026-07-29 11:01:28 +08:00
parent 24b46732e3
commit 5e6e068165
2 changed files with 129 additions and 27 deletions
@@ -15,6 +15,7 @@ internal sealed class CubicBSplineSmoother : IPathSmoother
private const double StraightToleranceMeters = 1e-9d; private const double StraightToleranceMeters = 1e-9d;
private const double EndpointTangentScale = 1d / 3d; private const double EndpointTangentScale = 1d / 3d;
private const double EndpointProbeParameter = 1e-6d; private const double EndpointProbeParameter = 1e-6d;
private const double MinimumTangentHandleLengthMeters = 1e-10d;
/// <inheritdoc /> /// <inheritdoc />
public SmoothingMethod Method => SmoothingMethod.CubicBSpline; public SmoothingMethod Method => SmoothingMethod.CubicBSpline;
@@ -88,11 +89,9 @@ internal sealed class CubicBSplineSmoother : IPathSmoother
return true; return true;
} }
Point2D[] controls = CreateControls(anchors, sourceSegment.Direction, strength, reserveMeters, if (!TryCreateControls(anchors, sourceSegment.Direction, strength, reserveMeters,
cancellationToken); cancellationToken, out Point2D[] controls, out reason))
if (controls == null)
{ {
reason = "B 样条控制点构造产生非法数值。";
return false; return false;
} }
@@ -100,45 +99,73 @@ internal sealed class CubicBSplineSmoother : IPathSmoother
var sampled = new List<SmoothingPoint2D>(); var sampled = new List<SmoothingPoint2D>();
int spanCount = controls.Length - Degree; int spanCount = controls.Length - Degree;
int uniformIntervals = spanCount * SamplesPerSpan; int uniformIntervals = spanCount * SamplesPerSpan;
AddSample(0d, anchors, controls, knots, sampled, cancellationToken); if (!TryAddSample(0d, anchors, controls, knots, reserveMeters, sampled, cancellationToken, out reason))
AddSample(EndpointProbeParameter, anchors, controls, knots, sampled, cancellationToken); return false;
if (!TryAddSample(EndpointProbeParameter, anchors, controls, knots, reserveMeters,
sampled, cancellationToken, out reason))
return false;
for (int index = 1; index < uniformIntervals; index++) for (int index = 1; index < uniformIntervals; index++)
{ {
AddSample((double)index / uniformIntervals, anchors, controls, knots, sampled, cancellationToken); if (!TryAddSample((double)index / uniformIntervals, anchors, controls, knots, reserveMeters,
sampled, cancellationToken, out reason))
{
return false;
}
} }
AddSample(1d - EndpointProbeParameter, anchors, controls, knots, sampled, cancellationToken); if (!TryAddSample(1d - EndpointProbeParameter, anchors, controls, knots, reserveMeters,
AddSample(1d, anchors, controls, knots, sampled, cancellationToken); sampled, cancellationToken, out reason))
return false;
if (!TryAddSample(1d, anchors, controls, knots, reserveMeters, sampled, cancellationToken, out reason))
return false;
result = sampled; result = sampled;
return true; return true;
} }
private static Point2D[] CreateControls( private static bool TryCreateControls(
IReadOnlyList<SmoothingPoint2D> anchors, IReadOnlyList<SmoothingPoint2D> anchors,
TravelDirection direction, TravelDirection direction,
double strength, double strength,
double reserveMeters, double reserveMeters,
CancellationToken cancellationToken) CancellationToken cancellationToken,
out Point2D[] controls,
out string reason)
{ {
var controls = new Point2D[anchors.Count]; reason = string.Empty;
controls = new Point2D[anchors.Count];
controls[0] = Point2D.FromAnchor(anchors[0]); controls[0] = Point2D.FromAnchor(anchors[0]);
controls[controls.Length - 1] = Point2D.FromAnchor(anchors[anchors.Count - 1]); controls[controls.Length - 1] = Point2D.FromAnchor(anchors[anchors.Count - 1]);
double startHandleLength = Distance(anchors[0], anchors[1]) * EndpointTangentScale * strength; double startHandleLength = Distance(anchors[0], anchors[1]) * EndpointTangentScale * strength;
double startTravelHeading = GetTravelHeading(anchors[0], direction); double startTravelHeading = GetTravelHeading(anchors[0], direction);
Point2D startProposed = new Point2D( if (!TryConstrainTangentHandle(
anchors[0].X + startHandleLength * Math.Cos(startTravelHeading), Point2D.FromAnchor(anchors[0]),
anchors[0].Y + startHandleLength * Math.Sin(startTravelHeading)); Math.Cos(startTravelHeading),
controls[1] = ClampDisplacement(anchors[1], startProposed, GetAllowedRadius(anchors[1], reserveMeters)); Math.Sin(startTravelHeading),
startHandleLength,
anchors[1],
GetAllowedRadius(anchors[1], reserveMeters),
out controls[1]))
{
reason = "B 样条起点切向手柄无法同时满足相邻锚点移动范围。";
return false;
}
int finalIndex = anchors.Count - 1; int finalIndex = anchors.Count - 1;
double endHandleLength = Distance(anchors[finalIndex - 1], anchors[finalIndex]) * EndpointTangentScale * strength; double endHandleLength = Distance(anchors[finalIndex - 1], anchors[finalIndex]) * EndpointTangentScale * strength;
double endTravelHeading = GetTravelHeading(anchors[finalIndex], direction); double endTravelHeading = GetTravelHeading(anchors[finalIndex], direction);
Point2D endProposed = new Point2D( if (!TryConstrainTangentHandle(
anchors[finalIndex].X - endHandleLength * Math.Cos(endTravelHeading), Point2D.FromAnchor(anchors[finalIndex]),
anchors[finalIndex].Y - endHandleLength * Math.Sin(endTravelHeading)); -Math.Cos(endTravelHeading),
controls[finalIndex - 1] = ClampDisplacement( -Math.Sin(endTravelHeading),
anchors[finalIndex - 1], endProposed, GetAllowedRadius(anchors[finalIndex - 1], reserveMeters)); 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++) for (int index = 2; index < finalIndex - 1; index++)
{ {
@@ -158,22 +185,34 @@ internal sealed class CubicBSplineSmoother : IPathSmoother
for (int index = 0; index < controls.Length; index++) for (int index = 0; index < controls.Length; index++)
{ {
if (!NumericGuard.IsFinite(controls[index].X) || !NumericGuard.IsFinite(controls[index].Y)) if (!NumericGuard.IsFinite(controls[index].X) || !NumericGuard.IsFinite(controls[index].Y))
return null; {
reason = "B 样条控制点构造产生非法数值。";
return false;
}
} }
return controls; return true;
} }
private static void AddSample( private static bool TryAddSample(
double parameter, double parameter,
IReadOnlyList<SmoothingPoint2D> anchors, IReadOnlyList<SmoothingPoint2D> anchors,
Point2D[] controls, Point2D[] controls,
double[] knots, double[] knots,
double reserveMeters,
List<SmoothingPoint2D> output, List<SmoothingPoint2D> output,
CancellationToken cancellationToken) CancellationToken cancellationToken,
out string reason)
{ {
reason = string.Empty;
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
Point2D evaluated = Evaluate(controls, knots, parameter); Point2D evaluated = Evaluate(controls, knots, parameter);
SmoothingPoint2D reference = InterpolateAnchor(anchors, parameter); SmoothingPoint2D reference = InterpolateAnchor(anchors, parameter);
double displacement = Distance(evaluated, reference);
if (!NumericGuard.IsFinite(displacement) || displacement > GetAllowedRadius(reference, reserveMeters))
{
reason = "B 样条评估点超过对应原始参考点的允许移动范围。";
return false;
}
bool endpoint = parameter == 0d || parameter == 1d; bool endpoint = parameter == 0d || parameter == 1d;
output.Add(new SmoothingPoint2D( output.Add(new SmoothingPoint2D(
evaluated.X, evaluated.X,
@@ -184,6 +223,38 @@ internal sealed class CubicBSplineSmoother : IPathSmoother
reference.BodyClearance, reference.BodyClearance,
endpoint && reference.IsGearSwitchPoint, endpoint && reference.IsGearSwitchPoint,
endpoint ? reference.Source : SmoothedPathPointSource.Interpolated)); 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) private static Point2D Evaluate(Point2D[] controls, double[] knots, double parameter)
@@ -303,6 +374,13 @@ internal sealed class CubicBSplineSmoother : IPathSmoother
return Math.Sqrt(deltaX * deltaX + deltaY * deltaY); 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 private readonly struct Point2D
{ {
internal Point2D(double x, double y) internal Point2D(double x, double y)
@@ -77,9 +77,13 @@ function New-AlgorithmInput([object[]]$Segments, [double]$ReserveMeters) {
return $inputConstructor.Invoke(@($preparedPath, (New-EmptyMap), $vehicle, [double]0.05, $ReserveMeters)) return $inputConstructor.Invoke(@($preparedPath, (New-EmptyMap), $vehicle, [double]0.05, $ReserveMeters))
} }
function Invoke-Smoothing([object[]]$Segments, [double]$ReserveMeters, [double]$Strength = 1.0) { function Invoke-Candidate([object[]]$Segments, [double]$ReserveMeters, [double]$Strength = 1.0) {
$candidate = $smoothMethod.Invoke($smoother, @( return $smoothMethod.Invoke($smoother, @(
(New-AlgorithmInput $Segments $ReserveMeters), $Strength, [Threading.CancellationToken]::None)) (New-AlgorithmInput $Segments $ReserveMeters), $Strength, [Threading.CancellationToken]::None))
}
function Invoke-Smoothing([object[]]$Segments, [double]$ReserveMeters, [double]$Strength = 1.0) {
$candidate = Invoke-Candidate $Segments $ReserveMeters $Strength
Assert-True (Get-PropertyValue $candidate 'Succeeded') 'B-spline smoothing must produce a candidate for the deterministic fixture.' Assert-True (Get-PropertyValue $candidate 'Succeeded') 'B-spline smoothing must produce a candidate for the deterministic fixture.'
return @(Get-PropertyValue $candidate 'Segments') return @(Get-PropertyValue $candidate 'Segments')
} }
@@ -198,6 +202,26 @@ foreach ($point in $cornerPoints) {
Assert-True ((Get-DistanceToPolyline $point $cornerSource) -le ($allowedRadius + 0.000000001)) 'Every B-spline displacement must stay inside the per-anchor clearance reserve radius.' Assert-True ((Get-DistanceToPolyline $point $cornerSource) -le ($allowedRadius + 0.000000001)) 'Every B-spline displacement must stay inside the per-anchor clearance reserve radius.'
} }
# A tight reserve may not publish an evaluated B-spline that leaves its 0.005 m movement radius.
$tightReserveCandidate = Invoke-Candidate @((New-DirectionSegment 0 $forward $cornerSource)) 0.075
# A tight endpoint circle that cannot meet the heading=0.2 rad tangent ray must fail, not silently rotate the handle.
$misalignedHeadingSource = @(
(New-Point 0.0 0.0 0.0 0.2 0.08),
(New-Point 0.05 0.0 0.05 0.0 0.08),
(New-Point 0.10 0.0 0.10 0.0 0.08),
(New-Point 0.10 0.05 0.15 ([Math]::PI / 2.0) 0.08),
(New-Point 0.10 0.10 0.20 ([Math]::PI / 2.0) 0.08))
$misalignedHeadingCandidate = Invoke-Candidate @((New-DirectionSegment 0 $forward $misalignedHeadingSource)) 0.075
$requiredFailures = New-Object System.Collections.Generic.List[string]
if (Get-PropertyValue $tightReserveCandidate 'Succeeded') {
[void]$requiredFailures.Add('A tight reserve published an evaluated candidate outside its permitted movement radius.')
}
if (Get-PropertyValue $misalignedHeadingCandidate 'Succeeded') {
[void]$requiredFailures.Add('A tight reserve silently accepted an endpoint handle that cannot follow the supplied travel tangent.')
}
Assert-Equal 0 $requiredFailures.Count ([string]::Join(' ', $requiredFailures))
# Adjacent direction segments retain their duplicated switch pose and independent topology; no fit may cross the switch. # Adjacent direction segments retain their duplicated switch pose and independent topology; no fit may cross the switch.
$reverseSource = @( $reverseSource = @(
(New-Point 0.10 0.10 0.0 ([Math]::PI / 2.0) 0.08 $true), (New-Point 0.10 0.10 0.0 ([Math]::PI / 2.0) 0.08 $true),