运用C#在VS2017的PictureBox控件中绘制简易二自由度机械臂,并且让机械臂实现画直线、圆、人物轮廓及写字的功能。

运用C#在VS2017的PictureBox控件中绘制简易二自由度机械臂,并且让机械臂实现画直线、圆、人物轮廓及写字的功能。

给大家看看效果吧

在这里插入图片描述
在这里插入图片描述
演示写字视频在下:

VID

首先放置了诸多控件

本来想详细标注的,但是手抖,qq截图就发出去了,也大致能看出各控件的功能了,还有一个定时器,用来实时画机械臂末端的轨迹。(其中机械臂的两臂的角度是只读的,实时显示角度数据,两臂长度可以设置。画直线、圆等肯定得有坐标数据)(特别提醒:坐标轴按画的那般,PictureBox1的中点为原点)

在给控件绑定事件前,先定义几个函数,需要反复调用。

绘制机械臂的函数

部分全局变量定义:
private int L1 = 100, L2 = 100; //表示机械臂的两臂长度
private double Ang1 = 0, Ang2 = 0; //表示机械臂的扭转角度,单位为弧度,取值范围为(-PI,PI)

#region 绘制机械臂
/// <summary>
/// 机械臂的底端位于坐标系原点
/// </summary>
public void DrawMechanism()
{
    //特别注意:我定义的坐标系为我们平常时常用的坐标系,
    //但电脑屏幕等运用的坐标系是:左上角为原点,水平向右为X正半轴,水平向下为Y正半轴
    //因此两个坐标系之间存在装换。 
    PointF pointO = new PointF(200F, 200F);//机械臂O点位置,位于坐标原点
    PointF pointA = new PointF((float)(200 + L1 * Math.Cos(Ang1)), (float)(200 - L1 * Math.Sin(Ang1)));//机械臂A点位置
    PointF pointB = new PointF((float)(200 + L1 * Math.Cos(Ang1) + L2 * Math.Cos(Ang1 + Ang2)), (float)(200 - L1 * Math.Sin(Ang1) - L2 * Math.Sin(Ang1 + Ang2)));//机械臂B点位置

    Bitmap bitmap = new Bitmap(pictureBox1.ClientSize.Width, pictureBox1.ClientSize.Height);//位图为PictureBox1的客户区尺寸
    Graphics g = Graphics.FromImage(bitmap);//定义图像,图像为抽象类,不可直接构造函数生成
    //绘制斜线时消除锯齿(鼠标放在那就可以看到函数功能)
    g.SmoothingMode = SmoothingMode.AntiAlias;
    g.SmoothingMode = SmoothingMode.HighQuality;
    Pen pen = new Pen(Color.Red, 1);//定义一只红色、1像素宽的笔
    g.DrawLine(pen, pointO, pointA);//绘制大臂(连接OA线段)
    g.DrawLine(pen, pointA, pointB);//绘制小臂(连接AB线段)
    //绘制转动副O
    g.DrawEllipse(pen, 200 - 5, 200 - 5, 10, 10);
    g.FillEllipse(new SolidBrush(Color.White), 200 - 5, 200 - 5, 10, 10);
    //绘制转动副A
    g.DrawEllipse(pen, (float)(200 + L1 * Math.Cos(Ang1)) - 5, (float)(200 - L1 * Math.Sin(Ang1)) - 5, 10, 10);
    g.FillEllipse(new SolidBrush(Color.White), (float)(200 + L1 * Math.Cos(Ang1)) - 5, (float)(200 - L1 * Math.Sin(Ang1)) - 5, 10, 10);//绘制转动副A

    pictureBox1.Image = bitmap;//所以Image每次都被更新了,只显示当前机械臂位置
    //释放资源
    g.Dispose();
    pen.Dispose();
}
#endregion

根据机械臂末端位置求解两臂旋转角度的函数

求解说明:见下图,对于一个末端点,机械臂存在两种姿态(除了边界点),因此存在选择哪种姿态的原则。
在这里插入图片描述
部分全局变量定义:
private double Ang1 = 0, Ang2 = 0; //表示机械臂的扭转角度,单位为弧度,取值范围为(-PI,PI)

#region 求解对应坐标下两臂转动角度
/// <summary>
/// 求解两臂转动角度,(反)三角函数都是弧度单位。
/// </summary>
/// <param name="X">目标点的Y坐标(对应我定义的坐标系)</param>
/// <param name="Y">目标点的Y坐标(对应我定义的坐标系)</param>
public void GetAngel(double X, double Y)
{
    double x = X;
    double y = Y;
    double r = Math.Sqrt(x * x + y * y);//目标点到原点的距离
    double theta = Math.Atan2(y, x);//取值范围为(-PI,PI)
    double phi = Math.PI - Math.Acos((L1 * L1 + L2 * L2 - r * r) / (2 * L1 * L2));
    double theta1 = Math.Acos((r * r + L1 * L1 - L2 * L2) / (2 * L1 * r));
    if (r == 0)
    {
        Ang2 = Math.PI;
    }
    //两个解:(theta+theta1,-phi)、(theta-theta1,phi)
    //选解采用最短准则:即对应的两臂角度相对于上一时刻的两臂角度需要变动的角度和更小的解
    else
    {
        if ((Math.Abs(theta + theta1 - Ang1) + Math.Abs(-phi - Ang2)) > (Math.Abs(theta - theta1 - Ang1) + Math.Abs(phi - Ang2)))
        {
            Ang1 = theta - theta1;
            Ang2 = phi;
        }
        else
        {
            Ang1 = theta + theta1;
            Ang2 = -phi;
        }
    }
    textBox3.Text = (Ang1 / Math.PI * 180).ToString();//将弧度换算成角度显示
    textBox4.Text = (Ang2 / Math.PI * 180).ToString();//
}
#endregion

网上随便找的延时函数

忘记是哪位老哥了,真的抱歉啊!

#region 延时函数

/// <summary>
 /// 延时函数,单位为毫秒
 /// </summary>
 /// <param name="delayTime"></param>
 public void DelayMs(int delayTime)
 {
     DateTime now = DateTime.Now;
     int s;
     do
     {
         TimeSpan spand = DateTime.Now - now;
         s = spand.Milliseconds;
         Application.DoEvents();
     }
     while (s < delayTime);
 }
 #endregion

下面开始给控件绑定事件

部分全局变量:
string graph;//用来标志画直线、画圆、画轮廓还是写字
PointF pointLast;//机械臂末端上一时刻位置
PointF pointNow;//机械臂末端现在位置

首先给定时器绑定事件

因为机械臂从当前位置移动到直线的起点、圆的起点、轮廓的起点和字的起点的过程中,机械臂末端移动轨迹并不需要绘制,故关闭Timer1
只有机械臂末端在画线等功能时开启Timer1进行绘制轨迹

private void timer1_Tick(object sender, EventArgs e)
{
    //机械臂现在末端位置
    pointNow = new PointF((float)(200 + L1 * Math.Cos(Ang1) + L2 * Math.Cos(Ang1 + Ang2)), (float)(200 - L1 * Math.Sin(Ang1) - L2 * Math.Sin(Ang1 + Ang2)));
    if (pointLast == new PointF(0, 0))
    {//可以试试没这个if语句会发生什么
        pointLast = pointNow;//如果是绘制曲线的起点,便没有pointLast,令其就等与pointNow
    }
    Bitmap bt = new Bitmap(pictureBox1.BackgroundImage);//保证了现在绘图是在以前绘图的基础上,不会丢失先前轨迹
    Graphics g = Graphics.FromImage(bt);
    //绘制斜线时消除锯齿
    g.SmoothingMode = SmoothingMode.AntiAlias;
    g.SmoothingMode = SmoothingMode.HighQuality;
    //将机械臂上一次末端位置及现在末端位置连起来,因为两个位置及近,就是用直线不断拟合轨迹
    g.DrawLine(new Pen(Color.Black, (float)0.4), pointLast, pointNow);
    //释放资源
    g.Dispose();
    //将包含新的轨迹的位图赋给PictureBox1的BackgroundImage
    pictureBox1.BackgroundImage = bt;
    pointLast = pointNow;//更新上一次机械臂末端位置,
}

开始是画直线

“画直线”按钮的点击事件,进行一些基本设置

private void button1_Click(object sender, EventArgs e)
{//“画直线”按钮的点击事件
    groupBox1.Visible = true;//坐标输入功能开启,用户输入起终点
    groupBox2.Visible = false;//写字输入功能关闭
    textBox4.Visible = true;
    label9.Text = "起点:";
    label10.Text = "终点:";
    graph = "Line";//标志其为画直线
}

