从零写一个CAD 04:中键拖动平移,抓住一个点让它一直待在鼠标底下

平移看起来是 CAD 里最简单的一个交互,比画线、比捕捉、比布尔运算都简单。但我第一次写完中键拖动之后,遇到的实际问题是:拖动过程中图会"漂",鼠标停下时视图跟着抖一下,快速拖动还会跳。后来把逻辑重写成"抓住一个点"的思路,问题基本消失了。

这篇是这个系列第四篇,前面搭好了视图变换和缩放。平移和缩放共享同一套坐标系统,所以先把坐标约定讲清楚,否则后面代码没法看。

先确定坐标约定

CAD 里至少有三层坐标:

  • 世界坐标(world):图元真实的位置,与视图无关。
  • 视图坐标(view):经过平移和缩放后,准备送进绘制管线的坐标。
  • 屏幕坐标(screen/device):窗口像素坐标,原点在左上角,y 轴向下。

我采用的变换是"先缩放、再平移":

python 复制代码
# view = (world - pan) * scale
# 反变换:world = view / scale + pan

其中 pan 用世界坐标表示,含义是"视图左上角对应的世界坐标"。这样定义有个好处:pan 直接就是世界坐标,缩放时围绕某个点调整 pan 的推导比较直观。

另一种常见写法是把平移量放在屏幕空间,即 view = world * scale + offset。两种都对,但混用会出问题。我一开始就是缩放按世界坐标写、平移按屏幕坐标写,结果拖动时缩放中心全乱。统一成一种,并且写死在注释里,这件事后面会省很多时间。

朴素的实现为什么不好用

最直接的做法是在鼠标移动事件里累加屏幕位移:

python 复制代码
def mouseMoveEvent(self, event):
    delta = event.position() - self._last_pos
    self.pan -= delta / self.scale   # 注意 y 轴方向
    self._last_pos = event.position()
    self.update()

这段代码能跑,但有几个问题:

  1. 误差累积。每次移动都做一次除法和减法,浮点误差会一点点堆起来。单次看不出来,长时间拖动后视图和鼠标的相对关系会偏。
  2. 依赖事件频率 。如果某一帧事件被合并或者丢了一个,delta 就少算或多算,视图会跳。
  3. 光标跳变 。当鼠标移到窗口边缘、系统把光标"夹"回边界时,delta 会出现一个巨大的值,视图瞬间飞出去。

所以我不再累加位移,而是改成记录锚点。

抓住一个点:核心思路

思路一句话:按下中键的那一刻,算出鼠标位置对应的世界坐标,记为锚点 anchor_world。此后每次鼠标移动,都调整 pan,使得这个锚点重新投影回当前鼠标所在的屏幕位置。

这样做的意义在于:

  • 不依赖位移的累加,每次都是绝对计算,误差不会累积。
  • 即使丢了几个移动事件,下一次移动也会自动纠正回来。
  • 光标被夹到边界时,只要鼠标屏幕位置是真实的,视图就不会飞。

数学推导不复杂。设屏幕点 s(y 轴向下),窗口高度 h,则视图坐标(y 轴向上)为:

text 复制代码
view.x = s.x
view.y = h - s.y

由 view = (world - pan) * scale 反解:

text 复制代码
world = view / scale + pan

按下时:

text 复制代码
anchor_world = view_press / scale + pan_old

移动时,我们希望 anchor_world 映射到当前的 view_now:

text 复制代码
view_now = (anchor_world - pan_new) * scale

两式联立,解出 pan_new:

text 复制代码
pan_new = anchor_world - view_now / scale

这就是全部。代码里就是两行减法和一次除法。

完整实现

我用的是 PySide6(写这篇文章时本地是 6.7 系列)。核心是一个 Viewport 类,只保留和平移相关的部分,绘制部分省略。

python 复制代码
import sys
from PySide6.QtCore import Qt, QPointF
from PySide6.QtGui import QPainter, QColor
from PySide6.QtWidgets import QApplication, QWidget


