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

文章目录
- 前言
- 一、先看看这几个数现在住在哪
- 二、这三个数的问题不是"多",是"必须一起改"
- 三、View:一组数加一对互逆函数
- [四、Canvas 瘦身](#四、Canvas 瘦身)
- [五、View 放哪儿,谁来持有](#五、View 放哪儿,谁来持有)
- 六、这一版留下的三个尾巴
-
- [尾巴一:画坐标轴绕过了 View](#尾巴一:画坐标轴绕过了 View)
- [尾巴二:两个 getter 没有 const](#尾巴二:两个 getter 没有 const)
- [尾巴三:resizeEvent 还是会把视图拉回窗口中心](#尾巴三:resizeEvent 还是会把视图拉回窗口中心)
- 总结
前言
上一篇结尾我说,下一步要把 originX / originY / scale 打包成 View。这篇就干这件事。先回应一个可能的疑问:就三个 double,值得单独建一个类、再加两个文件吗?
值得。但理由不是"三个数太多了",而是这三个数是一组,而且必须一起被改 。这句话现在看着像废话,等做到"以鼠标为中心滚轮缩放"的时候你会庆幸------那时候你必须同时改 scale 和 offset,少改一个,图就会跳。这篇我们来讲三件事:这三个数住错了哪儿、View 长什么样(一共三十行)、以及抽出来之后暴露的三个尾巴。

一、先看看这几个数现在住在哪
上一篇之后,Canvas 的私有成员是这样:
cpp
// GUI/canvas.h(这一篇之前)
private:
void cancelCurrentTool();
void updateViewTransform();
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));
}
Document *doc;
Point mousePosition; // 当前鼠标的图纸坐标
Point firstPoint; // 已经点击的第一个点
bool hasFirstPoint; // 有没有点过第一个点
double originX; // 图纸原点在屏幕上的位置
double originY;
double scale; // 缩放比例
看最后三行。它们和"鼠标点在哪""画到一半没有"这些交互状态并排躺在一个类里,但这两种东西的性质完全不一样:
交互状态(mousePosition / firstPoint / hasFirstPoint) |
视图参数(originX / originY / scale) |
|
|---|---|---|
| 描述的是 | 用户正在干什么 | 用户正在看什么 |
| 有效期 | 一次操作内,画完就该清 | 整个程序运行期间一直有效 |
| 谁会改它 | 鼠标事件 | 平移、缩放、窗口尺寸变化 |
| 打开另一张图 | 该清空 | 该保留 |
第一种是"操作的临时记忆",第二种是"你在看图纸的哪一块"。 混在同一个类里,改任何一个都要先把另一个读一遍。
二、这三个数的问题不是"多",是"必须一起改"
打开 resizeEvent 看一眼就明白了:
cpp
void Canvas::resizeEvent(QResizeEvent *event)
{
QWidget::resizeEvent(event);
updateViewTransform(); // 里面就两行:把原点摆到新的窗口中心
}
updateViewTransform() 做的事,是同时 改 originX 和 originY。再看下一篇要做的平移:鼠标每移动一个像素,originX 和 originY 也要一起变。再看再下一篇的滚轮缩放:
cpp
// 以鼠标为中心缩放(还没写,但形状已经确定了)
scale *= factor;
// 必须紧接着调整 offset,否则缩放会以窗口左上角为中心,而不是鼠标位置
每一处修改都是"这几个数一起动"。 这就是我说的不变式:offsetX / offsetY / scale 不是三个独立的数,而是描述同一个变换的三个参数 。它们分开来看没有任何意义------originX = 800 这句话本身说明不了任何事,它只有在配合 scale 和 originY 的时候才有含义。而现在这三个数没有自己的名字,也没有自己的边界 :它们的访问权限和 mousePosition 一模一样,谁拿到 Canvas 谁就能碰。
一个判断"这堆东西该不该独立成类"的实用标准:它们之间有没有必须一起满足的约束? 有,就该找个地方把这条约束写下来。三个 double 也一样。
三、View:一组数加一对互逆函数
新文件 GUI/view.h:
cpp
#ifndef VIEW_H
#define VIEW_H
#include <QPoint>
#include "Core/point.h"
class View
{
public:
View(const double& offsetX = 0.0, const double& offsetY = 0.0, const double& scale = 10.0);
void setOffsetX(double nOffsetX);
void setOffsetY(double nOffsetY);
QPoint toScreen(const Point& p) const; // 图纸 -> 屏幕
Point toCAD(const QPoint& s) const; // 屏幕 -> 图纸
double getOffsetX() { return offsetX; }
double getOffsetY() { return offsetY; }
private:
double offsetX;
double offsetY;
double scale;
};
#endif // VIEW_H
GUI/view.cpp:
cpp
#include "view.h"
View::View(const double& offsetX, const double& offsetY, const double& scale)
: offsetX(offsetX)
, offsetY(offsetY)
, scale(scale)
{}
void View::setOffsetX(double nOffsetX) { offsetX = nOffsetX; }
void View::setOffsetY(double nOffsetY) { offsetY = nOffsetY; }
QPoint View::toScreen(const Point& p) const
{
return QPoint(qRound(offsetX + p.x * scale), qRound(offsetY - p.y * scale));
}
Point View::toCAD(const QPoint& s) const
{
return Point((s.x() - offsetX) / scale, (offsetY - s.y()) / scale);
}
就这么点东西。但有三件事值得说。

3.1、那对互逆函数终于住在一起了
上一篇我专门讲过:toScreen 和 toCAD 互为逆运算,必须同时改。当时它们的位置也挨着,但那只是"恰好写在一起",没有任何机制保证以后还在一起。
现在它们是同一个类的两个成员,而且这个类是它们唯一的家 。以后要是有人在 Canvas 里手写一句 offsetX + x * scale,那是明显的异常------因为那个类里已经没有 offsetX 这个东西了。
把不变式变成"那个东西根本不在那儿",比写注释管用。
3.2、参数私有化,修改有了唯一的入口
三个 double 现在是私有的,外面只能通过 setter 改。这两个 setter 现在看起来纯属多余(不如直接把成员公开省事)。但它是给未来留的口子。等做滚轮缩放的时候需要这样:
cpp
void View::setScale(double nScale)
{
scale = std::clamp(nScale, kMinScale, kMaxScale); // 夹紧,防止缩放到 0 或者变成 inf
}
为什么必须夹紧? 因为滚轮是连续事件,用户按住滚轮滚二十下,scale 就会连乘二十次 1.15;往反方向滚二十下,又连除二十次。不设上下限的话,图纸要么小到看不见,要么大到把算出的一堆坐标变成 inf------屏幕直接空白。而如果没有 setter,这个夹紧动作就得散在每一个改 scale 的地方:构造函数、滚轮、缩放到全图,一共三处,漏一处就是一个"偶尔图不见了"的 bug。
3.3、它现在有自己的名字了
Canvas::updateViewTransform() 变成了:
cpp
void Canvas::recenterView()
{
view->setOffsetX(width() / 2.0);
view->setOffsetY(height() / 2.0);
}
函数名从 updateViewTransform(更新视图变换)改成了 recenterView(把视图重新居中)。名字变具体了,是因为职责变具体了:以前它管着画布里全部的视图参数,现在它只做"让视图回到窗口中心"这一个动作。函数名能变具体,本身就是拆分成功的信号------如果拆完之后名字还是那么含糊,说明你只是把东西换了个地方堆着。
四、Canvas 瘦身
改完之后 Canvas 的私有成员:
cpp
// GUI/canvas.h(这一篇之后)
private:
void cancelCurrentTool();
void recenterView();
Document *doc;
View *view; // ← 新增:视图参数不再属于 Canvas
Point mousePosition;
Point firstPoint;
bool hasFirstPoint;
删掉 3 个成员和 2 个函数,换来 1 个指针。 三个调用点的改法:
cpp
// 鼠标移动
mousePosition = view->toCAD(screenPosition);
// 鼠标点击
Point clickedPoint = view->toCAD(screenPosition);
// 绘制图元
QPoint startScreen = view->toScreen(line.startPoint);
QPoint endScreen = view->toScreen(line.endPoint);
这就是重点:Canvas 里再也找不到"坐标怎么算"这件事了。它只负责问,不负责算。

把它和上一篇的 Document 摆在一起看:
| 类 | 它负责回答 | Canvas 问它什么 |
|---|---|---|
Document |
图纸上有什么 | getLines() / addLine() |
View |
我在看图纸的哪一块 | toCAD() / toScreen() |
Canvas 现在只剩一件事:把事件翻译成对这两个对象的操作,然后把结果画出来。 这就是"交互层"该有的样子。
五、View 放哪儿,谁来持有
View 放在 GUI/ 目录里,因为它 #include <QPoint>、返回 QPoint。
这一点其实有争议,值得说清楚:从职责上说,视图变换是纯数学,应该能进 Core/。 它进不去的唯一原因是它用 Qt 的类型来表示"屏幕坐标"。三种选择:
| 方案 | 做法 | 代价 |
|---|---|---|
| 现在这样 | 放 GUI/,用 QPoint |
View 绑上了 Qt,不能在 Core 层单独测 |
换成 QPointF |
还是放 GUI/ |
只解决精度,没解决依赖 |
| 自定义屏幕点 | 定义 ScreenPoint{double x, y},View 进 Core/ |
多一个类型,还得写和 QPoint 的互转 |
这一版选了第一种,理由是:View 现在只有三十行、两个函数,为了让它进 Core 而专门造一个类型不划算。 这是有意的取舍,不是疏忽------等哪天需要"脱离 Qt 测视图数学",或者发现 QPoint 的整数精度不够用了,再升级。
这里埋个伏笔:整数
QPoint有个精度问题。toScreen的结果被qRound成整数,平时看不出来,但"以鼠标为中心缩放"要求的是缩放前后鼠标底下那个点不变 ,这就要求中间计算保留小数。下一篇会撞上,到时候toScreen会改成返回QPointF。
持有方式和上一篇的 Document 一模一样:
cpp
// GUI/mainwindow.h
private:
Ui::MainWindow *ui;
Document document;
View view; // ← 值成员,跟窗口同生共死
Canvas *canvas;
cpp
// GUI/mainwindow.cpp
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent)
, ui(new Ui::MainWindow)
, canvas(new Canvas(&document, &view, this))
{
为什么不把 View 塞进 Document? 因为它们描述的是两件不同的事:文档是"图纸上有什么",视图是"你在看图纸的哪一块"。硬塞在一起,就会出现"打开同一个文件,想开两个窗口分别看总图和局部"这种需求完全没法满足的情况。
而拆开之后,这件事是免费的------LibreCAD 就是两个画布视图共享同一个文档。分界线画对了,能力是白送的;画错了,能力得靠重构买回来。

六、这一版留下的三个尾巴
尾巴一:画坐标轴绕过了 View
cpp
// GUI/canvas.cpp(这一版,paintEvent 里)
painter.drawLine(0, static_cast<int>(view->getOffsetY()), width(), static_cast<int>(view->getOffsetY()));
painter.drawLine(static_cast<int>(view->getOffsetX()), 0, static_cast<int>(view->getOffsetX()), height());
这是漏抽象 。我把 toScreen 抽出来了,却在画坐标轴的时候直接读 offsetX / offsetY------因为坐标轴恰好就是"过图纸原点的两条线",直接拿偏移量看着更省事。正确的写法,是问 View 那个它本来就该回答的问题:
cpp
const QPoint originScreen = view->toScreen(Point(0.0, 0.0));
painter.drawLine(0, originScreen.y(), width(), originScreen.y());
painter.drawLine(originScreen.x(), 0, originScreen.x(), height());
绘制代码不该知道 offset 长什么样,它只该问"图纸原点画到屏幕哪里"。 这两行的效果现在完全一样,区别在于等以后坐标系有变化(比如加旋转),一种改法改一个函数,另一种改法要翻遍绘制代码。这个尾巴下一篇就要收------因为做平移的时候它会自己跳出来。
尾巴二:两个 getter 没有 const
cpp
double getOffsetX() { return offsetX; } // 少了 const
它们不改任何东西,就应该标 const。不加的后果很具体:const View* 或者 const 引用调不了它们。
但更根本的问题是------这两个 getter 本来就不该存在 。上面尾巴一的正确写法里,外部根本不需要知道 offset 是多少,只需要 toScreen。一个 getter 如果从来没被需要,它的存在就是在邀请别人绕过抽象。
尾巴三:resizeEvent 还是会把视图拉回窗口中心
cpp
void Canvas::resizeEvent(QResizeEvent *event)
{
QWidget::resizeEvent(event);
recenterView(); // 拉到中间
}
这件事上一篇就预告过:现在没有平移,看不出来;一旦用户能把图纸拖到别处,拉一下窗口,图纸就会"啪"地弹回中间。 为什么这一版不动它?因为改它需要一个现在还不存在的能力------"窗口中心对应图纸上的哪个点"。这句话得靠 toCAD 才问得出来,而 toCAD 刚刚才搬进 View。
所以这不是偷懒:依赖解决了,问题才轮得到解决。 下一篇一起处理。
(另外两个小的,不影响正确性,看到顺手改掉就行:View 构造函数里 offsetX / offsetY 的默认参数其实是死代码------Canvas 一构造完就调 recenterView() 把它们覆盖了;以及 double 用 const double& 传参没必要,值传更自然。)
项目主页(后续更新都在这里):
https://gitee.com/studyingart/mini-cad
总结
下一篇做中键拖动平移,顺带把上面三个尾巴一次收掉。平移本身出奇地简单------拖动时让"按下鼠标那一刻鼠标底下的那个图纸点"始终待在鼠标底下就行:
cpp
void View::alignTo(const Point& cadPoint, const QPoint& screenPos)
{
offsetX = screenPos.x() - cadPoint.x * scale;
offsetY = screenPos.y() + cadPoint.y * scale;
}
(注意第二个式子是加号。屏幕 Y 轴朝下、图纸 Y 轴朝上,所以这里要反过来。这种地方错一次就够记一辈子。)
真正的看点不是这两行算式,而是它牵出来的两个问题。
一是"拖到一半松手,图为什么会跳"。 要让它不跳,必须记住"按下去的时候,鼠标对应的是哪个图纸点"。这个状态属于交互状态,但它和视图参数的关系很紧密------放 Canvas 还是放 View,值得想清楚。
二是"拉窗口的时候怎么让视图中心不动"。 这需要先把 resizeEvent 里那句 recenterView() 看明白------它错在哪,而不是简单把它删掉。
🎇坚持到这里已经很厉害啦,辛苦啦🎇 ʕ • ᴥ • ʔ づ♡ど
