【歯型STLプレゼントキャンペーン】Three.jsでSTLデータをレンダリングしてみる

こんにちは。
株式会社Oh my teethでテックリードをやっているTerukiです。

テック企業を自称しているのにQiitaなどの技術系記事が無いのは如何なものかということで今月から不定期で記事を上げていきたいと思います:pray_tone1:
# TL;DR
- この記事を読めばミニマムな構成でThree.jsが動かせるようになる
- Oh my teethの[無料歯型スキャン予約時](https://reservation.oh-my-teeth.com/scan?tags=qiita_stl&code=QiitaSTL)にプロモーションコード「QiitaSTL」を入力すると契約時にスキャンしたSTLファイルが貰える

# 概要
(Three.jsの話に入る前に)Oh my teethでは矯正が可能か調べるために[無料歯型スキャン](https://www.oh-my-teeth.com/posts/free-3d-scan?tags=qiita_stl)を行っています。
その時にスキャンした歯型データを以下のようなビューアでユーザーが触れるようになっています。
もちろん、ユーザー操作でカメラ移動や拡大などもできます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3192685/b608c2c3-eb59-d47a-c31f-c0369a27c884.gif" width="400" alt="適合診断ビューア" />


このビューアの実装にThree.jsが使われています。

この記事では最小構成でThree.jsを使ったSTLデータのレンダリングまで説明できればと思います。

# 準備するもの
- NodeJS
- 適当なエディタ

基本的にTypeScriptで書いていこうと思います。

# やってみる
この記事ではStanford Bunnyのstlファイルをレンダリングしてドラッグでカメラ移動出来るところまでやってみようと思います。

最終形のサンプルのリポジトリはこちら。git cloneしてお使いいただければ。

https://github.com/ohmyteeth/threejs-stl-sample

## プロジェクト作成
今風にサクッとviteでやってみます。
```sh
npm create vite@latest
```

ここではプロジェクト名はデフォルトのまま、フレームワークは`Vanilla`、VariantはTypeScriptを選びました。

ここで一度起動してみます。
```sh
cd vite-project
npm install
npm run dev
```

ブラウザでこの表示になれば問題なさそうです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3192685/7f2eb05c-5e9b-60bf-cb4a-32683f468bf1.png)

## Three.jsインストール
さっそく本題に入っていきます。

```sh
npm install --save-dev @types/three
npm install --save three
```

Three.jsとその型定義をインストールします。
今回使うSTLファイルもダウンロードしてpackage.jsonがあるディレクトリに置いておきます。

https://commons.wikimedia.org/wiki/File:Stanford_Bunny.stl

viteを起動しておきましょう。
```sh
npm run dev
```

## STLファイルをレンダリングしてみる
main.tsを[Three.jsの公式ドキュメント](https://threejs.org/docs/index.html#manual/en/introduction/Creating-a-scene)を参考に書き換えてみます。

```ts:main.ts
import * as THREE from 'three';
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader';

const wrapper = document.getElementById('app') as HTMLDivElement;

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
wrapper.appendChild(renderer.domElement);

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 2000);

camera.position.z = 300;

// STLファイルをロードしてレンダリング
const stlLoader = new STLLoader();
stlLoader.load('./Stanford_Bunny.stl', (geometry) => {
    const material = new THREE.MeshBasicMaterial({
        color: 0xFFFFFF
    });
    const mesh = new THREE.Mesh(geometry, material);
    scene.add(mesh);

    renderer.render(scene, camera);
});
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3192685/6c22974c-555e-2323-b930-55f9d2c9ced1.png)
謎の白いシルエットが出てきました。これだとどの辺がうさぎなのかよく分かりませんね:thinking:


## 光を当ててみる
STLファイルをレンダリングすること自体はできたようですが、これでは3Dモデル感がないので適当に光源を設定して立体感が出るようにしてみます。

```ts:main.ts
import * as THREE from 'three';
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader';

const wrapper = document.getElementById('app') as HTMLDivElement;

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
wrapper.appendChild(renderer.domElement);

const scene = new THREE.Scene();

// ライティングの設定
const light1 = new THREE.DirectionalLight(0xFFFFFF);
light1.position.set(0, 0, -1);
const light2 = new THREE.DirectionalLight(0xFFFFFF);
light2.position.set(0, 0, 1);
const light3 = new THREE.AmbientLight(0x404040);
scene.add(light1);
scene.add(light2);
scene.add(light3);

const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 2000);

camera.position.z = 300;

// STLファイルをロードしてレンダリング
const stlLoader = new STLLoader();
stlLoader.load('./Stanford_Bunny.stl', (geometry) => {
    const material = new THREE.MeshPhongMaterial({
        color: 0xFFFFFF
    });
    const mesh = new THREE.Mesh(geometry, material);
    scene.add(mesh);

    renderer.render(scene, camera);
});
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3192685/fe0ee975-9f51-0285-7b27-0cd66ea68a99.png)
どうでしょう。さっきよりはよっぽど3Dモデル感が出たのではないでしょうか。

