
👓️博主简介:

文章目录
前言
上一篇(中键拖动平移)结尾我留了两件事:
一是 anchor 这个套路会原样复用。 缩放的时候要保住的是"鼠标底下那个点,缩放前后待在原地"——和这一篇的 anchor 是同一个思路。
二是 setScale 该有上下限了。 滚轮是连续事件,用户能连着滚二十下。不夹紧范围的话,scale 会一路乘到 inf。
这一篇把两件事都做了。但做完回头看,真正难的地方不是"怎么缩放",而是三个不会报错的坑:
- 顺序:anchor 算晚了一步,
alignTo就退化成一句废话(这个能用代数证出来); - 方向:滚轮我写反了——它不崩、数值全对,只有自己滚一下才知道别扭;
- 夹紧:我为了修上限写的那行代码,自己就写歪了,而且两个数字错了三个数量级,我一直以为它是好的。
先把"缩放到底改了什么"说清楚。

一、缩放改的也只是 View 里的一个数
上一篇说过:拖动图纸的时候,图纸上没有一个数字会变。缩放比它更极端——只有 scale 一个数变,Document 里的图元一个字节都不碰。但把 scale 改掉之后,画面不是你想要的那个样子。因为屏幕上每个点的位置是这么算的:
QPointF View::toScreen(const Point& p) const // 图纸坐标 → 屏幕坐标
{
return QPointF(offsetX + p.x * scale,
offsetY - p.y * scale);
}
把 p 取成图纸原点 (0, 0),得到 (offsetX, offsetY)——图纸原点在屏幕上的位置,只由 offset 决定,和 scale 没有半点关系。 所以只改 scale、不动 offset 的话:
| 只改 scale | 同时改 offset(也就是我们要的) | |
|---|---|---|
| 缩放中心 | 图纸原点 (0,0) 在屏幕上的那个位置 | 鼠标底下那个点 |
| 鼠标指着的那块图 | 跑掉了 | 待在原地 |
而"图纸原点在屏幕上的哪儿",取决于你之前拖没拖过图纸。平移过之后,它极可能在屏幕外面——这时候你滚一下滚轮,整张图就像被甩出去一样。

二、三步走:先记点,再改倍率,最后把点放回去
"以鼠标为中心"听起来像个玄学,其实就是上一篇平移那套:
void Canvas::wheelEvent(QWheelEvent *event)
{
const double factor = 1.15; // 每一格滚轮放大/缩小 15%
const Point& anchor = view->toCAD(event->pos()); // ① 记下鼠标底下是图纸上的哪个点
if (event->angleDelta().y() > 0) {
view->setScale(view->getScale() * factor); // ② 改成新的倍率
} else if (event->angleDelta().y() < 0) {
view->setScale(view->getScale() / factor);
} else {
return; // 不是竖直方向的滚动,不管
}
view->alignTo(anchor, event->pos()); // ③ 让那个点回到鼠标底下
event->accept();
update();
}
和上一篇并排看一下,会发现结构一模一样:
| 平移(第 4 篇) | 缩放(这一篇) | |
|---|---|---|
| ① 记点什么 | 按下中键那一刻,鼠标底下的图纸点 | 滚轮事件这一刻,鼠标底下的图纸点 |
| ② 改什么 | offsetX / offsetY | scale |
| ③ 怎么收尾 | alignTo(anchor, 鼠标位置) | alignTo(anchor, 鼠标位置) |
③那一步完全一样——这件事后面第三节单独说。

