1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

プリザンターの拡張機能だけでSTL(3Dモデル)プレビューを実装してみる

1
Posted at

はじめに

「PDF や DXF のプレビューができるなら、3D プリンタ用の STL ファイルもプレビューできない?」という話が出たので、今回も拡張機能だけで実装してみました。

以前の記事でプリザンターの拡張機能だけで PDF プレビューDXF プレビューを実装しましたが、今回は 3D プリンティングや CAD データ交換で広く使われる STL ファイルに挑戦してみます。

Three.js と STLLoader を組み合わせることで、拡張スクリプトと拡張スタイルだけで 3D プレビューを実装できました

バージョン 1.5.1.0 以降を対象にしています

STL ファイル形式とは

STL(Standard Triangulated Language)は 3D モデルの表面形状を三角形の集合(メッシュ)で表現するファイル形式です。3D プリンティングや CAD データ交換の標準フォーマットとして広く使われています。

項目 内容
正式名称 Standard Triangulated Language
拡張子 .stl
データ形式 ASCII またはバイナリ
格納情報 三角形ポリゴンの頂点座標と法線ベクトル
用途 3D プリンティング、CAD データ交換、シミュレーション

ASCII 形式とバイナリ形式

STL には 2 つの形式があります。

比較項目 ASCII 形式 バイナリ形式
ファイルサイズ 大きい(テキスト) 小さい(約 1/5)
可読性 テキストで読める バイナリで読めない
読み込み速度 遅い 速い
実務での利用 デバッグ・確認用 一般的に使われる

ASCII 形式の例:

solid model
  facet normal 0 0 1
    outer loop
      vertex 0 0 0
      vertex 1 0 0
      vertex 0 1 0
    endloop
  endfacet
endsolid model

バイナリ形式は 80 バイトのヘッダ、三角形の数(4 バイト)、三角形データ(各 50 バイト)で構成されます。

今回使用する Three.js の STLLoader は 両方の形式を自動判別して読み込めるため、利用者がファイル形式を意識する必要はありません。

Three.js と STLLoader

Three.js

Three.js は WebGL を抽象化した 3D レンダリングライブラリです。シーン、カメラ、ライティング、マテリアルなどの 3D グラフィックスの基本要素を JavaScript で扱えます。

STLLoader

Three.js の追加モジュールとして提供される STLLoader は、STL ファイル(ASCII / バイナリ)をパースして Three.js の BufferGeometry に変換します。

全体の流れ

ライセンス

Three.js は MIT ライセンス で公開されています。MIT ライセンスは商用利用・再配布・改変が自由に行えるきわめて寛容なライセンスで、著作権表示の記載のみが求められます。CDN 経由で読み込む場合でも、サービスのライセンス表記ページに Three.js の著作権表示を含めることを推奨します。

仕組みを整理する

添付ファイルの検出

PDF プレビュー・DXF プレビューと同じ仕組みで、添付ファイル項目の hidden input(input.control-attachments)の JSON 配列から .stl ファイルを検出します。

一覧画面ではダウンロードリンクのテキストが .stl で終わるかどうかで判定します。

STL ファイルの取得

STL ファイルにはバイナリ形式もあるため、Three.js の STLLoader が提供する load() メソッドを使います。STLLoader は URL を受け取り、ASCII / バイナリを自動判別してパースし、Three.js の BufferGeometry を返します。

var loader = new THREE.STLLoader();
loader.load(downloadUrl, function (geometry) {
  // geometry を Mesh にしてシーンに追加
});

3D 描画の構成

Three.js で 3D モデルを表示するには、最低限以下の要素が必要です。

要素 役割
Scene 3D オブジェクトを配置する空間
PerspectiveCamera 透視投影カメラ(遠近感のある描画)
WebGLRenderer WebGL を使ったレンダリングエンジン
Light 照明(環境光 + 平行光源)
Mesh メッシュ(ジオメトリ + マテリアル)

対象項目の設定

PDF プレビュー・DXF プレビューと同じく、スクリプト冒頭の MODE 変数でどの添付ファイル項目にビューアーを表示するかを切り替えられます。

モード MODE の値 動作
個別指定 'include' フィールド CSS に stl-viewer を追加した項目だけにビューアーを表示(既定)
全適用 'all' すべての添付ファイル項目にビューアーを表示
除外指定 'exclude' フィールド CSS に no-stl-viewer を追加した項目を除くすべてにビューアーを表示

設定手順は PDF プレビュー・DXF プレビューと同様です。テーブルの管理→エディタで対象の添付ファイル項目を選択し、「フィールド CSS」に stl-viewer(個別指定モード時)を入力します。

一覧画面のプレビューボタンはモード設定に関係なく、すべての STL リンクに表示されます。

実装してみよう

拡張スクリプトと拡張スタイルの 2 ファイルで構成します。

拡張機能 役割
拡張スクリプト Three.js + STLLoader の読み込み・3D 描画・モーダル制御
拡張スタイル 埋め込みビューアー・モーダルの見た目を定義

拡張スタイル

拡張スタイルとして App_Data/Parameters/ExtendedStyles/ に配置します。

ExtendedStyles/StlViewer.css
/* ===== 埋め込みビューアー(編集画面) ===== */
.stl-embed-field {
  padding-top: 0;
  min-height: 0;
}

.stl-embed {
  border: 1px solid var(--base-border);
  border-radius: 4px;
  overflow: hidden;
}

.stl-embed.collapsed .stl-canvas-wrap,
.stl-embed.collapsed .stl-controls {
  display: none;
}

.stl-embed .stl-tabs {
  display: flex;
  align-items: center;
  border-bottom: 1px solid var(--base-border);
  background: var(--base-bg-light);
  overflow-x: auto;
}