## 操作できるようにしてみる
これだけだと面白みが足りないのでマウスで操作できるようにしてみます。

```ts:main.ts
import * as THREE from 'three';
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';

const wrapper = document.getElementById('app') as HTMLDivElement;

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
wrapper.appendChild(renderer.domElement);

const scene = new THREE.Scene();

// ライティングの設定
const light1 = new THREE.DirectionalLight(0xFFFFFF);
light1.position.set(0, 0, -1);
const light2 = new THREE.DirectionalLight(0xFFFFFF);
light2.position.set(0, 0, 1);
const light3 = new THREE.AmbientLight(0x404040);
scene.add(light1);
scene.add(light2);
scene.add(light3);

const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 2000);

camera.position.z = 300;

const controls = new OrbitControls(camera, renderer.domElement);
controls.enableZoom = true;
controls.enableDamping = true;
controls.dampingFactor = 0.1;

// STLファイルをロードしてレンダリング
const stlLoader = new STLLoader();
stlLoader.load('./Stanford_Bunny.stl', (geometry) => {
    const material = new THREE.MeshPhongMaterial({
        color: 0xFFFFFF
    });
    const mesh = new THREE.Mesh(geometry, material);
    scene.add(mesh);

    // レンダリングループ
    const tick = () => {
        controls.update();
        renderer.render(scene, camera);
        requestAnimationFrame(tick);
    }
    tick();
});
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3192685/a41ea166-8e0e-b2d1-6c4a-eb938ba60abc.png)
回転するようになりましたが横方向の回転の様子がおかしいです。
左から右にドラッグしたら時計回りに回転してしまいます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3192685/603608a5-de16-c875-a14d-dcabcaee2641.png)

これはSTLの回転軸とシーンの軸がズレているためなのでシーンを回転してあげれば良さそうです。
Oh my teethでも使用する3Dスキャナーの種類によって回転軸が異なり、ビューア側で個別対応する必要があったため苦労しました。
```ts:main.ts
const scene = new THREE.Scene();
// 回転軸をオブジェクトと合わせて直感的な操作になるようにしておく
scene.rotateX(-90 * (Math.PI / 180));
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3192685/9c942530-da6a-0bdb-600e-0d7291b2ddea.png)
良い感じです。微妙に右上に寄ってるのが気になるので中央に配置されるようにしてみます。

```ts:main.ts
const mesh = new THREE.Mesh(geometry, material);
mesh.geometry.center(); // オブジェクトを中央に配置
scene.add(mesh);
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3192685/24c3ff69-5e9c-8d3a-a3a6-bdaec6c8c90c.png)

かなり良さそうです！
Three.jsを使えばほんの50行程度のコードでモデルを触れるようになります。

今回はSTLファイルでしたがThree.jsにはSTL以外のローダーもあるのでローダーを差し替えれば他の形式のファイルでも使えるかもしれません。

最終形のコードはこちら:
https://github.com/ohmyteeth/threejs-stl-sample/blob/main/src/main.ts

# 歯型STLプレゼントキャンペーン
Oh my teethはマウスピース矯正ブランドの1つです。
詳細なサービスの説明はQiitaでやることでもないと思うので[公式サイト](https://www.oh-my-teeth.com/?tags=qitta_stl_bottom)にお任せしますが、tech系の内容については以前Techableに寄稿した[こちらの記事](https://techable.jp/archives/187394)が面白いかもしれません。

また、マウスピース矯正に興味がある方に[無料歯型スキャン](https://www.oh-my-teeth.com/posts/free-3d-scan?tags=qiita_stl_bottom)でスキャンしたSTLデータをご契約後にお渡しするキャンペーンをやりたいと思います:tada:

[予約フォーム](https://reservation.oh-my-teeth.com/scan?tags=qiita_stl_bottom&code=QiitaSTL)

ご契約後に私から上顎と下顎のSTLファイルをお渡しします！
※不適合(Oh my teethのマウスピース矯正では直せない重度のがたつきなど)になり契約できなかった場合もお渡しします

## おわりに

歯科業界は電話での予約や紙の問診票など、かなりアナログなイメージがある方が多いと思いますがそれと比較してOh my teethはかなりぶっ飛んだことをやっているつもりなので興味がある方は是非ストアに来ていただければと思います。

Oh my teethは未来の歯科体験を実現していくミッションがありますので、テクノロジーに力を入れているところをどんどん発信していけたらと思っています。

ではでは！