
🫧 励志不掉头发的内向程序员 :个人主页
✨️ 个人专栏: 《C++语言》《Linux学习》
🌅偶尔悲伤,偶尔被幸福所完善
👓️博主简介:

文章目录
- 前言
- 一、给实体安个家:Document
-
- [1.1、为什么不该让 Canvas 存](#1.1、为什么不该让 Canvas 存)
- [1.2、Document:一个不碰 Qt 的实体容器](#1.2、Document:一个不碰 Qt 的实体容器)
- [1.3、谁来持有 Document](#1.3、谁来持有 Document)
- [1.4、画完的线进 Document,画布退化成显示器](#1.4、画完的线进 Document,画布退化成显示器)
- 二、把转换公式写成一对互逆函数
-
- 2.1、上一篇手写了四遍的那个公式
- 2.2、为什么这一对函数必须住在一起
- [2.3、`static_cast<int>` 换成 `qRound`](#2.3、
static_cast<int>换成qRound)
- 三、拆掉定时炸弹:让绘制函数只读
- [四、Esc 和右键:把"取消"做成一个动作](#四、Esc 和右键:把"取消"做成一个动作)
- [五、真正的坑:写了 keyPressEvent,按 Esc 没反应](#五、真正的坑:写了 keyPressEvent,按 Esc 没反应)
-
- 5.1、为什么画线一直好使,键盘却不行
- [5.2、三种 focus policy](#5.2、三种 focus policy)
- [六、顺手补上:浮点数不能用 `==` 比](#六、顺手补上:浮点数不能用
==比) - 总结
前言
上一篇结束时,MiniCAD 能画一条线:点两下,出现一条蓝线,再点就没反应了。我在结尾列了三个坑,还说下一篇讲状态机。但真动手的时候,发现还有一件更急的事:画完的线没地方放。 Canvas 里只有一个 Line currentLine 成员,它是"正在画的那条线"的临时存储,画完就占住了。这不是"状态机"能解决的问题,是数据该放在哪里的问题。所以这一篇的实际顺序是:先给实体安个家,再把取消操作补上,最后拆掉上一篇留下的那颗定时炸弹。
状态机往后放一放,等有了三种工具、一个 bool 真的撑不住了再换------那时候换,才换得明白。 这一篇要解决四件事:
- 多条线有地方存 →
Document - 屏幕 ↔ 图纸的转换公式只写一遍 → 一对互逆函数
- 绘制函数不再改状态 →
updateViewTransform - 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,为中键平移和滚轮缩放做准备。 到那时候你才会真正体会到这一篇做的两件事为什么值得------因为"平移"的本质就是"改视图参数",而它能不能改得干净,取决于今天有没有把口子留出来:转换函数收在一对,绘制路径只读,状态只在一处改。
🎇坚持到这里已经很厉害啦,辛苦啦🎇 ʕ • ᴥ • ʔ づ♡ど
