☰
VB.NET实现非均匀B样条曲线绘制与交互
2026/10/14 16:58:39 网站建设 项目流程

简介:本资源是一套基于VB.NET实现的非均匀B样条(NURBS)曲线建模完整工程,面向计算机图形学初学者、机械/建筑CAD开发人员及.NET平台算法实践者,解决复杂曲线数学建模与可视化编程落地难题。压缩包含59个文件,总计213KB,涵盖13个核心VB源码文件(如NL_B_Spline.vb、Draw_Curves_2D.vb等,实现基函数计算、节点插入、逆向求解与二维绘图)、5个可执行程序(exe)、13个动态库(dll)及项目配置文件(sln、vbproj、resx等),结构完整,支持开箱即调与模块化学习。已有184人下载学习,读者可直接运行演示程序观察控制点、权重与Knot向量对曲线形态的实时影响,深入理解NURBS参数化原理;同时获得封装良好的NURBSCurve类设计范式、鲁棒的文件解析逻辑与错误处理机制,为后续扩展曲面建模或对接OpenGL/DirectX渲染打下坚实基础。

1. 非均匀B样条曲线在VB.NET里不是“画线控件”,而是可控精度的几何建模底座

你手头有个CAD插件需求:用户拖动几个控制点,要实时生成一条光滑、可局部调节、且能精确通过首尾端点的曲线——用Graphics.DrawCurve?不行,它只是贝塞尔拟合,不满足C²连续性;用Polyline硬连?更不行,拐角生硬、导数跳变。这时候,“基于VB.NET的非均匀B样条曲线”就不是一句教科书术语,而是一套可嵌入工业级界面的底层几何引擎:它用非均匀节点矢量(knot vector)控制每一段基函数的影响范围,让曲线在控制点密集区更贴合、稀疏区更平滑;它天然支持升阶、节点插入、局部细化,比均匀B样条多出3倍以上的形状调控自由度。这个方案适合正在开发机械轮廓编辑器、字体字形生成器或数控路径预览模块的VB.NET开发者——不是学算法,是立刻把数学定义变成PointF()数组和DrawBezier可消费的分段参数。它不依赖WPF或第三方绘图库,纯GDI+即可驱动,编译后单exe零依赖部署。下面我就从数学定义落地到WinForms控件重绘,全程用VB.NET原生语法,不碰任何C#互操作或COM封装。


2. 从节点矢量到基函数:VB.NET实现De Boor递推的核心逻辑

非均匀B样条的本质,是用一组非等距节点(knot)加权控制点,通过递归计算基函数值来合成曲线。关键不在“样条”二字,而在“非均匀”——节点重复度(multiplicity)直接决定曲线在对应参数处的连续性阶数。比如节点序列{0,0,0,1,2,3,4,4,4}中,首尾三重节点强制曲线精确经过首尾控制点(C⁰连续),中间单节点保证C²连续。这比均匀节点(如{0,1,2,3,4,5,6})强得多:后者无法锚定端点,也无法在特定位置降阶连续性以制造尖角。

2.1 节点矢量构造:按需控制端点行为与连续性

节点矢量长度必须等于控制点数 + 阶数(degree + 1)。设控制点数为n+1(索引0~n),阶数为p,则节点数应为n+p+2。常见工程实践是采用开放型节点(open knot vector):首尾各重复p+1次,中间严格递增。例如:

  • 控制点7个(n=6),阶数3(即三次B样条,最常用),则节点总数 = 6 + 3 + 2 = 11
  • 构造:{0,0,0,0, 0.25, 0.5, 0.75, 1, 1,1,1}—— 首尾四重节点,中间三个单节点
''' <summary> ''' 生成开放型非均匀节点矢量 ''' </summary> ''' <param name="controlPointCount">控制点总数(>=2)</param> ''' <param name="degree">B样条阶数(>=1,常用3)</param> ''' <returns>Double() 节点数组</returns> Public Function GenerateOpenKnotVector(controlPointCount As Integer, degree As Integer) As Double() Dim n As Integer = controlPointCount - 1 ' 控制点索引上限 Dim m As Integer = n + degree + 1 ' 节点总数 = n+1 + degree+1 = n+degree+2 Dim knots(m) As Double ' 首p+1个节点为0 For i As Integer = 0 To degree knots(i) = 0.0 Next ' 尾p+1个节点为1 For i As Integer = m - degree To m knots(i) = 1.0 Next ' 中间节点:等距填充(也可按实际几何距离缩放) Dim interiorCount As Integer = m - 2 * degree - 1 If interiorCount > 0 Then For i As Integer = degree + 1 To m - degree - 1 knots(i) = (i - degree) / (interiorCount + 1) Next End If Return knots End Function

