正四面体回転コードの仕組み

## コード紹介
[![Gist](https://img.shields.io/badge/GIST-FFFFFF?style=for-the-badge&logo=github&logoColor=black)](https://gist.github.com/citrus-mizuha/7dd1513d8ce0f3ed174a30a57dafa2bb)
<details><summary>コード</summary>

```tetrahedron_spin.py
import math
import tkinter as tk
import sys
BG_COLOR = "black"
FG_COLOR = "white"

def rotate_point(x, y, z, angle_x, angle_y, angle_z):
    y1 = y * math.cos(angle_x) - z * math.sin(angle_x)
    z1 = y * math.sin(angle_x) + z * math.cos(angle_x)

    x2 = x * math.cos(angle_y) + z1 * math.sin(angle_y)
    z2 = -x * math.sin(angle_y) + z1 * math.cos(angle_y)

    x3 = x2 * math.cos(angle_z) - y1 * math.sin(angle_z)
    y3 = x2 * math.sin(angle_z) + y1 * math.cos(angle_z)

    return x3, y3, z2

def get_shading(x, y, z, light_dir):
    dot_product = x * light_dir[0] + y * light_dir[1] + z * light_dir[2]
    length = math.sqrt(x ** 2 + y ** 2 + z ** 2) * math.sqrt(light_dir[0] ** 2 + light_dir[1] ** 2 + light_dir[2] ** 2)
    shading = dot_product / length
    return shading

def interpolate_points(p1, p2, num_points):
    points = []
    for i in range(num_points + 1):
        t = i / num_points
        x = p1[0] * (1 - t) + p2[0] * t
        y = p1[1] * (1 - t) + p2[1] * t
        z = p1[2] * (1 - t) + p2[2] * t
        points.append((x, y, z))
    return points

def get_edge_char(shading):
    chars = "MIZUHA"
    index = int((shading + 1) / 2 * (len(chars) - 1))
    return chars[index]

def get_face_char(shading):
    chars = r"."
    index = int((shading + 1) / 2 * (len(chars) - 1))
    return chars[index]

def draw_tetrahedron(angle_x, angle_y, angle_z, light_dir, canvas, canvas_size):
    vertices = [
        (1, 1, 1),
        (-1, -1, 1),
        (-1, 1, -1),
        (1, -1, -1)
    ]

    edges = [
        (vertices[0], vertices[1]),
        (vertices[0], vertices[2]),
        (vertices[0], vertices[3]),
        (vertices[1], vertices[2]),
        (vertices[1], vertices[3]),
        (vertices[2], vertices[3])
    ]

    faces = [
        (vertices[0], vertices[1], vertices[2]),
        (vertices[0], vertices[1], vertices[3]),
        (vertices[0], vertices[2], vertices[3]),
        (vertices[1], vertices[2], vertices[3])
    ]

    num_points = 50

    for v1, v2 in edges:
        edge_points = interpolate_points(v1, v2, num_points)
        for x, y, z in edge_points:
            x_rot, y_rot, z_rot = rotate_point(x, y, z, angle_x, angle_y, angle_z)
            canvas_x = int(x_rot * 35 + canvas_size // 2)
            canvas_y = int(y_rot * 35 + canvas_size // 2)
            if 0 <= canvas_x < canvas_size and 0 <= canvas_y < canvas_size:
                shading = get_shading(x_rot, y_rot, z_rot, light_dir)
                char = get_edge_char(shading)
                canvas[canvas_y][canvas_x] = char

    for v1, v2, v3 in faces:
        for i in range(num_points + 1):
            for j in range(num_points + 1 - i):
                t1 = i / num_points
                t2 = j / num_points
                t3 = 1 - t1 - t2
                x = v1[0] * t1 + v2[0] * t2 + v3[0] * t3
                y = v1[1] * t1 + v2[1] * t2 + v3[1] * t3
                z = v1[2] * t1 + v2[2] * t2 + v3[2] * t3
                x_rot, y_rot, z_rot = rotate_point(x, y, z, angle_x, angle_y, angle_z)
                canvas_x = int(x_rot * 35 + canvas_size // 2)
                canvas_y = int(y_rot * 35 + canvas_size // 2)
                if 0 <= canvas_x < canvas_size and 0 <= canvas_y < canvas_size and canvas[canvas_y][canvas_x] == ' ':
                    shading = get_shading(x_rot, y_rot, z_rot, light_dir)
                    char = get_face_char(shading)
                    canvas[canvas_y][canvas_x] = char

    return canvas

def clear_screen():
    sys.stdout.write("\033[2J\033[H")
    sys.stdout.flush()

def update_frame(canvas, canvas_size, label, angle_x, angle_y, angle_z, light_dir):
    canvas = [[' ' for _ in range(canvas_size)] for _ in range(canvas_size)]
    canvas = draw_tetrahedron(angle_x, angle_y, angle_z, light_dir, canvas, canvas_size)
    frame = '\n'.join(''.join(row) for row in canvas)
    label.config(text=frame)
    label.after(20, update_frame, canvas, canvas_size, label, angle_x + 0.03, angle_y + 0.02, angle_z + 0.01, light_dir)

def main():
    root = tk.Tk()
    root.title("3D Tetrahedron Animation")
    root.configure(bg=BG_COLOR)

    canvas_size = 220
    canvas = [[' ' for _ in range(canvas_size)] for _ in range(canvas_size)]
    label = tk.Label(root, bg=BG_COLOR, fg=FG_COLOR, font=("Courier", 8), justify=tk.LEFT)
    label.pack(expand=True)

    angle_x, angle_y, angle_z = 0, 0, 0
    light_dir = (0, 0, 1)

    update_frame(canvas, canvas_size, label, angle_x, angle_y, angle_z, light_dir)

    root.mainloop()

if __name__ == "__main__":
    main()
```
</details>

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">今度は多分正になってると思う<a href="https://t.co/GzgeUG1M6G">https://t.co/GzgeUG1M6G</a> <a href="https://t.co/By3yTjYINZ">https://t.co/By3yTjYINZ</a> <a href="https://t.co/DJFBN2cLFH">pic.twitter.com/DJFBN2cLFH</a></p>&mdash; みずは (@mizuha_python) <a href="https://twitter.com/mizuha_python/status/1809886701634154855?ref_src=twsrc%5Etfw">July 7, 2024</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

## 解説
### 手順
手順は以下の通り
点ごとに以下の動きが行われる
1. 回転行列を適用して新しい座標を計算
2. シェーディングを計算
3. 2D画面に投影して、キャンバス上の位置を決定
4. キャンバスに対応するキャラクターを描画
#### 1. 正四面体の頂点
正四面体の頂点は次の座標に設定されている (典型的な正四面体)

1. (1, 1, 1)
2. (-1, -1, 1)
3. (-1, 1, -1)
4. (1, -1, -1)

#### 2. 回転行列
図形を回転させるために、3つの回転行列を使用する
ただし、$θ$ は初期値で `0` となっており、そこから1フレームごとに
$angle_x$ は 0.03、$angle_y$ は 0.02、$angle_z$ は 0.01 ずつ増加する

##### x軸回りの回転
```math
angle_x = 
\begin{pmatrix}
1 & 0 & 0 \\
0 & \cos(\theta_x) & -\sin(\theta_x) \\
0 & \sin(\theta_x) & \cos(\theta_x)
\end{pmatrix}
```
##### y軸回りの回転
```math
angle_y = 
\begin{pmatrix}
\cos(\theta_y) & 0 & \sin(\theta_y) \\
0 & 1 & 0 \\
-\sin(\theta_y) & 0 & \cos(\theta_y)
\end{pmatrix}
```

##### z軸回りの回転
```math
angle_z = 
\begin{pmatrix}
\cos(\theta_z) & -\sin(\theta_z) & 0 \\
\sin(\theta_z) & \cos(\theta_z) & 0 \\
0 & 0 & 1
\end{pmatrix}
```

#### 3. シェーディング

シェーディングは光源の方向と面の法線ベクトルの間の角度に基づいて計算されます。ドット積を使って法線ベクトルと光源方向ベクトルの角度を計算し、面の明るさを決定します。

#### 4. 補間

各辺と面の点は、線形補間によって計算されます。補間は、頂点間の点を一定の間隔で計算するために使用されます。

#### 5. 2Dへの投影

3D座標が計算されると、これらの座標は2D画面に投影されます。画面の中心を基準として、スケーリングと平行移動を行い、各点の画面座標を決定します。

#### 6. キャンバスへの描画

各点が計算されると、それぞれの点がキャンバスに描画されます。シェーディングの値に応じて、各点のキャラクターが選択され、エッジや面を描画します。

### コード1行解説
#### 1-3行目
```python
import math
import tkinter as tk
import sys
```
パッケージの読み込み
> `math`, `tkinter`, `sys` はどれも標準パッケージです
> `math`は名前通り数学計算を行うパッケージで、`tkinter`はGUIに出力するためのパッケージ、`sys`はシステム固有のパラメーターと関数を操作するためのパッケージです
> `as tk`は、tkとして読み込む、の意味

#### 4-5行目
```python
BG_COLOR = "black"
FG_COLOR = "white"
```
tkinterで使用する画面のバッググラウンドと文字の色を指定します

#### 7-17行目
```python
def rotate_point(x, y, z, angle_x, angle_y, angle_z):
    y1 = y * math.cos(angle_x) - z * math.sin(angle_x)
    z1 = y * math.sin(angle_x) + z * math.cos(angle_x)

    x2 = x * math.cos(angle_y) + z1 * math.sin(angle_y)
    z2 = -x * math.sin(angle_y) + z1 * math.cos(angle_y)

    x3 = x2 * math.cos(angle_z) - y1 * math.sin(angle_z)
    y3 = x2 * math.sin(angle_z) + y1 * math.cos(angle_z)

    return x3, y3, z2
```
先ほど解説した回転行列の導入です
この`rotate_point`関数では、点(x, y, z)をx軸、y軸、z軸周りにそれぞれangle_x、angle_y、angle_zだけ回転させます
三角関数`math.cos()`と`math.sin()`を使用して、回転後の座標(x3, y3, z2)を計算します

#### 19-23行目
```python
def get_shading(x, y, z, light_dir):
    dot_product = x * light_dir[0] + y * light_dir[1] + z * light_dir[2]
    length = math.sqrt(x ** 2 + y ** 2 + z ** 2) * math.sqrt(light_dir[0] ** 2 + light_dir[1] ** 2 + light_dir[2] ** 2)
    shading = dot_product / length
    return shading
```
この`get_shading`関数は、光の方向`light_dir`に対する点(x, y, z)の影の強さ（シェーディング）を計算します。具体的には、点積を計算して光の方向との関係を求め、その結果を返します。

#### 25-33行目
```python
def interpolate_points(p1, p2, num_points):
    points = []
    for i in range(num_points + 1):
        t = i / num_points
        x = p1[0] * (1 - t) + p2[0] * t
        y = p1[1] * (1 - t) + p2[1] * t
        z = p1[2] * (1 - t) + p2[2] * t
        points.append((x, y, z))
    return points
```
`interpolate_points`関数は、2つの点p1とp2の間を線形補間して、指定された数の中間点を生成します。`num_points`は中間点の数を指定します。

#### 35-38行目
```python
def get_edge_char(shading):
    chars = "MIZUHA"
    index = int((shading + 1) / 2 * (len(chars) - 1))
    return chars[index]
```
`get_edge_char`関数は、辺のシェーディングの値に基づいて適切な文字を選択します。`shading`が-1から1の間で変化し、文字列`"MIZUHA"`のインデックスを計算して返します。

#### 40-43行目
```python
def get_face_char(shading):
    chars = r"."
    index = int((shading + 1) / 2 * (len(chars) - 1))
    return chars[index]
```
`get_face_char`関数は、面のシェーディングの値に基づいて適切な文字を選択します。`shading`が-1から1の間で変化し、文字列`"."`のインデックスを計算して返します。

#### 45-99行目
```python
def draw_tetrahedron(angle_x, angle_y, angle_z, light_dir, canvas, canvas_size):
    vertices = [
        (1, 1, 1),
        (-1, -1, 1),
        (-1, 1, -1),
        (1, -1, -1)
    ]

    edges = [
        (vertices[0], vertices[1]),
        (vertices[0], vertices[2]),
        (vertices[0], vertices[3]),
        (vertices[1], vertices[2]),
        (vertices[1], vertices[3]),
        (vertices[2], vertices[3])
    ]

    faces = [
        (vertices[0], vertices[1], vertices[2]),
        (vertices[0], vertices[1], vertices[3]),
        (vertices[0], vertices[2], vertices[3]),
        (vertices[1], vertices[2], vertices[3])
    ]

    num_points = 50

    for v1, v2 in edges:
        edge_points = interpolate_points(v1, v2, num_points)
        for x, y, z in edge_points:
            x_rot, y_rot, z_rot = rotate_point(x, y, z, angle_x, angle_y, angle_z)
            canvas_x = int(x_rot * 35 + canvas_size // 2)
            canvas_y = int(y_rot * 35 + canvas_size // 2)
            if 0 <= canvas_x < canvas_size and 0 <= canvas_y < canvas_size:
                shading = get_shading(x_rot, y_rot, z_rot, light_dir)
                char = get_edge_char(shading)
                canvas[canvas_y][canvas_x] = char

    for v1, v2, v3 in faces:
        for i in range(num_points + 1):
            for j in range(num_points + 1 - i):
                t1 = i / num_points
                t2 = j / num_points
                t3 = 1 - t1 - t2
                x = v1[0] * t1 + v2[0] * t2 + v3[0] * t3
                y = v1[1] * t1 + v2[1] * t2 + v3[1] * t3
                z = v1[2] * t1 + v2[2] * t2 + v3[2] * t3
                x_rot, y_rot, z_rot = rotate_point(x, y, z, angle_x, angle_y, angle_z)
                canvas_x = int(x_rot * 35 + canvas_size // 2)
                canvas_y = int(y_rot * 35 + canvas_size // 2)
                if 0 <= canvas_x < canvas_size and 0 <= canvas_y < canvas_size and canvas[canvas_y][canvas_x] == ' ':
                    shading = get_shading(x_rot, y_rot, z_rot, light_dir)
                    char = get_face_char(shading)
                    canvas[canvas_y][canvas_x] = char

    return canvas
```
`draw_tetrahedron`関数は、四面体のエッジと面を描画するための関数です
テトラヘドロンの頂点、エッジ、および面は、それぞれ`vertices`、`edges`、`faces`で定義されています
`interpolate_points`関数を使用してエッジの間を補間し、回転後の座標を計算してから、`get_edge_char`と`get_face_char`関数を使ってシェーディングに応じた文字を選択し、tkinterのcanvasに描画します。

#### 101-103行目
```python
def clear_screen():
    sys.stdout.write("\033[2J\033[H")
    sys.stdout.flush()
```
`clear_screen`関数は、コンソール画面をクリアするための関数です。
ANSIエスケープシーケンス `"\033[2J\033[H"`を使用して、画面をクリアし、sys.stdout.flush()で出力をフラッシュします。

#### 105-110行目
```python
def update_frame(canvas, canvas_size, label, angle_x, angle_y, angle_z, light_dir):
    canvas = [[' ' for _ in range(canvas_size)] for _ in range(canvas_size)]
    canvas = draw_tetrahedron(angle_x, angle_y, angle_z, light_dir, canvas, canvas_size)
    frame = '\n'.join(''.join(row) for row in canvas)
    label.config(text=frame)
    label.after(20, update_frame, canvas, canvas_size, label, angle_x + 0.03, angle_y + 0.02, angle_z + 0.01, light_dir)
```
`update_frame`関数は、アニメーションのフレームを更新するための関数です
まず、canvasを空の文字列で初期化し、`draw_tetrahedron`関数を使って四面体を描画します
その後、`canvas`を文字列に変換し、labelのテキストとして設定します
最後に、`label.after`を使って20ミリ秒ごとに自身を再帰的に呼び出し、アニメーションを更新します

#### 112-127行目
```python
def main():
    root = tk.Tk()
    root.title("3D Tetrahedron Animation")
    root.configure(bg=BG_COLOR)

    canvas_size = 220
    canvas = [[' ' for _ in range(canvas_size)] for _ in range(canvas_size)]
    label = tk.Label(root, bg=BG_COLOR, fg=FG_COLOR, font=("Courier", 8), justify=tk.LEFT)
    label.pack(expand=True)

    angle_x, angle_y, angle_z = 0, 0, 0
    light_dir = (0, 0, 1)

    update_frame(canvas, canvas_size, label, angle_x, angle_y, angle_z, light_dir)

    root.mainloop()
```
main()関数は、プログラムのエントリーポイントです
Tkinterのウィンドウを作成し、タイトルを設定し、背景色を設定します
canvasのサイズと初期化、labelの設定を行い、初期の回転角度angle_x、angle_y、angle_zと光の方向light_dirを設定して、update_frame()関数を呼び出します
最後に、root.mainloop()でイベントループを開始し、ウィンドウを表示します

#### 129~130行目
```python
if __name__ == "__main__":
    main()
```
このファイルが直接的に実行されているとき、`main`関数を実行します
この実装方法をとった理由は、今後それぞれの関数を`import`することになるかもしれないな、と思ったからです

***

Thank you for reading!