励志不掉头发的内向程序员头像
关注
【从零写一个CAD 01】从一个空工程到画出第一条线:坐标系统是地基封面图

【从零写一个CAD 01】从一个空工程到画出第一条线:坐标系统是地基

请添加图片描述


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

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

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


👓️博主简介:

请添加图片描述



前言

前面我写了一个《LibreCAD 2D架构》的专栏,一共十几篇,把 LibreCAD 这个开源 CAD 的源码从程序入口一路扒到屏幕渲染,算是把"别人是怎么设计 CAD 的"看明白了(详情请看我的LibreCAD专栏)。

但看完之后有个后遗症:手痒。 看懂别人的架构,和自己从零写一个能跑的东西,中间隔着一道很宽的沟。所以我打算再开一个专栏,记录我从零写一个小 CAD 的完整过程——不追求功能多,追求每一步都说得清为什么这么做。 这一版的目标(我给它起名叫 MiniCAD 1.0)是:

  • 能画直线、圆、矩形
  • 能点选中、能删掉
  • 能撤销重做
  • 能保存和打开文件
  • 有工具栏和状态栏
  • 能平移、缩放、缩放到全图

技术栈没什么好挑的:Qt 5.14.2 + qmake + MinGW,因为 Qt 的绘图和事件系统足够好用,能把精力放在架构上而不是造轮子。这一篇讲两件事:把骨架搭起来,以及把坐标系统想清楚——后者是整个项目的地基,它错了后面全歪。

请添加图片描述


一、先立一条规矩:Core 不依赖 Qt

1.1、目录怎么分

新建工程之后,我第一件事不是写代码,而是分目录:

MIniCAD/
├── Application/       程序级的东西(暂时空着)
├── Core/              纯数据和几何,不碰 Qt
│   ├── point.h/cpp
│   ├── line.h/cpp
│   └── document.h/cpp
├── GUI/               Qt 界面和交互
│   ├── canvas.h/cpp
│   └── mainwindow.h/cpp
└── main.cpp

为什么一上来就分? 因为等到代码写多了再分,就分不动了。

这条分界线非常具体:Core/ 里的代码,一个 Qt 头文件都不许 include。

1.2、Core 里放什么

先说最小的:一个二维点。

// Core/point.h
#ifndef POINT_H
#define POINT_H

class Point
{
public:
    Point(double x = 0, double y = 0);

    double x;
    double y;
};

#endif // POINT_H

然后是线段——两条点而已:

// Core/line.h
#include "point.h"

class Line
{
public:
    Line();
    Line(const Point& startPoint, const Point& endPoint);

    Point startPoint;
    Point endPoint;
};

注意这两个类里没有任何 Qt 的东西——没有 QPoint,没有 QPainter,干干净净的 C++。

在这里插入图片描述

1.3、为什么非要守这条线

有人可能觉得这是"为了架构而架构"。不是的,这条线后面会实打实地回报你三次:

第一次是写几何算法的时候。 后面我要做"点到线段的距离"“线段求交”"偏移"这些算法,它们全都活在 Core 里。因为没有 Qt 依赖,我可以写一个几十行的 main.cpp 直接跑测试,不用起 GUI、不用点鼠标,几秒钟验证一个公式对不对。

第二次是加第二个绘制后端的时候。 后面我会给"画笔"写一个抽象接口,然后 Qt 只是一个实现——如果哪天想导出 PDF,只需要加一个新的实现类,Core 一行不用改。

第三次是读代码的时候。 当你打开一个文件,看到 #include <QPainter> 就知道"这是界面层的事",看到只有 <cmath> 就知道"这是算法层的事",不用猜。

反过来,如果一开始就让 Line 自己带一个 draw(QPainter&) 方法,那 Core 就永久绑死在 Qt 上了——想脱离 Qt 编译都不可能。

所以这条规矩值得从第一天开始守。



二、坐标系统:整个项目的地基

2.1、两套坐标系

这是 CAD 程序里最容易含糊、也最不能含糊的东西。屏幕上看到的每一个像素,背后都有两套坐标在打架:

屏幕坐标图纸坐标(CAD 坐标)
类型整数像素double
原点窗口左上角图纸原点
Y 轴方向向下向上
单位像素图纸单位(mm 等)
会随缩放平移变化吗是否

第三行是数学上的习惯和屏幕上的现实相反的经典例子:我们画图时习惯"Y 越大越靠上",而屏幕的像素是从上往下数。

最后一行才是关键:同一条线,图纸坐标永远不变,屏幕坐标随时在变——你滚一下滚轮,它的屏幕坐标就全变了。所以数据必须存图纸坐标,屏幕上怎么画是另一回事。

在这里插入图片描述

2.2、三个参数描述整个视图

要把图纸坐标翻译成屏幕坐标,需要三个数:

  • scale:缩放比例,含义是"1 个图纸单位等于多少像素"
  • offsetX / offsetY:图纸原点落在屏幕上的哪个像素