参数说明:controlPointCount必须≥2(至少2个点才能定义线段),degree推荐设为3(三次B样条),此时曲线具备C²连续性,视觉上最平滑;若需在某点制造尖角,可将该处节点重复度设为degree(如三次样条中设三重节点),但本例默认用开放型,保端点插值。

2.2 De Boor递推:用VB.NET二维数组实现基函数动态计算

B样条曲线在参数u处的点由公式给出:
C(u) = Σ N_{i,p}(u) × P_i
其中N_{i,p}(u)是第i个p阶基函数,需用De Boor算法递归计算。核心是:给定u,先定位其所在节点区间[u_k, u_{k+1}),再从0阶基函数(阶梯函数)向上递推至p阶。

''' <summary> ''' De Boor递推计算单个参数u处的基函数值向量 ''' </summary> ''' <param name="u">当前参数值(必须在有效节点区间内)</param> ''' <param name="knots">节点数组</param> ''' <param name="degree">阶数p</param> ''' <param name="startIndex">起始控制点索引(返回数组长度=degree+1)</param> ''' <returns>Double() 基函数权重数组,索引0对应P_startIndex</returns> Public Function DeBoorWeights(u As Double, knots() As Double, degree As Integer, ByRef startIndex As Integer) As Double() Dim n As Integer = knots.Length - 1 ' 节点索引上限 Dim p As Integer = degree ' Step 1: 定位u所在的节点跨度k(满足 u ∈ [u_k, u_{k+1}) ) Dim k As Integer = p While k < n AndAlso u >= knots(k + 1) k += 1 End While ' 此时u ∈ [u_k, u_{k+1}),有效基函数为N_{k-p,p} ~ N_{k,p},共p+1个 startIndex = k - p ' Step 2: 初始化0阶基函数(仅在[u_i, u_{i+1})为1) Dim d(p, p) As Double ' d[r, s] 表示r阶、s步递推的值 For i As Integer = 0 To p Dim idx As Integer = startIndex + i If u >= knots(idx) AndAlso u < knots(idx + 1) Then d(i, 0) = 1.0 Else d(i, 0) = 0.0 End If Next ' Step 3: 递推计算1阶到p阶 For r As Integer = 1 To p For i As Integer = 0 To p - r Dim leftDenom As Double = knots(startIndex + i + r) - knots(startIndex + i) Dim rightDenom As Double = knots(startIndex + i + r + 1) - knots(startIndex + i + 1) Dim alpha As Double = 0.0 If leftDenom <> 0.0 Then alpha = (u - knots(startIndex + i)) / leftDenom End If Dim beta As Double = 0.0 If rightDenom <> 0.0 Then beta = (knots(startIndex + i + r + 1) - u) / rightDenom End If d(i, r) = alpha * d(i, r - 1) + beta * d(i + 1, r - 1) Next Next ' 返回p阶结果:d(0,p) ~ d(p,p) 对应N_{k-p,p} ~ N_{k,p} Dim weights(p) As Double For i As Integer = 0 To p weights(i) = d(i, p) Next Return weights End Function

逻辑说明:此函数不返回曲线点,只返回p+1个基函数在u处的权重值。startIndex输出告诉调用方:这些权重分别乘以控制点P(startIndex)到P(startIndex + p)。注意分母为0的保护——当节点重复时(如u_k = u_{k+1}),对应项自然为0,不影响结果。这是非均匀性的数学体现:重复节点导致基函数支撑集收缩,从而降低连续性。


3. 曲线采样与WinForms绘制:把数学公式变成可交互的Graphics路径

有了基函数计算能力,下一步是生成足够密的点列供GDI+绘制。关键不是“越多越好”,而是自适应采样:曲率大的区域(如急弯)加密,平直段稀疏,既保证视觉质量又避免性能浪费。我们采用经典策略:对参数u做初始等距划分(如100段),计算相邻点距离,若距离超阈值则二分插入新点。

