補講B グラフィックス

シラバスでは第6講で実施する予定でしたが,以下の理由により実施しないことにしました. 講義資料はそのまま置いておきます.

この講で利用するプログラム

補講B グラフィックスのサブセクション

グラフィックス

EZ.java

本日のプログラムを実行するには EZ.javaが必要です. これから書くプログラムと,同じディレクトリにこのEZ.javaを置いてください.

EZ.java は正式には,EZ Graphics と呼びます. このプログラムは,ハワイ大学マノア校の Advanced Visualization and Applications 研究室 Dylan Kobayashi によって開発されたソフトウェアです. 1 つのファイルを同じディレクトリに置くことで図形の描画が容易に扱えるようになります. 公式サイトからもダウンロードできますが, この授業向けに少し修正していますので,ここからEZ.javaをダウンロードして利用してください.

楕円の描画

ダウンロードしたら,早速プログラムを書いていきましょう. 書き終えたら,コンパイル,実行してみましょう. 実行結果を確認できれば,値を変更して再度,コンパイル,実行してみましょう. どこを変更すると,どう変わるのかを確認してください.

import java.awt.Color;

public class DrawOval{
  void run(){
    EZ.initialize(400, 400); // 画面の大きさを決める.
    // 円を描く.(中心座標x, y, 幅,高さ,色,塗りつぶし)
    EZCircle circle1 = EZ.addCircle(
        100, 100, 200, 200, Color.BLUE, true);
    EZCircle circle2 = EZ.addCircle(
        200, 200, 200, 200, Color.RED,  false);
  }
  // mainメソッドは省略.
}

楕円の描画

なお,Colorは色を表す型です.この型を利用するときには,import java.awt.Color;の一文がクラス宣言の前に必要です. そして,BLACK, BLUE, CYAN, DARK_GRAY, GRAY, GREEN, LIGHT_GRAY , MAGENTA, ORANGE, PINK, RED, WHITE, YELLOW の 13 色が定義済みです.

座標系

座標は左右方向が x 軸,上下方向が y 軸になっており,右側が x 軸のプラス方向,左端が x 軸の 0 です. また,上下方向では,一番上が y 方向の 0,下方向がプラスになっていることに注意してください.

例題 1. 楕円の描画の変更

  • 円の位置をずらしてください.
  • 円の色を変更してください.
  • 円の大きさを変更してください.

その他の図形の描画

円以外の図形も描いてみましょう. どのようなメソッドを呼び出せば良いかは,EZ Graphics のドキュメント を読んでみましょう.

https://www2.hawaii.edu/~dylank/ics111/doc/

EZ Graphics ドキュメント1

EZ Graphics ドキュメント2

各ページの左のフレームは,EZ Graphics が持つ型を表しています. 右のフレームは,その型が持つ情報を表しています.右のフレームを下にスクロールしていくと, メソッドの一覧が閲覧できます(上の右側の画像).

この形式のドキュメントを一般に API ドキュメント と呼びます. Javadoc コメントで生成されるドキュメントです.

例題 2. 楕円以外の図形の描画

では,EZ Graphics の API ドキュメントを参照し, 楕円以外の図形も描いてみましょう.

まず,DrawOval.javaをコピーしてDrawShapes.javaを作成してください. そして,DrawShapes.javaに追加・変更していってください. なお,DrawShapes.javamainメソッドの中身も忘れずに変更しましょう.

アニメーション

アニメーションの基礎

アニメーションは基本的にパラパラ漫画と同じ原理で行います. EZ Graphics では,EZ.addCircleEZ.addLineなどで 追加して返される実体の位置を変更し,EZ.refreshScreen()を呼び出すと画面を更新します.

また,Java で一定時間スリープするのは,Thread.sleepメソッドを利用します. sleepメソッドに スリープする時間(ミリ秒)を渡します. 次の例では,100 ミリ秒(0.1 秒)スリープしています.

次の例で確認してみましょう.

import java.awt.Color;
public class RoundTrip{
    void run(){
        EZ.initialize(400, 400);
        EZCircle circle = EZ.addCircle(
            100, 100, 5, 5, Color.BLUE, true);
        this.roundTrip(circle);
    }
    void roundTrip(EZCircle circle){
        Integer deltaX = 10;
        while(true){ // 無限ループ
            Integer newX = circle.getXCenter() + deltaX;
            circle.translateTo(newX, circle.getYCenter());
            if(newX >= 400 || newX <= 0){
                deltaX = deltaX * -1;
            }
            EZ.refreshScreen();
            Thread.sleep(100);
        }
    }
}