(第一版里这两个偏移量我叫它 originX / originY,代码里也是这么写的。后来改名叫 offsetX / offsetY——因为加上平移之后,它已经不是"窗口中心"了,叫 origin 会让人误解。这篇为了和代码一致,贴代码时保留旧名字,讲概念时用新名字。)

有了这三个数,转换公式就出来了:

屏幕X = offsetX + 图纸X × scale
屏幕Y = offsetY - 图纸Y × scale      ← 注意这个负号

X 方向很好懂:先按比例放大,再加上原点的偏移。

Y 方向那个负号是整个公式的灵魂——图纸的 Y 越大越靠上,屏幕的 Y 越大越靠下,所以要取反。很多人第一次写 CAD 都会在这里画出一个上下颠倒的图形,然后盯着屏幕怀疑人生。

反过来的转换就是把公式倒着解一遍:

图纸X = (屏幕X - offsetX) ÷ scale
图纸Y = (offsetY - 屏幕Y) ÷ scale

第一版我只把屏幕 → 图纸这个方向抽成了函数:

// GUI/canvas.h(第一版)
Point screenToCAD(const QPoint& screen) {
    return Point((screen.x() - originX) / scale,
                 (originY - screen.y()) / scale);
}

2.3、第一版偷的懒:反方向没抽函数

反方向(图纸 → 屏幕)我没有抽成函数,而是在 paintEvent 里手写——而且写了两遍:画已完成的那条线一遍,画预览虚线又一遍。

// GUI/canvas.cpp(第一版,节选)
QPoint startScreen(
    static_cast<int>(originX + currentLine.startPoint.x * scale),
    static_cast<int>(originY - currentLine.startPoint.y * scale));
QPoint endScreen(
    static_cast<int>(originX + currentLine.endPoint.x * scale),
    static_cast<int>(originY - currentLine.endPoint.y * scale));
painter.drawLine(startScreen, endScreen);

当时觉得"就两行公式,写两遍也没啥"。但它埋了三个问题,而且每一个后面都真的发生了:

一是重复。同一个公式出现在四个地方,以后要改(比如给 Y 加个偏移)就得改四遍,漏一处就是一个"只有某条线画错"的诡异 bug。

二是精度。这里用的是 static_cast<int>,也就是向零截断:-1.8 变成 -1,而不是 -2。跨过原点的时候会有半个像素的偏差。单个看无所谓,但 CAD 是个处处在做坐标转换的东西,一次差半像素,经过几层调用就能看出错位。

三是没有对称性。屏幕→图纸有函数、图纸→屏幕没有,读代码的人会以为后者在别的地方有封装——结果发现是散落的手工算式。

后面我会把它们抽成一对互为逆运算的函数放在 View 里,顺手把 static_cast<int> 换成 qRound(四舍五入)。这就是一个很小的例子:"这段代码现在能跑"和"这段代码该不该这么写"是两回事。


三、把你点的那一下变成一条线

3.1、画布要接哪些事件

画布是个 QWidget,重写三个事件就够画出第一条线:

class Canvas : public QWidget
{
public:
    void mouseMoveEvent(QMouseEvent *event) override;      // 鼠标移动
    void mouseReleaseEvent(QMouseEvent *event) override;   // 鼠标松开(就是"点击完成")
    void paintEvent(QPaintEvent *event) override;          // 重绘
};

3.2、鼠标移动:记录当前坐标

void Canvas::mouseMoveEvent(QMouseEvent *event)
{
    mousePosition = screenToCAD(event->pos());
    update();                 // 请求重绘,让预览线跟上鼠标
}

注意这里发生了一次坐标转换:鼠标给的是屏幕像素,存下来的立刻变成图纸坐标。从这一刻起,界面上所有的状态都用图纸坐标表示——这是整个程序能正确缩放平移的前提。

(构造函数里别忘了 setMouseTracking(true),否则不按键的时候收不到移动事件。)

3.3、点击:两次点击确定一条线

void Canvas::mouseReleaseEvent(QMouseEvent *event)
{
    if (event->button() != Qt::LeftButton) {
        return;
    }

    Point clickedPoint = screenToCAD(event->pos());

    if (!hasFirstPoint) {
        firstPoint = clickedPoint;        // 第一次点击:记下起点
        hasFirstPoint = true;
    } else {
        currentLine = Line(firstPoint, clickedPoint);   // 第二次:连成一条线
        hasLine = true;
    }
    update();
}

逻辑很朴素:第一次点击是起点,第二次点击是终点,两点连成线。

3.4、绘制:把图纸坐标翻译回屏幕

