TwinMakerで、ログインユーザーの現在地を3D上に出す方法

# この記事は何？

この記事では、2つのことを解説します

- TwinMaker上で、ログインユーザーごとに違うコンテンツを表示する方法を解説します
- TwinMaker上で、リアルタイムな位置情報を、3Dコンテンツの位置に反映させる方法を解説します

**この記事で作るもの**

この記事では、以下のようなTwinMakerのシーンを作ります
- Aさんがログインすると、Aさんの部屋のルンバの位置に、ルンバの3Dモデルが表示されます
- Bさんがログインすると、Bさんの部屋のルンバの位置に、ルンバの3Dモデルが表示されます
- また、注釈に表示される画像と文言が変わります

|ログインユーザー|表示される画面|
|:--|:--|
|Aさん|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/a0e95761-14e6-3763-51d2-56b3bef1b7dc.png">|
|Bさん|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/b5d62048-d644-e549-d78a-006ac537b2a3.png">|

※記事では位置情報の取得は作らないため、あくまで3D上でルンバの位置が変わるよ、というところだけ説明します

**どうやって実現するの？**

- Pythonの定期実行で、動的にTwinMakerのシーンをコピーします
- ログインユーザーの情報が必要になるので、iot-app-kitでWebアプリ化します

**何が嬉しいの？**

- ユーザーAさんにはAさん向けの3Dを、BさんにはBさん向けの3Dだけを見せることができます

**必要なもの**

- AWSアカウント
- Python3.10
- VSCode
- Node.js（記事はv14.15.1で検証していますが、新しいほうがいいです）

# まずは下準備: iot-app-kitの導入

AWSマネジメントコンソール上のTwinMakerはAWSアカウントを持っている開発者しか見られないので、ログインユーザーごとに動きを変えるには不適当です。iot-app-kitを利用できるようにします。

iot-app-kitはAWSが公開しているライブラリです。TwinMakerの3Dシーン、グラフなど、ReactのWebアプリとして構築することができます。利用者専用の閲覧用Webページを作ることができます。

https://github.com/awslabs/iot-app-kit

（iot-app-kitがまだ歴史の浅いライブラリなこともあって）構築は少し大変です。README通りにやっても動かないので、以下の手順を見ながら始めてください

**ステップ1: Reactプロジェクトをつくる**

```bash: 構築手順
# プロジェクトを作りたいフォルダで、reactのプロジェクトを作成します
npx create-react-app app --template typescript 

# プロジェクトができたら、作成したフォルダに移動します
cd app

# 必要な依存ライブラリをインストールします
npm install
```

**ステップ2: iot-app-kitをインストールします**

```bash
# app-kitをインストールします（※READMEに出ている手順はここまでです）
npm install @iot-app-kit/components 
npm install @iot-app-kit/react-components 
npm install @iot-app-kit/source-iottwinmaker 
npm install @iot-app-kit/scene-composer

# とりあえずエラーが出るので、babelのプラグインをインストールします
npm install @babel/plugin-proposal-private-property-in-object

# sassがないので、sassをインストールします
npm install sass

# ポリフィルが必要なので、react-app-rewiredとポリフィルをインストールします
npm install react-app-rewired --save-dev
npm install node-polyfill-webpack-plugin
```

**ステップ3：プロジェクトのルートに、react-app-wired向けの設定ファイルを置きます**

```javascript: config-overrides.js
const NodePolyfillPlugin = require("node-polyfill-webpack-plugin");

module.exports = function override(config, env) {
  if (!config.plugins) {
    config.plugins = [];
  }
  config.plugins.push(
    new NodePolyfillPlugin({
      excludeAliases: ["console"],
    })
  );
  config.resolve.alias = {
    "react/jsx-runtime.js": "react/jsx-runtime",
    "react/jsx-dev-runtime.js": "react/jsx-dev-runtime",
  };
  return config;
};
```

**ステップ4: package.jsonのscriptsを書き変えて、react-app-rewiredを使えるようにします**

```json:package.json
  "scripts": {
    "start": "react-app-rewired start",
    "build": "react-app-rewired build",
    "test": "react-app-rewired test",
    "eject": "react-app-rewired eject"
  },
```

**ステップ5: three.jsのバージョンが合わないので、最新のバージョンを持ってきます**

