こんなホウキの絵のアイコンをつくりたい。
手動で点を算出した上であとから調整(拡大縮小や回転)するので、 Kotlin Script を使う。
最終的に完成したアイコンはこれ。
24 x 24 ピクセルの大きさのアイコンを考える。
ホウキの柄の部分のSVGコマンド
M2.0, 0.0
L2.0, 5.5
Q1.0, 6.5 1.0, 7.5
L4.5, 7.5
Q4.5, 6.5 3.5, 5.5
L3.5, 0.0
Z
ホウキのふさふさした部分(穂)のSVGコマンド
M1.0, 7.75
Q0.0, 9.75 0.5, 11.95
L1.6, 11.95
L1.9, 9.45
L2.1, 11.95
L3.4, 11.95
L3.6, 9.45
L3.9, 11.95
L5.0, 11.95
Q5.5, 9.75 4.5, 7.75
Z
SVG に書き起こすとこうなる。
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" x="0px" y="0px" width="128px" height="128px" viewBox="0 0 24 24">
<g stroke="rgb(32,32,32)" stroke-width="0.25" stroke-dasharray="0.5" fill="none"><rect width="24" height="24" /></g>
<g fill="rgb(101,123,131)">
<path d="
M2.0, 0.0
L2.0, 5.5
Q1.0, 6.5 1.0, 7.5
L4.5, 7.5
Q4.5, 6.5 3.5, 5.5
L3.5, 0.0
Z
M1.0, 7.75
Q0.0, 9.75 0.5, 11.95
L1.6, 11.95
L1.9, 9.45
L2.1, 11.95
L3.4, 11.95
L3.6, 9.45
L3.9, 11.95
L5.0, 11.95
Q5.5, 9.75 4.5, 7.75
Z
">
</path>
</g>
</svg>
レンダリングした結果。
実寸(24x24ピクセル)では小さすぎるので、128x128px に拡大した。
この段階ではホウキは左上でまっすぐに立った状態だが、最終的には多少拡大した上で中央に配置してさらに 30 度ほど回転したい。 座標の計算を紙とペンでするのは難しいので、Kotlin Script を使う。
かつては、電卓、そして今は(コンピュータ)スクリプトに支援してもらっているわけだが、 数年すれば(いや今時点でもすでにできるのか?) ChatGPT に自然文で頼めば点の座標計算をしてくれるのであろうか。
点データを反映させるために 点データ用クラスを準備。
data class Pt(val x: Float, val y: Float)
SVGパスコマンドで使用しているのは、M,L,Q,Z のみ。これを Sealed クラス Cmd を使って表現する。
sealed class Cmd {
data class M(val pt: Pt): Cmd()
data class L(val pt: Pt): Cmd()
data class Q(val pt0: Pt, val pt1: Pt): Cmd()
object Z: Cmd()
}
さらに、今あるSVGコマンドをパースして Cmd を生成する簡易パーサーを用意。
今ある SVGコマンドはこんな感じの文字列、
M2.0, 0.0
L2.0, 5.5
Q1.0, 6.5 1.0, 7.5
L4.5, 7.5
Q4.5, 6.5 3.5, 5.5
L3.5, 0.0
Z
これをパースするパーサー。
val toCmd: (String)->Cmd = { text->
val regM = "^M(.+),(.+)".toRegex()
val regL = "^L(.+),(.+)".toRegex()
val regQ = "^Q(.+),(.+) (.+),(.+)".toRegex()
val regZ = "^Z".toRegex()
val matchM = regM.find(text)
val matchL = regL.find(text)
val matchQ = regQ.find(text)
val matchZ = regZ.find(text)
if( matchM!=null ){
val x = matchM.groupValues[1].toFloat()
val y = matchM.groupValues[2].toFloat()
Cmd.M(Pt(x,y))
}
else if( matchL!=null ){
val x = matchL.groupValues[1].toFloat()
val y = matchL.groupValues[2].toFloat()
Cmd.L(Pt(x,y))
}
else if( matchQ!=null ){
val x0 = matchQ.groupValues[1].toFloat()
val y0 = matchQ.groupValues[2].toFloat()
val x1 = matchQ.groupValues[3].toFloat()
val y1 = matchQ.groupValues[4].toFloat()
Cmd.Q(Pt(x0,y0), Pt(x1,y1))
}
else if( matchZ!=null ){
Cmd.Z
}
else {
Cmd.Z
}
}
この toCmd パーサーを使って SVGコマンド文字列から List<Cmd> を生成。
val broomCmdList = listOf<Cmd>(
toCmd("M2.0, 0.0"),
toCmd("L2.0, 5.5"),
toCmd("Q1.0, 6.5 1.0, 7.5"),
toCmd("L4.5, 7.5"),
toCmd("Q4.5, 6.5 3.5, 5.5"),
toCmd("L3.5, 0.0"),
toCmd("Z"),
toCmd("M1.0, 7.75"),
toCmd("Q0.0, 9.75 0.5, 11.95"),
toCmd("L1.6, 11.95"),
toCmd("L1.9, 9.45"),
toCmd("L2.1, 11.95"),
toCmd("L3.4, 11.95"),
toCmd("L3.6, 9.45"),
toCmd("L3.9, 11.95"),
toCmd("L5.0, 11.95"),
toCmd("Q5.5, 9.75 4.5, 7.75"),
toCmd("Z"),
)
うまくパースできているか確認してみましょう。
broomCmdList.forEach {
println( cmdToString(it) )
}
Cmd からSVGコマンド文字列に戻すための関数 cmdToString はこれ。
val cmdToString: (Cmd)->String = { cmd-> when(cmd){ is Cmd.M -> { "M${cmd.pt.x}, ${cmd.pt.y}" } is Cmd.L -> { "L${cmd.pt.x}, ${cmd.pt.y}" } is Cmd.Q -> { "Q${cmd.pt0.x}, ${cmd.pt0.y} ${cmd.pt1.x}, ${cmd.pt1.y}" } else -> { "Z" } } }
ここまで来たらあとは、3x3 マトリックスを使って座標を意図した位置に写すだけです。
まず小手試しに、平行移動を使ってアイコンの中心にホウキを移動してみます。 中心にするための平行移動量は dx = 9.25 , dy = 6.025 です。
3x3 マトリックス値は次のようになります。
1 0 9.25
0 1 6.025
0 0 1
これと (x, y, 1) の積を計算 (後述の transform 関数)すると、(x,y) を移動できます。
平行移動だけの計算ならば 3x3 マトリックスを持ち出さなくても (x+dx, y+dy) するだけの話ですが、 ここではあとで拡大および回転もしたいので、3x3 マトリックスを使った計算を使っています。
transform と補助関数 translateM3x3
data class Pt(val x: Float, val y: Float) typealias M3x3 = FloatArray val translateM3x3: (Float, Float)-> M3x3 = { dx, dy-> floatArrayOf( 1f, 0f, dx, 0f, 1f, dy, 0f, 0f, 1f, ) } val transform: (M3x3, Pt)-> Pt = { a, pt-> val b = floatArrayOf(pt.x, pt.y, 1f) val c00 = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] val c10 = a[0 + 3] * b[0] + a[1 + 3] * b[1] + a[2 + 3] * b[2] //val c20 = a[0 + 6] * b[0] + a[1 + 6] * b[1] + a[2 + 6] * b[2] Pt(c00, c10) }
今ホウキのSVGコマンドが broomCmdList: List<Cmd> にあるので、これを平行移動させてみます。
val newBroomCmdList = broomCmdList.map { cmd->
val matrix = translateM3x3(9.25f, 6.025f)
when(cmd){
is Cmd.M -> {
val pt = transform(matrix, cmd.pt)
Cmd.M(pt)
}
is Cmd.L -> {
val pt = transform(matrix, cmd.pt)
Cmd.L(pt)
}
is Cmd.Q -> {
val pt0 = transform(matrix, cmd.pt0)
val pt1 = transform(matrix, cmd.pt1)
Cmd.Q(pt0, pt1)
}
is Cmd.Z -> Cmd.Z
}
}
平行移動済みの newBroomCmdList から SVGコマンドを生成。
newBroomCmdList.forEach {
println( cmdToString(it) )
}
実行すると次の文字列が標準出力されます。
M11.25, 6.025
L11.25, 11.525
Q10.25, 12.525 10.25, 13.525
L13.75, 13.525
Q13.75, 12.525 12.75, 11.525
L12.75, 6.025
Z
M10.25, 13.775
Q9.25, 15.775 9.75, 17.975
L10.85, 17.975
L11.15, 15.475
L11.35, 17.975
L12.65, 17.975
L12.85, 15.475
L13.15, 17.975
L14.25, 17.975
Q14.75, 15.775 13.75, 13.775
Z
意図通りセンターに配置できたか SVG にしてレンダリングしてみましょう。
うまくいきました。
それでは、1.2 倍に拡大して、30度傾け(回転)して中央に配置することを考えます。
現在ちょうど中央に配置されているので、この状態を出発点して 次のようにします。
1.2倍に拡大の 3x3 マトリックスは次のようになります。
1.2 0 0
0 1.2 0
0 0 1
30度回転のそれはこれです。
cos(θ) -sin(θ), 0
sin(θ) cos(θ), 0
0 0 1
θ はラジアンで度(degree)から変換方法はこれ
θ = degree * PI/180
いまは 30度にしたいので、次のようになる。
θ = 30 * PI/180
そして、この4つの変換を逐次行って座標を変換してもかまわないのだが・・・つまり、つぎのようにして、
val resultBroomList = newBroomList.map { 変換1 } .map { 変換2 } .map { 変換3 } .map { 変換4 }
しかし、複数の変換用 3x3 マトリックスはひとつにまとめられる( Pre-Multiplication というらしい)。
「・」をマトリックスの積(multiply)だとしたら、次のように変換順に(前に)preすることで、変換マトリックスをひとつにまとめられる。
変換4のマトリックス・変換3のマトリックス・変換2のマトリックス・変換1のマトリックス
もうだったら最初の状態からセンター平行移動するマトリックスも含めて計算することにしよう。
変換4のマトリックス・変換3のマトリックス・変換2のマトリックス・変換1のマトリックス・変換0のマトリックス
それでは実装してみます。
translateM3x3 関数にならって、scaleM3x3 と rotateM3x3 関数を定義。
val scaleM3x3: (Float, Float)-> M3x3 = { sx, sy->
floatArrayOf(
sx, 0f, 0f,
0f, sy, 0f,
0f, 0f, 1f,
)
}
val rotateM3x3: (Float)-> M3x3 = { degree->
val θ = degree * Math.PI/180f
floatArrayOf(
Math.cos(θ).toFloat(), -Math.sin(θ).toFloat(), 0f,
Math.sin(θ).toFloat(), Math.cos(θ).toFloat(), 0f,
0f, 0f, 1f,
)
}
次に multiply を定義。
val multiply: ( M3x3, M3x3 )-> M3x3 = { a, b->
floatArrayOf(
a[0]*b[0] + a[1]*b[3] + a[2]*b[6],
a[0]*b[1] + a[1]*b[4] + a[2]*b[7],
a[0]*b[2] + a[1]*b[5] + a[2]*b[8],
a[3]*b[0] + a[4]*b[3] + a[5]*b[6],
a[3]*b[1] + a[4]*b[4] + a[5]*b[7],
a[3]*b[2] + a[4]*b[5] + a[5]*b[8],
a[6]*b[0] + a[7]*b[3] + a[8]*b[6],
a[6]*b[1] + a[7]*b[4] + a[8]*b[7],
a[6]*b[2] + a[7]*b[5] + a[8]*b[8],
)
}
マトリックスの積を地味に計算しているだけです。自分で実装しないで、ライブラリを使えばよい。
それでは、変換 m0..m4 のマトリックスを定義したうえで、ひとつにまとめます。
val m0 = translateM3x3(9.25f, 6.025f)
val m1 = translateM3x3(-12f, -12f)
val m2 = scaleM3x3(1.2f, 1.2f)
val m3 = rotateM3x3(30f)
val m4 = translateM3x3(12f, 12f)
val matrix =
multiply(
m4,
multiply(
m3,
multiply(
m2,
multiply(m1, m0))))
multiply を使ってマトリックスをまとめる部分が読みづらいコードになるので、 infix を使って multiply 関数を書き直します。 ついでに、関数名を x にしてしまいましょう。
//infix fun M3x3.multiply( b:M3x3 ): M3x3 {
infix fun M3x3.x( b:M3x3 ): M3x3 {
val a = this
return floatArrayOf(
a[0]*b[0] + a[1]*b[3] + a[2]*b[6],
a[0]*b[1] + a[1]*b[4] + a[2]*b[7],
a[0]*b[2] + a[1]*b[5] + a[2]*b[8],
a[3]*b[0] + a[4]*b[3] + a[5]*b[6],
a[3]*b[1] + a[4]*b[4] + a[5]*b[7],
a[3]*b[2] + a[4]*b[5] + a[5]*b[8],
a[6]*b[0] + a[7]*b[3] + a[8]*b[6],
a[6]*b[1] + a[7]*b[4] + a[8]*b[7],
a[6]*b[2] + a[7]*b[5] + a[8]*b[8],
)
}
これを使えば、該当部分のコードは次のように書けます。
val matrix = m4 x (m3 x (m2 x (m1 x m0)))
infix は左結合なので、右結合にしたい場合は括弧をつけます。
この matrix を使ってあとは、点(というか Cmd の点)を写すだけです。
val newBroomCmdList = broomCmdList.map { cmd->
when(cmd){
is Cmd.M -> {
val pt = transform(matrix, cmd.pt)
Cmd.M(pt)
}
is Cmd.L -> {
val pt = transform(matrix, cmd.pt)
Cmd.L(pt)
}
is Cmd.Q -> {
val pt0 = transform(matrix, cmd.pt0)
val pt1 = transform(matrix, cmd.pt1)
Cmd.Q(pt0, pt1)
}
is Cmd.Z -> Cmd.Z
}
}
newBroomCmdList.forEach {
println( cmdToString(it) )
}
できたコマンドをSVGにしてレンダリング。
うまくいきました。
もう少しホウキを大きくしましょう。1.2 倍に代えて 1.6倍にしてみました。
//val m2 = scaleM3x3(1.2f, 1.2f)
val m2 = scaleM3x3(1.6f, 1.6f)
変更は拡大用マトリックスを 1.6 倍指定に変更しただけです。
コード全体。
broom.main.kts
typealias M3x3 = FloatArray
infix fun M3x3.x( b:M3x3 ): M3x3 {
val a = this
return floatArrayOf(
a[0]*b[0] + a[1]*b[3] + a[2]*b[6],
a[0]*b[1] + a[1]*b[4] + a[2]*b[7],
a[0]*b[2] + a[1]*b[5] + a[2]*b[8],
a[3]*b[0] + a[4]*b[3] + a[5]*b[6],
a[3]*b[1] + a[4]*b[4] + a[5]*b[7],
a[3]*b[2] + a[4]*b[5] + a[5]*b[8],
a[6]*b[0] + a[7]*b[3] + a[8]*b[6],
a[6]*b[1] + a[7]*b[4] + a[8]*b[7],
a[6]*b[2] + a[7]*b[5] + a[8]*b[8],
)
}
val translateM3x3: (Float, Float)-> M3x3 = { dx, dy->
floatArrayOf(
1f, 0f, dx,
0f, 1f, dy,
0f, 0f, 1f,
)
}
val scaleM3x3: (Float, Float)-> M3x3 = { sx, sy->
floatArrayOf(
sx, 0f, 0f,
0f, sy, 0f,
0f, 0f, 1f,
)
}
val rotateM3x3: (Float)-> M3x3 = { degree->
val θ = degree * Math.PI/180f
floatArrayOf(
Math.cos(θ).toFloat(), -Math.sin(θ).toFloat(), 0f,
Math.sin(θ).toFloat(), Math.cos(θ).toFloat(), 0f,
0f, 0f, 1f,
)
}
data class Pt(val x: Float, val y: Float)
val transform: (M3x3, Pt)-> Pt = { a, pt->
val b = floatArrayOf(pt.x, pt.y, 1f)
val c00 = a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
val c10 = a[0 + 3] * b[0] + a[1 + 3] * b[1] + a[2 + 3] * b[2]
//val c20 = a[0 + 6] * b[0] + a[1 + 6] * b[1] + a[2 + 6] * b[2]
Pt(c00, c10)
}
sealed class Cmd {
data class M(val pt: Pt): Cmd()
data class L(val pt: Pt): Cmd()
data class Q(val pt0: Pt, val pt1: Pt): Cmd()
object Z: Cmd()
}
val cmdToString: (Cmd)->String = { cmd->
when(cmd){
is Cmd.M -> {
"M${cmd.pt.x}, ${cmd.pt.y}"
}
is Cmd.L -> {
"L${cmd.pt.x}, ${cmd.pt.y}"
}
is Cmd.Q -> {
"Q${cmd.pt0.x}, ${cmd.pt0.y} ${cmd.pt1.x}, ${cmd.pt1.y}"
}
else -> {
"Z"
}
}
}
val toCmd: (String)->Cmd = { text->
val regM = "^M(.+),(.+)".toRegex()
val regL = "^L(.+),(.+)".toRegex()
val regQ = "^Q(.+),(.+) (.+),(.+)".toRegex()
val regZ = "^Z".toRegex()
val matchM = regM.find(text)
val matchL = regL.find(text)
val matchQ = regQ.find(text)
val matchZ = regZ.find(text)
if( matchM!=null ){
val x = matchM.groupValues[1].toFloat()
val y = matchM.groupValues[2].toFloat()
Cmd.M(Pt(x,y))
}
else if( matchL!=null ){
val x = matchL.groupValues[1].toFloat()
val y = matchL.groupValues[2].toFloat()
Cmd.L(Pt(x,y))
}
else if( matchQ!=null ){
val x0 = matchQ.groupValues[1].toFloat()
val y0 = matchQ.groupValues[2].toFloat()
val x1 = matchQ.groupValues[3].toFloat()
val y1 = matchQ.groupValues[4].toFloat()
Cmd.Q(Pt(x0,y0), Pt(x1,y1))
}
else if( matchZ!=null ){
Cmd.Z
}
else {
Cmd.Z
}
}
val broomCmdList = listOf<Cmd>(
toCmd("M2.0, 0.0"),
toCmd("L2.0, 5.5"),
toCmd("Q1.0, 6.5 1.0, 7.5"),
toCmd("L4.5, 7.5"),
toCmd("Q4.5, 6.5 3.5, 5.5"),
toCmd("L3.5, 0.0"),
toCmd("Z"),
toCmd("M1.0, 7.75"),
toCmd("Q0.0, 9.75 0.5, 11.95"),
toCmd("L1.6, 11.95"),
toCmd("L1.9, 9.45"),
toCmd("L2.1, 11.95"),
toCmd("L3.4, 11.95"),
toCmd("L3.6, 9.45"),
toCmd("L3.9, 11.95"),
toCmd("L5.0, 11.95"),
toCmd("Q5.5, 9.75 4.5, 7.75"),
toCmd("Z"),
)
val m0 = translateM3x3(9.25f, 6.025f)
val m1 = translateM3x3(-12f, -12f)
val m2 = scaleM3x3(1.6f, 1.6f)
val m3 = rotateM3x3(30f)
val m4 = translateM3x3(12f, 12f)
val matrix = m4 x (m3 x (m2 x (m1 x m0)))
val newBroomCmdList = broomCmdList.map { cmd->
when(cmd){
is Cmd.M -> {
val pt = transform(matrix, cmd.pt)
Cmd.M(pt)
}
is Cmd.L -> {
val pt = transform(matrix, cmd.pt)
Cmd.L(pt)
}
is Cmd.Q -> {
val pt0 = transform(matrix, cmd.pt0)
val pt1 = transform(matrix, cmd.pt1)
Cmd.Q(pt0, pt1)
}
is Cmd.Z -> Cmd.Z
}
}
newBroomCmdList.forEach {
println( cmdToString(it) )
}
kotlin 環境の確認とスクリプトの実行。
$ kotlin -version
Kotlin version 1.9.22-release-704 (JRE 17.0.9+9-Ubuntu-122.04)
$ kotlin broom.main.kts
今回 SVG コマンドを sealed class で表現しました。たしかにこれは便利。