USBシリアル3Dグラフアプリの製作(Android Studio Kotlin)


スマホとRaspberry Pi Pico WをUSBシリアル通信で接続して、Raspberry Pi Pico Wから送信する3個のデータにもとづいて、X-Y-Zの三次元グラフをリアルタイム表示するスマホアプリを製作しました。使用した開発ツールは、Android Studio Giraffe 2022.3.1 Path 3で、使用したプログラム言語はKotlinです。

グラフ描画は専用のライブラリーは使用していません。カスタムViewを作成して、Viewへの描画を行うCanvasオブジェクトを使用して、直線図形の描画等を組み合わせて作成しています。

アプリの動作は、Raspberry Pi Pico Wから以下の形でX、Y、Zの3個のデータをUSBシリアル通信でスマホに送信するとグラフがリアルタイムに表示されます。
X:「Xデータ」,Y:「Yデータ」,Z:「Zデータ」「ターミネータ(”r¥n”)」
例 ”X:12.34,Y:2.452,Z:455.3\r\n”

また、このアプリは、以前の記事で紹介しましたパソコンのX-Y-ZシリアルプロッタソフトのAndroidスマホ版に相当します。
パソコン版のX-Y-Zシリアルプロッタソフトに関しては機能を盛り込みすぎたために、プログラムが煩雑になりすぎて、ソフトウェアのコード等を紹介出来ませんでした。今回作成したスマホ版に関しては機能を限定して簡略化したので、その詳細を紹介します。

・パソコンのX-Y-Zシリアルプロッタソフトに関してはこちらの記事を参考にしてください。
・スマホのUSBシリアル通信に関してはこちらの記事を参考にして下さい。

1.アプリ使用例の動画

今回作成したアプリの使用例の動画です。スマホとRaspberry Pi Pico WをUSB接続してRaspberry Pi Pico W側からデータを送信することによって三次元のグラフを描いています。

例は以下3種類です。
(1)Raspberry Pi Pico Wでグラフに球を描くデータを作成してそれをスマホに送りグラフ描画。
(2)Raspberry Pi Pico Wで3個のステッピングモータを制御し、ヘリカル補間の動作を行い、その3個のステッピングモータのステップ位置を描画。
(3)Raspberry Pi Pico Wで3個のステッピングモータを制御し、直線補間を組み合わせた動作を行い、その3個のステッピングモータのステップ位置を描画。

Raspberry Pi Pico W側のプログラムに関しては、(1)は本記事に掲載しています。(2)、(3)は以前の記事で紹介したプログラムをそのまま使用しています。
以前の記事は以下を参照してください。

Raspberry Pi Pico Wを使用したヘリカル補間に関する記事はこちら
Raspberry Pi Pico Wを使用した3軸の直線補間に関する記事はこちら

2.スマホとRaspberry Pi Pico WのUSB接続に関して

使用したスマホにはType CのUSBコネクタが付いていますので、Type CからType A (メス)への変換ケーブル(OTGケーブル)を使用しました。それとType AとType micro-BのUSBケーブルを接続してスマホとRaspberry Pi Pico Wを接続しました。(図1)

図1.スマホとRaspberry Pi Pico WのUSB接続

3.usb-serial-for-androidに関して