```bash
npm install three
npm install @react-three/fiber
npm install @react-three/drei
npm install three-stdlib
```

**ステップ6: 準備ができたので、Reactを起動します**

```bash
npm run start
```

多分これで大丈夫です（※2023年6月11日現在）

<details>
<summary>6月11日時点のバージョン情報: package.json</summary><div>

```json:package.json
{
  "name": "app",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "@babel/plugin-proposal-private-property-in-object": "^7.21.11",
    "@iot-app-kit/components": "^6.2.0",
    "@iot-app-kit/react-components": "^6.2.0",
    "@iot-app-kit/scene-composer": "^3.3.0",
    "@iot-app-kit/source-iottwinmaker": "^6.2.0",
    "@react-three/drei": "^9.74.8",
    "@react-three/fiber": "^8.13.1",
    "@testing-library/jest-dom": "^5.16.5",
    "@testing-library/react": "^13.4.0",
    "@testing-library/user-event": "^13.5.0",
    "@types/jest": "^27.5.2",
    "@types/node": "^16.18.35",
    "@types/react": "^18.2.11",
    "@types/react-dom": "^18.2.4",
    "jsx-runtime": "^1.2.0",
    "node-polyfill-webpack-plugin": "^2.0.1",
    "path-browserify": "^1.0.1",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-scripts": "5.0.1",
    "sass": "^1.63.3",
    "three": "^0.153.0",
    "three-stdlib": "^2.23.9",
    "typescript": "^4.9.5",
    "web-vitals": "^2.1.4"
  },
  "scripts": {
    "start": "react-app-rewired  start",
    "build": "react-app-rewired  build",
    "test": "react-app-rewired  test",
    "eject": "react-app-rewired  eject"
  },
  "eslintConfig": {
    "extends": [
      "react-app",
      "react-app/jest"
    ]
  },
  "browserslist": {
    "production": [
      "last 1 chrome version"
    ],
    "development": [
      "last 1 chrome version"
    ]
  },
  "devDependencies": {
    "react-app-rewired": "^2.2.1"
  }
}
```
</div>
</details>

# 構築作業1: テンプレートになるTwinMakerのシーンを作ります

## 実施する作業

まずは`template`の名前で、TwinMakerのシーンを作成します
作業は以下の2点です

- 部屋の3Dモデルを中央に配置する
- カメラを`Camera1`の名前で作成する

![scene-template.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/8df446b7-f7ec-fac2-594a-2da5eed98513.png)

部屋のモデルのGLBはSketchfabからお借りしました（ライセンスはCC 4.0です）
https://sketchfab.com/3d-models/studio-apartment-vray-baked-textures-included-cae2d96ede1d4112b1fd391099a43f77

## この作業をすると、こうなる

`template`の名前でシーンを作成すると、S3の対応するバケット（末尾が-iadのバケット）に`template.json`のJSONファイルが作られます

![template-s3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/75422d59-3ecb-c11b-be49-a65de759b2de.png)

TwinMakerのシーンは、このように`S3に作られるシーン名と同じ名前のJSONファイル`で全て管理されます

# 構築作業2: ユーザーと同じ数だけ、空のシーンを作ります

## 実施すること

今回は2人のユーザーでログインさせる想定ですから、2つの空のシーンを作りました
- shotaoki_mail_com
- other_user_mail_com

命名規則の都合で@などが使えないため、メールアドレスの記号部分をアンダーバーに置き換えたものを使っています

![3-scenes.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/aa2c2910-58f4-4dab-96c7-df258bc4d5cc.png)

<a href="https://docs.aws.amazon.com/ja_jp/general/latest/gr/iot-twinmaker.html">クオータ</a>にある通り、1つのワークスペースに作成することができるシーンの数は100ですから、あと97人まで増やしても大丈夫です

# 構築作業3: Pythonのスクリプトを作って、templateをコピーする処理を実装します

![structure.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/09676c1d-913f-d389-d818-3b1c51bfebd0.png)

## 実施すること

1. Pythonのスクリプトで、S3にあるtemplateのJSONファイルを取得します
2. オブジェクトの配置情報を書き変えて、ユーザーA向けのJSONを上書きします
3. オブジェクトの配置情報を書き変えて、ユーザーB向けのJSONを上書きします

