【从零写一个CAD 03】三个 double 值得单独一个类吗:把视图变换抽成 View

🫧 励志不掉头发的内向程序员 :个人主页
✨️ 个人专栏: 《C++语言》《Linux学习》

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


👓️博主简介:

文章目录


前言

上一篇结尾我说,下一步要把 originX / originY / scale 打包成 View。这篇就干这件事。先回应一个可能的疑问:就三个 double,值得单独建一个类、再加两个文件吗?

值得。但理由不是"三个数太多了",而是这三个数是一组,而且必须一起被改 。这句话现在看着像废话,等做到"以鼠标为中心滚轮缩放"的时候你会庆幸------那时候你必须同时改 scale 和 offset,少改一个,图就会跳。这篇我们来讲三件事:这三个数住错了哪儿、View 长什么样(一共三十行)、以及抽出来之后暴露的三个尾巴。


一、先看看这几个数现在住在哪

上一篇之后,Canvas 的私有成员是这样:

cpp 复制代码
// GUI/canvas.h(这一篇之前)
private:
    void cancelCurrentTool();
    void updateViewTransform();

    Point screenToCAD(const QPoint& screen) const {
        return Point((screen.x() - originX) / scale, (originY - screen.y()) / scale);
    }

    QPoint cadToScreen(const Point& cad) const {
        return QPoint(qRound(originX + cad.x * scale), qRound(originY - cad.y * scale));
    }

    Document *doc;
    Point mousePosition;      // 当前鼠标的图纸坐标
    Point firstPoint;         // 已经点击的第一个点
    bool hasFirstPoint;       // 有没有点过第一个点

    double originX;           // 图纸原点在屏幕上的位置
    double originY;
    double scale;             // 缩放比例

看最后三行。它们和"鼠标点在哪""画到一半没有"这些交互状态并排躺在一个类里,但这两种东西的性质完全不一样:

交互状态(mousePosition / firstPoint / hasFirstPoint) 视图参数(originX / originY / scale)
描述的是 用户正在干什么 用户正在看什么
有效期 一次操作内,画完就该清 整个程序运行期间一直有效
谁会改它 鼠标事件 平移、缩放、窗口尺寸变化
打开另一张图 该清空 该保留

第一种是"操作的临时记忆",第二种是"你在看图纸的哪一块"。 混在同一个类里,改任何一个都要先把另一个读一遍。


二、这三个数的问题不是"多",是"必须一起改"

打开 resizeEvent 看一眼就明白了:

cpp 复制代码
void Canvas::resizeEvent(QResizeEvent *event)
{
    QWidget::resizeEvent(event);
    updateViewTransform();      // 里面就两行:把原点摆到新的窗口中心
}

updateViewTransform() 做的事,是同时 改 originX 和 originY。再看下一篇要做的平移:鼠标每移动一个像素,originX 和 originY 也要一起变。再看再下一篇的滚轮缩放:

cpp 复制代码
// 以鼠标为中心缩放(还没写,但形状已经确定了)
scale *= factor;
// 必须紧接着调整 offset,否则缩放会以窗口左上角为中心,而不是鼠标位置

每一处修改都是"这几个数一起动"。 这就是我说的不变式:offsetX / offsetY / scale 不是三个独立的数,而是描述同一个变换的三个参数 。它们分开来看没有任何意义------originX = 800 这句话本身说明不了任何事,它只有在配合 scale 和 originY 的时候才有含义。而现在这三个数没有自己的名字,也没有自己的边界 :它们的访问权限和 mousePosition 一模一样,谁拿到 Canvas 谁就能碰。

一个判断"这堆东西该不该独立成类"的实用标准:它们之间有没有必须一起满足的约束? 有,就该找个地方把这条约束写下来。三个 double 也一样。


三、View:一组数加一对互逆函数

新文件 GUI/view.h:

cpp 复制代码
#ifndef VIEW_H
#define VIEW_H

#include <QPoint>
#include "Core/point.h"