void Canvas::paintEvent(QPaintEvent *event)
{
    Q_UNUSED(event);
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing);

    painter.fillRect(rect(), Qt::white);

    // 坐标轴和原点:把图纸原点放在窗口中心
    originX = width() / 2.0;
    originY = height() / 2.0;
    painter.setPen(QPen(Qt::black, 1));
    painter.drawLine(0, static_cast<int>(originY), width(), static_cast<int>(originY));
    painter.drawLine(static_cast<int>(originX), 0, static_cast<int>(originX), height());

    // 已经完成的线
    if (hasLine) {
        painter.setPen(QPen(Qt::blue, 2));
        QPoint startScreen(
            static_cast<int>(originX + currentLine.startPoint.x * scale),
            static_cast<int>(originY - currentLine.startPoint.y * scale));
        QPoint endScreen(
            static_cast<int>(originX + currentLine.endPoint.x * scale),
            static_cast<int>(originY - currentLine.endPoint.y * scale));
        painter.drawLine(startScreen, endScreen);
    }

    // 只点了第一下时的虚线预览(同样的公式又写了一遍)
    if (hasFirstPoint && !hasLine) {
        painter.setPen(QPen(Qt::gray, 1, Qt::DashLine));
        QPoint startScreen(
            static_cast<int>(originX + firstPoint.x * scale),
            static_cast<int>(originY - firstPoint.y * scale));
        QPoint currentScreen(
            static_cast<int>(originX + mousePosition.x * scale),
            static_cast<int>(originY - mousePosition.y * scale));
        painter.drawLine(startScreen, currentScreen);
    }
}

看这段代码你能直接感受到 2.3 里说的那个问题:同一个转换公式,在同一个函数里写了四遍。当时我盯着屏幕按 F5 看效果,觉得"能跑就行"——这就是"技术债"最典型的样子,它不疼,但它在那。

这里有个体验上的小设计:点了第一个点之后、还没点第二个点的时候,画一条跟着鼠标走的虚线。

这就是 CAD 里"预览"的雏形。它的实现简单到只有四行,但用户第一次用的时候,正是靠这条虚线才感觉到"这个软件知道我在干什么"。

在这里插入图片描述
在这里插入图片描述

3.5、先说一个坑:为什么坐标轴的代码在 paintEvent 里

你可能会注意到上面这段里有两行把 originX / originY 设成窗口中心:

originX = width() / 2.0;
originY = height() / 2.0;

放在 paintEvent 里,现在跑起来完全没问题——因为窗口不变的时候它算出来永远是同一个数,放哪儿都一样。

但这是个定时炸弹。 等我们加了"中键拖动平移",originX / originY 就变成用户拖出来的可变量了——那时候如果还留在 paintEvent 里赋值,等于每画一帧就把用户辛苦拖出来的位置清一次。

这个坑我们会在讲视图变换那一篇专门拆掉。现在先记住一个原则:绘制函数只读状态,不改状态。


四、这一版跑起来了,但留下三个坑

到这里,程序已经能跑:点两下画出蓝线,第一点之后有虚线预览,左上角还能显示鼠标的图纸坐标。

但我知道它有三个地方撑不了多久:

第一个是上面说的那个——originX / originY 在 paintEvent 里赋值。现在无害,加了平移就会炸。

第二个是两个 bool 表示三种状态:

bool hasFirstPoint;   // 记了起点没有
bool hasLine;         // 画完了没有

这两个变量的组合其实只有三种有意义的状态(等起点 / 等终点 / 已完成),但用两个 bool 表达,就意味着有个组合是永远不会出现的——而且更麻烦的是,等你加上"画圆"“画矩形”"选择"这些工具,每个工具都有自己的中间状态,hasFirstPoint 这种名字就彻底不够用了。

第三个是只能画一条线。 currentLine 是画布里的一个成员,画完就占住了,再点也不会产生第二条。想画多条,需要一个"实体容器"把画过的都存起来——这就是后面 Document 要解决的问题。

这三个坑都不是"写错了",而是第一版的合理简化。它们的共同点是:等到需求变复杂的时候会突然变得很难受。所以后面几篇基本就是按顺序把它们拆掉:

  • 状态太乱 → 把交互写成状态机(下一篇)
  • 视图参数乱放 → 抽出视图变换
  • 只能画一条线 → 引入实体容器和基类
  • 只能画线 → 抽象画笔,让 Core 彻底不认识 Qt

本文对应的完整代码(可直接下载):
https://gitee.com/studyingart/mini-cad/repository/archive/9b08454.zip

项目主页(后续更新都在这里):
https://gitee.com/studyingart/mini-cad


总结

程序现在能画一条线了,但画完就废了——你既不能取消,也不能再画第二条,想重来只能重启程序。下一篇我们来解决"交互"这件事:怎么用 Esc 和右键取消当前操作?为什么"两个 bool"迟早要换成状态机?以及一个几乎所有人第一次写 Qt 都会踩的坑——你重写了 keyPressEvent,但按什么都没反应(剧透:因为默认的控件根本不接收键盘焦点)。

在这里插入图片描述

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

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2302_80177460/article/details/166601210

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--