USBシリアル通信を行うのに、usb-serial-for-androidのライブラリを使用しました。(詳細はhttps://github.com/mik3y/usb-serial-for-androidを参照して下さい)
以下でusb-serial-for-androidのライブラリを使用する手順に関して説明します。

4.usb-serial-for-androidを使用する手順

1.settings.gradle.kts(Project Settings)の設定
app→Gladle Scripts→settings.gradle.kts(Project Settings)のdependencyResolutionManagementのrepositories内に、
maven { url = uri(“https://jitpack.io”) }
を追加します。(下記6行目)

dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
        maven { url = uri("https://jitpack.io") }
    }
}

2.build.gradle.kts(Module:app)の設定
app→Gladle Scripts→build.gradle.kts(Module:app)のdependencies内に
implementation(“com.github.mik3y:usb-serial-for-android:3.8.1”)
を追加します。(下記7行目)

dependencies {

    implementation("androidx.core:core-ktx:1.9.0")
    implementation("androidx.appcompat:appcompat:1.7.0")
    implementation("com.google.android.material:material:1.12.0")
    implementation("androidx.constraintlayout:constraintlayout:2.2.0")
    implementation("com.github.mik3y:usb-serial-for-android:3.8.1")
    testImplementation("junit:junit:4.13.2")
    androidTestImplementation("androidx.test.ext:junit:1.2.1")
    androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
}
3.AndroidManifest.xmlの設定
スマホのUSBにRaspberry Pi Pico Wが接続された場合に、今回作成したアプリの選択画面を表示するために、app→manifests→AndroidManifest.xmlに以下の記述を追加します。

<intent-filter>~</intent-filter>タグ内に
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
を追加します。(下記6行目)

また、
<meta-data
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
android:resource="@xml/device_filter" />
を以下の様に追加します。(下記10行目~12行目)
<activity
    android:name=".MainActivity"
    android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>

    <meta-data
        android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
        android:resource="@xml/device_filter" />
</activity>

4.device_filter.xmlの作製
Raspberry Pi Pico WのUSBのVendor IDは0x2e8a (11914)、Product IDは0xf00a (61450)です。
これをapp→res→xmlの下にdevice_filter.xmlと言う名称のxmlファイルを作成して、以下の様に記述することによって、スマホのUSBにRaspberry Pi Pico Wが接続された場合に、今回作成したアプリの選択画面を表示することができます。そのアプリ名称を選択しタップすると、アプリが起動されます。

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <!--Raspberry Pi Pico W USB serial-->
    <usb-device vendor-id="11914" product-id="61450" /> <!--0x2e8a / 0xf00a: Pico W -->
</resources>

5.Kotlinプログラム

作成したアプリは、全体的な処理(USBシリアル通信データの受信や、三次元グラフへの描画)を記述したMainActivityクラスと、三次元グラフのカスタムViewを記載したArtViewクラスから構成されています。以下でMainActivityクラスとArtViewクラスに関して説明します。

5-1、MainActivityクラス(全体的な処理)

MainActivityクラスではUSBシリアル通信ポートの接続、データ受信、カスタムView(三次元グラフ)に受信したデータをプロットする動作をおこないます。その部分のソースコードの主要要素部分を以下に示します。

USBシリアル通信ポートから受信するデータは以下の形をしています。
X:「Xデータ」,Y:「Yデータ」,Z:「Zデータ」「ターミネータ(”r¥n”)」
例 ”X:12.34,Y:2.452,Z:455.3\r\n”

①USBシリアル通信の接続部分のソースコード

USBシリアル通信を接続する部分のKotlinのソースコードを以下に示します。

    //----USBシリアル通信接続の実施-------
    //Raspberry Pi Pico WのベンダーIDとプロダクトID
    val VENDOR_ID: Int = 0x2e8a     //11914
    val PRODUCT_ID: Int = 0xf00a    //61450
    //シリアルポート
    var port: UsbSerialPort? = null
    
    fun serialConnect(): Boolean {
        val usbManager: UsbManager = getSystemService(Context.USB_SERVICE) as UsbManager
        var driver: UsbSerialDriver? = null
        for (dev in UsbSerialProber.getDefaultProber().findAllDrivers(usbManager)) {
            val device: UsbDevice = dev.device
            //ベンダIDとプロダクトIDがPico Wと一致することを確認
            if ((device.vendorId == VENDOR_ID) && (device.productId == PRODUCT_ID)) {
                driver = dev
                break
            }
        }

        if (driver == null) {
            return false
        } else {
            if (usbManager.hasPermission(driver.device) == true) { //USB使用許可確認
                runOnUiThread {
                    Toast.makeText(this@MainActivity, "USB使用パーミッションOK", Toast.LENGTH_LONG).show()
                }
            } else {
                runOnUiThread {
                    Toast.makeText(this@MainActivity, "USB使用パーミッションNG", Toast.LENGTH_LONG).show()
                }
                return false
            }
        }

        val connection = usbManager.openDevice(driver!!.device)
        if (connection == null) {
            return false
        }

        port = driver!!.ports[0]
        port?.open(connection)
        port?.setParameters(9600, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)
        port?.dtr = true    //Dtrをtrueにする
        port?.rts = true    //Rtsをtrueにする

        return true
    }

【説明】
3行目、4行目:
val VENDOR_ID: Int = 0x2e8a
val PRODUCT_ID: Int = 0xf00a
で、Raspberry Pi Pico WのUSBのVendor IDとProduct IDを定義しています。

14行目、15行目:
device.vendorIdでUSB接続されているデバイスのVendor IDが得られます。
device.productIdでUSB接続されているデバイスのProduct IDが得られます。
Vendor IDとProduct IDがRaspberry Pi Pico Wのそれと一致する場合に、そのUSBシリアルドライバを選びます。

23行目:
if (usbManager.hasPermission(driver.device) == true) {
で、USBの使用許可が取れているかの確認をします。

前述したAndroidManifest.xmlとdevice_filter.xmlの記述がされている場合、スマホのUSBにRaspberry Pi Pico Wが接続された場合に、今回作成したアプリの選択画面が表示されます。
その時に、そのアプリ名称を選択しタップすると、アプリによるUSBの使用が許可された上でアプリが起動されますので、その手順を経た場合は、usbManager.hasPermission(driver.device)はtrueとなります。

何等かの状況でusbManager.hasPermission(driver.device)がfalseの場合は、USBの使用許可を要求する処理が必要ですが、簡略化のため今回のアプリでは無とします。

43行目、44行目:
port?.dtr = true
port?.rts = true
Raspberry Pi Pico Wとデータをやり取りするためには、USBシリアル通信ポートの信号線であるDtrとRtsをtrueにする必要があります。

②データの送信部分のソースコード

今回作成したアプリでは使用していませんが、以下は、USBシリアル通信を使用して、例として文字列”TEST\n”を送信する場合のソースコードです。

val data: String = "TEST\n"
val byteData = data.toByteArray()  //バイト配列にする
port?.write(byteData, 1000)        //データ送信

【説明】
1行目、2行目:
val data: String = “TEST\n”
val byteData = data.toByteArray()
で、文字列 “TEST\n”を8bitのバイナリに変換してバイトアレイにします。

3行目:
port?.write(byteData, 1000)
でデータを送信します。第1引数は送信するデータのバイトアレイ、第2引数はオーバタイム時間(msec単位)です。

③データの受信部分のソースコード

以下は、USBシリアル通信を使用して、データを受信する部分のソースコードです。

val rcvBuffer: ByteArray = ByteArray(100)  //受信バッファ
val numBytes = port?.read(rcvBuffer, 20)

【説明】
2行目:
val numBytes = port?.read(rcvBuffer, 20)
でUSBシリアル通信ポートから受信データを呼び出します。readメソッドの第1引数は、受信データを格納するバイトアレイです。第2引数はオーバタイム時間(msec単位です)

④三次元グラフプロット部分のソースコード

作製したアプリではArtViewと言う名称の三次元グラフのカスタムViewを作成しました。
このカスタムViewの詳細は5-2項を参照して下さい。
ここでは、カスタムViewのメソッドを使用して三次元グラフをプロットする部分のソースコードを示します。

val artView = findViewById<ArtView>(R.id.artView)
artView.drawPathLine(dataX, dataY, dataZ)    //グラフプロット

【説明】
1行目:
val artView = findViewById<ArtView>(R.id.artView)
今回作製した三次元グラフのカスタムViewの名称はArtViewでidはartViewなので、そのカスタムViewを得ています。

2行目:
artView.drawPathLine(dataX, dataY, dataZ)
のdrawPathLine()メソッドは今回作成したカスタムViewで三次元グラフにデータをプロットするメソッドです。dataX: FloatとdataY: FloatとdataZ: Floatで指定した位置に点をプロットして、前回のプロット点との間を線で結びます。

⑤MainActivityクラスの全ソースコード

以下にMainActivityクラスの全ソースコードを示します。データの受信部分は、スレッドで行っています。

このプログラムの動作は、アプリ画面の「接続」ボタンを押すとUSBシリアル通信が接続され、Raspberry Pi Pico Wからのデータに応じて三次元グラフが表示されます。「切断」ボタンを押すとUSBシリアル通信が切断されます。

「CLEAR」ボタンは描画されたグラフを消すボタンです。
「再描画」ボタンはグラフの目盛値を変更した場合にグラフを再描画するボタンです。
「オフセット読込」ボタンはグラフの原点オフセット値等を読込んでEditTextに表示するボタンです。

class MainActivity : AppCompatActivity() {
    //Raspberry Pi Pico WのベンダーIDとプロダクトID
    val VENDOR_ID: Int = 0x2e8a     //11914
    val PRODUCT_ID: Int = 0xf00a    //61450
    //シリアルポート
    var port: UsbSerialPort? = null
    //接続フラグ
    var connectFlag: Boolean = false
    //停止フラグ
    var stopFlag: Boolean = false

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        //-----押し釦スイッチ関係リスナ----------
        //接続ボタンのonClickListenerを設定
        val connectButton = findViewById<Button>(R.id.connectButton)
        connectButton.setOnClickListener(ConnectButtonOnClickListener())
        //切断ボタンのonClickListenerを設定
        val disconnectButton = findViewById<Button>(R.id.disconnectButton)
        disconnectButton.setOnClickListener(DisconnectButtonOnClickListener())
        //クリアボタンのonClickListenerを設定
        val clearButton = findViewById<Button>(R.id.clearButton)
        clearButton.setOnClickListener(ClearButtonOnClickListener())
        //再描画ボタンのonClickListenerを設定
        val redrawButton = findViewById<Button>(R.id.redrawButton)
        redrawButton.setOnClickListener(RedrawButtonOnClickListener())
        //オフセット読込ボタンのonClickListenerを設定
        val readOffsetButton = findViewById<Button>(R.id.readOffsetButton)
        readOffsetButton.setOnClickListener(ReadOffsetButtonOnClickListener())
    }

    //接続ボタン押された時のonClickListener
    private inner class ConnectButtonOnClickListener : View.OnClickListener {
        override fun onClick(v: View?) {
            if (connectFlag == false) { //接続中?
                //シリアル通信接続と受信スレッドスタート
                stopFlag = false    //停止フラグクリア
                val connectThread = ConnectThread()
                connectThread.start()
            }
        }
    }

    //切断ボタン押された時のonClickListener
    private inner class DisconnectButtonOnClickListener : View.OnClickListener {
        override fun onClick(v: View?) {
            stopFlag = true     //停止フラグセット
        }
    }

    //クリアボタン押された時のonClickListener
    private inner class ClearButtonOnClickListener : View.OnClickListener {
        override fun onClick(v: View?) {
            val artView = findViewById<ArtView>(R.id.artView)
            artView.drawClr()       //プロット点のクリア
        }
    }

    //再描画ボタン押された時のonClickListener
    private inner class RedrawButtonOnClickListener : View.OnClickListener {
        override fun onClick(v: View?) {
            //X,Y,Z軸オフセット値をEditTextから読み込み
            val xOffsetEditText = findViewById<EditText>(R.id.xOffsetEditText)
            val xOffset = xOffsetEditText.text.toString().toFloat()

            val yOffsetEditText = findViewById<EditText>(R.id.yOffsetEditText)
            val yOffset = yOffsetEditText.text.toString().toFloat()

            val zOffsetEditText = findViewById<EditText>(R.id.zOffsetEditText)
            var zOffset = zOffsetEditText.text.toString().toFloat()

            //1目盛り値をEditTextから読み込み
            val scale1ValEditText = findViewById<EditText>(R.id.scale1ValEditText)
            val scaleVal: Float = scale1ValEditText.text.toString().toFloat()
            var dpPerValue1: Float = 0f
            if (scaleVal != 0f) {
                dpPerValue1 = 10f / scaleVal
            }else{
                dpPerValue1 = 1f
            }

            //X,Y,Z軸オフセット値と1.0に対するdp値の設定と再描画
            val artView = findViewById<ArtView>(R.id.artView)
            runOnUiThread {     //UIスレッド
                artView.setOffsetDp(xOffset, yOffset, zOffset, dpPerValue1)
            }
        }
    }

    //オフセット読込ボタン押された時onClickListenerの
    private inner class ReadOffsetButtonOnClickListener : View.OnClickListener {
        override fun onClick(v: View?) {
            val artView = findViewById<ArtView>(R.id.artView)
            val xOffset = artView.xOffset
            val yOffset = artView.yOffset
            val zOffset = artView.zOffset
            val dpPerValue1 = artView.dpPerValue1

            //X,Y,Z軸オフセット値の設定
            val xOffsetEditText = findViewById<EditText>(R.id.xOffsetEditText)
            xOffsetEditText.setText(xOffset.toString())

            val yOffsetEditText = findViewById<EditText>(R.id.yOffsetEditText)
            yOffsetEditText.setText(yOffset.toString())

            val zOffsetEditText = findViewById<EditText>(R.id.zOffsetEditText)
            zOffsetEditText.setText(zOffset.toString())
            //1目盛値の設定
            val scale1ValEditText = findViewById<EditText>(R.id.scale1ValEditText)
            val scaleVal = 10f / dpPerValue1
            scale1ValEditText.setText(scaleVal.toString())
        }
    }

    //接続と受信スレッド
    private inner class ConnectThread() : Thread() {
        private val RCV_BUF_SIZE: Int = 1024                        //受信バッファサイズ
        private val rcvBuffer: ByteArray = ByteArray(RCV_BUF_SIZE)  //受信バッファ
        private var rcvString: String? = null                       //受信データ文字列

        public override fun run() {
            serialConnect()     //USBシリアル通信接続の実施

            runOnUiThread {
                Toast.makeText(this@MainActivity, "USBシリアル通信接続完了", Toast.LENGTH_LONG).show()
            }

            connectFlag = true      //接続中フラグセット

            //X,Y,Z軸オフセット値の設定
            val xOffsetEditText = findViewById<EditText>(R.id.xOffsetEditText)
            val xOffset = xOffsetEditText.text.toString().toFloat()

            val yOffsetEditText = findViewById<EditText>(R.id.yOffsetEditText)
            val yOffset = yOffsetEditText.text.toString().toFloat()

            val zOffsetEditText = findViewById<EditText>(R.id.zOffsetEditText)
            var zOffset = zOffsetEditText.text.toString().toFloat()

            //1目盛り値の読み込み
            val scale1ValEditText = findViewById<EditText>(R.id.scale1ValEditText)
            val scaleVal: Float = scale1ValEditText.text.toString().toFloat()
            var dpPerValue1: Float = 0f
            if (scaleVal != 0f) {
                dpPerValue1 = 10f / scaleVal
            }else{
                dpPerValue1 = 1f
            }

            //X,Y,Z軸オフセット値と1.0に対するdp値の設定
            val artView = findViewById<ArtView>(R.id.artView)
            runOnUiThread {     //UIスレッド
                artView.setOffsetDp(xOffset, yOffset, zOffset, dpPerValue1)
            }

            var numBytes: Int? = 0
            while (true) {
                try {
                    numBytes = port?.read(rcvBuffer, 20)
                }catch (e: IOException){
                    connectFlag = false //接続中フラグクリア
                    break
                }

                if (numBytes != 0) {
                    //受信データを文字列に変換する
                    var rcvData: String = String(rcvBuffer, 0, numBytes!!)

                    //前回までに受信したデータと結合
                    if (rcvString == null) {
                        rcvString = rcvData
                    } else {
                        rcvString = rcvString + rcvData
                    }

                    //ターミネータ"\r\n"検索
                    val termPos: Int = rcvString?.lastIndexOf("\r\n")!!
                    if (termPos != -1) {  //ターミネータ"\r\n"存在?
                        //"X:"を検索する
                        val XPos: Int = rcvString?.lastIndexOf("X:")!!
                        //"Y:"を検索する
                        val YPos: Int = rcvString?.lastIndexOf("Y:")!!
                        //"Z:"を検索する
                        val ZPos: Int = rcvString?.lastIndexOf("Z:")!!

                        //"X:"と"Y:"と"Z:"が存在して、"X:"が"Y:"より前に存在"Y:"が"Z:"より前に存在
                        if ((XPos != -1) && (YPos != -1) &&(ZPos != -1) && (XPos < YPos) && (YPos < ZPos)) {
                            //"X:"と"Y:"の後の数値の部分を抜き出す
                            val dataX = rcvString?.substring(XPos + 2, YPos - 1)!!.toFloat()
                            val dataY = rcvString?.substring(YPos + 2, ZPos - 1)!!.toFloat()
                            val dataZ = rcvString?.substring(ZPos + 2, rcvString?.length!!)!!.toFloat()
                            val artView = findViewById<ArtView>(R.id.artView)
                            runOnUiThread {     //UIスレッド
                                artView.drawPathLine(dataX, dataY, dataZ)    //グラフプロット
                            }
                        }
                        //受信データストリングクリア
                        rcvString = null
                    }
                }

                if (connectFlag == false){  //接続中?
                    break
                }

                if (stopFlag == true){  //停止要求?
                    break
                }
            }

            port?.close()
            connectFlag = false     //接続中フラグクリア
        }
    }

    //----USBシリアル通信接続の実施-------
    fun serialConnect(): Boolean {
        val usbManager: UsbManager = getSystemService(Context.USB_SERVICE) as UsbManager
        var driver: UsbSerialDriver? = null
        for (dev in UsbSerialProber.getDefaultProber().findAllDrivers(usbManager)) {
            val device: UsbDevice = dev.device
            //ベンダIDとプロダクトIDがPico Wと一致することを確認
            if ((device.vendorId == VENDOR_ID) && (device.productId == PRODUCT_ID)) {
                driver = dev
                break
            }
        }

        if (driver == null) {
            return false
        } else {
            if (usbManager.hasPermission(driver.device) == true) { //USB使用許可確認
                runOnUiThread {
                    Toast.makeText(this@MainActivity, "USB使用パーミッションOK", Toast.LENGTH_LONG).show()
                }
            } else {
                runOnUiThread {
                    Toast.makeText(this@MainActivity, "USB使用パーミッションNG", Toast.LENGTH_LONG).show()
                }
                return false
            }
        }

        val connection = usbManager.openDevice(driver!!.device)
        if (connection == null) {
            return false
        }

        port = driver!!.ports[0]
        port?.open(connection)
        port?.setParameters(9600, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)
        port?.dtr = true    //Dtrをtrueにする
        port?.rts = true    //Rtsをtrueにする

        return true
    }
}

5-2.ArtViewクラス(三次元グラフのカスタムView)

作成したアプリでは、三次元グラフを描画するのに専用のライブラリーは使用していません。
三次元グラフを表示するカスタムViewを作成しました。Viewへの描画を行うCanvasオブジェクトを使用して、直線図形の描画等を組み合わせてグラフを描画しています。

グラフはX軸、Y軸、Z軸の3軸を描画しますが、グラフが立体的に見えるようにするために、下図の様にX軸とY軸間は65°、Y軸とZ軸間は30°の角度を持たせています。

図2.グラフの軸の角度



作製したカスタムViewの名称と、Publicなプロパティとメソッドを表1と表2に示します。

◎カスタムViewの名称: ArtView

◎プロパティ

プロパティ役割
dpPerValue1: Floatデータ値1.0に対して、グラフを描く時にViewの画面上で何dpとするかの値です。
xOffset: Float三次元グラフのX軸原点(左下の点)の値です。
yOffset: Float三次元グラフのY軸原点(左下の点)の値です。
zOffset: Float三次元グラフのZ軸原点(左下の点)の値です。
表1.カスタムViewのPublicなプロパティ

◎メソッド

メソッド役割
drawPathLine(xData: Float, yData: Float, zData: Float)折れ線グラフに指定されたデータを追加して描きます。
(引数)
xData
Xデータ値
yData
Yデータ値
zData
Zデータ値
drawClr()描かれた折れ線グラフをクリアします。
表2.カスタムViewのPublicなメソッド

5-3.ArtViewクラス(カスタムView)のKotlinプログラム

三次元グラフを表示するカスタムView(ArtViewクラス)のKotlinプログラムの主要要素を説明します。

①クラス定義、プロパティ、onDraw()メソッド部分の主要コード

カスタムViewのクラス定義と、プロパティ定義と、AndroidがViewを表示、変更する時とinvalidate()メソッドを実効した時に呼び出されるonDraw()メソッド部分のソースコードを以下に示します。onDraw()メソッド内に全ての描画処理を記述します。

class ArtView(context: Context, attrs: AttributeSet) : View(context, attrs) {
    private val xOrig: Float = 35f  //X軸原点のView画面上の位置(35dp)
    private val yOrig: Float = 380f //Y軸原点のView画面上の位置(380dp)
    private val dens = resources.displayMetrics.density  //画面論理密度
    var dpPerValue1: Float = 0.1f   //データ値1.0対する画面のdp(0.1dp)
    var xOffset: Float = -1000f     //X軸のオフセット
    var yOffset: Float = -1000f     //Y軸のオフセット
    var zOffset: Float = 0f         //Z軸のオフセット

    //sin,cos値
    val cos5deg: Float = Math.cos(5.0 * Math.PI / 180.0 ).toFloat()
    val sin5deg: Float = Math.sin(5.0 * Math.PI / 180.0 ).toFloat()
    val cos60deg: Float = Math.cos(60.0 * Math.PI / 180.0 ).toFloat()
    val sin60deg: Float = Math.sin(60.0 * Math.PI / 180.0 ).toFloat()

    //Pathの初期プロットのフラグ
    private var firstPlotFlag: Boolean = true

    //プロットデータのデータクラス宣言
    data class dataPlot(val x: Float, val y: Float, val z: Float){
        var xData = x
        var yData = y
        var zData = z
    }
    var plotDatas: ArrayList<dataPlot> = ArrayList()

    private val path = Path()

    //プロット線用の描画スタイル定義
    private val pLine = Paint().apply {
        //各プロパティ定義
        color = Color.GREEN
        strokeWidth = 2f * dens
        style = Paint.Style.STROKE
        strokeJoin = Paint.Join.ROUND
    }

    //Viewが表示、変更される時に呼び出されるメソッド
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)

        //ビューに対して描画
        canvas.drawColor(Color.WHITE)   //canvasの色指定
        canvas.drawPath(path, pLine)    //Pathラインの表示
        scaleDisp(canvas)               //スケール表示
    }



