
简介这是一套基于C与QT的B样条曲线绘制工程面向CAD、计算机图形学及机械设计方向的开发者和学习者可在Windows、Linux等平台直接构建运行用于生成与交互调整平滑样条曲线支持控制点拖拽、实时更新等操作。压缩包内共含11个文件主要由3个cpp源文件、2个h头文件、2个ui界面文件以及pro工程文件和qrc资源文件组成覆盖曲线算法、界面布局与图标资源完整工程可直接导入Qt Creator编译整体约12KB结构精炼、便于学习。目前已有296人学习下载适合希望快速掌握B样条曲线核心算法和QT图形视图框架的读者。工程提供了控制点拖拽与曲线实时重绘的完整示例读者可借此理解B样条基函数递归计算、QPainter绘图路径以及QGraphicsScene交互机制并可在此基础上扩展数据拟合、蒙皮等更高级功能。1. 解开 B_Spline.zip 之后先看清曲线计算层拿到一个以B_Spline.zip命名的 Qt 工程时我最先做的事不是点开main.cpp而是搜工程里有没有保存“控制点”的容器它决定了这套代码到底是真曲线还是拿折线糊弄视觉效果。B_Spline.zip 这种命名把 B 样条曲线、QT 曲线、C 和 qt 工程压在一个包里常见于演示用项目一侧是控制点折线一侧是QPainter重绘出来的样条曲线。对于做 CAD、曲线编辑器、图形控件的人来说这个工程的价值不在那些窗口部件而在控制点数组、节点向量和曲线阶数之间的换算关系。想把这套代码改到自己的数据可视化界面里不要盯着paintEvent里的画线逻辑先看求值层怎么写否则后面挪一个控制点曲线就会给出一个莫名其妙的结果。2. B 样条曲线求值的 C 内核用 de Boor 算法替代递归2.1 先约定控制点、节点向量与曲线次数的关系B 样条曲线不是通过曲线上已知点反向拟合而是用若干个控制点去“拉”曲线。控制点数量、节点向量长度和曲线次数之间有硬性约束控制点数量记作 N曲线次数记作 p节点数组长度必须是 N p 1。例如 4 个控制点、3 次曲线节点数组长度就是 8少一个节点求值时索引就会越界程序跑起来要么结果错要么在 Release 下静默崩溃。节点向量的头 p1 个值和尾 p1 个值通常设成 0 和 1这叫 clamped 节点向量能让曲线真正穿过首尾控制点。中间节点如果均匀递增曲线在参数空间里的速度是均匀的但实际视觉上不一定是“均匀贴着控制点走”这个差别到第四章再展开。先把最核心的求值函数写对。2.2 用 Cox-de Boor 递归式求基函数B 样条曲线的标准写法是C(u) sum_{i0}^{n} N_{i,p}(u) * P_i所以先要实现基函数N_{i,p}(u)。最直观的写法是 Cox-de Boor 递归次数 0 时看参数 u 落在哪个节点区间内返回 1否则返回 0次数大于 0 时用前后两个区间长度把低一阶的基函数组合起来。#include vector using std::vector; // U 是节点数组i 是控制点下标p 是曲线次数u 是曲线参数 double basis(int i, int p, double u, const vectordouble U) { if (p 0) { return (u U[i] u U[i 1]) ? 1.0 : 0.0; } double left 0.0; double right 0.0; double d1 U[i p] - U[i]; if (d1 ! 0.0) { left (u - U[i]) / d1 * basis(i, p - 1, u, U); } double d2 U[i p 1] - U[i 1]; if (d2 ! 0.0) { right (U[i p 1] - u) / d2 * basis(i 1, p - 1, u, U); } return left right; }这段代码要解释几个关键点。u U[i] u U[i1]采用“左闭右开”的区间判断保证参数边界处的基函数求和等于 1。分母出现U[ip] - U[i]为 0 时说明节点区间长度是 0这在 clamped 节点向量里很常见必须跳过否则就是除零。递归写法正确性最高但性能很差同一个 u 值要重复计算大量子树控制点超过 50 个、采样超过 200 个点时就能明显卡顿不适合放到鼠标拖拽回调里反复调用。2.3 拖动控制点时用 de Boor 循环求坐标在 Qt 曲线编辑器里鼠标拖动控制点会触发连续重绘。常见的做法是改成 de Boor 算法先定位 u 落在哪个节点区间再拿出 p1 个控制点做局部线性插值循环 p 次。这样单次求值是 O(p^2)不随控制点总数线性增长。#include QPointF // P 是控制点数组U 是节点数组p 是曲线次数u 是曲线参数 QPointF evalBSpline(const vectorQPointF P, const vectordouble U, int p, double u) { const int n static_castint(P.size()) - 1; if (u U.back()) { u U.back() - 1e-8; } int k p; while (k n !(u U[k] u U[k 1])) { k; } // 取出当前区间关联的 p1 个控制点 vectorQPointF d(P.begin() k - p, P.begin() k 1); for (int r 1; r p; r) { for (int j p; j r; --j) { double den U[k j - r 1] - U[k - p j]; double alpha (den ! 0.0) ? (u - U[k - p j]) / den : 0.0; d[j] (1.0 - alpha) * d[j - 1] alpha * d[j]; } } return d[p]; }注意几个边界处理。u U.back()时要往回收一点否则最后区间的右端点会落到重复节点之外。区间定位循环最多走到k n因为 clamped 节点向量保证了有效参数范围是[U[p], U[n1])。用P.begin() k 1截取控制点时k已经保证小于等于n所以不会越界。d数组长度是p1每次循环只在局部更新最后d[p]就是曲线上的点。把这段代码放进任何 C 工程都能单独编译不依赖 Qt 界面逻辑。也就是说你可以先写个命令行程序验证曲线形状再往 Qt 工程里搬。3. 在 QT 工程里落成可见曲线模型、paintEvent 与重绘3.1 把求值器封装成不依赖 UI 的 SplineEvaluator常见的错误做法是把节点数组和求值代码全部写进QWidget子类看起来省事但后面一旦要支持多视图、导出数据或者单元测试就得把界面里的逻辑全部扯出来。我一般会先写一个SplineEvaluator只负责“根据控制点和参数输出 QPointF”class SplineEvaluator { public: static double basis(int i, int p, double u, const vectordouble U); static QPointF eval(const vectorQPointF P, const vectordouble U, int p, double u); static vectordouble buildClampedKnots(int count, int p); }; vectordouble SplineEvaluator::buildClampedKnots(int count, int p) { int n count - 1; int m n p 1; vectordouble U(m 1); for (int i 0; i p; i) { U[i] 0.0; } for (int i p 1; i m - p - 1; i) { U[i] static_castdouble(i - p) / (n - p 1); } for (int i m - p; i m; i) { U[i] 1.0; } return U; }buildClampedKnots的参数要和前一章的约束对上count是控制点数量p是次数。例如 5 个控制点、3 次曲线n4m8节点数组长度 9首尾各 4 个 0 和 1中间 1 个节点是 0.5。这样得到的曲线首尾端点恰好落在第一个和最后一个控制点上视觉上最容易确认代码是否正确。3.2 在 paintEvent 里用 QPainterPath 绘制曲线Qt 绘图的入口只能是paintEvent不建议在其他函数里直接调用QPainter往设备上画。自定义控件里最常见的做法是把采样循环放在paintEvent中用QPainterPath累积线段void SplineWidget::paintEvent(QPaintEvent*) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); if ((int)m_ctlPoints.size() m_degree 1) { painter.drawText(rect(), Qt::AlignCenter, QStringLiteral(至少需要 %1 个控制点).arg(m_degree 1)); return; } vectordouble U SplineEvaluator::buildClampedKnots( static_castint(m_ctlPoints.size()), m_degree); const int samples 400; QPainterPath curvePath; for (int i 0; i samples; i) { double u static_castdouble(i) / samples; QPointF pt SplineEvaluator::eval(m_ctlPoints, U, m_degree, u); if (i 0) { curvePath.moveTo(pt); } else { curvePath.lineTo(pt); } } painter.setPen(QPen(QColor(232, 80, 58), 2.2)); painter.drawPath(curvePath); // 控制多边形用灰色虚线便于和曲线区分 painter.setPen(QPen(QColor(140, 140, 140), 1.0, Qt::DashLine)); painter.setBrush(Qt::NoBrush); painter.drawPolyline(m_ctlPoints.data(), static_castint(m_ctlPoints.size())); }采样数 400 对屏幕显示已经非常平滑没必要用几千个点。曲线路径和控制多边形分两支画笔绘制颜色一暖一灰、线宽一粗一细视觉分层更清楚。QPainterPath在重复构造时会有开销但这套代码只在重绘事件里执行控制点不多时完全够用。3.3 鼠标事件把点加入、移除逻辑隔离在重绘之前曲线编辑器不能只看图还要能改控制点。这里用左键添加点右键删除离鼠标最近的点void SplineWidget::mousePressEvent(QMouseEvent* event) { if (event-button() Qt::LeftButton) { m_ctlPoints.push_back(event-pos()); } else if (event-button() Qt::RightButton) { int hitIndex -1; double bestDist 32.0 * 32.0; for (int i 0; i (int)m_ctlPoints.size(); i) { double dx event-pos().x() - m_ctlPoints[i].x(); double dy event-pos().y() - m_ctlPoints[i].y(); double d2 dx * dx dy * dy; if (d2 bestDist) { bestDist d2; hitIndex i; } } if (hitIndex 0) { m_ctlPoints.erase(m_ctlPoints.begin() hitIndex); } } update(); }删除判定用距离平方省去开根号性能更好。阈值 32 像素是一个比较宽松的热区在非高分屏上体验尚可如果要支持高分屏建议用event-position()和devicePixelRatioF()做坐标换算。添加点后只调用update()不直接调repaint()因为update()会把多次鼠标事件合并成一次重绘避免频繁拖拽时 CPU 飙高。4. 让 QT 曲线形态可调阶数、节点向量和采样精度4.1 打开工程后先改曲线次数确认局部支撑特性B 样条和贝塞尔曲线的最大区别是局部支撑移动一个控制点只有附近一段曲线改变其他段不受影响。影响范围由曲线次数决定。p1 时曲线就是控制点连线移动一个点只影响相邻两条线段p3 时影响范围扩大到相邻 4 个控制点之间的区间这是工程里最常用的次数。下面这个表格可以直接放在工程注释里帮助快速理解参数数量关系曲线次数 p控制点数量 N节点数组长度视觉效果157折线完全经过控制点258连续一阶可导拐点少359连续二阶可导曲线最平滑3812控制点增加局部微调更精细如果你拿到一个B_Spline.zip工程界面里可能写死了次数为 3。把它改成 2 或 4 再拖拖动控制点会立刻看到曲线变“紧”或变“松”这个实验比读任何公式都有用。4.2 节点向量加重复节点让曲线更贴近某个控制点节点向量中两个相邻节点相等时基函数会发生退化曲线会被拉向对应控制点。极端做法是把中间某个节点重复 p 次曲线会直接穿过那个控制点但会牺牲光滑性。// 在第 k 个位置插入一个重复节点 vectordouble U SplineEvaluator::buildClampedKnots(N, p); double target 0.4; vectordouble U2; for (double u : U) { U2.push_back(u); if (u target - 1e-6 u target 1e-6) { U2.push_back(u); // 手动制造重复 } }这种方式会产生不等距节点向量用之前要求的 de Boor 算法时不需要改代码因为算法本身对重复节点是兼容的。真正要小心的是递归基函数版本它遇到重复节点时会出现分母为 0所以本章的插入代码必须配合 de Boor 版本使用。4.3 均匀节点 vs 弦长参数化拖拽体验差异buildClampedKnots生成的均匀节点在控制点间距差异大时会让曲线在某段明显“拱起”。更稳的方案是弦长参数化按相邻控制点之间的实际距离分配节点值这样曲线在疏密不均匀的控制点网格上更贴近折线轮廓。vectordouble buildChordLengthKnots(const vectorQPointF P, int p) { int n static_castint(P.size()) - 1; vectordouble chord(n 1, 0.0); for (int i 1; i n; i) { double dx P[i].x() - P[i - 1].x(); double dy P[i].y() - P[i - 1].y(); chord[i] chord[i - 1] std::sqrt(dx * dx dy * dy); } double total chord[n]; if (total 0.0) { return SplineEvaluator::buildClampedKnots(n 1, p); } vectordouble U(n p 2, 0.0); for (int i 0; i p; i) U[i] 0.0; for (int i n; i (int)U.size(); i) U[i] 1.0; for (int j 1; j n - p; j) { double sum 0.0; for (int i j; i j p - 1; i) { sum chord[i]; } U[j p] sum / (p * total); } return U; }逻辑不复杂先求每个控制点累计弦长再把中间节点设成相邻 p 段弦长平均值的比例。换成这段代码后控制点分布不均的曲线不会再出现局部鼓包。默认工程如果只提供均匀节点建议把这里的差异写进注释方便后来的人知道还有这种选择。5. 曲线正确性自检与 QT 绘图的一个实际优化验证 B 样条曲线实现是否正确不需要高端仪器。先用已知结论clamped p 次曲线的首尾点必须等于首尾控制点。再加一条如果用一条圆形轨迹生成控制点采样计算半径最大偏差应该在很小的数值范围内否则就是节点向量长度或基函数索引出了问题。double maxError 0.0; double R 200.0; for (int i 0; i 8; i) { double angle 2.0 * 3.14159265 * i / 8.0; m_ctlPoints.push_back(QPointF(center.x() R * cos(angle), center.y() R * sin(angle))); } vectordouble U buildClampedKnots(8, 3); for (int s 0; s 1000; s) { double u s / 999.0; QPointF q evalBSpline(m_ctlPoints, U, 3, u); double r QLineF(center, q).length(); maxError std::max(maxError, std::fabs(r - R)); } qDebug() max radius error maxError;8 个控制点逼近圆形的误差通常在几个像素以内但如果节点向量长度少一位误差会变成几十甚至几百。这类自检代码非常适合放在main.cpp里用QDebug输出比肉眼盯着曲线形状可靠得多。绘图优化的核心习惯是不要在paintEvent中反复生成节点向量。控制点没变化时节点数组不会变所以把它缓存成一个成员变量只在上一次点数或次数改变时重建。拖动控制点时只改变QPointF数值QPainterPath仍要重画但省掉vector分配就能明显降低qt绘图的 CPU 占用控制点数量超过几百个后还可以把采样点直接存入QPolygonF缓存在成员里重绘只调drawPolyline。另一点容易引起qt崩溃删除控制点时不要用std::remove_if配合反向迭代器边遍历边删除先用前面的hitIndex方法找到位置再 erase避免迭代器失效这是 Qt 鼠标事件回调里最隐蔽的崩溃来源之一。本文还有配套的精品资源点击获取