
👓️博主简介:

文章目录
前言
前面我写了一个《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





