【从零写一个CAD 02】画完第一条线之后:实体容器、Esc 取消,和按了没反应的键盘

🫧 励志不掉头发的内向程序员 :个人主页
✨️ 个人专栏: 《C++语言》《Linux学习》

🌅偶尔悲伤,偶尔被幸福所完善


👓️博主简介:

文章目录


前言

上一篇结束时,MiniCAD 能画一条线:点两下,出现一条蓝线,再点就没反应了。我在结尾列了三个坑,还说下一篇讲状态机。但真动手的时候,发现还有一件更急的事:画完的线没地方放。 Canvas 里只有一个 Line currentLine 成员,它是"正在画的那条线"的临时存储,画完就占住了。这不是"状态机"能解决的问题,是数据该放在哪里的问题。所以这一篇的实际顺序是:先给实体安个家,再把取消操作补上,最后拆掉上一篇留下的那颗定时炸弹。

状态机往后放一放,等有了三种工具、一个 bool 真的撑不住了再换------那时候换,才换得明白。 这一篇要解决四件事:

  1. 多条线有地方存 → Document
  2. 屏幕 ↔ 图纸的转换公式只写一遍 → 一对互逆函数
  3. 绘制函数不再改状态 → updateViewTransform
  4. Esc / 右键能取消,以及为什么你写好了 keyPressEvent 却按什么都没反应

一、给实体安个家:Document

1.1、为什么不该让 Canvas 存

先看第一版的问题代码:

cpp 复制代码
// GUI/canvas.h(第一版)
Line currentLine;
bool hasLine;

我在 paintEvent 里判断 if (hasLine) { 画 currentLine; }------画布自己在存数据。

这在"只能画一条线"的需求下完全能跑,但它有三个代价:

一是没法扩展 。想画第二条,就得再加一个 Line、再加一个 bool------每多一条线就多一组变量,这是最荒谬的写法。

二是没法测试。数据(有哪些线)和视图(画布多大、原点在哪、缩放多少)混在同一个类里,你甚至没法脱离界面验证"平移之后线的坐标算得对不对"。

三是以后没法交差 。等要做保存文件,你得去问画布"你都存了哪些线?"------画布不该知道这个。 画布该知道的是"这个点画在屏幕哪里",不是"文档里有什么"。

所以第一步:把数据搬出去。

1.2、Document:一个不碰 Qt 的实体容器

cpp 复制代码
// Core/document.h
#ifndef DOCUMENT_H
#define DOCUMENT_H

#include <vector>
#include "line.h"

class Document
{
public:
    Document();

    bool addLine(const Line& nLine);
    bool removeLine(const Line& rLine);

    const std::vector<Line>& getLines() const;

private:
    std::vector<Line> lines;
};

#endif // DOCUMENT_H

注意三件事。

第一,它在 Core/ 里,一个 Qt 头文件都没有。 上一篇立的规矩在这里第一次见效------Document 只认 Line 和 Point。你想验证"零长度的线会不会被拒绝",写个几行的 main.cpp 就能跑,不用起界面、不用点鼠标。

第二,getLines() 返回的是 const 引用。 这不是为了好看,是为了断掉"外面偷偷往里塞线"的路 。想加线?调用 addLine()。想删线?调用 removeLine()。所有对数据的修改都必须经过 Document 自己------这样以后要在"加线"的时候顺便记一条撤销记录,只需要改一个地方。(这个伏笔在讲撤销重做那一篇会收。)

第三,addLine 返回 bool,因为它会拒绝。

cpp 复制代码
// Core/document.cpp
bool Document::addLine(const Line& nLine)
{
    if(nLine.startPoint == nLine.endPoint) {
        return false;          // 起点终点重合,这条线没有意义
    }
    lines.push_back(nLine);
    return true;
}

有人会问:为什么不在界面上拦?

因为界面不是唯一的入口。 以后会有文件加载、有复制粘贴、有脚本生成图元,它们都要经过 addLine。把校验放在数据层,就是只写一遍,所有入口都受保护 。反过来,如果你在鼠标点击那里加一句 if (起点 != 终点),那么"从文件里读一条零长度的线"这件事就漏网了------而它可能是别人手改文件改坏的。

1.3、谁来持有 Document

数据搬出去了,得有人拿着它。放在 MainWindow 里:

