ARTICLE DETAIL

资讯详情

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

【从零写一个CAD 04】中键拖动平移:抓住一个点,让它一直待在鼠标底下

【从零写一个CAD 04】中键拖动平移:抓住一个点,让它一直待在鼠标底下 励志不掉头发的内向程序员个人主页✨️ 个人专栏: 《C语言》《Linux学习》偶尔悲伤偶尔被幸福所完善️博主简介:文章目录前言一、平移改的不是图纸是你在看哪儿二、秘诀抓住一个点而不是累加位移2.1、先说一个看起来更直观、但会出问题的写法2.2、正确的思路记住抓住的那个点2.3、alignTo 里的那个加号三、三个状态三个事件函数四、收回第一个尾巴坐标轴改用 toScreen五、收回第三个尾巴resizeEvent 保持视图中心5.1、注意这里用的是同一个函数5.2、那个 oldSize 判断不是多余的5.3、为什么必须先算、再交给基类六、第三个尾巴有个意外的结局七、这一版的成果总结前言上一篇把originX / originY / scale打包成了View结尾我列了三个尾巴画坐标轴的时候绕过了toScreen直接读offsetX / offsetY两个 getter 没有constresizeEvent会把图纸拽回窗口中心这一篇做中键拖动平移顺手把第一和第三个尾巴收掉。第二个尾巴有个意外的结局最后一节说。不过在写代码之前有一件事得先想明白——平移的时候到底什么东西变了答案是图纸上没有任何一个数字发生变化。一、平移改的不是图纸是你在看哪儿这是 CAD 和很多 2D 绘图程序最容易被想歪的一点。如果拿一个记事本当类比把文字往右移你会去改每个字符的位置。但在 CAD 里拖动图纸两千条线的坐标一个都不会变——变的只是View里的那两个偏移量。平移前平移后图元坐标Line::startPoint等(0,0) → (100,0)(0,0) → (100,0)一模一样offsetX / offsetY400 / 300120 / 520屏幕上看到的位置靠中间挪到左上图纸和看图纸的窗口是两样东西。你在纸上滑动一张透明胶片胶片上的图形没动动的是它相对窗口的位置——CAD 的平移就是这个。想清楚这一点第 3 篇为什么要把View单独抽出来就一目了然了因为平移只改View一个字符都不用碰Document。如果当初那三个数还散在Canvas里、和鼠标点在哪混在一块儿现在做平移就得在一堆交互状态里翻找。二、秘诀抓住一个点而不是累加位移2.1、先说一个看起来更直观、但会出问题的写法很多人第一反应是这样鼠标每移动一次就算出它相对上一次移动了多少像素然后把这个差值加到偏移量上。// 不推荐的写法offsetXscreenPos.x()-lastMousePos.x();offsetYscreenPos.y()-lastMousePos.y();lastMousePosscreenPos;它能跑但有两个毛病一是误差会累积。每次移动都要算一次减法、做一次加法整数像素的取整误差一点点攒起来。拖久了你会发现我明明拖回原地图纸却没完全回到原来的位置。二是松手再按就要重新对准。因为这套逻辑依赖上一次鼠标在哪而这个量一旦断了松手、切窗口、切工具就得重新建立基准。基准没建好的那一帧图会跳一下。2.2、正确的思路记住抓住的那个点换个角度想拖动的时候真正在发生的是——你用手指按住纸上的某一个点然后让这个点始终跟着你的手指。所以只需要记住一件事按下鼠标那一刻鼠标底下是图纸上的哪个点。voidCanvas::mousePressEvent(QMouseEvent*event){if(event-button()Qt::MiddleButton){isPanningtrue;anchorCADview-toCAD(event-pos());// ← 就记这一个点}QWidget::mousePressEvent(event);}anchorCAD就是那个手指按住的点。之后鼠标每一次移动做的事都一样voidCanvas::mouseMoveEvent(QMouseEvent*event){QPoint screenPositionevent-pos();if(isPanning){view-alignTo(anchorCAD,screenPosition);// 让 anchor 回到鼠标底下}mousePositionview-toCAD(screenPosition);update();}没有累加没有上一次每次都是从 anchor 重新算一遍。误差不可能累积因为压根没有地方给它攒。2.3、alignTo 里的那个加号voidView::alignTo(constPointcadPoint,constQPointscreenPos){offsetXscreenPos.x()-cadPoint.x*scale;offsetYscreenPos.y()cadPoint.y*scale;}第一行减号、第二行加号很多人第一次写会以为第二个也是减号然后发现横向拖是对的纵向拖反了。但这两个符号其实不用背它是从toScreen反解出来的。要让图纸上的cadPoint显示在屏幕上的screenPos就是把toScreen的输入和输出对调已知 screenPos.x offsetX cadPoint.x * scale 求 offsetX ? 两边减去 cadPoint.x * scale offsetX screenPos.x - cadPoint.x * scale ← 第一个式子 已知 screenPos.y offsetY - cadPoint.y * scale 两边加上 cadPoint.y * scale offsetY screenPos.y cadPoint.y * scale ← 第二个式子加号自己冒出来了第二行的加号就是从屏幕 Y 轴朝下、图纸 Y 轴朝上那个负号里还回来的。记住这条推导路径比记住两个符号靠谱。顺便说alignTo这个函数名值得留意。它没有叫translate平移而是叫对齐到——因为它的语义是把某个图纸点对齐到某个屏幕位置。名字说的是意图不是实现。三、三个状态三个事件函数这一版给Canvas加了一次mousePressEvent之前只有 move 和 release成员也多了一个protected:voidmousePressEvent(QMouseEvent*event)override;// ← 新增private:boolisPanning;// 中键是不是按着Point anchorCAD;// 按下去那一刻鼠标底下的那个图纸点按下 → 记 anchor移动 → 对齐松开 → 关掉标记}elseif(event-button()Qt::MiddleButton){isPanningfalse;}三个细节一、为什么 anchor 要在mousePress里记不在mouseMove里记第一次移动。因为按下和第一次移动之间隔着一段时间。这段时间里用户可能已经在移动鼠标了——如果等到第一次 move 才记 anchor那记的就是已经移动过一点之后的位置图纸会先跳一下再跟手。抓住这个动作发生在按下那一刻状态就应该在那一刻建立。二、mousePressEvent末尾那一句QWidget::mousePressEvent(event);别漏。你自己只处理了中键左键右键还得让基类有机会处理。这是 Qt 事件处理的一条通用规矩处理了可以返回没处理的一定要交给基类——否则某些默认行为比如焦点转移会莫名其妙失效。三、中键在 CAD 里是个有传统的键。AutoCAD 里中键按住是平移滚轮是缩放这就是为什么这一版选它。左键留给确定右键留给取消/结束——第 2 篇已经占上了。三个键各管一件事不用教用惯 CAD 的人自己就会。四、收回第一个尾巴坐标轴改用 toScreen上一篇我在paintEvent里留了这样一段// 之前的写法绘制代码直接读 offsetpainter.drawLine(0,static_castint(view-getOffsetY()),width(),static_castint(view-getOffsetY()));painter.drawLine(static_castint(view-getOffsetX()),0,static_castint(view-getOffsetX()),height());painter.drawEllipse(QPoint(static_castint(view-getOffsetX()),static_castint(view-getOffsetY())),4,4);这一版改成了// CAD 原点 (0, 0) 落在屏幕上的哪个像素——它就是十字轴的交点constQPoint originScreenview-toScreen(Point(0.0,0.0));painter.setPen(QPen(Qt::black,1));painter.drawLine(0,originScreen.y(),width(),originScreen.y());painter.drawLine(originScreen.x(),0,originScreen.x(),height());painter.setBrush(Qt::red);painter.drawEllipse(originScreen,4,4);为什么这两版效果一样但只有新版是对的因为绘制代码的职责是把图纸上的东西画到屏幕上它该问的问题是**“图纸原点的屏幕位置在哪”而不是你的偏移量是多少**。旧写法有个隐藏的假设图纸原点和偏移量是同一件事。这个假设在今天成立因为offsetX/offsetY的定义恰好就是原点画在屏幕的哪一列/哪一行。但假设就是用来被打破的。哪天坐标系要支持旋转或者视图要支持多视口各自偏移toScreen还是那个toScreen而直接读 offset那三行就得整体重写。这也是封装最容易退化的地方不是接口被改坏了而是接口还在、但调用方绕过去了。上一篇我把toScreen抽出来了可画坐标轴的时候图省事直接读了 offset——抽了等于白抽。判断标准很简单在这个函数里有没有出现View内部成员的名字出现了就是绕过。五、收回第三个尾巴resizeEvent 保持视图中心上一篇那个版本是这样的voidCanvas::resizeEvent(QResizeEvent*event){QWidget::resizeEvent(event);recenterView();// ← 把视图拽回窗口中心}在没有平移的时候看不出问题反正原点本来就在窗口中心。但平移做完之后它的症状立刻显形你把图纸拖到角落随手拉一下窗口图纸啪地跳回中间。原因很直白recenterView()干的事是把偏移量设成新窗口的中心它完全不管你之前在看哪儿。正确的做法是要保住一件事窗口中心对应的那个图纸点尺寸变完之后还得在新的窗口中心。voidCanvas::resizeEvent(QResizeEvent*event){constQSize oldSizeevent-oldSize();// 首次显示时 oldSize 是无效尺寸-1,-1没有旧中心可言直接居中if(!oldSize.isValid()){QWidget::resizeEvent(event);recenterView();return;}// 1. 用【旧尺寸的中心】和【当前的 offset】算出那里是图纸上的哪个点constPoint centerCADview-toCAD(QPoint(oldSize.width()/2,oldSize.height()/2));// 2. 正常处理尺寸变化QWidget::resizeEvent(event);// 3. 让这个 CAD 点回到【新尺寸的中心】view-alignTo(centerCAD,QPoint(width()/2,height()/2));}5.1、注意这里用的是同一个函数第三步调的还是第二节那个alignTo。这一点值得单独说alignTo(cadPoint, screenPos)这个函数在平移里是让抓着的点跟住鼠标在 resize 里是让中心点跟住新中心——两个完全不同的功能用的是同一行代码。这不是巧合是因为它们本来就是同一件事把某个图纸点摆到某个屏幕位置。一个函数如果抽象对了它会在你意想不到的地方被复用。反过来如果当初alignTo写成panBy(dx, dy)按位移量平移那 resize 里就用不上它——因为 resize 要的不是移动多少而是让谁到哪儿。5.2、那个 oldSize 判断不是多余的if(!oldSize.isValid()){...return;}控件第一次显示时也会触发resizeEvent而此时oldSize是(-1, -1)。拿它去算旧窗口的中心会得到一个负数坐标然后再让这个不存在的点回到新中心——视图就歪了。这个边界很容易漏因为它的症状只在程序刚启动的那一帧出现之后你随便拉一下窗口就好了很容易被当成启动时闪一下忽略过去。判据QSize::isValid()在宽高都非负时为真而(-1,-1)恰好是 Qt 用来表示没有旧尺寸的哨兵值。凡是拿到event-oldSize()的地方都该先问一句它是不是有效的。5.3、为什么必须先算、再交给基类三步的顺序不能换constPoint centerCADview-toCAD(...);// 1. 先算——此时 offset 还是旧的QWidget::resizeEvent(event);// 2. 交给基类——尺寸真正改变view-alignTo(centerCAD,...);// 3. 再对齐——用新的 width()/height()因为第 1 步依赖旧的offset还没被改第 3 步依赖新的width() / height()基类处理完才更新。顺序一反算出来的就是新尺寸配旧偏移的四不像。六、第三个尾巴有个意外的结局上一篇我还留了一个尾巴getOffsetX()/getOffsetY()没有const而且这两个 getter 本来就不该存在。这一版的结局有点意思坐标轴改用toScreen之后这两个 getter 就彻底没人调用了。我搜过整个工程——定义还在调用点一个都没有。doublegetOffsetX(){returnoffsetX;}// 死代码doublegetOffsetY(){returnoffsetY;}// 死代码这件事本身说明了一个规律上一节那个绕过封装的写法不只是风格问题——它还在给不该存在的接口续命。要是没人绕过toScreen这两个 getter 在抽View的那天就应该直接不写。没有删是因为这一版的改动已经够多了我想把删接口单独做一次免得和功能改动混在同一个提交里——那样万一下次构建挂了你分不清是平移写错了还是删漏了谁在用。这个理由听起来像拖延但它确实是有意的一次提交只干一件事。七、这一版的成果现在能做的中键按住拖动图纸跟着鼠标走拖到哪儿停哪儿拉窗口视图中心对应的图纸点保持不变不再弹回中间坐标轴跟着平移走而且绘制代码不再偷看View的内部顺带说一个另一种写法你可能在别的 Qt 代码里见过// 另一种写法不维护 isPanning直接看当前按着哪些键voidCanvas::mouseMoveEvent(QMouseEvent*event){if(event-buttons()Qt::MiddleButton){...}}buttons()返回的是当前按着的所有键复数而button()是触发这个事件的那个键单数——这个单复数之差是 Qt 里一个经典的坑。这种写法省掉了一个状态变量但缺点是它没法回答这次拖动是不是从我这个控件上开始的。我选了显式的isPanning是因为状态越显式调试时越容易看懂——鼠标事件里出错的时候你至少能一眼看出现在到底以为自己在拖还是没在拖。总结下一篇做以鼠标为中心的滚轮缩放它会用到两个这次刚立起来的东西一是anchor这个套路会原样复用。缩放的时候要保住的是鼠标底下那个点缩放前后待在原地——和这一篇的 anchor 是同一个思路先记住一个点再做变换最后让那个点回到原处。二是setScale该有上下限了。滚轮是连续事件用户能连着滚二十下。不夹紧范围的话scale会一路乘到inf屏幕上什么都算不出来——这不是缩放到极限是程序坏了。上一篇我在View里放 setter 的时候说过这个伏笔下一篇就轮到它了。坚持到这里已经很厉害啦辛苦啦ʕ • ᴥ • ʔづ♡ど
返回列表