これでコンパイルすると,次のようなコンパイルエラーが発生します.

RoundTrip.java:17: エラー: 例外InterruptedExceptionは報告されません。スローするには、捕捉または宣言する必要があります
       Thread.sleep(100);
                    ^
エラー1個

例外機構(Exception Architecture)

例外(Exception)は,近年のプログラミング言語で採用されている実行時エラーの通知機構です.

従来のプログラミング言語,例えば,C 言語の場合,fopenで開くファイルが見つからなかった場合, 返り値をNULL にすることでエラーを通知していました. この場合,プログラマが責任を持って, 返り値の値を確認して,正常か異常かを判断しなければいけませんでした.

一方の例外機構は,異常処理を行うための別の処理経路を作るものです. もし,プログラムの実行途中で何らかの異常が発生した時,それまでに行なっていた処理を中断し, 別の処理を行うようにする機構です.

graph LR;
A[ファイルを開く] --> B{存在確認}
B -->|存在する| C[正常処理]
B -->|存在しない| D[異常処理]

C 言語のようなエラー処理は上記のフローチャートのように, プログラマ自身がfopenの返り値を元に分岐処理によって,正常処理,異常処理を振り分ける必要があります.

graph LR;
A[ファイルを開く]
A --> C[正常処理]
A -->|存在しない| D[異常処理]

対して,例外機構がサポートされている言語の場合,プログラマが異常,正常の分岐を明示的に書く必要はありません. 異常が起こった場合の処理の経路が決まっており,その経路に処理を書いておくことが異常処理を行うことになります. 正常処理の場合は,それまでの処理の続きにそのまま処理を書いていきます. これにより正常処理の見通しがよくなります.

そして,例外が投げられれば,何らかの異常が発生したと判断できるようになります. 逆に,例外が投げられなければ,データが変であろうが,正常な処理であると判断できます (もちろん,開発途中で変な場合はバグの可能性はありますが).

プログラマが明示的に投げる例外も存在しますが,多くの場合,システムが異常を検知し例外を投げます. 例えば,ファイルが見つからない場合,スリープ中に割り込みが入った場合などです. そのような例外が発生した時に,どのような対応をするのかをあらかじめ決めておく必要があります.

次のプログラムが,例外処理のイメージです.

void exceptionalMethod() throws Exception {
  // 例外が発生する可能性のある処理
  someMethodCall();

  // 例外が起こらなかった場合の処理
  process();
}

someMethod の実行中に何らかの例外が起こった場合,まずsomeMethodの呼び出し元であるexceptionalMethodにどのように処理するかが問い合わされます. そして,exceptionalMethodは例外は処理せず,呼び出し元に処理を任せるよう設定している(メソッドにthrows Exceptionと宣言しているため)ため,exceptionalMethod の呼び出し元に通知されます. このthrows節は後ほど説明します.

検査例外と非検査例外

Java の例外は,検査例外と非検査例外の2つに分類できます. 違いは次に挙げる通りです.

検査例外

例外が発生した時の処理をプログラム中に明示的に書いておかなければコンパイルエラーになる例外. 完全に防ぐことが不可能な例外(実行時の状態によって発生しうる例外).

例えば,実行時エラーは,プログラムでどれほど厳密にチェックしたとしても,完全に回避できません.

  • 代表的な検査例外
    • IOException
      • 入出力時にエラーが発生した時.
    • InterruptedException
      • 割り込みが発生した時に発生する例外.

非検査例外

一方,非検査例外はプログラム中で十分にチェックすることで避けることが可能です. そのため,例外が発生した時の処理は書かなくてもコンパイルが通るようになっています.

  • 代表的な非検査例外
    • NullPointerException
      • 初期化されていない変数に対する処理を行なった場合に発生する例外.
    • ArrayIndexOutOfBoundsException
      • 配列の範囲を超えてアクセスしようとした時に発生する例外.
    • NumberFormatException
      • 数値の変換に失敗した時に投げられる例外.