.stl-embed .stl-tab {
  padding: 6px 14px;
  border: none;
  background: none;
  cursor: pointer;
  font-size: 12px;
  white-space: nowrap;
  border-bottom: 2px solid transparent;
  color: var(--scrollbar-thumb);
  transition: color 0.2s, border-color 0.2s;
}

.stl-embed .stl-tab:hover {
  color: var(--base-text);
}

.stl-embed .stl-tab.active {
  color: var(--primaryColor);
  border-bottom-color: var(--primaryColor);
}

.stl-embed .stl-canvas-wrap {
  position: relative;
  display: flex;
  justify-content: center;
  padding: 0;
  background: #1e1e1e;
  aspect-ratio: 2 / 1;
  overflow: hidden;
}

.stl-embed canvas {
  width: 100%;
  height: 100%;
  display: block;
}

/* ===== ローディング ===== */
.stl-loader {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.6);
  z-index: 10;
  transition: opacity 0.2s;
}

.stl-loader.hidden {
  opacity: 0;
  pointer-events: none;
}

.stl-loader .stl-spinner {
  width: 40px;
  height: 40px;
  border: 4px solid var(--scrollbar-thumb);
  border-right-color: transparent;
  border-radius: 50%;
  animation: stl-rotate 1s linear infinite;
}

@keyframes stl-rotate {
  to {
    transform: rotate(1turn);
  }
}

.stl-embed .stl-controls {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  padding: 4px 8px;
  background: var(--base-bg-light);
  border-top: 1px solid var(--base-border);
}

.stl-ctrl-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: none;
  border: none;
  cursor: pointer;
  padding: 4px;
  border-radius: 4px;
  color: var(--base-text);
}

.stl-ctrl-btn:hover {
  background: var(--btn-normal-hover);
}

.stl-info {
  font-size: 13px;
  min-width: 50px;
  text-align: center;
}

.stl-toggle-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: none;
  border: none;
  cursor: pointer;
  padding: 4px 8px;
  border-radius: 4px;
  color: var(--base-text);
  margin-left: auto;
  font-size: 12px;
}

.stl-toggle-btn:hover {
  background: var(--btn-normal-hover);
}

/* ===== 表示設定パネル ===== */
.stl-display-panel {
  position: absolute;
  top: 0;
  right: 0;
  width: 220px;
  max-height: 100%;
  background: rgba(30, 30, 30, 0.92);
  border-left: 1px solid rgba(255, 255, 255, 0.15);
  display: flex;
  flex-direction: column;
  z-index: 5;
  color: #ccc;
  font-size: 12px;
}

.stl-display-panel.hidden {
  display: none;
}

.stl-display-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 6px 8px;
  border-bottom: 1px solid rgba(255, 255, 255, 0.15);
  font-weight: bold;
}

.stl-display-header button {
  background: none;
  border: 1px solid rgba(255, 255, 255, 0.3);
  border-radius: 3px;
  color: #ccc;
  font-size: 11px;
  cursor: pointer;
  padding: 2px 6px;
}

.stl-display-header button:hover {
  background: rgba(255, 255, 255, 0.15);
}

.stl-display-list {
  overflow-y: auto;
  flex: 1;
}

.stl-display-item {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 4px 8px;
  cursor: pointer;
  transition: background 0.15s;
}

.stl-display-item:hover {
  background: rgba(255, 255, 255, 0.08);
}

.stl-display-item input[type='checkbox'] {
  accent-color: var(--primaryColor, #4285f4);
  margin: 0;
}

.stl-display-item span {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.stl-color-row {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 4px 8px;
}

.stl-color-swatch {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  border: 2px solid transparent;
  cursor: pointer;
  transition: border-color 0.15s;
}

.stl-color-swatch:hover,
.stl-color-swatch.active {
  border-color: #fff;
}

/* ===== プレビューボタン(一覧画面) ===== */
.stl-preview-btn {
  font-size: 18px !important;
  vertical-align: middle;
  cursor: pointer;
  color: var(--warning-color);
  margin-left: 4px;
}

.stl-preview-btn:hover {
  opacity: 0.8;
}

/* ===== モーダル ===== */
#stl-modal {
  position: fixed;
  inset: 0;
  z-index: 9998;
  display: none;
  align-items: center;
  justify-content: center;
}

#stl-modal .stl-modal-overlay {
  position: fixed;
  inset: 0;
  background: var(--u-modal-bg);
}

#stl-modal .stl-modal-dialog {
  position: relative;
  background: var(--base-bg);
  border-radius: 8px;
  width: 95vw;
  max-width: 1400px;
  height: 92vh;
  display: flex;
  flex-direction: column;
  box-shadow: 0 8px 32px var(--base-shadow);
  z-index: 1;
}

#stl-modal .stl-modal-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 12px 16px;
  border-bottom: 1px solid var(--base-border);
}

#stl-modal .stl-modal-title {
  font-weight: bold;
  font-size: 14px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

#stl-modal .stl-modal-close {
  background: none;
  border: none;
  cursor: pointer;
  padding: 4px;
  display: flex;
  color: var(--base-text);
}

#stl-modal .stl-modal-close:hover {
  opacity: 0.7;
}

#stl-modal .stl-modal-body {
  position: relative;
  flex: 1;
  overflow: hidden;
  display: flex;
  justify-content: center;
  align-items: center;
  padding: 0;
  background: #1e1e1e;
}

#stl-modal .stl-modal-body canvas {
  width: 100%;
  height: 100%;
  display: block;
}

#stl-modal .stl-modal-controls {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 12px;
  padding: 8px 16px;
  border-top: 1px solid var(--base-border);
}

#stl-modal .stl-modal-controls button {
  background: none;
  border: none;
  cursor: pointer;
  padding: 4px;
  display: flex;
  color: var(--base-text);
  border-radius: 4px;
}

#stl-modal .stl-modal-controls button:hover {
  background: var(--btn-normal-hover);
}