【説明】
1行目:
class ArtView(context: Context, attrs: AttributeSet) : View(context, attrs)
は、カスタムViewのクラス宣言です。カスタムViewはViewクラスを継承します。
カスタムViewクラスの名称をArtViewとしました。

2行目~8行目:
主なプロパティに関して下表で説明します。

プロパティ名役割
private val xOrig: Float = 35fグラフ原点(左下位置)のView画面上のX座標位置です。(単位dp)
private val yOrig: Float = 380fグラフ原点(左下位置)のView画面上のY軸座位置です。(単位dp)
private val dens = resources.displayMetrics.density画面の論理密度です。
resources.displayMetrics.density
で得られる論理密度は、1インチ当たりのピクセル数(dpi)を160で割り算したものです。
dp値に、このdensを乗算することによってピクセル値に変換することが出来ます。
var dpPerValue1: Float = 0.1fグラフをプロットする時、データ1.0に対して、画面上で何dpとするかの値です。
var xOffset: Float = -1000fグラフ原点(左下位置)のX軸座標の原点オフセット量(原点の値)です。
var yOffset: Float = -1000fグラフ原点(左下位置)のY軸座標の原点オフセット量(原点の値)です。
var zOffset: Float = 0fグラフ原点(左下位置)のZ軸座標の原点オフセット量(原点の値)です。
表3.主なプロパティ

