Flutter 绘制番外篇 - 数学中的角度知识

前言


对一些有趣的绘制技能知识, 我会通过 [番外篇] 的形式加入《Flutter 绘制指南 - 妙笔生花》小册中,一方面保证小册的“与时俱进”“活力”。另一方面,是为了让一些重要的知识有个 好的归宿。普通文章就像昙花一现,不管多美丽,终会被时间泯灭。



另外 [番外篇] 的文章是完全公开免费的,也会同时在普通文章中发表,且 [番外篇] 会在普通文章发布三日后入驻小册,这样便于错误的暴露收集建议反馈。本文作为 [番外篇] 之一,主要来探讨一下角度坐标 的知识。




一、两点间的角度


你有没有想过,两点之间的角度如何计算。比如下面的 p0p1 点间的角度,也就是两点之间的斜率。这上过初中的人都知道,使用 反三角函数 算一下就行了。那其中有哪些坑点要注意呢,下面一方面学知识,一方面练画技,一起画画吧!





1. 把线信息画出来

首先来画出如下效果,点 p0(0,0) ;点 p1(60,60)



为了方便数据管理,将起止点封装在 Line 类中。其中黑色部分的线体Line 类承担,这样在就能减少画板的绘制逻辑。


class Line {
Line({
this.start = Offset.zero,
this.end = Offset.zero,
});

Offset start;
Offset end;

final Paint pointPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1;

void paint(Canvas canvas){
canvas.drawLine(Offset.zero, end, pointPaint);
drawAnchor(canvas,start);
drawAnchor(canvas,end);
}

void drawAnchor(Canvas canvas, Offset offset) {
canvas.drawCircle(offset, 4, pointPaint..style = PaintingStyle.stroke);
canvas.drawCircle(offset, 2, pointPaint..style = PaintingStyle.fill);
}
}



画板是 AnglePainter ,其中虚线通过我的 dash_painter 库进行绘制,定义 line 对象之后,在 paint 方法中通过 line.paint(canvas); 即可绘制黑色的线体部分,蓝色的辅助信息通过 drawHelp 进行绘制。这样通过改变 line 对象的点位就可以改变线体绘制,如下是 p1 点变化对应的绘制表现:



















p1(60,60)p1(60,-80)p1(-60,-80)p1(-60,80)
image-20210902212856156image-20210902212949081

class AnglePainter extends CustomPainter {
// 绘制虚线
final DashPainter dashPainter = const DashPainter(span: 4, step: 4);

final Paint helpPaint = Paint()
..style = PaintingStyle.stroke..color = Colors.lightBlue..strokeWidth = 1;

final TextPainter textPainter = TextPainter(
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
);

Line line = Line(start: Offset.zero, end: const Offset(60, 60));

@override
void paint(Canvas canvas, Size size) {
canvas.translate(size.width / 2, size.height / 2);
drawHelp(canvas, size);
line.paint(canvas);
}

void drawHelp(Canvas canvas, Size size) {
Path helpPath = Path()
..moveTo(-size.width / 2, 0)
..relativeLineTo(size.width, 0);
dashPainter.paint(canvas, helpPath, helpPaint);
drawHelpText('0°', canvas, Offset(size.width / 2 - 20, 0));
drawHelpText('p0', canvas, line.start.translate(-20, 0));
drawHelpText('p1', canvas, line.end.translate(-20, 0));
}

void drawHelpText( String text, Canvas canvas, Offset offset, {
Color color = Colors.lightBlue
}) {
textPainter.text = TextSpan(
text: text,
style: TextStyle(fontSize: 12, color: color),
);
textPainter.layout(maxWidth: 200);
textPainter.paint(canvas, offset);
}

@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return false;
}
}



2.角度计算

Flutter 中的 Offset 对象有 direction 属性,它是通过 atan2 反正切函数进行计算的。下面来看一下通过 direction 属性获取的角度特点。


class Line {
// 略同...

double get rad => (end-start).direction;
}

