2026-07-29 08:50:12 +08:00
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 Assert-False($Actual , [ string ] $Message ) {
if ( $Actual ) { throw $Message }
}
function Assert-Throws([scriptblock]$Action , [ string ] $Message ) {
try {
& $Action
}
catch {
return
}
throw $Message
}
function Get-RequiredType([string]$Name ) {
return $assembly . GetType ( $Name , $true )
}
function New-GeometryPoint (
[ double ] $X ,
[ double ] $Y ,
[ double ] $ArcLength ,
[ double ] $Heading ,
[ double ] $UnwrappedHeading ,
[ bool ] $IsGearSwitch = $false ) {
return [ Activator ]:: CreateInstance ( $pointType , @ (
$X , $Y , $ArcLength , $Heading , $UnwrappedHeading ,
[ double ] 1.0 , $IsGearSwitch , $anchor ))
}
function New-DirectionSegment (
[ int ] $Index ,
$Direction ,
[ object[] ] $Points ,
[ bool ] $StartsAtGearSwitch = $false ,
[ bool ] $EndsAtGearSwitch = $false ) {
$typedPoints = [ Array ]:: CreateInstance ( $pointType , $Points . Count )
for ( $pointIndex = 0 ; $pointIndex -lt $Points . Count ; $pointIndex ++) {
$typedPoints . SetValue ( $Points [ $pointIndex ], $pointIndex )
}
return [ Activator ]:: CreateInstance ( $segmentType , @ (
$Index , $Direction , $typedPoints , $StartsAtGearSwitch , $EndsAtGearSwitch ))
}
2026-07-30 16:39:08 +08:00
function New-DirectionSegmentWithStartCurvature (
[ int ] $Index ,
$Direction ,
[ double ] $StartVehicleCurvature ,
[ object[] ] $Points ,
[ bool ] $StartsAtGearSwitch = $false ,
[ bool ] $EndsAtGearSwitch = $false ) {
$typedPoints = [ Array ]:: CreateInstance ( $pointType , $Points . Count )
for ( $pointIndex = 0 ; $pointIndex -lt $Points . Count ; $pointIndex ++) {
$typedPoints . SetValue ( $Points [ $pointIndex ], $pointIndex )
}
return [ Activator ]:: CreateInstance ( $segmentType , @ (
$Index , $Direction , $typedPoints , $StartsAtGearSwitch , $EndsAtGearSwitch , $StartVehicleCurvature ))
}
2026-07-29 08:50:12 +08:00
function New-CoarsePoint (
[ double ] $X ,
[ double ] $Y ,
[ double ] $ArcLength ,
$Direction ,
[ bool ] $IsGearSwitch = $false ) {
return [ Activator ]:: CreateInstance ( $coarsePointType , @ (
$X , $Y , [ double]0.0, [double ] 0.0 , $ArcLength , $Direction ,
[ double]0.0, [double ] 1.0 , $IsGearSwitch , $coarseAnchor ))
}
function Invoke-Analysis([object[]]$Segments , [ double ] $Spacing = 0.05 ) {
$typedSegments = [ Array ]:: CreateInstance ( $segmentType , $Segments . Count )
for ( $index = 0 ; $index -lt $Segments . Count ; $index ++) {
$typedSegments . SetValue ( $Segments [ $index ], $index )
}
$arguments = [ object[] ] @ ( $typedSegments , $Spacing , $null , $null )
$accepted = $analyzeMethod . Invoke ( $analyzer , $arguments )
$description = [ string ]:: Join ( ',' , @ ( $Segments | ForEach-Object {
"index= $( $_ . SegmentIndex ) ;direction= $( $_ . Direction ) ;points= $( $_ . Points . Count ) "
}))
Assert-True $accepted ( "Geometry analysis must accept the analytic candidate. Reason=" + $arguments [ 3 ] + '; Segments=' + $description )
Assert-True ( $null -ne $arguments [ 2 ]) 'Successful geometry analysis must return PathGeometryAnalysis.'
return $arguments [ 2 ]
}
function Assert-AnalysisRejected([object[]]$Segments , [ string ] $Message ) {
$typedSegments = [ Array ]:: CreateInstance ( $segmentType , $Segments . Count )
for ( $segmentIndex = 0 ; $segmentIndex -lt $Segments . Count ; $segmentIndex ++) {
$typedSegments . SetValue ( $Segments [ $segmentIndex ], $segmentIndex )
}
$arguments = [ object[] ] @ ( $typedSegments , [ double ] 0.05 , $null , $null )
$accepted = $analyzeMethod . Invoke ( $analyzer , $arguments )
Assert-True ( -not $accepted ) ( $Message + '; Reason=' + $arguments [ 3 ])
}
function Invoke-RejectedAnalysis([object[]]$Segments , [ string ] $Message ) {
$typedSegments = [ Array ]:: CreateInstance ( $segmentType , $Segments . Count )
for ( $index = 0 ; $index -lt $Segments . Count ; $index ++) {
$typedSegments . SetValue ( $Segments [ $index ], $index )
}
$arguments = [ object[] ] @ ( $typedSegments , [ double ] 0.05 , $null , $null )
$accepted = $analyzeMethod . Invoke ( $analyzer , $arguments )
Assert-False $accepted ( $Message + '; Reason=' + $arguments [ 3 ])
}
function New-CoarsePathPoint (
[ double ] $X ,
[ double ] $Y ,
[ double ] $ArcLength ,
$Direction ,
[ bool ] $IsGearSwitch = $false ,
[ string ] $SourceName = 'MotionPrimitive' ) {
return [ Activator ]:: CreateInstance ( $coarsePointType , @ (
$X , $Y , [ double]0.0, [double ] 0.0 , $ArcLength ,
$Direction , [ double]0.0, [double ] 1.0 , $IsGearSwitch ,
[ Enum ]:: Parse ( $coarsePointSourceType , $SourceName )))
}
function New-EmptyGeometryMap {
$mapRequest = [ Activator ]:: CreateInstance ( $mapRequestType )
$mapRequest . Bounds = [ Activator ]:: CreateInstance ( $boundsType , @ ([ single]0, [single]5000, [single]0, [single ] 5000 ))
$mapRequest . ResolutionMm = [ single ] 50
$mapRequest . AllowExplicitEmptyMap = $true
$map = [ Activator ]:: CreateInstance ( $mapFactoryType ). Create ( $mapRequest ). Map
Assert-True ( $null -ne $map ) 'Geometry test must create an explicit empty planning map.'
return $map
}
2026-07-31 15:28:49 +08:00
function Invoke-GeometryValidation($Analysis , [ object[] ] $Segments , $Vehicle ) {
$typedSegments = [ Array ]:: CreateInstance ( $segmentType , $Segments . Count )
for ( $index = 0 ; $index -lt $Segments . Count ; $index ++) {
$typedSegments . SetValue ( $Segments [ $index ], $index )
}
$preparedPath = [ Activator ]:: CreateInstance ( $preparedPathType , [ object[] ](, $typedSegments ))
$arguments = [ object[] ] @ (
$Analysis . Path , $Analysis . Segments , $preparedPath , ( New-EmptyGeometryMap ), $Vehicle , [ double ] 0.05 ,
$null , [ double ] 0.0 , $null )
$accepted = $validateMethod . Invoke ( $validator , $arguments )
return [ pscustomobject ] @ { Accepted = $accepted ; Reason = $arguments [ 8 ] }
}
function New-CircularDirectionSegment (
$Direction ,
[ double ] $Curvature ,
[ int ] $Intervals ,
[ double ] $ChordLength ) {
$radius = 1.0 / $Curvature
$headingStep = 2.0 * [ Math ]:: Asin ( $Curvature * $ChordLength / 2.0 )
$points = New-Object System . Collections . Generic . List [ object ]
for ( $index = 0 ; $index -le $Intervals ; $index ++) {
$theta = $headingStep * $index
$heading = if ( $Direction . ToString () -eq 'Forward' ) { $theta } else { $theta + [ Math ]:: PI }
[ void ] $points . Add (( New-GeometryPoint `
( 1.0 + $radius * [ Math ]:: Sin ( $theta )) `
( 1.0 + $radius * ( 1.0 - [ Math ]:: Cos ( $theta ))) `
( $index * $ChordLength ) $heading $heading ))
}
return New-DirectionSegment 0 $Direction $points . ToArray ()
}
function Get-OldPolylineCurvatureMaximum($Analysis ) {
$maximum = 0.0
$path = $Analysis . Path
for ( $index = 0 ; $index -lt $path . Count ; $index ++) {
if ( $path . Count -eq 1 ) {
$curvature = 0.0
}
elseif ( $index -eq 0 ) {
$curvature = ( $path [ 1 ]. UnwrappedHeading - $path [ 0 ]. UnwrappedHeading ) /
( $path [ 1 ]. ArcLength - $path [ 0 ]. ArcLength )
}
elseif ( $index -eq $path . Count - 1 ) {
$curvature = ( $path [ $index ]. UnwrappedHeading - $path [ $index - 1 ]. UnwrappedHeading ) /
( $path [ $index ]. ArcLength - $path [ $index - 1 ]. ArcLength )
}
else {
$curvature = ( $path [ $index + 1 ]. UnwrappedHeading - $path [ $index - 1 ]. UnwrappedHeading ) /
( $path [ $index + 1 ]. ArcLength - $path [ $index - 1 ]. ArcLength )
}
$maximum = [ Math ]:: Max ( $maximum , [ Math ]:: Abs ( $curvature ))
}
return $maximum
}
function Get-QuinticAnalyticCurvatureMaximum (
[ double ] $C2 ,
[ double ] $C3 ,
[ double ] $C4 ,
[ double ] $C5 ,
[ int ] $ReferenceSamples = 20000 ) {
$maximum = 0.0
for ( $index = 0 ; $index -lt $ReferenceSamples ; $index ++) {
$t = $index / [ double ]( $ReferenceSamples - 1 )
$firstDerivative = 2.0 * $C2 * $t + 3.0 * $C3 * $t * $t +
4.0 * $C4 * $t * $t * $t + 5.0 * $C5 * $t * $t * $t * $t
$secondDerivative = 2.0 * $C2 + 6.0 * $C3 * $t +
12.0 * $C4 * $t * $t + 20.0 * $C5 * $t * $t * $t
$curvature = [ Math ]:: Abs ( $secondDerivative / [ Math ]:: Pow ( 1.0 + $firstDerivative * $firstDerivative , 1.5 ))
$maximum = [ Math ]:: Max ( $maximum , $curvature )
}
return $maximum
}
function New-QuinticDirectionSegment (
[ double ] $C2 ,
[ double ] $C3 ,
[ double ] $C4 ,
[ double ] $C5 ,
[ double ] $DistributionPower ,
[ int ] $Samples = 400 ) {
$points = New-Object System . Collections . Generic . List [ object ]
$arcLength = 0.0
$previousX = 0.0
$previousY = 0.0
for ( $index = 0 ; $index -lt $Samples ; $index ++) {
$t = [ Math ]:: Pow ( $index / [ double ]( $Samples - 1 ), $DistributionPower )
$x = 1.0 + $t
$y = $C2 * $t * $t + $C3 * $t * $t * $t + $C4 * $t * $t * $t * $t + $C5 * $t * $t * $t * $t * $t
if ( $index -gt 0 ) {
$arcLength += [ Math ]:: Sqrt (( $x - $previousX ) * ( $x - $previousX ) + ( $y - $previousY ) * ( $y - $previousY ))
}
$heading = [ Math ]:: Atan2 (
2.0 * $C2 * $t + 3.0 * $C3 * $t * $t + 4.0 * $C4 * $t * $t * $t + 5.0 * $C5 * $t * $t * $t * $t ,
1.0 )
[ void ] $points . Add (( New-GeometryPoint $x $y $arcLength $heading $heading ))
$previousX = $x
$previousY = $y
}
return New-DirectionSegment 0 $forward $points . ToArray ()
}
2026-07-29 08:50:12 +08:00
$root = 'MultiWheelC.TrajectoryPlanning.PathSmoothing.'
$processing = $root + 'Processing.'
$coarsePath = 'MultiWheelC.TrajectoryPlanning.CoarsePath.'
$analyzerType = Get-RequiredType ( $processing + 'PathGeometryAnalyzer' )
$directionType = Get-RequiredType ( $coarsePath + 'TravelDirection' )
$sourceType = Get-RequiredType ( $root + 'SmoothedPathPointSource' )
$pointType = Get-RequiredType ( $processing + 'SmoothingPoint2D' )
$segmentType = Get-RequiredType ( $processing + 'PreparedDirectionSegment' )
$preparedPathType = Get-RequiredType ( $processing + 'PreparedPath' )
$analysisType = Get-RequiredType ( $processing + 'PathGeometryAnalysis' )
$preprocessorType = Get-RequiredType ( $processing + 'PathSmoothingPreprocessor' )
$resamplerType = Get-RequiredType ( $processing + 'ArcLengthResampler' )
$requestType = Get-RequiredType ( $root + 'PathSmoothingRequest' )
$configurationType = Get-RequiredType ( $root + 'PathSmoothingConfiguration' )
$coarsePointType = Get-RequiredType ( $coarsePath + 'CoarsePathPoint' )
$coarseSegmentType = Get-RequiredType ( $coarsePath + 'PathSegment' )
$coarseSourceType = Get-RequiredType ( $coarsePath + 'CoarsePathPointSource' )
$vehicleType = Get-RequiredType ( $coarsePath + 'VehicleParameters' )
$mapType = Get-RequiredType 'MultiWheelC.TrajectoryPlanning.Mapping.PlanningGridMap'
$coarsePointType = Get-RequiredType ( $coarsePath + 'CoarsePathPoint' )
$coarseSegmentType = Get-RequiredType ( $coarsePath + 'PathSegment' )
$coarsePointSourceType = Get-RequiredType ( $coarsePath + 'CoarsePathPointSource' )
$smoothingRequestType = Get-RequiredType ( $root + 'PathSmoothingRequest' )
$smoothingConfigurationType = Get-RequiredType ( $root + 'PathSmoothingConfiguration' )
$vehicleType = Get-RequiredType ( $coarsePath + 'VehicleParameters' )
$boundsType = Get-RequiredType 'MultiWheelC.TrajectoryPlanning.Mapping.MapBoundsMm'
$mapRequestType = Get-RequiredType 'MultiWheelC.TrajectoryPlanning.Mapping.PlanningMapRequest'
$mapFactoryType = Get-RequiredType 'MultiWheelC.TrajectoryPlanning.Mapping.PlanningMapFactory'
2026-07-31 15:28:49 +08:00
$validatorType = Get-RequiredType ( $root + 'Validation.SmoothedPathValidator' )
2026-07-29 08:50:12 +08:00
Assert-True ( $null -ne $preparedPathType ) 'PreparedPath must be discoverable for smoothing algorithms.'
Assert-True ( $null -ne $preprocessorType ) 'PathSmoothingPreprocessor must be discoverable for request preparation.'
Assert-True ( $null -ne $resamplerType ) 'ArcLengthResampler must be discoverable for deterministic resampling.'
2026-07-30 16:39:08 +08:00
Assert-True ( $null -ne $analysisType . GetProperty ( 'MaximumAbsoluteVehicleCurvatureDerivativePerSquareMeter' )) `
'PathGeometryAnalysis must expose peak d-kappa/d-s.'
2026-07-29 08:50:12 +08:00
$analyzer = [ Activator ]:: CreateInstance ( $analyzerType )
$analyzeMethod = $analyzerType . GetMethod ( 'TryAnalyze' )
Assert-True ( $null -ne $analyzeMethod ) 'PathGeometryAnalyzer must expose TryAnalyze.'
Assert-Equal 4 $analyzeMethod . GetParameters (). Length 'TryAnalyze must accept segments, spacing, analysis, and reason.'
2026-07-31 15:28:49 +08:00
$validator = [ Activator ]:: CreateInstance ( $validatorType )
$validateMethod = $validatorType . GetMethod ( 'TryValidate' )
Assert-True ( $null -ne $validateMethod ) 'SmoothedPathValidator must expose TryValidate.'
Assert-Equal 9 $validateMethod . GetParameters (). Length 'SmoothedPathValidator.TryValidate must retain its public contract.'
2026-07-29 08:50:12 +08:00
$forward = [ Enum ]:: Parse ( $directionType , 'Forward' )
$reverse = [ Enum ]:: Parse ( $directionType , 'Reverse' )
$anchor = [ Enum ]:: Parse ( $sourceType , 'Anchor' )
$coarseAnchor = [ Enum ]:: Parse ( $coarseSourceType , 'Start' )
2026-07-31 15:28:49 +08:00
# The chord-corrected estimator must retain an exact circular curvature limit for both travel directions.
$maximumAllowedCurvature = 5.0 / 6.0
$circleVehicle = [ Activator ]:: CreateInstance ( $vehicleType )
$circleVehicle . LengthMeters = 0.20
$circleVehicle . WidthMeters = 0.20
$circleVehicle . SafetyMarginMeters = 0.0
$circleVehicle . MaximumCurvaturePerMeter = $maximumAllowedCurvature
foreach ( $direction in @ ( $forward , $reverse )) {
$exactLimitCircle = New-CircularDirectionSegment $direction $maximumAllowedCurvature 20 0.05
$exactLimitAnalysis = Invoke-Analysis @ ( $exactLimitCircle )
foreach ( $point in $exactLimitAnalysis . Path ) {
Assert-True ([ Math ]:: Abs ( $point . VehicleCurvature ) -le $maximumAllowedCurvature + 1 . 0e - 9 ) `
( 'An exact-limit ' + $direction + ' circle must not exceed the curvature limit.' )
}
$validation = Invoke-GeometryValidation $exactLimitAnalysis @ ( $exactLimitCircle ) $circleVehicle
Assert-True $validation . Accepted ( 'The validator must accept an analyzed exact-limit ' + $direction + ' circle. Reason=' + $validation . Reason )
}
# An analyzed over-limit circle must remain detectable by the unchanged validator threshold.
$overLimitCircle = New-CircularDirectionSegment $forward ( $maximumAllowedCurvature + 0.01 ) 20 0.05
$overLimitAnalysis = Invoke-Analysis @ ( $overLimitCircle )
Assert-True ( $overLimitAnalysis . MaximumAbsoluteVehicleCurvaturePerMeter -gt $maximumAllowedCurvature + 1 . 0e - 6 ) `
'An over-limit circle must exceed the vehicle curvature limit by more than the validator tolerance.'
$overLimitValidation = Invoke-GeometryValidation $overLimitAnalysis @ ( $overLimitCircle ) $circleVehicle
Assert-False $overLimitValidation . Accepted 'The validator must reject an analyzed over-limit circle.'
# The chord-corrected estimator must not under-estimate these smooth references more than the former polyline estimator.
foreach ( $quinticCase in @ (
[ pscustomobject ] @ { Name = 'SBend' ; C2 = 0.0 ; C3 = 0.30 ; C4 = -0.45 ; C5 = 0.18 ; Power = 1.0 },
[ pscustomobject ] @ { Name = 'EndpointPeak' ; C2 = 0.18 ; C3 = -0.12 ; C4 = 0.0 ; C5 = 0.0 ; Power = 1.0 },
[ pscustomobject ] @ { Name = 'NonUniformFinalInterval' ; C2 = -0.12 ; C3 = 0.36 ; C4 = -0.30 ; C5 = 0.08 ; Power = 1.7 })) {
$quintic = New-QuinticDirectionSegment $quinticCase . C2 $quinticCase . C3 $quinticCase . C4 $quinticCase . C5 $quinticCase . Power
$quinticAnalysis = Invoke-Analysis @ ( $quintic )
$analyticMaximum = Get-QuinticAnalyticCurvatureMaximum $quinticCase . C2 $quinticCase . C3 $quinticCase . C4 $quinticCase . C5
$newDeficit = [ Math ]:: Max ( 0.0 , $analyticMaximum - $quinticAnalysis . MaximumAbsoluteVehicleCurvaturePerMeter )
$oldDeficit = [ Math ]:: Max ( 0.0 , $analyticMaximum - ( Get-OldPolylineCurvatureMaximum $quinticAnalysis ))
Assert-True ( $newDeficit -le $oldDeficit + 1 . 0e - 6 ) `
( $quinticCase . Name + ' must not have greater one-sided curvature under-estimation than the old polyline estimator.' )
}
# A half-turn over a single chord has no unambiguous geometric curvature estimate.
$ambiguousTurn = New-DirectionSegment 0 $forward @ (
( New-GeometryPoint 0.0 0.0 0.0 0.0 0.0 ),
( New-GeometryPoint 1.0 0.0 1.0 ([ Math ]:: PI ) ([ Math ]:: PI )))
Assert-AnalysisRejected @ ( $ambiguousTurn ) 'A two-point heading turn of π must be rejected as ambiguous.'
2026-07-29 08:50:12 +08:00
# Forward straight: resampling is exactly 0.05 m, preserves the exact endpoint, and has zero curvature.
$straight = New-DirectionSegment 0 $forward @ (
( New-GeometryPoint 0.0 0.0 0.0 0.0 0.0 ),
( New-GeometryPoint 1.0 0.0 1.0 0.0 0.0 ))
$straightAnalysis = Invoke-Analysis @ ( $straight )
Assert-Equal 21 $straightAnalysis . Path . Count 'A one-metre straight must produce twenty 0.05 m intervals plus the initial point.'
2026-07-30 16:39:08 +08:00
Assert-Near 0.0 $straightAnalysis . MaximumAbsoluteVehicleCurvatureDerivativePerSquareMeter 0.000000001 `
'A straight must have zero peak d-kappa/d-s.'
foreach ( $point in $straightAnalysis . Path ) {
Assert-Near 0.0 $point . VehicleCurvatureDerivative 0.000000001 `
'A straight point must carry zero d-kappa/d-s.'
}
2026-07-29 08:50:12 +08:00
for ( $index = 1 ; $index -lt $straightAnalysis . Path . Count ; $index ++) {
$left = $straightAnalysis . Path [ $index - 1 ]
$right = $straightAnalysis . Path [ $index ]
$distance = [ Math ]:: Sqrt (( $right . X - $left . X ) * ( $right . X - $left . X ) + ( $right . Y - $left . Y ) * ( $right . Y - $left . Y ))
Assert-Near 0.05 $distance 0.000000001 'Straight resampling intervals must be exactly 0.05 m.'
Assert-Near 0.0 $right . GeometricCurvature 0.000000001 'A forward straight must have zero geometric curvature.'
Assert-Near 0.0 $right . VehicleCurvature 0.000000001 'A forward straight must have zero vehicle curvature.'
}
$straightEnd = $straightAnalysis . Path [ $straightAnalysis . Path . Count - 1 ]
Assert-Near 1.0 $straightEnd . X 0.0 'Resampling must retain the exact final X coordinate.'
Assert-Near 0.0 $straightEnd . Y 0.0 'Resampling must retain the exact final Y coordinate.'
2026-07-30 08:42:14 +08:00
# Raw coarse anchors carry the vehicle pose, which may differ slightly from the chord tangent of a finite integration step.
$poseAnchoredCurve = New-DirectionSegment 0 $forward @ (
( New-GeometryPoint 0.0 0.0 0.0 0.0 0.0 ),
( New-GeometryPoint 1.0 0.2 1.0 0.4 0.4 ))
$poseAnchoredAnalysis = Invoke-Analysis @ ( $poseAnchoredCurve )
Assert-Near 0.0 $poseAnchoredAnalysis . Path [ 0 ]. Heading 0.000000000001 'Geometry analysis must retain the first coarse-anchor heading rather than replace it with a chord tangent.'
$poseAnchoredEnd = $poseAnchoredAnalysis . Path [ $poseAnchoredAnalysis . Path . Count - 1 ]
Assert-Near 0.4 $poseAnchoredEnd . Heading 0.000000000001 'Geometry analysis must retain the final coarse-anchor heading rather than replace it with a chord tangent.'
2026-07-29 08:50:12 +08:00
# A forward R=2 quarter circle has positive +0.5 1/m vehicle curvature.
$forwardArcPoints = New-Object System . Collections . Generic . List [ object ]
for ( $index = 0 ; $index -le 32 ; $index ++) {
$theta = ([ Math ]:: PI / 2.0 ) * $index / 32.0
$x = 2.0 * [ Math ]:: Sin ( $theta )
$y = 2.0 * ( 1.0 - [ Math ]:: Cos ( $theta ))
$arcLength = 2.0 * $theta
[ void ] $forwardArcPoints . Add (( New-GeometryPoint $x $y $arcLength $theta $theta ))
}
$forwardArc = New-DirectionSegment 0 $forward $forwardArcPoints . ToArray ()
$forwardArcAnalysis = Invoke-Analysis @ ( $forwardArc )
$forwardArcMidpoint = $forwardArcAnalysis . Path [ [int ]( $forwardArcAnalysis . Path . Count / 2 )]
Assert-Near 0.5 $forwardArcMidpoint . GeometricCurvature 0.01 'An R=2 quarter circle must have geometric curvature +0.5 1/m.'
Assert-Near 0.5 $forwardArcMidpoint . VehicleCurvature 0.01 'A forward R=2 quarter circle must have vehicle curvature +0.5 1/m.'
# The same spatial R=2 circle in reverse retains geometric curvature but negates vehicle curvature.
$reverseArc = New-DirectionSegment 0 $reverse $forwardArcPoints . ToArray ()
$reverseArcAnalysis = Invoke-Analysis @ ( $reverseArc )
$reverseArcMidpoint = $reverseArcAnalysis . Path [ [int ]( $reverseArcAnalysis . Path . Count / 2 )]
Assert-Near 0.5 $reverseArcMidpoint . GeometricCurvature 0.01 'Reverse travel must not change geometric curvature.'
Assert-Near -0.5 $reverseArcMidpoint . VehicleCurvature 0.01 'A reverse R=2 quarter circle must have vehicle curvature -0.5 1/m.'
# Gear-switch poses are intentionally duplicated: they keep equal arc length and never enter a derivative denominator.
$forwardBeforeSwitch = New-DirectionSegment 0 $forward @ (
( New-GeometryPoint 0.0 0.0 0.0 0.0 0.0 ),
( New-GeometryPoint 1.0 0.0 1.0 0.0 0.0 $true )) $false $true
$reverseAfterSwitch = New-DirectionSegment 1 $reverse @ (
( New-GeometryPoint 1.0 0.0 1.0 0.0 0.0 $true ),
( New-GeometryPoint 0.0 0.0 2.0 0.0 0.0 )) $true $false
$switchAnalysis = Invoke-Analysis @ ( $forwardBeforeSwitch , $reverseAfterSwitch )
$firstSegment = $switchAnalysis . Segments [ 0 ]
$secondSegment = $switchAnalysis . Segments [ 1 ]
$switchLeft = $switchAnalysis . Path [ $firstSegment . EndIndex ]
$switchRight = $switchAnalysis . Path [ $secondSegment . StartIndex ]
Assert-Near $switchLeft . X $switchRight . X 0.0 'Gear-switch endpoints must retain duplicate X coordinates.'
Assert-Near $switchLeft . Y $switchRight . Y 0.0 'Gear-switch endpoints must retain duplicate Y coordinates.'
Assert-Near $switchLeft . ArcLength $switchRight . ArcLength 0.0 'Gear-switch endpoints must retain duplicate arc length.'
Assert-Equal 'Forward' $switchLeft . Direction . ToString () 'The first gear-switch pose must retain its forward segment direction.'
Assert-Equal 'Reverse' $switchRight . Direction . ToString () 'The second gear-switch pose must retain its reverse segment direction.'
Assert-True ( -not [ double ]:: IsNaN ( $switchLeft . GeometricCurvature )) 'No derivative may cross the gear-switch duplicate point.'
Assert-True ( -not [ double ]:: IsNaN ( $switchRight . GeometricCurvature )) 'No reverse derivative may cross the gear-switch duplicate point.'
2026-07-30 16:39:08 +08:00
Assert-True ( -not [ double ]:: IsNaN ( $switchLeft . VehicleCurvatureDerivative )) `
'The forward side of a gear switch must have a finite one-sided derivative.'
Assert-True ( -not [ double ]:: IsNaN ( $switchRight . VehicleCurvatureDerivative )) `
'The reverse side of a gear switch must have a finite one-sided derivative.'
2026-07-29 08:50:12 +08:00
# Curvature at a curved segment's end and the following straight reverse segment's start must remain independently differentiated.
$forwardArcToSwitch = New-DirectionSegment 0 $forward $forwardArcPoints . ToArray () $false $true
$reverseStraightAfterArc = New-DirectionSegment 1 $reverse @ (
( New-GeometryPoint 2.0 2.0 0.0 ([ Math ]:: PI / 2.0 ) ([ Math ]:: PI / 2.0 ) $true ),
( New-GeometryPoint 2.0 1.0 1.0 ([ Math ]:: PI / 2.0 ) ([ Math ]:: PI / 2.0 ))) $true $false
$curveSwitchAnalysis = Invoke-Analysis @ ( $forwardArcToSwitch , $reverseStraightAfterArc )
$reverseStraightStart = $curveSwitchAnalysis . Path [ $curveSwitchAnalysis . Segments [ 1 ]. StartIndex ]
Assert-Near 0.0 $reverseStraightStart . GeometricCurvature 0.000000001 'A gear-switch must not use the preceding curve to differentiate a reverse straight segment.'
2026-07-30 16:39:08 +08:00
Assert-Near 0.0 $reverseStraightStart . VehicleCurvatureDerivative 0.000000001 `
'No curvature derivative may cross from the preceding forward arc into a reverse straight.'
$physicalStart = New-DirectionSegmentWithStartCurvature 0 $forward 0.20 @ (
( New-GeometryPoint 0.0 0.0 0.0 0.0 0.0 ),
( New-GeometryPoint 1.0 0.0 1.0 0.0 0.0 ))
$physicalStartAnalysis = Invoke-Analysis @ ( $physicalStart )
Assert-Near 0.20 $physicalStartAnalysis . Path [ 0 ]. VehicleCurvature 0.000000001 `
'The unified analyzer must retain a real start steering-curvature boundary state.'
Assert-True ( $physicalStartAnalysis . MaximumAbsoluteVehicleCurvatureDerivativePerSquareMeter -gt 0.0 ) `
'A real start-curvature mismatch must remain visible to the quality analyzer.'
2026-07-29 08:50:12 +08:00
# A boundary that claims a gear switch must be a duplicated pose with opposite direction; discontinuities are rejected.
$invalidSwitch = New-DirectionSegment 1 $reverse @ (
( New-GeometryPoint 1.2 0.0 1.0 0.0 0.0 $true ),
( New-GeometryPoint 0.2 0.0 2.0 0.0 0.0 )) $true $false
Assert-AnalysisRejected @ ( $forwardBeforeSwitch , $invalidSwitch ) 'A discontinuous gear-switch boundary must be rejected.'
$trailingGearSwitch = New-DirectionSegment 0 $forward @ (
( New-GeometryPoint 0.0 0.0 0.0 0.0 0.0 ),
( New-GeometryPoint 1.0 0.0 1.0 0.0 0.0 )) $false $true
Assert-AnalysisRejected @ ( $trailingGearSwitch ) 'The final direction segment must not advertise a non-existent trailing gear switch.'
# The public preprocessor receives a raw coarse path and resets every prepared direction segment to local arc length zero.
$coarsePoints = [ Array ]:: CreateInstance ( $coarsePointType , 4 )
$coarsePoints . SetValue (( New-CoarsePoint 0.0 0.0 0.0 $forward ), 0 )
$coarsePoints . SetValue (( New-CoarsePoint 1.0 0.0 1.0 $forward ), 1 )
$coarsePoints . SetValue (( New-CoarsePoint 1.0 0.0 1.0 $reverse $true ), 2 )
$coarsePoints . SetValue (( New-CoarsePoint 0.0 0.0 2.0 $reverse ), 3 )
$coarseSegments = [ Array ]:: CreateInstance ( $coarseSegmentType , 2 )
$coarseSegments . SetValue ([ Activator ]:: CreateInstance ( $coarseSegmentType , @ ( 0 , $forward , 0 , 1 , $false , $true )), 0 )
$coarseSegments . SetValue ([ Activator ]:: CreateInstance ( $coarseSegmentType , @ ( 1 , $reverse , 2 , 3 , $true , $false )), 1 )
$vehicle = [ Activator ]:: CreateInstance ( $vehicleType )
$vehicle . LengthMeters = 1.0
$vehicle . WidthMeters = 0.5
$vehicle . SafetyMarginMeters = 0.0
$vehicle . MaximumCurvaturePerMeter = 1.0
$configuration = [ Activator ]:: CreateInstance ( $configurationType )
$uninitializedMap = [ System.Runtime.Serialization.FormatterServices ]:: GetUninitializedObject ( $mapType )
$request = [ Activator ]:: CreateInstance ( $requestType , @ ( $coarsePoints , $coarseSegments , $uninitializedMap , $vehicle , $configuration ))
$preprocessor = [ Activator ]:: CreateInstance ( $preprocessorType )
$prepareMethod = $preprocessorType . GetMethod ( 'TryPrepare' )
$prepareArguments = [ object[] ] @ ( $request , $null , $null )
$prepared = $prepareMethod . Invoke ( $preprocessor , $prepareArguments )
Assert-True $prepared ( 'Preprocessor must accept a legal forward/reverse raw coarse path. Reason=' + $prepareArguments [ 2 ])
Assert-Near 0.0 $prepareArguments [ 1 ]. Segments [ 1 ]. Points [ 0 ]. ArcLength 0.0 'Every prepared direction segment must begin at local arc length zero.'
Assert-True $prepareArguments [ 1 ]. Segments [ 1 ]. Points [ 0 ]. IsGearSwitchPoint 'The reverse prepared segment must retain its gear-switch point.'
# Finite coordinates can still overflow distance arithmetic; public resampling must reject them instead of emitting NaN/Infinity.
$overflowSegment = New-DirectionSegment 0 $forward @ (
( New-GeometryPoint -1.0e308 0.0 0.0 0.0 0.0 ),
( New-GeometryPoint 1.0e308 0.0 1.0 0.0 0.0 ))
$resampler = [ Activator ]:: CreateInstance ( $resamplerType )
$segmentResampleMethod = @ ( $resamplerType . GetMethods () | Where-Object {
$_ . Name -eq 'TryResample' -and $_ . GetParameters ()[ 0 ]. ParameterType -eq $segmentType
})[ 0 ]
$resampleArguments = [ object[] ] @ ( $overflowSegment , [ double ] 0.05 , $null , $null )
$resampled = $segmentResampleMethod . Invoke ( $resampler , $resampleArguments )
Assert-True ( -not $resampled ) ( 'Resampling must reject a distance overflow. Reason=' + $resampleArguments [ 3 ])
# A prepared path must reject null direction segments rather than silently dropping them during flattening.
$null SegmentArray = [ Array ]:: CreateInstance ( $segmentType , 1 )
$null SegmentRejected = $false
try { [ void][Activator ]:: CreateInstance ( $preparedPathType , @ ( $null SegmentArray )) } catch { $null SegmentRejected = $true }
Assert-True $null SegmentRejected 'PreparedPath must reject a null direction segment.'
# Unwrapped heading must not jump by 2π when tangents cross the -π/π branch cut.
$crossing = New-DirectionSegment 0 $forward @ (
( New-GeometryPoint 0.0 0.0 0.0 ( 170.0 * [ Math ]:: PI / 180.0 ) ( 170.0 * [ Math ]:: PI / 180.0 )),
( New-GeometryPoint -1.0 ([ Math ]:: Tan ( 10.0 * [ Math ]:: PI / 180.0 )) 1.015 ( 170.0 * [ Math ]:: PI / 180.0 ) ( 170.0 * [ Math ]:: PI / 180.0 )),
( New-GeometryPoint -2.0 0.0 2.03 ( -170.0 * [ Math ]:: PI / 180.0 ) ( -170.0 * [ Math ]:: PI / 180.0 )))
$crossingAnalysis = Invoke-Analysis @ ( $crossing )
for ( $index = 1 ; $index -lt $crossingAnalysis . Path . Count ; $index ++) {
$difference = [ Math ]:: Abs ( $crossingAnalysis . Path [ $index ]. UnwrappedHeading - $crossingAnalysis . Path [ $index - 1 ]. UnwrappedHeading )
Assert-True ( $difference -lt [ Math ]:: PI ) 'Unwrapped headings must remain continuous across the ±π branch cut.'
}
# The request preprocessor must reset arc length independently for every direction segment,
# while retaining the duplicated pose that represents a legal forward-to-reverse gear switch.
$preprocessor = [ Activator ]:: CreateInstance ( $preprocessorType )
$prepareMethod = $preprocessorType . GetMethod ( 'TryPrepare' )
Assert-True ( $null -ne $prepareMethod ) 'PathSmoothingPreprocessor must expose TryPrepare.'
$coarsePath = [ Array ]:: CreateInstance ( $coarsePointType , 5 )
$coarsePath . SetValue (( New-CoarsePathPoint 0.0 0.0 0.0 $forward $false 'Start' ), 0 )
$coarsePath . SetValue (( New-CoarsePathPoint 1.0 0.0 1.0 $forward ), 1 )
$coarsePath . SetValue (( New-CoarsePathPoint 2.0 0.0 2.0 $forward ), 2 )
$coarsePath . SetValue (( New-CoarsePathPoint 2.0 0.0 2.0 $reverse $true ), 3 )
$coarsePath . SetValue (( New-CoarsePathPoint 1.0 0.0 3.0 $reverse ), 4 )
$coarseSegments = [ Array ]:: CreateInstance ( $coarseSegmentType , 2 )
$coarseSegments . SetValue ([ Activator ]:: CreateInstance ( $coarseSegmentType , @ ( 0 , $forward , 0 , 2 , $false , $true )), 0 )
$coarseSegments . SetValue ([ Activator ]:: CreateInstance ( $coarseSegmentType , @ ( 1 , $reverse , 3 , 4 , $true , $false )), 1 )
$vehicle = [ Activator ]:: CreateInstance ( $vehicleType )
$vehicle . LengthMeters = [ double ] 0.80
$vehicle . WidthMeters = [ double ] 0.60
$vehicle . SafetyMarginMeters = [ double ] 0.05
$vehicle . MaximumCurvaturePerMeter = [ double ] 0.80
$configuration = [ Activator ]:: CreateInstance ( $smoothingConfigurationType )
$smoothingRequest = [ Activator ]:: CreateInstance ( $smoothingRequestType , @ (
$coarsePath , $coarseSegments , ( New-EmptyGeometryMap ), $vehicle , $configuration ))
$prepareArguments = [ object[] ] @ ( $smoothingRequest , $null , $null )
Assert-True $prepareMethod . Invoke ( $preprocessor , $prepareArguments ) ( 'Preprocessor must accept legal forward/reverse topology. Reason=' + $prepareArguments [ 2 ])
$preparedPath = $prepareArguments [ 1 ]
Assert-Equal 2 $preparedPath . Segments . Count 'Preprocessor must preserve both direction segments.'
Assert-Near 0.0 $preparedPath . Segments [ 1 ]. Points [ 0 ]. ArcLength 0.0 'The reverse segment must restart local arc length at zero.'
Assert-True $preparedPath . Segments [ 1 ]. Points [ 0 ]. IsGearSwitchPoint 'The duplicate reverse gear-switch point must be retained.'
# Segments may meet only at a paired, coincident forward/reverse gear switch.
$illegalGearJump = New-DirectionSegment 1 $reverse @ (
( New-GeometryPoint 1.25 0.0 1.0 0.0 0.0 $true ),
( New-GeometryPoint 0.25 0.0 2.0 0.0 0.0 )) $true $false
Invoke-RejectedAnalysis @ ( $forwardBeforeSwitch , $illegalGearJump ) 'A gear-switch boundary whose poses differ must be rejected.'
$illegalNormalBoundary = New-DirectionSegment 1 $forward @ (
( New-GeometryPoint 2.0 0.0 0.0 0.0 0.0 ),
( New-GeometryPoint 3.0 0.0 1.0 0.0 0.0 )) $false $false
Invoke-RejectedAnalysis @ ( $straight , $illegalNormalBoundary ) 'A non-gear segment boundary must be rejected.'
# Finite endpoint coordinates can still overflow while computing their separation; reject before interpolation.
$resampler = [ Activator ]:: CreateInstance ( $resamplerType )
$resamplePointsMethod = $resamplerType . GetMethods () | Where-Object {
$_ . Name -eq 'TryResample' -and $_ . GetParameters (). Length -eq 4 -and
$_ . GetParameters ()[ 0 ]. ParameterType -eq [ System.Collections.Generic.IReadOnlyList``1 ]. MakeGenericType ( $pointType )
} | Select-Object -First 1
Assert-True ( $null -ne $resamplePointsMethod ) 'ArcLengthResampler must expose point-list TryResample.'
$hugePoints = [ Array ]:: CreateInstance ( $pointType , 2 )
$hugeCoordinate = [ double ]:: MaxValue / 2.0
$hugePoints . SetValue (( New-GeometryPoint (- $hugeCoordinate ) 0.0 0.0 0.0 0.0 ), 0 )
$hugePoints . SetValue (( New-GeometryPoint $hugeCoordinate 0.0 1.0 0.0 0.0 ), 1 )
$resampleArguments = [ object[] ] @ ( $hugePoints , [ double ] 0.05 , $null , $null )
Assert-False $resamplePointsMethod . Invoke ( $resampler , $resampleArguments ) 'Resampling must reject an infinite geometric distance caused by finite coordinates.'
# PreparedPath is an all-or-nothing immutable topology snapshot: null direction segments are invalid.
$null PreparedSegments = [ Array ]:: CreateInstance ( $segmentType , 1 )
Assert-Throws { [ Activator ]:: CreateInstance ( $preparedPathType , @ ( $null PreparedSegments )) } 'PreparedPath must reject null direction segments.'
# Segment indices are deliberately dense and equal to their position in the candidate array.
$sparseSegment = New-DirectionSegment 2 $forward @ (
( New-GeometryPoint 0.0 0.0 0.0 0.0 0.0 ),
( New-GeometryPoint 1.0 0.0 1.0 0.0 0.0 ))
Invoke-RejectedAnalysis @ ( $sparseSegment ) 'Prepared direction-segment indices must match their dense array position.'
Write-Output 'Path smoothing geometry checks passed.'