/* ===== レスポンシブ対応 ===== */
@media (max-width: 1024px) {
  .stl-embed-field > .field-label {
    display: none;
  }
}

@media (max-width: 768px) {
  #stl-modal .stl-modal-dialog {
    width: 100vw;
    height: 100vh;
    max-width: none;
    border-radius: 0;
  }

  #stl-modal .stl-modal-header {
    padding: 8px 12px;
  }

  #stl-modal .stl-modal-body {
    padding: 0;
  }

  #stl-modal .stl-modal-controls {
    padding: 6px 8px;
  }

  .stl-embed .stl-tab {
    padding: 4px 10px;
    font-size: 11px;
  }

  .stl-display-panel {
    width: 180px;
  }
}

拡張スクリプト

拡張スクリプトとして App_Data/Parameters/ExtendedScripts/ に配置します。

ExtendedScripts/StlViewer.js
$(function () {
  // Three.js の設定(バージョンを固定して動作の安定性を確保)
  var THREE_VER = '0.172.0';
  var THREE_URL =
    'https://cdn.jsdelivr.net/npm/three@' + THREE_VER + '/+esm';
  var STL_LOADER_URL =
    'https://cdn.jsdelivr.net/npm/three@' +
    THREE_VER +
    '/examples/jsm/loaders/STLLoader.js';

  // モード設定: 'include' | 'all' | 'exclude'
  //   include : フィールドCSSに stl-viewer がある項目だけ対象(既定)
  //   all     : すべての添付ファイル項目を対象
  //   exclude : フィールドCSSに no-stl-viewer がある項目を除外
  var MODE = 'include';
  var CSS_INCLUDE = 'stl-viewer';
  var CSS_EXCLUDE = 'no-stl-viewer';

  // 表示モードの初期状態: true = 折りたたみ / false = 展開(既定)
  var DEFAULT_COLLAPSED = false;

  // モデルの色
  var MODEL_COLOR = 0x4fc3f7;

  // 編集画面・一覧画面のみ対象
  var action = $p.action();
  if (action !== 'edit' && action !== 'index') return;

  // Three.js モジュールを CDN から動的に読み込む
  Promise.all([import(THREE_URL), import(STL_LOADER_URL)])
    .then(function (modules) {
      var THREE = modules[0];
      var STLLoader = modules[1].STLLoader;
      var libs = { THREE: THREE, STLLoader: STLLoader };

      if (action === 'edit') {
        initEdit(libs);
        var timer = null;
        $(document).ajaxComplete(function () {
          clearTimeout(timer);
          timer = setTimeout(function () {
            initEdit(libs);
          }, 300);
        });
      }
      if (action === 'index') {
        initList(libs);
        // 追加読み込み(gridrows)後にアイコンを再付与
        var listTimer = null;
        $(document).ajaxComplete(function () {
          clearTimeout(listTimer);
          listTimer = setTimeout(function () {
            initList(libs);
          }, 300);
        });
      }
    })
    .catch(function (e) {
      console.error('STL ビューアーの読み込みに失敗しました:', e);
    });

  /* ========== 編集画面 ========== */

  function initEdit(libs) {
    document.querySelectorAll('.stl-embed-field').forEach(function (el) {
      if (el._renderer) {
        el._renderer.dispose();
        el._renderer = null;
      }
      el.remove();
    });

    document
      .querySelectorAll('.control-attachments')
      .forEach(function (input) {
        var field = input.closest('[id$="Field"]');
        if (!shouldAttach(field)) return;

        var files = findStlFiles(input);
        if (files.length === 0) return;
        buildEmbedViewer(field, files, libs);
      });
  }

  function shouldAttach(field) {
    if (!field) return false;
    if (MODE === 'all') return true;
    if (MODE === 'exclude') return !field.classList.contains(CSS_EXCLUDE);
    return field.classList.contains(CSS_INCLUDE);
  }

  // hidden input の JSON から STL だけを抽出する
  function findStlFiles(input) {
    var files = [];
    try {
      var list = JSON.parse(input.value || '[]');
    } catch (e) {
      return files;
    }
    list.forEach(function (att) {
      if (att.Deleted) return;
      if (!att.Name || !/\.stl$/i.test(att.Name)) return;
      files.push({
        name: att.Name,
        url: '/binaries/' + att.Guid + '/download'
      });
    });
    return files;
  }

  function buildEmbedViewer(field, stlFiles, libs) {
    var THREE = libs.THREE;

    // 添付ファイル項目の直後に field-wide の wrapper を作成
    var wrapper = document.createElement('div');
    wrapper.className = 'field-wide stl-embed-field';
    var label = document.createElement('p');
    label.className = 'field-label';
    label.appendChild(document.createElement('label'));
    wrapper.appendChild(label);
    var fc = document.createElement('div');
    fc.className = 'field-control';
    var cn = document.createElement('div');
    cn.className = 'container-normal';
    fc.appendChild(cn);
    wrapper.appendChild(fc);

    var el = document.createElement('div');
    el.className = 'stl-embed';

    // タブバー
    var tabBar = document.createElement('div');
    tabBar.className = 'stl-tabs';

    var btnToggle = document.createElement('button');
    btnToggle.type = 'button';
    btnToggle.className = 'stl-toggle-btn';
    btnToggle.textContent = DEFAULT_COLLAPSED ? '' : '';
    btnToggle.title = 'プレビューの表示切替';

    // キャンバス
    var wrap = document.createElement('div');
    wrap.className = 'stl-canvas-wrap';
    var canvas = document.createElement('canvas');
    var loader = document.createElement('div');
    loader.className = 'stl-loader';
    loader.innerHTML = '<div class="stl-spinner"></div>';
    wrap.append(canvas, loader);

    // コントロールバー
    var ctrl = document.createElement('div');
    ctrl.className = 'stl-controls';
    var btnZoomOut = iconBtn('zoom_out');
    btnZoomOut.title = '縮小';
    var btnZoomIn = iconBtn('zoom_in');
    btnZoomIn.title = '拡大';
    var btnReset = iconBtn('restart_alt');
    btnReset.title = 'デフォルト表示に戻す';
    var info = document.createElement('span');
    info.className = 'stl-info';
    info.textContent = 'ドラッグで回転';
    var btnDisplay = iconBtn('tune');
    btnDisplay.title = '表示設定';
    var btnOpen = iconBtn('open_in_new');
    btnOpen.title = '別ウィンドウで表示';
    ctrl.append(btnZoomOut, btnZoomIn, btnReset, info, btnDisplay, btnOpen);

    el.append(tabBar, wrap, ctrl);
    if (DEFAULT_COLLAPSED) el.classList.add('collapsed');
    cn.appendChild(el);
    field.after(wrapper);

    // Three.js のセットアップ
    var w = wrap.clientWidth || 400;
    var h = wrap.clientHeight || 200;
    var renderer = new THREE.WebGLRenderer({
      antialias: true,
      canvas: canvas
    });
    renderer.setSize(w, h);
    renderer.setPixelRatio(window.devicePixelRatio);
    renderer.setClearColor(0x1e1e1e);
    wrapper._renderer = renderer;

    var scene = new THREE.Scene();
    var camera = new THREE.PerspectiveCamera(45, w / h, 0.01, 100000);

    // ライティング
    scene.add(new THREE.AmbientLight(0x606060));
    var dirLight1 = new THREE.DirectionalLight(0xffffff, 1.0);
    dirLight1.position.set(1, 2, 3);
    scene.add(dirLight1);
    var dirLight2 = new THREE.DirectionalLight(0xffffff, 0.3);
    dirLight2.position.set(-1, -1, -2);
    scene.add(dirLight2);

    var vs = {
      idx: 0,
      files: stlFiles,
      scene: scene,
      camera: camera,
      renderer: renderer,
      wrap: wrap,
      loader: loader,
      info: info,
      mesh: null,
      orbit: null,
      displayPanel: null
    };

    stlFiles.forEach(function (f, i) {
      var tab = document.createElement('button');
      tab.type = 'button';
      tab.className = 'stl-tab' + (i === 0 ? ' active' : '');
      tab.textContent = f.name;
      tab.onclick = function () {
        tabBar.querySelectorAll('.stl-tab').forEach(function (t, j) {
          t.classList.toggle('active', j === i);
        });
        loadStl(vs, i, libs);
      };
      tabBar.appendChild(tab);
    });
    tabBar.appendChild(btnToggle);

    btnToggle.onclick = function () {
      var collapsed = el.classList.toggle('collapsed');
      btnToggle.textContent = collapsed ? '' : '';
      if (!collapsed) {
        resizeRenderer(vs);
        vs.renderer.render(vs.scene, vs.camera);
      }
    };

    btnZoomOut.onclick = function () {
      vs.orbit.radius *= 1.3;
      vs.updateCamera();
    };
    btnZoomIn.onclick = function () {
      vs.orbit.radius *= 0.7;
      vs.orbit.radius = Math.max(0.01, vs.orbit.radius);
      vs.updateCamera();
    };
    btnReset.onclick = function () {
      resetCamera(vs, libs.THREE);
      vs.renderer.render(vs.scene, vs.camera);
    };
    btnDisplay.onclick = function () {
      if (vs.displayPanel) vs.displayPanel.classList.toggle('hidden');
    };
    btnOpen.onclick = function () {
      openModal(vs.files[vs.idx], libs);
    };

    // マウス操作(回転・ズーム・パン)
    addOrbitControls(vs, libs.THREE);

    // ウィンドウリサイズ対応
    window.addEventListener('resize', function () {
      resizeRenderer(vs);
      vs.renderer.render(vs.scene, vs.camera);
    });

    loadStl(vs, 0, libs);
  }

  /* ========== 一覧画面 ========== */

  function initList(libs) {
    document
      .querySelectorAll('a[href*="/binaries/"][href*="/download"]')
      .forEach(function (a) {
        if (!/\.stl$/i.test(a.textContent.trim())) return;
        if (a.dataset.stlIcon) return;
        a.dataset.stlIcon = '1';
        var btn = document.createElement('span');
        btn.className = 'material-symbols-outlined stl-preview-btn';
        btn.textContent = 'preview';
        btn.title = 'STL 3D プレビュー';
        btn.onclick = function (e) {
          e.preventDefault();
          e.stopPropagation();
          openModal(
            { name: a.textContent.trim(), url: a.getAttribute('href') },
            libs
          );
        };
        a.after(btn);
      });
  }

  /* ========== STL 読み込み・描画 ========== */

  function loadStl(vs, idx, libs) {
    var THREE = libs.THREE;

    vs.idx = idx;
    if (vs.mesh) {
      vs.scene.remove(vs.mesh);
      vs.mesh.geometry.dispose();
      vs.mesh.material.dispose();
      vs.mesh = null;
    }
    if (vs.loader) vs.loader.classList.remove('hidden');
    vs.info.textContent = '読み込み中...';

    var stlLoader = new libs.STLLoader();
    stlLoader.load(
      vs.files[idx].url,
      function (geometry) {
        var material = new THREE.MeshPhongMaterial({
          color: MODEL_COLOR,
          specular: 0x222222,
          shininess: 40,
          side: THREE.DoubleSide,
          flatShading: true
        });
        var mesh = new THREE.Mesh(geometry, material);
        vs.mesh = mesh;
        vs.scene.add(mesh);

        resetCamera(vs, THREE);
        vs.renderer.render(vs.scene, vs.camera);
        buildDisplayPanel(vs, libs);

        // 三角形数を表示
        var triCount = geometry.index
          ? geometry.index.count / 3
          : geometry.attributes.position.count / 3;
        vs.info.textContent =
          Math.round(triCount).toLocaleString() + ' triangles';
      },
      undefined,
      function () {
        vs.info.textContent = '読み込み失敗';
      }
    );
    if (vs.loader) {
      setTimeout(function () {
        vs.loader.classList.add('hidden');
      }, 500);
    }
  }

  /* ========== カメラ制御 ========== */

  function resetCamera(vs, THREE) {
    if (!vs.mesh) return;
    var box = new THREE.Box3().setFromObject(vs.mesh);
    var center = box.getCenter(new THREE.Vector3());
    var size = box.getSize(new THREE.Vector3()).length();
    var dist = size * 1.5;

    vs.camera.position.set(
      center.x + dist * 0.5,
      center.y + dist * 0.5,
      center.z + dist
    );
    vs.camera.lookAt(center);
    vs.camera.near = size * 0.001;
    vs.camera.far = size * 100;
    vs.camera.updateProjectionMatrix();

    if (vs.orbit) {
      vs.orbit.target = center.clone();
      vs.orbit.radius = dist;
      vs.orbit.theta = Math.atan2(0.5, 1);
      vs.orbit.phi = Math.acos(0.5 / Math.sqrt(1.5));
    }
  }

  function addOrbitControls(vs, THREE) {
    var el = vs.renderer.domElement;
    var dragging = false;
    var panning = false;
    var lastX = 0;
    var lastY = 0;

    var orbit = {
      target: new THREE.Vector3(),
      radius: 10,
      theta: Math.PI / 4,
      phi: Math.PI / 3
    };
    vs.orbit = orbit;

    function updateCamera() {
      var sinPhi = Math.sin(orbit.phi);
      vs.camera.position.set(
        orbit.target.x + orbit.radius * sinPhi * Math.sin(orbit.theta),
        orbit.target.y + orbit.radius * Math.cos(orbit.phi),
        orbit.target.z + orbit.radius * sinPhi * Math.cos(orbit.theta)
      );
      vs.camera.lookAt(orbit.target);
      vs.renderer.render(vs.scene, vs.camera);
    }
    vs.updateCamera = updateCamera;

    el.addEventListener('pointerdown', function (e) {
      if (e.button === 1 || e.button === 2 || e.shiftKey) {
        panning = true;
      } else {
        dragging = true;
      }
      lastX = e.clientX;
      lastY = e.clientY;
      el.setPointerCapture(e.pointerId);
    });

    el.addEventListener('pointermove', function (e) {
      if (!dragging && !panning) return;
      var dx = e.clientX - lastX;
      var dy = e.clientY - lastY;
      lastX = e.clientX;
      lastY = e.clientY;

      if (panning) {
        vs.camera.updateMatrixWorld();
        var panSpeed = orbit.radius * 0.002;
        var right = new THREE.Vector3();
        var up = new THREE.Vector3();
        right.setFromMatrixColumn(vs.camera.matrixWorld, 0);
        up.setFromMatrixColumn(vs.camera.matrixWorld, 1);
        orbit.target.addScaledVector(right, -dx * panSpeed);
        orbit.target.addScaledVector(up, dy * panSpeed);
      } else {
        orbit.theta -= dx * 0.01;
        orbit.phi = Math.max(
          0.1,
          Math.min(Math.PI - 0.1, orbit.phi + dy * 0.01)
        );
      }
      updateCamera();
    });

    el.addEventListener('pointerup', function () {
      dragging = false;
      panning = false;
    });

    el.addEventListener('contextmenu', function (e) {
      e.preventDefault();
    });

    el.addEventListener(
      'wheel',
      function (e) {
        e.preventDefault();
        orbit.radius *= e.deltaY > 0 ? 1.1 : 0.9;
        orbit.radius = Math.max(0.01, orbit.radius);
        updateCamera();
      },
      { passive: false }
    );
  }

  function resizeRenderer(vs) {
    var w = vs.wrap.clientWidth;
    var h = vs.wrap.clientHeight;
    if (w <= 0 || h <= 0) return;
    vs.renderer.setSize(w, h);
    vs.camera.aspect = w / h;
    vs.camera.updateProjectionMatrix();
  }

  /* ========== モーダル ========== */

  function openModal(file, libs) {
    var THREE = libs.THREE;
    var modal = document.getElementById('stl-modal');
    if (!modal) modal = buildModal(libs);

    modal.querySelector('.stl-modal-title').textContent = file.name;
    modal.style.display = 'flex';

    var body = modal.querySelector('.stl-modal-body');
    var oldPanel = body.querySelector('.stl-display-panel');
    if (oldPanel) oldPanel.remove();

    var canvas = body.querySelector('canvas');
    if (!canvas) {
      canvas = document.createElement('canvas');
      body.appendChild(canvas);
    }

    var loader = body.querySelector('.stl-loader');
    if (loader) loader.classList.remove('hidden');

    var w = body.clientWidth || 800;
    var h = body.clientHeight || 600;

    var renderer = new THREE.WebGLRenderer({
      antialias: true,
      canvas: canvas
    });
    renderer.setSize(w, h);
    renderer.setPixelRatio(window.devicePixelRatio);
    renderer.setClearColor(0x1e1e1e);

    var scene = new THREE.Scene();
    var camera = new THREE.PerspectiveCamera(45, w / h, 0.01, 100000);

    scene.add(new THREE.AmbientLight(0x606060));
    var dl1 = new THREE.DirectionalLight(0xffffff, 1.0);
    dl1.position.set(1, 2, 3);
    scene.add(dl1);
    var dl2 = new THREE.DirectionalLight(0xffffff, 0.3);
    dl2.position.set(-1, -1, -2);
    scene.add(dl2);

    var vs = {
      idx: 0,
      files: [file],
      scene: scene,
      camera: camera,
      renderer: renderer,
      wrap: body,
      loader: loader,
      info: modal.querySelector('.stl-info'),
      mesh: null,
      orbit: null,
      displayPanel: null
    };
    modal._vs = vs;
    modal._renderer = renderer;

    addOrbitControls(vs, THREE);
    vs.info.textContent = '読み込み中...';

    loadStl(vs, 0, libs);

    var onKey = function (e) {
      if (e.key === 'Escape') closeModal();
    };
    document.addEventListener('keydown', onKey);
    modal._onKey = onKey;

    var onResize = function () {
      var mw = body.clientWidth;
      var mh = body.clientHeight;
      if (mw <= 0 || mh <= 0) return;
      renderer.setSize(mw, mh);
      camera.aspect = mw / mh;
      camera.updateProjectionMatrix();
      renderer.render(scene, camera);
    };
    window.addEventListener('resize', onResize);
    modal._onResize = onResize;
    setTimeout(onResize, 100);
  }

  function buildModal(libs) {
    var m = document.createElement('div');
    m.id = 'stl-modal';
    m.innerHTML =
      '<div class="stl-modal-overlay"></div>' +
      '<div class="stl-modal-dialog">' +
      '<div class="stl-modal-header">' +
      '<span class="stl-modal-title"></span>' +
      '<button type="button" class="stl-modal-close">' +
      '<span class="material-symbols-outlined">close</span>' +
      '</button>' +
      '</div>' +
      '<div class="stl-modal-body">' +
      '<div class="stl-loader"><div class="stl-spinner"></div></div>' +
      '</div>' +
      '<div class="stl-modal-controls">' +
      '<button type="button" class="stl-modal-zoom-out" title="縮小">' +
      '<span class="material-symbols-outlined">zoom_out</span>' +
      '</button>' +
      '<button type="button" class="stl-modal-zoom-in" title="拡大">' +
      '<span class="material-symbols-outlined">zoom_in</span>' +
      '</button>' +
      '<button type="button" class="stl-modal-reset" title="デフォルト表示に戻す">' +
      '<span class="material-symbols-outlined">restart_alt</span>' +
      '</button>' +
      '<span class="stl-info">ドラッグで回転</span>' +
      '<button type="button" class="stl-modal-display" title="表示設定">' +
      '<span class="material-symbols-outlined">tune</span>' +
      '</button>' +
      '</div>' +
      '</div>';

    document.body.appendChild(m);

    m.querySelector('.stl-modal-overlay').onclick = closeModal;
    m.querySelector('.stl-modal-close').onclick = closeModal;
    m.querySelector('.stl-modal-zoom-out').onclick = function () {
      var vs = m._vs;
      if (!vs || !vs.orbit) return;
      vs.orbit.radius *= 1.3;
      vs.updateCamera();
    };
    m.querySelector('.stl-modal-zoom-in').onclick = function () {
      var vs = m._vs;
      if (!vs || !vs.orbit) return;
      vs.orbit.radius *= 0.7;
      vs.orbit.radius = Math.max(0.01, vs.orbit.radius);
      vs.updateCamera();
    };
    m.querySelector('.stl-modal-reset').onclick = function () {
      var vs = m._vs;
      if (!vs) return;
      resetCamera(vs, libs.THREE);
      vs.renderer.render(vs.scene, vs.camera);
    };
    m.querySelector('.stl-modal-display').onclick = function () {
      var vs = m._vs;
      if (vs && vs.displayPanel) vs.displayPanel.classList.toggle('hidden');
    };
    return m;
  }

  function buildDisplayPanel(vs, libs) {
    var THREE = libs.THREE;
    var existing = vs.wrap.querySelector('.stl-display-panel');
    if (existing) existing.remove();
    vs.displayPanel = null;

    if (!vs.mesh) return;

    var panel = document.createElement('div');
    panel.className = 'stl-display-panel hidden';

    var header = document.createElement('div');
    header.className = 'stl-display-header';
    header.innerHTML = '<span>表示設定</span>';
    var resetBtn = document.createElement('button');
    resetBtn.type = 'button';
    resetBtn.textContent = 'リセット';
    resetBtn.onclick = function () {
      vs.mesh.material.wireframe = false;
      vs.mesh.material.flatShading = true;
      vs.mesh.material.color.setHex(MODEL_COLOR);
      vs.mesh.material.needsUpdate = true;
      cbWire.checked = false;
      cbFlat.checked = true;
      swatches.forEach(function (sw) {
        sw.classList.toggle('active', sw.dataset.color === '0x' + MODEL_COLOR.toString(16));
      });
      vs.renderer.render(vs.scene, vs.camera);
    };
    header.appendChild(resetBtn);
    panel.appendChild(header);

    var list = document.createElement('div');
    list.className = 'stl-display-list';

    // ワイヤーフレーム切替
    var wireItem = document.createElement('label');
    wireItem.className = 'stl-display-item';
    var cbWire = document.createElement('input');
    cbWire.type = 'checkbox';
    cbWire.checked = false;
    cbWire.onchange = function () {
      vs.mesh.material.wireframe = cbWire.checked;
      vs.renderer.render(vs.scene, vs.camera);
    };
    var wireLabel = document.createElement('span');
    wireLabel.textContent = 'ワイヤーフレーム';
    wireItem.append(cbWire, wireLabel);
    list.appendChild(wireItem);

    // フラットシェーディング切替
    var flatItem = document.createElement('label');
    flatItem.className = 'stl-display-item';
    var cbFlat = document.createElement('input');
    cbFlat.type = 'checkbox';
    cbFlat.checked = true;
    cbFlat.onchange = function () {
      vs.mesh.material.flatShading = cbFlat.checked;
      vs.mesh.material.needsUpdate = true;
      vs.renderer.render(vs.scene, vs.camera);
    };
    var flatLabel = document.createElement('span');
    flatLabel.textContent = 'フラットシェーディング';
    flatItem.append(cbFlat, flatLabel);
    list.appendChild(flatItem);

    // カラープリセット
    var colorRow = document.createElement('div');
    colorRow.className = 'stl-color-row';
    var colorLabel = document.createElement('span');
    colorLabel.textContent = 'カラー';
    colorLabel.style.marginRight = '4px';
    colorRow.appendChild(colorLabel);

    var colors = [
      { hex: 0x4fc3f7, name: 'ライトブルー' },
      { hex: 0x81c784, name: 'グリーン' },
      { hex: 0xffb74d, name: 'オレンジ' },
      { hex: 0xe57373, name: 'レッド' },
      { hex: 0xbdbdbd, name: 'グレー' },
      { hex: 0xfff176, name: 'イエロー' }
    ];
    var swatches = [];
    colors.forEach(function (c) {
      var sw = document.createElement('span');
      sw.className = 'stl-color-swatch';
      sw.style.background = '#' + c.hex.toString(16).padStart(6, '0');
      sw.title = c.name;
      sw.dataset.color = '0x' + c.hex.toString(16);
      if (c.hex === MODEL_COLOR) sw.classList.add('active');
      sw.onclick = function () {
        vs.mesh.material.color.setHex(c.hex);
        vs.renderer.render(vs.scene, vs.camera);
        swatches.forEach(function (s) {
          s.classList.remove('active');
        });
        sw.classList.add('active');
      };
      swatches.push(sw);
      colorRow.appendChild(sw);
    });
    list.appendChild(colorRow);

    panel.appendChild(list);
    vs.wrap.appendChild(panel);
    vs.displayPanel = panel;
  }

  function closeModal() {
    var m = document.getElementById('stl-modal');
    if (!m) return;
    m.style.display = 'none';
    if (m._onKey) {
      document.removeEventListener('keydown', m._onKey);
    }
    if (m._onResize) {
      window.removeEventListener('resize', m._onResize);
    }
    if (m._renderer) {
      m._renderer.dispose();
      m._renderer = null;
    }
  }

  /* ========== ユーティリティ ========== */

  function iconBtn(name) {
    var b = document.createElement('button');
    b.type = 'button';
    b.className = 'stl-ctrl-btn';
    var s = document.createElement('span');
    s.className = 'material-symbols-outlined';
    s.textContent = name;
    b.appendChild(s);
    return b;
  }
});

