【LibreCAD 2D架构】从两个坐标到图形实体:RS_ActionDrawLine如何创建RS_Line

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

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


👓️博主简介:

文章目录


前言

上一篇文章中,我们追踪了 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,真正值得关注的并不是"画线用了哪个函数",而是"一个用户操作如何在不同层次之间传递,最终变成一个可以保存、编辑和显示的图形实体"。
🎇坚持到这里已经很厉害啦,辛苦啦🎇 ʕ • ᴥ • ʔ づ♡ど

相关推荐
Thomas21431 小时前
scala 闭包
开发语言·后端·scala
sunshine22 girl1 小时前
Java学习一 环境配置1 安装JDK,配置环境变量
java·开发语言·学习
似水এ᭄往昔1 小时前
【Qt】--常用控件(按钮类控件)
开发语言·qt
j7~1 小时前
【C++】C++的类型转换--详解
c++·static_cast·const_cast·rtti·c语言类型转换·c++强制类型转换
我爱cope1 小时前
【计算机网络 | 传输层5:TCP 如何实现可靠传输?滑动窗口、累计确认与重传机制】
网络·网络协议·学习·tcp/ip·计算机网络·传输层
范什么特西1 小时前
一些常用名词
开发语言·前端·javascript
zfoo-framework1 小时前
kotlin技术栈各种 学习文档
学习
是隼人2 小时前
buuctf-pwn wdb_2018_3rd_soEasy(ret2shellcode)题解(学习过程持续更新)
c语言·学习·安全·pwn入门·ctf入门
吴声子夜歌2 小时前
ApacheCommons——commons-configuration2(多数据源配置统一管理与热加载)
java·开发语言·算法·apache