cpp 复制代码
// GUI/mainwindow.h
class MainWindow : public QMainWindow
{
    // ...
private:
    Ui::MainWindow *ui;
    Document document;          // 值成员
    Canvas *canvas;
};
cpp 复制代码
// GUI/mainwindow.cpp
MainWindow::MainWindow(QWidget *parent)
    : QMainWindow(parent)
    , ui(new Ui::MainWindow)
    , canvas(new Canvas(&document, this))
{
    ui->setupUi(this);
    setCentralWidget(canvas);
    setWindowTitle("MiniCAD");
}

MainWindow::~MainWindow()
{
    delete ui;
    // 注意:这里不再 delete canvas
}

三个细节,都是踩过的。

细节一:document 是值成员,不是指针。 它跟 MainWindow 生命周期完全一致:窗口活着它就在,窗口死了它自动析构。换成 new Document 你就得操心谁 delete、什么时候 delete。不必要的手动内存管理,都是 bug 的温床。

细节二:canvas 反而不手动 delete 了。 构造时传了 this 作 parent,Qt 的父子对象机制会在 MainWindow 析构时自动删掉它。第一版我写的是 delete canvas;,这一版删掉了------同一个对象被两套机制管着,就是"二次释放"的种子 。Qt 里的口诀很简单:给了 parent,就别自己 delete。

细节三:document 必须声明在 canvas 前面。 因为 Canvas 构造要拿 &document,document 得先存在。C++ 的规则是成员按声明顺序初始化,跟初始化列表里写的顺序无关 ------写反了,编译器通常只给一条 -Wreorder 警告,然后你得到一个"看起来赋值了、其实没赋值"的成员。这类问题的典型症状是:某个成员偶尔是随机值,Debug 版跑得好、Release 版崩。

顺带说一句:delete ui; 反过来是必须留的。ui 是手工 new 出来的普通结构(Ui::MainWindow 根本不是 QObject),Qt 完全不认识它。判断标准不是"在不在 Qt 里",而是"它有没有 parent"------有 parent 的交给 Qt,没有的自己收。

1.4、画完的线进 Document,画布退化成显示器

mouseReleaseEvent 现在这样写:

cpp 复制代码
void Canvas::mouseReleaseEvent(QMouseEvent *event)
{
    if(event->button() == Qt::RightButton) {
        cancelCurrentTool();
        return;
    } else if(event->button() == Qt::LeftButton) {
        QPoint screenPosition = event->pos();
        Point clickedPoint = screenToCAD(screenPosition);
        if(!hasFirstPoint) {
            firstPoint = clickedPoint;
            hasFirstPoint = true;
        } else {
            doc->addLine(Line(firstPoint, clickedPoint));
            firstPoint = clickedPoint;     // ← 起点前移
        }
    } else {
        return;
    }
    update();
}

那一行 firstPoint = clickedPoint; 值得单独说一句:画完之后不把起点清空,而是把终点接上。

这就是 CAD 里最常用的"连续画线"------画完一条,下一段从当前点接着画,想结束就按 Esc 或点右键。如果这里写成 hasFirstPoint = false;,那你画完一条就得重新点两下才能画下一条,画一条折线会点到手酸。

对应的绘制代码变成遍历:

cpp 复制代码
// GUI/canvas.cpp(paintEvent 节选)
painter.setPen(QPen(Qt::blue, 2));
for(const auto& line : doc->getLines()) {
    QPoint startScreen = cadToScreen(line.startPoint);
    QPoint endScreen = cadToScreen(line.endPoint);
    painter.drawLine(startScreen, endScreen);
}

从"画一个成员变量"变成"画一个集合",加第二十条线不需要改一行代码。这就是把数据搬出去换来的东西。


二、把转换公式写成一对互逆函数

2.1、上一篇手写了四遍的那个公式

上一篇我坦白过:originX + x * scale 这个式子,在 paintEvent 里手写了四遍------画已完成的那条一遍,画虚线预览又一遍,横竖各算两个端点。

这一篇把它收掉,写成一对函数:

cpp 复制代码
// GUI/canvas.h
Point screenToCAD(const QPoint& screen) const {
    return Point((screen.x() - originX) / scale,
                 (originY - screen.y()) / scale);
}

QPoint cadToScreen(const Point& cad) const {
    return QPoint(qRound(originX + cad.x * scale),
                  qRound(originY - cad.y * scale));
}

2.2、为什么这一对函数必须住在一起