各処理のポイント

ライブラリの読み込み

Three.js 本体と STLLoader の 2 つを CDN から動的に読み込みます。

Promise.all([import(THREE_URL), import(STL_LOADER_URL)])
  • Three.js: ES モジュール形式のため import() で動的にインポート。jsDelivr の +esm エンドポイントを利用
  • STLLoader: Three.js の追加モジュールとして同様に import() で読み込み

マウス操作(軌道カメラ制御)

Three.js の OrbitControls はモジュール依存関係の解決が複雑になるため、簡易的な軌道カメラ制御を自前で実装しています。

var orbit = {
  target: new THREE.Vector3(), // 注視点
  radius: 10,                 // カメラと注視点の距離
  theta: Math.PI / 4,         // 水平方向の角度
  phi: Math.PI / 3            // 垂直方向の角度
};
  • ドラッグ: theta(水平角度)と phi(垂直角度)を更新して回転
  • Shift+ドラッグ / 右ドラッグ: target(注視点)をカメラのローカル座標系に沿って移動(パン)
  • ホイール: radius(距離)を更新してズーム
  • ズームボタン: ホイール操作と同等のズームをボタンで実行
  • phi0.1π - 0.1 の範囲に制限して、真上・真下でカメラが反転するのを防止

球面座標からカメラ位置を計算します。