こうすると、ユーザーA向けのシーン、ユーザーB向けのシーンができます

ルンバのデータもScketchfabからお借りしました。こちらもライセンスはCC 4.0です
https://sketchfab.com/3d-models/low-poly-roomba-be32b697116f4c70971da8c53b3b6653

## ソースコード

以下のpythonファイルを、`python app --bucketname ${S3のバケット名}`で実行します

```bash: 実行に必要なライブラリのインストール
pip install pydantic boto3
```

```python: app.py
from argparse import ArgumentParser
from pydantic import BaseModel
import boto3
import io
import json
from typing import List


class Argments(BaseModel):
    """
    app.pyの引数を定義する

    実行方法:
    python app.py --bucketname ${バケット名}
    """

    bucketname: str

    @classmethod
    def parse_args(cls):
        # pythonファイルの実行引数をpydanticにパースする
        parser = ArgumentParser()
        for k in cls.schema()["properties"].keys():
            parser.add_argument(f"-{k[0:1]}", f"--{k}")
        return cls.parse_obj(parser.parse_args().__dict__)


def add_roomba(
    template: dict, args: Argments, x: float, y: float, z: float, message: str
):
    """
    ルンバを部屋に召喚する

    message: アノテーションに表示するメッセージ
    x, y, z: 表示する座標
    """
    current: List[dict] = template["nodes"]

    # ルンバを配置する（glbがややズレているので、固定値でオフセットを入れる）
    current.append(
        {
            "name": "low-poly_roomba",
            "transform": {
                "position": [-3.056 + x, -0.4 + y, -2.78 + z],
                "rotation": [0, 0, 0],
                "scale": [0.5, 0.5, 0.5],
            },
            "transformConstraint": {},
            "components": [
                {
                    "type": "ModelRef",
                    "uri": f"s3://{args.bucketname}/low-poly_roomba.glb",
                    "modelType": "GLB",
                    "unitOfMeasure": "meters",
                }
            ],
            "properties": {},
        }
    )
    # ルンバの上のアノテーションを配置する
    current.append(
        {
            "name": "Annotation",
            "transform": {
                "position": [-1.2 + x, 0.1671 + y, 1.4 + z],
                "rotation": [0, 0, 0],
                "scale": [0.5, 0.5, 0.5],
            },
            "transformConstraint": {},
            "components": [
                {
                    "type": "DataOverlay",
                    "subType": "TextAnnotation",
                    "valueDataBindings": [],
                    "dataRows": [{"rowType": "Markdown", "content": message}],
                }
            ],
            "properties": {},
        }
    )
    # オブジェクトの配置情報を設定する
    template["nodes"] = current
    # 全てのオブジェクトをルートノードに設定する
    template["rootNodeIndexes"] = [r for r in range(len(current))]


def main(args: Argments):
    """
    ユーザーAのシーンを作成する
    """
    s3 = boto3.resource("s3")
    bucket = s3.Bucket(args.bucketname)

    # テンプレートシーンをコピーする
    template = {}
    with io.BytesIO() as f:
        bucket.download_fileobj("template.json", f)
        template = json.loads(f.getvalue())

    # ルンバ召喚
    add_roomba(
        template,
        args,
        x=0,
        y=0,
        z=0,
        message="""
## Aさんのルンバ
        """,
    )

    # ルンバを召還したシーンを、ユーザーAのシーンとして保存する
    with io.BytesIO(json.dumps(template).encode("utf-8")) as f:
        bucket.upload_fileobj(f, "shotaoki_mail_com.json")


def main2(args: Argments):
    """
    ユーザーBのシーンを作成する
    """
    s3 = boto3.resource("s3")
    bucket = s3.Bucket(args.bucketname)

    # テンプレートシーンをコピーする
    template = {}
    with io.BytesIO() as f:
        bucket.download_fileobj("template.json", f)
        template = json.loads(f.getvalue())

    # ルンバ召喚（※X方向に1.6メートル、Z方向に2.2メートル移動した場所）
    add_roomba(
        template,
        args,
        x=1.6,
        y=0,
        z=-2.2,
        message="""
## Bさんのルンバ
        """,
    )

    # ルンバを召還したシーンを、ユーザーBのシーンとして保存する
    with io.BytesIO(json.dumps(template).encode("utf-8")) as f:
        bucket.upload_fileobj(f, "other_user_mail_com.json")


# 処理を実行する
main(Argments.parse_args())
main2(Argments.parse_args())
```