因为它们互为逆运算 :screenToCAD(cadToScreen(p)) 应该等于 p。你拿图纸上的一个点,换算到屏幕,再换算回来,必须还是原来那个点。

这意味着它们必须同时改 。你要是哪天给 X 方向加了个 0.5 的偏移,只改了 cadToScreen,那"点哪画哪"立刻就崩------点下去位置是对的,画出来的线偏了半格。把两个函数上下摆在同一个头文件里,就是让人一眼看见"这俩是一对"。

顺便说它们为什么都写成 const:因为只读 originX / originY / scale,不改任何东西。const 在这里不只是好看,它是一句承诺------承诺"坐标转换不会顺手改你说的那些状态"。(这个承诺在这一篇第三节会变成一条正式的规矩。)

2.3、static_cast<int> 换成 qRound

cpp 复制代码
// 旧写法
QPoint(static_cast<int>(originX + cad.x * scale), ...)

// 现在
QPoint(qRound(originX + cad.x * scale), ...)

static_cast<int> 是向零截断,跟四舍五入不是一回事:

真实值 static_cast<int> qRound
3.7 3 4
-3.7 -3 -4
3.2 3 3
-3.2 -3 -3

看第二行:-3.7 截断成 -3,而四舍五入是 -4。原点左侧的点和原点右侧的点,误差方向是相反的------一个往回收,一个往外放。结果就是跨过原点那一带会有一圈像素级的错位。

单个看是半个像素,谁在乎?但 CAD 是个每一步都在做坐标转换的东西:用户点一下 → 屏幕转图纸 → 存进文档 → 画的时候图纸转屏幕。误差不会自己消失,只会一层层叠上去,最后表现成"我明明点的就是端点,画出来怎么差一点"。

在"能跑"和"对"之间,坐标转换这种基础运算永远选"对"。


三、拆掉定时炸弹:让绘制函数只读

上一篇我指认过一个定时炸弹:

cpp 复制代码
// 第一版 paintEvent 里的头两行
originX = width() / 2.0;
originY = height() / 2.0;

绘制函数在改状态。

放在 paintEvent 里,现在跑起来完全没问题------窗口不变的时候它算出来永远是同一个数,写哪儿都一样。但等加了中键拖动平移,originX / originY 就变成用户拖出来的可变量了,那时候如果还留在这里赋值,等于每画一帧就把用户辛苦拖出来的位置清一次。症状会非常迷惑:鼠标一松、窗口一重绘,图纸就"啪"地弹回去。

这一版把它拎出来,单独成一个函数:

cpp 复制代码
void Canvas::updateViewTransform()
{
    originX = width() / 2.0;
    originY = height() / 2.0;
}

然后只在两个地方调用:构造的时候 、尺寸变化的时候。

cpp 复制代码
Canvas::Canvas(Document *doc, QWidget *parent)
    : QWidget(parent)
    // ...
{
    setMouseTracking(true);
    setMinimumSize(400, 300);
    updateViewTransform();          // ← 这里
    setFocusPolicy(Qt::StrongFocus);
}

void Canvas::resizeEvent(QResizeEvent *event)
{
    QWidget::resizeEvent(event);    // 先交给基类
    updateViewTransform();          // 再更新自己的视图参数
}

paintEvent 里那两行删掉了。现在不管重绘多少次,视图参数一个字节都不会变。

这里有个值得记下来的原则:绘制函数只读状态,不改状态。

它的理由和"重绘次数应该只影响性能、不影响画面"是一体的------如果画面会因为"多画了一次"而变化,那这个软件就是不可信的。反过来说,这也是一个很好的自查手段:如果你遇到"窗口被遮住再露出来,图变了"这种诡异现象,那几乎一定是有赋值藏在了绘制路径里。

顺便预告一个还没收干净的尾:这一版 resizeEvent 的处理仍然很粗暴------窗口一变,原点就回到新窗口的中心。这在"没有平移"的时候无所谓,可一旦用户把图纸拖到角落,再拉一下窗口,图纸就跳回中间了。更合理的做法是"记住窗口中心对应的那个图纸点,尺寸变完让它还落在中心",那是讲视图变换的那一篇要收的尾。这一版先把"谁改状态"这件事分开,别混在一起改。


四、Esc 和右键:把"取消"做成一个动作

现在画线是连续的:画完一条,起点前移,继续等下一条。那怎么结束? 总得给用户一个退出的方式。