3.1 参数空间采样:用递归细分控制点密度

''' <summary> ''' 生成B样条曲线的采样点列表(自适应细分) ''' </summary> ''' <param name="controlPoints">控制点集合(PointF())</param> ''' <param name="knots">节点数组</param> ''' <param name="degree">阶数</param> ''' <param name="maxDistance">相邻采样点最大欧氏距离(像素)</param> ''' <returns>PointF() 采样点数组</returns> Public Function SampleBSpline(controlPoints() As PointF, knots() As Double, degree As Integer, maxDistance As Double) As PointF() Dim points As New List(Of PointF) Dim uMin As Double = knots(degree) ' 有效参数区间起点 Dim uMax As Double = knots(knots.Length - 1 - degree) ' 终点 ' 初始等距采样20点 Dim initialU As New List(Of Double) For i As Integer = 0 To 20 initialU.Add(uMin + (uMax - uMin) * i / 20) Next ' 递归细分 Dim allU As New List(Of Double)(initialU) Dim changed As Boolean = True While changed changed = False Dim newU As New List(Of Double) For i As Integer = 0 To allU.Count - 2 Dim u1 As Double = allU(i) Dim u2 As Double = allU(i + 1) Dim p1 As PointF = EvaluateBSpline(u1, controlPoints, knots, degree) Dim p2 As PointF = EvaluateBSpline(u2, controlPoints, knots, degree) Dim dist As Double = Math.Sqrt((p1.X - p2.X) ^ 2 + (p1.Y - p2.Y) ^ 2) If dist > maxDistance Then newU.Add(u1) newU.Add((u1 + u2) / 2) changed = True Else newU.Add(u1) End If Next newU.Add(allU.Last) allU = newU End While ' 生成最终点列 For Each u As Double In allU points.Add(EvaluateBSpline(u, controlPoints, knots, degree)) Next Return points.ToArray End Function ''' <summary> ''' 计算单个参数u处的曲线点 ''' </summary> Public Function EvaluateBSpline(u As Double, controlPoints() As PointF, knots() As Double, degree As Integer) As PointF Dim startIndex As Integer = 0 Dim weights() As Double = DeBoorWeights(u, knots, degree, startIndex) Dim px As Double = 0.0, py As Double = 0.0 For i As Integer = 0 To weights.Length - 1 Dim idx As Integer = startIndex + i If idx >= 0 AndAlso idx < controlPoints.Length Then px += weights(i) * controlPoints(idx).X py += weights(i) * controlPoints(idx).Y End If Next Return New PointF(CSng(px), CSng(py)) End Function

参数说明:maxDistance建议设为2~5像素(视显示DPI而定),过小会导致上千点拖慢绘制,过大则圆弧出现明显折角。EvaluateBSpline是核心评估函数,它调用DeBoorWeights获取权重,再线性组合控制点坐标——这就是B样条“凸包性质”的体现:结果必在当前活跃控制点构成的凸包内。

3.2 WinForms控件重绘:用GraphicsPath实现抗锯齿与填充

将采样点喂给GraphicsPath,就能享受GDI+的硬件加速抗锯齿和复杂填充:

Public Class BSplineControl Inherits Control Public Property ControlPoints As New List(Of PointF) Public Property Knots As Double() Public Property Degree As Integer = 3 Public Property MaxSampleDistance As Double = 3.0 Protected Overrides Sub OnPaint(e As PaintEventArgs) MyBase.OnPaint(e) If ControlPoints.Count < 2 Then Return ' 生成采样点 Dim points() As PointF = SampleBSpline(ControlPoints.ToArray, Knots, Degree, MaxSampleDistance) ' 构建GraphicsPath Using path As New GraphicsPath If points.Length > 0 Then path.StartFigure() path.AddLines(points) path.CloseFigure() ' 可选:闭合曲线 ' 抗锯齿绘制 e.Graphics.SmoothingMode = SmoothingMode.AntiAlias Using pen As New Pen(Color.Blue, 2.0F) e.Graphics.DrawPath(pen, path) End Using ' 可选:填充内部(需闭合) ' Using brush As New SolidBrush(Color.FromArgb(50, Color.LightBlue)) ' e.Graphics.FillPath(brush, path) ' End Using End If End Using End Sub End Class