class View
{
public:
    View(const double& offsetX = 0.0, const double& offsetY = 0.0, const double& scale = 10.0);
    void setOffsetX(double nOffsetX);
    void setOffsetY(double nOffsetY);

    QPoint toScreen(const Point& p) const;   // 图纸 -> 屏幕
    Point toCAD(const QPoint& s) const;      // 屏幕 -> 图纸

    double getOffsetX() { return offsetX; }
    double getOffsetY() { return offsetY; }

private:
    double offsetX;
    double offsetY;
    double scale;
};

#endif // VIEW_H

GUI/view.cpp:

cpp 复制代码
#include "view.h"

View::View(const double& offsetX, const double& offsetY, const double& scale)
    : offsetX(offsetX)
    , offsetY(offsetY)
    , scale(scale)
{}

void View::setOffsetX(double nOffsetX) { offsetX = nOffsetX; }
void View::setOffsetY(double nOffsetY) { offsetY = nOffsetY; }

QPoint View::toScreen(const Point& p) const
{
    return QPoint(qRound(offsetX + p.x * scale), qRound(offsetY - p.y * scale));
}

Point View::toCAD(const QPoint& s) const
{
    return Point((s.x() - offsetX) / scale, (offsetY - s.y()) / scale);
}

就这么点东西。但有三件事值得说。

3.1、那对互逆函数终于住在一起了

上一篇我专门讲过:toScreen 和 toCAD 互为逆运算,必须同时改。当时它们的位置也挨着,但那只是"恰好写在一起",没有任何机制保证以后还在一起。

现在它们是同一个类的两个成员,而且这个类是它们唯一的家 。以后要是有人在 Canvas 里手写一句 offsetX + x * scale,那是明显的异常------因为那个类里已经没有 offsetX 这个东西了。

把不变式变成"那个东西根本不在那儿",比写注释管用。

3.2、参数私有化,修改有了唯一的入口

三个 double 现在是私有的,外面只能通过 setter 改。这两个 setter 现在看起来纯属多余(不如直接把成员公开省事)。但它是给未来留的口子。等做滚轮缩放的时候需要这样:

cpp 复制代码
void View::setScale(double nScale)
{
    scale = std::clamp(nScale, kMinScale, kMaxScale);   // 夹紧,防止缩放到 0 或者变成 inf
}

为什么必须夹紧? 因为滚轮是连续事件,用户按住滚轮滚二十下,scale 就会连乘二十次 1.15;往反方向滚二十下,又连除二十次。不设上下限的话,图纸要么小到看不见,要么大到把算出的一堆坐标变成 inf------屏幕直接空白。而如果没有 setter,这个夹紧动作就得散在每一个改 scale 的地方:构造函数、滚轮、缩放到全图,一共三处,漏一处就是一个"偶尔图不见了"的 bug。

3.3、它现在有自己的名字了

Canvas::updateViewTransform() 变成了:

cpp 复制代码
void Canvas::recenterView()
{
    view->setOffsetX(width() / 2.0);
    view->setOffsetY(height() / 2.0);
}

函数名从 updateViewTransform(更新视图变换)改成了 recenterView(把视图重新居中)。名字变具体了,是因为职责变具体了:以前它管着画布里全部的视图参数,现在它只做"让视图回到窗口中心"这一个动作。函数名能变具体,本身就是拆分成功的信号------如果拆完之后名字还是那么含糊,说明你只是把东西换了个地方堆着。


四、Canvas 瘦身

改完之后 Canvas 的私有成员:

cpp 复制代码
// GUI/canvas.h(这一篇之后)
private:
    void cancelCurrentTool();
    void recenterView();

    Document *doc;
    View *view;              // ← 新增:视图参数不再属于 Canvas
    Point mousePosition;
    Point firstPoint;
    bool hasFirstPoint;

删掉 3 个成员和 2 个函数,换来 1 个指针。 三个调用点的改法:

cpp 复制代码
// 鼠标移动
mousePosition = view->toCAD(screenPosition);

// 鼠标点击
Point clickedPoint = view->toCAD(screenPosition);

