
做简易 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仍然映射回mxmx wx * scale1 offset1_x把 wx 代入mx ((mx - offset0_x) / scale0) * scale1 offset1_x解出 offset1_xoffset1_x mx - (mx - offset0_x) * (scale1 / scale0)这就是核心公式。令k scale1 / scale0为缩放倍率则offset1_x mx - (mx - offset0_x) * ky 方向同理offset1_y my - (my - offset0_y) * k推导到这里其实就结束了。真正容易翻车的地方在后面。一个能跑的 PySide6 最小实现我用的环境是 Python 3.11 PySide6 6.6.x。这个版本区间的 QWheelEvent 接口是稳定的angleDelta()、position()都能正常用。如果你用的是 PyQt5API 名字略有差异event.pos()而不是event.position()需要注意替换。先说事件处理部分fromPySide6.QtCoreimportQPointFfromPySide6.QtGuiimportQWheelEventfromPySide6.QtWidgetsimportQWidgetclassCanvasView(QWidget):def__init__(self,parentNone):super().__init__(parent)self.scale1.0self.offsetQPointF(0.0,0.0)self.min_scale0.02self.max_scale200.0defwheelEvent(self,event:QWheelEvent)-None:# 1. 取鼠标在控件内的位置posevent.position()# QPointFmx,mypos.x(),pos.y()# 2. 计算缩放倍率deltaevent.angleDelta().y()# 一格通常是 120ifdelta0:returnfactor1.0015**delta# 平滑一点new_scaleself.scale*factor new_scalemax(self.min_scale,min(self.max_scale,new_scale))# 被 clamp 之后实际倍率要重新算不能用 factorknew_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.scalenew_scale self.update()这段代码不长但里面有两个我踩过的点值得单独拎出来讲。坑一clamp 之后必须重算 k我一开始写的是factor1.0015**delta self.scale*factorifself.scaleself.min_scale:self.scaleself.min_scaleelifself.scaleself.max_scale:self.scaleself.max_scale然后 offset 用factor去算。这样在缩放没触到边界时没问题但一旦撞到 min/maxself.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_scaleoffset0_x是旧 offset。也就是说公式右边用的必须是旧的 scale 和旧的 offset。如果你图省事写成这样# 错误写法self.offset.setX(mx-(mx-self.offset.x())*(new_scale/self.scale))self.scalenew_scale其实这个是对的因为它是在改 scale 之前先取到了self.scale的旧值。真正错的是下面这种# 错误写法先改 scale再算 offsetself.scalenew_scale knew_scale/self.scale# 这里 self.scale 已经是新值了k 恒等于 1self.offset.setX(mx-(mx-self.offset.x())*k)这时候k永远是 1offset 一动不动缩放中心直接跑回画布原点。我第一版就是这么写的滚轮一转视图“嗖”地朝左上角飞出去当时还以为是符号写反了查了半天才发现是顺序问题。所以顺序规则可以总结成一句话先用旧参数算新 offset再更新 scale。公式里的每一项都绑定到“缩放前”这个状态任何一步提前改了 scale公式就失效了。【关键结论】offset 和 scale 不是两个独立变量它们通过“鼠标下的世界点不动”这个约束耦合在一起。约束方程里的 scale 必须是旧值所以更新顺序被公式本身锁死了。为什么“复用平移的招法”在这里行不通写 pan 的时候逻辑很清爽鼠标按下 → 记录起点 → 移动时offset (dx, dy)一次只改一个变量。这套“增量累加”的写法很容易让人产生惯性想着缩放也照搬记录鼠标起点滚轮时按位移比例改 offset。但缩放不一样。缩放的本质不是“平移一段距离”而是“以某点为不动点做一次相似变换”。相似变换里offset 的变化量是(mx - offset0_x) * (k - 1)它依赖于当前的 offset 和鼠标位置不是固定增量。如果直接套用平移的“累加位移”思路比如# 想当然的写法实际会漂移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, ...)缩放前后这两个世界坐标应该几乎相等差一个浮点精度。这个自检我在开发时用过很多次非常直接。wx_before(mx-self.offset.x())/self.scale# ... 更新之后 ...wx_after(mx-self.offset.x())/self.scaleprint(wx_before,wx_after)# 两者应当非常接近关于这一篇的收尾缩放这件事公式本身推导出来只有三行但真正落地的难点在“状态更新的时序”和“边界处理”上。平移和缩放看起来都是改视图参数招法可以互相借鉴但缩放的更新顺序是被公式锁死的不能像平移那样随意累加。下一篇打算处理“框选”和“命中检测”——那边会涉及坐标反变换的批量调用正好可以复用这一篇的映射关系。如果对视图变换这块有兴趣建议先把这一篇的 offset 公式自己推一遍再写代码比直接抄要稳得多。备用标题以鼠标为中心缩放的公式推导与 PySide6 实现一个顺序问题让我调了很久自研 CAD 视图器五滚轮缩放为什么要先改 offset 再改 scalePython 写 CAD 视图器从零实现以鼠标为中心的滚轮缩放滚轮缩放中心总是跑偏聊聊 offset 与 scale 的更新顺序PySide6 实现以鼠标为中心缩放公式、代码和两个容易忽略的边界问题