从零写一个CAD 05:以鼠标为中心的滚轮缩放,招法能复用但顺序不能反

做简易 CAD 视图器到第五篇,前四篇把画布、图元、世界坐标与屏幕坐标的映射、以及基础的平移(pan)都搭起来了。这一篇专门处理滚轮缩放。

说实话,我一开始以为缩放是最简单的一个功能:监听 wheelEvent,拿到 delta,把 self.scale 乘个系数,重绘,完事。真正写下去才发现,问题不在"乘系数",而在"围绕哪个点放大"。如果只是改 scale,视图会围绕画布原点缩放,鼠标指着的那条线会飞快地跑出屏幕,用起来非常别扭。

这篇文章记录的就是我实际实现"以鼠标为中心缩放"的过程:公式怎么推,代码怎么写,以及一个我调了挺久才想明白的点------平移和缩放的更新顺序不能反。

先明确坐标系这一层

在讲缩放之前,得把坐标关系摆清楚,不然后面公式会读不下去。我目前的视图器里有三套坐标:

坐标 含义 谁在用
世界坐标 (wx, wy) 图元本身存储的坐标,原点固定 数据模型
屏幕坐标 (sx, sy) 控件像素坐标,原点在左上角 鼠标事件、绘制
视图变换参数 scale + offset,负责前两者互转 视图器内部

变换关系我用的是最常见的仿射形式:

复制代码
sx = wx * scale + offset_x
sy = wy * scale + offset_y

注意这里 y 轴方向。Qt 的屏幕坐标 y 向下为正,而 CAD 里习惯 y 向上为正,所以在实际映射时 y 那一项通常会带个负号。为了不让公式变复杂,本文下面统一假设已经处理过 y 翻转,只讨论 x 方向的缩放逻辑------y 方向完全对称,处理方式一模一样。

反变换也很直接:

复制代码
wx = (sx - offset_x) / scale
wy = (sy - offset_y) / scale

平移(pan)改的是 offset,缩放(zoom)改的是 scale。两者都会影响同一个映射关系,这就是后面顺序问题的来源。

以鼠标为中心缩放,到底在要求什么

"以鼠标为中心缩放"用一句话说清楚就是:缩放前后,鼠标位置对应的世界坐标点,必须落在同一个屏幕位置上。

也就是鼠标下面那个"东西"不动,周围的视图围绕它放大或缩小。这个约束就是推导公式的全部依据。

设:

  • 鼠标屏幕坐标 (mx, my),缩放前视图参数 scale0, offset0,缩放后 scale1, offset1
  • 缩放前鼠标下的世界坐标 (wx, wy)

由反变换:

复制代码
wx = (mx - offset0_x) / scale0

缩放后,我们要求这个 wx 仍然映射回 mx:

复制代码
mx = wx * scale1 + offset1_x

把 wx 代入:

复制代码
mx = ((mx - offset0_x) / scale0) * scale1 + offset1_x

解出 offset1_x:

复制代码
offset1_x = mx - (mx - offset0_x) * (scale1 / scale0)

这就是核心公式。令 k = scale1 / scale0 为缩放倍率,则:

复制代码
offset1_x = mx - (mx - offset0_x) * k

y 方向同理:

复制代码
offset1_y = my - (my - offset0_y) * k

推导到这里其实就结束了。真正容易翻车的地方在后面。

一个能跑的 PySide6 最小实现

我用的环境是 Python 3.11 + PySide6 6.6.x。这个版本区间的 QWheelEvent 接口是稳定的,angleDelta()、position() 都能正常用。如果你用的是 PyQt5,API 名字略有差异(event.pos() 而不是 event.position()),需要注意替换。

先说事件处理部分:

python 复制代码
from PySide6.QtCore import QPointF
from PySide6.QtGui import QWheelEvent
from PySide6.QtWidgets import QWidget


class CanvasView(QWidget):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.scale = 1.0
        self.offset = QPointF(0.0, 0.0)
        self.min_scale = 0.02
        self.max_scale = 200.0

    def wheelEvent(self, event: QWheelEvent) -> None:
        # 1. 取鼠标在控件内的位置
        pos = event.position()          # QPointF
        mx, my = pos.x(), pos.y()

        # 2. 计算缩放倍率
        delta = event.angleDelta().y()  # 一格通常是 120
        if delta == 0:
            return
        factor = 1.0015 ** delta        # 平滑一点

        new_scale = self.scale * factor
        new_scale = max(self.min_scale, min(self.max_scale, new_scale))
        # 被 clamp 之后,实际倍率要重新算,不能用 factor
        k = new_scale / self.scale

        # 3. 先算新的 offset,再更新 scale ------ 顺序很关键
        self.offset.setX(mx - (mx - self.offset.x()) * k)
        self.offset.setY(my - (my - self.offset.y()) * k)
        self.scale = new_scale

        self.update()

