0


Qt 绘图基础

前言

    项目需要设计一个雷达P显窗口,可选的实现方案有画图和极坐标两种。经实验,在极坐标图中,会出现目标航迹从360°移动到361°时存在不显示(坐标为361°)或折线强制先连接原点再连接361°的位置(将坐标361°转换为1°)的问题,且暂未想到决方案(有大佬知道的求告知,谢谢!)。因此考虑换QPainter画雷达图、目标以及目标航迹。(相对于极坐标,QPainter不存在上述BUG,但是在涉及到缩放等处理时,不如极坐标方便,可见困难总是恒定的,我们可以通过转换角度来转移困难,然后在新的角度下寻找已有的、更合适的工具。)

(半)成品图:


    以下内容不求深入,只求能画最基本的内容。

参考内容:

[1] Qt官方文档

[2] 邓阿奇, Qt6开发及实(第5版)[M].北京:电子工业出版社,2022



一、画图基础

首先是QPainter画图基础,我们需要弄明白一下几个问题:

  1. 在哪画;
  2. 用什么画;
  3. 怎么画;
  4. 画在哪;
  5. 画什么;
  6. 什么时候画。

1.1 在哪画

    首先是第一个问题:在哪画。即:承载绘制图案的画布是什么?

    一句话:直接在QWidget上画。

    最基础的用来绘画的类是QPaintDevice,QWidget继承自QPaintDevice,在使用过程中,直接用一个QWidget控件作为画布即可。

1.2 用什么画

    绘画用的类是QPainter,无论画什么(包括文字),都是通过QPainter画出来,至于具体画什么,则由Qt提供的各种类来描述,详见1.5。

    (一个猜想:所有UI控件都是画出来的?)

   另外, 在QPainter和QPaintDevice之间其实还存在一个类:QPaintEngine。 官方文档对于QPaintEngine的定义:

The QPaintEngine class provides an abstract definition of how QPainter draws to a given device on a given platform.

QPainter is used to perform drawing operations, QPaintDevice is an abstraction of a two-dimensional space that can be painted on using a QPainter, and QPaintEngine provides the interface that the painter uses to draw onto different types of devices. The QPaintEngine class is used internally by QPainter and QPaintDevice, and is hidden from application programmers unless they create their own device type.

    可以理解成:QPainter是画在了QPaintEngine上,然后在QPaintDevice上显示出来。在初级实践中,可以忽略QPaintEngine的存在,只考虑QPainter和QPaintDevice。

1.3 怎么画

    有了画笔和画布,下一个问题就是:画笔是怎么在画布上画出图案的?

    一般通过重写QWidget的paintEvent()函数来画图:
protected:
    void paintEvent(QPaintEvent *event) override;
    绘制流程如下:
  1. 创建QPainter;
  2. 给QPainter指定一个PQaintEngine;
  3. 设置QPainter的参数(要画的内容);
  4. 画;
  5. 重复2、3或结束。

官方例程:

void SimpleExampleWidget::paintEvent(QPaintEvent *)
 {
     QPainter painter(this); // 创建QPainter,并指定一个QPaintEngine。可以用begin(QPaintEngine* )方法替代

     //QPainter painter(); // 这两行等价于上面的一行
     //painter.begin(this); // 指定painter的绘画对象

     painter.setPen(Qt::blue);// 设置参数
     painter.setFont(QFont("Arial", 30));// 设置参数
     painter.drawText(rect(), Qt::AlignCenter, "Qt"); // 画出来
 }
    paintEvent()会在widget显示时(这个显示时不一定等于show(),触发的准确时刻暂不讨论)自动触发一次。关于paintEvent()的其他触发条件,会在1.6中讨论。另外,需要注意的是,该函数每次被触发时,该函数绘制的旧的图案会被抹去,即重新画而非继续画。
void canvas::paintEvent(QPaintEvent *event)
{
    static int diff = 0;// 设置一个偏移量,每次触发时改变绘制位置

    QPainter mPainter(this);// 创建painter并指定paintEngine
    int widWidth = width();// 获取画布尺寸
    int widHeight = height();

    QPoint center;// 落笔位置
    center.setX(widWidth/2);// 设定落笔位置为画布中心
    center.setY(widHeight/2);

    qDebug()<<center<<"\n";

    mPainter.setPen(Qt::blue);// 设定画笔颜色
    mPainter.setFont(QFont("Arial", 50));// 设定画出的文字的字体及大小
    mPainter.drawText(QPointF(center.x() + diff, center.y()),"A");// 在位置center上draw a text "A"

    // 画两条线段,交叉点在画布的中心
    mPainter.drawLine(QLineF(QPointF(center.x() - 100, center.y()),QPointF(center.x() + 100, center.y())));
    mPainter.drawLine(QLineF(QPointF(center.x(), center.y() - 100),QPointF(center.x(), center.y() + 100)));

    diff++;
}
    上面这段代码在不断触发时,画布上会出现一个逐渐向左平移的A和两个一直在画布中心的垂直交叉的线段,而不是逐渐向左平移的一串A和数个重叠的线段。关于位置问题将在下一个小结讨论,这里只需要知道通过重写paintEvent()函数来绘图即可。

    另外,需要留意的是,第一个A的中心位置并不在画布中心(即使此时diff的值为0),这个问题在后面详细讨论绘画各种图案的细节时再说。