非検査例外は,事前にチェックすることで,例外の発生を抑えられます. 逆に言えば,実行時に非検査例外が投げられた場合は,事前のチェックが不十分であるとも言えます. 例えば,配列の範囲を超えてアクセスすることは,事前に配列の範囲を超えないようにプログラム中で確認することで避けられます.

例外の責任転嫁

さて,例外が発生した時の対処法をプログラム中に書いておく必要があります. 取れる対処法は2つです.

  • 例外が投げられたら,その場で例外に対応する.
  • 例外が発生する可能性のあるメソッドを呼び出している元に対応を任せる.

どちらがふさわしいかは場合により異なります. ここでは,呼び出し元に対応を任せましょう. そのために,呼び出し元に責任を丸投げするようにプログラム中に明示しておきましょう.

こうすることで,例外が発生した時はそのメソッドの呼び出し元に責任を転嫁し,正常処理のみに集中して処理を書くことができます.

情報

本講義では,例外に対応する処理については省略します. 詳細を知りたい場合は,try-catch について調べてみてください.

InterruptedException の責任転嫁

さて,コンパイルエラーで,InterruptedExceptionが投げられる可能性があると述べられています. この例外は,スリープ中に割り込みが発生した時に発生する例外です. ここでは,呼び出し元に責任を転嫁しましょう. 以下のような対応になります.

  • Thread.sleep が例外を発生させた時,Thread.sleep の呼び出し元であるroundTripに対応が任されます.これが例外が投げられた,ということです.
  • そこで,roundTripの呼び出し元であるrunに対応を任せましょう.
  • さらに,runでも,呼び出し元であるmainに処理を任せる事にします.
  • 同じく,mainも呼び出し元に対応を任せましょう.
  • すると,実行環境が対応されなかった例外をスタックトレースという形で出力し,プログラムが終了するようになります.

throws 節

呼び出し元に対応を任せるには,メソッドのシグネチャthrows節を追加します.

throws 節は以下のように指定します. 以下のように書くことで,例外クラスが発生した時,methodNameの呼び出し元に責任を転嫁することができるようになります.

public class ClassName{
  void methodName() throws 例外クラス {
    // 検査例外が発生する可能性のある処理
  }
}

なお,複数の例外が発生する可能性のある場合,throws節に例外クラスの名前をコンマ区切りで指定できます.

例題 3. アニメーション

すなわち,メソッドの宣言部分を以下のように変更してください.

public class RoundTrip{
    void run() throws InterruptedException{
        // ... 省略
        // roundTripで IntrruptedException が発生する可能性がある.
        this.roundTrip(circle);
    }
    void roundTrip(EZCircle circle)
            throws InterruptedException{
        // .... 省略
        // Thead.sleepの呼び出しで
        // IntrruptedExceptionが発生する可能性がある.
        Thread.sleep(100);
    }
    public static void main(String[] args) throws InterruptedException{
        RoundTrip trip = new RoundTrip();
        // runでIntrruptedExceptionが発生する可能性がある.
        trip.run();
    }
}

このようにプログラムを変更し,コンパイルしてみましょう.今度はコンパイルできたはずです. 次のような実行結果となるはずです.

アニメーションの例

このように,アニメーションを行うには,スリープが必要です. 一方,スリープを行うには,例外への対応が必要になります. 今後,ファイルの入出力を扱う時にも例外機構は必要になってきますので, どのような機構であるのか,しっかりと押さえておいてください.

スリープを行わないと,環境によっては目にも留まらぬ速さでアニメーションが繰り広げられます.

import java.awt.Color;

public class RoundTrip{
    void run() throws InterruptedException{
        EZ.initialize(400, 400);
        EZCircle circle = EZ.addCircle(100, 100, 5, 5, Color.BLUE, true);
        roundTrip(circle);
    }
    void roundTrip(EZCircle circle) throws InterruptedException{
        Integer vx = 10;
        while(true){ // 無限ループ
            Integer x = circle.getXCenter() + vx;
            circle.translateTo(x, circle.getYCenter());
            if(x >= 400 || x<= 0){
                vx = vx * -1;
            }
            EZ.refreshScreen();
            Thread.sleep(100);
        }
    }
    public static void main(String[] args) throws InterruptedException{
        RoundTrip trip = new RoundTrip();
        trip.run();
    }
}

例題 4. 鉛直投げ上げ運動のアニメーション