这段代码不长,但里面有两个我踩过的点,值得单独拎出来讲。

坑一:clamp 之后必须重算 k

我一开始写的是:

python 复制代码
factor = 1.0015 ** delta
self.scale *= factor
if self.scale < self.min_scale:
    self.scale = self.min_scale
elif self.scale > self.max_scale:
    self.scale = self.max_scale

然后 offset 用 factor 去算。这样在缩放没触到边界时没问题,但一旦撞到 min/max,self.scale 被夹住不再变化,offset 却还在按 factor 移动,视图就会在边界处出现"缩放不动但画面在飘"的现象。用户感受就是滚轮还在滚,画面却歪了。

修法很简单:先把 new_scale 算出来并 clamp,再用 k = new_scale / self.scale 反推实际倍率。代码里就是上面那段。任何时候,offset 的更新都必须基于实际生效的倍率,而不是你打算施加的倍率。

坑二:顺序------为什么必须先改 offset 再改 scale

这是本文标题里说的"顺序不能反"。看公式:

复制代码
offset1_x = mx - (mx - offset0_x) * k

这里的 k = new_scale / old_scale,offset0_x 是旧 offset。也就是说,公式右边用的必须是旧的 scale 和旧的 offset。

如果你图省事写成这样:

python 复制代码
# 错误写法
self.offset.setX(mx - (mx - self.offset.x()) * (new_scale / self.scale))
self.scale = new_scale

其实这个是对的,因为它是在改 scale 之前先取到了 self.scale 的旧值。真正错的是下面这种:

python 复制代码
# 错误写法:先改 scale,再算 offset
self.scale = new_scale
k = new_scale / self.scale   # 这里 self.scale 已经是新值了,k 恒等于 1
self.offset.setX(mx - (mx - self.offset.x()) * k)

这时候 k 永远是 1,offset 一动不动,缩放中心直接跑回画布原点。我第一版就是这么写的,滚轮一转,视图"嗖"地朝左上角飞出去,当时还以为是符号写反了,查了半天才发现是顺序问题。

所以顺序规则可以总结成一句话:先用旧参数算新 offset,再更新 scale。公式里的每一项都绑定到"缩放前"这个状态,任何一步提前改了 scale,公式就失效了。

【关键结论】offset 和 scale 不是两个独立变量,它们通过"鼠标下的世界点不动"这个约束耦合在一起。约束方程里的 scale 必须是旧值,所以更新顺序被公式本身锁死了。

为什么"复用平移的招法"在这里行不通

写 pan 的时候,逻辑很清爽:鼠标按下 → 记录起点 → 移动时 offset += (dx, dy),一次只改一个变量。这套"增量累加"的写法很容易让人产生惯性,想着缩放也照搬:记录鼠标起点,滚轮时按位移比例改 offset。

但缩放不一样。缩放的本质不是"平移一段距离",而是"以某点为不动点做一次相似变换"。相似变换里,offset 的变化量是 (mx - offset0_x) * (k - 1),它依赖于当前的 offset 和鼠标位置,不是固定增量。如果直接套用平移的"累加位移"思路,比如:

python 复制代码
# 想当然的写法,实际会漂移
self.offset.setX(self.offset.x() + (mx - self.offset.x()) * (1 - k))

它和正确公式在数学上其实是等价的(你可以展开验证一下),但问题在于一旦你在中途 clamp 了 scale,或者在同一个事件里做了不止一次变换,增量写法就会累积误差,而绝对公式写法不会。所以我的建议是:缩放一律用绝对公式,不要用增量。

平移可以增量,缩放最好绝对,这是两者在实现上最本质的差别。

平滑缩放的系数怎么选

factor = 1.0015 ** delta 里的 1.0015 是我自己试出来的,不是官方推荐值。标准滚轮一格 delta = 120,代入得到约 1.197,也就是一格放大将近 20%。这个手感我觉得还行,一格一格滚下去不会太跳。

如果你想要更细腻,把 1.0015 调到 1.0008 左右,一格约 10%。但太小了用户会觉得"滚了半天没反应",太大了会"一格就飞了"。这个值没有标准答案,跟控件尺寸、默认 scale 都有关系,建议实际调。

