平移看起来是 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):delta=event.position()-self._last_pos self.pan-=delta/self.scale# 注意 y 轴方向self._last_pos=event.position()self.update()这段代码能跑,但有几个问题:
- 误差累积。每次移动都做一次除法和减法,浮点误差会一点点堆起来。单次看不出来,长时间拖动后视图和鼠标的相对关系会偏。
- 依赖事件频率。如果某一帧事件被合并或者丢了一个,
delta就少算或多算,视图会跳。 - 光标跳变。当鼠标移到窗口边缘、系统把光标"夹"回边界时,
delta会出现一个巨大的值,视图瞬间飞出去。
所以我不再累加位移,而是改成记录锚点。
抓住一个点:核心思路
思路一句话:按下中键的那一刻,算出鼠标位置对应的世界坐标,记为锚点anchor_world。此后每次鼠标移动,都调整pan,使得这个锚点重新投影回当前鼠标所在的屏幕位置。
这样做的意义在于:
- 不依赖位移的累加,每次都是绝对计算,误差不会累积。
- 即使丢了几个移动事件,下一次移动也会自动纠正回来。
- 光标被夹到边界时,只要鼠标屏幕位置是真实的,视图就不会飞。
数学推导不复杂。设屏幕点s(y 轴向下),窗口高度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_now:
view_now = (anchor_world - pan_new) * scale两式联立,解出pan_new:
pan_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.pan=QPointF(-200.0,-200.0)self.scale=2.0# 平移会话状态self._panning=Falseself._anchor_world=QPointF()# 按下时锁定的世界坐标# ---------- 坐标变换 ----------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.scale+self.pan.x(),view.y()/self.scale+self.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_press=self.screen_to_view(event.position())# 关键:按下瞬间锁定世界坐标锚点self._anchor_world=self.to_world(view_press)self._panning=Trueself.setCursor(Qt.ClosedHandCursor)event.accept()returnsuper().mousePressEvent(event)defmouseMoveEvent(self,event):ifself._panning:view_now=self.screen_to_view(event.position())# pan_new = anchor_world - view_now / scaleself.pan=QPointF(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._panning=Falseself.unsetCursor()event.accept()returnsuper().mouseReleaseEvent(event)# ---------- 绘制 ----------defpaintEvent(self,event):painter=QPainter(self)painter.fillRect(self.rect(),QColor(30,30,30))painter.setPen(QColor(120,180,255))# 画一个 100x100 的世界坐标网格,用于肉眼验证平移是否跟手step=50foriinrange(-4,5):forjinrange(-4,5):p0=self.to_view(QPointF(i*step,j*step))p1=self.to_view(QPointF((i+1)*step,(j+1)*step))painter.drawLine(QPointF(p0.x(),self.height()-p0.y()),QPointF(p1.x(),self.height()-p1.y()),)painter.end()if__name__=="__main__":app=QApplication(sys.argv)w=Viewport()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):view=self.screen_to_view(screen_pos)world_before=self.to_world(view)self.scale*=factor# 让 world_before 仍然落在同一个 view 位置self.pan=QPointF(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_h=event.oldSize().height()new_h=event.size().height()ifold_h>0andself._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。这样既不依赖位移累加,也不怕丢事件,边界情况也稳。缩放围绕鼠标位置时,用的是同一个公式,只是锚点换成鼠标当前点。
如果你也在写类似的视图控件,建议先把坐标约定写死在注释里,再实现变换函数,最后才写事件处理。顺序反了的话,调试时间会明显变长。下一篇我会写网格吸附和捕捉,那部分和这里的坐标变换关系更紧密。