class Viewport(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 = False
        self._anchor_world = QPointF()   # 按下时锁定的世界坐标

    # ---------- 坐标变换 ----------
    def to_view(self, world: QPointF) -> QPointF:
        return QPointF(
            (world.x() - self.pan.x()) * self.scale,
            (world.y() - self.pan.y()) * self.scale,
        )

    def to_world(self, view: QPointF) -> QPointF:
        return QPointF(
            view.x() / self.scale + self.pan.x(),
            view.y() / self.scale + self.pan.y(),
        )

    def screen_to_view(self, pos: QPointF) -> QPointF:
        # 屏幕 y 向下,视图 y 向上
        return QPointF(pos.x(), self.height() - pos.y())

    # ---------- 事件 ----------
    def mousePressEvent(self, event):
        if event.button() == Qt.MiddleButton:
            view_press = self.screen_to_view(event.position())
            # 关键:按下瞬间锁定世界坐标锚点
            self._anchor_world = self.to_world(view_press)
            self._panning = True
            self.setCursor(Qt.ClosedHandCursor)
            event.accept()
            return
        super().mousePressEvent(event)

    def mouseMoveEvent(self, event):
        if self._panning:
            view_now = self.screen_to_view(event.position())
            # pan_new = anchor_world - view_now / scale
            self.pan = QPointF(
                self._anchor_world.x() - view_now.x() / self.scale,
                self._anchor_world.y() - view_now.y() / self.scale,
            )
            self.update()
            event.accept()
            return
        super().mouseMoveEvent(event)

    def mouseReleaseEvent(self, event):
        if event.button() == Qt.MiddleButton and self._panning:
            self._panning = False
            self.unsetCursor()
            event.accept()
            return
        super().mouseReleaseEvent(event)

    # ---------- 绘制 ----------
    def paintEvent(self, event):
        painter = QPainter(self)
        painter.fillRect(self.rect(), QColor(30, 30, 30))

        painter.setPen(QColor(120, 180, 255))
        # 画一个 100x100 的世界坐标网格,用于肉眼验证平移是否跟手
        step = 50
        for i in range(-4, 5):
            for j in range(-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 重新回到鼠标屏幕位置:

python 复制代码
def zoom_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 按高度差修正:

python 复制代码
def resizeEvent(self, event):
    old_h = event.oldSize().height()
    new_h = event.size().height()
    if old_h > 0 and self._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 里不是必需的,暂时没做。

关于"跟手"的一点判断

很多人评价平移手感时会说"顺不顺""跟不跟手",听起来很主观。但拆开看,可以落到两个可验证的指标:

  1. 锚点偏移:拖动过程中,同一个世界坐标点投影到屏幕后与鼠标指针的距离,是否始终接近 0。
  2. 帧间连续性:相邻两次移动事件之间,视图的位移是否和鼠标位移成比例。

第一项由锚点逻辑保证,第二项由事件处理保证。我在实现之后用了一个很土的办法验证:在 mouseMoveEvent 里把 anchor_world 投影到屏幕,打印它和 event.position() 的距离。正常情况下这个值应该在 1 像素以内(受浮点和取整影响)。如果它随拖动时间增长,说明有累积误差;如果它偶尔跳大,说明有事件被异常处理。

这个打印我没有留在最终代码里,但它在调试阶段比肉眼看网格有用得多。

小结

中键拖动平移的核心就一个公式:

text 复制代码
pan = anchor_world - view_now / scale

按下中键时锁定 anchor_world,之后每次移动都用绝对计算重新求 pan。这样既不依赖位移累加,也不怕丢事件,边界情况也稳。缩放围绕鼠标位置时,用的是同一个公式,只是锚点换成鼠标当前点。

如果你也在写类似的视图控件,建议先把坐标约定写死在注释里,再实现变换函数,最后才写事件处理。顺序反了的话,调试时间会明显变长。下一篇我会写网格吸附和捕捉,那部分和这里的坐标变换关系更紧密。

相关推荐
workflower1 小时前
从生成式模型开始的技术延伸
人工智能·机器学习·机器人·云计算·无人机
kali-Myon1 小时前
ARTEX:AI 驱动的自动化漏洞挖掘平台
人工智能·安全·ai·自动化·web
论文复现现场1 小时前
RTX 3090 24GB 适合科研吗?单卡论文复现、PyTorch 显存检查与 OOM 排错
人工智能·pytorch·python·深度学习·cuda·rtx3090
7yewh1 小时前
SLAM 相机与图像(4)
人工智能·数码相机·计算机视觉
FYKJ_20101 小时前
springboot家政服务平台66766-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·mysql·spark·课程设计
正经教主1 小时前
【FDE系列】阶段3:Day 71:高级检索 — 查询改写与 HyDE
人工智能·rag·fde
天空之城--1 小时前
Android一周动态:Android 18首次官宣、Compose Material3 1.4转正(5趋势+5资讯)
android·人工智能·flutter·架构·android jetpack
ZzT1 小时前
Claude 干活的时候,在终端里打砖块
人工智能·ai编程·claude