2.1、①必须在②前面——反了就等于白写
这是这一篇最要紧的一句话。anchor 是用 toCAD 算出来的,而 toCAD 里用到了 scale:
Point View::toCAD(const QPoint& s) const // 屏幕坐标 → 图纸坐标
{
return Point((s.x() - offsetX) / scale,
(offsetY - s.y()) / scale);
}
所以"什么时候算 anchor",决定了你算出来的是改之前的图纸点,还是改之后的图纸点。如果顺序反了(先 setScale 再算 anchor),第①步得到的是:
anchor' = (鼠标.x − offsetX) ÷ 新scale
然后第③步 alignTo(anchor', 鼠标位置) 又会把 offsetX 重算一遍:
offsetX' = 鼠标.x − anchor'.x × 新scale
= 鼠标.x − ((鼠标.x − offsetX) ÷ 新scale) × 新scale
= 鼠标.x − (鼠标.x − offsetX)
= offsetX ← 原样弹回来了
offsetX 一动没动。 也就是说 alignTo 这一步变成了恒等式,什么都没补偿——缩放还是以图纸原点为中心。这不是"效果差一点",是这一行等于没写。而且它不报错、不崩、scale 也确实变了,只是画面不对劲。

三、上一篇那个函数,一个字都没改
注意上面第③步用的 alignTo,是上一篇为平移写的那个函数,这次一个字都没动:
void View::alignTo(const Point& cadPoint, const QPoint& screenPos)
{
offsetX = screenPos.x() - cadPoint.x * scale;
offsetY = screenPos.y() + cadPoint.y * scale;
}
它为什么能直接拿来用?因为它的语义里根本没有"平移"两个字:
让图纸上的某个点,出现在屏幕上的某个位置。
这句话对平移成立,对缩放也成立。它不关心 scale 是刚被改过还是没动过——它只知道"我要让 P 出现在这儿",于是反解出 offset 该是多少。这就是上一篇把"拖动图纸"抽象成 alignTo(cadPoint, screenPos) 的回报:当时多花的那几分钟,这一篇连本带利拿回来了。 反过来说,如果上一篇图省事,写成"鼠标每次移动多少,offset 就挪多少":
// 上一篇我特意没这么写
offsetX += screenPos.x() - lastMousePos.x();
offsetY += screenPos.y() - lastMousePos.y();
那这一篇就得再写一套缩放的补偿逻辑,两套东西各自维护、各自出 bug。
顺手记一条:判断一个函数抽得对不对,有个很实用的标准——换个场景,它还能不能直接用。alignTo 做到了,所以它抽对了。
四、坑一:滚轮方向
我第一版写的是这样:
if (event->angleDelta().y() < 0) { // 往下滚
view->setScale(view->getScale() * factor); // 放大 ← 反了
} else if (event->angleDelta().y() > 0) { // 往上滚
view->setScale(view->getScale() / factor); // 缩小 ← 反了
}
当时脑子里的画面是"把图纸往下推,就是拉远"——然后就写反了。滚轮的隐喻不是"推图纸",是"把镜头推近 / 拉远":
往上滚(远离自己) → 拉近 → 放大
往下滚(朝向自己) → 推远 → 缩小
QWheelEvent::angleDelta().y() 的符号:往上滚是正数,往下滚是负数。所以正确的是:
if (event->angleDelta().y() > 0) {
view->setScale(view->getScale() * factor);
} else if (event->angleDelta().y() < 0) {
view->setScale(view->getScale() / factor);
}
这个坑的讨厌之处在于:它不报错、不崩、数值全对。scale 确实变了,图形也确实在缩放,只是方向反人类。你不亲手滚一下,永远发现不了。
说句实话:这一行我是在做下一篇(缩放到全图)的时候才发现的。当时顺手滚了两下,觉得"这不对劲"。
4.1、为什么是乘 1.15,不是加
如果用加法(每次 scale += 1),手感会随当前比例变:
| 当前 scale | 每次加 1 | 实际变化 |
|---|---|---|
| 10 | 10 → 11 | 放大 10% |
| 100 | 100 → 101 | 放大 1% |
同一个滚轮,在不同比例下效果差十倍。 用乘法就没这个问题:不管现在 scale 是几,一格永远是 15%。
| 滚了几格 | scale 变成 |
|---|---|
| 10 | × 4.05 |
| 20 | × 16.4 |
| 50 | × 1084 |
这才是"缩放"该有的手感:均匀。
4.2、一个暂时没管的小地方
严格说,angleDelta() 的单位是 1/8 度,一格普通滚轮是 120。但触控板的惯性滚动给的是一串小于 120 的增量——上面这个写法会给每一个小增量都乘一次 1.15,触控板上会缩放得飞快。更讲究的写法是把增量算进去:
const double steps = event->angleDelta().y() / 120.0; // 一格 = 1
view->setScale(view->getScale() * std::pow(factor, steps));
鼠标滚轮上这两者没区别(steps 正好是 ±1),所以我先留着——等哪天真在触控板上用了,再改不迟。
五、坑二:夹紧的三段,边界和赋值必须是同一个数
5.1、为什么一定要夹
滚轮是连续事件。用户不会只滚一下,他会"哗啦哗啦"滚二十下。而 scale 是乘上去的,滚得越多涨得越快(上面那张表:滚 50 格就是 ×1084)。不夹紧的话,scale 会一路乘到 inf。到那时 toScreen 算出来的全是 inf / nan,屏幕上什么都画不出来——这不是"缩放到极限了",这是程序坏了。
(下限同理:scale 除以 1.15 除到 0 之后,toCAD 里那个除法会变成除以 0,得到 inf。图纸坐标一旦是 inf,捕捉、命中的距离计算全废。)
5.2、我那行写歪了的代码
我加了夹紧,提交信息里还写着"修正缩放上限"。代码是这样的:
void View::setScale(double nScale)
{
if(nScale < 0.01) {
scale = 0.01;
} else if(nScale > 10000000.0) { // ← 判断用一千万
scale = 10000.0; // ← 赋值却是一万
} else {
scale = nScale;
}
}
现在慢慢读一遍:
小于 0.01 的,夹到 0.01;大于一千万的,夹到一万;其余的,原样用。
那一万到一千万之间呢?原样放行。 也就是说:上限根本没生效。它只是把"大到离谱"变成了"非常大"——两个数字差了三个数量级,而这三个数量级正好就是出问题的那一段。我为这个错付出的代价是:它看起来特别像对的。 10000 和 10000000 都是很正常的整数,扫一眼不会觉得可疑;而且我当时注意力全在"要加个上限"这件事上,压根没去核对"夹到几"。
5.3、重写一遍:三个数变成两个名字
这种错有个规律:三段式夹紧,天生容易写歪。 因为"判断里出现的数"和"赋值里出现的数"是分开的,脑子只会认真核对其中一个。所以别让它们分开。把上下限提出来,判断和赋值共用同一个名字:
namespace {
constexpr double kMinScale = 0.01;
constexpr double kMaxScale = 10000.0;
}
void View::setScale(double nScale)
{
if (nScale < kMinScale) {
scale = kMinScale;
} else if (nScale > kMaxScale) {
scale = kMaxScale;
} else {
scale = nScale;
}
}
再进一步:C++17 有个 std::clamp(项目本来就开着 C++17),整个函数剩一行——
#include <algorithm>
void View::setScale(double nScale)
{
scale = std::clamp(nScale, kMinScale, kMaxScale);
}
没有判断、没有分支、没有"两个地方要写成同一个数"的机会。 它只剩一个数 kMaxScale,写错也只可能错一处。
从"三个数"到"一个数",这不是简洁不简洁的问题,是**“我能不能写错”**的问题。

5.4、夹紧之后,alignTo 用哪个 scale
有个细节值得单独说一句。alignTo 里用的 scale,是夹紧之后的值:
if (event->angleDelta().y() > 0) {
view->setScale(view->getScale() * factor); // 里面可能被夹住了
}
view->alignTo(anchor, event->pos()); // 读的是成员 scale,也就是夹紧后的值
这是对的,而且很关键。因为你滚到上限之后还会继续滚,setScale 每次都想给你一个"更大的值"、每次都被夹回去。如果 alignTo 用的是"用户想要的那个 scale"(比如又算了一遍 getScale() * factor),那么每滚一下,offset 都会按一个根本不存在的倍率重算一遍——画面就会一直漂,手松开的时候视图已经不知道跑到哪儿去了。用夹紧后的值,滚到头再滚就是"scale 不变、offset 被重算成同一个值",画面纹丝不动。这才是"到达极限"该有的表现。
六、第三个尾巴,我收错了地方
第 3 篇留了三个尾巴,上一篇收了两个。剩下这个是:getOffsetX() / getOffsetY() 没有 const。而且上一篇已经查明:坐标轴改用 toScreen 之后,这两个 getter 一个调用点都没有了,是死代码。当时我说"删接口单独做一次,免得和功能改动混在一个提交里"。结果这一版我在改 View 的时候,顺手把它们补上了 const:
double getOffsetX() const { return offsetX; }
double getOffsetY() const { return offsetY; }
double getScale() const { return scale; } // ← 只有这个,是这次真正要用的
现在回头看,顺序做反了。 正确顺序应该是:先确认没人调用 → 删掉 → 以后再也不用管它们有没有 const。给一个已经死掉的函数补 const,是把死人扶起来量体温——看着像在改进,实际上那两行一份价值都没产生。而且它还有个副作用:view.h 里从此多出两行"看起来有人用"的接口,下一个读代码的人还得再搜一遍,才敢确认它们是死的。
(这件事到现在都没做。2.0 都写完了,这两个 getter 还躺在 view.h 里。先记在待办上。)
顺手还改了个小的:构造函数从传 const double& 改成传值——
// 之前
View(const double& offsetX = 0.0, const double& offsetY = 0.0, const double& scale = 10.0);
// 现在
View(double offsetX = 0.0, double offsetY = 0.0, double scale = 10.0);
double 只有 8 个字节,传引用要先取地址、再解引用,未必比直接传值快——给基本类型加 const& 是没必要的。这种写法在老代码里很常见,看到顺手改掉就好。
七、这一版的成果
现在能做的:
- 滚轮缩放:往上滚放大、往下滚缩小,鼠标底下那个点不动
- 缩放有范围:
scale夹在0.01 ~ 10000之间,滚到头不会漂 alignTo一个字没改就复用了——上一篇抽出来的函数,这一篇直接拿去用
还有一个不太看得见、但更重要的:
-
改
scale只有setScale这一个入口,而夹紧逻辑就住在它里面。所以任何调它的地方都自动受保护——不会出现"某个角落忘了夹一下"这种事。这比"在每个调用点都记得检查"可靠得多:规矩写在唯一的入口上,就不可能漏。(
setOffsetX/setOffsetY还在,现在只有recenterView在用。这对 getter/setter 其实可以合成一个setOffset(Point),属于同一个待办。)
顺手记两条这次的教训:
一、手感类的错误,编译器帮不了你。 滚轮方向、正负号、上下左右——这类东西跑起来全对,只有人肉用一下才知道。所以改完交互一定要自己点两下,别只看编译过没过。
二、"修 bug 的代码"要当成新代码重新审一遍。 我那次是"为了修上限"而加的三段判断,注意力全在"要加",没去核对"加了什么"。越是抱着"我在修 bug"心态写的那几行,越容易带着下一个 bug。
项目主页(后续更新都在这里):
https://gitee.com/studyingart/mini-cad
总结
下一篇做中键双击缩放到全图(很多 CAD 里那个"缩放到范围")。它会用到两样东西:
一是新东西:Document 得能回答"我这些图元一共占了多大一块"。 现在文档只管存图元,没人算过它的包围盒。有了包围盒,缩放倍率其实就是一个除法:窗口宽 ÷ 图纸宽。
二是这一篇的 setScale 和 alignTo 又要用一次。 倍率算出来了,还得让图纸中心出现在窗口中心——还是那两步:算倍率、alignTo。
写到那儿你大概会发现:从第 4 篇到第 6 篇,视图这套东西一共就两个动作,改倍率和对齐。
🎇坚持到这里已经很厉害啦,辛苦啦🎇 ʕ • ᴥ • ʔ づ♡ど
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/2302_80177460/article/details/167074305





