航空CAD 草图绘制模块 — 直线绘制智能捕捉
C++17 / Qt6 实现 · 端点 / 圆心 / 交点 / 切点捕捉 · 切线几何 · 与画线交互同源
本文档聚焦 sketch-cad-cpp(C++17 / Qt 6.10.1 / MinGW-w64 GCC 13.1.0)本轮新增的「直线绘制智能捕捉」功能:画直线时,端点可吸附到五种几何特征点——直线/弧的端点、圆/弧的圆心、两图元的交点、以及由已知另一端点向圆作切线的切点。重点探讨「几何计算如何保证精度」「与现有画线交互如何无缝集成」「精度如何通过无界面自测确保」。更庞大的架构基线见综合文档。
验证基线:干净构建 0 警告 0 错误;核心自测 139 项全通过,其中 §19 共 14 项用例专门覆盖本功能。
一、五种捕捉方式的几何归类
需求列出的五种点,按其几何来源可归为两类计算引擎,避免在 UI 层各自实现:
捕捉类型 | 几何来源 | 计算引擎 | 依赖anotherEnd? |
直线/弧端点 | 图元控制点 | snapHandles取真实点 | 否 |
圆/弧圆心 | 图元控制点 | snapHandles 取 center | 否 |
两图元交点 | 图元求交 | intersectElements(复用) | 否 |
切点(切于圆) | 由外部点作切线 | tangentPointsFromPointToCircle | 是(首端点) |
关键决策:交点直接复用既有的 geo::intersectElements(直线×直线、直线×圆、圆×圆、含圆弧角度过滤均已实现),不另写求交逻辑——再次印证「core 层几何下沉」的价值:新交互只是换个触发条件,底层零新增代码。
二、切点几何:由外部点向圆作切线
需求 1 的「直线与选定圆相切,端点为该圆切点」在数学上等价于:已知一个端点 P(已确定的另一端点),求从 P 向圆 (C, r) 所作切线的切点 T。T 必须满足 |T−C|=r 且 PT ⟂ CT。
int tangentPointsFromPointToCircle(const Vec3& P, const Vec3& C, double r, Vec3 out[2]) { const double d = hypot(P.x-C.x, P.y-C.y); // 计算点P到圆心C的距离 if (d < r - 1e-9) return 0; // 点在圆内:无实切线 const double phi = atan2(P.y-C.y, P.x-C.x); // 圆心指向P的方向角 const double alpha = acos(min(1.0, r/d)); // 半张角 out[0] = C + r*(cos(phi+alpha), sin(phi+alpha)); // 第一个切点 out[1] = C + r*(cos(phi-alpha), sin(phi-alpha)); // 第二个切点 return 2; // 返回切点数量 }2.1 边界与精度
- 点在圆内(d<r):返回 0——不存在实切线(自测 §19.1 验证)。
- 点在圆上(d≈r):alpha≈0,两切点重合于 P 投影点,退化为单点,不崩溃。
- 弧的切点必须落在其角度扫掠范围内:对 arc 类型再用 angleInSweep 过滤,圆则恒通过。
- 垂直性由构造保证:T−C 与 P−T 的点积理论为 0;自测 §19.1 断言 |dot|<1e-6 且 |T−C−r|<1e-6,把「在圆上且垂直」钉死。
三、综合捕捉 snap():最近者胜
snap(elems, w, otherEnd, tol) 在容差 tol 内收集所有候选(端点/圆心、交点、切点),取离光标 w 最近者。之所以「最近距离优先」而非「按类型排优先级」,是因为它最贴近主流 CAD 的体感,且实现最省:
- 端点/圆心:遍历每个可见图元的 snapHandles,逐个 consider(圆仅 center,弧含 center+起/终,直线两端,矩形四角,多边形/样条全顶点)。
- 交点:先以 distanceToElement(A,w)<tol*6 粗筛靠近光标的图元(O(n²) 但 n 通常很小),再对筛选后的两两对求交并 consider。
- 切点:仅当 otherEnd 非空时,对每个圆/弧算 tangentPointsFromPointToCircle,过滤弧角范围后 consider。
auto consider = [&](const Vec3& p, SnapType tp, int a, int b=-1) { const double d = p.distTo(w); // 计算候选点到光标w的距离 if (d < bestD) { // 如果距离更近,更新最佳捕捉结果 bestD = d; // 更新最小距离 best.point = p; // 更新捕捉点坐标 best.snapped = true; // 标记已捕捉 best.type = tp; // 记录捕捉类型 best.elemA = a; // 记录第一个相关图元索引 best.elemB = b; // 记录第二个相关图元索引(默认为-1) } };3.1 已知设计权衡:交点与端点重合时端点胜
若交点恰好与某图元端点重合(如两线在 (0,0) 相接),二者到光标距离相等,consider 顺序上端点先入栈、交点距离相等不更新,最终返回端点。这是「最近者胜」的天然结果。自测 §19.5 专门用「交点不落在任一端点上」的配置来验证 Intersection 类型,既覆盖功能又避免与端点捕捉在退化情形下互相干扰。若未来需要『交点恒优先』,只需在 consider 里给 Intersection 一个微小距离折扣即可,改动局部。
下图展示了snap()函数的完整决策流程:
关键节点备注:
- 粗筛:使用 tol*6 范围减少求交计算量,将 O(n²) 降为局部候选集两两求交。
- consider:统一距离比较入口,最近者胜;交点与端点重合时端点先入栈胜出。
- 端点/圆心捕捉:直接遍历 snapHandles 取真实控制点,零额外几何计算。
- 切点捕捉:仅当 otherEnd 非空时触发,对弧额外做 angleInSweep 角度过滤。
流程说明:
- 初始化:设置最佳距离为无穷大,捕捉状态为 false。
- 三类候选并行收集:端点/圆心直接遍历所有图元的控制点;交点先粗筛靠近光标的图元,再两两求交;切点仅当另一端点已知时计算。
- 统一距离比较:所有候选点通过 consider lambda 比较距离,最近者胜。
- 返回结果:最终返回距离光标最近的特征点及其类型。
四、与画线交互的咬合(GraphicsView)
捕捉不是独立功能,而是「拾取点」的前置滤波器:
- snapWorld(w):以 pendingPts_.back() 作为 otherEnd 调用 snap;返回的 point 替换原始光标坐标。首点时 pendingPts_ 为空 → otherEnd=null → 仅做端点/圆心/交点捕捉(无切线)。
- handlePress:画线分支把 snapWorld(w) 推入 pendingPts_,于是落定的端点已是吸附点,后续镜像/联动/标注都吃到正确坐标。
- mouseMove:画线预览改为 updatePreview(snapWorld(w)),预览线段的游离端实时吸附,所见即所得。
- drawForeground:按 snapNow_.type 画不同颜色/形状的标记(端点/圆心=方框,交点=×,切点=圆圈),约 10px 恒定屏幕大小,帮助用户确认吸附类型。
- resetPending / Esc:清空 snapNow_,状态机不残留。
- 容差复用:worldTol() = PICK_TOL_PX / zoom_,与端点拖拽捕捉、命中检测同一把尺,避免「能抓端点却吸不上交点」的割裂。
五、精度如何被自测钉死(§19,14 项)
- 切线:外部点→圆返回 2 切点;每点 |T−C−r|<1e-6 且在圆上;PT·CT<1e-6(垂直);圆内点返回 0。
- 圆心:光标贴 (100,100) r=40 圆心 → Center 且坐标精确为 (100,100)。
- 直线端点/弧端点:贴 (0,0) 与弧起/终点 → Endpoint 且坐标精确;弧端点不含圆心(snapHandles 对 arc 给 center+起+终,但端点判定 i!=0)。
- 交点:两轴交 (0,0) → Intersection,且 elemA/elemB 记录两条线 id,供后续自动加「相交」约束可扩展。
- 切点:给定首端点 P=(40,0)、圆 (0,0) r=25,光标近切点 → Tangent 且精确落在圆上。
- 自由点:远离任何特征 → snapped=false,point==w,不污染自由绘制。
这些断言全部基于几何解析值(圆心、半径、切线公式),不依赖浮点拟合,因此一旦有人把容差或过滤写错,测试立刻变红。
六、刻意留白(非本轮范围)
- 「同时切两圆」的切线:本实现只允许第二点切于一个圆(另一端点已知)。若需画一条同时外切两圆的公切线,需要 『选圆A→选圆B』的双圆切线求解,留待后续。
- 捕捉命中后自动加约束(如「端点重合」「相切」):snap 已记录 elemA/elemB 与类型,未来可据其自动建约束,与需求3的拓扑保持呼应。
- 三维/非笛卡尔平面下的捕捉:当前 model 仅标准平面,snap 直接吃世界坐标;若引入多工作平面,需先把 w 投影到目标平面再算。
七、验证结论
- 干净构建(CMake + Ninja + MinGW GCC 13.1.0):0 警告 / 0 错误。
- 核心自测 SketchSelfTest:139 项通过 / 0 失败。
- 新增文件:src/core/Snap.h / src/core/Snap.cpp(已注册进 SketchCAD 与 SketchSelfTest 两目标);改动:src/ui/GraphicsView.cpp/.h(snapWorld + 标记绘制)、tests/selftest.cpp(§19)。
- 生成脚本:sketch-cad-cpp/tools/gen_deepthink_snap.py(可重跑复现本文档)。
—— 聚焦文档由深度思考过程自动导出(sketch-cad-cpp / C++17 / Qt6)
八、性能优化与扩展思考
当前snap()函数在交点计算环节存在潜在的 O(n²) 复杂度瓶颈:首先以distanceToElement(A,w) < tol*6粗筛靠近光标的图元,再对筛选后的图元两两求交。当场景中图元数量 n 很大时(如复杂装配图、大型地形网格),即使粗筛能过滤掉大部分远离光标的图元,最坏情况下仍需对 O(n²) 对图元进行求交检测,可能成为实时交互的性能瓶颈。
8.1 空间划分优化
一种可行的优化策略是引入空间划分数据结构,如四叉树(2D)或网格空间索引:
- 预处理阶段:将场景中所有图元的包围盒注册到空间索引中。
- 粗筛加速:在
snap()调用时,先查询以光标 w 为中心、tol*6为半径的范围内有哪些图元,直接获得候选集,避免遍历全部 n 个图元。 - 求交优化:对候选集中的图元,可利用空间索引进一步减少不必要的两两检测——若两个图元的包围盒不相交,则不可能有交点。
- 实现代价:需要维护图元增删改时的索引更新,并处理动态场景(如拖拽中的图元)。对于 sketch-cad-cpp 这类轻量级 CAD,可在图元数量超过阈值(如 1000)时自动启用空间索引。
8.1.1 四叉树加速图元筛选(QuadTree)
下面给出一个简化的四叉树(QuadTree)C++ 类,用于加速snap()中的图元粗筛。核心思路:把场景包围盒递归四等分,每个节点只保存与其包围盒相交的图元 id;查询时沿光标所在区域向下递归,只返回可能靠近光标的候选集,从而把「遍历全部 n 个图元」降为「只访问局部节点」。
// QuadTree.h —— 简化四叉树:以图元包围盒为粒度做空间索引 class QuadTree { public: struct Box { double x0, y0, x1, y1; }; // 轴对齐包围盒 QuadTree(const Box& region, int maxDepth = 8, int maxItems = 8) : region_(region), maxDepth_(maxDepth), maxItems_(maxItems) {} // 插入:把图元 id 及其包围盒注册进树 void insert(int elemId, const Box& bbox) { insertRec(elemId, bbox, region_, 0); } // 查询:返回与查询框 query 相交的所有图元 id(去重) std::vector<int> query(const Box& query) const { std::vector<int> out; queryRec(query, region_, out); return out; } private: struct Node { std::vector<std::pair<int, Box>> items; // 本节点直接持有的图元 std::unique_ptr<Node> children[4]; // 四个子象限 }; void insertRec(int elemId, const Box& bbox, const Box& region, int depth) { if (depth >= maxDepth_ || node_.items.size() < maxItems_ || !subdivide(region)) { node_.items.emplace_back(elemId, bbox); // 达到深度/容量上限则就地保存 return; } // 否则把图元下推到与其相交的子象限 for (int i = 0; i < 4; ++i) { Box child = childBox(region, i); if (intersects(bbox, child)) { if (!node_.children[i]) node_.children[i] = std::make_unique<Node>(); node_.children[i]->insertRec(elemId, bbox, child, depth + 1); } } } void queryRec(const Box& query, const Box& region, std::vector<int>& out) const { if (!intersects(query, region)) return; // 查询框与当前区域不相交,剪枝 for (auto& [id, bbox] : node_.items) if (intersects(query, bbox)) out.push_back(id); // 命中本节点图元 for (int i = 0; i < 4; ++i) if (node_.children[i]) node_.children[i]->queryRec(query, childBox(region, i), out); } static bool intersects(const Box& a, const Box& b) { return a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1; } static Box childBox(const Box& r, int i) { /* 按 i 取四分之一象限 */ } Node node_; // 根节点 Box region_; // 场景总包围盒 int maxDepth_; // 最大深度,防止退化 int maxItems_; // 节点容量,超过则分裂 };在snap()中替换原有的 O(n²) 粗筛逻辑:预处理阶段把所有图元包围盒注册进四叉树;每次调用时,以光标 w 为中心、tol*6为半径构造查询框,一次拿到候选集,再对候选集两两求交。
// Snap.cpp —— 用四叉树替换 O(n²) 粗筛 QuadTree tree(sceneBounds); // 场景总包围盒 for (int i = 0; i < (int)elems.size(); ++i) tree.insert(i, bboxOf(elems[i])); // 预处理:注册所有图元 // snap() 内部,替代原来的「遍历全部 n 个图元粗筛」 QuadTree::Box query{ w.x - tol6, w.y - tol6, w.x + tol6, w.y + tol6 }; auto candidates = tree.query(query); // 只返回光标附近的图元 id for (int a : candidates) { for (int b : candidates) { if (b <= a) continue; // 避免重复对 // 仅对候选集中的图元两两求交,而非全量 O(n²) auto pts = geo::intersectElements(elems[a], elems[b]); for (auto& p : pts) consider(p, SnapType::Intersection, a, b); } }性能提升效果:粗筛阶段从「遍历全部 n 个图元」降为「只访问光标所在局部区域的节点」,平均复杂度由 O(n) 降为 O(log n + k)(k 为局部候选数);求交阶段从全量 O(n²) 降为 O(k²),其中 k 通常远小于 n。以 1000 个图元、光标附近平均 20 个候选为例,粗筛比较次数约减少 50 倍,求交对数从约 50 万对降为约 200 对,整体帧耗时可下降一个数量级以上,为实时拖拽数百图元留出充足余量。
8.2 增量缓存与惰性求交
另一种思路是缓存最近的计算结果,避免重复求交:
- 交点缓存:对静态场景(图元位置未变),可缓存图元之间的交点计算结果。当光标移动时,只需检查缓存中哪些交点落在容差范围内,无需重新计算。
- 增量更新:若场景中只有少数图元被移动/编辑,可仅更新受影响的相关交点缓存,而非全量重建。
- 惰性求交:将交点计算推迟到真正需要时。例如,先收集所有可能产生交点的图元对,但仅在光标靠近该区域时才实际计算交点坐标。
- 适用场景:适合图元拓扑相对稳定、光标移动连续的交互场景,可显著降低每帧的计算量。
权衡与选型建议:对于中小规模图纸(n < 500),当前的 O(n²) 粗筛+求交在现代化硬件上仍可保持 60fps,过早优化可能增加代码复杂度。若预期处理大型图纸或需要支持实时拖拽数百个图元,可优先实现空间划分(四叉树),因其对动态更新友好且通用性更强。缓存方案更适合静态分析场景,如批量标注、自动约束推导等离线任务。
无论采用哪种优化,都应在SketchSelfTest中增加性能基准用例,确保优化后捕捉精度不变,且在大规模场景下帧率稳定。