11行目~14行目:
val cos5deg: Float = Math.cos(5.0 * Math.PI / 180.0 ).toFloat()
val sin5deg: Float = Math.sin(5.0 * Math.PI / 180.0 ).toFloat()
val cos60deg: Float = Math.cos(60.0 * Math.PI / 180.0 ).toFloat()
val sin60deg: Float = Math.sin(60.0 * Math.PI / 180.0 ).toFloat()
は、表示するグラフのX軸とY軸間は65°、Y軸とZ軸間は30°の角度を持たせているので、下図の様にプロットする画面上の位置を計算するのにSIN5°、COS5°、SIN60°、COS60°多用するので、前もって計算しておきます。

図3.座標等角度説明図

20行目:
data class dataPlot(val x: Float, val y: Float, val z: Float){}
は、プロットしたデータを記憶しておくためのデータクラスです。25行目のplotDatasのアレイリストを使用して、過去にプロットしたデータを記憶しておきます。
この記憶したデータは、グラフ描画後に各軸のオフセット値や、1目盛値を変更した場合にグラフを再描画するのに使用します。

30行目:
private val pLine = Paint().apply{}
は、グラフ線の描画のスタイルを指定しています。

39行目:
override fun onDraw(canvas: Canvas) {}
このメソッドはAndroidがViewを表示、変更する時に呼び出されます。またinvalidate()メソッドを実効すると、このメソッドが呼び出されます。このメソッド内で全ての描画の処理を行います。
引数canvas: CanvasはViewへの描画を行うオブジェクトです。

43行目:
canvas.drawColor(Color.WHITE)
はキャンバスの背景色を白に指定します。

44行目:
canvas.drawPath(path, pLine)
はpath: Passのデータにそって、pLineの描画スタイルで折れ線を表示します。
Passのデータの作製に関しては②項で説明します。

メソッド内容
fun drawPath(path: Path, paint: Paint)Pathに沿って図形描画する
(引数)
path: パス
paint: 描画スタイル
表4、drawPathメソッド

45行目:
scaleDisp(canvas)
はX-Y-Z三次元グラフのX軸線と、Y軸線と、Z軸線と、それぞれの目盛値を表示するメソッドです。ソースコードは③の全ソースコードを参照してください。

②データからのPathの構築・描画と描画クリア部分の主要コード