以下の実行結果になるよう,鉛直投げ上げ運動のアニメーションを作成してみましょう. (アニメーションが途中で終わっているので変な挙動のように見えますが,バウンドし続ける動きになっています) クラス名は Bound としてください.

$y$方向の 0 が一番上,下方向がプラスになっていますので,通常の投げ上げとは方向が逆になっています(上方向に重力がかかっていると思ってください). 必要な式は次の通りです.

  • 時間$t$は 0 から始まり,0.1 ずつ増加するものとする.
    • 投げ上げ運動,自由落下運動それぞれに切り替わるとき,$t=0$となる.
  • 初期値(投げ上げ運動)
    • 初速 $v_0 = 85.0$
    • 初期位置 $y_0 = 10.0$
    • 時間 $t=0.0$
    • 重力加速度 $g=9.8$
  • 投げ上げ運動と自由落下運動の切り替え条件
    • 投げ上げ運動の時
      • $v<0$となったとき,自由落下運動に切り替わる.
    • 自由落下運動の時
      • $y < 10$となったとき,投げ上げ運動に切り替わる.
        • 本来の物理世界であれば, $v_{i -1}$に跳ね返り係数をかけ, $v_i$とするが,今回は跳ね返り係数を 1 としている.
  • 投げ上げ運動と自由落下運動の切り替え時の値の更新
    • 時間 $t$ を初期化する( $t=0$).
    • 初速を更新する( $v_0=-v$).
    • 初期位置を更新する( $y_0 = y$).
  • その他
    • 時刻 $t$における速度を求める.
      • $v = v_0 - gt$
    • 時刻 $t$における現在位置を求める.
      • $y = y0 + (v_0 t - \frac{1}{2}gt^2)$

跳ね返り

import java.awt.Color;

public class Bound{
    void run() throws InterruptedException{
        Integer x = 100;
        Double y0 = 10.0;
        Double y = y0;
        EZ.initialize(400, 400);
        EZCircle circle = EZ.addCircle(x, y.intValue(), 5, 5, Color.RED, true);

        Double v = 85.0;
        Double v0 = v;
        Double t = 0.0;
        Double g = 9.8;
        Boolean nageage = true;

        while(true){
            v = v0 - g * t;
            y = y0 + (v0 * t - (g / 2) * t * t);

            if(isSwitch(nageage, v, y)){ // 切り替え条件を確認する.
                t = 0.0;
                v0 = -1 * v;
                y0 = y;
                nageage = !nageage;
            }
            t += 0.1;

            // デバッグ用.
            // System.out.printf("(x, y) = (%3d, %+4d, %+4.2f), t = %5.2f, v = %+5.2f, g = %+5.2f, v0 = %+5.2f, nageage: %s%n",
            //                   x, y.intValue(), y0, t, v, g, v0, nageage);
            circle.translateTo(x, y); // 位置を更新する.
            EZ.refreshScreen();       // 画面を更新する.

            Thread.sleep(100);        // 0.1秒間スリープする.
        }
    }

    Boolean isSwitch(boolean nageage, Double v, Double y){
        if(nageage && v < 0){ // 投げ上げ時,最高点に達した.
            return true;
        }
        else if(!nageage && y < 10.0){ // 自由落下時,地面に達した.
            return true;
        }
        return false;
    }

    public static void main(String[] args) throws InterruptedException{
        Bound bound = new Bound();
        bound.run();
    }
}

練習問題

1. 図形の描画

次の図になるようにプログラムを作成してみましょう. 色は必ずしもこの通りでなくて構いません. プログラム名は,DrawShapes2としてください.

図形の描画の完成形

2. サイン波の描画

$0 \leq x \leq 2\pi$ の範囲でサイン波を描画してみましょう.クラス名は SineCurve としてください.

サイン波

ヒント

プログラム全体の構成

上の図は,399 本の直線で描画されています. x=0から400未満まで繰り返し,v = Math.sin(i * delta) * s$(x_i, y_i)=(i, v)$を得ます. $((x_{i-1}, y_{i-1}), (x_i, y_i))$に線を追加することで上記のサイン波が描画できます.