# 環境構築4: iot-app-kitからシーンを開きます

## 実施すること

下準備で作っていたReactプロジェクトを開いて、src/App.tsxを更新します

```tsx
import "./App.css";
import { initialize } from "@iot-app-kit/source-iottwinmaker";
import { SceneViewer } from "@iot-app-kit/scene-composer";

function App() {
  const sceneLoader = initialize("${workspace-name}", {
    awsCredentials: {
      accessKeyId: "XXXXXXXXXXXXXXX",
      secretAccessKey: "XXXXXXXXXXXXXXX",
    },
    awsRegion: "${region-name}",
  }).s3SceneLoader("${scene-name}");

  return (
    <div className="App">
      <SceneViewer sceneLoader={sceneLoader} activeCamera="Camera1" />
    </div>
  );
}

export default App;
```

変数は以下のように、自分の環境に置き換えてください 

|キー名|値|
|:--|:--|
|${workspace-name}|TwinMakerのワークスペースの名前です|
|${scene-name}|シーン名です ※ログインユーザーのユーザー名を当てはめます|
|AccessKeyId|AWSの認証情報です。※実際の運用ではCognitoを使うようにしてください|
|SecretAccessKey|AWSの認証情報です。※実際の運用ではCognitoを使うようにしてください|
|activeCamera|初期状態で指定するカメラです。シーン内にカメラを置いていないのなら未指定にします|

## 動かしたところ

npm run startで動かして、ブラウザで`http://localhost:3000`を開くと、TwinMakerの画面が表示されます

![a-san.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/6c944718-4a50-03d1-b213-75233d76044b.png)

# 環境構築5: アノテーションに画像を入れる

アノテーションには画像を表示することができます。

ソースコードで`## Aさんのルンバ`と書いてある部分（アノテーション、オーバーレイ）は、マークダウンを使うことができるため、`![](http://url.com/file.png)`の書式で、好きな画像を表示させることができます。

テキストの下に画像を表示するには、2行あけることが必要です
必ずテキストが必要です。テキストの内容でアノテーションの表示エリアの大きさが決まるため、画像だけを表示させることはできません

```markdown
## Aさんのルンバ

![](http://url.com/file.png)
```

> **注意：マネジメントコンソールではアノテーション上の画像リンクは動かない**
> ※AWSマネジメントコンソールのTwinMaker上では、接続できるドメインが限定されているため、画像リンクは動きません。iot-app-kitが必要です

# まとめ: ログインユーザーの位置情報を3Dに表示する

位置情報を取得してSiteWiseに連携させる必要はありますが、Lambdaの定期実行でシーンコピーを行うことで、ログインユーザーが持っているIoT機器の位置情報を3D上に表示させることができるようになります。

つまりTwinMakerを使うことで、**ルンバがバーチャルウォールの向こう側に引っかかって動けなくなっているよ**みたいなことを、デジタルツインで可視化することができます。複雑なコーディングの必要はなく、Blenderのような3Dソフトを覚える必要もありません。そこが大きな利点です。

また、アノテーションの画像の取得先をApiGatewayやS3 Object Lambdaにすれば、`TwinMakerのページを開いた瞬間`のグラフを出すこともできます

# 補足

シーンの更新はリロード時にしか行われず、TwinMaker上で3Dモデルのアニメーションは（※普通の方法では）再生できません。ですので、ダッシュボードを開いたままリアルタイムで追いかける、といったことは難しいです

TwinMakerで表示する3DモデルをLambdaで動的に作って配信することは可能です。ですが、その場合はLambdaの定期実行で出力したファイルをS3に配置する必要があります。Object Lambdaのエンドポイントを通して、TwinMakerが参照する3DモデルをLambdaから直接配信することはできません。（※エイリアスを使うことでS3エンドポイントを通した配信はできますが、Object Lambdaのエンドポイントは厳密にS3エンドポイントとして扱えないので、ドメインの制限に引っかかります）

画像から動的にGLTFを作る方法はこちらを参照してください

https://qiita.com/ShotaOki/items/612811ef34317f033911