// 绘制图元
QPoint startScreen = view->toScreen(line.startPoint);
QPoint endScreen   = view->toScreen(line.endPoint);

这就是重点:Canvas 里再也找不到"坐标怎么算"这件事了。它只负责问,不负责算。

把它和上一篇的 Document 摆在一起看:

类 它负责回答 Canvas 问它什么
Document 图纸上有什么 getLines() / addLine()
View 我在看图纸的哪一块 toCAD() / toScreen()

Canvas 现在只剩一件事:把事件翻译成对这两个对象的操作,然后把结果画出来。 这就是"交互层"该有的样子。


五、View 放哪儿,谁来持有

View 放在 GUI/ 目录里,因为它 #include <QPoint>、返回 QPoint。

这一点其实有争议,值得说清楚:从职责上说,视图变换是纯数学,应该能进 Core/。 它进不去的唯一原因是它用 Qt 的类型来表示"屏幕坐标"。三种选择:

方案 做法 代价
现在这样 放 GUI/,用 QPoint View 绑上了 Qt,不能在 Core 层单独测
换成 QPointF 还是放 GUI/ 只解决精度,没解决依赖
自定义屏幕点 定义 ScreenPoint{double x, y},View 进 Core/ 多一个类型,还得写和 QPoint 的互转

这一版选了第一种,理由是:View 现在只有三十行、两个函数,为了让它进 Core 而专门造一个类型不划算。 这是有意的取舍,不是疏忽------等哪天需要"脱离 Qt 测视图数学",或者发现 QPoint 的整数精度不够用了,再升级。

这里埋个伏笔:整数 QPoint 有个精度问题。toScreen 的结果被 qRound 成整数,平时看不出来,但"以鼠标为中心缩放"要求的是缩放前后鼠标底下那个点不变 ,这就要求中间计算保留小数。下一篇会撞上,到时候 toScreen 会改成返回 QPointF。

持有方式和上一篇的 Document 一模一样:

cpp 复制代码
// GUI/mainwindow.h
private:
    Ui::MainWindow *ui;
    Document document;
    View view;              // ← 值成员,跟窗口同生共死
    Canvas *canvas;