图1.1 写"A"时,A的中心并不是画布的中心

1.4 画在哪

    现在有了画布和画笔,接下来的问题就是:落笔的位置在哪里才能画出我想要的东西?

    很明显,我们需要给画笔提供一个坐标,指定其落笔的位置。所以,我们需要一系列函数来将图像的像素点(像素在QWidget坐标系上的坐标)映射到屏幕的像素点上(像素在屏幕坐标系的坐标)。

    那么,画图时落笔坐标对应的坐标系是什么?

    经测试,直接给QPainter一个画布的相对坐标就可以了,不需要额外计算相对父对象的位置,即:画图的坐标系是以画布左上角为原点的坐标系。

    设定一个坐标系需要三项内容:原点、轴和轴的单位长度(一个像素对应的长度)。根据上文,原点即为画布的左上角;X轴水平正方向向右,Y轴垂直正方向向下。轴的单位长度与画布的尺寸有关。当画布变化,若轴长不变,则单位像素对应的长度变化;若单位像素对应的长度不变,则轴长变化。在计算像素与长度之间的关系时,需要获取画布的尺寸。

    Qt提供了一些函数来获取一个QWidget的尺寸或者坐标:
  1. int x();

  2. int y();

  3. QPoint pos();

  4. QRect frameGeometry();

  5. QRect geometry();

  6. int width();

  7. int height();

  8. QRect rect();

  9. QSize size()。

     需要注意的是,虽然都能提供坐标/尺寸,但是这些函数描述的对象是不同的:
    

图1.2 获取尺寸和坐标的函数的区别示意图

    可以看出,frameGermetry()、frameGermetry().width()、frameGermetry().height()、pos()、x()和y()都是用来描述的**窗口**属性的;geometry()、geometry().x()、geometry().y()、width()和height()都是用来描述一个QWidget**部件**的。所以,需要在画布上的指定位置落笔时,将QWidget坐标(相对坐标)映射到屏幕坐标(绝对坐标)上时,应该用geometry()或者geometry().x()和geometry().y()获取相对量。

    所以,需要在画布上的指定位置落笔时,应该通过width()和height()方法获取画布的尺寸,然后计算落笔的坐标(以画布的左上角——geometry()为原点)。

    此外size()和rect()都是用来描述Client Area的属性的函数,其中,size()用来描述Client Area的长和宽,rect()与geometry()只在描述左上角的坐标时有区别:rect()返回的左上角坐标永远为(0,0),而geometry()返回的是**相对于父窗体**而言的坐标。

    对没错,**相对于父窗体**。上面所有的关于坐标的描述都是建立在这个QWidget是一个独立的窗口的前提下的。此时它的父窗体可以理解为整个桌面。当这个QWidget(w1)被嵌入到另一个QWidget(w2)里之后,w1的坐标就变成了相对于w2左上角的坐标了,如果我把w1设置成了一个主窗口的centerWidget,则以下函数永远返回(0,0):pos()、x()、y()、geometry().x()和geometry().y()(在不做其他操作的前提下)。

    *关于窗口的布局,我计划在整完画图基础之后专门研究一下。这里挖个坑等填。*

    重新看一下1.3的第二段代码,重点关注坐标计算部分:
void canvas::paintEvent(QPaintEvent *event)
{
    static int diff = 0;// 设置一个偏移量,每次触发时改变绘制位置

    QPainter mPainter(this);// 创建painter并指定paintEngine
    int widWidth = width();// 获取画布尺寸
    int widHeight = height();

    QPoint center;// 落笔位置
    center.setX(widWidth/2);// 设定落笔位置为画布中心
    center.setY(widHeight/2);

    qDebug()<<center<<"\n";

    mPainter.setPen(Qt::blue);// 设定画笔颜色
    mPainter.setFont(QFont("Arial", 50));// 设定画出的文字的字体及大小
    mPainter.drawText(QPointF(center.x() + diff, center.y()),"A");// 在位置center上draw a text "A"

    // 画两条线段,交叉点在画布的中心
    mPainter.drawLine(QLineF(QPointF(center.x() - 100, center.y()),QPointF(center.x() + 100, center.y())));
    mPainter.drawLine(QLineF(QPointF(center.x(), center.y() - 100),QPointF(center.x(), center.y() + 100)));

    diff++;
}
    绘图内容主要包括两个部分: 一个初始位置在画布中心的字母“A”和两个垂直相交于画布中心的线段。

    在计算画布中心时,通过width()和height()函数来获取画布尺寸,其中心即为长和宽的一半。此时,无论如何拖动、缩放窗口以改变画布的位置和大小,线段的交叉点均为画布中心。

