Skip to content

focus255/curve-project

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

58 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

曲线编辑器

作者:Focus

华南理工大学软件学院2026年《工业设计软件与计算几何基础》课程设计。

基于 C++17 / Win32 / Direct2D 的桌面端曲线编辑工具,支持贝塞尔(Bézier)曲线与 NURBS 曲线的绘制、编辑、图层管理及多种格式导入导出。所有数学算法均从底层实现,不依赖任何第三方算法库。

Tech Stack Platform API Build


目录


技术栈

类别 技术
语言 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 数据结构:AnchorPointCurveStroke(贝塞尔)、NURBSStrokeHandleMode
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 二维向量

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 基函数

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] 上非负(凸包性)。

EvalDeCasteljau — De Casteljau 算法

这是数值最稳定的求值方法,通过三层线性插值计算曲线上任意点:

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₁),第三层得到最终结果。几何意义:这些中间点恰好是曲线分割后的控制点。

EvalBernstein — Bernstein 展开式

直接代入 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₃

Derivative1 — 一阶导数

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。

Derivative2 — 二阶导数

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₁)]

表示加速度向量。用于曲率计算和牛顿迭代求最近点。

Tangent — 单位切向量

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 处曲线的方向。当导数为零向量时(退化情况),采用微小偏移量的数值差分法避免崩溃。

Normal — 单位法向量

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)的基础,用于沿法线方向偏移控制点。

Curvature — 曲率

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 标量叉积。

Split — 曲线分割

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)成为可能——递归分割直到曲线足够平坦。


弧长与采样

弧长计算是曲线数学中最重要的数值计算之一,因为贝塞尔曲线的弧长没有解析闭式解,必须依赖数值积分。

ArcLength — 高斯-勒让德数值积分

// 高斯-勒让德 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)|√(二次多项式),没有解析原函数,使用高斯-勒让德求积法数值近似:

  1. 将积分区间 [t₀, t₁] 换元到 [-1, 1]t = (t₁+t₀)/2 + s·(t₁−t₀)/2
  2. 在每个高斯节点处计算 |B'(t)|
  3. 加权求和:L ≈ (t₁−t₀)/2 · Σ wᵢ · |B'(tᵢ)|

12 点高斯-勒让德对 23 次以下多项式精确积分。三次贝塞尔的 |B'(t)| 涉及平方根,并非多项式,但高斯求积在此类光滑函数上收敛极快。

SampleUniform — 均匀弧长采样

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 个采样点。过程:

  1. 计算总弧长
  2. 将总长等分为 count−1 段
  3. 对每个分割点,使用二分法 TFromArcLength 反求参数 t
  4. 在对应 t 处求值

这保证了点在视觉上均匀分布,而非在参数 t 上均匀(高曲率区域会自动密集)。

SampleAdaptive — 自适应细分采样

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 层防止无限递归。

与均匀采样相比,自适应采样在平坦区域使用少点、高曲率区域使用多点,渲染效率更高。

TFromArcLength — 弧长反求参数

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 次迭代保证精度。应用于均匀弧长采样。


描边与偏移

OffsetPolyline — 偏移曲线

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 向外偏移(曲线左侧),负值向内(曲线右侧)。

注意:纯法向偏移在高曲率区域会产生自交环(称为"偏移曲线奇点")。本实现提供的偏移点列主要用于后续的描边轮廓计算。

StrokeOutline — 描边轮廓多边形

这是最复杂的数学函数,生成填充后即可渲染为粗细为 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. 拼合多边形:外侧 + 末端圆弧 + 反向内侧 + 起点圆弧
    // → 封闭多边形,可直接填充
}