画直线的函数:将直线细分成很多个点,机械臂末端依次到达这些点

#region 绘制两点之间的一条直线
 /// <summary>
 /// 绘制两点之间的一条直线。
 /// </summary>
 /// <param name="startx">起点的X坐标</param>
 /// <param name="starty">起点的Y坐标</param>
 /// <param name="endx">终点的x坐标</param>
 /// <param name="X">终点的Y坐标</param>
 /// <param name="bl">是否是绘图,绘图:true;移动:false</param>
 public void DrawLine(double startx, double starty, double endx, double endy, Boolean bl)
 {
     if (bl)//如果是绘图,就开启Timer1进行绘制轨迹
     {
         timer1.Enabled = true;
     }
     double x, y;//用来表示下一个机械臂末端位置
     double r = Math.Sqrt((startx - endx) * (startx - endx) + (starty - endy) * (starty - endy));//直线长度
     for (int i = 0; i < r / 0.05; i++)//对所画图形进行分段计数
     {
         x = startx + i / (r / 0.05) * (endx - startx);
         y = starty + i / (r / 0.05) * (endy - starty);
         GetAngel(x, y);//通过调用此函数,计算得到对应(x,y)点时的两臂角度,对全局变量Ang1和Ang2赋值
         DrawMechanism();//通过Ang1和Ang2的实时数据,更新机械臂位置,因为每次变化很小,肉眼上以为机械臂在转动
         DelayMs(4);//每个机械臂状态停留4ms,如果不停留,程序运行很快,基本基本看不到机械臂中间移动过程
     }
     timer1.Enabled = false;//无论是机械臂绘图还是单纯移动到目标点,都关闭定时器
 }
 #endregion

注:bl是用来标志是画图形还是前往图形绘制起点;false:前往图形绘制起点;true:画图形

然后是画圆

“画圆”按钮的点击事件,进行一些基本设置

private void button2_Click(object sender, EventArgs e)
{//“画圆”按钮的点击事件
     groupBox1.Visible = true;//坐标输入功能开启
     groupBox2.Visible = false;//写字输入功能关闭
     textBox4.Visible = false;
     label9.Text = "圆心:";
     label10.Text = "半径:";
     graph = "Circle";//标志其为画圆
}

画圆的函数:将圆细分成很多个点,机械臂末端依次到达这些点

#region 绘制一个圆
/// <summary>
/// 绘制给定圆心和半径的圆。
/// </summary>
/// <param name="startx">圆心的X坐标</param>
/// <param name="starty">圆心的Y坐标</param>
/// <param name="r">圆的半径</param>
public void DrawCircle(double startx, double starty, double r)
{
    timer1.Enabled = true;
    double x, y;
    double theta;//存储画圆时的弧度,取值范围(-PI,PI)
    double k = Math.PI * r / 0.05; //将圆的周长细分为2k个点
    for (int i = 0; i < 2 * k; i++)//从圆的最左点开始逆时针绘制,即从-PI到PI绘制
    {
        theta = (i - k) / k * Math.PI;//每个点对应的弧度
        //计算点的坐标
        x = startx + r * Math.Cos(theta);
        y = starty + r * Math.Sin(theta);
        GetAngel(x, y);
        DrawMechanism();
        DelayMs(10);
    }
    timer1.Enabled = false;
}
#endregion

接着画人物轮廓

部分全局变量:VectorOfVectorOfPoint contours = new VectorOfVectorOfPoint();//创建用于存储轮廓的VectorOfVectorOfPoint数据类型(命名空间Emgu.CV.Util)(contours里面存储的数据可以自己查,里面就是存储了很多段线段,由线段组成了轮廓,所以一定要知道contours存储的数据格式)

CvInvoke.FindContours函数的参数讲解参考了这篇老大哥:
OpenCV中的findContours函数参数讲解

“画轮廓”按钮的点击事件,提取图片轮廓