cpp 复制代码
void Canvas::cancelCurrentTool()
{
    hasFirstPoint = false;
    firstPoint = Point(0.0, 0.0);
    update();                  // 状态变了要重绘:虚线预览该消失了
}

然后两个入口都指向它:

cpp 复制代码
// 鼠标右键松开
if(event->button() == Qt::RightButton) {
    cancelCurrentTool();
    return;
}

// 键盘
void Canvas::keyPressEvent(QKeyEvent *event)
{
    if (event->key() == Qt::Key_Escape) {
        cancelCurrentTool();
        return;                      // 处理完就返回
    }
    QWidget::keyPressEvent(event);   // 没处理的交回基类
}

三个细节。

细节一:右键和 Esc 走同一个函数。 两者对用户来说是同一个意思------"我不画了"。既然是一个意思,就不该有两份"清状态"的代码:两份代码意味着以后加一个状态(比如"正在输入半径"),你会漏改其中一个,然后出现"按 Esc 好使、右键不好使"这种报告。

细节二:右键在 CAD 里就是取消。 AutoCAD、LibreCAD 这些软件几十年养出来的肌肉记忆:左键确定,右键取消/结束。这属于免费的用户体验------你不用写一句提示,用惯 CAD 的人自己就会去点右键。同类还有:空格和回车在 CAD 里常常等于"确认"。

细节三:return 不是可选的。 处理了 Esc 就 return,别继续往下走;没处理的事件交给 QWidget::keyPressEvent(event),让基类有机会响应。"要么处理,要么完全不碰"------写 Qt 事件处理函数时守住这条,能省掉很多"某个键莫名其妙不灵了"的排查时间。


五、真正的坑:写了 keyPressEvent,按 Esc 没反应

这一节是这篇最值钱的部分。

上面那几行写完,编译过了,运行,按 Esc------一点反应都没有。

原因不在 keyPressEvent 里,在构造函数少了一行:

cpp 复制代码
setFocusPolicy(Qt::StrongFocus);      // ← 少了这行,键盘事件根本送不到这个控件

Qt 的键盘事件只发给"有焦点"的控件。

这是 Qt 事件分发机制决定的:你在键盘上敲一下,操作系统只告诉窗口"有人按了 Esc",窗口自己得决定"这封信该给谁" ------给那个当前持有焦点的控件。没有焦点的控件,keyPressEvent 写得多漂亮都不会被调用。

而 QWidget 默认的 focus policy 是 Qt::NoFocus,也就是**"我不参与焦点竞争"**。所以画布收不到任何按键,keyPressEvent 形同虚设。

5.1、为什么画线一直好使,键盘却不行

因为鼠标事件和键盘事件的分发规则不一样:

  • 鼠标事件按位置分发:点在哪个控件上,就给哪个控件,跟焦点没关系
  • 键盘事件按焦点分发:键盘只有"按键",没有位置,只能送给持有焦点的那个控件

所以第一版画线一直好用------鼠标点画布,画布就收到事件。而这个坑特别爱在"第一次写键盘交互"的时候冒出来 ,症状还很迷惑:代码明明写对了,就是没反应,你会把 event->key() == Qt::Key_Escape 的拼写反复检查五遍。

5.2、三种 focus policy

值 含义 什么时候用
Qt::NoFocus 不接受焦点(QWidget 的默认值) 纯展示的标签、分隔线
Qt::ClickFocus 只有点击时才拿到焦点 "点一下才能用键盘"的次要控件
Qt::StrongFocus 点击、Tab 都能拿到焦点 画布、输入框这类靠键盘操作的核心控件

我给画布选的是 StrongFocus:点一下能拿到焦点,Tab 到它也能拿到。

这里有个可以自查的办法:运行时按几次 Tab,看焦点框在各个控件之间跳的时候会不会跳过你的画布。会跳过,就是没设置。

更直接的办法是在 keyPressEvent 第一行临时加一句 qDebug() << "key:" << event->key();------打不出来,就说明事件根本没送到,不用再往下查判断逻辑了。

"先确认事件到了没有,再查处理逻辑",这个习惯能省掉大量时间。它适用于所有 GUI 框架里"我明明写对了却没反应"的场景。


六、顺手补上:浮点数不能用 == 比

addLine 里有一句 nLine.startPoint == nLine.endPoint,removeLine 里也要靠 == 找同一条线。所以这一步得先把 Point 的比较写对。

