【Flutter】アフィン変換で星をまわせ
作ったもの
環境
マシン: M1 MacBook Air
エディタ: VSCode
リポジトリ: https://github.com/kenta-wakasa/flutter_playground
ざっくりアフィン変換の話
アフィン変換を簡単に式で表すとこのようなことになっています。
これがなにを示しているかというと、
コンピュータグラフィックの世界では、このありがたい数式によって、図形を拡大したり回転したり平行移動したりすることができます。
たとえば、( 1 ) の式を x 座標, y 座標にあてはめて考えると次のようになります。
これを行列で書くとこんな感じです。自分で展開してみるとよくわかると思います。
この
となっています。
いわゆる回転行列というやつです。
ここに定数をかけてやれば
つまり定数を
あとはこれをコードに落とし込むだけ!
書いたソースコード
アフィン変換はこうなります。
といっても、拡大、縮小、回転、平行移動だけで、せん断とかはやっていません。
Offset _affinTranslate(
Offset pos, {
double radians = 0,
double scale = 1.0,
double tx = 0,
double ty = 0,
}) {
final dx = scale * (pos.dx * cos(radians) - pos.dy * sin(radians)) + tx;
final dy = scale * (pos.dx * sin(radians) + pos.dy * cos(radians)) + ty;
return Offset(dx, dy);
}
コントローラー
星を書いているのでその部分も何かの参考になればと思います。
コードで図形を描くの楽しいですね。
import 'dart:math';
import 'package:flutter/material.dart';
class CanvasController extends ChangeNotifier {
CanvasController() {
_radians = 0;
_scale = 1;
_tx = 0;
_ty = 0;
_initOffset = const Offset(0, -10); // 星型の最初の頂点を決める。
_sourceOffsetList.add(_initOffset);
// 星型は同一円の円周上を 4π/5 ずつ回転させた点を結ぶことで描ける。
for (var radians = 0.0; radians <= 4 * pi; radians += 4 * pi / 5) {
_sourceOffsetList.add(_affinTranslate(_initOffset, radians: radians));
}
_destinationOffsetList = _sourceOffsetList;
}
double _radians;
double get radians => _radians;
set radians(double radians) {
_radians = radians;
_update();
}
double _scale;
double get scale => _scale;
set scale(double scale) {
_scale = scale;
_update();
}
double _tx;
double get tx => _tx;
set tx(double tx) {
_tx = tx;
_update();
}
double _ty;
double get ty => _ty;
set ty(double ty) {
_ty = ty;
_update();
}
Offset _initOffset;
final _sourceOffsetList = <Offset>[];
List<Offset> _destinationOffsetList;
List<Offset> get destinationOffsetList => _destinationOffsetList;
/// 拡大・回転・平行移動のアフィン変換は次のようになっている
/// ```
/// dx = scale * ( x*cosθ - y*sinθ ) + tx
/// dy = scale * ( x*sinθ + y*cosθ ) + ty
/// ```
Offset _affinTranslate(
Offset pos, {
double radians = 0,
double scale = 1.0,
double tx = 0,
double ty = 0,
}) {
final dx = scale * (pos.dx * cos(radians) - pos.dy * sin(radians)) + tx;
final dy = scale * (pos.dx * sin(radians) + pos.dy * cos(radians)) + ty;
return Offset(dx, dy);
}
/// すべての offset に対してアフィン変換をかける
void _update() {
_destinationOffsetList = _sourceOffsetList
.map((s) =>
_affinTranslate(s, radians: radians, scale: scale, tx: tx, ty: ty))
.toList();
notifyListeners();
}
}
ページ
スライダーとかテキストボックスで入力をやっています。
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'canvas_controller.dart';
import 'painter.dart';
final canvasProvider = ChangeNotifierProvider.autoDispose<CanvasController>(
(ref) => CanvasController(),
);
class CanvasPage extends ConsumerWidget {
const CanvasPage({Key key}) : super(key: key);
static const String title = 'アフィン変換';
static const textStyle = TextStyle(fontSize: 24, fontWeight: FontWeight.bold);
Widget build(BuildContext context, ScopedReader watch) {
final _provider = watch(canvasProvider);
return Scaffold(
appBar: AppBar(
title: const Text(title,
style: TextStyle(fontWeight: FontWeight.bold))),
body: SafeArea(
child: Stack(children: [
Center(
child: CustomPaint(
painter: Painter(offsetList: _provider.destinationOffsetList)),
),
Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: 80,
width: 300,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Slider(
onChanged: (value) => _provider.radians = value,
value: _provider.radians,
min: 0,
max: 2 * pi),
const Text('Rotation', style: textStyle)
])),
SizedBox(
height: 80,
width: 300,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Slider(
value: _provider.scale,
onChanged: (value) => _provider.scale = value,
min: 1,
max: 20),
const Text('Scale', style: textStyle)
]))
]),
Align(
alignment: Alignment.bottomCenter,
child:
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
SizedBox(
width: 160,
child: TextField(
style: const TextStyle(fontSize: 36),
decoration: const InputDecoration(
labelText: 'tx',
hintText: '0',
border: OutlineInputBorder()),
onChanged: (tx) {
_provider.tx = double.parse(tx);
},
),
),
const SizedBox(width: 16, height: 500),
SizedBox(
width: 160,
child: TextField(
style: const TextStyle(fontSize: 36),
decoration: const InputDecoration(
labelText: 'ty',
hintText: '0',
border: OutlineInputBorder(),
),
onChanged: (ty) {
_provider.ty = double.parse(ty);
}))
]))
])));
}
}
ペインター
ここで図形の描画をやっています。
今回は offset で点群データを用意。
それらを繋げて線分で描画、ということをやっています。
Path()..addPolygon
で点をつなげて線を書いていけます。
import 'package:flutter/material.dart';
class Painter extends CustomPainter {
Painter({ this.offsetList});
List<Offset> offsetList;
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.orange
..strokeWidth = 2.0
..style = PaintingStyle.stroke;
final path = Path()..addPolygon(offsetList, false);
canvas.drawPath(path, paint);
}
bool shouldRepaint(Painter oldDelegate) {
return true;
}
}
おしまいに
Offset
クラスにはスケールと平行移動のメソッドがあった気がしますが、原点を中心に回転するメソッドはないんですよね。普通につけておいてくれてもいい気がするんですが、と思いました。
Discussion