
平移看起来是 CAD 里最简单的一个交互比画线、比捕捉、比布尔运算都简单。但我第一次写完中键拖动之后遇到的实际问题是拖动过程中图会漂鼠标停下时视图跟着抖一下快速拖动还会跳。后来把逻辑重写成抓住一个点的思路问题基本消失了。这篇是这个系列第四篇前面搭好了视图变换和缩放。平移和缩放共享同一套坐标系统所以先把坐标约定讲清楚否则后面代码没法看。先确定坐标约定CAD 里至少有三层坐标世界坐标world图元真实的位置与视图无关。视图坐标view经过平移和缩放后准备送进绘制管线的坐标。屏幕坐标screen/device窗口像素坐标原点在左上角y 轴向下。我采用的变换是先缩放、再平移# view (world - pan) * scale# 反变换world view / scale pan其中pan用世界坐标表示含义是视图左上角对应的世界坐标。这样定义有个好处pan直接就是世界坐标缩放时围绕某个点调整pan的推导比较直观。另一种常见写法是把平移量放在屏幕空间即view world * scale offset。两种都对但混用会出问题。我一开始就是缩放按世界坐标写、平移按屏幕坐标写结果拖动时缩放中心全乱。统一成一种并且写死在注释里这件事后面会省很多时间。朴素的实现为什么不好用最直接的做法是在鼠标移动事件里累加屏幕位移defmouseMoveEvent(self,event):deltaevent.position()-self._last_pos self.pan-delta/self.scale# 注意 y 轴方向self._last_posevent.position()self.update()这段代码能跑但有几个问题误差累积。每次移动都做一次除法和减法浮点误差会一点点堆起来。单次看不出来长时间拖动后视图和鼠标的相对关系会偏。依赖事件频率。如果某一帧事件被合并或者丢了一个delta就少算或多算视图会跳。光标跳变。当鼠标移到窗口边缘、系统把光标夹回边界时delta会出现一个巨大的值视图瞬间飞出去。所以我不再累加位移而是改成记录锚点。抓住一个点核心思路思路一句话按下中键的那一刻算出鼠标位置对应的世界坐标记为锚点anchor_world。此后每次鼠标移动都调整pan使得这个锚点重新投影回当前鼠标所在的屏幕位置。这样做的意义在于不依赖位移的累加每次都是绝对计算误差不会累积。即使丢了几个移动事件下一次移动也会自动纠正回来。光标被夹到边界时只要鼠标屏幕位置是真实的视图就不会飞。数学推导不复杂。设屏幕点sy 轴向下窗口高度h则视图坐标y 轴向上为view.x s.x view.y h - s.y由view (world - pan) * scale反解world view / scale pan按下时anchor_world view_press / scale pan_old移动时我们希望anchor_world映射到当前的view_nowview_now (anchor_world - pan_new) * scale两式联立解出pan_newpan_new anchor_world - view_now / scale这就是全部。代码里就是两行减法和一次除法。完整实现我用的是 PySide6写这篇文章时本地是 6.7 系列。核心是一个Viewport类只保留和平移相关的部分绘制部分省略。importsysfromPySide6.QtCoreimportQt,QPointFfromPySide6.QtGuiimportQPainter,QColorfromPySide6.QtWidgetsimportQApplication,QWidgetclassViewport(QWidget):def__init__(self):super().__init__()self.setMouseTracking(True)self.setFocusPolicy(Qt.StrongFocus)# 视图状态pan 是世界坐标scale 是像素/世界单位self.panQPointF(-200.0,-200.0)self.scale2.0# 平移会话状态self._panningFalseself._anchor_worldQPointF()# 按下时锁定的世界坐标# ---------- 坐标变换 ----------defto_view(self,world:QPointF)-QPointF:returnQPointF((world.x()-self.pan.x())*self.scale,(world.y()-self.pan.y())*self.scale,)defto_world(self,view:QPointF)-QPointF:returnQPointF(view.x()/self.scaleself.pan.x(),view.y()/self.scaleself.pan.y(),)defscreen_to_view(self,pos:QPointF)-QPointF:# 屏幕 y 向下视图 y 向上returnQPointF(pos.x(),self.height()-pos.y())# ---------- 事件 ----------defmousePressEvent(self,event):ifevent.button()Qt.MiddleButton:view_pressself.screen_to_view(event.position())# 关键按下瞬间锁定世界坐标锚点self._anchor_worldself.to_world(view_press)self._panningTrueself.setCursor(Qt.ClosedHandCursor)event.accept()returnsuper().mousePressEvent(event)defmouseMoveEvent(self,event):ifself._panning:view_nowself.screen_to_view(event.position())# pan_new anchor_world - view_now / scaleself.panQPointF(self._anchor_world.x()-view_now.x()/self.scale,self._anchor_world.y()-view_now.y()/self.scale,)self.update()event.accept()returnsuper().mouseMoveEvent(event)defmouseReleaseEvent(self,event):ifevent.button()Qt.MiddleButtonandself._panning:self._panningFalseself.unsetCursor()event.accept()returnsuper().mouseReleaseEvent(event)# ---------- 绘制 ----------defpaintEvent(self,event):painterQPainter(self)painter.fillRect(self.rect(),QColor(30,30,30))painter.setPen(QColor(120,180,255))# 画一个 100x100 的世界坐标网格用于肉眼验证平移是否跟手step50foriinrange(-4,5):forjinrange(-4,5):p0self.to_view(QPointF(i*step,j*step))p1self.to_view(QPointF((i1)*step,(j1)*step))painter.drawLine(QPointF(p0.x(),self.height()-p0.y()),QPointF(p1.x(),self.height()-p1.y()),)painter.end()if__name____main__:appQApplication(sys.argv)wViewport()w.resize(960,640)w.show()sys.exit(app.exec())跑起来之后按住中键拖动网格会跟着光标走。判定跟手的标准可以很具体把光标对准某个网格交点按下中键拖动过程中这个交点应该一直在光标正下方。如果它慢慢偏离说明锚点逻辑有问题如果它始终贴合就说明实现是对的。这里paintEvent里画的是斜线网格而不是正交网格只是为了让平移的视觉效果更明显不需要额外解释。实际项目里换成正经的图元绘制即可。几个容易出问题的地方光标跳变前面提到光标被系统夹到窗口边界的情况。有些平台在鼠标按住并移出窗口时会继续发mouseMoveEvent坐标可能是负值或者超过width()/height()。这时如果直接用event.position()视图会突然偏移很大一段。我的处理方式是不裁剪。因为锚点逻辑本身是绝对计算即使view_now超出窗口只要它和光标真实位置一致视图就不会飞只会跟着光标走。真正需要防的是光标被夹住但事件坐标被改写的情况这在部分平台上确实存在。这一点我没有在所有平台上验证过如果你遇到可以在mouseMoveEvent里判断event.position()是否和上一次相同相同就跳过。缩放与平移的联动如果缩放是围绕鼠标位置做的需要在缩放前后同步调整pan否则缩放后锚点会漂。常用做法是缩放前先算出鼠标对应的世界坐标p改变scale后把pan调整为让p重新回到鼠标屏幕位置defzoom_at(self,screen_pos:QPointF,factor:float):viewself.screen_to_view(screen_pos)world_beforeself.to_world(view)self.scale*factor# 让 world_before 仍然落在同一个 view 位置self.panQPointF(world_before.x()-view.x()/self.scale,world_before.y()-view.y()/self.scale,)self.update()这和pan_new anchor_world - view_now / scale是同一个公式只是锚点从中键按下点换成了鼠标当前位置。所以缩放和平移可以共用一套推导代码量很少。窗口尺寸变化screen_to_view里用到了self.height()。如果窗口被 resize而平移会话正在进行height()变了会导致view_now突变视图跟着跳。稳妥的做法是在resizeEvent里把pan按高度差修正defresizeEvent(self,event):old_hevent.oldSize().height()new_hevent.size().height()ifold_h0andself._panning:# 视图 y 向上窗口变高相当于视图原点在世界坐标里下移self.pan.setY(self.pan.y()(new_h-old_h)/self.scale)super().resizeEvent(event)这段逻辑我没有在缩放会话里也做同样处理因为缩放时窗口一般不会被拖动。如果你需要可以按同样的思路补。浮点精度pan anchor_world - view_now / scale每次都基于anchor_world重算所以单次误差不会累积。但anchor_world本身是按下时算出来的如果那一刻的pan已经有误差误差会一直带着。实践中这个量级很小除非你连续平移几千次并且每次都不重置锚点。真在意的话可以在mouseReleaseEvent里把pan的数值规整到某个精度不过我没这么做因为没必要。和累加位移方案的对比方案优点缺点适用场景累加屏幕位移代码最短直观误差累积、丢事件会跳、边界易飞简单查看器、不需要精确对位锚点绝对计算无累积误差、丢事件可自恢复、边界稳需要正确推导坐标变换CAD、地图、任何需要抓取的交互锚点 惯性手感更顺滑逻辑复杂需要速度估计触屏、需要滑动惯性我选的是第二种。第三种在桌面 CAD 里不是必需的暂时没做。关于跟手的一点判断很多人评价平移手感时会说顺不顺“跟不跟手”听起来很主观。但拆开看可以落到两个可验证的指标锚点偏移拖动过程中同一个世界坐标点投影到屏幕后与鼠标指针的距离是否始终接近 0。帧间连续性相邻两次移动事件之间视图的位移是否和鼠标位移成比例。第一项由锚点逻辑保证第二项由事件处理保证。我在实现之后用了一个很土的办法验证在mouseMoveEvent里把anchor_world投影到屏幕打印它和event.position()的距离。正常情况下这个值应该在 1 像素以内受浮点和取整影响。如果它随拖动时间增长说明有累积误差如果它偶尔跳大说明有事件被异常处理。这个打印我没有留在最终代码里但它在调试阶段比肉眼看网格有用得多。小结中键拖动平移的核心就一个公式pan anchor_world - view_now / scale按下中键时锁定anchor_world之后每次移动都用绝对计算重新求pan。这样既不依赖位移累加也不怕丢事件边界情况也稳。缩放围绕鼠标位置时用的是同一个公式只是锚点换成鼠标当前点。如果你也在写类似的视图控件建议先把坐标约定写死在注释里再实现变换函数最后才写事件处理。顺序反了的话调试时间会明显变长。下一篇我会写网格吸附和捕捉那部分和这里的坐标变换关系更紧密。