作者:Focus
华南理工大学软件学院2026年《工业设计软件与计算几何基础》课程设计。
基于 C++17 / Win32 / Direct2D 的桌面端曲线编辑工具,支持贝塞尔(Bézier)曲线与 NURBS 曲线的绘制、编辑、图层管理及多种格式导入导出。所有数学算法均从底层实现,不依赖任何第三方算法库。
| 类别 | 技术 |
|---|---|
| 语言 | C++17 (stdcpp17) |
| 编译器 | MSVC v143 (Visual Studio 2022) |
| 构建 | Visual Studio .vcxproj + MSBuild,或 build.bat 命令行编译 |
| UI 框架 | Win32 API (windows.h, comctl32, commdlg32) |
| 图形渲染 | Direct2D 1.1 (d2d1_1.h) |
| 文本渲染 | DirectWrite (dwrite.h) |
| 高 DPI | Per-monitor DPI-aware (Shcore.lib),运行时 g_dpiScale 缩放 |
| 字符集 | Unicode (/utf-8) |
| 数学库 | 自实现(无外部依赖) |
| 文件格式 | SVG 导出(手动拼串)、自定义 .crv 纯文本格式 |
所有源文件位于项目根目录(无 src/ 子目录)。
| 文件 | 用途 |
|---|---|
BezierMath.h |
Vec2 向量运算、三次贝塞尔全数学库、NURBS Cox-de Boor 基函数与求值 |
CurveData.h |
数据结构:AnchorPoint、CurveStroke(贝塞尔)、NURBSStroke、HandleMode |
Canvas.h |
画布主类:D2D 渲染、工具调度、文档操作、选中管理、撤销/重做 |
UIManager.h |
右侧属性面板:颜色/线宽控件 |
LayerManager.h |
左侧图层管理器:图层列表、可见性/锁定、排序、增删 |
SVGExporter.h |
SVG 导出(手动拼串,无 XML 库依赖) |
CRVExporter.h |
自定义 .crv 格式导入/导出(纯文本逐行解析) |
| 文件 | 行数 | 用途 |
|---|---|---|
Main.cpp |
543 | WinMain、窗口创建、消息循环、菜单/工具栏、子窗口布局 |
Canvas.cpp |
2231 | 核心渲染与交互:钢笔/选择/NURBS 工具、D2D 绘制、坐标变换、点击测试 |
BezierMath.cpp |
840 | 数学函数实现:求值/分割/弧长/采样/偏移/最近点/NURBS |
Tests.cpp |
658 | 单元测试(Vec2、CurveData、贝塞尔、NURBS 边界条件) |
UIManager.cpp |
368 | 属性面板控件创建与事件处理 |
LayerManager.cpp |
416 | 图层面板自绘控件与交互逻辑 |
CRVExporter.cpp |
345 | .crv 文件格式读写 |
SVGExporter.cpp |
142 | SVG 文件格式写出 |
| 文件 | 用途 |
|---|---|
curve project.sln |
Visual Studio 2022 解决方案 |
curve project.vcxproj |
MSBuild 项目配置(Debug/Release × Win32/x64) |
build.bat |
命令行一键编译 CurveEditor.exe |
run_tests.bat |
编译并运行单元测试 |
.gitignore |
忽略 .vs/、x64/、Debug/、*.obj、*.exe 等 |
- Windows 10+
- Visual Studio 2022(含"使用 C++ 的桌面开发"工作负载)
- Windows SDK 10.0
方式一:Visual Studio GUI
打开 curve project.sln → 按 F5 构建并运行。
方式二:命令行
.\build.bat
# 生成 CurveEditor.exe.\run_tests.bat
# 编译 Tests.exe 并执行所有单元测试:: 主程序
cl /nologo /EHsc /std:c++17 /utf-8 /Fe:CurveEditor.exe ^
Main.cpp Canvas.cpp UIManager.cpp LayerManager.cpp ^
BezierMath.cpp SVGExporter.cpp CRVExporter.cpp ^
/link gdi32.lib user32.lib kernel32.lib comctl32.lib comdlg32.lib shcore.lib ^
/SUBSYSTEM:WINDOWS
:: 测试
cl /Fe:Tests.exe /std:c++17 /EHsc /utf-8 Tests.cpp BezierMath.cpp+----------------------------------------------------------+
| 菜单栏(文件 / 编辑) |
+----------------------------------------------------------+
| 工具栏:[新建] [保存SVG] [导出PNG] | [选择] [钢笔] [NURBS] [删除点] | [撤销] [重做] |
+--------+-----------------------------------+---------------+
| 图层 | | 属性面板 |
| 管理 | 画布区域 | 颜色: [■] |
| 器 | | 线宽: [__] |
| | - 中键平移 | 类型信息 |
| | - 滚轮缩放 | |
| | - 左键绘制/选择 | |
+--------+-----------------------------------+---------------+
| 状态栏 |
+----------------------------------------------------------+
| 工具 | 快捷键 | 功能 |
|---|---|---|
| 钢笔 (Pen) | 工具栏按钮 | 点击添加锚点构建贝塞尔曲线;拖拽锚点调整手柄;靠近起点可闭合路径 |
| 选择 (Select) | 工具栏按钮 | 点击选中锚点/手柄并拖拽调整;选中曲线层后可编辑属性 |
| NURBS | 工具栏按钮 | 点击添加 NURBS 控制点构建 NURBS 曲线 |
| 删除点 | 工具栏按钮 | 删除当前选中的锚点或 NURBS 控制点 |
| 操作 | 快捷键 | 说明 |
|---|---|---|
| 新建 | Ctrl+N |
清空当前文档 |
| 打开 SVG | Ctrl+O |
加载 SVG 文件 |
| 打开 CRV | Ctrl+R |
加载自定义 .crv 文件 |
| 保存 SVG | Ctrl+S |
导出为 SVG 矢量图 |
| 导出 PNG | Ctrl+E |
导出为 PNG 位图 |
| 保存 CRV | Ctrl+D |
保存为自定义 .crv 格式 |
| 撤销 | Ctrl+Z |
撤销上一步操作 |
| 重做 | Ctrl+Y |
重做被撤销的操作 |
左侧面板支持:
- 单击选中图层
- 点击眼睛图标切换可见性
- 点击锁图标切换编辑锁定
- 上/下箭头调整图层顺序
- +/- 按钮增删图层
Backspace删除选中图层
本项目实现了一套完整的曲线数学库,位于 BezierMath.h / BezierMath.cpp。所有算法从数学公式直接推导,不依赖任何外部算法库。
Vec2 是曲线数学的基石,封装了所有二维向量运算。
struct Vec2
{
double x = 0.0, y = 0.0;
Vec2() = default;
Vec2(double x, double y) : x(x), y(y) {}
Vec2 operator+(const Vec2& o) const { return { x + o.x, y + o.y }; }
Vec2 operator-(const Vec2& o) const { return { x - o.x, y - o.y }; }
Vec2 operator*(double s) const { return { x * s, y * s }; }
Vec2 operator/(double s) const { return { x / s, y / s }; }
double dot(const Vec2& o) const { return x * o.x + y * o.y; }
double cross(const Vec2& o) const { return x * o.y - y * o.x; }
double lengthSq() const { return x * x + y * y; }
double length() const { return std::sqrt(lengthSq()); }
Vec2 normalized() const;
Vec2 perp() const { return { -y, x }; } // 逆时针旋转 90°
static Vec2 lerp(const Vec2& a, const Vec2& b, double t);
};支持的运算:
- 四则运算:加、减、标量乘除,均按分量逐元素计算
- 点积
dot(v):x·v.x + y·v.y,用于投影长度计算 - 二维叉积
cross(v):x·v.y - y·v.x,2D 中结果是一个标量,表示两向量围成的平行四边形有向面积,用于判断方向(正=逆时针)和计算距离 - 长度
length()/lengthSq():避免开方的平方长度用于比较性能优化 - 单位化
normalized():零向量返回(0,0)而非除以零 - 垂直向量
perp():逆时针旋转 90°,即(-y, x) - 线性插值
lerp(a, b, t):a + (b-a)·t,是 De Casteljau 算法的基本操作
三次贝塞尔曲线由 4 个控制点 (P₀, P₁, P₂, P₃) 定义,参数 t ∈ [0,1]。
Bernstein 基函数是贝塞尔曲线的多项式基础:
static void BernsteinBasis(double t, double b[4])
{
double mt = 1.0 - t;
double mt2 = mt * mt;
double t2 = t * t;
b[0] = mt2 * mt; // (1-t)^3
b[1] = 3.0 * mt2 * t; // 3(1-t)^2 t
b[2] = 3.0 * mt * t2; // 3(1-t) t^2
b[3] = t2 * t; // t^3
}三次 Bernstein 基函数为:
- B₀₃(t) = (1−t)³
- B₁₃(t) = 3(1−t)²t
- B₂₃(t) = 3(1−t)t²
- B₃₃(t) = t³
满足性质:ΣBᵢ₃(t) = 1(单位分割性),所有基函数在 [0,1] 上非负(凸包性)。
这是数值最稳定的求值方法,通过三层线性插值计算曲线上任意点:
Vec2 EvalDeCasteljau(const Vec2& p0, const Vec2& p1,
const Vec2& p2, const Vec2& p3, double t)
{
// 第一层插值
Vec2 q0 = Vec2::lerp(p0, p1, t);
Vec2 q1 = Vec2::lerp(p1, p2, t);
Vec2 q2 = Vec2::lerp(p2, p3, t);
// 第二层插值
Vec2 r0 = Vec2::lerp(q0, q1, t);
Vec2 r1 = Vec2::lerp(q1, q2, t);
// 第三层插值:结果
return Vec2::lerp(r0, r1, t);
}数学推导:对 4 个控制点反复做线性插值。第一层得到 3 个点 (q₀, q₁, q₂),第二层得到 2 个点 (r₀, r₁),第三层得到最终结果。几何意义:这些中间点恰好是曲线分割后的控制点。
直接代入 Bernstein 基函数的展开公式,与 De Casteljau 在数学上等价:
Vec2 EvalBernstein(const Vec2& p0, const Vec2& p1,
const Vec2& p2, const Vec2& p3, double t)
{
double b[4];
BernsteinBasis(t, b);
return p0 * b[0] + p1 * b[1] + p2 * b[2] + p3 * b[3];
}公式:B(t) = (1−t)³·P₀ + 3(1−t)²t·P₁ + 3(1−t)t²·P₂ + t³·P₃
Vec2 Derivative1(const Vec2& p0, const Vec2& p1,
const Vec2& p2, const Vec2& p3, double t)
{
double mt = 1.0 - t;
double mt2 = mt * mt;
double t2 = t * t;
Vec2 d01 = p1 - p0;
Vec2 d12 = p2 - p1;
Vec2 d23 = p3 - p2;
return (d01 * mt2 + d12 * (2.0 * mt * t) + d23 * t2) * 3.0;
}公式:B'(t) = 3[(1−t)²·(P₁−P₀) + 2(1−t)t·(P₂−P₁) + t²·(P₃−P₂)]
表示曲线在 t 处的瞬时速度向量。由 Bernstein 基函数求导推导得出,本质上是三个差向量 (P₁−P₀, P₂−P₁, P₃−P₂) 的二次 Bernstein 插值再乘以 3。
Vec2 Derivative2(const Vec2& p0, const Vec2& p1,
const Vec2& p2, const Vec2& p3, double t)
{
Vec2 e0 = p0 - p1 * 2.0 + p2; // P0 - 2P1 + P2
Vec2 e1 = p1 - p2 * 2.0 + p3; // P1 - 2P2 + P3
return (e0 * (1.0 - t) + e1 * t) * 6.0;
}公式:B''(t) = 6[(1−t)·(P₂−2P₁+P₀) + t·(P₃−2P₂+P₁)]
表示加速度向量。用于曲率计算和牛顿迭代求最近点。
Vec2 Tangent(const Vec2& p0, const Vec2& p1,
const Vec2& p2, const Vec2& p3, double t)
{
Vec2 d = Derivative1(p0, p1, p2, p3, t);
if (d.lengthSq() < 1e-24) { // 处理退化
if (t < 0.5)
d = Derivative1(p0, p1, p2, p3, t + 1e-6);
else
d = Derivative1(p0, p1, p2, p3, t - 1e-6);
}
return d.normalized();
}对一阶导数进行单位化,得到 t 处曲线的方向。当导数为零向量时(退化情况),采用微小偏移量的数值差分法避免崩溃。
Vec2 Normal(const Vec2& p0, const Vec2& p1,
const Vec2& p2, const Vec2& p3, double t)
{
return Tangent(p0, p1, p2, p3, t).perp();
}将切向量逆时针旋转 90°。法向量是描边轮廓(StrokeOutline)和偏移曲线(OffsetPolyline)的基础,用于沿法线方向偏移控制点。
double Curvature(const Vec2& p0, const Vec2& p1,
const Vec2& p2, const Vec2& p3, double t)
{
Vec2 d1 = Derivative1(p0, p1, p2, p3, t);
Vec2 d2 = Derivative2(p0, p1, p2, p3, t);
double cross = d1.cross(d2);
double speed = d1.length();
if (speed < 1e-12) return 0.0;
return std::abs(cross) / (speed * speed * speed);
}公式:κ = |B'(t) × B''(t)| / |B'(t)|³
曲率表示曲线在某点的弯曲程度,单位是 1/长度。直线曲率为 0,小圆弧曲率为 1/r。这里的叉积使用 2D 标量叉积。
void Split(const Vec2& p0, const Vec2& p1,
const Vec2& p2, const Vec2& p3, double t,
Vec2 left[4], Vec2 right[4])
{
Vec2 q0 = Vec2::lerp(p0, p1, t);
Vec2 q1 = Vec2::lerp(p1, p2, t);
Vec2 q2 = Vec2::lerp(p2, p3, t);
Vec2 r0 = Vec2::lerp(q0, q1, t);
Vec2 r1 = Vec2::lerp(q1, q2, t);
Vec2 m = Vec2::lerp(r0, r1, t);
left[0] = p0; left[1] = q0; left[2] = r0; left[3] = m;
right[0]= m; right[1]= r1; right[2]= q2; right[3]= p3;
}在参数 t 处将一条三次贝塞尔曲线分割为两段三次贝塞尔曲线。关键性质:分割点 m 恰好是 De Casteljau 算法第三层插值的结果,而中间点 (q₀, r₀) 和 (r₁, q₂) 分别是左右子曲线的控制点。
这一性质使得自适应细分(SampleAdaptive)成为可能——递归分割直到曲线足够平坦。
弧长计算是曲线数学中最重要的数值计算之一,因为贝塞尔曲线的弧长没有解析闭式解,必须依赖数值积分。
// 高斯-勒让德 12 点节点(在 [-1,1] 上精确到 degree-23 多项式)
static const double GL_T[12] = {
-0.98156063424671924, -0.90411725637047487,
-0.76990267419430469, -0.58731795428661748,
-0.36783149899818021, -0.12523340851146891,
0.12523340851146891, 0.36783149899818021,
0.58731795428661748, 0.76990267419430469,
0.90411725637047487, 0.98156063424671924
};
static const double GL_W[12] = { /* 对应权重 */ };
double ArcLength(const Vec2& p0, const Vec2& p1,
const Vec2& p2, const Vec2& p3,
double t0, double t1)
{
double half = (t1 - t0) * 0.5;
double center = (t1 + t0) * 0.5;
double sum = 0.0;
for (int i = 0; i < GL_N; ++i) {
double t = center + half * GL_T[i];
double spd = Derivative1(p0, p1, p2, p3, t).length();
sum += GL_W[i] * spd;
}
return half * sum;
}数学原理: 弧长公式:L = ∫ₜ₀ᵗ¹ |B'(t)| dt
由于 |B'(t)| 是 √(二次多项式),没有解析原函数,使用高斯-勒让德求积法数值近似:
- 将积分区间
[t₀, t₁]换元到[-1, 1]:t = (t₁+t₀)/2 + s·(t₁−t₀)/2 - 在每个高斯节点处计算
|B'(t)| - 加权求和:
L ≈ (t₁−t₀)/2 · Σ wᵢ · |B'(tᵢ)|
12 点高斯-勒让德对 23 次以下多项式精确积分。三次贝塞尔的 |B'(t)| 涉及平方根,并非多项式,但高斯求积在此类光滑函数上收敛极快。
std::vector<Vec2> SampleUniform(const Vec2& p0, const Vec2& p1,
const Vec2& p2, const Vec2& p3, int count)
{
double total = ArcLength(p0, p1, p2, p3);
pts.push_back(EvalDeCasteljau(p0, p1, p2, p3, 0.0));
for (int i = 1; i < count - 1; ++i) {
double target = total * i / (count - 1);
double t = TFromArcLength(p0, p1, p2, p3, target);
pts.push_back(EvalDeCasteljau(p0, p1, p2, p3, t));
}
pts.push_back(EvalDeCasteljau(p0, p1, p2, p3, 1.0));
return pts;
}返回沿曲线弧长均匀分布的 count 个采样点。过程:
- 计算总弧长
- 将总长等分为 count−1 段
- 对每个分割点,使用二分法
TFromArcLength反求参数 t - 在对应 t 处求值
这保证了点在视觉上均匀分布,而非在参数 t 上均匀(高曲率区域会自动密集)。
static void AdaptiveHelper(
const Vec2& p0, const Vec2& p1,
const Vec2& p2, const Vec2& p3,
double flatness, std::vector<Vec2>& out, int depth = 0)
{
if (depth > 16) { out.push_back(EvalDeCasteljau(p0, p1, p2, p3, 0.5)); return; }
Vec2 chord = p3 - p0;
double chordLen = chord.length();
double err;
if (chordLen < 1e-6) {
err = std::max((p1 - p0).length(), (p2 - p0).length());
} else {
double d1 = std::abs((p1 - p0).cross(chord)) / chordLen;
double d2 = std::abs((p2 - p0).cross(chord)) / chordLen;
err = std::max(d1, d2);
}
if (err <= flatness) {
out.push_back(p3);
return;
}
Vec2 left[4], right[4];
Split(p0, p1, p2, p3, 0.5, left, right);
AdaptiveHelper(left[0], left[1], left[2], left[3], flatness, out, depth + 1);
AdaptiveHelper(right[0], right[1], right[2], right[3], flatness, out, depth + 1);
}递归地将曲线分割为平坦线段。判据:弦高误差——控制点 P₁、P₂ 到弦 P₀→P₃ 的最大垂直距离。当误差小于 flatness 阈值时停止分割。深度限制 16 层防止无限递归。
与均匀采样相比,自适应采样在平坦区域使用少点、高曲率区域使用多点,渲染效率更高。
double TFromArcLength(const Vec2& p0, const Vec2& p1,
const Vec2& p2, const Vec2& p3,
double targetLen, double eps)
{
if (targetLen <= 0.0) return 0.0;
double total = ArcLength(p0, p1, p2, p3);
if (targetLen >= total) return 1.0;
double lo = 0.0, hi = 1.0;
for (int i = 0; i < 64; ++i) {
double mid = (lo + hi) * 0.5;
double len = ArcLength(p0, p1, p2, p3, 0.0, mid);
if (std::abs(len - targetLen) < eps) return mid;
if (len < targetLen) lo = mid;
else hi = mid;
}
return (lo + hi) * 0.5;
}这是"给定弧长 s,求对应的参数 t"的逆问题。由于 ArcLength(t) 是严格单调递增函数,使用二分法求解。最多 64 次迭代保证精度。应用于均匀弧长采样。
std::vector<Vec2> OffsetPolyline(const Vec2& p0, const Vec2& p1,
const Vec2& p2, const Vec2& p3,
double offset, int samples)
{
std::vector<Vec2> pts;
for (int i = 0; i <= samples; ++i) {
double t = (double)i / samples;
Vec2 pt = EvalDeCasteljau(p0, p1, p2, p3, t);
Vec2 n = Normal(p0, p1, p2, p3, t);
pts.push_back(pt + n * offset); // 沿法向偏移
}
return pts;
}在曲线每个采样点处沿法向量方向平移 offset 距离。正 offset 向外偏移(曲线左侧),负值向内(曲线右侧)。
注意:纯法向偏移在高曲率区域会产生自交环(称为"偏移曲线奇点")。本实现提供的偏移点列主要用于后续的描边轮廓计算。
这是最复杂的数学函数,生成填充后即可渲染为粗细为 width 的描边。
std::vector<Vec2> StrokeOutline(const Vec2& p0, const Vec2& p1,
const Vec2& p2, const Vec2& p3,
double width, int samples)
{
double r = width * 0.5;
// 1. 自适应采样中心线
std::vector<Vec2> center = SampleAdaptive(p0, p1, p2, p3, 0.25);
// 2. 计算每个采样点的法向量
std::vector<Vec2> normals;
for (int i = 0; i < N; ++i) {
double t = (double)i / (N - 1);
normals[i] = Normal(p0, p1, p2, p3, t);
}
// 3. 构建外侧/内侧顶点(带 miter 连接处理)
for (int i = 1; i < N - 1; ++i) {
Vec2 n0 = normals[i - 1];
Vec2 n1 = normals[i];
Vec2 avg = (n0 + n1);
double avgLen = avg.length();
if (avgLen < 1e-6) {
// 180° 折角 → round join(圆弧)
AppendArc(outer, center[i], r, a0, a1, 6);
} else {
Vec2 miterDir = avg.normalized();
double cosA = n0.dot(n1);
double sinHalf = sqrt((1.0 - cosA) * 0.5);
double miterLen = r / sinHalf; // miter 长度修正
// 如果 miter 过长,降级为 bevel
if (miterLen > MITER_LIMIT * r) { /* bevel */ }
else { outer.push_back(center[i] + miterDir * miterLen); }
}
}
// 4. 拼合多边形:外侧 + 末端圆弧 + 反向内侧 + 起点圆弧
// → 封闭多边形,可直接填充
}算法流程:
- 自适应采样中心线得到 N 个点
- 计算法向量:每个点的单位法向量
- Miter(斜切)连接:
- 相邻法向量平均得 miter 方向
- 修正长度:
L = r / sin(θ/2),其中 θ 是两法向夹角 - 当
L > 4r时降级为 bevel(平切)防止尖峰 - 180° 折角时使用 round(圆弧)连接
- 端帽:起点和终点生成半圆形点集
- 拼合:外轮廓(正向)+ 末端圆弧(过渡)+ 内轮廓(反向)+ 起点圆弧(过渡)→ 封闭多边形
AABB BoundingBox(const Vec2& p0, const Vec2& p1,
const Vec2& p2, const Vec2& p3)
{
// 初始化为端点包围盒
box.minX = std::min(p0.x, p3.x); box.maxX = std::max(p0.x, p3.x);
box.minY = std::min(p0.y, p3.y); box.maxY = std::max(p0.y, p3.y);
// 对 x、y 分量分别求 B'(t)=0 的根
auto solveRoots = [](double p0v, double p1v, double p2v, double p3v,
double& r0, double& r1) -> int
{
double a = -3*p0v + 9*p1v - 9*p2v + 3*p3v;
double b = 6*p0v -12*p1v + 6*p2v;
double c = -3*p0v + 3*p1v;
// 求解二次方程 at² + bt + c = 0
// ...
};
solveRoots(p0.x, p1.x, p2.x, p3.x, rx0, rx1); // x 分量极值
solveRoots(p0.y, p1.y, p2.y, p3.y, ry0, ry1); // y 分量极值
// 展开包围盒包含极值点
}数学原理:三次贝塞尔曲线的包围盒不一定由端点 (P₀, P₃) 决定——曲线可能"超出"端点范围。精确包围盒需要找到 B'(t) = 0 的根(即极值点所在参数位置)。
B'(t) 的每个分量是二次多项式:B'ₓ(t) = at² + bt + c,其中:
- a = −3P₀ₓ + 9P₁ₓ − 9P₂ₓ + 3P₃ₓ
- b = 6P₀ₓ − 12P₁ₓ + 6P₂ₓ
- c = −3P₀ₓ + 3P₁ₓ
求解二次方程 at² + bt + c = 0,取 [0,1] 内的根,这些根对应的点就是曲线的极值点。
double NearestT(const Vec2& p0, const Vec2& p1,
const Vec2& p2, const Vec2& p3,
const Vec2& query, double eps)
{
// 粗搜索:32 格均匀采样
for (int i = 0; i <= COARSE; ++i) {
double t = (double)i / COARSE;
double d2 = (EvalDeCasteljau(p0, p1, p2, p3, t) - query).lengthSq();
if (d2 < bestDist) { bestDist = d2; bestT = t; }
}
// 牛顿迭代精化(最多 16 步)
for (int iter = 0; iter < 16; ++iter) {
Vec2 diff = pt - query;
double fp = 2.0 * diff.dot(d1); // 一阶导
double fpp = 2.0 * (d1.lengthSq() + diff.dot(d2)); // 二阶导
t -= fp / fpp;
t = std::max(0.0, std::min(1.0, t));
}
return t;
}算法:最小化距离平方函数 f(t) = |B(t)−Q|²。这是一个非线性优化问题,采用粗搜索 + 牛顿迭代策略:
- 粗搜索:32 等分采样找到最近似的最优 t
- 牛顿迭代:用解析导数
f'(t) = 2(B(t)−Q)·B'(t)和f''(t) = 2(|B'|² + (B−Q)·B'')精化
每一步的更新公式:tₙ₊₁ = tₙ − f'(tₙ)/f''(tₙ)
由于距离平方函数在最近点附近近似二次,牛顿迭代通常 3-5 步即可收敛到机器精度。
NURBS(Non-Uniform Rational B-Spline)是本项目支持的第二种曲线类型,提供了比贝塞尔曲线更灵活的造型能力。
这是 B 样条曲线的核心递推公式:
double CoxDeBoorBasis(int i, int p,
const std::vector<double>& knots, double t)
{
if (p == 0) {
return (t >= knots[i] && t < knots[i + 1]) ? 1.0 : 0.0;
}
double left = 0.0;
double right = 0.0;
double denom1 = knots[i + p] - knots[i];
if (denom1 != 0.0) {
left = (t - knots[i]) / denom1
* CoxDeBoorBasis(i, p - 1, knots, t);
}
double denom2 = knots[i + p + 1] - knots[i + 1];
if (denom2 != 0.0) {
right = (knots[i + p + 1] - t) / denom2
* CoxDeBoorBasis(i + 1, p - 1, knots, t);
}
return left + right;
}递推公式:
- 0 次基函数(p=0):
Nᵢ₀(t) = 1当knots[i] ≤ t < knots[i+1],否则 0 - 高次基函数(p>0): Nᵢₚ(t) = (t−kᵢ)/(kᵢ₊ₚ−kᵢ) · Nᵢₚ₋₁(t) + (kᵢ₊ₚ₊₁−t)/(kᵢ₊ₚ₊₁−kᵢ₊₁) · Nᵢ₊₁ₚ₋₁(t)
这是一个递归定义:p 次基函数是两个 p−1 次基函数的线性组合。系数由节点向量(knots)决定。当分母为零时(重复节点),对应项贡献为 0。
Nᵢₚ(t) 的性质:
- 局部支撑性:Nᵢₚ(t) 仅在
[knots[i], knots[i+p+1])上非零 - 单位分割:Σᵢ Nᵢₚ(t) = 1(对 t 在有效范围内)
- 非负性:Nᵢₚ(t) ≥ 0
Vec2 EvalNURBS(const std::vector<Vec2>& controlPoints,
const std::vector<double>& weights,
const std::vector<double>& knots,
int degree, double t)
{
// 有理加权平均:C(t) = Σ(Nᵢₚ(t)·wᵢ·Pᵢ) / Σ(Nᵢₚ(t)·wᵢ)
Vec2 num;
double den = 0.0;
for (int i = 0; i < n; ++i) {
double basis = CoxDeBoorBasis(i, degree, knots, t);
if (basis > 1e-15) {
num = num + controlPoints[i] * (weights[i] * basis);
den += weights[i] * basis;
}
}
if (std::abs(den) < 1e-15) return controlPoints[0]; // 退化
return num / den;
}公式:C(t) = Σᵢ₌₀ⁿ⁻¹(Nᵢₚ(t) · wᵢ · Pᵢ) / Σᵢ₌₀ⁿ⁻¹(Nᵢₚ(t) · wᵢ)
这是 NURBS 的有理形式:分子是加权控制点的 B 样条曲线,分母是权重的 B 样条曲线。当所有权重相等(如 wᵢ=1)时退化为非有理 B 样条。
函数处理了若干退化情况:
- 控制点不足 degree+1 个时退化为线性插值
- t 被夹紧到
[knots[degree], knots[n]]有效范围 - 总权重为零时返回第一个控制点
std::vector<double> ClampKnots(int n, int p)
{
int m = n + p + 1;
std::vector<double> knots(m);
for (int i = 0; i < m; ++i) {
if (i <= p) knots[i] = 0.0; // 前 p+1 个为 0
else if (i >= n) knots[i] = 1.0; // 后 p+1 个为 1
else knots[i] = (double)(i - p) / (double)(n - p); // 中间均匀
}
return knots;
}生成标准的 clamped 节点向量。前 p+1 个节点为 0,后 p+1 个节点为 1,中间均匀分布。总长度 n + p + 1。
夹紧(clamped) 意味着曲线经过第一个和最后一个控制点,这与非夹紧的 B 样条不同。
static void NURBSAdaptiveHelper(
const Vec2& c0, const Vec2& c1, double t0, double t1,
double flatness, std::vector<Vec2>& out, int depth)
{
Vec2 p0 = EvalNURBS(..., t0);
Vec2 p1 = EvalNURBS(..., t1);
double tm = (t0 + t1) * 0.5;
Vec2 pm = EvalNURBS(..., tm);
// 弦高误差
double err = std::abs((pm - p0).cross(p1 - p0)) / (p1 - p0).length();
if (err <= flatness) { out.push_back(p1); return; }
NURBSAdaptiveHelper(c0, c1, t0, tm, flatness, out, depth + 1);
NURBSAdaptiveHelper(c0, c1, tm, t1, flatness, out, depth + 1);
}与贝塞尔的自适应采样类似,NURBS 版本在参数空间 t 上递归二分。判据也是弦高误差:P(t₀)→P(t₁) 弦与中点 P(tₘ) 的垂直距离。由于 NURBS 不能使用贝塞尔的 Split 直接分割,每次在参数中点处重新求值。
double NURBSArcLength(...) {
// 使用数值微分求导:C'(t) ≈ (C(t+ε) - C(t-ε)) / (2ε)
// 然后高斯-勒让德积分
}
double NURBSNearestT(...) {
// 100 格粗搜索 + 32 步牛顿迭代
// 由于 NURBS 一阶导无解析形式,使用数值差分
}NURBS 的弧长和最近点计算与贝塞尔类似,但一阶/二阶导数使用数值差分(有限差分法)而非解析导数,因为 NURBS 的有理形式使导数推导极为复杂。
struct AnchorPoint
{
Vec2 pos; // 锚点位置(绝对坐标)
Vec2 handleIn; // 入手柄(绝对坐标)
Vec2 handleOut; // 出手柄(绝对坐标)
HandleMode mode; // 手柄约束模式
};锚点是贝塞尔曲线的"关节",连接相邻曲线段。每个锚点有:
- 位置
pos:锚点在画布上的坐标 - 入手柄
handleIn:进入该锚点时曲线的切线控制点,影响前一段曲线的出射形状 - 出手柄
handleOut:从该锚点离开时的切线控制点,影响后一段曲线的入射形状 - 手柄模式:控制两个手柄之间的同步关系
enum class HandleMode {
Symmetric, // 对称:|handleOut| = |handleIn|,方向相反
Smooth, // 平滑:方向共线,长度独立
Corner // 拐角:完全独立
};通过 SyncHandle(changed) 函数实现:
void SyncHandle(int changed) {
if (mode == HandleMode::Symmetric) {
if (changed == 0) handleIn = pos * 2.0 - handleOut; // 沿 pos 镜像
else handleOut = pos * 2.0 - handleIn;
}
else if (mode == HandleMode::Smooth) {
// 方向共线,各自保留长度
if (changed == 0) {
double lenIn = (handleIn - pos).length();
Vec2 dir = (pos - handleOut).normalized();
handleIn = pos + dir * lenIn;
} else {
// 类似
}
}
// Corner: 不做任何同步
}Symmetric:handleIn 和 handleOut 关于 pos 中心对称,即 handleIn = 2·pos − handleOut。这保证了在锚点处曲线的一阶导数连续(C¹)。
Smooth:两个手柄方向共线(与 Symmetric 相同),但长度独立。只保证切线方向连续(G¹),曲率可以突变。
Corner:完全独立,允许曲线在锚点处产生尖角(C⁰ 连续)。
struct CurveStroke
{
std::vector<AnchorPoint> points; // 锚点序列
bool closed = false; // 是否闭合
COLORREF color = RGB(0, 0, 0); // 描边颜色
double width = 1.0; // 描边线宽
bool visible = true; // 图层可见性
bool locked = false; // 图层锁定
std::wstring name; // 图层名称
int SegCount() const; // 返回曲线段数
void GetSeg(int seg, Vec2& p0, Vec2& p1, Vec2& p2, Vec2& p3) const;
};由多个 AnchorPoint 依次连接而成。相邻两个锚点形成一条三次贝塞尔曲线段。n 个点:
- 开放曲线:
n−1段 - 闭合曲线:
n段(首尾相连)
GetSeg 将锚点数据转换为渲染所需的 4 控制点格式:
void GetSeg(int seg, Vec2& p0, Vec2& p1, Vec2& p2, Vec2& p3) const {
int n = (int)points.size();
const AnchorPoint& a = points[seg % n];
const AnchorPoint& b = points[(seg + 1) % n];
p0 = a.pos; // 段起点
p1 = a.handleOut; // 起点出手柄
p2 = b.handleIn; // 终点入手柄
p3 = b.pos; // 段终点
}闭合时通过 % n 实现 points[n-1] 连接到 points[0]。
struct NURBSStroke
{
std::vector<Vec2> controlPoints; // 控制点序列
std::vector<double> weights; // 权重(对应每个控制点)
std::vector<double> knots; // 节点向量
int degree = 3; // 曲线次数
bool closed = false;
COLORREF color = RGB(0, 0, 0);
double width = 1.0;
bool visible = true;
bool locked = false;
std::wstring name;
};NURBS 曲线的参数:
- 控制点
controlPoints:n 个二维点,定义了曲线的大致形状 - 权重
weights:每个控制点的权重值,权重越大曲线越"靠近"该控制点 - 节点向量
knots:长度为n + degree + 1的单调非减序列,定义了基函数的参数分布 - 次数
degree:基函数的多项式次数。degree=1 为折线,degree=2 为二次,degree=3 为三次
EnsureKnots() 和 EnsureWeights() 自动初始化默认值:
void EnsureKnots() {
int expected = n + degree + 1;
if ((int)knots.size() == expected) return;
// 生成夹紧节点向量:前 degree+1 个为 0,后 degree+1 个为 1,中间均匀
}
void EnsureWeights() {
if ((int)weights.size() != (int)controlPoints.size()) {
weights.assign(controlPoints.size(), 1.0); // 默认权重为 1
}
}SVG 导出使用手动字符串拼接方式,不依赖任何 XML 库。每条 Bezier 曲线输出为一个 <path> 元素:
<path d="M x0,y0 C cp1x,cp1y cp2x,cp2y x3,y3 [Z]"
fill="none" stroke="#RRGGBB" stroke-width="w"
stroke-linecap="round" stroke-linejoin="round"/>M= MoveTo(移动到起点)C= CubicBezier(三次贝塞尔:两个控制点 + 终点)Z= ClosePath(闭合路径,仅闭合曲线)- NURBS 曲线先采样为折线,再输出为
M ... L ...路径
纯文本格式,每条曲线以 BEGIN CURVE / END CURVE 包裹:
BEGIN CURVE
TYPE BEZIER
POINTS n
x y handleOutX handleOutY handleInX handleInY [handleMode]
...
CLOSED YES/NO
COLOR r g b
WIDTH w
NAME name
END CURVE
BEGIN CURVE
TYPE NURBS
DEGREE d
CONTROLPOINTS n
x y
...
WEIGHTS n
w0 w1 ... wn
KNOTS m
k0 k1 ... km
CLOSED YES/NO
COLOR r g b
WIDTH w
NAME name
END CURVE
编码为 UTF-8(含 BOM),逐行解析,支持包含空格的图层名称。
| 类别 | 风格 | 示例 |
|---|---|---|
| 类/结构体 | PascalCase | Canvas, CurveStroke, NURBSStroke |
| 成员变量 | m_ 前缀 + PascalCase |
m_hwnd, m_renderTarget, m_selPoint |
| 静态成员变量 | s_ 前缀 + camelCase |
s_classReg, s_panelClassReg |
| 局部变量 | camelCase | hMenu, len, outStroke |
| 函数/方法 | PascalCase | CreateToolbar(), Resize(), RenderStrokes() |
| 常量/枚举值 | PascalCase | Tool::Pen, HandleMode::Smooth |
| 宏 | UPPER_CASE | NOMINMAX, WIN32_LEAN_AND_MEAN |
| Windows 句柄 | 匈牙利命名 | hwndMain, hMenu, hEdit |
| 命名空间 | PascalCase | BezierMath, SVGExporter, CRVExporter |
| 控件 ID | IDP_ / ID_ / CMD_ + UPPER |
IDP_COLOR_BTN, CMD_UNDO |
- 缩进:4 空格(VS 默认)
- 大括号:K&R 风格(左大括号不换行)
- 文件头:
// =====分隔块,标明文件名与功能描述 - 节分隔:
// ----或// ====分组 - 注释语言:中文注释,英文标识符
- 字符串类型:
std::wstring+L"..."宽字符串字面量 - DPI 缩放:所有像素值经过
g_dpiScale缩放 - 颜色表示:
COLORREF(RGB 宏)用于 Win32,运行时转为D2D1_COLOR_F - 坐标系统:
Vec2(double)用世界坐标,D2D1_POINT_2F用屏幕坐标 - 错误处理:
HRESULT用于 D2D 操作,bool用于文件 I/O,assert用于不变式 - 编译防护:每个
.cpp文件顶部包含#define NOMINMAX,防止windows.h定义min/max宏
- Canvas 为中心:持有文档数据(
m_strokes/m_nurbsStrokes),通过指针调用 UIManager/LayerManager - UIManager / LayerManager:作为 Canvas 的观察者,
UpdateFromCanvas()刷新显示 - 撤销/重做:快照栈模式(
UndoState保存完整文档快照) - 导出器:无状态命名空间函数(
SVGExporter::SaveSVG()、CRVExporter::LoadCRV()) - 数学库:
BezierMath命名空间中的纯函数,不依赖外部库