camera.position.set(
  target.x + radius * Math.sin(phi) * Math.sin(theta),
  target.y + radius * Math.cos(phi),
  target.z + radius * Math.sin(phi) * Math.cos(theta)
);
camera.lookAt(target);

PointerEvent によるデバイス統合

マウスイベント(mousedown / mousemove)ではなく PointerEvent(pointerdown / pointermove)を使用しています。これにより、マウス・タッチ・ペンの操作を 1 つのイベントハンドラで処理できます。

el.addEventListener('pointerdown', function (e) {
  dragging = true;
  el.setPointerCapture(e.pointerId);
});

setPointerCapture でキャンバス外にドラッグしても操作が継続するようにしています。

ライティング

3D モデルの形状を視覚的に把握するには適切なライティングが重要です。

// 環境光(全体を均一に照らす)
scene.add(new THREE.AmbientLight(0x606060));
// 主光源(斜め上から)
var dirLight1 = new THREE.DirectionalLight(0xffffff, 1.0);
dirLight1.position.set(1, 2, 3);
// 補助光源(反対側から弱く照らす)
var dirLight2 = new THREE.DirectionalLight(0xffffff, 0.3);
dirLight2.position.set(-1, -1, -2);
  • 環境光(AmbientLight): 全方向から均一に照らす。影のない部分が真っ黒にならないようにする
  • 平行光源(DirectionalLight): 太陽光のように平行な光。面の向きによって明暗が生まれ、形状が把握しやすくなる
  • 2 つの平行光源を反対方向から配置することで、裏面も暗くなりすぎない

