
🫧 励志不掉头发的内向程序员 :个人主页
✨️ 个人专栏: 《C++语言》《Linux学习》
🌅偶尔悲伤,偶尔被幸福所完善
👓️博主简介:

文章目录
- 前言
- 一、第二次点击:从预览线到正式直线
-
- 1.1、为什么需要两个坐标?
- [1.2、从 RS_ActionDrawLine 到 RS_Line](#1.2、从 RS_ActionDrawLine 到 RS_Line)
- 1.3、创建对象之后,还没有完成整个过程
- 1.4、把第二次点击完整串起来
- 二、第二次点击:从"临时终点"变成"确定终点"
-
- [2.1、第二次点击首先进入 mouseReleaseEvent](#2.1、第二次点击首先进入 mouseReleaseEvent)
- 2.2、当前鼠标位置成为正式终点
- [2.3、坐标被封装成 RS_CoordinateEvent](#2.3、坐标被封装成 RS_CoordinateEvent)
- 2.4、状态决定这次点击的作用
- 2.5、临时直线和正式直线的区别
- 2.6、第二次点击的完整逻辑
- 三、两个坐标是如何变成一条直线的?
-
- [3.1、RS_Line 保存的是什么?](#3.1、RS_Line 保存的是什么?)
- 3.2、第一次点击保存起点
- 3.3、鼠标移动时生成临时直线
- 3.4、第二次点击确定终点
- 3.5、构造函数内部做了什么?
- 3.6、为什么不需要计算斜率和长度?
- 3.7、创建对象和显示对象是两回事
- [四、创建 RS_Line 之后,为什么还要加入 Graphic?](#四、创建 RS_Line 之后,为什么还要加入 Graphic?)
-
- [4.1、new RS_Line() 只是在内存中创建对象](#4.1、new RS_Line() 只是在内存中创建对象)
- [4.2、Graphic 相当于当前图形的总容器](#4.2、Graphic 相当于当前图形的总容器)
- [4.3、不加入 Graphic 会发生什么?](#4.3、不加入 Graphic 会发生什么?)
- [4.4、为什么预览线不一定要加入正式 Graphic?](#4.4、为什么预览线不一定要加入正式 Graphic?)
- [4.5、加入 Graphic 后,图形系统才能继续处理](#4.5、加入 Graphic 后,图形系统才能继续处理)
- 4.6、可以用一个简单例子理解
- [五、加入 Graphic 之后,绘图区为什么会更新?](#五、加入 Graphic 之后,绘图区为什么会更新?)
-
- [5.1、Graphic 保存的是数据,绘图区显示的是数据](#5.1、Graphic 保存的是数据,绘图区显示的是数据)
- 5.2、为什么视图能够知道数据发生了变化?
- [5.3、绘制时并不是把 RS_Line 直接显示出来](#5.3、绘制时并不是把 RS_Line 直接显示出来)
- 5.4、为什么鼠标移动时也能看到预览?
- 六、把第二次点击完整串起来
-
- [6.1、鼠标左键释放,进入 mouseReleaseEvent()](#6.1、鼠标左键释放,进入 mouseReleaseEvent())
- [6.2、snapPoint() 获取终点坐标](#6.2、snapPoint() 获取终点坐标)
- 6.3、必要时进行角度捕捉
- 6.4、将终点封装成坐标事件
- [6.5、coordinateEvent() 根据状态处理终点](#6.5、coordinateEvent() 根据状态处理终点)
- [6.6、两个坐标真正变成 RS_Line](#6.6、两个坐标真正变成 RS_Line)
- [6.7、RS_Line 创建后,还要加入 Graphic](#6.7、RS_Line 创建后,还要加入 Graphic)
- [6.8、加入 Graphic 后,绘图区为什么更新?](#6.8、加入 Graphic 后,绘图区为什么更新?)
- 6.9、绘图区重新绘制
- 6.10、创建完成后,进入下一轮绘制
- [七、从 RS_ActionDrawLine 看 LibreCAD 的分层设计](#七、从 RS_ActionDrawLine 看 LibreCAD 的分层设计)
-
- [7.1、RS_ActionDrawLine 不是"画线函数",而是交互控制器](#7.1、RS_ActionDrawLine 不是“画线函数”,而是交互控制器)
- 7.2、输入层:负责接收用户操作
- 7.3、交互层:负责组织绘图流程
- [7.4、坐标层:负责把屏幕位置转换为 CAD 坐标](#7.4、坐标层:负责把屏幕位置转换为 CAD 坐标)
- 7.5、几何层:负责表示真正的图形
- [7.6、数据管理层:负责把实体加入 Graphic](#7.6、数据管理层:负责把实体加入 Graphic)
- 7.7、显示层:负责把几何数据变成屏幕图像
- 7.8、预览系统也体现了分层设计
- 7.9、为什么这种设计更容易扩展?
- [7.10、RS_ActionDrawLine 在架构中的真正位置](#7.10、RS_ActionDrawLine 在架构中的真正位置)
- 总结
前言
上一篇文章中,我们追踪了 LibreCAD 中鼠标事件的传递过程,了解了用户点击绘图区后,鼠标输入如何经过 Qt 事件系统、绘图区以及 RS_EventHandler,最终进入当前工作的 RS_ActionDrawLine。但是,事件进入 RS_ActionDrawLine,并不意味着一条直线已经真正创建完成。对于用户来说,画线似乎只是点击两次鼠标;而从源码角度来看,这两次点击之间还涉及起点保存、终点确定、坐标处理、图形实体创建以及图形数据更新等多个步骤。尤其是第二次点击之后,程序不再只是处理一个临时的鼠标位置,而是需要将已经确定的起点和终点转换成一个真正的 CAD 图形实体。因此,本篇文章将继续围绕 RS_ActionDrawLine 展开分析,重点追踪:**第二次鼠标点击 → 获取终点坐标 → 创建 RS_Line → 加入图形数据。**通过这条调用链,我们可以进一步理解Action 如何将用户的几何操作转换成 LibreCAD 中可以被保存、管理和显示的图形实体。上一篇文章解决的是鼠标事件如何进入当前 Action?而本篇文章要解决的是当前 Action 如何把两个坐标变成一条真正的直线?这也是从用户交互层进入 LibreCAD 图形数据层的重要一步。
一、第二次点击:从预览线到正式直线
上一篇文章中,我们已经知道,第一次点击之后,RS_ActionDrawLine 保存了直线的起点;鼠标移动时,当前鼠标位置暂时作为终点,用来显示预览线。此时,程序中的状态可以理解为:
test
startPoint = 已确定
currentPoint = 鼠标当前位置
endPoint = 尚未最终确定
当用户第二次点击鼠标时,情况就发生了变化。这次点击不再只是更新预览,而是要告诉程序 "我已经确定了这条直线的终点,可以正式创建图形了。"因此,第二次点击之后,程序需要完成以下几个步骤:
第二次点击 -> 获取当前鼠标坐标 -> 确定终点 -> 使用起点和终点创建 RS_Line -> 将 RS_Line 加入图形数据
这里最关键的变化是:
临时终点 -> 确定终点 -> 正式图形实体
也就是说,第二次点击是从"交互过程"进入"图形创建过程"的分界点。
1.1、为什么需要两个坐标?
从几何角度来看,一条直线需要两个点:
test
起点 A
终点 B
例如:
test
A = (100, 100)
B = (300, 200)
第一次点击时,程序获得:
test
startPoint = (100, 100);
鼠标移动时,程序不断更新临时终点:
test
currentPoint = (300, 200);
第二次点击之后,当前鼠标位置才真正成为终点:
test
endPoint = currentPoint;
于是,程序就拥有了创建直线所需要的两个几何点:
test
startPoint = (100, 100)
endPoint = (300, 200)
接下来,RS_ActionDrawLine 就可以使用这两个点构造一个 RS_Line 对象。
1.2、从 RS_ActionDrawLine 到 RS_Line
当第二次点击确定终点之后,RS_ActionDrawLine 已经拥有了完整的起点和终点。接下来,程序使用这两个坐标创建 RS_Line 对象。此时,用户在交互过程中确定的几何意图,才真正被转换成了一个可以被 LibreCAD 管理的图形实体。
cpp
void RS_ActionDrawLine::coordinateEvent(RS_CoordinateEvent* e)
{
switch (getStatus()) {
case SetStartpoint:
// ...
case SetEndpoint:
if ((mouse-pPoints->data.startpoint).squared() > RS_TOLERANCE2) {
// refuse zero length lines
pPoints->data.endpoint = mouse;
++pPoints->startOffset;
addHistory( HA_SetEndpoint, pPoints->data.startpoint, mouse, pPoints->startOffset);
trigger();
pPoints->data.startpoint = pPoints->data.endpoint;
if (pPoints->history.size() >= 2) {
updateMouseButtonHints();
}
}
break;
default:
// ...
}
}
当我们设置完第一个点后,此时状态变成了 SetEndpoint。此时再次点击鼠标,这个时候就会进入 case SetEndpoint 分支。得到终点后,此时就会进入创建真正对象的阶段了,这个时候会调用 trigger() 函数,这个函数的作用就是由预览的线变成真正的实体的。
cpp
void RS_ActionDrawLine::trigger()
{
RS_PreviewActionInterface::trigger();
RS_Line* line = new RS_Line(container, pPoints->data);
// ...
}
1.3、创建对象之后,还没有完成整个过程
这里还需要区分两个概念:创建 RS_Line和将 RS_Line 加入图形数据并不是同一件事。例如:
test
RS_Line* line = new RS_Line(...);
这一步表示程序创建了一个直线对象。但如果这个对象没有被加入 LibreCAD 的图形数据结构,那么它还不能简单地理解为"已经成为当前 CAD 文档中的正式图形"。因此,创建 RS_Line 之后,还需要继续追踪:
RS_ActionDrawLine -> 创建 RS_Line -> 加入 Graphic / Document -> 图形数据发生变化
而真正成为当前 CAD 文档中的正式图形的操作则是在创建后加入到容器队列中去。
cpp
void RS_ActionDrawLine::trigger()
{
RS_PreviewActionInterface::trigger();
RS_Line* line = new RS_Line(container, pPoints->data);
line->setLayerToActive();
line->setPenToActive();
container->addEntity(line);
// ...
}
此时这里的 addEntity 就是加入到容器队列,此时我们来看看 addEntity 函数:
cpp
void RS_EntityContainer::addEntity(RS_Entity* entity) {
if (!entity) return;
if (entity->rtti()==RS2::EntityImage ||
entity->rtti()==RS2::EntityHatch) {
// ...
} else {
entities.append(entity);
}
if (autoUpdateBorders) {
// ...
}
}
这里发现我们是将参数插入到 entities 中去,这个就是我们的容器队列了,作用是记录当前 CAD 文档中的正式图形。
cpp
QList<RS_Entity *> entities;
我们可以看到,队列存储的对象是 RS_Entity*,这个对象是我们所有容器的基类,它被原子容器(RS_AtomicEntity)和容器(RS_EntityContainer)两大类继承,然后我们的所有容器,都是通过继承他们得到的,这样 LibreCAD 就能对我们的各容器对象统一进行管理,这样就很好的将容器和其他的模块进行解耦,是我们想要实现新容器变得十分容易。
1.4、把第二次点击完整串起来
到这里,我们可以先把整个过程抽象成:
用户第二次点击 -> 获取当前鼠标坐标 -> 确定 endPoint -> 使用 startPoint + endPoint -> 创建 RS_Line -> 加入图形数据 -> 后续绘图更新
后面我们继续往源码追踪,就可以把这条抽象链替换成真实调用链:
实际鼠标事件入口 -> 实际坐标事件处理函数 -> RS_ActionDrawLine -> 实际 RS_Line 构造函数 -> 实际 addEntity 调用
第二次点击的本质,是让 RS_ActionDrawLine 从"等待终点"的交互阶段进入"创建图形实体"的阶段。整个过程可以概括为:
第一次点击 -> 确定起点 -> 鼠标移动 -> 更新临时终点 -> 第二次点击 -> 确定终点 -> 创建 RS_Line -> 加入图形数据
因此,RS_ActionDrawLine 并不是简单地"接收两个坐标,然后画一条线",而是负责将用户的交互过程逐步转换成几何数据,再交给图形实体和数据管理层。
二、第二次点击:从"临时终点"变成"确定终点"
前面中我们已经知道:第一次点击确定起点后,鼠标移动时,RS_ActionDrawLine 会不断根据当前鼠标位置生成一条临时直线,用来显示绘图预览。此时,鼠标所在位置只是临时终点。它会随着鼠标移动而变化,并没有真正写入图形数据。当用户再次点击鼠标左键时,程序才会把当前鼠标位置确定为直线的终点,完成从"预览"到"正式创建"的转换。
2.1、第二次点击首先进入 mouseReleaseEvent
在 RS_ActionDrawLine 中,鼠标左键释放事件会进入:
cpp
void RS_ActionDrawLine::mouseReleaseEvent(QMouseEvent* e)
核心代码如下:
cpp
void RS_ActionDrawLine::mouseReleaseEvent(QMouseEvent* e)
{
if (e->button() == Qt::LeftButton) {
RS_Vector snapped = snapPoint(e);
// Snapping to angle(15*) if shift key is pressed
if ((e->modifiers() & Qt::ShiftModifier)
&& getStatus() == SetEndpoint ) {
snapped = snapToAngle(snapped, pPoints->data.startpoint, 15.);
}
RS_CoordinateEvent ce(snapped);
coordinateEvent(&ce);
}
else if (e->button() == Qt::RightButton) {
// ...
}
}
}
这段代码可以分成三个步骤:
鼠标左键释放 -> 获取当前鼠标对应的 CAD 坐标 -> 必要时进行角度捕捉 -> 封装成 RS_CoordinateEvent -> 交给 coordinateEvent() 处理
这里需要注意,第二次点击并不是直接在 mouseReleaseEvent() 中创建直线,而是先获取确定的坐标,再交给后续的坐标事件处理函数。
2.2、当前鼠标位置成为正式终点
获取坐标的代码是:
test
RS_Vector snapped = snapPoint(e);
snapped 表示经过捕捉处理后的坐标。它可能是:
test
鼠标当前位置对应的 CAD 坐标;
捕捉到的端点坐标;
捕捉到的交点坐标;
开启正交或角度限制后的坐标;
如果用户按住了 Shift,程序还会执行:
cpp
snapped = snapToAngle(snapped, pPoints->data.startpoint, 15.);
这里的含义是:
以已经确定的起点为基准,对当前终点进行角度约束。因此,第二次点击得到的终点并不一定是鼠标屏幕上的原始位置,而是经过坐标转换、对象捕捉和角度约束之后的最终 CAD 坐标。
2.3、坐标被封装成 RS_CoordinateEvent
得到终点坐标后,程序将它封装成一个坐标事件:
test
RS_CoordinateEvent ce(snapped);
然后调用:
test
coordinateEvent(&ce);
这一步可以理解为:
snapped -> RS_CoordinateEvent -> coordinateEvent()
mouseReleaseEvent() 主要负责处理鼠标输入,而 coordinateEvent() 才是进一步处理"用户已经确定了一个坐标"的地方。也就是说:
鼠标事件负责告诉程序"用户点击了哪里",坐标事件负责告诉绘图动作"这个坐标应该如何参与图形创建"。
2.4、状态决定这次点击的作用
RS_ActionDrawLine 使用状态来区分当前是在等待起点,还是等待终点:
cpp
/// Action States
enum Status {
SetStartpoint, ///< Setting the startpoint
SetEndpoint ///< Setting the endpoint
};
因此,同样是一次鼠标左键点击,在不同状态下具有不同含义。
| 当前状态 | 鼠标左键点击后的作用 |
|---|---|
| SetStartpoint | 保存起点,进入等待终点状态 |
| SetEndpoint | 保存终点,创建正式直线 |
第二次点击发生时,当前状态应该是:
test
SetEndpoint
这意味着程序已经拥有:
test
pPoints->data.startpoint
现在需要做的就是获取终点,并使用起点和终点创建一条真正的 RS_Line。
2.5、临时直线和正式直线的区别
在鼠标移动过程中,程序会不断创建临时直线:
cpp
RS_Line *line = new RS_Line(
pPoints->data.startpoint,
mouse
);
preview->addEntity(line);
这里的 mouse 是当前鼠标位置,因此这条直线只是预览对象。
鼠标继续移动时:
删除旧预览 -> 获取新的鼠标坐标 -> 创建新的临时 RS_Line -> 显示新的预览
第二次点击之后,情况发生变化:
临时终点 -> 鼠标左键点击 -> 确定终点 -> 使用起点和终点创建正式 RS_Line
两者虽然都可能使用:
test
new RS_Line(startPoint, endPoint);
但它们的用途不同:
test
预览直线:用于显示当前绘图结果,鼠标移动后可以被删除;
正式直线:表示用户已经确认的图形,需要加入正式图形数据中。
因此,不能简单地认为只要执行了 new RS_Line(),图形就已经正式创建完成。真正完成绘图,还需要继续追踪这条直线是否被加入到正式的图形容器、图形文档或图层数据中。
2.6、第二次点击的完整逻辑
从当前代码可以整理出如下流程:
第二次点击鼠标左键 -> RS_ActionDrawLine::mouseReleaseEvent() -> snapPoint(e) -> 获取经过捕捉处理的终点坐标 -> 必要时执行 snapToAngle() -> 创建 RS_CoordinateEvent -> 调用 coordinateEvent() -> 根据当前状态确认终点 -> 使用起点和终点创建正式 RS_Line -> 将 RS_Line 加入正式图形数据 -> 更新显示 -> 进入下一次绘制,等待新的起点
这里最关键的变化是:
鼠标移动时:
test
当前鼠标位置 = 临时终点
第二次点击后:
test
当前鼠标位置 = 确定终点
所以,第二次点击并不是简单地"再获取一次鼠标坐标",而是完成了一个重要的状态转换:
SetEndpoint -> 终点确认 -> 创建正式图形 -> 重新进入 SetStartpoint
第二次点击是 RS_ActionDrawLine 从预览阶段进入正式创建阶段的关键节点。程序首先通过 snapPoint() 获取经过捕捉处理的 CAD 坐标,然后通过 RS_CoordinateEvent 将坐标交给 coordinateEvent()。后续逻辑再根据当前状态,将这个坐标作为正式终点,并使用起点和终点创建真正的 RS_Line。因此,整个过程可以概括为:
第二次点击确定终点,坐标事件驱动图形创建,状态机推动绘图动作进入下一轮。
三、两个坐标是如何变成一条直线的?
两个坐标变成一条直线,本质上不是进行复杂计算,而是把两个点保存到一个 RS_Line 对象中。
假设第一次点击得到起点:
test
RS_Vector startPoint(10, 20);
第二次点击得到终点:
test
RS_Vector endPoint(100, 80);
那么直线对象可以表示为:
test
RS_Line* line = new RS_Line(startPoint, endPoint);
也就是:
test
RS_Line:
起点 P1(10, 20) 终点 P2(100, 80)
●────────────────●
3.1、RS_Line 保存的是什么?
一条直线段最基本的数据就是两个端点:
test
P1 = 起点
P2 = 终点
在 LibreCAD 中,RS_Line 就是用来表示这条线段的图形实体。
cpp
struct RS_LineData {
RS_LineData() :
startpoint( false),
endpoint( false)
{}
RS_LineData(const RS_Vector& point1,
const RS_Vector& point2) :
startpoint( point1),
endpoint( point2)
{}
RS_Vector startpoint;
RS_Vector endpoint;
};
class RS_Line : public RS_AtomicEntity {
public:
RS_Line() = default;
RS_Line(RS_EntityContainer* parent,
const RS_LineData& d);
RS_Line(RS_EntityContainer* parent, const RS_Vector& pStart, const RS_Vector& pEnd);
RS_Line(const RS_Vector& pStart, const RS_Vector& pEnd);
// ...
protected:
RS_LineData data;
};
创建直线时传入的就是两个端点:
cpp
new RS_Line(startPoint, endPoint);
因此,RS_Line 并不是只保存一个方向,也不是只保存鼠标轨迹,而是保存起点坐标和终点坐标,由图形实体基类提供的图层、画笔等属性。
3.2、第一次点击保存起点
第一次点击时,程序处于 SetStartpoint 状态。点击后,得到一个 CAD 坐标,并保存到起点数据中:
cpp
pPoints->data.startpoint = e->getCoordinate();
逻辑就是:
第一次点击 -> 得到 CAD 坐标 -> 保存为 startpoint -> 状态变为 SetEndpoint
此时程序只有一个点:
P1 ●
还不能创建一条有明确长度的线段。
3.3、鼠标移动时生成临时直线
进入 SetEndpoint 状态后,鼠标移动会不断得到新的临时终点:
cpp
RS_Vector mouse = snapPoint(e);
然后创建预览直线:
cpp
RS_Line* line = new RS_Line(
pPoints->data.startpoint,
mouse
);
每次移动时,程序都会根据:
test
固定起点 + 当前鼠标坐标
重新生成一条临时 RS_Line。这就是为什么预览直线会跟着鼠标移动。
3.4、第二次点击确定终点
第二次点击时,程序再次执行:
cpp
RS_Vector snapped = snapPoint(e);
此时得到的 snapped 就不再只是用于预览,而是作为最终终点:
test
startpoint = 第一次点击的位置
endpoint = 第二次点击的位置
之后通过:
cpp
RS_CoordinateEvent ce(snapped);
coordinateEvent(&ce);
把这个终点坐标交给坐标事件处理函数后去创建真正的直线对象。
这一步完成了:
两个 RS_Vector -> RS_Line 构造函数 -> 一个 RS_Line 图形实体
3.5、构造函数内部做了什么?
从面向对象的角度看:
cpp
new RS_Line(startPoint, endPoint);
相当于调用:
cpp
RS_Line::RS_Line(startPoint, endPoint);
构造函数会把传入的两个点保存到直线对象内部。
概念上类似:
cpp
RS_Line::RS_Line(const RS_Vector& start, const RS_Vector& end)
{
startpoint = start;
endpoint = end;
}
因此:
cpp
RS_Line* line = new RS_Line(RS_Vector(10, 20),RS_Vector(100, 80));
得到的对象内部可以理解为:

这就是"两个坐标变成一条直线"的核心。
3.6、为什么不需要计算斜率和长度?
因为 CAD 图形实体通常采用几何参数表示法。
对于线段:
test
线段 = 起点 + 终点
斜率、长度、方向等信息都可以根据两个端点计算出来:
test
dx = x2 - x1
dy = y2 - y1
长度L:
( x 2 − x 1 ) 2 + ( y 2 − y 1 ) 2 \sqrt{(x2−x1)^2 + (y2−y1)^2} (x2−x1)2+(y2−y1)2
方向:
test
θ=atan2(y2−y1, x2−x1)
但创建 RS_Line 时,通常不需要提前计算这些值。只要保存两个端点,后续需要时再计算即可。
所以:
cpp
new RS_Line(startPoint, endPoint);
并不是把整条线上的所有像素都保存下来,而是保存这条线的几何定义。
3.7、创建对象和显示对象是两回事
需要区分两个过程:
创建几何实体
cpp
RS_Line* line = new RS_Line(startPoint, endPoint);
表示:
内存中创建了一个代表线段的对象。加入正式图形数据,概念上还需要类似:
cpp
container->addEntity(line);
表示:
把这条线加入当前图形、图层或文档的数据结构中。只有加入正式图形数据后,这条线才会成为 CAD 文档中的正式图形,而不是一个临时对象。完整逻辑是:
两个坐标 -> new RS_Line(startPoint, endPoint) -> 创建线段对象 -> 加入正式图形容器 -> 图形文档拥有这条线 -> 刷新视图 -> 屏幕显示线段
一条直线在程序中并不是由大量像素组成的,而是由两个几何端点定义的。第一次点击确定起点,第二次点击确定终点,RS_ActionDrawLine 将这两个 RS_Vector 坐标传入 RS_Line 的构造函数,创建一个新的线段实体。随后,该实体还需要被加入正式图形数据中,才能成为当前 CAD 文档的一部分。
四、创建 RS_Line 之后,为什么还要加入 Graphic?
因为 RS_Line 只是一个"图形对象",加入 Graphic 才能让它成为当前 CAD 图形的一部分。可以把两者理解成:
test
RS_Line:创建一件物品
Graphic:保存和管理这些物品的仓库
4.1、new RS_Line() 只是在内存中创建对象
执行:
test
RS_Line* line = new RS_Line(startPoint, endPoint);
之后,内存中确实有了一个线段对象:

但是,当前图形文档并不知道这个对象的存在。如果没有其他地方保存这个指针,那么后续甚至可能出现:
创建 RS_Line -> 没有加入任何容器 -> 对象失去管理 -> 无法作为 CAD 图形继续使用
因此,创建对象和把对象交给图形系统管理,是两个不同的步骤。
4.2、Graphic 相当于当前图形的总容器
一个 CAD 文件中可能包含很多实体:

Graphic 负责管理当前图形中的实体。当直线加入 Graphic 后,图形系统才能通过容器找到它:
Graphic -> 查找所有图形实体 -> 发现 RS_Line -> 对 RS_Line 进行显示、选择、保存、编辑
概念上类似:
cpp
RS_Line* line = new RS_Line(startPoint, endPoint);
graphic->addEntity(line);
这里:
cpp
new RS_Line(...)
负责创建实体;
cpp
graphic->addEntity(line);
负责把实体纳入当前图形的数据管理体系。
4.3、不加入 Graphic 会发生什么?
假设用户已经画出了一条直线,但是程序只执行:
cpp
RS_Line* line = new RS_Line(P1, P2);
却没有加入 Graphic。那么这条线可能只是一个独立存在的对象:
test
当前图形无法遍历到它;
保存文件时不会保存它;
重新加载文件时不会出现它;
选择工具无法正常选中它;
删除、移动、修改等操作无法通过图形容器找到它;
视图刷新时也不一定会绘制它。
因此没有加入 Graphic,这条线就没有真正成为当前 CAD 文档中的图形。
4.4、为什么预览线不一定要加入正式 Graphic?
鼠标移动时创建的临时直线,通常加入的是预览容器:
cpp
RS_Line* line = new RS_Line(
pPoints->data.startpoint,
mouse
);
preview->addEntity(line);
这里的 preview 和正式的 Graphic 作用不同。预览容器 preview -> 临时 RS_Line,用于显示用户当前正在绘制的效果。鼠标移动后,旧的预览线可以被删除:
删除旧预览 -> 创建新预览 -> 显示新预览
而正式图形容器 Graphic:已确认的 RS_Line、已确认的 RS_Circle、已确认的 RS_Arc,用于保存用户已经确认的图形。所以两者的区别是:
| 对象 | 所属容器 | 生命周期 | 是否属于正式图形 |
|---|---|---|---|
| 临时直线 | preview | 鼠标移动时不断变化 | 否 |
| 正式直线 | Graphic | 直到用户删除或修改 | 是 |
4.5、加入 Graphic 后,图形系统才能继续处理
正式直线加入 Graphic 后,后续操作就可以围绕它展开:
RS_Line -> 加入 Graphic -> Graphic 管理实体 -> 视图绘制 -> 文件保存 -> 选择、移动、删除、修改
例如保存文件时,程序需要遍历当前图形:
Graphic -> 遍历所有实体 -> 找到 RS_Line -> 读取起点和终点 -> 写入文件
如果直线没有加入 Graphic,保存过程就找不到它。
4.6、可以用一个简单例子理解
假设:
cpp
RS_Line* line = new RS_Line(P1, P2);
相当于我在内存中制作了一把椅子。而:
cpp
graphic->addEntity(line);
相当于我把这把椅子放进房间,并登记到房间的物品清单中。之后房间可以找到这把椅子可以移动它、可以删除它、可以统计它、可以保存房间布局。如果只创建椅子,却没有放进房间,房间自然无法管理它。
new RS_Line(startPoint, endPoint) 只负责在内存中创建一个线段实体,它并不会自动成为当前 CAD 文档中的正式图形。为了让图形系统能够统一管理这条直线,还需要将其加入 Graphic。加入后,Graphic 才能在绘制、选择、编辑、保存等操作中找到并处理该实体。
因此,直线创建过程实际上包含两个层次:第一步是通过两个端点构造 RS_Line,完成几何对象的创建;第二步是将该对象加入 Graphic,完成它在当前图形文档中的注册。可以概括为:
两个坐标 -> new RS_Line(startPoint, endPoint) -> 创建几何实体 -> graphic->addEntity(line) -> 加入正式图形数据 -> 视图绘制、文件保存、后续编辑
一句话:RS_Line 解决"这条线是什么",Graphic 解决"这条线归谁管理、如何参与整个 CAD 系统"。
五、加入 Graphic 之后,绘图区为什么会更新?
加入 Graphic 后,绘图区并不是因为"容器里多了一条线"就自动刷新,而是因为图形数据发生变化后,程序触发了视图更新机制。这里要区分两个动作:
加入 Graphic -> 图形数据发生变化 -> 通知或请求视图更新 -> 绘图区重新绘制
addEntity() 负责数据管理,真正让屏幕更新的代码,需要继续查看它后面的调用链。
5.1、Graphic 保存的是数据,绘图区显示的是数据
可以把 LibreCAD 理解成两个部分:

当执行:
cpp
graphic->addEntity(line);
首先发生的是 Graphic 的实体列表增加了一条 RS_Line。此时改变的是内存中的图形数据,不是屏幕像素。绘图区要显示这条线,还需要重新读取 Graphic 中的实体并执行绘制。
5.2、为什么视图能够知道数据发生了变化?
通常有两种可能的机制。
方式一:加入实体时主动通知视图
概念上可能是:
cpp
graphic->addEntity(line);
graphicView->update();
或者:
cpp
graphic->addEntity(line);
graphicView->redraw();
流程是:
addEntity() -> Graphic 数据发生变化 -> 调用 update() / redraw() -> Qt 请求重新绘制
这里的 update() 一般不是立即把像素画出来,而是向 Qt 发送一个重绘请求。
方式二:Graphic 发出变化通知
也可能是图形数据对象发出信号,由视图接收:
Graphic(图形发生变化)-> 变化通知 -> GraphicView -> update() -> 重新绘制
如果项目使用 Qt 信号槽,概念上可能类似:
cpp
connect(
graphic,
&Graphic::changed,
graphicView,
&GraphicView::update
);
而这里, LibreCAD 则是使用的方式一,我们在生成实体容器的时候,或者预览生成预览的容器时,都会在创建完毕后调用重绘函数,此时触发重绘的流程。
cpp
void RS_ActionDrawLine::trigger()
{
// ...
graphicView->redraw(RS2::RedrawDrawing);
// ...
}
上面的 update() 在 LibreCAD 中为 redraw 函数,它的参数是要重绘位置的内容,就比如如果我们是在预览,创建的是预览的容器,此时就是告诉 graphicView 要重绘预览对应的图层,如果是画好了容器,则是这里的对应的实体图层。
5.3、绘制时并不是把 RS_Line 直接显示出来
RS_Line 保存的是几何数据:
test
startPoint = (10, 20)
endPoint = (100, 80)
绘图区需要把 CAD 坐标转换成屏幕坐标,然后才能交给绘图系统。概念流程是:
Graphic -> 找到 RS_Line -> 读取起点和终点 -> CAD 坐标 → 屏幕坐标 -> 绘制线段 -> 屏幕出现直线
所以:Graphic 负责保存"画什么",视图负责决定"怎么把它画到屏幕上"。
5.4、为什么鼠标移动时也能看到预览?
之前有说过预览代码是:
cpp
RS_Line* line = new RS_Line(pPoints->data.startpoint, mouse);
preview->addEntity(line);
drawPreview();
这里已经明确出现了:
cpp
drawPreview();
因此,预览更新的链路比较清楚:
鼠标移动 -> 创建临时 RS_Line -> 加入 preview -> drawPreview() -> 显示临时直线
六、把第二次点击完整串起来
鼠标左键释放 -> RS_ActionDrawLine::mouseReleaseEvent() ->snapPoint(e) ->得到经过捕捉处理的 CAD 坐标 ->创建 RS_CoordinateEvent -> coordinateEvent(&ce) -> 根据当前状态确认终点 -> 创建正式 RS_Line -> 加入 Graphic -> 通知或请求视图更新 -> 绘图区重新绘制 -> 进入下一轮绘制
6.1、鼠标左键释放,进入 mouseReleaseEvent()
第二次点击鼠标左键后,首先进入:
cpp
void RS_ActionDrawLine::mouseReleaseEvent(QMouseEvent* e)
核心代码是:
cpp
void RS_ActionDrawLine::mouseReleaseEvent(QMouseEvent* e)
{
if (e->button() == Qt::LeftButton) {
RS_Vector snapped = snapPoint(e);
// Snapping to angle(15*) if shift key is pressed
if ((e->modifiers() & Qt::ShiftModifier)
&& getStatus() == SetEndpoint ) {
snapped = snapToAngle(snapped, pPoints->data.startpoint, 15.);
}
RS_CoordinateEvent ce(snapped);
coordinateEvent(&ce);
}
else if (e->button() == Qt::RightButton) {
// ...
}
}
这段代码说明,第二次点击并不是直接创建直线,而是先把鼠标输入转换成一个确定的坐标事件。
6.2、snapPoint() 获取终点坐标
执行:
cpp
RS_Vector snapped = snapPoint(e);
会进入捕捉逻辑。
在 RS_Snapper::snapPoint(QMouseEvent* e) 中,鼠标位置会被转换为 CAD 坐标:
cpp
RS_Vector mouseCoord = graphicView->toGraph(e->x(), e->y());
也就是说:
鼠标屏幕坐标 -> snapPoint() -> graphicView->toGraph() -> CAD 坐标
例如,鼠标在屏幕上的某个位置,经过坐标转换后得到:
test
RS_Vector(100, 80)
这个坐标才是后续创建直线时使用的终点坐标。
6.3、必要时进行角度捕捉
如果用户按住 Shift,并且当前状态是:
test
SetEndpoint
程序会执行:
cpp
snapped = snapToAngle(snapped, pPoints->data.startpoint, 15.);
这意味着终点还经过角度约束。因此,最终的终点不是简单的鼠标原始位置,而是:
鼠标位置 -> 坐标转换 -> 对象捕捉 -> 角度捕捉 -> 最终 CAD 终点
6.4、将终点封装成坐标事件
得到最终坐标后:
cpp
RS_CoordinateEvent ce(snapped);
然后:
cpp
coordinateEvent(&ce);
此时调用链变成:
RS_ActionDrawLine::mouseReleaseEvent() -> snapPoint() -> RS_Vector snapped -> RS_CoordinateEvent ce(snapped) -> coordinateEvent(&ce)
这里的关键是:mouseReleaseEvent() 负责接收鼠标输入,coordinateEvent() 负责处理已经确定的 CAD 坐标。
6.5、coordinateEvent() 根据状态处理终点
此时 RS_ActionDrawLine 处于:
test
SetEndpoint
状态。
程序已经在第一次点击时保存了起点:startPoint = 第一次点击确定的坐标
第二次点击得到:endPoint = 第二次点击确定的坐标
因此,后续逻辑需要完成:
SetEndpoint -> 读取起点 -> 读取终点 -> 创建正式 RS_Line
6.6、两个坐标真正变成 RS_Line
此时代码最终执行:
cpp
RS_Line* line = new RS_Line(container, pPoints->data);
此时就完成了几何实体的创建,这里并没有生成一堆屏幕像素,而是创建了一个保存两个端点的几何对象。因此:两个坐标通过 RS_Line 的构造函数,被组织成一个具有起点和终点的线段实体。
6.7、RS_Line 创建后,还要加入 Graphic
创建对象之后,还需要将它加入正式图形数据:
graphic->addEntity(line);
这一步的作用是:
new RS_Line(...) -> 创建线段对象 -> addEntity(line) -> 加入 Graphic
加入前:
内存中有一条独立的 RS_Line
加入后:

这样,当前 CAD 文档才能统一管理这条线。
6.8、加入 Graphic 后,绘图区为什么更新?
加入 Graphic 后,图形数据发生变化:Graphic 中新增 RS_Line,接下来需要由视图更新机制重新绘制。
完整逻辑是:
Graphic 数据变化 -> 触发或请求视图更新 -> Qt 处理重绘请求 -> 视图读取 Graphic 中的实体 -> 读取 RS_Line 的起点和终点 -> 转换为屏幕坐标 -> 绘制到屏幕
因此,不能简单写成 addEntity() 执行后,屏幕自动出现直线。更准确的说法是 addEntity() 将正式线段加入图形数据,随后图形系统通过视图更新机制重新绘制,最终把这条线显示在绘图区。
cpp
graphicView->redraw(RS2::RedrawDrawing);
6.9、绘图区重新绘制
视图更新后,绘图区会重新读取图形数据。概念流程是:
Graphic -> 遍历图形实体 -> 找到 RS_Line -> 读取 startPoint 和 endPoint -> CAD 坐标转换为屏幕坐标 -> 绘制线段
最终显示过程不是:
两个坐标 → 直接变成屏幕像素
而是:
两个坐标 -> RS_Line 几何实体 -> Graphic 保存实体 -> 视图读取实体 -> 坐标转换 -> 绘图系统 -> 屏幕像素
6.10、创建完成后,进入下一轮绘制
正式直线创建完成后,绘图动作通常还需要回到等待起点的状态:
SetEndpoint -> 终点确认 -> 正式直线创建 -> 加入 Graphic -> 视图更新 -> 回到 SetStartpoint
这样用户就可以继续点击,绘制下一条直线。用一句话概括:
第二次点击先通过 mouseReleaseEvent() 获取最终终点,再通过 coordinateEvent() 进入绘图逻辑;后续使用起点和终点创建 RS_Line,将其加入 Graphic,再由视图更新机制重新绘制,最终把正式直线显示在绘图区。
七、从 RS_ActionDrawLine 看 LibreCAD 的分层设计
前面我们一直围绕 RS_ActionDrawLine 追踪一条直线的创建过程:
鼠标点击 -> 获取 CAD 坐标 -> 确定起点和终点 -> 创建 RS_Line -> 加入 Graphic -> 更新绘图区
表面上看,这只是一个"画线功能"。但如果把调用链继续拆开,就会发现:LibreCAD 并没有把鼠标处理、坐标转换、图形创建和屏幕绘制全部写在同一个函数里,而是将它们分配给不同层次的对象。RS_ActionDrawLine 正好可以作为观察这种分层设计的一个入口。
7.1、RS_ActionDrawLine 不是"画线函数",而是交互控制器
从名字上看 RS_ActionDrawLine 很容易让人认为它的职责就是创建一条直线,但实际来看,它还负责:
test
接收鼠标事件;
判断当前绘图状态;
保存起点;
获取当前终点;
处理角度捕捉;
管理临时预览;
响应确认、取消和下一步操作。
因此,它更接近一个交互控制器。可以概括为:
用户操作 -> RS_ActionDrawLine -> 决定当前操作应该做什么
例如:
cpp
if (getStatus() == SetEndpoint) {
// 当前正在等待终点
}
这里的代码并不是几何计算,而是在回答用户现在处于绘图流程的哪一步?这就是交互层的职责。
7.2、输入层:负责接收用户操作
用户点击鼠标时,首先产生的是 Qt 鼠标事件 QMouseEvent,在当前绘图动作中,鼠标左键释放最终进入:
cpp
RS_ActionDrawLine::mouseReleaseEvent(QMouseEvent* e)
它首先判断用户进行了什么操作:
cpp
if (e->button() == Qt::LeftButton) {
// 确认一个坐标
}
else if (e->button() == Qt::RightButton) {
// 取消或返回上一步
}
这一层关心的是:
test
用户点击了什么按钮?
用户是否按下 Shift?
当前是否需要确认坐标?
它并不直接负责绘制屏幕像素。可以表示为:
鼠标 -> QMouseEvent -> mouseReleaseEvent()
输入层的特点,输入层主要处理:
test
鼠标按下、释放和移动;
键盘修饰键;
左键确认;
右键取消;
用户操作与当前 Action 的连接。
它解决的是用户做了什么?
7.3、交互层:负责组织绘图流程
RS_ActionDrawLine 位于输入和图形数据之间。例如,第二次点击时:
cpp
RS_Vector snapped = snapPoint(e);
然后:
cpp
RS_CoordinateEvent ce(snapped);
coordinateEvent(&ce);
这里并不是简单地把鼠标坐标传给图形对象,而是先由 Action 判断,当前状态是不是 SetEndpoint?如果是,说明第一次点击已经确定了起点,现在这次点击应该被解释为终点。因此:
第一次点击 -> 保存起点 -> 进入 SetEndpoint -> 第二次点击 -> 确认终点
这就是状态机的作用。状态机体现了什么?
cpp
enum Status {
SetStartpoint,
SetEndpoint
};
同一个鼠标左键事件,在不同状态下有不同含义:
test
SetStartpoint + 左键
= 确定起点
SetEndpoint + 左键
= 确定终点
所以,RS_ActionDrawLine 并不是被动接收坐标,而是根据当前状态解释坐标。它解决的是用户当前的操作应该如何推进绘图流程。
7.4、坐标层:负责把屏幕位置转换为 CAD 坐标
用户点击的是屏幕位置:
test
e->x()
e->y()
但 RS_Line 需要的是 CAD 世界坐标。因此,程序需要经过
cpp
snapPoint(e)
进入:
cpp
graphicView->toGraph(e->x(), e->y());
然后通过:
cpp
toGraphX()
toGraphY()
完成坐标转换。整体过程是:
屏幕坐标 -> RS_Snapper::snapPoint() -> RS_GraphicView::toGraph() -> CAD 坐标
这部分逻辑与"画线"本身没有直接关系。无论用户是在画:
test
直线;
圆;
圆弧;
多段线;
都需要把屏幕位置转换为 CAD 坐标。因此,坐标转换被放在独立的视图和捕捉相关对象中,而不是全部写进 RS_ActionDrawLine。它解决的是屏幕上的这个位置,在 CAD 世界中对应哪里。
7.5、几何层:负责表示真正的图形
当起点和终点都确定后,程序才需要创建几何实体:
cpp
RS_Line* line = new RS_Line(container, pPoints->data);
这里的 RS_Line 不负责:
test
接收鼠标;
判断当前状态;
处理 Shift;
进行界面交互。
它的职责是表示一条线段:
test
RS_Line ( 起点, 终点 )
也就是说:
RS_ActionDrawLine -> 决定什么时候创建线 -> RS_Line -> 表示这条线的几何数据
这是一个重要的分层 RS_ActionDrawLine 负责"什么时候创建",RS_Line 负责"创建出来的图形是什么"。
7.6、数据管理层:负责把实体加入 Graphic
创建 RS_Line 后,还需要将它加入正式图形数据:
test
graphic->addEntity(line);
这一步不是几何计算,而是对象管理。Graphic 可以理解为当前 CAD 文档的图形集合:

加入之后,RS_Line 才能被当前图形系统统一管理。例如后续的:
test
遍历图形;
保存文件;
选择实体;
删除实体;
移动实体;
修改图层;
撤销操作;
都需要通过正式图形数据找到这条线。因此:
test
new RS_Line(...) -> 创建几何对象
graphic->addEntity(line) -> 加入图形数据模型
这两个步骤不能混为一谈。数据管理层解决的是这条图形属于哪个文档,以及如何被系统管理。
7.7、显示层:负责把几何数据变成屏幕图像
RS_Line 保存的是 CAD 坐标:
test
startPoint = (10, 20)
endPoint = (100, 80)
而绘图区需要的是屏幕像素。因此,显示层需要完成:
Graphic 中的 RS_Line -> 读取几何数据 -> CAD 坐标转换为屏幕坐标 -> 绘图系统绘制 -> 屏幕显示
这里的核心区别是:
test
RS_Line:描述画什么
GraphicView:负责在哪里、以什么方式显示
例如同一条 RS_Line:
test
缩放前:屏幕上长度较短
缩放后:屏幕上长度变长
但它的 CAD 坐标并没有改变。改变的是视图的显示参数,而不是几何实体本身。这说明几何数据和显示数据是分离的。显示层解决的是如何把 CAD 图形显示到当前窗口中。
7.8、预览系统也体现了分层设计
鼠标移动时,程序会创建临时直线:
cpp
RS_Line* line = new RS_Line(
pPoints->data.startpoint,
mouse
);
preview->addEntity(line);
drawPreview();
这里同样包含多个层次:
RS_ActionDrawLine -> 获取当前鼠标坐标 -> 创建临时 RS_Line -> 加入 preview -> drawPreview() -> 显示预览
临时预览和正式图形的区别是:
test
preview -> 临时实体
Graphic -> 正式实体
鼠标移动时,预览线可以被不断删除和重新创建;正式线则应该保存在当前图形数据中。这说明 LibreCAD 将:
test
用户尚未确认的结果;
用户已经确认的结果;
分别放在不同的数据管理路径中。
7.9、为什么这种设计更容易扩展?
假设所有逻辑都写在一个函数里:
cpp
void drawLine() {
// 处理鼠标
// 转换坐标
// 捕捉
// 判断状态
// 创建 RS_Line
// 加入 Graphic
// 刷新界面
}
那么以后增加功能时,代码会越来越复杂。
例如:
test
增加圆形绘制;
增加角度捕捉;
增加对象捕捉;
增加撤销;
增加图层;
增加不同显示模式。
如果采用分层设计,就可以让不同功能分别承担自己的职责。例如:
新增圆形绘制 -> 创建新的 RS_ActionDrawCircle -> 复用坐标转换 -> 创建 RS_Circle -> 复用 Graphic 管理 -> 复用视图绘制
因此,绘制圆形不需要重新实现整套:
鼠标事件 → 坐标转换 → 数据管理 → 屏幕显示
只需要替换交互逻辑和几何实体类型。这就是分层设计带来的复用能力。
7.10、RS_ActionDrawLine 在架构中的真正位置
从整个系统来看,RS_ActionDrawLine 位于:
用户输入 -> RS_ActionDrawLine -> 图形数据
它既不是最底层的鼠标驱动,也不是最终的几何实体,更不是负责所有屏幕绘制的对象。它更像一个协调者:
输入层提供事件 -> RS_ActionDrawLine 组织流程 -> 坐标层提供 CAD 坐标 -> 几何层创建 RS_Line -> 数据层保存实体 -> 显示层负责绘制
因此可以把它概括为RS_ActionDrawLine 是连接用户交互与图形数据之间的桥梁。
总结
通过分析 RS_ActionDrawLine 可以看到,LibreCAD 的绘图过程并不是一个函数从头执行到尾,而是多个层次共同协作的结果:输入层接收鼠标和键盘事件、交互层通过 Action 和状态机组织绘图流程、坐标层负责屏幕坐标与 CAD 坐标之间的转换、几何层通过 RS_Line 表示线段、数据管理层通过 Graphic 保存和管理实体、显示层负责将几何数据重新绘制到屏幕。因此,一条直线的创建过程可以概括为:
用户输入 -> Action 解释操作 -> 获取 CAD 坐标 -> 创建几何实体 -> 加入 Graphic -> 视图重新绘制
从 RS_ActionDrawLine 看 LibreCAD,真正值得关注的并不是"画线用了哪个函数",而是"一个用户操作如何在不同层次之间传递,最终变成一个可以保存、编辑和显示的图形实体"。
🎇坚持到这里已经很厉害啦,辛苦啦🎇 ʕ • ᴥ • ʔ づ♡ど

