我们的目标是类似 sketch 的曲线点编辑功能。
1. 分析
如图,这是一个3个点的曲线,每个点都提供了两个控制把柄,可以拖拽其编辑曲线。
(本来想传gif动画,但太大了传不上去)
要实现该逻辑,我们有以下几个任务
- 定义曲线点
- 定义曲线
- 绘制曲线
- 绘制控制把柄
- 与鼠标交互,可拖拽曲线点及控制把柄
2. 曲线的绘制
绘制曲线段很容易实现,用GDI+ 的 Graphics.DrawBazier() 方法就行了。其原型是:
// p1、p2 是曲线段终点,ctrl1和ctrl2是两个控制点
void DrawBazier(Point p1, Point ctl1, Point ctrl2, Point p2);
一段Bazier(B样条)曲线由2个端点,2个控制点实现。以下为简图:
(p1, ctrl1)--------(p2, ctrl2)
而多段段曲线无非就是把这些曲线连接起来。要实现这个逻辑,每个曲线点就需要两个控制点,一个是绘制前面的曲线段用的,一个是绘制后面的曲线段用的。以3个点的曲线为例,其简图如下:
(p1, p1.NextCtrl)--------(p2, p2.PrevCtrl)(p2, p2.NextCtrl)--------(p3,p3.PrevCtrl)
那么我们可以定义曲线的点结构体为:
public class BPoint
{
/// <summary>曲线上的点</summary>
public PointF Point { get; set; }
/// <summary>前控制点</summary>
public PointF PrevCtrl { get; set; }
/// <summary>后控制点</summary>
public PointF NextCtrl { get; set; }
}
而曲线的定义也很简单:
public class BSpline
{
public List<BPoint> Points { get; set; }
// 绘制
public void Draw(Graphics g){}
// 点击判断
public void HitTest(PointF point) {}
}
根据前面的分析,我们可以很容易的遍历曲线点,然后把曲线给画出来。
3. 用鼠标与曲线点交互
要实现该目标,我们分解一下任务
- 鼠标按下后,对曲线进行点击判断(HitTest),判断是哪个部分被点中了,记录下来,并重绘曲线。
- 拖动鼠标时,将被点中的部分移动到光标位置上。重绘曲线。
- 绘制曲线时,根据点中的部分,绘制相应的控制句柄及连线。
涉及到的技术点是:
- 如何判断点被点中:这个容易,让点膨胀成矩形,判断是否容纳了光标就行。
- 如何判断曲线被点中:用Path对象,添加曲线,并膨胀一下,转化为Region,判断Region是否容纳了光标。
- 如何不闪烁:用双缓冲即可。
中间过程懒得写(其实是没留档),看代码吧:
点的代码:
public class BPoint
{
/// <summary>曲线上的点</summary>
public PointF Point { get; set; }
/// <summary>前控制点</summary>
public PointF PrevCtrl { get; set; }
/// <summary>后控制点</summary>
public PointF NextCtrl { get; set; }
/// <summary>选中的部分</summary>
public BPointPart HitPart { get; set; } = BPointPart.None;
/// <summary>构造函数</summary>
public BPoint(PointF point, PointF prevCtrl, PointF nextCtrl)
{
this.Point = point;
this.PrevCtrl = prevCtrl;
this.NextCtrl = nextCtrl;
}
/// <summary>测试点击的位置(并设置SelectedPart值)</summary>
/// <param name="ctrls">需要测试的控制部件</param>
public void HitTest(PointF p, BPointPart ctrls=BPointPart.PrevCtrl | BPointPart.NextCtrl)
{
this.HitPart = BPointPart.None;
if (HitPoint(this.Point, p))
this.HitPart = BPointPart.Point;
if (ctrls.HasFlag(BPointPart.PrevCtrl) && HitPoint(this.PrevCtrl, p))
this.HitPart = BPointPart.PrevCtrl;
if (ctrls.HasFlag(BPointPart.NextCtrl) && HitPoint(this.NextCtrl, p))
this.HitPart = BPointPart.NextCtrl;
}
/// <summary>测试点是否被点中</summary>
/// <param name="target">目标点</param>
/// <param name="cursor">光标点</param>
static bool HitPoint(PointF target, PointF cursor)
{
var rect = new RectangleF(target, SizeF.Empty);
rect.Inflate(5, 5);
return rect.Contains(cursor);
}
/// <summary>绘制贝塞尔曲线控制点</summary>
/// <param name="ctrls">要显示的控制点</param>
public void Draw(Graphics g, BPointPart ctrls = BPointPart.PrevCtrl | BPointPart.NextCtrl)
{
var s = 4; // 点宽度
var pen = new Pen(Color.Black);
if (HitPart == BPointPart.None)
{
Painter.DrawDot(g, this.Point, s, Color.Black, false);
}
else
{
if (ctrls.HasFlag(BPointPart.PrevCtrl))
{
g.DrawLine(pen, this.Point, this.PrevCtrl);
Painter.DrawDot(g, this.PrevCtrl, s, Color.Black, HitPart == BPointPart.PrevCtrl);
}
if (ctrls.HasFlag(BPointPart.NextCtrl))
{
g.DrawLine(pen, this.Point, this.NextCtrl);
Painter.DrawDot(g, this.NextCtrl, s, Color.Black, HitPart == BPointPart.NextCtrl);
}
Painter.DrawDot(g, this.Point, s, Color.Black, HitPart == BPointPart.Point);
}
}
}
/// <summary>B样条曲线点的组成部分(点击判断时用)</summary>
[Flags]
public enum BPointPart
{
None = 0,
Point = 1,
PrevCtrl = 2,
NextCtrl = 4,
}
曲线的代码:
/// <summary>
/// B 样条曲线
/// </summary>
public class BSpline
{
/// <summary>包含的曲线点</summary>
public List<BPoint> Points { get; set; }
/// <summary>选中的线段编号</summary>
public int SelectedSeg { get; set; } = -1;
/// <summary>创建新 B 样条曲线</summary>
public BSpline(List<BPoint> points)
{
this.Points = points;
}
/// <summary>点击测试(会设置每个点被选中的部件、被选中的曲线段)</summary>
public void HitTest(PointF point)
{
// 测试点
foreach (var p in Points)
p.HitTest(point);
Points.First().HitTest(point, BPointPart.NextCtrl);
Points.Last().HitTest(point, BPointPart.PrevCtrl);
// 测试曲线段
this.SelectedSeg = HitTestSeg(point);
}
/// <summary>测试点中了哪条曲线</summary>
int HitTestSeg(PointF point)
{
for (int i = 0; i < Points.Count - 1; i++)
{
var p1 = Points[i];
var p2 = Points[i + 1];
var path = new GraphicsPath();
path.AddBezier(p1.Point, p1.NextCtrl, p2.PrevCtrl, p2.Point);
path.Widen(new Pen(Color.Black, 4));
var region = new Region(path);
if (region.IsVisible(point))
return i;
}
return -1;
}
/// <summary>绘制</summary>
public void Draw(Graphics g, Pen penNormal, Pen penSelected)
{
// 绘制曲线
for (int i = 0; i < Points.Count - 1; i++)
{
var p1 = Points[i];
var p2 = Points[i + 1];
var pen = (SelectedSeg == i) ? penSelected : penNormal;
g.DrawBezier(pen, p1.Point, p1.NextCtrl, p2.PrevCtrl, p2.Point);
}
// 绘制点(包括句柄)
for (int i = 0; i < Points.Count; i++)
{
var p = Points[i];
if (i == 0)
p.Draw(g, BPointPart.NextCtrl); // 第一个点只绘制后句柄
else if (i == Points.Count - 1)
p.Draw(g, BPointPart.PrevCtrl); // 最后一个点只绘制前句柄
else
p.Draw(g);
}
}
}
窗体交互的代码:
public partial class Form1 : Form
{
BSpline _curve = new BSpline(new List<BPoint>()
{
new BPoint(new PointF(50, 50), new PointF(50, 50), new PointF(150, 50)),
new BPoint(new PointF(100, 100), new PointF(50, 100), new PointF(150, 100)),
new BPoint(new PointF(200, 200), new PointF(150, 200), new PointF(200, 200)),
});
public Form1()
{
InitializeComponent();
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.DoubleBuffer, true); // 双缓冲绘图
}
/// <summary>根据贝塞尔曲线数据绘制曲线</summary>
private void Form1_Paint(object sender, PaintEventArgs e)
{
var g = e.Graphics;
g.SmoothingMode = SmoothingMode.AntiAlias;
var pen1 = new Pen(Color.Black);
var pen2 = new Pen(Color.Blue);
_curve.Draw(g, pen1, pen2);
}
/// <summary>鼠标按下时,测试点击位置并记录下来</summary>
private void Form1_MouseDown(object sender, MouseEventArgs e)
{
_curve.HitTest(e.Location);
Refresh();
}
/// <summary>鼠标按下并移动时,设置选中的控制点位置等于当前光标位置</summary>
private void Form1_MouseMove(object sender, MouseEventArgs e)
{
if (e.Button != MouseButtons.Left)
return;
// 拖动中心点、前控制点、后控制点
foreach (var p in _curve.Points)
{
if (p.HitPart == BPointPart.None)
continue;
if (p.HitPart == BPointPart.Point)
{
var dx = e.Location.X - p.Point.X;
var dy = e.Location.Y - p.Point.Y;
p.Point = e.Location;
p.PrevCtrl = new PointF(p.PrevCtrl.X + dx, p.PrevCtrl.Y + dy);
p.NextCtrl = new PointF(p.NextCtrl.X + dx, p.NextCtrl.Y + dy);
}
else if (p.HitPart == BPointPart.PrevCtrl)
p.PrevCtrl = e.Location;
else if (p.HitPart == BPointPart.NextCtrl)
p.NextCtrl = e.Location;
}
Refresh();
}
/// <summary>鼠标提起时</summary>
private void Form1_MouseUp(object sender, MouseEventArgs e)
{
Refresh();
}
}
这是源代码:https://download.csdn.net/download/surfsky/12654549
转载请标明出处:https://blog.csdn.net/surfsky/article/details/107533869