---->[源码: Offset#direction]----
double get direction => math.atan2(dy, dx);

下面将计算出的弧度,转化为角度值,标注在左上角。源码中对 direction 属性的介绍是:
x 轴右向为正,y 轴向下为正的坐标系下,该偏移角度以是从 x 正轴顺时针方向偏移弧度,范围在 [-pi,pi] 之间。也就是说,x 轴的上部分的角度是负值 ,如下面的 34 图所示。



















p1(60,60)p1(-60,80)p1(-60,-60)p1(60,-80)

drawHelpText(
'角度: ${(line.rad * 180 / pi).toStringAsFixed(2)}°',
canvas,
Offset(
-size.width / 2 + 10,
-size.height / 2 + 10,
),
);
复制代码



这里角度在 [-pi,pi] 之间,那我们能不能让它在 [0,2*pi] 之间呢?这样比较符合 0~360° 的常归认识。其实很简单,如果为负,加个 2*pi 就行了,如下 positiveRad 的处理。


---->[Line]----
double get rad => (end - start).direction;

double get positiveRad => rad < 0 ? 2 * pi + rad : rad;



3.角度的使用

现在来做一个小案例,如下:通过两点间的角度来决定矩形旋转的角度,使用动画将 p1 点绕 p0 做圆周运动。由于两点的角度变化,矩形也会伴随旋转。



为了让 Line 的变化方便通知画板进行更新,这里让它继承自 ChangeNotifier ,成为可监听对象。并给出一个 rotate 方法,传入角度来更新坐标。这里为了方便,先以 0,0 为起点,只变更 end 坐标,已知 p1 做圆周运动,所以两点间距离不变,又知道了旋转角度,那 p1 在旋转 rad 时,p1 的坐标就很容易得出:



class Line with ChangeNotifier {
// 略同...

double get length => (end - start).distance;

void rotate(double rad) {
end = Offset(length * cos(rad), length * sin(rad));
notifyListeners();
}
}



上面实现了椭圆的角度伴随运动,那想一下,如何动态绘制如下的线与水平正方向的圆弧呢?



其实很简单,我们已经知道了角度值,通过 canvas.drawArc 就可以根据先的角度绘制圆弧。


---->[AnglePainter#drawHelp]----
canvas.drawArc(
Rect.fromCenter(center: Offset.zero, width: 20, height: 20),
0,
line.positiveRad,
false,
helpPaint,
);



4. 点任意的绕点旋转

其实刚才的圆周运动是一个及其特殊的情况,也就是线的起点在原点,且初始夹角为 0。这样在坐标计算时,不必考虑初始角度的影响。但对于一般场合,上面的运算方式会出现错误。那如何实现 p0 点的任意呢?其实这就是移到简单的初中数学题:



已知: p0(a,b)、p1(c,d),求 p1 绕 p0 顺时针旋转 θ 弧度后得到 p1' 点。
求: p1' 点的坐标。

 其实算起来很简单,如下,旋转了 θ 弧度后得到 p1' 。以 p0 为参考系原点的话,p1' 的坐标呼之欲出。


令两点间角度为 rad, 两点间距离为 length, 则: 
p1': (length*cos(rad+θ),length*sin(rad+θ))

已知 p0 坐标为 start,则以 (0,0) 为坐标系,则
p1': (length*cos(rad+θ),length*sin(rad+θ)) + start


由于 rotate 参数是总的旋转角度,而rotate 方法每次触发都会更新 end 的坐标,所以 rad 会不断更新,我们需要处理的是每次动画触发间的旋转角度,即下面的 detaRotate 。本案例完整源码见: rad_rotate


double detaRotate = 0;
void rotate(double rotate) {
detaRotate = rotate - detaRotate;
end = Offset(
length * cos(rad + detaRotate),
length * sin(rad + detaRotate),
) +
start;
detaRotate = rotate;
notifyListeners();
}



二、你的点又何须是点


也许上面在你眼中,这些只是点的运算而已,但在我眼中,它们是一种约束绑定关系,因为运算本身就是约束法则。两个点数据构成一种结构,一种骨架,那你所见的点,又何须是点呢?




1. 绘制箭头

如下,是绘制箭头的案例:界面上所展现的,是Line#paint 方法绘制的内容,只要通过两个点所提供的信息,绘制出箭头即可。绘制逻辑是:先画一个水平箭头,再根据旋转角度,绕 p0 旋转。



void paint(Canvas canvas) {
canvas.save();
canvas.translate(start.dx, start.dy);
canvas.rotate(positiveRad);
Path arrowPath = Path();
arrowPath
..relativeLineTo(length - 10, 3)
..relativeLineTo(0, 2)
..lineTo(length, 0)
..relativeLineTo(-10, -5)
..relativeLineTo(0, 2)..close();
canvas.drawPath(arrowPath,pointPaint);
canvas.restore();
}

这样,点位数据的变化,同样可以驱动绘制的变化。本案例完整源码见: arrow





2. 绘制图片

如下是一张图片,现在通过 PS 获取胳膊的区域数据:0, 93, 104, 212 。左上角和左下角两点构成直线,如果我们根据点的位置信息,来绘制图片会怎么样呢?



为了储存图片和区域信息,下面定义 ImageZone 对象,在构造中传入图片 image 和区域 rect 。另外通过 imagerect ,我们可以算出以图片中心为原点,左上角和左下角对应坐标构成的线对象


import 'dart:ui';
import 'line.dart';

class ImageZone {
final Image image;
final Rect rect;

Line? _line;

ImageZone({required this.image, this.rect = Rect.zero});

Line get line {
if (_line != null) {
return _line!;
}
Offset start = Offset(
-(image.width / 2 - rect.right), -(image.height / 2 - rect.bottom));
Offset end = start.translate(-rect.width, -rect.height);
_line = Line(start: start, end: end);
return _line!;
}
}



ImageZone 中定义一个 paint 方法,通过 canvasline 进行图片的绘制。这样方便在 Line 类中进行图片绘制,简化 Line 的绘制逻辑。


---->[ImageZone]----
void paint(Canvas canvas, Line line) {
canvas.save();
canvas.translate(line.start.dx, line.start.dy);
canvas.rotate(line.positiveRad - this.line.positiveRad);
canvas.translate(-line.start.dx, -line.start.dy);
canvas.drawImageRect(
image,
rect,
rect.translate(-image.width / 2, -image.height / 2),
imagePaint,
);
canvas.restore();
}



Line 类中,添加一个 attachImage 方法,将 ImageZone 对象关联到 Line对象上。在 paint中只需要通过 _zone 对象进行绘制即可。


---->[Line]----
class Line with ChangeNotifier {
// 略同...

ImageZone? _zone;

void attachImage(ImageZone zone) {
_zone = zone;
start = zone.line.start;
end = zone.line.end;
notifyListeners();
}

void paint(Canvas canvas) {
// 绘制箭头略....
_zone?.paint(canvas, this);
}

这样我们就可以将图片的某个矩形区域 附魔 到一个线段上。手的图片通过 _loadImage 来加载,并通过 attachImage 方法为 line 对象 附魔



void _loadImage() async {
ByteData data = await rootBundle.load('assets/images/hand.png');
List<int> bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
_image = await decodeImageFromList(Uint8List.fromList(bytes));
line.attachImage(ImageZone(
rect: const Rect.fromLTRB(0, 93, 104, 212),
image: _image!,
));
}

同样,可以让线段绕起点进行旋转,如下的挥手动作。



void _updateLine() {
line.rotate(ctrl.value * 2* pi/50);
}

将背景图片进行绘制,就可以得到一个完整的效果。本案例完整源码见: body





三、线绕任意点旋转


下面我们来如何让已知线段按照某个点,进行旋转,这个问题等价于:


已知,p0、p1、p2点坐标,线段 p0、p1 绕 p2 顺时针旋转 θ 弧度后的到 p0'、p1'。
求:p0'、p1' 坐标。




1.问题分析

由于两点确定一条直线,线段 p0、p1p2旋转,等价于 p0p1 分别绕 p2 旋转。示意图如下:



对应于代码,就是在 rotate 方法中,传入一个坐标 centre ,根据该坐标和旋转角度,对 p0p1 点进行处理,得到新的点。


void rotate(double rotate,{Offset? centre}) {
//TODO
}



2.解决方案和代码处理

之前已经处理了绕起点旋转的逻辑,这里我们可以用一个非常巧妙的方案:


求 p0’ 的坐标,可以构建 p2,p0 线段,让该线段执行旋转逻辑,其 end 坐标即是 p0’。
求 p1’ 的坐标,可以构建 p2,p1 线段,让该线段执行旋转逻辑,其 end 坐标即是 p1’。

思路有了,下面来看一下代码的实现。前面实现的 绕起点旋转 封装到 _rotateByStart 方法中。


---->[Line]----
void _rotateByStart(double rotate) {
end = Offset(
length * cos(rad + rotate),
length * sin(rad + rotate),
) +
start;
}



外界可调用的的 rotate 方法,可以传入 centre 点,如果为空就以起点为旋转中心。下面 tag1tag2 出分别构建 p2p0p2p1 线段。之后两条线旋转即可获得我们期望的 p0’ p1’ 坐标。



double detaRotate = 0;

void rotate(double rotate, {Offset? centre}) {
detaRotate = rotate - detaRotate;
centre = centre ?? start;
Line p2p0 = Line(start: centre, end: start); // tag1
Line p2p1 = Line(start: centre, end: end); // tag2
p2p0._rotateByStart(detaRotate);
p2p1._rotateByStart(detaRotate);
start = p2p0.end;
end = p2p1.end;
detaRotate = rotate;
notifyListeners();
}



3.线段分度值出坐标

现在有个需求,计算线段 percent 分率处点的坐标。比如 0.5 就线段中间的坐标,0.4 就是距离顶点长 40% 线长位置的坐标。效果如下:

















0.20.50.8
image-20210907085552225

其实思路很简单,既然点在线上,那么斜率是不变的,只是长度发生变化,根据斜率长度即可求出坐标值,代码实现如下:


Offset percent(double percent){
return Offset(
length*percent*cos(rad),
length*percent*sin(rad),
)+start;
}



前面说过了线的,绕点旋转。现在已知分度值处的坐标,就可以很轻松地实现 线绕分度锚点旋转。本案例完整源码见: rotate_by_point





本文中的点线操作,都是对坐标本身的数据进行修改系。比如在旋转时,线对应的角度值是真实的。这种基于逻辑运算的数据驱动方式,可以进行一些很有意思的操作,更容易让数据间进行 联动 。另外,本文仅仅是两个点组成线 的简单研究。多个线的组合、约束也许会打开一个新世界的大门。相关以后有机会再深入研究一下,分享给大家。


那这里本文想介绍的内容就差不多了,谢谢观看,拜拜~




作者:张风捷特烈
链接:https://juejin.cn/post/7005347352798035999
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

0 个评论

要回复文章请先登录注册