private void button3_Click(object sender, EventArgs e)
{
    IOutputArray hierarchy = null;//与contours对应的向量,hierarchy[i][0]~hierarchy[i][3]存储后、前、子、父级轮廓链表表头
    OpenFileDialog ofd = new OpenFileDialog();//创建一个对话框,选择需要画轮廓的图片
    ofd.Filter = "JPG图片|*.jpg|BMP图片|*.bmp";//选择文件的类型(filter:过滤)
    
    //处理图像,得到图像的轮廓信息!
    if (ofd.ShowDialog() == DialogResult.OK)
    {
        Mat _inputmat = new Mat(ofd.FileName);
        imageBox1.Image = _inputmat;//读入图像,在ImageBox中显示
        CvInvoke.GaussianBlur(_inputmat, _inputmat, new Size(3, 3), 3, 3);//对输入图像进行高斯滤波,并将滤波后的图像存至_inputmat
        Mat dst = new Mat();//存储图片轮廓信息
        CvInvoke.Canny(_inputmat, dst, 120, 180);//Canny 边缘检测算子
        imageBox2.Image = dst;//显示轮廓图片

        #region  CvInvoke.FindContours方法参数讲解
        ///<summary>
        ///IOutputArray contours:检测到的轮廓。通常使用VectorOfVectorOfPoint类型。
        ///IOutputArray hierarchy:可选的输出向量,包含图像的拓扑信息。不使用的时候可以用 null 填充。
        ///每个独立的轮廓(连通域)对应 4 个 hierarchy元素 hierarchy[i][0]~hierarchy[i][4]
        ///(i表示独立轮廓的序数)分别表示后一个轮廓、前一个轮廓、父轮廓、子轮廓的序数。

        ///RetrType mode标识符及其解析:
        ///External = 0 提取的最外层轮廓;
        ///List = 1 提取所有轮廓
        ///Ccomp = 2 检索所有轮廓并将它们组织成两级层次结构:水平是组件的外部边界,二级约束边界的洞。
        ///Tree = 3 提取所有的轮廓和建构完整的层次结构嵌套的轮廓。

        ///ChainApproxMethod表示轮廓的逼近方法
        ///ChainCode = 0 Freeman链码输出轮廓。所有其他方法输出多边形(顶点序列)。
        ///ChainApproxNone = 1 所有的点从链代码转化为点;
        ///ChainApproxSimple = 2 压缩水平、垂直和对角线部分,也就是说, 只剩下他们的终点;
        ///ChainApproxTc89L1 = 3 使用The - Chinl 链逼近算法的一个
        ///ChainApproxTc89Kcos = 4 使用The - Chinl 链逼近算法的一个
        ///LinkRuns = 5, 使用完全不同的轮廓检索算法通过链接的水平段的1s轨道。
        ///用这种方法只能使用列表检索模式。
        ///</summary>
        #endregion

        CvInvoke.FindContours(dst, contours, hierarchy, Emgu.CV.CvEnum.RetrType.External,
            Emgu.CV.CvEnum.ChainApproxMethod.ChainApproxSimple);
    }
    graph = "Graph";//标志是画人物轮廓
}

因为存储的轮廓都是线段表示,故只要调用DrawLine函数就行

最后是写字