drawPathLine()メソッドは、引数で指定したXデータとYデータとZデータから作製したプロット位置をPathに追加した後に、Pathのデータにそって折れ線を描画するメソッドです。
drawClear()メソッドは、描画した折れ線をクリアするメソッドです。
それらのメソッドのソースコードを以下に示します。

    //Pathにプロット点を追加して描画
    fun drawPathLine(xData: Float, yData: Float, zData: Float){
        val data = dataPlot(xData, yData, zData)
        plotDatas.add(data)   //前回までのプロットデータに新プロットデータを追加して記憶

        //X,Y,Z値をピクセル位置に変換
        val xDp = (xData - xOffset) * dpPerValue1 * cos5deg + (yData - yOffset) * dpPerValue1 * cos60deg
        val yDp = -(xData - xOffset) * dpPerValue1 * sin5deg + (yData - yOffset) * dpPerValue1 * sin60deg + (zData - zOffset) * dpPerValue1

        val xPix = (xOrig + xDp) * dens
        val yPix = (yOrig - yDp) * dens

        if (firstPlotFlag == true){     //初期プロット点?
            firstPlotFlag = false       //初期プロット点フラグリセット
            path.moveTo(xPix, yPix)
        }
        else{
            path.lineTo(xPix, yPix)
        }

        invalidate()    //onDraw()メソッドを呼び出す
    }

    //プロットした表示全クリア
    fun drawClr(){
        plotDatas.clear()       //記憶したプロットデータをクリアする
        path.reset()            //パスのクリア
        firstPlotFlag = true    //初期プロット点フラグフラグセット
        invalidate()            //onDraw()メソッドを呼び出す
    }

【説明】
2行目:
drawPathLine()メソッドは、引数で指定されたプロット位置をPathに追加した後に、Pathに従って折れ線を描画するメソッドです。

4行目:
plotDatas.add(data)
は、グラフの各軸のオフセット値や1目盛値を変更した時の再描画にそなえて、プロットしたデータをplotDatas.add(data)でアレイリストに記憶しておきます。

7行目~11行目:
val xDp = (xData – xOffset) * dpPerValue1 * cos5deg + (yData – yOffset) * dpPerValue1 * cos60deg
val yDp = -(xData – xOffset) * dpPerValue1 * sin5deg + (yData – yOffset) * dpPerValue1 * sin60deg + (zData – zOffset) * dpPerValue1
val xPix = (xOrig + xDp) * dens
val yPix = (yOrig – yDp) * dens

は、X、Y、Zのデータ値を、View画面のピクセル位置に変換しています。
グラフのX軸とY軸間は65°、Y軸とZ軸間は30°の角度を持たせているので、画面位置のX座標値は、XデータとYデータからSIN,COS計算を使用して得られます。Y座標値はXデータとYデータとZデータからSIN,COS演算をして得られます。

dpPerValue1は、データ値1.0の値がViewの画面上で何dpであるかを示す変数です。データ値にdpPerValue1を乗算することによって、データ値に対応した画面上のdp単位での位置を計算しています。

densは画面の論理密度で、dp値に論理密度を乗算することによって、ピクセル値を得ることができます。

13行目~19行目:
グラフにプロットするView画面上の座標値(X軸方向のピクセル位置とY軸方向のピクセル位置)をPathに追加します。
最初のデータは、moveTo()メソッドを使用してPathに追加します。途中経過のデータはlineTo()メソッドを使用してPathに追加します。moveTo()メソッドとlineTo()メソッドに関して表5に示します。
firstPlotFlagを使用してどちらのメソッドを使用するか判断しています。

メソッド内容
fun moveTo(x:Float, y: Float)パスの最初の点となる座標を指定
(引数)
x: X座標値(単位はピクセル)
y: Y座標値(単位はピクセル)
fun lineTo(x: Float, y: Float)パスの終点または経由点の座標を指定
続けて呼び出すことによって、連続した複数の直線(折れ線)を表現出来る
(引数)
x: X座標値(単位はピクセル)
y: Y座標値(単位はピクセル)
表5.moveToメソッドとlineToメソッド

21行目:
invalidate()メソッドを呼び出すと、onDraw()メソッドが呼び出されて再描画が行われます。

25行目:
drawClr()メソッドは、描画された折れ線をクリアするメソッドです。

27行目、29行目:
描画した折れ線グラフをクリアするdrawClr()メソッドでは、27行目のpath.reset()でPathをクリアした後に、29行目のinvalidate()メソッドからonDraw()メソッドを呼び出して、クリアしたPathを再描画することによって折れ線グラフを消しています。

③全ソースコード

三次元グラフのカスタムViewの全ソースコードを以下に示します。
前記で説明しなかった部分として、X軸、Y軸、Z軸の表示と目盛値表示とグリッド表示が有ります。

X軸、Y軸、Z軸は10dp毎に目盛線を描き、50dpの倍数の部分は長めの目盛線を描いています。
また、50dpの倍数の目盛線の場所に目盛値を描画しています。目盛値の少数点以下は切り捨てています。実際に使用する時は、データ値のレンジ等に応じて切り捨てる位置を変える必用があると思います。

また、グラフが立体的に見えるように50dp毎にグリッド線を描いています。

class ArtView(context: Context, attrs: AttributeSet) : View(context, attrs) {
    private val xOrig: Float = 35f  //X軸原点のView画面上の位置(35dp)
    private val yOrig: Float = 380f //Y軸原点のView画面上の位置(380dp)
    private val dens = resources.displayMetrics.density  //画面論理密度
    var dpPerValue1: Float = 0.1f   //データ値1.0対する画面のdp(0.1dp)
    var xOffset: Float = -1000f     //X軸のオフセット
    var yOffset: Float = -1000f     //Y軸のオフセット
    var zOffset: Float = 0f         //Z軸のオフセット

    //sin,cos値
    val cos5deg: Float = Math.cos(5.0 * Math.PI / 180.0 ).toFloat()
    val sin5deg: Float = Math.sin(5.0 * Math.PI / 180.0 ).toFloat()
    val cos60deg: Float = Math.cos(60.0 * Math.PI / 180.0 ).toFloat()
    val sin60deg: Float = Math.sin(60.0 * Math.PI / 180.0 ).toFloat()

    //Pathの初期プロットのフラグ
    private var firstPlotFlag: Boolean = true

    //プロットデータのデータクラス宣言
    data class dataPlot(val x: Float, val y: Float, val z: Float){
        var xData = x
        var yData = y
        var zData = z
    }
    var plotDatas: ArrayList<dataPlot> = ArrayList()

    private val path = Path()

    //プロット線用の描画スタイル定義
    private val pLine = Paint().apply {
        //各プロパティ定義
        color = Color.GREEN
        strokeWidth = 2f * dens
        style = Paint.Style.STROKE
        strokeJoin = Paint.Join.ROUND
    }

