ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

【从零写一个CAD 06】缩放到全图:先问一句「我有多大」,再做一次除法

【从零写一个CAD 06】缩放到全图:先问一句「我有多大」,再做一次除法 励志不掉头发的内向程序员个人主页✨️ 个人专栏: 《C语言》《Linux学习》偶尔悲伤偶尔被幸福所完善️博主简介:文章目录前言一、「我有多大」这件事Canvas 答不上来1.1、为什么用 bool 两个出参1.2、一趟遍历四个数二、缩放倍率就是一个除法2.1、为什么是 min不是 max三、alignTo 第三次登场四、中键双击先数清楚 Qt 发了几个事件4.1、那我在 zoomAuto() 末尾写的 isPanning false; 是干嘛的五、顺手修掉的那个方向5.1、为什么它和「缩放到全图」挤进了同一个提交六、这一版的两个毛病6.1、没有留边距图形顶格6.2、一个被「运气」挡住的除零七、这一版的成果总结前言上一篇结尾我留了一句话从第 4 篇到第 6 篇视图这套东西一共就两个动作改倍率和对齐。这一篇做缩放到全图很多 CAD 里就是中键双击正好把这句话验证一遍。做完回头看这个功能的代码一共九行其中只有两行在真办事——剩下的都在回答一个先决问题「我这些图元一共占了多大一块」而这个问题的答案Canvas自己给不出来。一、「我有多大」这件事Canvas 答不上来先说结论这个功能九成的工作量不在缩放上在「我有多大」上。Canvas想知道图纸的尺寸就得先知道文档里有哪些图元。而这件事只有Document自己清楚——图元全躺在它肚子里。如果让Canvas自己伸手进去翻// 不建议Canvas 伸手进 Document 里翻数据doubleminX1e300;for(constLineline:doc-getLines()){...}那就等于把「文档长什么样」这件事从Document泄漏到了Canvas。今天文档里装的是直线明天装了圆和矩形Canvas这段循环就得跟着改——可它本来应该和「文档里装什么」完全无关。这和第 3 篇把View从Canvas里抽出来是同一个判断谁的数据谁负责回答关于这份数据的问题。所以这一篇先给Document加一个查询boolboundingBox(PointminPoint,PointmaxPoint)const;「给我一个最小的矩形能把你肚子里所有图元都框住。」这个东西英文叫 bounding box包围盒后面的代码里就直接叫它 bbox。1.1、为什么用 bool 两个出参这里有个设计选择值得停一下「文档是空的」怎么办一个偷懒的写法是空文档就返回(0,0) - (0,0)让调用方自己去判断「如果宽和高都是 0 就别缩放了」。这样能跑但它把两件不一样的事混成了同一个值情况真实的含义那个偷懒的写法文档里一个图元都没有没有「多大」这回事(0,0) - (0,0)文档里有一个图元恰好就在原点大小是 0但它存在(0,0) - (0,0)这两件事必须能分开。用同一个值表示它们等于约定了一句暗话「看见 0 就当作没有」。暗话的问题在于——下个月读你代码的人不知道有这句暗话包括你自己。所以我用返回值把这个分支摆到调用点面前boolCanvas::zoomAuto(){Point minPoint,maxPoint;if(!doc-boundingBox(minPoint,maxPoint)){returnfalse;// 文档是空的 —— 没得缩}...}那个if就是这个设计的意义所在「这可能失败」这件事编译器强迫调用方当场表个态。比起「返回一个哨兵值让调用方记得去检查」这个写法强在你想忽略失败至少得亲手写一行去忽略它而不是因为忘了就默默当成成功了。这个小习惯后面会一直用下去。写到项目后面你会看到凡是「可能不成立」的查询签名都长成bool f(..., 出参)。一条约定只有被写进类型里才会有人替你守。1.2、一趟遍历四个数实现上没什么花头扫一遍所有直线同时维护四个数boolDocument::boundingBox(PointminPoint,PointmaxPoint)const{if(lines.empty()){returnfalse;}doubleminXlines.front().startPoint.x;doubleminYlines.front().startPoint.y;doublemaxXlines.front().startPoint.x;doublemaxYlines.front().startPoint.y;for(constautoline:lines){constPointp1line.startPoint;constPointp2line.endPoint;minXstd::min(minX,std::min(p1.x,p2.x));minYstd::min(minY,std::min(p1.y,p2.y));maxXstd::max(maxX,std::max(p1.x,p2.x));maxYstd::max(maxY,std::max(p1.y,p2.y));}minPointPoint{minX,minY};maxPointPoint{maxX,maxY};returntrue;}两个细节值得说一句一是起手用的是第一条线的起点不是 ±∞。因为前面已经拦住了空文档lines.front()一定存在。拿一条真实数据起手比拿DBL_MAX起手更不容易写错——而且它顺手把「至少有一条线」这个前提又写明了一次。二是一条线要带上两个端点。写成minX std::min(minX, p1.x)是不够的p2也得进去——一条从左下往右上走的线两个端点各贡献一半的上界和下界。写包围盒最常漏的就是这个p2。二、缩放倍率就是一个除法现在知道自己多大了剩下的反而简单。屏幕上能放下多少倍——窗口有多宽除以图纸有多宽。横向能放的倍率 窗口宽 ÷ 图纸宽 纵向能放的倍率 窗口高 ÷ 图纸高两个方向各算一个数然后取小的那个view-setScale(std::min(width()/nWidth,height()/nHeight));2.1、为什么是 min不是 max这是这一节唯一值得记下来的东西。假设图纸是 200 × 100宽高比 2 : 1窗口是 800 × 600横向纵向算出来的倍率800 ÷ 200 4600 ÷ 100 6看这两个数会怎么用用4图纸画成 800 × 400宽正好铺满上下各空 100 像素✓用6图纸画成 1200 × 600高正好铺满左右各溢出 200 像素✗「装得下」这个要求是两个方向都要满足的。而「两个方向都满足」翻译成数字就是取小的那个——因为它是最受限制的那个方向。取大的那个等于按宽松的方向来定倍率另一个方向必然溢出。一句话要同时满足两条约束就取更紧的那条。这也顺便回答了「这个功能为什么在 CAD 里叫『缩放到范围』而不是『铺满窗口』」——它的目标从来不是铺满是全部看见。这个区别第六节还会回来。三、alignTo 第三次登场倍率算出来了图还不能看——因为scale一改图纸是以图纸原点为中心缩放的缩完大概率跑到窗口外面去。还得让「图纸的中心」出现在「窗口的中心」。这一步第 3 篇写好的那个函数正是干这个的constPointcenterCAD(minPoint.xnWidth/2,minPoint.ynHeight/2);view-alignTo(centerCAD,QPoint(width()/2,height()/2));整个函数连起来是这样boolCanvas::zoomAuto(){Point minPoint,maxPoint;if(!doc-boundingBox(minPoint,maxPoint)){returnfalse;}doublenWidthmaxPoint.x-minPoint.x;doublenHeightmaxPoint.y-minPoint.y;constPointcenterCAD(minPoint.xnWidth/2,minPoint.ynHeight/2);view-setScale(std::min(width()/nWidth,height()/nHeight));view-alignTo(centerCAD,QPoint(width()/2,height()/2));update();isPanningfalse;returntrue;}九行两行在办事剩下七行都在回答「我有多大、我该多大」。把三次用到alignTo的地方排一起看看篇场景要保住什么一句话04中键拖动平移按下那一刻鼠标底下的图纸点鼠标不动图纸跟着走05滚轮缩放鼠标底下那个点缩放前后它还在同一个像素上06缩放到全图图纸的中心图纸中心 → 窗口中心三次都在说同一句话「让图纸上的哪个点出现在屏幕上的哪个位置。」而这个函数从第 3 篇写出来到现在一个字都没改过。写到这儿可以下一个判断了从第 4 篇到第 6 篇视图这套东西一共只有两个动作——改倍率、对齐。平移是「只对齐」缩放是「改倍率 对齐」缩放到全图是「先算倍率再改倍率 对齐」。剩下的所有复杂度都在**「倍率该是多少」**上而不在「怎么把它摆好」上。四、中键双击先数清楚 Qt 发了几个事件绑定本身很简单voidCanvas::mouseDoubleClickEvent(QMouseEvent*event){if(event-button()Qt::MiddleButton){zoomAuto();}QWidget::mouseDoubleClickEvent(event);}但有个细节必须弄清楚不然后面那段状态处理你会写得多余、甚至写错。「双击」不是一个事件是四个第一次按下 press(middle) → isPanning true记下 anchorCAD 第一次松开 release(middle) → isPanning false 第二次按下 dblClick(middle) ← 注意这次不是 press是 dblClick 第二次松开 release(middle) → isPanning false关键在第三行Qt 把「第二次按下」替换成了mouseDoubleClickEventmousePressEvent不会再收到一次。所以双击中键的时候zoomAuto()跑起来的那个时刻isPanning早就被第一次release置回false了。4.1、那我在 zoomAuto() 末尾写的 isPanning false; 是干嘛的老实说按上面这个顺序它是多余的。我留着它理由只有一个——它不花钱而且万一哪天事件顺序不是这样它能兜住。但我得承认这不是个好理由。更诚实的说法是我当时并不确定双击之前那次 press 有没有把isPanning留在 true 上。与其去查文档确认我加了一行保险。这件事本身没有害处但值得记住一个界限「防御性的一行」和「逻辑的一行」要分清楚。防御性的一行是「我不确定加一行兜住」逻辑的一行是「这里必须这样否则就是错的」。两者都可以写但你得知道自己写的是哪一种。因为如果哪天有人来重构这个函数他会看到这行、猜它的用意然后要么删掉那还好要么以为它是逻辑、把它搬到别的地方去——到那时候它就可能真的变成一个 bug。五、顺手修掉的那个方向上一篇讲过滚轮方向我第一版写反了而且它不报错、不崩、数值全对只有自己滚一下才知道别扭。那处修正就落在这一篇的提交里-if(event-angleDelta().y()0){if(event-angleDelta().y()0){view-setScale(view-getScale()*factor);-}elseif(event-angleDelta().y()0){}elseif(event-angleDelta().y()0){view-setScale(view-getScale()/factor);}else{return;// 不是竖直方向的滚动不管}顺手还补了那个else { return; }angleDelta().y() 0说明这是横向滚动或者压根没事发生这种情况下不该继续往下走——因为往下走会执行alignTo而alignTo会把offset整个重算一遍。虽然重算出来是同一个值、画面看不出来但没必要的写就是没必要的写它多留了一个「万一」。5.1、为什么它和「缩放到全图」挤进了同一个提交这件事其实是不该的。我在第 2 篇和第 4 篇都说过「一次提交只干一件事」结果这一篇自己没做到。原因也很朴素我是在测「缩放到全图」的时候顺手滚了两下滚轮才发现旧功能不对劲。这种事没法提前规划——你不可能在写新功能之前就预知自己会在哪里撞见旧bug。所以这条规矩的准确版本应该比原来宽松一点一次提交只干一件事除非第二件事是你在做这件事的过程中撞见的。前者是规划后者是顺手。而这两者的区别落在一条具体的做法上顺手修的东西如果和主线无关就要在提交信息里单独点名。我这次就在提交信息里写了「新增缩放到全图中键双击Document 提供包围盒查询」——滚轮方向那处一个字都没提。这就是这次没做好的地方。半年之后你想翻「滚轮方向到底是哪次改的」从提交信息里搜不到只能一条条 diff 翻。顺带说一句这也正是上一篇结尾要专门写「另外两处修正分别在34b5998和b034a20」的原因。当时的我就是在替未来的自己补这个漏。六、这一版的两个毛病功能是对了但有两条尾巴留着。6.1、没有留边距图形顶格这是「缩放到全图」跑起来第一眼就能看见的图形正好贴满窗口四条边全顶死。算式本身就决定了这个结果view-setScale(std::min(width()/nWidth,height()/nHeight));只要图纸和窗口的宽高比接近两个商就接近min取到哪一个都差不多——图纸就正好铺满。问题在于铺满不等于好看。线宽 2 像素。如果一条线的端点正好落在窗口边界上它的一半在窗外画出来像被刀切了一刀人的视觉习惯是「图形周围该有点空白」。顶死了会让人怀疑「是不是还有东西在外面没看见」。CAD 里的「缩放到范围」其实都留边常见做法是乘一个小于 1 的系数constdoublekMargin0.9;// 四周各留 5%view-setScale(std::min(width()/nWidth,height()/nHeight)*kMargin);当时我没做。理由很典型「缩放到全图」这几个字里有个「全」字我以为它就是要铺满。但上一篇标题里那句话这里要再说一遍手感和语义的东西编译器帮不了你。「全图」到底是「全部看见」还是「铺满窗口」只有自己用起来才知道不对。再回头看第 2.1 节那句「它的目标从来不是铺满是全部看见」。写算式的时候要是把这句想清楚了这个边距顺手就加上了。6.2、一个被「运气」挡住的除零第二条更隐蔽而且它现在恰好是对的。想一个场景如果文档里只有一条水平线那么它的高度就是 0nHeight maxPoint.y - minPoint.y 0于是height()/nHeight// 600.0 / 0.0 infstd::min(width() / nWidth, inf)会取到有限的那个值——结果是对的水平线铺满宽度高度方向上它本来就没有尺寸屏幕上看起来完全正常。但如果当初我写的是std::max这里就会拿到inf然后setScale(inf)被夹到上限……整张图会缩成一个小点。也就是说第 2.1 节那个「取小的那个」的选择顺手把这里的除零也挡掉了。不过我要说清楚——这是运气不是设计。靠inf被min丢掉来兜底有两层不稳我当时压根没想到这件事。是写这篇的时候回头推演才发现的。没想到就不算设计。它只在「有一个方向的尺寸恰好为 0」的时候表现得对。等以后图元种类多了这个「恰好对」不一定还在。真要稳应该把「这个方向不构成约束」明说出来而不是让它退化成一个inf去等min帮忙收尸constdoublescaleX(nWidth0.0)?width()/nWidth:std::numeric_limitsdouble::infinity();constdoublescaleY(nHeight0.0)?height()/nHeight:std::numeric_limitsdouble::infinity();inf还是出现了但这次是我主动写的。区别在于主动写出来的inf是「这个方向没有约束」这个意思的明确表达而撞出来的inf是「我忘了这里会除零」。这段到现在也还没改成这样。先记在待办上——因为它现在恰好不出错而「去改一个现在不出错的写法」很容易改出新问题值得单独做一次。七、这一版的成果现在能做的中键双击 → 缩放到全图所有图元进入视野图纸中心对准窗口中心Document::boundingBox(minPoint, maxPoint)文档能回答「我占多大一块」了滚轮方向修正往上滚是放大上一篇预告的那一处还有一个不太看得见、但更值得记的Canvas到现在仍然不知道「文档里装的是什么」。它没有getLines()没有循环只问了一句boundingBox。这意味着——下一版往文档里塞圆和矩形的时候zoomAuto()一行都不用改。这不是推测是可以直接验证的。工程走到今天boundingBox早就不是遍历lines了文档里也没有lines这个成员了它现在长这样entities[index]-boundingBox(mn,mx);// 每个图元自己会算而zoomAuto()那九行从这一天起到现在逻辑一个字都没动过只有排版空格变了。这就是「谁的数据谁回答」的回报改的是文档内部怎么存不动的是别人怎么用。顺手记两条一、「可能失败」要用返回值说出来不要用哨兵值悄悄说。返回值里那个bool换来的是调用点上那个if——它把「如果没有图元该怎么办」变成一个必须当场做决定的问题而不是「等你哪天想起来再说」。二、发现自己「恰好没出错」的地方要立刻记下来。它比一个正在报错的 bug 更危险报错的 bug 会逼你修而恰好没出错的 bug只会安安静静地等你放开手。总结下一篇做把鼠标坐标搬到状态栏。现在那行CAD X: ... CAD Y: ...是用painter.drawText画在画布左上角的。看着没问题但它有个更大的毛病Canvas在替窗口画本该由状态栏承担的东西——坐标属于「程序状态」不属于「图纸内容」而现在它被画进了图纸那一层里。搬过去会撞上一个结构问题Canvas只认识Document和View它根本不知道状态栏在哪——也不应该知道。所以这两个东西得靠信号槽通信。那一步看起来很小一个emit但它其实是这个项目里**「GUI 内部再分一层」**的开始Canvas ——只管把「这里有个坐标」喊出去 MainWindow ——决定这句话显示在哪喊的人和听的人互不认识各自都能单独换掉。坚持到这里已经很厉害啦辛苦啦ʕ • ᴥ • ʔづ♡ど
返回列表