说说【从零写一个CAD 05】以鼠标为中心的滚轮缩放:招法能复用,顺序不能反(学习笔记)

开发过程中有些细节容易被忽略,今天挑几个重点聊一聊。

🫧 励志不掉头发的内向程序员:个人主页

 ✨️ 个人专栏: 《C++语言》《Linux学习》

🌅偶尔悲伤,偶尔被幸福所完善


👓️博主简介:

文章目录

三、上一篇那个函数,一个字都没改四、坑一:滚轮方向 五、坑二:夹紧的三段,边界和赋值一定要是同一个数 六、第三个尾巴,我收错了地方七、这一版的成果总结

前言

上一篇(中键拖动平移)结尾我留了两件事:

一是 anchor 这个套路会原样复用。 缩放的时候要保住的是"鼠标底下那个点,缩放前后待在原地"——和这一篇的 anchor 是同一个思路。
二是 setScale 该有上下限了。 滚轮是连续事件,用户能连着滚二十下。不夹紧范围的话,scale 会一路乘到 inf。

这一篇把两件事都做了。但做完回头看,真正难的地方不是"怎么缩放",而是三个不会报错的坑:

1. 顺序:anchor 算晚了一步,alignTo 就退化成一句废话(这个能用代数证出来);
2. 方向:滚轮我写反了——它不崩、数值全对,只有自己滚一下才明白别扭;
3. 夹紧:我为了修上限写的那行代码,自己就写歪了,而且两个数字错了三个数量级,我一直以为它是好的。

先把"缩放到底改了什么"说清楚。


一、缩放改的也只是 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 / offsetYscale③ 怎么收尾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实际变化1010 → 11放大 10%100100 → 101放大 1%

同一个滚轮,在不同比例下效果差十倍。 用乘法就没这个问题:不管现在 scale 是几,一格永远是 15%。

滚了几格scale 变成10× 4.0520× 16.450× 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

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 篇,视图这套东西一共就两个动作,改倍率和对齐。

🎇坚持到这里已经很厉害啦,辛苦啦🎇
> ʕ • ᴥ • ʔ
>
づ♡ど

本次分享就到这里。技术这东西越研究越有意思,后续有新的收获我也会继续更新。

评论 (0)

暂无评论