☰
从零写一个CAD:中键拖动平移,让图形跟着鼠标走不跑偏
2026/10/4 2:51:33 网站建设 项目流程

前面几篇里,我已经把画布、网格、世界坐标到屏幕坐标的变换搭起来了。这一篇要处理视图交互里最基础、也最容易被写歪的一个动作:按住鼠标中键拖动,让画面跟着鼠标走。

这件事的直觉描述特别简单——“抓住画布上的一个点,拖动的时候让它一直待在鼠标底下”。但真落到代码里,会发现这句话其实已经蕴含了全部数学:鼠标位移量、视图偏移量、缩放系数三者的关系,一个都不能错。错一个,拖动就会变成"画面跑得比鼠标快"或者"方向反了"。

我一开始以为这只是加个offset += delta,后来发现得先把坐标系理清楚,否则调参数调到怀疑人生。

先把"抓住一个点"翻译成公式

假设当前视图有一个平移量pan,也就是世界坐标原点在屏幕上被推到了哪里;还有一个缩放系数scale,表示一个世界单位对应多少像素。

屏幕坐标和世界坐标的关系是:

screen = world * scale + pan

反过来:

world = (screen - pan) / scale

现在用户按下中键时,鼠标位于屏幕位置s0。这一刻,鼠标底下压着的那个世界点w是固定的——我们"抓住"的就是它:

w = (s0 - pan0) / scale

拖动过程中,鼠标来到s1。我们希望刚才抓的那个世界点w,此时出现在s1的位置:

s1 = w * scale + pan1

把w代进去:

s1 = (s0 - pan0) + pan1

整理一下:

pan1 = pan0 + (s1 - s0)

【关键结论】视图的平移增量,就等于鼠标的屏幕位移增量,和scale无关。

这个结论挺反直觉的:很多人会下意识写成pan += delta / scale或者pan += delta * scale,结果就是缩放级别一变,拖动手感就跟着变。实际上,因为位移和偏移都在屏幕像素空间里,缩放系数会在推导中自然约掉。

反过来说,如果哪天你发现"放大之后拖动变慢了",基本可以确定是哪里多乘了一个scale。

用 PySide6 搭一个最小可跑的例子

为了能真跑起来,我用 PySide6。版本上,PySide6 目前 6.x 系列都能跑,我本地是 6.7 附近,接口没变化。

先搭一个最小的画布控件,只做两件事:画网格、处理中键拖动。

# canvas.pyfromPySide6.QtCoreimportQt,QPointFfromPySide6.QtGuiimportQPainter,QPen,QColorfromPySide6.QtWidgetsimportQWidgetclassCanvas(QWidget):def__init__(self,parent=None):super().__init__(parent)self.setMouseTracking(True)self.setFocusPolicy(Qt.StrongFocus)# 视图状态self.scale=1.0# 世界单位 -> 像素self.pan=QPointF(0.0,0.0)# 世界原点在屏幕上的位置# 拖动状态self._dragging=Falseself._last_pos=QPointF()defworld_to_screen(self,wx:float,wy:float)->QPointF:returnQPointF(wx*self.scale+self.pan.x(),wy*self.scale+self.pan.y())defscreen_to_world(self,sx:float,sy:float)->QPointF:returnQPointF((sx-self.pan.x())/self.scale,(sy-self.pan.y())/self.scale)

这里有个我想强调的点:pan用的是屏幕坐标,不是世界坐标。这样world_to_screen就是一次乘加,screen_to_world就是一次减除,两个函数互为逆运算,后面排查问题的时候非常直观。

网格绘制我直接用屏幕坐标循环,简单粗暴,够用:

defpaintEvent(self,event):painter=QPainter(self)painter.fillRect(self.rect(),QColor(30,30,30))painter.setPen(QPen(QColor(70,70,70),1))step=50*self.scaleifstep<5:step=50*self.scale*5# 太小就跳一级# 竖线x=self.pan.x()%stepwhilex<self.width():painter.drawLine(int(x),0,int(x),self.height())x+=step# 横线y=self.pan.y()%stepwhiley<self.height():painter.drawLine(0,int(y),self.width(),int(y))y+=step

注意这里取模% step的用法:网格线的屏幕位置永远落在pan的周期性偏移上,所以拖动pan的时候,网格自然就跟着走,不需要额外计算哪条是"第一条线"。这个技巧在无限网格里特别省事。

中键拖动的状态机

拖动逻辑分三步:按下、移动、抬起。Qt 里对应的就是mousePressEvent、mouseMoveEvent、mouseReleaseEvent。

defmousePressEvent(self,event):ifevent.button()==Qt.MiddleButton:self._dragging=Trueself._last_pos=event.position()self.setCursor(Qt.ClosedHandCursor)event.accept()else:super().mousePressEvent(event)defmouseMoveEvent(self,event):ifnotself._dragging:returncur=event.position()delta=cur-self._last_pos self.pan+=delta self._last_pos=cur self.update()event.accept()defmouseReleaseEvent(self,event):ifevent.button()==Qt.MiddleButton:self._dragging=Falseself.unsetCursor()event.accept()else:super().mouseReleaseEvent(event)

核心就一句self.pan += delta,正好对应前面推导的pan1 = pan0 + (s1 - s0)。

这里有几个地方容易出问题,我一个个说。

第一个坑:用event.pos()还是event.position()。Qt5 里pos()返回的是QPoint,整数像素;Qt6 里推荐position(),返回QPointF,浮点。如果你用pos(),在慢慢拖动的时候,鼠标每次位移不到 1 像素,整数截断会让画面一顿一顿的。PySide6 里position()是首选。