    //Viewが表示、変更される時に呼び出されるメソッド
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)

        //ビューに対して描画
        canvas.drawColor(Color.WHITE)   //canvasの色指定
        canvas.drawPath(path, pLine)    //Pathラインの表示
        scaleDisp(canvas)               //スケール表示
    }

    //Pathにプロット点を追加して描画
    fun drawPathLine(xData: Float, yData: Float, zData: Float){
        val data = dataPlot(xData, yData, zData)
        plotDatas.add(data)   //前回までのプロットデータに新プロットデータを追加して記憶

        //X,Y,Z値をピクセル位置に変換
        val xDp = (xData - xOffset) * dpPerValue1 * cos5deg + (yData - yOffset) * dpPerValue1 * cos60deg
        val yDp = -(xData - xOffset) * dpPerValue1 * sin5deg + (yData - yOffset) * dpPerValue1 * sin60deg + (zData - zOffset) * dpPerValue1

        val xPix = (xOrig + xDp) * dens
        val yPix = (yOrig - yDp) * dens

        if (firstPlotFlag == true){     //初期プロット点?
            firstPlotFlag = false       //初期プロット点フラグリセット
            path.moveTo(xPix, yPix)
        }
        else{
            path.lineTo(xPix, yPix)
        }

        invalidate()    //onDraw()メソッドを呼び出す
    }

    //プロットした表示全クリア
    fun drawClr(){
        plotDatas.clear()       //記憶したプロットデータをクリアする
        path.reset()            //パスのクリア
        firstPlotFlag = true    //初期プロット点フラグフラグセット
        invalidate()            //onDraw()メソッドを呼び出す
    }

    //X,Y,Z軸オフセット値と1.0に対するdp値の変更
    fun setOffsetDp(offsetX: Float, offsetY: Float, offsetZ: Float, dpVal: Float){
        xOffset = offsetX
        yOffset = offsetY
        zOffset = offsetZ
        dpPerValue1 = dpVal
        reMakePath()
    }

    //パスの再生と再表示
    fun reMakePath(){
        //今までのPathの目盛に対応した作り直し
        path.reset()        //パスのクリア
        firstPlotFlag = true
        plotDatas.forEach {
            val data = it
            val xData = data.xData
            val yData = data.yData
            val zData = data.zData

            //X,Y位置をピクセル位置に変換してPathを再作成
            //X,Y,Z値をピクセル位置に変換
            val xAxDp = (xData - xOffset) * dpPerValue1 * cos5deg + (yData - yOffset) * dpPerValue1 * cos60deg
            val yAxDp = -(xData - xOffset) * dpPerValue1 * sin5deg + (yData - yOffset) * dpPerValue1 * sin60deg + (zData - zOffset) * dpPerValue1

            val xAxPix = (xOrig + xAxDp) * dens
            val yAxPix = (yOrig - yAxDp) * dens

            if (firstPlotFlag == true){
                firstPlotFlag = false
                path.moveTo(xAxPix, yAxPix)
            } else {
                path.lineTo(xAxPix, yAxPix)
            }
        }
        invalidate()    //onDraw()メソッドを呼び出す
    }

    //--------目盛線の表示-----------------------
    fun scaleDisp(canvas: Canvas) {
        drawXaxis(canvas)       //X軸の表示
        drawYaxis(canvas)       //Y軸の表示
        drawZaxis(canvas)       //Z軸の表示
        drawXscale(canvas)      //X軸の目盛り表示
        drawYscale(canvas)      //Y軸の目盛り表示
        drawZscale(canvas)      //Z軸の目盛り表示
    }

    //X軸(横軸)の表示
    fun drawXaxis(canvas: Canvas){
        //目盛線用描画スタイル
        val pa1 = Paint().apply {
            color = Color.BLUE
            strokeWidth = 1.5f * dens
        }

        //目盛線の延長線用描画スタイル
        val pa2 = Paint().apply {
            color = Color.GRAY
            strokeWidth = 0.5f * dens
        }

        //X軸を引く (原点)~(原点+200dp)
        var x1Pos = xOrig * dens
        var y1Pos = yOrig * dens
        var x2Pos = (xOrig + 200f * cos5deg) * dens
        var y2Pos = (yOrig + 200f * sin5deg) * dens
        canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa1)

        //10dp毎に目盛り線を引く
        for (i in 1..20){
            //原点から50dp,100dp,150dp,200dp
            if ((i == 5) || (i == 10) || (i == 15) || (i == 20)){    //長めの線
                x1Pos = (xOrig + i * 10 * cos5deg) * dens
                y1Pos = (yOrig + i * 10 * sin5deg - 5) * dens
                x2Pos = (xOrig + i * 10 * cos5deg) * dens
                y2Pos = (yOrig + i * 10 * sin5deg + 5) * dens
                canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa1)
            }
            else {  //原点から50dp,100dp,150dp,200dp以外は短い線
                x1Pos = (xOrig + i * 10 * cos5deg) * dens
                y1Pos = (yOrig + i * 10 * sin5deg - 2) * dens
                x2Pos = (xOrig + i * 10 * cos5deg) * dens
                y2Pos = (yOrig + i * 10 * sin5deg + 2) * dens
                canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa1)
            }
        }

        //x軸の平行線(グリッド)を引く(下面)
        var x1Base = xOrig
        var y1Base = yOrig
        var x2Base = xOrig + 200f * cos5deg
        var y2Base = yOrig + 200f * sin5deg

        for (i in 1..4){
            x1Pos = (x1Base + 50 * i * cos60deg) * dens
            y1Pos = (y1Base - 50 * i * sin60deg) * dens
            x2Pos = (x2Base + 50 * i * cos60deg) * dens
            y2Pos = (y2Base - 50 * i * sin60deg) * dens
            canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa2)
        }


        //x軸の平行線(グリッド)を引く(右面)
        val x1Base2 = x1Base + 200 * cos60deg
        val y1Base2 = y1Base - 200 * sin60deg
        val x2Base2 = x2Base + 200 * cos60deg
        val y2Base2 = y2Base - 200 * sin60deg

        for (i in 1..4) {
            x1Pos = x1Base2 * dens
            y1Pos = (y1Base2 - 50 * i) * dens
            x2Pos = x2Base2 * dens
            y2Pos = (y2Base2 - 50 * i) * dens
            canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa2)
        }
    }

    //Y軸の表示
    fun drawYaxis(canvas: Canvas){
        //目盛線用描画スタイル
        val pa1 = Paint().apply {
            color = Color.BLUE
            strokeWidth = 1.5f * dens
        }

        //目盛線の延長線用描画スタイル
        val pa2 = Paint().apply {
            color = Color.GRAY
            strokeWidth = 0.5f * dens
        }

        //Y軸を引く (原点)~(原点+200dp)
        //Y軸の原点のView上のdp
        var xOrigYax = xOrig + 200f * cos5deg
        var yOrigYax = yOrig + 200f * sin5deg

        var x1Pos = xOrigYax * dens
        var y1Pos = yOrigYax * dens
        var x2Pos = (xOrigYax + 200f * cos60deg) * dens
        var y2Pos = (yOrigYax - 200f * sin60deg) * dens
        canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa1)


        //10dp毎に目盛り線を引く
        for (i in 1..20){
            //中心から50dp,100dp,150dp,200dp
            if ((i == 5) || (i == 10) || (i == 15) || (i == 20)){    //長めの線
                x1Pos = (xOrigYax + i * 10 * cos60deg) * dens
                y1Pos = (yOrigYax - i * 10 * sin60deg - 5) * dens
                x2Pos = (xOrigYax + i * 10 * cos60deg) * dens
                y2Pos = (yOrigYax - i * 10 * sin60deg + 5) * dens
                canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa1)
            }
            else {  //中心から50dp,100dp,150dp,200dp以外は短い線
                x1Pos = (xOrigYax + i * 10 * cos60deg) * dens
                y1Pos = (yOrigYax - i * 10 * sin60deg - 2) * dens
                x2Pos = (xOrigYax + i * 10 * cos60deg) * dens
                y2Pos = (yOrigYax - i * 10 * sin60deg + 2) * dens
                canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa1)
            }
        }


        //Y軸の平行線(グリッド)を引く(下面)
        var x1Base = xOrigYax
        var y1Base = yOrigYax
        var x2Base = xOrigYax + 200f * cos60deg
        var y2Base = yOrigYax - 200f * sin60deg

        for (i in 1..4){
            x1Pos = (x1Base - 50 * i * cos5deg) * dens
            y1Pos = (y1Base - 50 * i * sin5deg) * dens
            x2Pos = (x2Base - 50 * i * cos5deg) * dens
            y2Pos = (y2Base - 50 * i * sin5deg) * dens
            canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa2)
        }

        //Y軸の平行線(グリッド)を引く(右面)
        val x1Base2 = x1Base - 200 * cos5deg
        val y1Base2 = y1Base - 200 * sin5deg
        val x2Base2 = x2Base - 200 * cos5deg
        val y2Base2 = y2Base - 200 * sin5deg

        for (i in 1..4) {
            x1Pos = x1Base2 * dens
            y1Pos = (y1Base2 - 50 * i) * dens
            x2Pos = x2Base2 * dens
            y2Pos = (y2Base2 - 50 * i) * dens
            canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa2)
        }
    }

    //Z軸(縦軸)の表示
    fun drawZaxis(canvas: Canvas){
        //目盛線用描画スタイル
        val pa1 = Paint().apply {
            color = Color.BLUE
            strokeWidth = 1.5f * dens
        }

        //目盛線の延長線用描画スタイル
        val pa2 = Paint().apply {
            color = Color.GRAY
            strokeWidth = 0.5f * dens
        }

        //Z軸を引く (原点)~(原点-200dp)
        var x1Pos = xOrig * dens
        var y1Pos = yOrig * dens
        var x2Pos = xOrig * dens
        var y2Pos = (yOrig - 200f) * dens
        canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa1)

        //10dp毎に目盛り線を引く
        for (i in 1..20) {
            //原点から50dp,100dp,150dp,200dpは長い線
            if ((i == 5) || (i == 10) || (i == 15) || (i == 20)) {   //長めの線
                x1Pos = (xOrig - 5) * dens
                y1Pos = (yOrig - i * 10) * dens
                x2Pos = (xOrig + 5) * dens
                y2Pos = (yOrig - i * 10) * dens
                canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa1)
            } else {    ////中心から50dp,100dp,150dp,200以外は短い線
                //中心から上側
                x1Pos = (xOrig - 2) * dens
                y1Pos = (yOrig - i * 10) * dens
                x2Pos = (xOrig + 2) * dens
                y2Pos = (yOrig - i * 10) * dens
                canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa1)
            }
        }

        //Z軸の平行線(グリッド)を引く(左面)
        var x1Base = xOrig
        var y1Base = yOrig
        var x2Base = x1Base
        var y2Base = y1Base - 200f

        for (i in 0..4){
            //右側の線
            x1Pos = (x1Base + 50 * i * cos60deg) * dens
            y1Pos = (y1Base - 50 * i * sin60deg) * dens
            x2Pos = (x2Base + 50 * i * cos60deg) * dens
            y2Pos = (y2Base - 50 * i * sin60deg) * dens
            canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa2)
        }

        //Z軸の平行線(グリッド)を引く(右面)
        x1Base = xOrig + 200f * cos60deg
        y1Base = yOrig - 200f * sin60deg
        x2Base = x1Base
        y2Base = y1Base - 200f

        for (i in 1..4) {
            //右側の線
            x1Pos = (x1Base + 50 * i * cos5deg) * dens
            y1Pos = (y1Base + 50 * i * sin5deg) * dens
            x2Pos = (x2Base + 50 * i * cos5deg) * dens
            y2Pos = (y2Base + 50 * i * sin5deg) * dens
            canvas.drawLine(x1Pos, y1Pos, x2Pos, y2Pos, pa2)
        }
    }

    //X軸の目盛り表示
    fun drawXscale(canvas: Canvas){
        //文字用描画スタイル
        val pText = Paint().apply{
            color = Color.BLACK
            strokeWidth = 5f * dens
            typeface = Typeface.SERIF
            textSize = 15f      //テキストサイズ(sp)
            textAlign = Paint.Align.CENTER  //表示位置
            textScaleX = 1.5f   //水平方向への拡大率
            textSkewX = -0.5f   //水平方向にずらす度合
        }

        for (i in 0..4){
            //中心から横へ50dp, 100dp,150dp,200dpの位置に数値表示
            var xPos = (xOrig + i * 50f * cos5deg -0f) * dens
            var yPos = (yOrig + i * 50f * sin5deg + 15f) * dens
            var tex = changeString(i * 50f / dpPerValue1 + xOffset)
            canvas.drawText(tex, xPos, yPos, pText)
        }

        //"X"の表示
        var xPos = (xOrig + 100f * cos5deg - 10f) * dens
        var yPos = (yOrig + 100f * sin5deg + 25f) * dens

        canvas.drawText("X", xPos, yPos, pText)
    }

    //Y軸の目盛り表示
    fun drawYscale(canvas: Canvas){
        //文字用描画スタイル
        val pText = Paint().apply{
            color = Color.BLACK
            strokeWidth = 5f * dens
            typeface = Typeface.SERIF
            textSize = 15f      //テキストサイズ(sp)
            textAlign = Paint.Align.LEFT  //表示位置
            textScaleX = 1.5f   //水平方向への拡大率
            textSkewX = -0.5f   //水平方向にずらす度合
        }

        var xOrigYax = xOrig + 200f * cos5deg
        var yOrigYax = yOrig + 200f * sin5deg
        
        for (i in 0..4){
            //中心から横へ50dp, 100dp,150dp,200dpの位置に数値表示
            var xPos = (xOrigYax + i * 50f * cos60deg + 5f) * dens
            var yPos = (yOrigYax - i * 50f * sin60deg + 5f) * dens
            var tex = changeString(i * 50f / dpPerValue1 + yOffset)
            canvas.drawText(tex, xPos, yPos, pText)
        }

        //"Y"の表示
        var xPos = (xOrigYax + 125f * cos60deg + 10f) * dens
        var yPos = (yOrigYax - 125f * sin60deg + 10f) * dens
        canvas.drawText("Y", xPos, yPos, pText)
    }

    //Z軸の目盛り表示
    fun drawZscale(canvas: Canvas){
        //文字用描画スタイル
        val pText = Paint().apply{
            color = Color.BLACK
            strokeWidth = 5f * dens
            typeface = Typeface.SERIF
            textSize = 15f      //テキストサイズ(sp)
            textAlign = Paint.Align.LEFT  //表示位置
            textScaleX = 1.5f   //水平方向への拡大率
            textSkewX = -0.5f   //水平方向にずらす度合
        }

        for (i in 0..4){
            //中心から下へ50dp, 100dp,150dp,200dp付近に数値表示
            var xPos = (xOrig + 5f) * dens
            var yPos = (yOrig - i * 50f - 2f) * dens
            var tex = changeString(i * 50f / dpPerValue1 + zOffset)
            canvas.drawText(tex, xPos, yPos, pText)
        }

        //"Z"の表示
        val xPos = (xOrig - 5f) * dens
        val yPos = (yOrig - 210f) * dens
        canvas.drawText("Z", xPos, yPos, pText)
    }

    //少数点以下を切り捨て文字列化
    fun changeString(value: Float): String{
        //小数点以下切り捨て
        val dataTxt = Math.floor(value.toDouble()).toString()
        return dataTxt
    }

