【gas】google ドライブのフォルダにある360度パノラマ画像をグリグリ出来るようにした

#はじめに
Three.jsの勉強がてらgoogleドライブにあるTHETA等で撮影した360度写真を、専用サイトにアップロードすることなくお手軽にグリグリ出来るビューアを作ってみました。
ソースにはところどころ自身が理解するため用にコメントが書いてあり、中には間違った解釈をしている箇所があるかもしれないのでご了承ください。

#作成動機
THETAの写真を共有したい
↓
THETAの公式サイトにアップロードしURLを教える方法があるが正直めんどくさい
↓
写真データはgoogleドライブで共有されている
↓
Three.jsの勉強もしたかったのでgasでビューアを作ろう

#概要
・画像が入っているgoogleドライブのフォルダを参照してサムネイル表示をする
・参照するフォルダは切り替えることができる
・サムネイルをクリックすることで写真をパノラマ表示する
・表示する際は全画面
・全画面表示後サムネイル選択画面に戻れる

#使っている言語とかライブラリとか
・google apps script
・html
・css
・javascript
・jQuery
・Three.js

#ソースコード
コードは6つに別れています。
・main.gs
・index.html
・style.html (css)
・js.html (javascript)
・three.html
・orbitcontrols.html
「style.html」はcssファイル、「js.html」はjavascriptファイルを担っています。

処理の流れは
「main.gs」が「index.html」を呼び出す
↓
「index.html」で参照したいフォルダIDをテキストボックスへ入力
↓
入力されたフォルダIDを「js.html」で取得し、「main.gs」を呼び出し処理を依頼
↓
「main.gs」で動的にhtmlを生成し「js.html」でDOM操作
アルバム風にサムネイルを表示
↓
サムネイルをクリックすることで「js.html」がイベント処理をし「main.gs」を呼び出す
「main.gs」がファイルIDを元に、画像をBase64型式に変換し、「js.html」で360度パノラマ画像表示処理をする
という流れになっています。

