C++ 源码剖析:LeoCAD是如何实现撤销重做功能的?
在开发 CAD 或图形编辑软件时,撤销/重做(Undo/Redo) 永远是开发者绕不开的“大山”。 很多开发者第一时间会想到命令模式(Command Pattern)——记录用户的每一个动作(比如:将零件 A 移动了 5 厘米)。但你是否思考过,在复杂的 3D 场景中,如果涉及零件嵌套、群组关联、柔性连接,这种“增量式”记录极易产生逻辑 Bug。
今天,我们深度拆解开源乐高 CAD 软件 LeoCAD 的源码,看看它如何用一种“暴力且优雅”的方式,教科书般地解决了这个问题。

一、 核心逻辑:告别增量,拥抱镜像
通过对 LeoCAD 核心文件 lc_model.cpp 和 lc_model.h 的逐行扫描,我们发现了一个有趣的结构体定义:
structlcModelHistoryEntry
{
QByteArray File; // 关键:全量数据副本
QString Description; // 操作描述(如 "Undo Move")
};
出乎意料! LeoCAD 并没有采用复杂的增量记录,而是采用了全量内存镜像(Full Memory Snapshot)。
当你执行每一个操作(移动、着色、删除)时,LeoCAD 实际上是在内存里为整个模型“拍了一张全家福”。
二、 深度解析:三层栈管理架构
LeoCAD 的撤销系统由一个清晰的“金字塔”结构支撑:
1. 顶层:双栈控制器
在 lcModel 类中,维护着两个 std::vector 容器:mUndoHistory 和 mRedoHistory。 它们存储的是 unique_ptr<lcModelHistoryEntry>,确保了在撤销步骤被移除时,内存能被自动、安全地回收。
2. 中层:快照生成器(SaveCheckpoint)
每当用户完成一个动作,lcModel::SaveCheckpoint 就会被触发。它的工作极其简单粗暴:
-
序列化:调用 SaveLDraw将当前场景所有零件、相机、灯光转化为 LDraw 文本流。 -
持久化:将这串文本流存入 QByteArray字节数组。 -
压栈:放入 Undo 栈。
3. 底层:状态恢复器(LoadCheckPoint)
当你按下 Ctrl+Z,魔术发生了:
-
系统直接清空当前内存中的整个模型。 -
将 QByteArray中的数据重新“喂”给解析器,像打开一个新文件一样重建整个场景。
三、 为什么“暴力全量”反而是更好的设计?
对于 CAD 软件,这种“全量快照”逻辑有三大不可替代的优势:
1. 逻辑的绝对鲁棒性
在 3D 编辑中,删除一个父群组可能会导致级联的状态变化。如果使用命令模式,你需要编写极其复杂的逆向逻辑(UndoDeleteGroup)。而使用全量快照,你永远不需要担心“撤销不回去”,因为你回到的就是那个真实的过去。
2. 实现成本极低
LeoCAD 直接复用了现成的文件保存/加载代码(LDraw 协议)。这意味着:只要软件能保存文件,它就自动拥有了撤销功能。
3. 内存与性能的巧妙平衡
虽然是“全量”,但 LDraw 格式基于文本,极为精简。一个包含上千个零件的模型,其文本描述可能也就几十 KB。配合现代计算机 GB 级的内存,保存几百步撤销也毫无压力。
四、 细节见真章:状态检测与脏数据
在源码中,我们还发现了两个体现开发者功底的细节:
-
变化检测:在保存快照前,LeoCAD 会对比前后状态。如果用户只是点了一下零件但没产生实际位移,快照会被丢弃,避免浪费内存。 -
脏数据标记: mSavedHistory指针。它指向最后一次点击“保存”时的历史节点。通过对比当前栈顶指针和它的位置,软件能精准地判断标题栏是否需要显示那个代表已修改的星号(*)。
五、 给开发者的启示
LeoCAD 的设计哲学告诉我们:不要过度设计。
在数据规模可控、结构逻辑复杂的场景下,与其为了节省那点内存去编写如履薄冰的增量撤销逻辑,不如利用好已有的序列化机制,做一个稳健、一致的全量快照系统。
毕竟,对于用户来说,一个永不崩溃、撤销必成功的编辑器,才是最好的编辑器。
项目地址:https://github.com/leozide/leocad解析文件:
lc_model.cpp/lc_model.h
本分析基于 LeoCAD 最新版本源码。
夜雨聆风