なお,delta$\delta$)は横幅,s$s$)は高さを表しています. EZ.initialize(400, 400) で初期化した時, delta$\delta = \frac{2\pi}{400}$sは 150としてください( $-1 \leq \sin\theta \leq 1$であり,この値を -150〜150に割り当てるため). また,中央に寄せるため,y 軸方向に +200 としてください.

Java での $sin$,$cos$

Java で, $\sin$, $\cos$を計算するには,Math.sinMath.cosメソッドを利用しましょう. ただし,以下の点に注意してください.

  • 引数にはラジアンの値を渡してください.
  • $\pi$を利用するには,Math.PIという変数を利用してください.
  • すなわち,$\sin \frac{\pi}{3}, \cos \frac{\pi}{3}$を Java で求めるには,次のようなコードを用いてください.
Double sinValue = Math.sin(Math.PI / 3.0);
Double cosValue = Math.cos(Math.PI / 3.0);

Double 型の Integer 型への変換

Double型をInteger型として扱うには,Double型の変数に対して,intValueメソッドを呼び出しましょう. 例えば,Double型のdValueという値を Integer型のiValueに代入するには,次のようなプログラムになります.

Double dValue = // Double型の値を代入.
Integer iValue = dValue.intValue();

3. コッホ曲線(Koch curve)の描画

コッホ曲線は,線分を三等分し,分割点を頂点とした正三角形を描く線です. この作図を無限に繰り返すことで,線分の長さが$\infty$になります. コマンドライン引数でコッホ曲線の $n$ を指定できるようにしましょう.

クラス名は,KochCurveとしてください. コッホ曲線の例を以下に示します(画像のクリックで画像が更新されます).

コッホ曲線
上記のように,$(x_1, y_1)$と$(x_5, y_5)$が指定された時,$(x_2, y_2)$〜$(x_4, y_4)$を求めましょう.
  • $l=(\sqrt{(x_5 - x_1)^2 + (y_5 - y_1)^2})/3$
  • $(x_2, y_2) = (x_1 + l, y_1)$
  • $(x_3, y_3) = (x_2 + l\cos{\frac{\pi}{3}}, y_2 + l\sin{\frac{\pi}{3}})$
  • $(x_4, y_4) = (x_1 + 2l, y_1)$
$n=1$までは上記のように計算できますが,$n>2$の場合はこの計算では求められません. 次の例を元に考えてみましょう(画像のクリックで画像が更新されます).
上記のように,$(x_1, y_1)$と$(x_5, y_5)$が指定された時,$(x_2, y_2)$〜$(x_4, y_4)$を求めましょう. 元々の傾きを$\theta$として示します.
  • $l=(\sqrt{(x_5 - x_1)^2 + (y_5 - y_1)^2})/3$
  • $(x_2, y_2) = (x_1 + l\cos{\theta}, y_1 + l\sin{\theta})$
  • $(x_3, y_3) = (x_2 + l\cos{(\theta + \frac{\pi}{3})}, y_2 + l\sin{(\theta + \frac{\pi}{3})})$
  • $(x_4, y_4) = (x_3 + l\cos{(\theta + \frac{\pi}{3} - \frac{2\pi}{3})}, y_3 + l\sin{(\theta + \frac{\pi}{3} - \frac{2\pi}{3})}) = (x_3 + l\cos{(\theta - \frac{\pi}{3})}, y_3 + l\sin{(\theta - \frac{\pi}{3})})$

この計算式で,コッホ曲線を描いてみましょう. 再帰呼び出しを利用すると良いでしょう. $(x_2, y_2),(x_3, y_3)$間の直線を引く時,また,$(x_3, y_3),(x_4, y_4)$間の直線を引く時に それぞれを$(x_1, y_1),(x_5, y_5)$として再帰呼び出しを行えばコッホ曲線を描けるでしょう.

ヒント

再帰呼び出し

次のようなメソッドを用意しましょう.このメソッドを呼び出すことで,2点の間にコッホ曲線を描けるようになります.

