拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
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,结尾我列了三个尾巴:

  1. 画坐标轴的时候绕过了toScreen,直接读offsetX / offsetY
  2. 两个 getter 没有const
  3. resizeEvent会把图纸拽回窗口中心

这一篇做中键拖动平移,顺手把第一和第三个尾巴收掉。第二个尾巴有个意外的结局,最后一节说。不过在写代码之前,有一件事得先想明白——平移的时候,到底什么东西变了?答案是:图纸上没有任何一个数字发生变化。



一、平移改的不是图纸,是"你在看哪儿"

这是 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、先说一个看起来更直观、但会出问题的写法

很多人第一反应是这样:鼠标每移动一次,就算出它相对上一次移动了多少像素,然后把这个差值加到偏移量上。

// 不推荐的写法offsetX+=screenPos.x()-lastMousePos.x();offsetY+=screenPos.y()-lastMousePos.y();lastMousePos=screenPos;

它能跑,但有两个毛病:

一是误差会累积。每次移动都要算一次减法、做一次加法,整数像素的取整误差一点点攒起来。拖久了,你会发现"我明明拖回原地,图纸却没完全回到原来的位置"。

二是松手再按就要重新对准。因为这套逻辑依赖"上一次鼠标在哪",而这个量一旦断了(松手、切窗口、切工具),就得重新建立基准。基准没建好的那一帧,图会跳一下。

2.2、正确的思路:记住"抓住的那个点"

换个角度想:拖动的时候,真正在发生的是——你用手指按住纸上的某一个点,然后让这个点始终跟着你的手指。

所以只需要记住一件事:按下鼠标那一刻,鼠标底下是图纸上的哪个点。

voidCanvas::mousePressEvent(QMouseEvent*event){if(event->button()==Qt::MiddleButton){isPanning=true;anchorCAD=view->toCAD(event->pos());// ← 就记这一个点}QWidget::mousePressEvent(event);}

anchorCAD就是那个"手指按住的点"。之后鼠标每一次移动,做的事都一样:

voidCanvas::mouseMoveEvent(QMouseEvent*event){QPoint screenPosition=event->pos();if(isPanning){view->alignTo(anchorCAD,screenPosition);// 让 anchor 回到鼠标底下}mousePosition=view->toCAD(screenPosition);update();}

没有累加,没有"上一次",每次都是从 anchor 重新算一遍。误差不可能累积,因为压根没有地方给它攒。

2.3、alignTo 里的那个加号

voidView::alignTo(constPoint&cadPoint,constQPoint&screenPos){offsetX=screenPos.x()-cadPoint.x*scale;offsetY=screenPos.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){isPanning=false;}

三个细节:

一、为什么 anchor 要在mousePress里记,不在mouseMove里记"第一次移动"。

因为按下和第一次移动之间隔着一段时间。这段时间里用户可能已经在移动鼠标了——如果等到第一次 move 才记 anchor,那记的就是"已经移动过一点之后"的位置,图纸会先跳一下再跟手。"抓住"这个动作发生在按下那一刻,状态就应该在那一刻建立。

二、mousePressEvent末尾那一句QWidget::mousePressEvent(event);别漏。

你自己只处理了中键,左键右键还得让基类有机会处理。这是 Qt 事件处理的一条通用规矩:处理了可以返回,没处理的一定要交给基类——否则某些默认行为(比如焦点转移)会莫名其妙失效。

三、中键在 CAD 里是个有传统的键。

AutoCAD 里中键按住是平移,滚轮是缩放,这就是为什么这一版选它。左键留给"确定",右键留给"取消/结束"——第 2 篇已经占上了。三个键各管一件事,不用教,用惯 CAD 的人自己就会。



四、收回第一个尾巴:坐标轴改用 toScreen

上一篇我在paintEvent里留了这样一段:

// 之前的写法:绘制代码直接读 offsetpainter.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());painter.drawEllipse(QPoint(static_cast<int>(view->getOffsetX()),static_cast<int>(view->getOffsetY())),4,4);

这一版改成了:

// CAD 原点 (0, 0) 落在屏幕上的哪个像素——它就是十字轴的交点constQPoint originScreen=view->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 oldSize=event->oldSize();// 首次显示时 oldSize 是无效尺寸(-1,-1),没有"旧中心"可言,直接居中if(!oldSize.isValid()){QWidget::resizeEvent(event);recenterView();return;}// 1. 用【旧尺寸的中心】和【当前的 offset】,算出那里是图纸上的哪个点constPoint centerCAD=view->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 centerCAD=view->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 的时候说过这个伏笔,下一篇就轮到它了。

🎇坚持到这里已经很厉害啦,辛苦啦🎇
ʕ • ᴥ • ʔ
づ♡ど
返回列表