从零写一个CAD:中键拖动平移,让图形跟着鼠标走不跑偏

发布时间:2026/10/4 2:52:17
从零写一个CAD:中键拖动平移,让图形跟着鼠标走不跑偏
前面几篇里我已经把画布、网格、世界坐标到屏幕坐标的变换搭起来了。这一篇要处理视图交互里最基础、也最容易被写歪的一个动作按住鼠标中键拖动让画面跟着鼠标走。这件事的直觉描述特别简单——“抓住画布上的一个点拖动的时候让它一直待在鼠标底下”。但真落到代码里会发现这句话其实已经蕴含了全部数学鼠标位移量、视图偏移量、缩放系数三者的关系一个都不能错。错一个拖动就会变成画面跑得比鼠标快或者方向反了。我一开始以为这只是加个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,parentNone):super().__init__(parent)self.setMouseTracking(True)self.setFocusPolicy(Qt.StrongFocus)# 视图状态self.scale1.0# 世界单位 - 像素self.panQPointF(0.0,0.0)# 世界原点在屏幕上的位置# 拖动状态self._draggingFalseself._last_posQPointF()defworld_to_screen(self,wx:float,wy:float)-QPointF:returnQPointF(wx*self.scaleself.pan.x(),wy*self.scaleself.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):painterQPainter(self)painter.fillRect(self.rect(),QColor(30,30,30))painter.setPen(QPen(QColor(70,70,70),1))step50*self.scaleifstep5:step50*self.scale*5# 太小就跳一级# 竖线xself.pan.x()%stepwhilexself.width():painter.drawLine(int(x),0,int(x),self.height())xstep# 横线yself.pan.y()%stepwhileyself.height():painter.drawLine(0,int(y),self.width(),int(y))ystep注意这里取模% step的用法网格线的屏幕位置永远落在pan的周期性偏移上所以拖动pan的时候网格自然就跟着走不需要额外计算哪条是第一条线。这个技巧在无限网格里特别省事。中键拖动的状态机拖动逻辑分三步按下、移动、抬起。Qt 里对应的就是mousePressEvent、mouseMoveEvent、mouseReleaseEvent。defmousePressEvent(self,event):ifevent.button()Qt.MiddleButton:self._draggingTrueself._last_posevent.position()self.setCursor(Qt.ClosedHandCursor)event.accept()else:super().mousePressEvent(event)defmouseMoveEvent(self,event):ifnotself._dragging:returncurevent.position()deltacur-self._last_pos self.pandelta self._last_poscur self.update()event.accept()defmouseReleaseEvent(self,event):ifevent.button()Qt.MiddleButton:self._draggingFalseself.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._draggingTrueself._last_posevent.position()self._grab_worldself.screen_to_world(event.position().x(),event.position().y())self.setCursor(Qt.ClosedHandCursor)event.accept()defmouseMoveEvent(self,event):ifnotself._dragging:returncurevent.position()self.pancur-self._last_pos self._last_poscur# 验证被抓的世界点现在应该正好在鼠标位置backself.world_to_screen(self._grab_world.x(),self._grab_world.y())print(fmouse({cur.x():.2f},{cur.y():.2f}) fgrabbed({back.x():.2f},{back.y():.2f}))self.update()event.accept()跑起来拖动控制台里两个坐标应该是一模一样的。如果不一样说明你的公式里混进了多余的scale或者符号错了。【踩坑提醒】如果你把screen_to_world里的- pan写成了 pan验证时两个坐标会差出一个2 * pan一眼能看出来别硬调参数回头检查公式。缩放和拖动叠加时的顺序问题上一段推导里scale被约掉了说明拖动本身和缩放无关。但一旦你再加上滚轮缩放就得想清楚缩放的锚点在哪。常见的错误做法是滚轮时直接改scalepan不动。这样缩放会围绕世界原点进行视觉上就是画面从左上角飞出去。正确的做法是以鼠标位置为锚点缩放也就是保持鼠标下的世界点不动defwheelEvent(self,event):factor1.1ifevent.angleDelta().y()0else1/1.1beforeself.screen_to_world(event.position().x(),event.position().y())self.scale*factor afterself.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、绘图工具QGraphicsViewScrollHandDrag开箱即用滚动条、缩放都现成场景坐标系固定做无限画布要 hack简单图形编辑器我选自写原因是 CAD 里视图变换是最核心的一块交给框架反而会在后面做捕捉、吸附、标注的时候被它绑住手脚。但如果你只是做个简单的图形查看器QGraphicsView确实省事。最后再说两个小细节光标反馈。按下中键时把光标换成ClosedHandCursor抬起时unsetCursor()用户一眼就知道自己正在拖。这个不算功能但体验差别很大。中途失去焦点。如果拖动过程中用户按了 AltTab 切走mouseReleaseEvent可能收不到_dragging会一直卡在True。稳妥的做法是重写focusOutEvent或者leaveEvent把_dragging强制置为False。这一点我一开始没加后来调试的时候发现切窗口再回来鼠标一动画面就跟着跑才补上。deffocusOutEvent(self,event):self._draggingFalseself.unsetCursor()super().focusOutEvent(event)整个平移做下来代码不到一百行但背后的坐标推导和边界情况不少。我的体会是这类看起来最简单的交互往往是最值得先把数学写清楚再动手的。公式对了代码几乎是照抄公式错了调参数调到天亮也调不对。下一篇我打算处理滚轮缩放的完整版本包括缩放级别限制和以鼠标为中心的平滑缩放。