1.5 画什么

    现在我们有了画笔QPainter、画布QPaintDevice和QPaintEngine以及计算落笔位置的函数。可以正式开始绘制我们想画的东西了。

    QPainter通过包括上面提及的drawText()和drawLine()在内的数个函数来绘制指定的内容,详见QPainter的成员函数,因成员太多,此处不全部列出。

    *再挖一个坑,后面单独详细讨论这些成员函数的功能、细节和彼此之间的区别。*

   这些成员函数几乎每个都有很多重构函数,来用不同的方式描述需要绘制内容的属性。这里主要讨论这些内容和一些基础绘图中需要注意的事项。

    首先是位置/区域属性 :

    描述绘制位置/区域的类/变量类型主要有:QRectF、QRect、int x & int y、QPointF、QPoint、QPolygonF、QPolygon、QLineF、QLine、QList<>、QPainterPath。其中*F和*的区别在于定义其位置/尺寸的参数是float还是int。下面只描述不带F的类。
  1. QPoint是一个点类,描述了一个指定位置(x(),y())的点。通常可以通过int x , int y来等价替换;
  2. QRect是一个矩形类,描述了一个指定位置(x(),y())和指定大小(width(),height())的矩形。其中,指定位置(x(),y())是指该矩形左上角的位置;
  3. QPolygon是一个 QList<QPoint>,描述了一个由一组点组成的多边形,每个点都有自己的坐标;
  4. QLine是一个线段类,描述了一个指定起点(x1(),y1())或p1()和终点(x2(),y2())或p2()的线段;
  5. QPainterPath是一个容器类,描述了数个连续的绘画操作,这些操作包括了所有基础的绘画操作。一个简单的示例如下:
void canvas::painterPathTest(int width,int height,QPainter *painter)
{
    QPainterPath painterPath,painterPath2,painterPath3;// 创建两个painterPath
    painterPath.addText(QPointF(width/4,height/2),QFont("Arial", 50),"B");// p1 add 一个字母B,字体参数同A
    painterPath2.addText(QPointF(3 * width/4,height/2),QFont("Arial", 50),"C");// p2 add 一个字母C,字体参数同A
    painterPath3.addRect(QRectF(width/2,height/2,width/10,height/10));// p3 add 一个左上角在画布中心,长宽均为画布1/10的矩形
    painterPath.connectPath(painterPath2);// 连接两个Path的末端

    painter->drawPath(painterPath);// 画出来
    painter->drawPath(painterPath2);
    painter->drawPath(painterPath3);
}

函数在paintEvent函数中调用,包含1.4中的代码,效果如图1.3所示:

图1.3 绘制效果

    可以看到,painterPath打印出的字母是镂空的,直接drawText的字母是实心的。可以理解为,painterPath只是提供了一个画笔移动的路径,并用不同的基础元素来描述这个路径。所以painterPath描述的字母就只有描边,没有填充。至于如何用painterPath画出图中字母“A”的效果,这里不讨论。

1.6 什么时候画

最后一个问题:paintEvent函数的触发时刻在什么时候。

    1) 启动的触发时刻

    这个属于Qt底层机制的探究,不在本文讨论范围内,我们只是简单看看:在paintEvent函数的第一行设一个断点,然后运行Debug模式:

图1.4 paintEvent自动触发的时刻

    2) 自动触发

    Qt官方文档中给出的描述:

This event handler can be reimplemented in a subclass to receive paint events passed in event.
A paint event is a request to repaint all or part of a widget. It can happen for one of the following reasons:

  • repaint() or update() was invoked,
  • the widget was obscured and has now been uncovered, or
  • many other reasons.
     首先是触发的两个函数:repaint() or update()。当这两个函数被触发后,就会自动触发paintEvent。我们可以通过手动执行repaint() or update()函数来间接触发paintEvent。   

    这两个函数的描述如下:

void QWidget::repaint()
Repaints the widget directly by calling paintEvent() immediately, unless updates are disabled or the widget is hidden.
We suggest only using repaint() if you need an immediate repaint, for example during animation. In almost all circumstances update() is better, as it permits Qt to optimize for speed and minimize flicker.

Warning: If you call repaint() in a function which may itself be called from paintEvent(), you may get infinite recursion. The update() function never causes recursion.

