
励志不掉头发的内向程序员个人主页✨️ 个人专栏: 《C语言》《Linux学习》偶尔悲伤偶尔被幸福所完善️博主简介:文章目录前言一、先看看这几个数现在住在哪二、这三个数的问题不是多是必须一起改三、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(constQPointscreen)const{returnPoint((screen.x()-originX)/scale,(originY-screen.y())/scale);}QPointcadToScreen(constPointcad)const{returnQPoint(qRound(originXcad.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#includeQPoint#includeCore/point.hclassView{public:View(constdoubleoffsetX0.0,constdoubleoffsetY0.0,constdoublescale10.0);voidsetOffsetX(doublenOffsetX);voidsetOffsetY(doublenOffsetY);QPointtoScreen(constPointp)const;// 图纸 - 屏幕PointtoCAD(constQPoints)const;// 屏幕 - 图纸doublegetOffsetX(){returnoffsetX;}doublegetOffsetY(){returnoffsetY;}private:doubleoffsetX;doubleoffsetY;doublescale;};#endif// VIEW_HGUI/view.cpp#includeview.hView::View(constdoubleoffsetX,constdoubleoffsetY,constdoublescale):offsetX(offsetX),offsetY(offsetY),scale(scale){}voidView::setOffsetX(doublenOffsetX){offsetXnOffsetX;}voidView::setOffsetY(doublenOffsetY){offsetYnOffsetY;}QPointView::toScreen(constPointp)const{returnQPoint(qRound(offsetXp.x*scale),qRound(offsetY-p.y*scale));}PointView::toCAD(constQPoints)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){scalestd::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 个指针。三个调用点的改法// 鼠标移动mousePositionview-toCAD(screenPosition);// 鼠标点击Point clickedPointview-toCAD(screenPosition);// 绘制图元QPoint startScreenview-toScreen(line.startPoint);QPoint endScreenview-toScreen(line.endPoint);这就是重点Canvas里再也找不到坐标怎么算这件事了。它只负责问不负责算。把它和上一篇的Document摆在一起看类它负责回答Canvas 问它什么Document图纸上有什么getLines()/addLine()View我在看图纸的哪一块toCAD()/toScreen()Canvas现在只剩一件事把事件翻译成对这两个对象的操作然后把结果画出来。这就是交互层该有的样子。五、View 放哪儿谁来持有View放在GUI/目录里因为它#include QPoint、返回QPoint。这一点其实有争议值得说清楚从职责上说视图变换是纯数学应该能进Core/。它进不去的唯一原因是它用 Qt 的类型来表示屏幕坐标。三种选择方案做法代价现在这样放GUI/用QPointView 绑上了 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_castint(view-getOffsetY()),width(),static_castint(view-getOffsetY()));painter.drawLine(static_castint(view-getOffsetX()),0,static_castint(view-getOffsetX()),height());这是漏抽象。我把toScreen抽出来了却在画坐标轴的时候直接读offsetX / offsetY——因为坐标轴恰好就是过图纸原点的两条线直接拿偏移量看着更省事。正确的写法是问 View 那个它本来就该回答的问题constQPoint originScreenview-toScreen(Point(0.0,0.0));painter.drawLine(0,originScreen.y(),width(),originScreen.y());painter.drawLine(originScreen.x(),0,originScreen.x(),height());绘制代码不该知道 offset 长什么样它只该问图纸原点画到屏幕哪里。这两行的效果现在完全一样区别在于等以后坐标系有变化比如加旋转一种改法改一个函数另一种改法要翻遍绘制代码。这个尾巴下一篇就要收——因为做平移的时候它会自己跳出来。尾巴二两个 getter 没有 constdoublegetOffsetX(){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(constPointcadPoint,constQPointscreenPos){offsetXscreenPos.x()-cadPoint.x*scale;offsetYscreenPos.y()cadPoint.y*scale;}注意第二个式子是加号。屏幕 Y 轴朝下、图纸 Y 轴朝上所以这里要反过来。这种地方错一次就够记一辈子。真正的看点不是这两行算式而是它牵出来的两个问题。一是拖到一半松手图为什么会跳。要让它不跳必须记住按下去的时候鼠标对应的是哪个图纸点。这个状态属于交互状态但它和视图参数的关系很紧密——放Canvas还是放View值得想清楚。二是拉窗口的时候怎么让视图中心不动。这需要先把resizeEvent里那句recenterView()看明白——它错在哪而不是简单把它删掉。坚持到这里已经很厉害啦辛苦啦ʕ • ᴥ • ʔづ♡ど