提示:GraphicsPath.AddLines比循环DrawLine快一个数量级,且SmoothingMode.AntiAlias对曲线边缘的柔化效果远超TextRenderingHint.ClearTypeGridFit。若需高亮控制点,可在OnPaint末尾单独绘制小圆圈——但注意不要在AddLines前调用DrawEllipse,否则抗锯齿状态会被覆盖。


4. 避坑:非均匀B样条在VB.NET中踩过的5个真实坑

非均匀B样条看似只是数学公式翻译,但在VB.NET实操中,有5个坑会让调试耗掉整整两天——全是血泪经验,不是理论假设。

4.1 现象:曲线完全不显示,或只显示一个点

原因:节点矢量长度错误。常见误写knots.Length = n + degree(漏了+1),导致DeBoorWeights中k定位越界,startIndex为负,后续数组访问抛出IndexOutOfRangeException。但如果你用了Try/Catch吞掉异常,程序静默失败,只画出原点(0,0)。
解决:严格校验knots.Length = controlPoints.Length + degree + 1。在GenerateOpenKnotVector末尾加断言:Debug.Assert(knots.Length = controlPointCount + degree + 1)。

4.2 现象:曲线在端点处“翘起”,不经过首尾控制点

原因:未使用开放型节点,或节点重复度不足。例如7个点、3次样条,若节点设为{0,1,2,3,4,5,6,7}(均匀),则端点插值失效;或设为{0,0,0,1,2,3,4,4,4}(仅三重),但degree=3要求四重节点(p+1=4)才能强制C⁰连续。
解决:打印knots数组,确认首尾各有degree+1个相同值。用Debug.WriteLine(String.Join(", ", knots))比肉眼检查可靠十倍。

4.3 现象:拖动控制点时曲线剧烈抖动,像触电

原因:采样点未缓存,每次OnPaint都重新调用SampleBSpline。该函数含递归细分,100个控制点下可能生成2000+点,GraphicsPath构建成为性能瓶颈。
解决:在ControlPoints变更时(如Property Set中)预计算并缓存CachedPoints As PointF(),OnPaint直接消费。添加Invalidate()触发重绘即可。

4.4 现象:曲线在某段突然变直,像被截断

原因:u参数超出有效区间。EvaluateBSpline中u必须满足u ∈ [u_p, u_{m-p}),其中m = knots.Length-1。若传入u=0或u=1而节点非[0,0,0,...,1,1,1],DeBoorWeights的k定位失败,返回全零权重。
解决:在EvaluateBSpline开头加钳位:u = Math.Max(uMin, Math.Min(u, uMax)),其中uMin = knots(degree),uMax = knots(knots.Length - 1 - degree)。

4.5 现象:放大视图后曲线边缘出现“毛刺”,抗锯齿失效

原因:GraphicsPath未设置FillMode.Alternate或Winding,且路径自相交时填充规则混乱,导致GDI+渲染引擎放弃抗锯齿。更隐蔽的是,SmoothingMode在Control.CreateGraphics中无效,必须在PaintEventArgs.Graphics中设置。
解决:确保OnPaint中e.Graphics.SmoothingMode = SmoothingMode.AntiAlias在DrawPath前执行;若路径复杂,用path.Flatten()展平贝塞尔段后再绘制(Flatten会增加点数但提升一致性)。


5. 进阶技巧:实时拖拽控制点 + 局部细化,让B样条真正“活”起来

光会画静态曲线只是入门。真正的工程价值在于交互——让用户像操作Adobe Illustrator钢笔工具一样,拖拽控制点实时更新曲线,并在需要处插入新节点来增强局部控制力。这需要两个关键技术:控制点命中检测和节点插入算法。

5.1 控制点拖拽:用HitTest实现像素级精准拾取

在MouseDown中遍历所有控制点,计算鼠标到点的距离(考虑缩放):