void QWidget::update()
Updates the widget unless updates are disabled or the widget is hidden.
This function does not cause an immediate repaint; instead it schedules a paint event for processing when Qt returns to the main event loop. This permits Qt to optimize for more speed and less flicker than a call to repaint() does.
Calling update() several times normally results in just one paintEvent() call.
Qt normally erases the widget's area before the paintEvent() call. If the Qt::WA_OpaquePaintEvent widget attribute is set, the widget is responsible for painting all its pixels with an opaque color

     根据文档描述,除非用于绘制动画,默认使用update()。

    然后是“the widget was obscured and has now been uncovered”。这里需要留意的是,这种情况可能触发意料之外的paintEvent事件触发,如果存在如1.4中代码那样的累加值,就可能会出现意料之外显示效果。

    3) 主动触发

    根据2)中描述的触发函数,我们只需要在需要绘图的时候调用一次update()函数即可。

1.7 总结

    综上所述,一个完整的画图流程如图1.5所示:

图1.5 绘图流程

  1. 所有绘制内容都写在paintEvent函数内;
  2. 所有内容通过QPainter绘制;
  3. 绘制之前需要设置好绘制内容的各项属性:画笔、笔刷、元素尺寸、元素形状、元素坐标;
  4. 设置完后调用绘图函数;
  5. 在合适的时候通过update()函数触发来实现绘制。

二、实战演练

2.1 需求分析

    所需雷达P显效果图如图2.1 所示:

图2.1 效果图

2.1.1 功能分解

    整个UI界面可以分为以下几个部分:
  1. 背景,包括三个半径为等差数列的同心圆、一横一竖两条垂直与圆心的线段、刻度和底色;

  2. 目标,包括不同颜色区分的目标点迹、用线段连接的目标航迹和鼠标移上去之后显示的目标信息。

      其中,背景部分需要实现的功能有:
    
  3. 坐标轴:绘制三个半径为等差数列的同心圆、一横一竖两条垂直与圆心的线段和刻度;

  4. 适应缩放:在改变窗口大小时,画面随窗口大小而改变,但不改变坐标轴;

  5. 主动缩放:手动改变坐标轴的单位刻度,画布大小不变;

  6. 画布尺寸不为正方向时,在以最短边为基准的正方形内画图。

     目标部分需要实现的功能有:
    
  7. 根据上传信息实时绘制、更新目标点迹图;

  8. 鼠标移到目标点上后,显示目标信息,并连接该目标的所有点,形成航迹;

  9. 不同的目标用不同的颜色绘制(目标数量太多时,允许重复)。

     下面进行功能分解及实现设计:
    
     2.1.1.1 自适应缩放、主动缩放与坐标映射
    
     自适应缩放应该实现的效果是:画布大小任意改变,绘制元素相对画布的位置不变。因此,在设计、计算元素位置时,均以百分比系数为单位。在绘制时获取画布的长宽后再乘以该系数,即为真实的坐标。
    
     主动缩放应该实现的效果是:通过外部接口(如鼠标滚轮)人为控制画布中单位像素所对应的长度。可以通过一个缩放系数来表示。
    
     以上两点均可融合成单位像素所对应的长度这一个系数,且由于雷达图要保证长:宽=1:1的比例,最终只需要一个系数值即可。
    
     最终的坐标计算流程如图2.2所示:
    

图2.2 坐标计算流程

    1) 获取实时的画布尺寸width、height,并取其中最小值;

    2) 获取缩放比例系数zoomCoe和系数为1时的对应长度iniLen;

    3) 计算单位长度 :

scale=iniLen*zoomCoe/min(width,height)

    4) 真实坐标映射到画布:

\\x_{v}=x_{r}/scale\\ y_{v}=y_{r}/scale\\

    2.1.1.2 坐标轴绘制

    首先是三个同心圆。考虑到应该尽可能占满画布,且半径为等差数列,因此将三个圆的半径设为:30%、60%、90%,初始刻度设置为0、40、80、120。当缩放比例系数变化时,改变刻度值。

    2.1.1.3 目标点绘制

    接收到坐标后,根据2.1.1.1中的计算方式,将目标坐标映射到画布上,然后画出来。目标颜色通过预设一组颜色并按顺序选取来区分。

    2.1.1.4 目标点信息

    鼠标移动到目标点后,显示目标点信息,并连接目标所有的点,形成航迹。该功能暂不做。

2.2 坐标轴绘制

    to be continue...
标签: qt ui

本文转载自: https://blog.csdn.net/wqwqwq604/article/details/126962685
版权归原作者 SkyRainW 所有, 如有侵权,请联系我们删除。

“Qt 绘图基础”的评论:

还没有评论