做简易 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 公式自己推一遍再写代码,比直接抄要稳得多。
=备用标题=
- 以鼠标为中心缩放的公式推导与 PySide6 实现:一个顺序问题让我调了很久
- 自研 CAD 视图器(五):滚轮缩放为什么要先改 offset 再改 scale
- Python 写 CAD 视图器:从零实现以鼠标为中心的滚轮缩放
- 滚轮缩放中心总是跑偏?聊聊 offset 与 scale 的更新顺序
- PySide6 实现以鼠标为中心缩放:公式、代码和两个容易忽略的边界问题