不動産・建築系3Dモデルのビジュアライゼーションを作ってみた

[PlayCanvas運営事務局](https://playcanvas.jp/)の城戸です。

今回はビジュアライゼーションで不動産・建設関係で利用できそうなコンテンツを作りました。

# 建築系ビジュアライズ

![build.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109168/a996cf68-9d4f-a60f-5150-b70d3c40eaae.gif)
[こちらでコンテンツを体験できます](https://playcanv.as/p/F1ZzFOYS/)


とあるビルのオフィス内の案内コンテンツのモックのデモを作成しました。
階層の番号をクリックすることでその階層を一望、3Dモデルのビルを360度俯瞰することもできます。
スポットであるポイントを設置し、クリックするとHTMLで概要が表示される仕組みも作りました。

FlashがWebでよく使われていた頃に、デパートとか施設のマップ案内をするWebページがありました。
それらはFlashで実装されていましたが、2020年いっぱいでFlashはサービスを終了しました。
今では画像やPDFなどで実装されていて、少し見づらくなったように思えます。

二次元の施設のマップ案内も3次元にすることで視覚的にも分かりやすい案内マップを作ることができます。


# PlayCanvasに建築系3Dモデルを使用するメリットとは

PlayCanvasにインポートすることで、作成した建築モデルをWeb上で3Dビジュアライゼーションが可能になります。
これにより、建築物の外観や内部の構造をWebからいつでもで確認できるため、営業ツールとして活用することもできます。

また、建築モデルにインタラクティブな機能を付け加えることができ、建物内を自由に移動したり、部屋の中の家具を動かして配置変更を行うことも実現可能です。今回のコンテンツのように改装ごとにプレビューを切り替えることもできます。

インタラクティブな機能を追加したプロジェクトを一つ作成すれば、プロジェクトを複製して他建築モデルに差し替えることもできます。プロジェクトを再利用することで作業効率が向上し、プロジェクトのコストを抑えることができます。



# 作り方

**今回の記事では完成したプロジェクトから紹介しているだけなので、丸々コピーしても正しく動作しない場合があるのでご了承ください。**

コードの説明については軽く触れますが詳しく解説しません。ご了承をください。
設定内容やコードなどでご質問などがあれば、コメントください。
また、[PlayCanvasユーザーコミュニティ](https://playcanvas.jp/joincommunity/)がございますので、こちらからお問い合わせいただいても構いません。

## レシピ

今回は以下のPlayCanvasと3つ用意すれば問題ありません。

- [PlayCanvas](https://playcanvas.jp/)
- 使用する3Dモデル（使用したビルの階層は事前に用意していたものになります）
- [Vue.js](https://jp.vuejs.org/v2/guide/installation.html) (CDN:`https://cdn.jsdelivr.net/npm/vue@2.6.14`)
- [PlayCanvas Tweenライブラリ](https://developer.playcanvas.com/en/tutorials/tweening/)

PlayCanvasから新規プロジェクトでテンプレートの「**Model Viewer**」を選び新規プロジェクトを作成します。
![スクリーンショット 2023-03-29 12.32.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109168/7e5b38e7-6112-467c-c3a9-c6cb2fcc3506.png)


## 3Dモデルを配置

用意した3Dモデルは階層ごとにあらかじめ分けてPlayCanvasにインポートしています。
今回はLightを使用せず、3Dモデルのマテリアルに陰影(AO)を焼き付けてから使用しています。

事前に3Dモデルを用意していない場合は、PlayCanvasで作成可能のプリミティブなBoxなどでも同じようなコンテンツを作成ができます。

以下画像を見ると分かりますが、ヒエラルキーに **building** を親として子に各階層のEntityを入れています。
<img width="1619" alt="スクリーンショット 2020-02-12 15.50.40.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109168/bc189a26-15d3-56e5-b838-602846a3f528.png">


## 3Dモデルが移動する位置を決める
先ほど配置した3DモデルのEntityの子に、横にスライドしたときの位置を決めたEntityを配置します。
同じ3Dモデルを使って位置を決めるとわかりやすくなります。決められたらこのEntityは **Enabledをfalse** にして非表示しておきます。
<img width="1590" alt="スクリーンショット 2020-02-12 15.57.14.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109168/1c4125fe-cccd-f515-c407-8f5b015cf577.png">

3Dモデルの配置ができたら、次はCameraも移動する位置を決めます。
横にスライドした3Dモデルを俯瞰できるような位置に設置し、これも同様に **Enabledをfalse** にして非表示にします。
<img width="1605" alt="スクリーンショット 2020-02-12 15.57.24.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109168/71a575ad-fce0-4fcc-7080-c180ba13d47a.png">


## Cameraの設定
テンプレートの「Model Viewer」から作っていると、以下のScriptsがセットされていると思います。

- orbitCamera.js
- mouseInput.js
- touchInput.js

`orbitCamera.js`の **Focus Entity** を変更しますが、その原点となるEntityを設置します(下部2枚目の画像)
ここでは **orbitcamera-genten** という原点となる空のEntityを配置しています。
**Focus Entity** が変更できたらOKです。

<img width="318" alt="スクリーンショット 2020-02-12 16.03.29.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109168/63a6c3a1-6a77-f6b0-bbb8-7e6c00d48c7d.png">
<img width="1632" alt="スクリーンショット 2020-02-12 16.03.43.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109168/613fe01f-bd8d-6920-c4af-69df9d72b3b2.png">

### autoRotate.js
Cameraに新しく自動回転するスクリプトを作成します。

この`autoRotate.js`はテンプレートにある`orbitCamera.js`で設定しているCameraのYawを操作しています。

updateで`this.orbitCamera.yaw += this.speed;`と記述しているのが自動回転する処理です。
`this.timer += dt;`とかは Attributes の wait の秒数と比較していて、急に回転を始めるのではなく、ちょっと待ってから自動回転を始めるようになります。


```js
var AutoRotate = pc.createScript('autoRotate');

AutoRotate.attributes.add('speed', {type: 'number',default: 1,title: 'Speed',description: 'The rotate speed of camera.'});
AutoRotate.attributes.add('wait', {type: 'number',default: 5,title: 'Wait',description: 'Enable auto rotate after seconds.'});

AutoRotate.prototype.initialize = function() {
    this.timer = 0;
    this.orbitCamera = this.entity.script.orbitCamera;
};

AutoRotate.prototype.resetTimer = function() {
    this.timer = 0;
};

AutoRotate.prototype.update = function(dt) {
    if(vueApp.rotateFlag) return false;
    if(!vueApp.openfloor&&!vueApp.clickAnimation){
        this.timer += dt;

        if (this.timer > this.wait) {
            this.orbitCamera.yaw += this.speed;
        }
    }
};
```



## DOMを追加

次はHTMLやCSSの設定に進みます。

### Vue.jsを使う
PlayCanvasでVue.jsとかのCDNを使う場合には、SETTINGSの **EXTERNAL SCRIPTS** を使います。
<img width="327" alt="スクリーンショット 2020-02-12 16.24.17.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109168/928aaaa9-382a-0198-8379-7b1c058f4816.png">

[詳しい解説はこちらから](https://qiita.com/sutobu000/items/9fe9eed3ce8baf8974ca)


### changeFloor.js
`orbitCamera.js`がCameraの動きの中枢でしたが、`changeFloor.js`はコンテンツの柱となるためコードが多くなります。

DOMを配置するためにdiv要素`wrapper`を配置や、CSSは`getFileUrl()`を使いlink要素で読み込むなど処理をしていますが、選択された階層に対するCameraと3Dモデルの移動が肝となる処理になります。

`vueApp`の箇所が**Vue.js**で操作する箇所になります。

`tween()`はPlayCanvasのTweenライブラリです。
これにより、CameraのPositionやEulerAngleと、3DモデルのPositionを処理してます。

このTweenの処理を　**true/flase(Boolean)のFlag** で管理しています。
階層がOpen、Closeを判断し、対応した処理を行なっています。

このFlagは先ほどの`autoRotate.js`の以下のコードも見ています。
ここではCloseの状態でTweenのアニメーションが切れていれば autoRotate するようにしています。

```js
if(vueApp.rotateFlag) return false;
    if(!vueApp.openfloor&&!vueApp.clickAnimation){
```

```js
/*jshint esversion: 6, asi: true, laxbreak: true*/
const ChangeFloor = pc.createScript('changeFloor');

ChangeFloor.attributes.add("baseHtml", {type:"asset", assetType:"html"});
ChangeFloor.attributes.add("setCSS", {type:"asset", assetType:"css"});
ChangeFloor.attributes.add("target", {type:"entity"});
ChangeFloor.attributes.add("cameraTarget", {type:"entity"});

ChangeFloor.prototype.initialize = function() {
    let self = this;
    let canvas = document.getElementsByTagName("canvas")[0];
    canvas.classList.add("pcCanvas");
    let wrapper = document.createElement("div");
    wrapper.classList.add("wrapper");
    wrapper.innerHTML = self.baseHtml._resources[0];
    canvas.parentNode.appendChild(wrapper);

    let css = document.createElement("link");
    css.setAttribute("href", this.setCSS.getFileUrl());
    css.setAttribute("rel", "stylesheet");
    document.head.appendChild(css);

    let t_camera = self.cameraTarget;
    let cameraPosOri = Object.assign({},t_camera.getLocalPosition());
    let cameraRotOri = Object.assign({},t_camera.getLocalEulerAngles());
    
    let tHead = "とあるビルのオフィス内";
    let tContent = "PlayCanvasでビル内の各階層を3Dで観ることができます。 <br>気になる箇所をクリックすることで詳細が観れます。";
    
    vueApp = new Vue({
        el: '#app',
        data: {
            openfloor: false,
            clickAnimation: false,
            floors: self.target.children,
            lastTarget: null,
            targetPosOri: [],
            DOMhead: tHead,
            DOMcontent: tContent,
            DOMraycastFlag: false,
            DOMraycastHead: "",
            DOMraycastContent: "",
            rotateFlag: false,
        },
        methods: {
            onfloorClick: function(target,index) {
                const v_self = this;
                if(v_self.clickAnimation || v_self.openfloor) return;
                cameraPosOri = Object.assign({},t_camera.getLocalPosition());
                cameraRotOri = Object.assign({},t_camera.getLocalEulerAngles());
                v_self.clickAnimation = true;
                v_self.openfloor = true;

                v_self.lastTarget = index;
                v_self.targetPosOri[index] = Object.assign({}, target.getLocalPosition());

                let cameraPos,cameraRot,targetPos;

                for(let i=0; i<target.children.length; i++) {
                    if(target.children[i].name != "RootNode"){
                        if(target.children[i].camera){
                            cameraPos = Object.assign({}, target.children[i].getLocalPosition());
                            cameraRot = Object.assign({}, target.children[i].getLocalEulerAngles());
                        }else if(target.children[i].tags._list[0] === "tween"){
                            targetPos = Object.assign({}, target.children[i].getLocalPosition());
                        }
                    }
                }
                
                // カメラ移動
                t_camera.tween(t_camera.getLocalPosition()).to({
                    x:target.getLocalPosition().x+cameraPos.x,
                    y:target.getLocalPosition().y+cameraPos.y,
                    z:target.getLocalPosition().z+cameraPos.z
                }, 1, pc.SineOut).start();
                t_camera.tween(t_camera.getLocalEulerAngles()).rotate(cameraRot, 1, pc.Linear).start();
                // ビル階層移動
                target.tween(target.getLocalPosition()).to({
                    x:target.getLocalPosition().x+targetPos.x,
                    y:target.getLocalPosition().y+targetPos.y,
                    z:target.getLocalPosition().z+targetPos.z
                }, 1, pc.SineOut).on("complete",function(){
                    target.tags.add("isopen");
                    v_self.clickAnimation = false;
                }).start();
                
                v_self.DOMhead = "ただいま、" + target.name + "階";
                v_self.DOMcontent = "ここは" + target.name + "階です。ここにはその階に応じた説明文を記入する感じになります。";
            },
            oncloseClick: function() {
                const v_self = this;
                v_self.floors[v_self.lastTarget].tags.remove("isopen");
                // カメラ移動
                t_camera.tween(t_camera.getLocalPosition()).to({
                    x:cameraPosOri.x,
                    y:cameraPosOri.y,
                    z:cameraPosOri.z
                }, 1, pc.SineOut).start();
                t_camera.tween(t_camera.getLocalEulerAngles()).rotate(cameraRotOri, 1, pc.SineOut).start();
                v_self.clickAnimation = true;
                // ビル階層移動
                v_self.floors[v_self.lastTarget].tween(v_self.floors[v_self.lastTarget].getLocalPosition()).to(v_self.targetPosOri[v_self.lastTarget], 1, pc.SineOut)
                    .on("complete",function(){ v_self.clickAnimation = false; })
                    .start();
                v_self.openfloor = false;
            
                v_self.DOMhead = tHead;
                v_self.DOMcontent = tContent;
            },
            oncloseRcClick: function() {
                const v_self = this;
                v_self.DOMraycastFlag = false;
                v_self.DOMraycastHead = "";
                v_self.DOMraycastContent = "";
            }
        }
    });
    
};
```

Attributesに設定は以下画像の通り
<img width="322" alt="スクリーンショット 2020-02-12 16.30.12.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109168/b0fdb752-b8af-b50d-7173-edc1f466365f.png">

DOMを操作する系のスクリプトはRootに登録しています。Rootの方が管理がしやすいからと言うのが理由ですね。
<img width="1631" alt="スクリーンショット 2020-02-12 16.29.50.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109168/5dffbe57-3f5d-eeb8-0e57-882147825d11.png">

Attributesに登録されているHTMLとCSSのコードを次に解説します。

### base.html

Vueの構文が色々書いていますが、階層がOpenかCloseかをv-ifで見ていたり、どの階層をクリックしたかを@clickでイベント取得したりしています。
`class="detail is-ray"`の要素は、レイキャストでクリックしたスポットの情報を表示するDOMです。


```html
<div id="app" class="wrapper">
    <nav v-if="!rotateFlag" class="select" :class="openfloor ? '' : 'is-open'">
        <div class="select__inner">
            <div class="item" v-for="(floor,index) in floors" @click="onfloorClick(floor,index)"><span>{{floor.name}}</span></div>
        </div>
    </nav>
    <div v-if="!rotateFlag" class="detail" :class="{'is-open':openfloor}">
        <div class="closeBtn" v-if="openfloor&&!DOMraycastFlag" @click="oncloseClick()"></div>
        <div class="detail__inner">
            <p class="domhead" v-html="DOMhead"></p>
            <p class="domcontent" v-html="DOMcontent"></p>
        </div>
    </div>
    <div class="detail is-ray" v-if="DOMraycastFlag">
        <div class="closeBtn" @click="oncloseRcClick()"></div>
        <div class="detail__inner">
            <p class="domhead" v-html="DOMraycastHead"></p>
            <p class="domcontent" v-html="DOMraycastContent"></p>
        </div>
    </div>
    <div class="rotateCheck" v-if="!openfloor"><label for="rotate"><input type="checkbox" id="rotate" v-model="rotateFlag" /><span>カメラ操作切り替え</span></label></div>
</div>
```

### style.css
本来は`reset.css`を記述していますが、特筆するものはないのでここでは割愛します。

```css
body {
    background-color: #b1b1b1;
}

.wrapper {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    z-index: 10;
}
.select {
    position: absolute;
    top: 0;
    right: 0;
    z-index: 5;
    transform: translateX(400px);
    transition: transform .4s;
}
.select.is-open {
    transform: translateX(0);
}
.select__inner {
    position: absolute;
    top: 0;
    right: 0;
    width: 330px;
    padding: 5px;
    border-radius: 10px;
    background-color: rgba(255,255,255, .5);
}
.item {
    display: inline-block;
    vertical-align: middle;
    margin: 10px;
    padding: 10px;
    border: 1px solid #cccccc;
    border-radius: 50%;
    background-color: #cccccc;
    text-align: center;
    transition: background .3s;
    cursor: pointer;
}
.item span {
    color: #eeeeee;
    font-size: 2rem;
    line-height: 1;
    transition: color .3s;
}
.item:hover {
    background-color: #eeeeee;
}
.item:hover span {
    color: #333333;
}
.closeBtn {
    position: absolute;
    top: -10px;
    right: 0;
    width: 50px;
    height: 50px;
    background-color: #333333;
    transition: background .3s;
    cursor: pointer;
}
.closeBtn:before,.closeBtn:after {
    content: "";
    position: absolute;
    top: 50%;
    left: 50%;
    width: 30px;
    height: 2px;
    border-radius: 25%;
    background-color: #eeeeee;
    transition: background .3s;
}
.closeBtn:before {
    transform: translate(-50%,-50%) rotate(45deg);
}
.closeBtn:after {
    transform: translate(-50%,-50%) rotate(135deg);
}
.closeBtn:hover {
    background-color: #eeeeee;
}
.closeBtn:hover:before,.closeBtn:hover:after {
    background-color: #333333;
}
.detail {
    position: absolute;
    bottom: 50%;
    left: 50%;
    z-index: 2;
    transform: translate(-50%,50%);
    max-width: 800px;
    min-width: 420px;
    width: 100%;
    padding: 50px 20px;
    transition: all .4s;
    pointer-events: none;
}
.detail.is-ray {
    pointer-events: auto;
}
.detail * {
    transition: all .6s;
}
.detail__inner {
    padding: 100px 30px;
    border-radius: 10%;
    background-color: rgba(35,35,35, .7);
    text-align: center;
}
.domhead {
    margin-bottom: .5em;
    color: #eeeeee;
    margin-bottom: 10px;
    font-size: 2rem;
    font-weight: bold;
    line-height: 2;
}
.domcontent {
    color: #eeeeee;
    font-size: 1.6rem;
    line-height: 2;
}
.rotateCheck {
    position: fixed;
    bottom: 0;
    right: 0;
    z-index: 10;
    pointer-events: auto;
}
.rotateCheck label {
    display: block;
    cursor: pointer;
}
.rotateCheck label input {
    display: none;
}
.rotateCheck label span {
    display: inline-block;
    padding: 1rem;
    background-color: #cccccc;
    font-size: 1rem;
    line-height: 1;
    transition: color .2s, background .2s;
}
.rotateCheck label input:checked+span {
    color: #eeeeee;
    background-color: #333333;
    pointer-events: none;
}

.detail.is-open {
    bottom: 0;
    left: 0;
    transform: translate(0,0);
    max-width: 100%;
    min-width: 0;
    width: 100%;
    padding: 20px;
    pointer-events: auto;
}
.detail.is-open .detail__inner {
    padding: 20px;
    border-radius: 0;
    background-color: rgba(255,255,255, .7);
    text-align: left;
}
.detail.is-open .domhead {
    color: #111111;
    font-size: 1.2rem;
}
.detail.is-open .domcontent {
    color: #111111;
    font-size: 1rem;
}
```

## レイキャスト
[レイキャストについてはこちら](https://qiita.com/sutobu000/items/3b105ca85f4152d59cb2)

各階層のスポットを紹介するためにレイキャストでクリックできるポインターを設置します。
各階層の子としてEntityを追加します。真上からの視点で配置させるとやりやすいです。
<img width="1670" alt="スクリーンショット 2020-02-12 17.14.55.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109168/edb5a41f-4c9a-6879-fc00-43b1b4cf7ab6.png">

### domRaycast.js

ポインターを追加できたら、次はスクリプトを作ります。
今回はDOMを使ったものになっているため`style`のコードばかりとなりますが、`update`で書いているコードがレイキャストの処理になります。

[DOMでのレイキャスト以外にも方法あります](https://qiita.com/sutobu000/items/e9146a7d136ef5255ca2)

```js
/*jshint esversion: 6, asi: true, laxbreak: true*/
const DomRaycast = pc.createScript('domRaycast');

DomRaycast.attributes.add("cameraEntity", {type: "entity", title: "Camera Entity"});
DomRaycast.attributes.add("a_domhead", {type: "string", title: "DOM Head"});
DomRaycast.attributes.add("a_domcontent", {type: "string", title: "DOM Content"});

// g_domRC = {};
// g_domRC.flag = false;
// g_domRC.domhead = "test";
// g_domRC.domcontent = "testest";

DomRaycast.prototype.initialize = function() { // init
    let self = this;
    self.directionToCamera = new pc.Vec3();
    self.defaultForwardDirection = self.entity.forward.clone();
    
    self.btn = document.createElement("div");
    document.getElementsByTagName("canvas")[0].parentNode.appendChild(self.btn);
    self.btn.style.position = "absolute";
    self.btn.style.width = "30px";
    self.btn.style.height = "30px";
    self.btn.style.borderRadius = "50%";
    self.btn.style.background = "#111111";
    self.btn.style.transition = "opacity .5s";
    self.btn.style.zIndex = 10;
    self.btn.style.cursor = "pointer";
    self.btn.style.pointerEvents = "none";
    self.btn.addEventListener("mouseover",function(){ this.style.background = "#555555"; });
    self.btn.addEventListener("mouseout",function(){ this.style.background = "#111111"; });
    self.btn.addEventListener("mousedown",function(){ this.style.background = "#aaaaaa"; });
    self.btn.addEventListener("mouseup",function(){
        vueApp.DOMraycastFlag = true;
        vueApp.DOMraycastHead = self.a_domhead;
        vueApp.DOMraycastContent = self.a_domcontent;
        this.style.background = "#111111";
    });
};

DomRaycast.prototype.update = function(dt) { // update
    let worldPos = this.entity.getPosition();
    let screenPos = new pc.Vec3();
    
    this.cameraEntity.camera.worldToScreen(worldPos, screenPos);
    
    this.directionToCamera.sub2(this.cameraEntity.getPosition(), this.entity.getPosition());
    this.directionToCamera.normalize();
    // let dot = this.directionToCamera.dot(this.defaultForwardDirection);
    if (this.entity.parent.tags._list[0] === "isopen" && !vueApp.DOMraycastFlag) {
        this.btn.style.pointerEvents = "auto";
        this.btn.style.opacity = 1;
    } else {
        this.btn.style.pointerEvents = "none";
        this.btn.style.opacity = 0;
    }
    
    this.btn.style.transform = "translate(" + screenPos.x + "px," + screenPos.y + "px)";
};
```

AttributesではCameraとモーダルで表示させるテキスト情報を登録できます。
スクリプトを一つ作って各Entityである階層に登録していけるのがPlayCanvasの良いところです。

<img width="318" alt="スクリーンショット 2020-02-12 17.16.33.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109168/8b314718-b32a-2646-790e-b91361b509af.png">


## 背景透過
ここまでで処理は完成していますが、おまけでPlayCanvasのコンテンツ背景を透過させます。
Cameraの設定のClear ColorのAlphaを0に。
<img width="536" alt="スクリーンショット 2020-02-12 17.23.40.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109168/31dcd154-bc41-a156-d304-da3fea0ee29a.png">
SETTINGSのRENDERINGの設定内にある`Transparent Canvas`をtrueにします。

<img width="337" alt="スクリーンショット 2020-02-12 17.23.56.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109168/30eb7494-ef68-ca59-a323-7320bedf6cda.png">


PlayCanvasでのcanvasの透過方法ですが、これも別記事で説明しています。
[詳しくはそちらでご参照ください](https://qiita.com/sutobu000/items/6ea88a88583cc26bb7b1)



## 完成
ここまで設定できたら完成です！
Vue.jsを使うことでデータの受け渡しが楽になるので良いです。ライブラリ感覚で使えるのも良いところですね。
<img width="1594" alt="スクリーンショット 2020-02-12 17.29.06.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109168/823ee12c-131b-9081-40f4-623eff552cda.png">

## 処理のフロー
コードについてあまり説明をできていませんが、流れについて説明しておきます。

今回の根幹は`changeFloor.js`ですが、基本的にはopenする階層を選択したら移動するというのが主な処理です。
これを行うためには、各それぞれの要素がどんな動きをしているのか、どんな状態で待機しているのかを管理する必要がありました。
今回では、`autoRotate.js`は自動回転を始めてしまいますので、階層をopenにした後にも自動回転してしまっては思った挙動になりません。
DOMについても表示すべき場面で表示させないといけません。
そのため、`changeFloor.js`は階層を変える処理を行うのですが、他の処理をコントロールする中枢の処理も担っています。
![スクリーンショット 2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109168/bb56a220-3584-ae7e-9843-90ed1f6eec0a.png)