6.アプリ画面のレイアウト

作製したアプリ画面のレイアウトを以下に示します。
アプリ画面のレイアウトは、作成した三次元グラフのカスタムViewと、Buttonを5個と、EditTextを4個と、TextViewを4個配置しました。表6に各Viewのidと役割に関して記載します。

図4.画面レイアウト
名称種類id役割
三次元グラフViewカスタムView (ArtView)artViewデータをリアルタイムに三次元グラフ表示
接続ボタンButtonconnectButtonUSBシリアル通信を接続して、受信データのグラフ表示を開始する
切断ボタンButtondisconnectButtonUSBシリアル通信を切断する
CLEARボタンButtonclearButton描画したグラフを消去する
再描画ボタンButtonredrawButtonグラフの各軸のオフセット値や1目盛値を変更した時に、グラフの再描画を行う
オフセット読込ボタンButtonreadOffsetButtonグラフの各軸のオフセット値と1目盛値の現在値を読み出してEditTextに表示する
X軸のオフセット値設定EditTextxOffsetEditTextグラフのX軸原点の目盛値を設定
Y軸のオフセット値設定EditTextyOffsetEditTextグラフのY軸原点の目盛値を設定
Z軸のオフセット値設定EditTextzOffsetEditTextグラフのZ軸原点の目盛値を設定
グラフの1目盛の値設定EditTextscale1ValEditTextグラフの1目盛の値を設定
“X offset” 文字列TextViewxOffLabelTextView“X offset” 文字列の表示
“Y offset” 文字列TextViewyOffLabelTextView“Y offset” 文字列の表示
“Z offset” 文字列TextViewzOffLabelTextView“Z offset” 文字列の表示
”1目盛値” 文字列TextViewscale1LabelTextView”1目盛値” 文字列の表示
表6.各Viewの役割とid