Private dragIndex As Integer = -1 Private dragOffset As Point Protected Overrides Sub OnMouseDown(e As MouseEventArgs) MyBase.OnMouseDown(e) If e.Button <> MouseButtons.Left Then Return Dim pt As PointF = e.Location Dim hitRadius As Single = 8.0F ' 拾取半径(像素) For i As Integer = 0 To ControlPoints.Count - 1 Dim dist As Double = Math.Sqrt((pt.X - ControlPoints(i).X) ^ 2 + (pt.Y - ControlPoints(i).Y) ^ 2) If dist <= hitRadius Then dragIndex = i dragOffset = New Point(CInt(pt.X - ControlPoints(i).X), CInt(pt.Y - ControlPoints(i).Y)) Me.Capture = True Return End If Next End Sub Protected Overrides Sub OnMouseMove(e As MouseEventArgs) MyBase.OnMouseMove(e) If dragIndex >= 0 AndAlso Me.Capture Then Dim newX As Single = e.X - dragOffset.X Dim newY As Single = e.Y - dragOffset.Y ControlPoints(dragIndex) = New PointF(newX, newY) Me.Invalidate() ' 触发重绘 End If End Sub Protected Overrides Sub OnMouseUp(e As MouseEventArgs) MyBase.OnMouseUp(e) If dragIndex >= 0 Then dragIndex = -1 Me.Capture = False End If End Sub

技巧:hitRadius = 8是经验值,太小难点击,太大易误触。若控件支持缩放,需将e.Location除以缩放因子再计算距离。

5.2 节点插入:在参数u处增加一个节点,提升局部控制力

节点插入(knot insertion)是B样条的核心优势:不改变曲线形状,只增加一个控制点,让该区域更易调节。算法基于Boehm插入法,本质是将一个控制点拆分为两个,权重由节点位置决定。

''' <summary> ''' 在参数u处插入一个节点(不改变曲线形状) ''' </summary> ''' <param name="u">待插入的参数值(必须在有效区间内)</param> ''' <param name="controlPoints">原控制点列表</param> ''' <param name="knots">原节点数组</param> ''' <param name="degree">阶数</param> ''' <returns>新控制点列表与新节点数组</returns> Public Function InsertKnot(u As Double, controlPoints As List(Of PointF), knots() As Double, degree As Integer) As (NewControlPoints As List(Of PointF), NewKnots As Double()) ' Step 1: 找到u所在的节点跨度k Dim k As Integer = degree While k < knots.Length - 1 AndAlso u >= knots(k + 1) k += 1 End While ' Step 2: 计算需要修改的控制点索引范围 [k-degree, k] Dim startIdx As Integer = k - degree Dim endIdx As Integer = k ' Step 3: 复制原控制点,准备插入 Dim newCPs As New List(Of PointF)(controlPoints) Dim newKnots As Double() = New Double(knots.Length) {} Array.Copy(knots, newKnots, knots.Length) ' Step 4: 在knots中插入u(保持升序) Dim insertPos As Integer = k + 1 For i As Integer = newKnots.Length - 1 To insertPos Step -1 newKnots(i) = newKnots(i - 1) Next newKnots(insertPos) = u ' Step 5: Boehm插入:从高阶向低阶更新控制点 ' 创建临时数组存储新点 Dim tempCPs As PointF() = New PointF(degree) {} For i As Integer = 0 To degree tempCPs(i) = controlPoints(startIdx + i) Next ' 递推更新:r从1到degree For r As Integer = 1 To degree For i As Integer = degree DownTo r Dim alpha As Double = (u - knots(startIdx + i)) / (knots(startIdx + i + degree - r + 1) - knots(startIdx + i)) tempCPs(i) = New PointF( CSng((1 - alpha) * tempCPs(i - 1).X + alpha * tempCPs(i).X), CSng((1 - alpha) * tempCPs(i - 1).Y + alpha * tempCPs(i).Y) ) Next Next ' 插入新控制点tempCPs(degree)到startIdx+degree位置 newCPs.Insert(startIdx + degree, tempCPs(degree)) Return (newCPs, newKnots) End Function

使用场景:当用户右键点击曲线某处,调用InsertKnot(u, ...),其中u由鼠标位置反算(需参数化逆求解,此处简化为沿采样点线性插值得到近似u)。插入后,ControlPoints和Knots更新,Invalidate()重绘——用户立刻看到新增的控制点,且曲线形状丝毫不变,但该区域现在有了独立调节能力。

我当年在模拟项目X中实现数控刀具路径编辑时,就是靠这套节点插入机制,让客户能在0.1mm精度要求下,仅用3个控制点定义整段圆弧,再在切点处插入节点,单独调整过渡段曲率。没有它,就得堆20个点硬凑,修改时牵一发而动全身。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询