cpp 复制代码
// Core/point.h
constexpr double kPointEpsilon = 1e-9;

// Core/point.cpp
double Point::distanceTo(const Point& other) const
{
    double lenX = x - other.x;
    double lenY = y - other.y;
    return std::sqrt(lenX * lenX + lenY * lenY);
}

bool Point::operator==(const Point& other) const
{
    return distanceTo(other) < kPointEpsilon;      // 带容差的相等
}

为什么不能直接写 a.x == b.x && a.y == b.y?

因为 double 是二进制近似表示的,0.1 存进去就不是精确的 0.1。两条线在数学上共用同一个端点,只要它们中间经过一次"屏幕坐标 → 图纸坐标"的来回换算,尾数就可能差那么一点点。用 == 比,结论是"不相等",于是 removeLine 会告诉你"没找到这条线"------看起来像逻辑写错了,实际是浮点误差。

用距离小于一个极小值 判相等,是这个问题的标准解法。这也是 CAD 里所有几何判断的通用套路:只要涉及 double,就要问一句"容差是多少"。

顺带把 Line 的比较也写上:

cpp 复制代码
bool Line::operator==(const Line& other) const
{
    return (startPoint == other.startPoint && endPoint == other.endPoint) ||
           (startPoint == other.endPoint   && endPoint == other.startPoint);
}

后面那个条件是我特意加的:线段没有方向。 从 A 到 B 的线和从 B 到 A 的线是同一条线,removeLine 不该因为它们方向相反就找不到。

(distanceTo 这个函数以后还要反复用:点到线段的距离、包围盒、命中测试都会拿它当基础。这就是"Core 不碰 Qt"的第二笔回报------它搬到哪都能跑,也能脱离界面单独写测试。)

项目主页(后续更新都在这里):

https://gitee.com/studyingart/mini-cad


总结

现在跑起来是什么样:

  • 点两下画一条线,画完接着画下一条,想结束按 Esc 或点右键
  • 起点之后仍然有灰色虚线预览
  • 左上角显示鼠标的图纸坐标

"只能画一条线"这个坑填掉了,"绘制函数改状态"这颗炸弹也拆掉了。 还剩两个:

一是视图参数还是散的。 originX / originY / scale 三个变量直接摊在 Canvas 里,resizeEvent 又粗暴地把原点拉回窗口中心。这一坨东西该有自己的名字,也该有自己的职责------下一篇就把它抽成 View。

二是交互状态还是一个 bool 撑天下。 现在只有一个工具,hasFirstPoint 够用;等画圆、画矩形、选择工具进来,"是否点过第一个点"这种描述就彻底不够用了------那时候我们把 bool 换成状态枚举。

所以下一篇:把视图参数打包成 View,为中键平移和滚轮缩放做准备。 到那时候你才会真正体会到这一篇做的两件事为什么值得------因为"平移"的本质就是"改视图参数",而它能不能改得干净,取决于今天有没有把口子留出来:转换函数收在一对,绘制路径只读,状态只在一处改。


🎇坚持到这里已经很厉害啦,辛苦啦🎇 ʕ • ᴥ • ʔ づ♡ど

相关推荐
l1t1 小时前
编译测试兼容gzip并行压缩解压工具pigz
c++·算法
摇滚侠1 小时前
《On Java 中文版 基础卷》阅读笔记 安装 Java 和本书示例 02
java·开发语言·笔记
无名猿1 小时前
对象存储期全解:automatic / static / dynamic / thread
c++·内存管理·现代c++·语法基础
FfHUCisI2 小时前
sync.Once 与 sync.Cond 源码与并发控制陷阱
服务器·开发语言·后端·golang
阿钱真强道2 小时前
21 嵌入式操作系统 | JSON 与 json-c:生成与解析
c语言·开发语言·json·json-c
j7~2 小时前
【Python】(篇六)《基础语法(函数、列表和元组、字典、文件)》---详解
开发语言·python·文件·函数·字典·编程学习·元组与列表
今天要早睡_2 小时前
C++入门到精通:类和对象(下)全方位解析
android·c++
艾莉丝努力练剑2 小时前
【AI大模型接入SDK】ChatSDK架构设计与实现
网络·c++·人工智能·学习·语言模型
重生之小比特2 小时前
【C++进阶】红黑树的实现
java·开发语言·c++