写字关键是字库,我选择的是CAD软件定制的SHP字体格式。网上很容易找到CAD软件中的SHX文件经过反汇编后得到SHP文件,反汇编后的SHP文件和普通的SHP文件不同,它是Shape文件,它是可以直接用记事本打开的,它是使用特殊代码法编写的文件,我们可以直接解码,不是普通的SHP文件的解码方式,那是不对的。Shape文件的代码可以通过看这位老大哥的文章学会然后解码:shx文件格式说明形文件及其开发
我是将一个Shape文件(包含几千个字)里的每个字拿出来,单独建立一个记事本,然后根据这个字的GB2313编码给这个记事本命名。然后在根据机械臂要写的字的GB2313编码找到这个文件在读取解码出来。(主要就是利用字符串的split方法切片(split方法这位老哥讲解得很详细C#利用正则表达式实现字符串搜索),当然可能有更好的方法,我这方法较笨)

“写字”按钮的点击事件,进行基础设置

private void button4_Click(object sender, EventArgs e)
{
    groupBox1.Visible = false;
    groupBox2.Visible = true;
    graph = "Font";
}

最后就是开始按钮的事件绑定了

部分全局变量:
double startX; // 用来存储运动开始的位置
double startY;
double endX = 200;//用来存储运动结束的位置
double endY = 0;

“开始”按钮的点击事件:有点多,

private void button5_Click(object sender, EventArgs e)
{
    //读取设置的两臂长度
    L1 = Convert.ToInt16(textBox6.Text);
    L2 = Convert.ToInt16(textBox7.Text);

    //绘制图像为直线
    if (graph == "Line")
    {
        //将上一次的机械臂的末端位置(即现在机械臂末端位置)设置为起点位置
        startX = endX;
        startY = endY;
        //将绘制目标图像(即线段)的起点位置设置为终点位置
        endX = double.Parse(textBox1.Text);
        endY = double.Parse(textBox2.Text);
        //然后机械臂末端从起点沿直线运动到终点位置,并且这段移动是不需要绘制轨迹的,所以为false
        DrawLine(startX, startY, endX, endY, false);
        //将上一次的机械臂的末端位置(即上面说的目标图像的起点位置)设置为起点位置
        startX = endX;
        startY = endY;
        //将绘制目标图像的终点位置设置为终点位置
        endX = double.Parse(textBox3.Text);
        endY = double.Parse(textBox4.Text);
        //然后机械臂末端从起点沿直线运动到终点位置,这段移动是需要绘制轨迹的,所以为true
        DrawLine(startX, startY, endX, endY, true);
    }
    //绘制图像为圆
    if (graph == "Circle")
    {
        //将上一次的机械臂的末端位置(即现在机械臂末端位置)设置为起点位置
        startX = endX;
        startY = endY;
        //将绘制目标图像(即圆)的起点位置(即圆的最左点)设置为终点位置
        endX = double.Parse(textBox1.Text) - double.Parse(textBox3.Text);
        endY = double.Parse(textBox2.Text);
        //然后机械臂末端从起点沿直线运动到终点位置,并且这段移动是不需要绘制轨迹的,所以为false
        DrawLine(startX, startY, endX, endY, false);
        //最后开始画圆,这段移动是需要绘制轨迹的,所以为true
        DrawCircle(double.Parse(textBox1.Text), double.Parse(textBox2.Text), double.Parse(textBox3.Text));
    }
    //绘制图像为人像轮廓
    if (graph == "Graph")
    {//轮廓
        for (int i = 0; i < contours.Size; i++)
        {
            for (int j = 0; j < contours[i].Size - 1; j++)
            {
                //将上一次的机械臂的末端位置(即现在机械臂末端位置)设置为起点位置
                startX = endX;
                startY = endY;
                //将绘制目标图像(即轮廓的第i组的第j条线段)的起点位置(即圆的最左点)设置为终点位置
                endX = contours[i][j].X - 40;
                endY = -(contours[i][j].Y - 125);
                //然后机械臂末端从起点沿直线运动到终点位置,并且这段移动是不需要绘制轨迹的,所以为false
                DrawLine(startX, startY, endX, endY, false);
                //将上一次的机械臂的末端位置(即上面说的轮廓的第i组的第j条线段的起点位置)设置为起点位置
                startX = endX;
                startY = endY;
                //将机械臂的目标末端位置(即上面说的轮廓的第i组的第j条线段的终点位置)设置为起点位置
                endX = contours[i][j + 1].X - 40;
                endY = -(contours[i][j + 1].Y - 125);
                //然后绘制轮廓的第i组第j条线段
                DrawLine(startX, startY, endX, endY, true);
                pointLast = new PointF(0, 0);//每一次图像绘制成功后,将pointLast设置(0,0)(与定时器配合使用),自行体会作用,感觉讲不清
            }
        }
    }
    //绘制图像为汉字
    if (graph == "Font")
    {
        for (int j = 0; j < textBox5.Text.Length; j++)
        {
            char word = textBox5.Text[j];//取出第j个文字
            //将这个汉字转化为GB22313编码,并且加0构成文件名
            byte[] wordbytes = Encoding.GetEncoding("GB2312").GetBytes(new char[] { word });
            string filename = "0" + Convert.ToString((wordbytes[0] << 8) + wordbytes[1], 16);
            //从文件中读出数据
            StreamReader sr = new StreamReader("E:/下载软件/Visual Studio 2017/C#文件/Robotic Arm/汉字库/" + filename + ".txt");
            string wordtxt = sr.ReadToEnd();//将文字所有信息读入wordtxt变量
            string font = wordtxt.Split(new string[] { "7,-114,5,", "7,-113,0" }, StringSplitOptions.RemoveEmptyEntries)[1];//切取有用片段
            string[] fontarray = font.Split(new string[] { ",", "\r\n" }, StringSplitOptions.RemoveEmptyEntries);//将字符串转化为字符数组,去掉逗号空格这些
            int i = 0;
            //调整字的初始位置,即字的左下角坐标
            Point point = new Point(100 * j - 50, 0);
            //将上一次机械臂末端位置设置为起点位置
            startX = endX;
            startY = endY;
            //将字的左下角位置设置为机械臂末端目标位置
            endX = point.X;
            endY = point.Y;
            //使机械臂从当前位置沿直线移动到字的走下角位置,此过程不需要画轨迹,故为false
            DrawLine(startX, startY, endX, endY, false);
            //解码文字信息,结合Shape文件格式解码出想要的数据
            while (i < fontarray.Length)
            {
                if (fontarray[i] == "2")//说明为抬笔过程,就不用画轨迹,故为false
                {
                    startX = endX;
                    startY = endY;
                    endX = startX + Convert.ToInt16(fontarray[i + 2]);
                    endY = startY + Convert.ToInt16(fontarray[i + 3]);
                    DrawLine(startX, startY, endX, endY, false);
                    i = i + 4;
                }
                if (fontarray[i++] == "1")//说明为落笔过程,需要画轨迹,故为true
                {
                    for (; i < fontarray.Length && fontarray[i] != "2";)
                    {
                        if (fontarray[i] == "8")
                        {
                            startX = endX;
                            startY = endY;
                            endX = startX + Convert.ToInt16(fontarray[i + 1]);
                            endY = startY + Convert.ToInt16(fontarray[i + 2]);
                            DrawLine(startX, startY, endX, endY, true);
                            i = i + 3;
                        }
                        else if (fontarray[i] == "12")//因为用到圆弧的字较少,所以忽略
                        {

                        }
                    }
                    pointLast = new PointF(0, 0);//每一笔绘制成功后,将pointLast设置(0,0)(与定时器配合使用),自行体会作用
                }
            }
        }
    }
    pointLast = new PointF(0, 0);

}

基本就是这些了

本来想把一些函数写到另一个命名空间里,但是发现使用了太多全局变量,导致不太好弄,我也很头大。

Emgu中的坑

我下载的版本是4.2的,最新的,一下载完根本找不到网上说的啥bin文件夹,是需要运行EmguCV\Solution\Windows.Desktop文件夹下的Emgu.CV.Example.sln程序后会生成bin文件夹,并且bin文件夹里也没有网上说的啥Emgu.CV.World.dll和啥这文件,应该是版本更新问题,只要把bin文件夹里的那几个(我这是4个)Emgu开头的dll文件添加到项目的引用中(那几个像ImageBox工具也是通过添加其中一个就有了,忘了哪个了,自己试试),然后将EmaguCV文件夹下的bin文件夹下的X64文件夹里的所有文件复制到自己项目里的Debug文件夹下就行(可能不需要,但是复制了全部应该是没问题的,),然后就是右击项目选择属性,在“生成”中的平台目标改为X64,应该不会还有32位的电脑吧,不会吧。当然可以试试X86,可能电脑不一样。这些可以参照这个老大哥的视频EmguCV图像处理基础教程(-)

SHP文件的坑

一开始我以为shp文件就可以通过shp文件的格式解析,网上有关这的一大堆,但是实际CAD的shx文件反汇编后得到shp文件格式不一样,是可以用记事本打开看的,
第一张CAD的SHP文件打开后的样子,第二张是普通SHP文件打开的样子
在这里插入图片描述
在这里插入图片描述
什么差别不需要多说了吧

最后

如果Emgu安装有问题或者SHP文件找不到啥的,可以留言,但是俺不一定看,也可以加我qq(2897035088),想要源代码的就别来了,代码都贴出来了,也都给注释了。那个将一个SHP文件切片函数没发,这个我一不小心删了,也懒得写了,自己看看吧,也挺简单的,就是格式别错。
并且没什么写文章的经验,有问题欢迎指出,有想法欢迎交流,特别是有改进的意见请老大哥一定要指导一下小弟,万分感激。

  • 8
    点赞
  • 39
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值