第二个坑:不要用event.globalPos()。全局坐标在多屏、系统缩放(比如 Windows 的 125% DPI 缩放)下会和你窗口里的坐标不一致,你抓的那个点会漂。用窗口内的局部坐标最稳。

第三个坑:_last_pos的更新时机。必须每次移动后立刻更新成当前位置,不能等抬起再更新。否则第二帧的 delta 会从最初的位置算,画面会加速乱跑。

验证一下:抓点是不是真的不动

光看代码不够,得验证"抓住的点一直待在鼠标底下"这句话是不是成立。我写了一个最简单的手动验证:在按下时记下鼠标下的世界点,移动过程中不断重算它现在落在屏幕哪里,打印出来。

defmousePressEvent(self,event):ifevent.button()==Qt.MiddleButton:self._dragging=Trueself._last_pos=event.position()self._grab_world=self.screen_to_world(event.position().x(),event.position().y())self.setCursor(Qt.ClosedHandCursor)event.accept()defmouseMoveEvent(self,event):ifnotself._dragging:returncur=event.position()self.pan+=cur-self._last_pos self._last_pos=cur# 验证:被抓的世界点,现在应该正好在鼠标位置back=self.world_to_screen(self._grab_world.x(),self._grab_world.y())print(f"mouse=({cur.x():.2f},{cur.y():.2f}) "f"grabbed=({back.x():.2f},{back.y():.2f})")self.update()event.accept()

跑起来拖动,控制台里两个坐标应该是一模一样的。如果不一样,说明你的公式里混进了多余的scale或者符号错了。

【踩坑提醒】如果你把screen_to_world里的- pan写成了+ pan,验证时两个坐标会差出一个2 * pan,一眼能看出来,别硬调参数,回头检查公式。

缩放和拖动叠加时的顺序问题

上一段推导里,scale被约掉了,说明拖动本身和缩放无关。但一旦你再加上"滚轮缩放",就得想清楚缩放的锚点在哪。

常见的错误做法是:滚轮时直接改scale,pan不动。这样缩放会围绕世界原点进行,视觉上就是画面"从左上角飞出去"。正确的做法是以鼠标位置为锚点缩放,也就是保持鼠标下的世界点不动:

defwheelEvent(self,event):factor=1.1ifevent.angleDelta().y()>0else1/1.1before=self.screen_to_world(event.position().x(),event.position().y())self.scale*=factor after=self.screen_to_world(event.position().x(),event.position().y())# 让 before == after,反解 pan 的修正量self.pan+=(after-before)*self.scale self.update()

这段逻辑和拖动其实是同一个思路:先确定哪个世界点要保持不动,再反解视图参数。拖动时保持的是"按下瞬间抓的那个点",缩放时保持的是"鼠标当前位置下的点"。把这两个场景套进同一个思维框架,代码就不容易写乱。

浮点精度和长时间拖动的漂移

pan是QPointF,底层是双精度浮点。正常用法下,拖几个小时也不会明显漂。但如果你玩一个花活——每次移动都pan = pan + delta,而delta又恰好是很小的浮点数,长期累积理论上会有微小误差,实际影响小到肉眼看不见。

真正需要注意的是别把pan存成整数。我见过有实现图省事用QPoint,结果放大到 100 倍时,网格边缘会出现 1 像素的抖动。用QPointF就没这问题。

另一个不太起眼的地方:pan的数值会随着拖动不断增大或减小。如果用户一直往一个方向拖,pan能到几十万。这时候world_to_screen里的wx * scale + pan会因为大数加小数损失精度。解决办法是定期把pan归一化——比如当|pan|超过阈值时,把它减去若干倍的网格间距,因为网格是周期性的,视觉上完全看不出来。这一点我在自己的项目里没做到那么严格,只做了个简单的范围限制,够用就行。

和"用 QGraphicsView 现成的拖动"对比一下

Qt 自带的QGraphicsView有ScrollHandDrag模式,也能实现类似的拖动。到底该用哪个?

方案优点缺点适用场景
自写pan平移完全掌控变换,方便做无限网格、自定义坐标需要自己处理所有交互细节自研 CAD、绘图工具
QGraphicsView+ScrollHandDrag开箱即用,滚动条、缩放都现成场景坐标系固定,做无限画布要 hack简单图形编辑器

我选自写,原因是 CAD 里视图变换是最核心的一块,交给框架反而会在后面做捕捉、吸附、标注的时候被它绑住手脚。但如果你只是做个简单的图形查看器,QGraphicsView确实省事。

最后再说两个小细节

光标反馈。按下中键时把光标换成ClosedHandCursor,抬起时unsetCursor(),用户一眼就知道自己正在拖。这个不算功能,但体验差别很大。

中途失去焦点。如果拖动过程中用户按了 Alt+Tab 切走,mouseReleaseEvent可能收不到,_dragging会一直卡在True。稳妥的做法是重写focusOutEvent或者leaveEvent,把_dragging强制置为False。这一点我一开始没加,后来调试的时候发现切窗口再回来,鼠标一动画面就跟着跑,才补上。

deffocusOutEvent(self,event):self._dragging=Falseself.unsetCursor()super().focusOutEvent(event)

整个平移做下来,代码不到一百行,但背后的坐标推导和边界情况不少。我的体会是,这类"看起来最简单"的交互,往往是最值得先把数学写清楚再动手的。公式对了,代码几乎是照抄;公式错了,调参数调到天亮也调不对。

下一篇我打算处理滚轮缩放的完整版本,包括缩放级别限制和以鼠标为中心的平滑缩放。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询