マテリアル

STL ファイルには色やテクスチャの情報が含まれないため、スクリプト側でマテリアルを設定します。MeshPhongMaterial はライティングに反応する材質で、flatShading: true を指定すると三角形ポリゴンの面がはっきりと見える表示になります。side: THREE.DoubleSide でメッシュの裏面も描画します。

var material = new THREE.MeshPhongMaterial({
  color: MODEL_COLOR,
  specular: 0x222222,
  shininess: 40,
  side: THREE.DoubleSide,
  flatShading: true
});

flatShadingfalse にするとスムーズシェーディングになり、滑らかな曲面の表現に適します。表示設定パネルから動的に切り替えられます。

カメラの自動フィッティング

STL ファイルを読み込んだ後、モデル全体がビューポートに収まるようにカメラ位置を自動調整します。

var box = new THREE.Box3().setFromObject(mesh);
var center = box.getCenter(new THREE.Vector3());
var size = box.getSize(new THREE.Vector3()).length();
var dist = size * 1.5;

camera.position.set(
  center.x + dist * 0.5,
  center.y + dist * 0.5,
  center.z + dist
);
camera.lookAt(center);

Box3 でモデル全体のバウンディングボックスを計算し、その対角線の長さから適切なカメラ距離を算出しています。near / far クリッピング面もモデルサイズに合わせて動的に設定することで、極小モデルから大型モデルまで対応しています。