cpp 复制代码
// GUI/mainwindow.cpp
MainWindow::MainWindow(QWidget *parent)
    : QMainWindow(parent)
    , ui(new Ui::MainWindow)
    , canvas(new Canvas(&document, &view, this))
{

为什么不把 View 塞进 Document? 因为它们描述的是两件不同的事:文档是"图纸上有什么",视图是"你在看图纸的哪一块"。硬塞在一起,就会出现"打开同一个文件,想开两个窗口分别看总图和局部"这种需求完全没法满足的情况。

而拆开之后,这件事是免费的------LibreCAD 就是两个画布视图共享同一个文档。分界线画对了,能力是白送的;画错了,能力得靠重构买回来。


六、这一版留下的三个尾巴

尾巴一:画坐标轴绕过了 View

cpp 复制代码
// GUI/canvas.cpp(这一版,paintEvent 里)
painter.drawLine(0, static_cast<int>(view->getOffsetY()), width(), static_cast<int>(view->getOffsetY()));
painter.drawLine(static_cast<int>(view->getOffsetX()), 0, static_cast<int>(view->getOffsetX()), height());

这是漏抽象 。我把 toScreen 抽出来了,却在画坐标轴的时候直接读 offsetX / offsetY------因为坐标轴恰好就是"过图纸原点的两条线",直接拿偏移量看着更省事。正确的写法,是问 View 那个它本来就该回答的问题:

cpp 复制代码
const QPoint originScreen = view->toScreen(Point(0.0, 0.0));
painter.drawLine(0, originScreen.y(), width(), originScreen.y());
painter.drawLine(originScreen.x(), 0, originScreen.x(), height());

绘制代码不该知道 offset 长什么样,它只该问"图纸原点画到屏幕哪里"。 这两行的效果现在完全一样,区别在于等以后坐标系有变化(比如加旋转),一种改法改一个函数,另一种改法要翻遍绘制代码。这个尾巴下一篇就要收------因为做平移的时候它会自己跳出来。

尾巴二:两个 getter 没有 const

cpp 复制代码
double getOffsetX() { return offsetX; }    // 少了 const

它们不改任何东西,就应该标 const。不加的后果很具体:const View* 或者 const 引用调不了它们。

但更根本的问题是------这两个 getter 本来就不该存在 。上面尾巴一的正确写法里,外部根本不需要知道 offset 是多少,只需要 toScreen。一个 getter 如果从来没被需要,它的存在就是在邀请别人绕过抽象。

尾巴三:resizeEvent 还是会把视图拉回窗口中心

cpp 复制代码
void Canvas::resizeEvent(QResizeEvent *event)
{
    QWidget::resizeEvent(event);
    recenterView();      // 拉到中间
}

这件事上一篇就预告过:现在没有平移,看不出来;一旦用户能把图纸拖到别处,拉一下窗口,图纸就会"啪"地弹回中间。 为什么这一版不动它?因为改它需要一个现在还不存在的能力------"窗口中心对应图纸上的哪个点"。这句话得靠 toCAD 才问得出来,而 toCAD 刚刚才搬进 View。

所以这不是偷懒:依赖解决了,问题才轮得到解决。 下一篇一起处理。

(另外两个小的,不影响正确性,看到顺手改掉就行:View 构造函数里 offsetX / offsetY 的默认参数其实是死代码------Canvas 一构造完就调 recenterView() 把它们覆盖了;以及 double 用 const double& 传参没必要,值传更自然。)

项目主页(后续更新都在这里):

https://gitee.com/studyingart/mini-cad


总结

下一篇做中键拖动平移,顺带把上面三个尾巴一次收掉。平移本身出奇地简单------拖动时让"按下鼠标那一刻鼠标底下的那个图纸点"始终待在鼠标底下就行:

cpp 复制代码
void View::alignTo(const Point& cadPoint, const QPoint& screenPos)
{
    offsetX = screenPos.x() - cadPoint.x * scale;
    offsetY = screenPos.y() + cadPoint.y * scale;
}

(注意第二个式子是加号。屏幕 Y 轴朝下、图纸 Y 轴朝上,所以这里要反过来。这种地方错一次就够记一辈子。)

真正的看点不是这两行算式,而是它牵出来的两个问题。

一是"拖到一半松手,图为什么会跳"。 要让它不跳,必须记住"按下去的时候,鼠标对应的是哪个图纸点"。这个状态属于交互状态,但它和视图参数的关系很紧密------放 Canvas 还是放 View,值得想清楚。

二是"拉窗口的时候怎么让视图中心不动"。 这需要先把 resizeEvent 里那句 recenterView() 看明白------它错在哪,而不是简单把它删掉。


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

相关推荐
PHP实战开发录1 小时前
PHP接口大整数为什么变了
开发语言·php
Travis_del1 小时前
系统架构设计师考试大纲:考试说明
系统架构·软考·系统架构设计师
临床数据科学和人工智能兴趣组5 小时前
在R语言中, 使用 as.factor() 函数转换数值型变量
开发语言·r语言
m4Rk_8 小时前
【论文阅读】Agent 记忆机制(83):Inside Out——用可演化 PersonaTree 构建 Agent 的核心长期记忆
论文阅读·人工智能·学习·开源·github
andyweike8 小时前
php笔记
开发语言·笔记·php
蒸蒸yyyyzwd8 小时前
秋招学习笔记 day46
c++·八股
鬓戈8 小时前
Rust 语言与 AI 应用生态调研及学习路径
人工智能·学习·rust
传奇开心果编程9 小时前
【SwiftUI娓娓道来】第3课:让界面活起来——交互与动画指南
学习·macos·ui·ios·swiftui·swift
Circ.9 小时前
Python 调试 Litellm 本地大模型接口:模型列表与对话接口实操踩坑记录
开发语言·python