Lesson05:绘图工具如何工作
源码 https://github.com/CHMOSE023/MiniCAD
系列:MiniCAD第 7 / 17 篇
- MiniCAD 初级教程课程规划
- C++ 基础课:读懂 MiniCAD 所需的语言知识
- Lesson01:项目能跑起来
- Lesson02:整体架构地图
- Lesson03:程序入口与主循环
- Lesson04:输入系统与消息映射
- Lesson05:绘图工具如何工作
- Lesson06:Core 核心层:数学、几何、对象与实体
- Lesson07:命令模式与撤销重做
- Lesson08:选择、拾取与夹点编辑
- Lesson09:视口、相机与坐标转换
- Lesson10:渲染层入门
- Lesson11:文字与字体系统
- Lesson12:吸附与约束系统
- Lesson13:命令行系统
- Lesson14:多文档管理
- Lesson15:综合实战:扩展一个 MiniCAD 功能
目录
学习目标
以 LineTool 为解剖对象,读懂工具的完整生命周期:接口定义、状态机设计、预览渲染、坐标获取(含吸附回退)、提交命令、锚点供约束使用、提示语更新。完成后应能独立实现一个新工具。
相关源码
src/Editor/Tools/ITool.hsrc/Editor/Tools/LineTool.hsrc/Editor/EditorContext.hsrc/Document/Command/AddEntityCommand.hsrc/Editor/Overlay/Overlay.hsrc/Editor/Resolver/InputResult.h
ITool 接口全解
class ITool {
virtual bool OnInput(const EditorContext& ctx) = 0;
virtual void Cancel() {} // ESC / 右键 / 工具切换
virtual void OnSceneChanged() {} // Undo/Redo/Delete 后通知工具
virtual void OnFocusLost() {} // 中键平移开始,工具暂停
virtual void OnFocusRestored() {} // 中键平移结束,工具恢复
virtual bool HasAnchor() const { return false; }
virtual Point3 GetAnchor() const { return {}; }
virtual std::string GetPrompt() const { return {}; }
std::function<void()> OnFinished; // 工具主动完成时调用
};
各方法职责:
| 方法 | 何时调用 | 作用 |
|---|---|---|
OnInput() | 每个输入事件 | 核心逻辑,返回 true 表示事件已消费 |
Cancel() | ESC / 工具切换 | 清理 Overlay、重置状态 |
OnSceneChanged() | Undo/Redo/Delete | 场景突变后工具需要重置(如已选点可能不再有效) |
OnFocusLost() | 中键按下开始平移 | 工具暂停,隐藏预览 |
OnFocusRestored() | 中键释放 | 工具恢复,重绘预览 |
HasAnchor() / GetAnchor() | ConstraintEngine 每帧查询 | 第一点已确定后提供锚点,用于正交/极轴约束 |
GetPrompt() | 每帧 CommandLine 读取 | 提示语随状态变化("指定第一个点" vs "指定下一点") |
OnFinished | 工具自身在完成时触发 | 通知 Editor 销毁当前工具 |
EditorContext:工具能用到的所有上下文
struct EditorContext {
InputEvent& event; // 当前输入事件(非 const:可被回填)
Scene& scene; // 场景(只读),工具不直接写
Viewport& viewport; // 坐标转换
SnapEngine& snap; // 吸附(一般不直接用,已经由 InputResolver 处理)
ConstraintEngine& constraint; // 约束(一般不直接用)
Picking& picking; // 选择集查询
CommandStack& cmdStack; // 提交命令的唯一入口
Overlay& overlay; // 预览几何(临时线、辅助线)
InputResult resolved; // InputResolver 已解析的结果(含吸附/约束/拾取)
ITool* tool; // 当前工具自身(一般不用)
GripEditor* grip; // 夹点编辑器引用
};
工具三件套:cmdStack(提交)、overlay(预览)、viewport.GetCamera()(坐标转换)。
LineTool 状态机
LineTool 只有两个状态,状态由 m_hasStart 控制:
[初始] m_hasStart = false
左键 → 记录 m_start,m_hasStart = true
进入 [已有起点]
[已有起点] m_hasStart = true
MouseMove → 更新 m_preview,刷新 Overlay(橡皮筋预览线)
左键 → Commit(m_start, pt),m_start = pt(连续画线,保留上一个终点为下一段起点)
右键 / ESC → Clear Overlay,调用 OnFinished(),工具结束
实际代码:
bool OnInput(const EditorContext& ctx) override {
m_ctx = &ctx;
const auto& e = ctx.event;
if (e.IsLeftClick()) {
auto pt = GetPoint(e); // ← 含吸附优先逻辑
if (!m_hasStart) {
m_start = pt;
m_hasStart = true;
} else {
Commit(m_start, pt);
m_start = pt; // 保留终点继续连线
}
return true; // 消费事件
}
if (e.IsRightClick() || e.IsCancel()) {
m_ctx->overlay.Clear();
if (OnFinished) OnFinished(); // 通知 Editor 销毁工具
return true;
}
if (e.Type == InputEventType::MouseMove && m_hasStart) {
m_preview = GetPoint(e);
m_ctx->overlay.Clear();
const auto& layer = m_ctx->scene.GetLayerManager().GetActiveLayer();
m_ctx->overlay.AddLine(m_start, m_preview, layer.GetColor());
return false; // 不消费 MouseMove(其他系统还需要处理)
}
return false;
}
坐标获取:吸附优先
GetPoint() 的逻辑体现了输入解析优先级:
Math::Point3 GetPoint(const InputEvent& e) {
if (e.HasSnap) return e.SnapWorld; // 吸附点优先(端点、中点等)
return m_ctx->viewport.GetCamera().ScreenToWorld(e.MouseX, e.MouseY);
}
InputEvent::HasSnap 和 SnapWorld 由 InputResolver 在每帧填充:如果 SnapEngine 在屏幕 12px 范围内找到了吸附点,就将结果写入事件。工具不需要自己查询吸附,直接读取即可。
Commit:提交命令
void Commit(const Point3& a, const Point3& b) {
auto id = m_ctx->scene.NextObjectID(); // 分配唯一 ID(atomic++)
auto line = std::make_unique<LineEntity>(id, a, b); // 构造实体
auto cmd = std::make_unique<AddEntityCommand>(std::move(line)); // 包装命令
m_ctx->cmdStack.Execute(std::move(cmd), m_ctx->scene); // 执行并入栈
}
为什么用 NextObjectID():Scene 内部用 std::atomic<ObjectID> 分配 ID,工具在提交前必须先获取 ID,然后才能创建实体。ID 是 uint64_t,0 保留为 InvalidID,从 1 开始。
为什么不直接 scene.AddEntity():绕过命令栈的修改无法撤销。CommandStack::Execute() 在 Execute() 返回 true 后才入栈,返回 false(校验失败,如零长度线)则不入栈。
锚点:为约束系统服务
bool HasAnchor() const override { return m_hasStart; }
Math::Point3 GetAnchor() const override { return m_start; }
ConstraintEngine 每帧查询活跃工具的锚点。有锚点时,正交约束(F8)和极轴约束会以锚点为参考修正鼠标坐标——这就是为什么画线第二点会自动对齐水平/垂直方向。
Overlay 预览原则
- 只用于当前帧显示:每次
MouseMove先Clear()再AddLine(),不累积。 - ESC 时必须 Clear():否则预览线段会残留在画面上。
- 不进命令栈:
Overlay里的线段不属于任何实体,不可选择,不可撤销。
工具注册与激活
工具在 Editor::RegisterBuiltinTools() 中注册:
RegisterTool("Line", []{ return std::make_unique<LineTool>(); });
RegisterAlias("L", "Line");
RegisterAlias("直线", "Line");
激活时通过 ActivateToolById("Line") 调用工厂函数创建新实例,每次激活都是全新状态。
工具是 header-only
所有工具类(LineTool、CircleTool 等)只有 .h 文件,没有对应 .cpp。这是有意为之:工具逻辑短小,内联到头文件避免了编译单元拆分带来的头文件依赖问题,也方便整体阅读。
拓展练习
LineTool的OnSceneChanged()是默认空实现。如果用户在连续画线时按 Ctrl+Z 撤销了上一条线,会有什么问题?应该如何修复?- 比较
LineTool和CircleTool的状态数量(变量)和状态转换逻辑。 - 尝试实现一个"两点矩形工具":第一点左上角,第二点右下角,
MouseMove时显示矩形预览,Commit时创建RectangleEntity。 GetPoint()只处理了吸附,为什么不需要处理约束?约束修正在哪里发生?