##Three.jsについて
「three.html」と「orbitcontrols.html」はThree.jsをDLしてきて、「three.js」と「OrbitControls.js」をコピペして貼りつけたものです。
[Three.jsの公式ページ](https://threejs.org/)からDLしてください。

「three.js」はダウンロードしたフォルダ内の ［build/three.js］
「Orbitcontrols.js」はダウンロードしたフォルダ内の ［examples/js/controls/OrbitControls.js］
にそれぞれあります。
いずれも`<script>ライブラリのソース</script>`という形にして、scriptタグで囲ってください。

「three.html」と「orbitcontrols.html」の全ソースは長いので割愛します。
「three.html」はコピペしてきた内容をそのまま使いますが、「orbitcontrols.html」には少し手を加えます。

##Drive APIについて
Drive APIを利用するので、リソースタブ→googleの拡張サービスからDrive APIを有効にしてください。

以下ソースです。
##main.gs
```js:main.gs
function doGet() {
  var tpl = HtmlService.createTemplateFromFile('index.html');
  return tpl.evaluate().setSandboxMode(HtmlService.SandboxMode.IFRAME);
}

function mkPics(folderid) {
  if (folderid == "") {
    return "<p>フォルダID入力欄が空です</p>"
  }
  
  var piclist = getPicturesList(folderid);
  var html = "";
  
  try {
    for (var i = 0; i < piclist.length; i++) {
      html += "<figure class='pic' onClick='makesphere(\"" + piclist[i][0] + "\")'>";
      html += "<img id='" + piclist[i][0] + "' class='picture' src='" + piclist[i][1] + "'>";
      html += "<figcaption>" + piclist[i][2] + "</figcaption>";
      html += "</figure>";
    }
  } catch(e) {
    return "<p>フォルダIDやアクセス権限が間違っている可能性があります</p>";
  }
  
  if (piclist.length == 0) {
    return "<p>対象画像がありません</p>";
  }

  return html;
}

function getPicturesList(fid) {
  try {
    var folder = DriveApp.getFolderById(fid);
  } catch(e) {
    return;
  }
  var contents = folder.getFiles();
  var piclist = new Array();
  var i = 0;
  var pic;

  while (contents.hasNext()) {
    pic = contents.next();
    if (pic.getMimeType() == "image/jpeg") {
      var picpro = new Array();
      picpro[0] = pic.getId();
      picpro[1] = Drive.Files.get(pic.getId()).thumbnailLink;
      picpro[2] = pic.getName();
      piclist[i] = picpro;
      i++;
    }
  }

  return piclist;
}

function getBase64(id) {
  var res = getDriveImage(id);
  res = JSON.parse(res);

  return res.image;
}

function getDriveImage(id) {
  var response = {};
  
  var res = DriveApp.getFileById(id);
  
  response.fileid = res.getId();
  
  var accesstoken = ScriptApp.getOAuthToken();
  var fetchArgs = {};
  fetchArgs.headers = { 'Authorization': 'Bearer ' + accesstoken };
  fetchArgs.method = 'GET';
  fetchArgs.muteHttpExceptions = true;
  var image = UrlFetchApp.fetch(res.getDownloadUrl(), fetchArgs).getContent();
  //convert dataurl
  var dataurl = 'data:image/jpeg;base64,' + Utilities.base64Encode(image);
  response.image = dataurl;
  
  response = JSON.stringify(response);
  
  return response;
}
```

##index.html
```html:index.html
<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv='content-type' content='text/html; charset=utf-8' />
    <script src='//ajax.googleapis.com/ajax/libs/jquery/1.11.0/jquery.min.js'></script>
    <script  src='//cdnjs.cloudflare.com/ajax/libs/jquery-cookie/1.4.1/jquery.cookie.min.js' ></script>
  </head>
  <body>
    <div id='album'>
      <div id='inputfolder'>
        <b>全天球画像フォルダID: </b>
        <input id='driveid' type='text' size='50'>
        <input id='sbt' type='submit' value='送信' onClick='makepiclist()'>
      </div>
      <hr class='line' noshade>
      <div id='pictures'>
        <b>ここにサムネイルが表示されます</b>
      </div>
      <hr class='line' noshade>
    </div>
    <div id='celestial_sphere'>
    </div>
  </body>
  <?!= HtmlService.createHtmlOutputFromFile('three').getContent(); ?>
  <?!= HtmlService.createHtmlOutputFromFile('orbitcontrols').getContent(); ?>
  <?!= HtmlService.createHtmlOutputFromFile('js').getContent(); ?>
  <?!= HtmlService.createHtmlOutputFromFile('style').getContent(); ?>
</html>
```

##js.html
```js:js.html
<script>
let controls; //controlsを保持したいのでグルーバル化

function makepiclist() { //入力されたドライブIDを使いアルバム風に画像を表示する関数
  let did = document.getElementById("driveid");
  let read = "<b>読み込み中...</b>";
  
  $('#pictures').html(read);
  
  $(function() { //gasへ
    google.script.run.withSuccessHandler(mkpiccb).mkPics(did.value);
  });
}

function mkpiccb(picshtml) { //mkPics の callback関数 htmlを受取指定タグへ上書き
  let read = "<b>画像を選択してください</b>";
  $('#pictures').html(picshtml);
  $('#celestial_sphere').html(read);
}

function makesphere(id) { //サムネイルをクリックした際、アルバム部分を隠しつつファイルIDを使い画像のBase64化をはかる関数
  let album = document.getElementById("album");
  album.style.display = "none";
  
  let read = "<b>全天球読み込み中...</b>";
  
  $('#celestial_sphere').html(read);
  
  $(function() { //gasへ
    google.script.run.withSuccessHandler(spherecb).getBase64(id);
  });
}

function spherecb(imgdata) { //getBase64 の callback関数 取得した画像のデータを使い全天球画像を表示する
  //ブラウザの表示領域を取得(後に画面比の計算や全画面対応するために使う)
  let width = window.innerWidth;
  let height = window.innerHeight;

  //舞台設置
  let scene = new THREE.Scene();

  //物体の形や大きさの定義
  let geometry = new THREE.SphereGeometry(5, 60, 40); //球体 半径:5 経度分割数:60 緯度分割数:40
  geometry.scale(-1, 1, 1); //球体の大きさ
  
  //物体の質感
  let material = new THREE.MeshBasicMaterial({ //MeshBasicMaterial(低) or MeshLambertMaterial(中) or MeshPhongMaterial(高)
    map: THREE.ImageUtils.loadTexture(imgdata)
  });
  
  //Mesh関数を使い定義した物体をセット
  let sphere = new THREE.Mesh(geometry, material);
  
  //舞台に追加
  scene.add(sphere);
  
  //カメラの作成
  //透視投影(普段見ている視点)か正投影(見えている物体の大きさは固定)か
  let camera = new THREE.PerspectiveCamera(75, width / height, 1, 100000); //透視投影 画角:75度 アスペクト比:width/height ニアークリップ:1 ファークリップ:100000
  camera.position.set(0, 0, 3); //カメラの位置
  camera.lookAt(sphere.position); //カメラが何を中心に見てほしいか、向きを固定する
  
  //レンダリング
  let renderer = new THREE.WebGLRenderer(); //WebGLRenderer(早い、多機能、モダンブラウザオンリー) or CanvasRenderer(遅い、機能少ない、対応ブラウザ多) 
  renderer.setSize((width - 2), (height - 2)); //大きさをセット
  renderer.setClearColor(0xeeeeee, 1.0); //背景色をセット
  $('#celestial_sphere').html(renderer.domElement); //celestial_sphereタグへ上書き
  renderer.render(scene, camera); //シーンとカメラから該当するシーンを描画
  
  //コントローラの作成
  //orbitControls を使うことでマウスのホイールなどに対応してくれるようになる
  controls = new THREE.OrbitControls(camera, renderer.domElement);
  
  //コントロール使用時に描画するための関数
  function render(){
    requestAnimationFrame(render);
    controls.update();
    renderer.render(scene, camera);
  }
  render();
  
  $('#celestial_sphere').append("<button id='in' class='bt' onclick='zoomin()'>ズームイン</button>");
  $('#celestial_sphere').append("<button id='out' class='bt' onclick='zoomout()'>ズームアウト</button>");
  $('#celestial_sphere').append("<button id='cut' class='bt' onclick='zoomcut()'>リセット</button>");
  $('#celestial_sphere').append("<button id='close' class='bt' onclick='sphereclose()'>閉じる</button>");
  
}

function sphereclose() {
  let read = "<b>画像を選択してください</b>";
  let album = document.getElementById("album");
  album.style.display = "inline";
  
  $('#celestial_sphere').html(read);
  
}

function zoomin() {
  controls.zoomin();
}
  
function zoomout() {
  controls.zoomout();
}

function zoomcut() {
  controls.reset();
}

</script>
```

##style.html
```css:style.html
<style>
html {
  background-color: #E0FFFF;
}

body {
  margin: 0;
}

hr {
  height: 1px;
  color: black;
}

b {
  font-size: 20px;
}

#in {
  position: fixed;
  z-index: 10000;
  top: 10px;
  left: 10px;
}

#out {
  position: fixed;
  z-index: 10000;
  top: 10px;
  left: 96px;
}

#cut {
  position: fixed;
  z-index: 10000;
  top: 10px;
  left: 196px;
}

#close {
  position: fixed;
  z-index: 10000;
  top: 10px;
  left: 270px;
}

.line {
  clear: both;
}

.pic {
  cursor: pointer;
  margin: 5px;
  float: left;
}

.picture {
  width: auto;
  height: 175px;
}

</style>
```

##orbitcontrols.html
「orbitcontrols.html」の30行目付近に`this.minDistance = 0;`というプロパティがあるので、`this.minDistance = 1;`に変更してください。

また360行目付近に`function dollyIn( dollyScale )`というメソッドがあります。
その中身を下記のように書き換えてください。

```js:orbitcontrols.html
function dollyIn( dollyScale ) {
  if ( scope.object instanceof THREE.PerspectiveCamera ) {
  
    if (object.zoom > 1) {
      scope.object.zoom = Math.max( scope.minZoom, Math.min( scope.maxZoom, scope.object.zoom * dollyScale ) );
      scope.object.updateProjectionMatrix();
      zoomChanged = true;
    } else {
      object.zoom = 1;
      scale /= dollyScale;
    }
      
  } else if ( scope.object instanceof THREE.OrthographicCamera ) {
        
    scope.object.zoom = Math.max( scope.minZoom, Math.min( scope.maxZoom, scope.object.zoom * dollyScale ) );
    scope.object.updateProjectionMatrix();
    zoomChanged = true;
        
  } else {
      
    console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.' );
    scope.enableZoom = false;
        
  }

}
```

dollyInの次のメソッドが`function dollyOut( dollyScale )`なので、同じく下記のように書き換えてください。

```js:orbitcontrols.html
function dollyOut( dollyScale ) {

  if ( scope.object instanceof THREE.PerspectiveCamera ) {

    if (scope.minDistance == spherical.radius) {
      scope.object.zoom = Math.max( scope.minZoom, Math.min( scope.maxZoom, scope.object.zoom / dollyScale ) );
      scope.object.updateProjectionMatrix();
      zoomChanged = true;
    } else {
      scale *= (dollyScale);
    }

  } else if ( scope.object instanceof THREE.OrthographicCamera ) {

    scope.object.zoom = Math.max( scope.minZoom, Math.min( scope.maxZoom, scope.object.zoom / dollyScale ) );
    scope.object.updateProjectionMatrix();
    zoomChanged = true;

  } else {

    console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.' );
    scope.enableZoom = false;

  }

}
```

最後に下記のソースをorbitcontrols.htmlの`THREE.OrbitControls = function ( object, domElement )`に加えてください。
`THREE.OrbitControls = function ( object, domElement )`は15行目付近〜900行目付近までとなっています。

```js:orbitcontrols.html
this.zoomout = function () {
  var dollyScale = getZoomScale();
  if (object.zoom > 1) {
    scope.object.zoom = Math.max( scope.minZoom, Math.min( scope.maxZoom, scope.object.zoom * dollyScale ) );
    scope.object.updateProjectionMatrix();
    zoomChanged = true;
  } else {
    object.zoom = 1;
    scale /= dollyScale;
  }
}

this.zoomin = function () {
  var dollyScale = getZoomScale();
  if (scope.minDistance == spherical.radius) {
    scope.object.zoom = Math.max( scope.minZoom, Math.min( scope.maxZoom, scope.object.zoom / dollyScale ) );
    scope.object.updateProjectionMatrix();
    zoomChanged = true;
  } else {
    scale *= (dollyScale);
  }
}
```

orbitcontrols.htmlにこれらのソースを加えることで、ボタンでの拡大縮小対応と拡大倍率を更に大きくすることが出来ます。

#デモ
[デモページ](https://script.google.com/macros/s/AKfycbw87VXWORe5z7tOa4Dra1zIP5ZLSFIEJlH0k_OWVRMP9pVLYOQ/exec)
※初回実行時はパーミッションを求められるので許可してください

#参考
[【Three.js】360°パノラマビューワーを作ってグリグリ操作出来るようにした](http://qiita.com/kingpanda/items/1c3a47765b40d6d62f46)