ファイル操作後の再構築

PDF プレビューと同様に、$(document).ajaxComplete() でファイル操作後のビューアー再構築に対応しています。

var timer = null;
$(document).ajaxComplete(function () {
  clearTimeout(timer);
  timer = setTimeout(function () {
    initEdit(libs);
  }, 300);
});

テーマ対応

DXF プレビューと同じテーマ CSS 変数を使用しているため、テーマの切り替えに自動追従します。3D ビューポートの背景色(#1e1e1e)はモデルの視認性を優先して固定しています。

旧テーマ(base / start 等)では CSS 変数が定義されていないため、コントロールバーの色が反映されません。cerulean(既定)や green-tea などの新テーマを使用してください。

表示設定パネル

STEP プレビューではパーツ単位の表示切替パネルを用意しましたが、STL はパーツ構造を持たないため、代わりに 表示設定パネルを実装しています。コントロールバーの tune アイコンで開閉できます。

// ワイヤーフレーム切替
vs.mesh.material.wireframe = cbWire.checked;

// フラットシェーディング切替
vs.mesh.material.flatShading = cbFlat.checked;
vs.mesh.material.needsUpdate = true;

// カラープリセット
vs.mesh.material.color.setHex(c.hex);
  • ワイヤーフレーム: ポリゴンの辺のみを表示するモード。メッシュの構造を確認する際に便利
  • フラットシェーディング: 三角形ポリゴンの面を平面として描画。OFF にするとスムーズシェーディングになり、滑らかな曲面表現に適する
  • カラープリセット: 6 色のプリセットからモデルの色を変更可能。STL ファイルは色情報を持たないため、視認性に応じて変更できる
  • リセット: すべての表示設定を初期状態に戻す

PDF・DXF・STL・STEP プレビューの比較

比較項目 PDF プレビュー DXF プレビュー STL プレビュー STEP プレビュー
ファイル形式 PDF DXF(テキスト) STL(ASCII / バイナリ) STEP(テキスト)
次元 2D 2D 3D 3D
パーサー PDF.js dxf-parser Three.js STLLoader occt-import-js(WASM)
レンダラー Canvas 2D Canvas 2D Three.js(WebGL) Three.js(WebGL)
描画方式 ページ単位ラスタライズ ベクター描画 3D メッシュ描画 3D メッシュ描画
マウス操作 ページ送り ズーム 回転・ズーム・パン 回転・ズーム・パン
表示設定 ワイヤーフレーム・シェーディング・カラー パーツ表示切替
WASM 不要 不要 不要 必要(約 13MB)
CDN 読み込みサイズ 小(約 800KB) 小(約 50KB) 中(約 700KB) 大(約 13MB)

制約事項

WebGL 対応

Three.js は WebGL を使用するため、WebGL に対応していないブラウザでは動作しません。現在のモダンブラウザ(Chrome、Firefox、Edge、Safari)はすべて WebGL に対応しています。

大規模モデルの処理

三角形数が多い大規模な STL ファイルの場合、WebGL の描画負荷が高くなり、操作のレスポンスが低下する可能性があります。

CSP(Content Security Policy)

プリザンターサーバに Content Security Policy が設定されている環境では、CDN からのスクリプト読み込みがブロックされることがあります。

CSP が設定されている場合は、script-src ディレクティブに cdn.jsdelivr.net を追加してください。

まとめ

今回は Three.js と STLLoader を組み合わせて、STL ファイルの 3D プレビューを拡張機能だけで実装してみました。

  • STL は 3D プリンティングや CAD データ交換で広く使われるメッシュ形式
  • Three.js の STLLoader が ASCII / バイナリ両形式を自動判別して読み込み、WebGL で 3D 描画
  • CDN(jsdelivr)から動的に読み込むため、サーバ側の設定は不要
  • MODE 変数で対象項目を 3 パターンで制御可能(include / all / exclude
  • マウスドラッグで 3D 回転、Shift+ドラッグ / 右ドラッグでパン、ホイール+ボタンでズーム操作に対応
  • 表示設定パネルでワイヤーフレーム切替・シェーディング切替・カラープリセット変更に対応
  • MeshPhongMaterial + DoubleSide + flatShading でメッシュの両面を陰影付きで表示
  • 環境光と 2 方向の平行光源で裏面も暗くなりすぎない自然なライティング
  • 編集画面では埋め込みビューアー、一覧画面ではモーダルビューアーを提供
  • カメラの自動フィッティングでモデルサイズに関わらず全体を表示
  • ファイルの追加・削除後も ajaxComplete でビューアーを自動再構築
  • 一覧画面でも ajaxComplete でスクロール追加読み込み後にアイコンを再付与
  • PDF.js(2D PDF)、dxf-parser(2D CAD)、occt-import-js(3D STEP)に続く第 4 弾として、3D メッシュのプレビューを拡張機能だけで実現
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?