NoodlとMicro:bitの連携で地球をグルグル回してみた

# はじめに

この記事はNoodlもくもく会｜GWアドベントカレンダーの7日目の記事となります。
4日目の記事でCanvas上にThree.jsで3Dオブジェクトを表示しましたので、今回の記事ではMicro:bitにWebBluetooth接続して、取得した加速度情報でCanvas上の3Dオブジェクトを操作してみます。

<blockquote class="twitter-tweet"><p lang="und" dir="ltr"><a href="https://t.co/yKjqjVIbts">https://t.co/yKjqjVIbts</a></p>&mdash; みよぴ (@masato_miyoshi) <a href="https://twitter.com/masato_miyoshi/status/1256506821721731077?ref_src=twsrc%5Etfw">May 2, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

### 目次

1. サンプルデモ
2. GitHub上のサンプルデータと構築手順
3. Micro:bitプログラム
4. Noodlプログラム
5. まとめ

## 1. サンプルデモ
1. micro:bitの[サンプルコード](https://makecode.microbit.org/_YUTAEqiHzguk)をお持ちのMicro:bitにダウンロードしてください。
成功するとLEDに「□」四角が表示されます。
**※micro:bitの設定で、「No Pairing Required: Anyone can connect via Bluetooth.」にチェックを入れておいてください。**

2. 次に、[Noodlのデモページ](https://macole.github.io/Noodl2_MicrobitThreeDemo/index.html)を開いてください。
**※WebBluetoothが実装されているブラウザ・バージョンは限定されています。なるべく最新のChromeを使うといいでしょう。ブラウザの[実装状況](https://github.com/WebBluetoothCG/web-bluetooth/blob/master/implementation-status.md)はこちらを確認ください。**

3. Noodlのデモページが表示されたら、「connect」ボタンをクリックしブラウザ上からmicro:bitにペアリング要求をします。ここでブラウザ・micro:bitの設定が正しい場合はダイアログに「BBC micro:bit」と表示されているはずです。micro:bitを選択して「ペア設定」ボタンを押します。

![image635.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/192442/b4402ab4-cae7-8248-a85d-0223f0ea01d2.png)

ブラウザとmicro:bitとの接続が完了すると、micro:bitの傾きに合わせてXYZ値が変化しているのを確認できます。
![image670.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/192442/b0fac08a-cfe3-bb8e-cf5e-ad80043ce328.png)

## 2. GitHub上のサンプルデータと構築手順
ソースを下記に公開しています。[Github](https://github.com/macole/Noodl)プロジェクトを対象PCにクローンし/Noodl2_MicrobitThreeDemoをNoodlプロジェクトにインポートしてください。

#### ローカル上での確認
WebBluetoothはブラウザのセキュリティの問題で、保護された通信(https)か、ローカル(localhost)で実行しなければ使用できません。
自身のPC上で確認する場合はURLの「192.168.XXX.XXX」を「localhost」に変更してください。

## 3. Micro:bitプログラム　- Bluetooth加速度サービス -
micro:bitの[サンプルコード](https://makecode.microbit.org/_03ocj1H145dR
)から説明します。

Micro:bit側のプログラムはBluetoothの加速度、温度計、LED、ボタンサービスを読込んでいるだけです。
正しく起動できたことを確認できるようにLEDで「□」四角を表示し、Bluetoothに接続できると「レ」チェック、切断時に「X」バツを表示します。
Micro:bitの使い方については[こちら](https://sanuki-tech.net/micro-bit/)で詳しく説明されています。
![image652.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/192442/59835cab-0a67-d4ad-24fb-2e536649d914.png)

ここでは加速度データの扱いについて説明します。
加速度センサーは下記図に示す通り、XYZ軸の加速度を-2G～+2Gの範囲で知ることができます。
各軸は
- X軸:-2032～2048　右に傾けるとプラス、左に傾けるとマイナス
- Y軸:-2032～2048　手前に傾けるとプラス、奥に傾けるとマイナス
- Z軸:-2048～2032　上(LED表面)がプラス、下(裏面)がマイナス

例えばmicro:bitのLED面を上に水平にした状態ではX軸・Y軸の値が0、Z軸の値が-1023(1G)となります。※測定誤差があるため数値は常にブレがあります。

![image639.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/192442/70aa7650-1012-8eac-c30f-e1c9bf3983ff.png)

## 4. Noodlプログラム
### Noodlのノードとノードの接続

各ノードは下記のようにつなぎます
![image654.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/192442/1440cb19-99e6-beb9-42e3-fc305a6ba065.png)
左側にJavascriptノードのMicro:bitとの通信部、ThreeJS表示部
右側にCanvas、画像・テキスト、接続/切断ボタン、XYZ軸表示
が並んでいます。

ノードとノードの接続は
1. Micro:bit通信部では「connect/disconect」ボタンのclickを
　JavascritpノードのmySignalとして入力
2. Micro:bitから取得した加速度をNumberRemapperで変換：-1G~1Gの値から-180°~180°にします
3. 変換された角度をThreeJS表示用のJavascriptノードに入力
4. 入力された角度を使って3D空間上のカメラの位置を移動
5. CanvasのDomElementに3Dの地球モデルを描画しています

ノード設定は下記のようになっています
![image653.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/192442/3f2ed573-4bf8-7fd4-489b-a74687309e92.png)

ScriptDownloaderではThree.jsをCDNより読込んでいます。
External scripts>script0に`https://cdnjs.cloudflare.com/ajax/libs/three.js/110/three.min.js`と設定しています。

### Javascriptノード
Noodlでは[Noodlとmicro:bitをつないで、メッセージのやり取りをする](https://qiita.com/kisaichi07/items/81211926c8ae81a5cd87)を参考に、加速度データを取得します。
Noodl上では各軸の値を1/1000としています。1000mG⇒1G表示。

```js:Microbit通信部
//micro:bit BLE UUID
var ACCELEROMETERSERVICE_SERVICE_UUID       = 'e95d0753-251d-470a-a062-fa1922dfa9a8';
var ACCELEROMETERDATA_CHARACTERISTIC_UUID   = 'e95dca4b-251d-470a-a062-fa1922dfa9a8';
var accelerometer_device;
var accelerometer_characteristic;
var accX,accY,accZ;
    
define({
	// The input ports of the Javascript node, name of input and type
	inputs:{
	    // ExampleInput:'number',
	    // Available types are 'number', 'string', 'boolean', 'color' and 'signal',
	    connect:'signal',
	    disconnect:'signal',
	    mySignal:"signal"
	},
	
	// The output ports of the Javascript node, name of output and type
	outputs:{
	    // ExampleOutput:'string',
	    accX: "number",
	    accY: "number",
	    accZ: "number"
	},
	
	connect:function(inputs,outputs) {
		    navigator.bluetooth.requestDevice({
            filters: [{
              namePrefix: 'BBC micro:bit',
            }],
            optionalServices: [ACCELEROMETERSERVICE_SERVICE_UUID],
            
          })
          .then(device => {
            accelerometer_device = device;
            console.log("device", device);
            return device.gatt.connect();
          })
          //ACCELEROMETER
          .then(server =>{
            console.log("server", server)
            return server.getPrimaryService(ACCELEROMETERSERVICE_SERVICE_UUID);
          })
          .then(service => {
            console.log("service", service)
            return service.getCharacteristic(ACCELEROMETERDATA_CHARACTERISTIC_UUID)
          })
          .then(chara => {
            console.log("ACCELEROMETER:", chara)
            console.log("BLE接続が完了しました。");
            characteristic = chara;
            characteristic.startNotifications();
            characteristic.addEventListener('characteristicvaluechanged',onAccelerometerValueChanged);
          })  
          .catch(error => {
            console.log("BLE接続に失敗しました。もう一度試してみてください");
            console.log(error);
          });    
	},
	
	disconnect:function(inputs,outputs) {
		if (!accelerometer_device || !accelerometer_device.gatt.connected) return ;
        accelerometer_device.gatt.disconnect();
        console.log("BLE接続を切断しました。");
	},
	
	change:function(inputs,outputs) {
		this.runNextFrame();
		outputs.accX = accX;
	    this.flagOutputDirty("accX");
	    outputs.accY = accY;
	    this.flagOutputDirty("accY");
	    outputs.accZ = accZ;
	    this.flagOutputDirty("accZ");
	}
})

function onAccelerometerValueChanged(event) {
    //AcceleratorX = event.target.value.getUint16(0)/1000.0;
    AcceleratorX = event.target.value.getInt16(0,true)/1000.0;
    accX = AcceleratorX;
    console.log("TargetValue="+event.target.value);
    
    //AcceleratorY = event.target.value.getUint16(2)/1000.0;
    AcceleratorY = event.target.value.getInt16(2,true)/1000.0;
    accY = AcceleratorY;

    //AcceleratorZ = event.target.value.getUint16(4)/1000.0;
    AcceleratorZ = event.target.value.getInt16(4,true)/1000.0;
    accZ = AcceleratorZ;
    console.log("XYZ="+Math.round(AcceleratorX)+"Y="+Math.round(AcceleratorY)+"Z="+Math.round(AcceleratorZ));
} 
```

**ハマったところ**　
>`AcceleratorX = event.target.value.getInt16(0,true)/1000.0;`

の`getInt16(0,true)`で符号付き16bit整数値で読み込むんですが、第2引数を省略した場合こちらの環境では数値が正しく取れていませんでした。リファレンスでは
>第2引数には、任意でデータの配置方式を指定する。trueならLittle endian、falseならBig endian。省略した場合はtrueになる。

とのことでしたが、第2引数を省略せず、trueをしっかり記載すると動作が安定しました。

### Canvas上にThree.jsで地球を描く
Noodl上で外部ライブラリを使ってCanvasに描画するときは下記の3連コンボが基本形になります。詳しくは[こちら](https://qiita.com/macole/items/ad61bc9887e7bdd1e9be)で紹介しています。

![image603.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/192442/fbdf4cd5-d5c8-ecb8-e2c6-f89e8fb1eba0.png)
**図 Canvasを使う基本形**

3Dモデルを表示するJavascriptノード

```js:ThreeJS表示部
define({
	inputs:{
	    mySignal:'signal',
	    DOM:'domelement',
	    scriptLoaded:'string',
        accX:'number',
        accY:'number',
        accZ:'number',
	},
	outputs:{
	    // ExampleOutput:'string',
	},
	mySignal:function(inputs,outputs) {
        // レンダラーを作成
        // サイズを指定
        const width = window.innerWidth;
        const height = window.innerHeight;

        // 角度
        let rot = 0;

        // レンダラーを作成
        const renderer = new THREE.WebGLRenderer({
          canvas: inputs.DOM
        });
        renderer.setPixelRatio(window.devicePixelRatio);
        renderer.setSize(width, height);

        // シーンを作成
        const scene = new THREE.Scene();

        // 画面上にグリッド（格子）を配置
        const grid = new THREE.GridHelper(1000, 10);
        scene.add(grid);

        // 画面上に3軸を配置
        const axes = new THREE.AxesHelper(1000);
        scene.add(axes);

        // カメラを作成
        const camera = new THREE.PerspectiveCamera(45, width / height);
        camera.position.set(0, 500, +1000);
        camera.lookAt(new THREE.Vector3(0, 0, 0));

        // 平行光源
        const directionalLight = new THREE.DirectionalLight(0xffffff);
        directionalLight.position.set(1, 1, 1);
        scene.add(directionalLight); // シーンに追加

        // 球体を作成
        const geometry = new THREE.SphereGeometry(300, 30, 30);
        // 画像を読み込む
        const loader = new THREE.TextureLoader();
        const texture = loader.load('imgs/earthmap1k.jpg');
        // マテリアルにテクスチャーを設定
        const material = new THREE.MeshStandardMaterial({
          map: texture
        });
        // メッシュを作成
        const mesh = new THREE.Mesh(geometry, material);
        // 3D空間にメッシュを追加
        scene.add(mesh);

        tick();

        // 毎フレーム時に実行されるループイベントです
        function tick() {
          rot = inputs.accX;
          pit = inputs.accY;

          // ラジアンに変換
          const radianX = (rot * Math.PI) / 180;
          const radianY = (pit * Math.PI) / 180;

          // ライトの座標に反映
          camera.position.x = 1000 *Math.cos(radianX);
          camera.position.y = 500;
          camera.position.z = 1000 * Math.sin(radianX);
          // ライトの座標に反映
          //camera.position.z = 1000 *Math.cos(radianX);
          //camera.position.x = 1000 *Math.sin(radianX)* Math.cos(radianY);
          //camera.position.y = 1000 * Math.sin(radianX)*Math.sin(radianY);

          // カメラは常に中央を向くように指定
          camera.lookAt(new THREE.Vector3(0, 0, 0));

          // レンダリング
          renderer.render(scene, camera);
          requestAnimationFrame(tick);
        }
	},
	
	// This function will be called when any of the inputs have changed
	change:function(inputs,outputs) {
		// ...
		      console.log("change");
	}
})
```

## 5.まとめ
今回はMicro:bitとのBLE通信とCanvas上の3Dモデルの操作について説明しました。
ここまでくると結構複雑ですが、Noodl上ではJavascriptをノードとして機能分離できること。Viewとデータの接続が視覚的に見えるため見通しがいいです。

この内容はオンラインもくもく会で、もくもくしていたものです。はじめてもくもく会に参加させていいただきましたが、体感でいつもの2・3倍は集中できますね。主催されたもくもく会メンバーの方、参加された皆様ありがとうございました。