news 2026/8/8 8:27:29

航空CAD 草图绘制模块 — 直线绘制智能捕捉

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
航空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&amp; region, int maxDepth = 8, int maxItems = 8) : region_(region), maxDepth_(maxDepth), maxItems_(maxItems) {} // 插入:把图元 id 及其包围盒注册进树 void insert(int elemId, const Box&amp; bbox) { insertRec(elemId, bbox, region_, 0); } // 查询:返回与查询框 query 相交的所有图元 id(去重) std::vector&lt;int&gt; query(const Box&amp; query) const { std::vector&lt;int&gt; 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&amp; bbox, const Box&amp; region, int depth) { if (depth &gt;= maxDepth_ || node_.items.size() &lt; maxItems_ || !subdivide(region)) { node_.items.emplace_back(elemId, bbox); // 达到深度/容量上限则就地保存 return; } // 否则把图元下推到与其相交的子象限 for (int i = 0; i &lt; 4; ++i) { Box child = childBox(region, i); if (intersects(bbox, child)) { if (!node_.children[i]) node_.children[i] = std::make_unique&lt;Node&gt;(); node_.children[i]-&gt;insertRec(elemId, bbox, child, depth + 1); } } } void queryRec(const Box&amp; query, const Box&amp; region, std::vector&lt;int&gt;&amp; out) const { if (!intersects(query, region)) return; // 查询框与当前区域不相交,剪枝 for (auto&amp; [id, bbox] : node_.items) if (intersects(query, bbox)) out.push_back(id); // 命中本节点图元 for (int i = 0; i &lt; 4; ++i) if (node_.children[i]) node_.children[i]-&gt;queryRec(query, childBox(region, i), out); } static bool intersects(const Box&amp; a, const Box&amp; b) { return a.x0 &lt;= b.x1 &amp;&amp; b.x0 &lt;= a.x1 &amp;&amp; a.y0 &lt;= b.y1 &amp;&amp; b.y0 &lt;= a.y1; } static Box childBox(const Box&amp; 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中增加性能基准用例,确保优化后捕捉精度不变,且在大规模场景下帧率稳定。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/8 8:24:25

C语言基础:构造数据类型-结构体 memcpy系统函数

11、const修饰的指针const&#xff1a;只读。只允许被访问&#xff0c;不允许修改表示该变量只允许使用&#xff0c;不允许被修改&#xff0c;记得初始化const int num1const int *p; //同下int const *p; //修饰*p&#xff0c;*p的内容只读&#xff0c;*pnum &#xff0…

作者头像 李华
网站建设 2026/8/8 8:22:47

AI 观测站|AI 开始让传统运维解释不了问题

上一篇我讲了一个判断&#xff1a;传统可观测性&#xff0c;为什么越来越看不懂 AI&#xff1f;这篇继续往下讲一层。上一篇讲的是&#xff1a;观察对象变了。从系统状态&#xff0c;变成 AI 行为。这篇讲的是&#xff1a;传统运维的问题定位逻辑&#xff0c;也开始变了。传统运…

作者头像 李华
网站建设 2026/8/8 8:21:20

财务软件凭证录入规范:摘要怎么写、科目怎么选、附件怎么贴

【摘要】凭证录入是财务日常工作的基础&#xff0c;规范与否直接影响账务质量和审计合规。本文详解凭证录入的规范要求。1 凭证摘要的撰写规范摘要的作用&#xff1a;简明扼要说明经济业务的内容&#xff0c;便于日后查账和审计。撰写原则&#xff1a;简洁、准确、完整。避免模…

作者头像 李华
网站建设 2026/8/8 8:20:43

秒杀场景下基于Jackson流式解析与JVM内存管控的流量控制方案

秒杀场景下基于Jackson流式解析与JVM内存管控的流量控制方案 电商秒杀活动的核心痛点是瞬时高并发与库存一致性的平衡&#xff1a;以常规美妆秒杀为例&#xff0c;峰值QPS常达到50万~200万&#xff0c;其中60%以上的请求为无效请求&#xff0c;包括参数格式错误、商品ID不存在、…

作者头像 李华
网站建设 2026/8/8 8:19:40

C语言指针与数组:本质区别与高级应用

1. 指针与数组的本质区别在C语言中&#xff0c;指针和数组是两个经常被混淆的概念。很多初学者会认为它们可以互换使用&#xff0c;但实际上它们有着本质的区别。指针是一个变量&#xff0c;它存储的是内存地址。当我们声明一个指针时&#xff0c;它会在内存中分配空间来存储这…

作者头像 李华