👓️博主简介:
文章目录
- 前言
- 一、先看看这几个数现在住在哪
- 二、这三个数的问题不是"多",是"必须一起改"
- 三、View:一组数加一对互逆函数
- 3.1、那对互逆函数终于住在一起了
- 3.2、参数私有化,修改有了唯一的入口
- 3.3、它现在有自己的名字了
- 四、Canvas 瘦身
- 五、View 放哪儿,谁来持有
- 六、这一版留下的三个尾巴
- 尾巴一:画坐标轴绕过了 View
- 尾巴二:两个 getter 没有 const
- 尾巴三:resizeEvent 还是会把视图拉回窗口中心
- 总结
前言
上一篇结尾我说,下一步要把originX / originY / scale打包成View。这篇就干这件事。先回应一个可能的疑问:就三个 double,值得单独建一个类、再加两个文件吗?
值得。但理由不是"三个数太多了",而是这三个数是一组,而且必须一起被改。这句话现在看着像废话,等做到"以鼠标为中心滚轮缩放"的时候你会庆幸——那时候你必须同时改scale和offset,少改一个,图就会跳。这篇我们来讲三件事:这三个数住错了哪儿、View长什么样(一共三十行)、以及抽出来之后暴露的三个尾巴。
一、先看看这几个数现在住在哪
上一篇之后,Canvas的私有成员是这样:
// GUI/canvas.h(这一篇之前)private:voidcancelCurrentTool();voidupdateViewTransform();PointscreenToCAD(constQPoint&screen)const{returnPoint((screen.x()-originX)/scale,(originY-screen.y())/scale);}QPointcadToScreen(constPoint&cad)const{returnQPoint(qRound(originX+cad.x*scale),qRound(originY-cad.y*scale));}Document*doc;Point mousePosition;// 当前鼠标的图纸坐标Point firstPoint;// 已经点击的第一个点boolhasFirstPoint;// 有没有点过第一个点doubleoriginX;// 图纸原点在屏幕上的位置doubleoriginY;doublescale;// 缩放比例看最后三行。它们和"鼠标点在哪""画到一半没有"这些交互状态并排躺在一个类里,但这两种东西的性质完全不一样:
交互状态(mousePosition/firstPoint/hasFirstPoint) | 视图参数(originX/originY/scale) | |
|---|---|---|
| 描述的是 | 用户正在干什么 | 用户正在看什么 |
| 有效期 | 一次操作内,画完就该清 | 整个程序运行期间一直有效 |
| 谁会改它 | 鼠标事件 | 平移、缩放、窗口尺寸变化 |
| 打开另一张图 | 该清空 | 该保留 |
第一种是"操作的临时记忆",第二种是"你在看图纸的哪一块"。混在同一个类里,改任何一个都要先把另一个读一遍。
二、这三个数的问题不是"多",是"必须一起改"
打开resizeEvent看一眼就明白了:
voidCanvas::resizeEvent(QResizeEvent*event){QWidget::resizeEvent(event);updateViewTransform();// 里面就两行:把原点摆到新的窗口中心}updateViewTransform()做的事,是同时改originX和originY。再看下一篇要做的平移:鼠标每移动一个像素,originX和originY也要一起变。再看再下一篇的滚轮缩放:
// 以鼠标为中心缩放(还没写,但形状已经确定了)scale*=factor;// 必须紧接着调整 offset,否则缩放会以窗口左上角为中心,而不是鼠标位置每一处修改都是"这几个数一起动"。这就是我说的不变式:offsetX / offsetY / scale不是三个独立的数,而是描述同一个变换的三个参数。它们分开来看没有任何意义——originX = 800这句话本身说明不了任何事,它只有在配合scale和originY的时候才有含义。而现在这三个数没有自己的名字,也没有自己的边界:它们的访问权限和mousePosition一模一样,谁拿到Canvas谁就能碰。
一个判断"这堆东西该不该独立成类"的实用标准:它们之间有没有必须一起满足的约束?有,就该找个地方把这条约束写下来。三个 double 也一样。
三、View:一组数加一对互逆函数
新文件GUI/view.h:
#ifndefVIEW_H#defineVIEW_H#include<QPoint>#include"Core/point.h"classView{public:View(constdouble&offsetX=0.0,constdouble&offsetY=0.0,constdouble&scale=10.0);voidsetOffsetX(doublenOffsetX);voidsetOffsetY(doublenOffsetY);QPointtoScreen(constPoint&p)const;// 图纸 -> 屏幕PointtoCAD(constQPoint&s)const;// 屏幕 -> 图纸doublegetOffsetX(){returnoffsetX;}doublegetOffsetY(){returnoffsetY;}private:doubleoffsetX;doubleoffsetY;doublescale;};#endif// VIEW_HGUI/view.cpp:
#include"view.h"View::View(constdouble&offsetX,constdouble&offsetY,constdouble&scale):offsetX(offsetX),offsetY(offsetY),scale(scale){}voidView::setOffsetX(doublenOffsetX){offsetX=nOffsetX;}voidView::setOffsetY(doublenOffsetY){offsetY=nOffsetY;}QPointView::toScreen(constPoint&p)const{returnQPoint(qRound(offsetX+p.x*scale),qRound(offsetY-p.y*scale));}PointView::toCAD(constQPoint&s)const{returnPoint((s.x()-offsetX)/scale,(offsetY-s.y())/scale);}就这么点东西。但有三件事值得说。
3.1、那对互逆函数终于住在一起了
上一篇我专门讲过:toScreen和toCAD互为逆运算,必须同时改。当时它们的位置也挨着,但那只是"恰好写在一起",没有任何机制保证以后还在一起。
现在它们是同一个类的两个成员,而且这个类是它们唯一的家。以后要是有人在Canvas里手写一句offsetX + x * scale,那是明显的异常——因为那个类里已经没有offsetX这个东西了。
把不变式变成"那个东西根本不在那儿",比写注释管用。
3.2、参数私有化,修改有了唯一的入口
三个double现在是私有的,外面只能通过 setter 改。这两个 setter 现在看起来纯属多余(不如直接把成员公开省事)。但它是给未来留的口子。等做滚轮缩放的时候需要这样:
voidView::setScale(doublenScale){scale=std::clamp(nScale,kMinScale,kMaxScale);// 夹紧,防止缩放到 0 或者变成 inf}为什么必须夹紧?因为滚轮是连续事件,用户按住滚轮滚二十下,scale就会连乘二十次1.15;往反方向滚二十下,又连除二十次。不设上下限的话,图纸要么小到看不见,要么大到把算出的一堆坐标变成inf——屏幕直接空白。而如果没有 setter,这个夹紧动作就得散在每一个改scale的地方:构造函数、滚轮、缩放到全图,一共三处,漏一处就是一个"偶尔图不见了"的 bug。
3.3、它现在有自己的名字了
Canvas::updateViewTransform()变成了:
voidCanvas::recenterView(){view->setOffsetX(width()/2.0);view->setOffsetY(height()/2.0);}函数名从updateViewTransform(更新视图变换)改成了recenterView(把视图重新居中)。名字变具体了,是因为职责变具体了:以前它管着画布里全部的视图参数,现在它只做"让视图回到窗口中心"这一个动作。函数名能变具体,本身就是拆分成功的信号——如果拆完之后名字还是那么含糊,说明你只是把东西换了个地方堆着。
四、Canvas 瘦身
改完之后Canvas的私有成员:
// GUI/canvas.h(这一篇之后)private:voidcancelCurrentTool();voidrecenterView();Document*doc;View*view;// ← 新增:视图参数不再属于 CanvasPoint mousePosition;Point firstPoint;boolhasFirstPoint;删掉 3 个成员和 2 个函数,换来 1 个指针。三个调用点的改法:
// 鼠标移动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一模一样:
// GUI/mainwindow.hprivate:Ui::MainWindow*ui;Document document;View view;// ← 值成员,跟窗口同生共死Canvas*canvas;// GUI/mainwindow.cppMainWindow::MainWindow(QWidget*parent):QMainWindow(parent),ui(newUi::MainWindow),canvas(newCanvas(&document,&view,this)){为什么不把View塞进Document?因为它们描述的是两件不同的事:文档是"图纸上有什么",视图是"你在看图纸的哪一块"。硬塞在一起,就会出现"打开同一个文件,想开两个窗口分别看总图和局部"这种需求完全没法满足的情况。
而拆开之后,这件事是免费的——LibreCAD 就是两个画布视图共享同一个文档。分界线画对了,能力是白送的;画错了,能力得靠重构买回来。
六、这一版留下的三个尾巴
尾巴一:画坐标轴绕过了 View
// 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 那个它本来就该回答的问题:
constQPoint 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
doublegetOffsetX(){returnoffsetX;}// 少了 const它们不改任何东西,就应该标const。不加的后果很具体:const View*或者 const 引用调不了它们。
但更根本的问题是——这两个 getter 本来就不该存在。上面尾巴一的正确写法里,外部根本不需要知道 offset 是多少,只需要toScreen。一个 getter 如果从来没被需要,它的存在就是在邀请别人绕过抽象。
尾巴三:resizeEvent 还是会把视图拉回窗口中心
voidCanvas::resizeEvent(QResizeEvent*event){QWidget::resizeEvent(event);recenterView();// 拉到中间}这件事上一篇就预告过:现在没有平移,看不出来;一旦用户能把图纸拖到别处,拉一下窗口,图纸就会"啪"地弹回中间。为什么这一版不动它?因为改它需要一个现在还不存在的能力——“窗口中心对应图纸上的哪个点”。这句话得靠toCAD才问得出来,而toCAD刚刚才搬进 View。
所以这不是偷懒:依赖解决了,问题才轮得到解决。下一篇一起处理。
(另外两个小的,不影响正确性,看到顺手改掉就行:View构造函数里offsetX / offsetY的默认参数其实是死代码——Canvas一构造完就调recenterView()把它们覆盖了;以及double用const double&传参没必要,值传更自然。)
项目主页(后续更新都在这里):
https://gitee.com/studyingart/mini-cad
总结
下一篇做中键拖动平移,顺带把上面三个尾巴一次收掉。平移本身出奇地简单——拖动时让"按下鼠标那一刻鼠标底下的那个图纸点"始终待在鼠标底下就行:
voidView::alignTo(constPoint&cadPoint,constQPoint&screenPos){offsetX=screenPos.x()-cadPoint.x*scale;offsetY=screenPos.y()+cadPoint.y*scale;}(注意第二个式子是加号。屏幕 Y 轴朝下、图纸 Y 轴朝上,所以这里要反过来。这种地方错一次就够记一辈子。)
真正的看点不是这两行算式,而是它牵出来的两个问题。
一是"拖到一半松手,图为什么会跳"。要让它不跳,必须记住"按下去的时候,鼠标对应的是哪个图纸点"。这个状态属于交互状态,但它和视图参数的关系很紧密——放Canvas还是放View,值得想清楚。
二是"拉窗口的时候怎么让视图中心不动"。这需要先把resizeEvent里那句recenterView()看明白——它错在哪,而不是简单把它删掉。
🎇坚持到这里已经很厉害啦,辛苦啦🎇 ʕ • ᴥ • ʔ づ♡ど