另外触控板的情况不太一样。触控板滚轮事件可能一次只给 delta = 1 或 2,用同一个底数会导致几乎没反应。这一点我的处理是:如果检测到 abs(delta) < 120,就用一个更大的底数 ,比如 1.05 ** delta。不过触控板在不同系统上的 delta 行为差异较大,我这里只在 macOS 上简单验证过,Windows 和 Linux 上具体表现我没有逐一测试,实际项目里最好按平台分别调。

边界保护与浮点精度

两个小细节。

第一,min_scale 和 max_scale 一定要设。完全没有上限的话,用户疯狂滚动会把 scale 推到 1e8 甚至更大,然后图元坐标乘上这个数直接溢出成 inf,画面全白。我设的是 0.02 到 200,对一般工程图够用。

第二,浮点误差。连续缩放几百次之后,offset 的尾数会积累误差,虽然肉眼看不出来,但如果你有"缩放到指定视图"这类需要精确恢复状态的功能,误差就会暴露。我的做法是提供一个 reset_view(),直接重置 scale 和 offset 到初始值,而不是靠反向缩放"退回去"。反向缩放永远退不回精确的初始状态,这个我没必要硬扛。

验证方式

光看代码不好判断对不对,我一般用一个很土但有效的验证方法:在画布上画一个固定的十字标记在世界坐标 (0, 0) 处,然后盯着它滚。

  • 如果鼠标停在十字上滚,十字应该纹丝不动;
  • 如果鼠标停在十字左边一段距离滚,十字会从鼠标位置向远离鼠标的方向移动。

这两条符合,说明缩放中心是对的。如果十字在鼠标停下时还会缓慢平移,那就是顺序或者 clamp 的问题,回去检查 k 是不是用了新 scale 算的。

再补一个:在 wheelEvent 里临时打印 (mx, my) 和鼠标下的世界坐标 ((mx - offset.x()) / scale, ...),缩放前后这两个世界坐标应该几乎相等(差一个浮点精度)。这个自检我在开发时用过很多次,非常直接。

python 复制代码
wx_before = (mx - self.offset.x()) / self.scale
# ... 更新之后 ...
wx_after = (mx - self.offset.x()) / self.scale
print(wx_before, wx_after)   # 两者应当非常接近

关于这一篇的收尾

缩放这件事,公式本身推导出来只有三行,但真正落地的难点在"状态更新的时序"和"边界处理"上。平移和缩放看起来都是改视图参数,招法可以互相借鉴,但缩放的更新顺序是被公式锁死的,不能像平移那样随意累加。

下一篇打算处理"框选"和"命中检测"------那边会涉及坐标反变换的批量调用,正好可以复用这一篇的映射关系。如果对视图变换这块有兴趣,建议先把这一篇的 offset 公式自己推一遍再写代码,比直接抄要稳得多。

=备用标题=

  1. 以鼠标为中心缩放的公式推导与 PySide6 实现:一个顺序问题让我调了很久
  2. 自研 CAD 视图器(五):滚轮缩放为什么要先改 offset 再改 scale
  3. Python 写 CAD 视图器:从零实现以鼠标为中心的滚轮缩放
  4. 滚轮缩放中心总是跑偏?聊聊 offset 与 scale 的更新顺序
  5. PySide6 实现以鼠标为中心缩放:公式、代码和两个容易忽略的边界问题
相关推荐
CarIise1 小时前
Java实训阶段查漏补缺复习笔记1
java·开发语言·笔记
事圆则缓1 小时前
Kotlin 泛型方差实战:out、in、星投影与类型擦除
android·开发语言·kotlin
for_ever_love__1 小时前
控制流——if、for、while 与可迭代对象
python·大模型开发·循环·控制流
for_ever_love__1 小时前
PyTorch 张量与 autograd——自动求导怎么工作
pytorch·python·深度学习·自动求导
Code_Solitude1 小时前
c语言:二分法的完整补充
c语言·开发语言·算法
智鸟科技GemeOpen开发者智能设备1 小时前
MQTT智能通断器GSCW1M2P · 开发者实战指南
开发语言·物联网·智能家居
小小张说故事1 小时前
Python 里 `is` 和 `==` 到底差在哪?5 个让你怀疑人生的案例 + 对照表
python
XLYcmy2 小时前
PDF 论文处理器 — 改进方向与建议文档
python·pdf·llm·agent·dify·rag·harness
青柠之夏cc2 小时前
前端拖拽功能原生实现,不引入拖拽库完成业务
开发语言·前端·python