iOS 判断点在绘制曲线上的思路
写在前面
最近项目中需要实现画板功能,除了基本的绘制各种图形和曲线的功能,还需要在手指触摸屏幕的时候,判断手指是否在绘制的图形上,在的话就拖动该图形,否则就绘制新的图形,绘制的原理是UIBezierPath
+ CAShapeLayer
,所以判断点在图形上也就是判断点在图形对应的贝塞尔曲线上,对于闭合的贝塞尔曲线,我们更多的倾向于判断点在贝塞尔绘制的曲线内部,比如椭圆和矩形等,UIBezierPath
也提供了containsPoint:
API可以直接进行判断,很easy,但是对于手指画出的轨迹以及贝塞尔曲线等不闭合且不规则的曲线,判断点在其上就没那么简单了,我思考了不少时间才有了一个我认为比较好的思路,先看看图片效果:
1、点在手指绘制曲线上
点在手指绘制曲线上.gif2、点在二阶贝塞尔曲线上
点在二阶贝塞尔曲线上.gif方案
对于不闭合的曲线我觉得总结起来就只有两种:
1、通过若干个点连接起来组成的曲线,这只是视觉上的曲线,其实质是多条细小线段的组合,我们绘制手指轨迹也就是这样做的;
2、根据各种曲线公式绘制的曲线,比如二阶贝塞尔曲线以及N阶贝塞尔曲线,正弦函数等等
这两种情况基本上就概括了所有的情况了。
先来考虑第一种情况:我们的需求是判断点在这条曲线上,其实也就是判断点是否在构成曲线的任意一条小的线段上即可,其实也是判断点到线段的最小距离是否小于你所允许的一个值而已(这个值越大说明判断越松),既然要求最小距离,我们就需要用到点到直线的距离公式:
点到直线的距离公式
该公式表示了点(x0,y0)到直线方程Ax+By+C = 0 的距离。
具体步骤如下:
1、遍历构成曲线的所有点,并从第二个点开始和上一个点构成一条直线,已知直线两点,我们可以求出直线的一般式方程,进而求出ABC的值(也就是直线方程的两点式到一般式的转换)。
2、计算出ABC后即可将手指所在的点带入方程求出点到直线的距离,如果该距离大于你允许的一个值,则认为该点不在该线段上,否则进行进一步判断。
3、如果算出的距离小于你所你允许的值是不是一定就表示这个点在该线段上呢?答案也是不一定的,因为这个值只是点到直线的距离而并非最小距离,此时我们需要考虑该点的投影点是否在线段上,如果在线段上该距离就是最短距离,如果不在线段上,这个距离则并非最短距离,最短距离应该由该点和靠近该点的线段的端点构成,所以我们需要做这个判断才对,这样我们就成功的判断好了,一旦我们检测到了点在某条线段上就可以跳出循环遍历,肯定点在这条曲线上咯!代码如下:
/**
*判断点point是否在p0 和 p1两点构成的线段上
*/
- (BOOL)_xw_point:(CGPoint)point isInLineByTwoPoint:(CGPoint)p0 p1:(CGPoint)p1{
//先设置一个所允许的最大值,点到线段的最短距离小于该值说明点在线段上
CGFloat maxAllowOffsetLength = 15;
//通过直线方程的两点式计算出一般式的ABC参数,具体可以自己拿起笔换算一下,很容易
CGFloat A = p1.y - p0.y;
CGFloat B = p0.x - p1.x;
CGFloat C = p1.x * p0.y - p0.x * p1.y;
//带入点到直线的距离公式求出点到直线的距离dis
CGFloat dis = fabs((A * point.x + B * point.y + C) / sqrt(pow(A, 2) + pow(B, 2)));
//如果该距离大于允许值说明则不在线段上
if (dis > maxAllowOffsetLength || isnan(dis)) {
return NO;
}else{
//否则我们要进一步判断,投影点是否在线段上,根据公式求出投影点的X坐标jiaoX
CGFloat D = (A * point.y - B * point.x);
CGFloat jiaoX = -(A * C + B *D) / (pow(B, 2) + pow(A, 2));
//判断jiaoX是否在线段上,t如果在0~1之间说明在线段上,大于1则说明不在线段且靠近端点p1,小于0则不在线段上且靠近端点p0,这里用了插值的思想
CGFloat t = (jiaoX - p0.x) / (p1.x - p0.x);
if (t > 1 || isnan(t)) {
//最小距离为到p1点的距离
dis = XWLengthOfTwoPoint(p1, point);
}else if (t < 0){
//最小距离为到p2点的距离
dis = XWLengthOfTwoPoint(p0, point);
}
//再次判断真正的最小距离是否小于允许值,小于则该点在直线上,反之则不在
if (dis <= maxAllowOffsetLength) {
return YES;
}else{
return NO;
}
}
}
//这里是求两点距离公式
static inline CGFloat XWLengthOfTwoPoint(CGPoint point1, CGPoint point2){
return sqrt(pow(point1.x - point2.x, 2) + pow(point1.y - point2.y, 2));
}
可以看到代码的实质就是一步一步根据公式计算出结果而已,如果能够搞清楚公式,代码也就简单了。
再来看看第二种情况:这里我们并不知道构成曲线的所有点,但是我们知道曲线的公式,刚开始我是想通过直接计算曲线方程的方法来求解,但发现这些高阶的曲线方程的求解对我来说完全是不可能的,而且各种曲线的方程不同,求解也各异,所以我在想要能用第一种情况的方法去解决该问题就好了,那我们就要取得构成曲线的点,我们可以使用插值思想,通过一个循环来取点,取多少个点就看需求了,下面以二阶贝塞尔曲线举例子,二阶贝塞尔的公式如下:
二阶贝塞尔曲线//我们首先提供一个函数,将上述公式转换成代码
static inline CGPoint XWPointOnPowerCurveLine(CGPoint p0, CGPoint p1, CGPoint p2, CGFloat t){
CGFloat x = (pow(1 - t, 2) * p0.x + 2 * t * (1 - t) * p1.x + pow(t, 2) * p2.x);
CGFloat y = (pow(1 - t, 2) * p0.y + 2 * t * (1 - t) * p1.y + pow(t, 2) * p2.y);
return CGPointMake(x, y);
}
/**
判断点在二阶贝塞尔曲线上
*/
- (BOOL)_xw_containsPointForCurveLineType:(CGPoint)point{
CGPoint p0 = _startPoint;//我是贝塞尔曲线的起始点
CGPoint p1 = _allPoints.firstObject.CGPointValue;//我是贝塞尔曲线终点
CGPoint p2 = _allPoints.lastObject.CGPointValue;//控制点
CGPoint tempPoint1 = p0;记录采样的每条线段起点,第一次起点就是p0
CGPoint tempPoint2 = CGPointZero;记录采样线段终点
//这里我取了100个点,基本上满足要求了
for (int i = 1; i < 101; i ++) {
//计算出终点
tempPoint2 = XWPointOnPowerCurveLine(p0, p1, p2, i / 100.0f);
//调用我们解决第一种情况的方法,判断点是否在这两点构成的直线上
if ([self _xw_point:point isInLineByTwoPoint:tempPoint1 p1:tempPoint2]) {
//如果在可以认为点在这条贝塞尔曲线上,直接跳出循环返回即可
return YES;
}
//如果不在则赋值准备下一次循环
tempPoint1 = tempPoint2;
}
return NO;
}
采用这样的插值取点的思路,对于任何的曲线都能够很轻松的转换成第一种方式求解了,而且你并不需要理解这条曲线公式,只需要对其插值求出一系列的点即可,比如对于一个圆X² + Y² = 100
,你只需要在对X在0~10之间进行插值就能取出构成原的点,然后就可以按照同样的方式判断点是否在圆上而不是圆内了!
写在最后
由于本篇文章的主要是围绕这一系列数学公式展开的,所以不熟悉公式可能会有点懵,不过只要明白思路就差不多了,其实这些公式都是我们在初高中烂熟于心的数学公式咯,只不过很多人和我一样丢的差不多了吧,对于我们程序猿来说,多去思考和学习一些数学的思路和想法还是很有帮助的,下一步准备把这个画图控件封装好总结总结,不知道又是啥时候咯o(╯□╰)o!