尚、三次元グラフのカスタムView (ArtView)は、下図の様にDesign画面のPaletteのContainers→<View>をデザインの画面上にドラッグすると、Viewsの選択画面が表示されるので、作成したカスタムViewの名称ArtViewを選択して「OK」をクリックすると画面上に配置することができます。

画面レイアウトに対応したXMLのコード(app→res→layout→activity_main.xml)を以下に示します。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <view
        android:id="@+id/artView"
        class="com.ri_control.usb_serial_x_y_z_plotter.ArtView"
        android:layout_width="400dp"
        android:layout_height="420dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <Button
        android:id="@+id/connectButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="16dp"
        android:layout_marginTop="8dp"
        android:text="接続"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/artView" />

    <Button
        android:id="@+id/disconnectButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="16dp"
        android:layout_marginTop="8dp"
        android:text="切断"
        app:layout_constraintStart_toEndOf="@+id/connectButton"
        app:layout_constraintTop_toBottomOf="@+id/artView" />

    <Button
        android:id="@+id/clearButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="32dp"
        android:layout_marginTop="8dp"
        android:text="CLEAR"
        app:layout_constraintStart_toEndOf="@+id/disconnectButton"
        app:layout_constraintTop_toBottomOf="@+id/artView" />

    <EditText
        android:id="@+id/xOffsetEditText"
        android:layout_width="91dp"
        android:layout_height="42dp"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:ems="10"
        android:inputType="text"
        android:text="-1000"
        app:layout_constraintStart_toEndOf="@+id/xOffLabelTextView"
        app:layout_constraintTop_toBottomOf="@+id/connectButton" />

    <EditText
        android:id="@+id/yOffsetEditText"
        android:layout_width="91dp"
        android:layout_height="42dp"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:ems="10"
        android:inputType="text"
        android:text="-1000"
        app:layout_constraintStart_toEndOf="@+id/yOffLabelTextView"
        app:layout_constraintTop_toBottomOf="@+id/clearButton" />

    <EditText
        android:id="@+id/zOffsetEditText"
        android:layout_width="91dp"
        android:layout_height="42dp"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:ems="10"
        android:inputType="text"
        android:text="0"
        app:layout_constraintStart_toEndOf="@+id/zOffLabelTextView"
        app:layout_constraintTop_toBottomOf="@+id/xOffsetEditText" />

    <EditText
        android:id="@+id/scale1ValEditText"
        android:layout_width="91dp"
        android:layout_height="42dp"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:ems="10"
        android:inputType="text"
        android:text="100"
        app:layout_constraintStart_toEndOf="@+id/scale1LabelTextView"
        app:layout_constraintTop_toBottomOf="@+id/yOffsetEditText" />


    <TextView
        android:id="@+id/xOffLabelTextView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginTop="16dp"
        android:text="X offset"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/connectButton" />

    <TextView
        android:id="@+id/yOffLabelTextView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="16dp"
        android:layout_marginTop="16dp"
        android:text="Y offset"
        app:layout_constraintStart_toEndOf="@+id/xOffsetEditText"
        app:layout_constraintTop_toBottomOf="@+id/disconnectButton" />

    <TextView
        android:id="@+id/zOffLabelTextView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginTop="32dp"
        android:text="Z offset"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/xOffLabelTextView" />

    <TextView
        android:id="@+id/scale1LabelTextView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="16dp"
        android:layout_marginTop="64dp"
        android:text="1目盛値"
        app:layout_constraintStart_toEndOf="@+id/xOffsetEditText"
        app:layout_constraintTop_toBottomOf="@+id/disconnectButton" />

    <Button
        android:id="@+id/redrawButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="16dp"
        android:text="再描画"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/zOffsetEditText" />

    <Button
        android:id="@+id/readOffsetButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="16dp"
        android:text="オフセット読込"
        app:layout_constraintStart_toEndOf="@+id/redrawButton"
        app:layout_constraintTop_toBottomOf="@+id/zOffsetEditText" />

</androidx.constraintlayout.widget.ConstraintLayout>

7.Raspberry Pi Pico W側のプログラム

Raspberry Pi Pico Wから以下の形でXデータとYデータとZデータをUSBシリアル通信でスマホに送信すると3Dグラフがリアルタイムに表示されます。
X:「Xデータ」,Y:「Yデータ」「ターミネータ(”r¥n”)」
例 ”X:12.34,Y:245.2,Z:455.3\r\n”

以下に、Arduino IDEを使用してArduino言語で作成した、中心(0,0,0)で半径400の円をZ軸を軸として10°ずつ回転させた図を描き、見た目が球状となるデータをUSBシリアル通信で送信するスケッチの例を示します。

Raspberry Pi Pico WのGP15のI/Oにスイッチを接続しています。このスイッチをONするとデータがスマホに送られて、グラフに球形の形状が描画されます。

//-----三次元グラフ用データ作成(球状のライン)-----
#define PI_DATA 3.141592653589793

void setup() {
  Serial.begin(9600);       //USBシリアル通信初期化
  pinMode(15, INPUT_PULLUP);  //GP15を入力端子に設定(スイッチ接続)
}

void loop() {
  while (true){
    //スイッチ読込(LOW判断)
    if (digitalRead(15) == LOW){
      break;
    }
  }

  for (int j = 0; j < 360; j+=10){
    for (int i = 0; i < 360; i++){
      //X軸、Y軸、Z軸のデータ作成
      double x = 400 * sin(PI_DATA * (float)i / 180) * cos(PI_DATA * (float)j / 180);
      double y = 400 * sin(PI_DATA * (float)i / 180) * sin(PI_DATA * (float)j / 180);
      double z = 400 * cos(PI_DATA * (float)i / 180);

      //データ送信
      String text_x = String(x);
      String text_y = String(y);
      String text_z = String(z);
      String text = String("X:" + text_x +",Y:" + text_y + ",Z:" + text_z);
      Serial.println(text);        //現在データをUSBシリアルに送信

      delay(5);     //5msec待ち
    }
  }
}

8.最後に

今回作成したアプリでは、Raspberry Pi Pico WからUSBシリアル通信を使用してスマホへ送られたデータをX-Y-Z三次元グラフに表示しました。
この機能はマイコンボード等から得られる3個の信号を立体的に表示することが出来ますので、3軸のモータの軌跡等を表示するのに便利と思われます。


PAGE TOP