void drawKoch(Integer x1, Integer y1, Integer x5, Integer y5,
      Integer dimension, Double angle){

  if(dimension == 0){
    // (x1, y1)から(x5, y5)まで線を引く.
  }
  else{
    // (x1, y1), (x5, y5) 間の長さの 1/3.これが l となる.
    Double length = // 長さlを求める.
    Double delta = Math.PI / 3.0;

    // (x2, y2) を求める.
    // (x1, y1)から(x2, y2)まで線を引く.

    // (x3, y3) を求める(θ は angle + delta).
    // (x2, y2)から(x3, y3)まで線を引く.
    this.drawKoch(x2.intValue(), y2.intValue(),
                  x3.intValue(), y3.intValue(),
                  dimension - 1, angle + delta);

    // (x4, y4) を求める(θ は angle - delta).
    // (x3, y3)から(x4, y4)まで線を引く.
    this.drawKoch(x3.intValue(), y3.intValue(),
                  x4.intValue(), y4.intValue(),
                  dimension - 1, angle - delta);

    // (x4, y4)から(x5, y5)まで線を引く.
  }
}

実行例

コッホ曲線

画像のクリックで画像が更新されます.

4. コッホ曲線(Koch curve)のアニメーション描画

コッホ曲線を $n=0$から$n=5$までを 1 秒程度で更新して描いてみましょう. クラス名は,KochCurveAnimationとしてください.

今まで描画した内容を消したい場合は,EZ.removeAllEZElements() メソッドを呼び出してください.

5. 斜方投射

例題 3例題 4を合わせた動きをするボールを描きましょう. $x$軸方向には,例題3を,$y$軸方向には,例題4の動きを設定すれば良いでしょう. クラス名を ThrowingExercise にしてください.

実行例

斜方投射の実行例

6. アニメーション

EZ Graphics を利用して,自由にアニメーションを描いてください. クラス名は Animation としてください. 内容は自由です.

7. アナログ時計

EZ.javaを利用して,アナログ時計を描画してください. クラス名は Clockとします.

実行例

アナログ時計 アナログ時計

この時の時刻は,19時22分25秒.

ヒント

基本的な描画方法

  1. 現在時刻を取得する.
  2. 背景を描画する.
  3. 短針,長針,秒針の両端座標を計算で求める.
  4. 短針,長針,秒針をそれぞれ描画する.
  5. 適当な時間(1,000ミリ秒(1秒))スリープする.
  • ただし,何秒かに一回程度,描画されない秒が出てくる.
  • 針の両端座標の計算処理の積み重ねのため.
  • これを防ぐためには,スリープの秒数を少なくする.
  • ただし,そのぶん処理が重くなり,チラツキの原因となる.
  • これらの問題は,ここでは解決する必要はない.
  1. 全ての描画をクリアする.
  2. 1に戻る.
  1. スリープ時間を100ミリ秒程度に短くする.
  2. 時,分,秒を保持しておく.
  3. 時,分,秒が更新されていた場合のみ描画する.

度数法から弧度法(ラジアン)に変更する

  • Math.toRadians に度数法の値(0〜360度)を渡せばラジアン値が得られる.
Double degree = // 度数法による角度
Double radian = Math.toRadians(degree); // 弧度法による角度

針の両端座標の計算

  • 中心座標は常に同じ.もう一方の端を毎秒,計算により求める.
  • 秒針は degreeOfSeconds = date.getSeconds() * 6.0 - 90 で得られる角度を元に計算する.
  • 長針も秒針と同様.date.getSeconds()の代わりに date.getMinutes() を利用する.
  • 短針は,degreeOfHours = (date.getHours() * 5 + date.getMinutes() / 12.0) * 6.0 - 90 で値を得る.
    • date.getHours() * 30 - 90で計算すると,例えば,6:30 の時でも短針は一番下を指したままである. そうではなく,長針が進むに従って,短針も少しずつ移動して欲しいため,上記の処理としている.

Javaの座標では,0度はx軸のプラス方向である. 一方で,アナログ時計の0はy軸のマイナス方向(上方向)である. そのため,角度を-90度にすることで,座標の角度と時間による角度を揃えられる.

なお,角度のプラス方向は共に時計回りである.

再描画

EZ.refreshScreen() を呼び出すと,今まで追加した要素を再描画します. つまり,これまでに addLineaddCircleなどで追加した図形を再描画します. ここでは,1秒ごとに新たな線を引きたいため,今まで追加した図形を削除する必要があります. そのために,EZ.refreshScreen()ではなく,EZ.removeAllEZElements()を呼び出す必要があります.

もちろん,追加した EZLine の実体に対して,適切に座標を変更した上で,EZ.refreshScreen()を呼び出せば期待通りの動作となります.

参考

まとめ

まとめ