算法流程

  1. 自适应采样中心线得到 N 个点
  2. 计算法向量:每个点的单位法向量
  3. Miter(斜切)连接
    • 相邻法向量平均得 miter 方向
    • 修正长度:L = r / sin(θ/2),其中 θ 是两法向夹角
    • L > 4r 时降级为 bevel(平切)防止尖峰
    • 180° 折角时使用 round(圆弧)连接
  4. 端帽:起点和终点生成半圆形点集
  5. 拼合:外轮廓(正向)+ 末端圆弧(过渡)+ 内轮廓(反向)+ 起点圆弧(过渡)→ 封闭多边形

包围盒与最近点

BoundingBox — 精确包围盒

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] 内的根,这些根对应的点就是曲线的极值点。

NearestT — 最近点参数

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|²。这是一个非线性优化问题,采用粗搜索 + 牛顿迭代策略:

  1. 粗搜索:32 等分采样找到最近似的最优 t
  2. 牛顿迭代:用解析导数 f'(t) = 2(B(t)−Q)·B'(t)f''(t) = 2(|B'|² + (B−Q)·B'') 精化

每一步的更新公式:tₙ₊₁ = tₙ − f'(tₙ)/f''(tₙ)

由于距离平方函数在最近点附近近似二次,牛顿迭代通常 3-5 步即可收敛到机器精度。


NURBS 曲线

NURBS(Non-Uniform Rational B-Spline)是本项目支持的第二种曲线类型,提供了比贝塞尔曲线更灵活的造型能力。

CoxDeBoorBasis — Cox-de Boor 递推基函数

这是 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) = 1knots[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

EvalNURBS — NURBS 曲线求值

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]] 有效范围
  • 总权重为零时返回第一个控制点

ClampKnots — 生成夹紧节点向量

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 样条不同。

SampleNURBSAdaptive — NURBS 自适应采样

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 直接分割,每次在参数中点处重新求值。

NURBS 弧长与最近点

double NURBSArcLength(...) {
    // 使用数值微分求导:C'(t) ≈ (C(t+ε) - C(t-ε)) / (2ε)
    // 然后高斯-勒让德积分
}
double NURBSNearestT(...) {
    // 100 格粗搜索 + 32 步牛顿迭代
    // 由于 NURBS 一阶导无解析形式,使用数值差分
}

NURBS 的弧长和最近点计算与贝塞尔类似,但一阶/二阶导数使用数值差分(有限差分法)而非解析导数,因为 NURBS 的有理形式使导数推导极为复杂。


数据结构详解

AnchorPoint — 锚点

struct AnchorPoint
{
    Vec2       pos;        // 锚点位置(绝对坐标)
    Vec2       handleIn;   // 入手柄(绝对坐标)
    Vec2       handleOut;  // 出手柄(绝对坐标)
    HandleMode mode;       // 手柄约束模式
};

锚点是贝塞尔曲线的"关节",连接相邻曲线段。每个锚点有:

  • 位置 pos:锚点在画布上的坐标
  • 入手柄 handleIn:进入该锚点时曲线的切线控制点,影响前一段曲线的出射形状
  • 出手柄 handleOut:从该锚点离开时的切线控制点,影响后一段曲线的入射形状
  • 手柄模式:控制两个手柄之间的同步关系

HandleMode — 手柄约束模式

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: 不做任何同步
}

SymmetrichandleInhandleOut 关于 pos 中心对称,即 handleIn = 2·pos − handleOut。这保证了在锚点处曲线的一阶导数连续(C¹)。

Smooth:两个手柄方向共线(与 Symmetric 相同),但长度独立。只保证切线方向连续(G¹),曲率可以突变。

Corner:完全独立,允许曲线在锚点处产生尖角(C⁰ 连续)。

CurveStroke — 贝塞尔曲线

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]

NURBSStroke — NURBS 曲线

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 导出格式

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 ... 路径

.crv 自定义格式

纯文本格式,每条曲线以 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
  • 坐标系统:Vec2double)用世界坐标,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 命名空间中的纯函数,不依赖外部库

About

华南理工大学软件学院2026《工业软件设计与计算几何基础》课程设计

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors