Three.jsでお手軽簡単に3Dプリントをする

# この記事は誰を想定した記事？

Blenderのような3Dソフトを使わずに、簡単なJavaScriptで3Dプリントをしたい人

# はじめに：この記事について

この記事で説明する事柄は以下のとおりです。

- three.jsを使って、図形、テキスト、SVGを立体化する方法を紹介します
- 立体化した図形を使って、3Dプリンタで日用品を作成する手順を紹介します
- マテリアルの設定など、3Dプリントに必要なthree.jsの設定について説明します

章立ては以下のとおりです。

- 前書き：3Dプリンタって？
- 基本設定：three.jsの基本的な設定
- 関数の使い方：three.jsで3Dプリントをする
- 実際にプリントしてみる

ソースコードはこちらにあります。
Vite、CLI、React、どの環境でも動きますので、READMEを参照してください。

https://github.com/ShotaOki/threejs-3d-print/tree/main

## 開発環境、この記事で使った機材

- Node.js
    - three 0.175.0
    - text-to-svg 3.1.5
    - three-bvh-csg 0.0.17
- Bambu Studio
- 3Dプリンター：Bambu Lab A1 mini  
※エントリーモデル、単色の3Dプリンター
- フィラメント：PLAマット  
※アッシュグレイ

## 構成図

データの流れは以下のとおりです。

![struct.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/ef785ade-47d0-4a62-98b8-8562c4f7f314.png)

# この記事で作るもの

この記事では、以下のものを印刷します。

**1.額縁ケース**  
手のひらほどの大きさのケースです。  
表が額縁になっていて、好きな写真を入れることができます。  
（データ作成時間：1時間。印刷時間：70分。フィラメント：35グラム）  

![image-case.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/d3d7371d-2035-4e03-b9f8-1389d2fd9dc7.jpeg)


レジン工作に使うシェイカー用のクリアフィルムを写真の前に重ねると、綺麗に写真を保護してくれます。

# 前書き：3Dプリンタって？

3Dプリンタの購入を検討されている方に向けて、3Dプリンタの話をします。
ご存知の方はこの章を読み飛ばしてください。

## 必要なソフト、必要なスキル

### 3Dプログラミング

一般的に、3Dプログラミングは難しいと言われます。
たとえば[3Dプログラミングを解説している和歌山大学の床井研究室の資料](https://marina.sys.wakayama-u.ac.jp/~tokoi/?blogcategory=OpenGL)を見ると、OpenGLを使った3Dプログラミングを解説する記事が123ページあります。視点（カメラ）、光源、テクスチャ、マッピング、シェーダ、法線…普段耳にしない言葉も並びます。

ですが、3Dプリントにカメラは出てきません。光源も法線も出てきません。シェーダ、マッピング、テクスチャ、どれも出てきません。123ページの中で、3Dプリントをするために必要なのは「第４回　図形の描画」と「第１０回　球を三角形で描く」の2ページくらい、乱暴な計算で1/60です。

3Dプログラミングで挫折しやすい部分の知識が出てこないため、とっつきやすさがあります。

### 組み立て

Bambu Lab A1 miniでは、組み立ては付属品の六角レンチでネジを回して、チューブを取り付けるだけで組み立てが終わります。家具よりもずっと簡単に組み立てられます。
[Bambu Lab A1 miniの組み立て説明書](https://cdn1.bambulab.com/documentation/quick-start-7bbefb9997312/%E3%82%AF%E3%82%A4%E3%83%83%E3%82%AF%E3%82%B9%E3%82%BF%E3%83%BC%E3%83%88%20-%20A1%20mini.pdf)

設置が20分くらい、セットアップも全自動でした。

### スライシングソフト

印刷用のユーティリティソフトのようなものです。
「スライス」で印刷データに変換、「造形開始」で印刷してくれます。

![slicing.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/90d1cd02-2f01-4aa2-a53a-d8fef14e054f.png)

## ランニングコストとインク代

普通のインクジェットプリンターは、多色セットが6000円くらいです。

![ink-canon.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/9001607d-f213-4533-b4df-7029929bcf89.png)

それなりの枚数が印刷できるので、A4一枚あたりにすると11.9円だそうです。
L版の写真用紙に印刷すると22.0円かかります。
[Canon 印刷代](https://faq.canon.jp/app/answers/detail/a_id/101735/~/%E3%80%90%E3%82%A4%E3%83%B3%E3%82%AF%E3%82%B8%E3%82%A7%E3%83%83%E3%83%88%E3%83%97%E3%83%AA%E3%83%B3%E3%82%BF%E3%83%BC%E3%80%91pixus-ts8430%E3%81%AE%E5%8D%B0%E5%88%B7%E3%83%BB%E3%82%B3%E3%83%94%E3%83%BC%E3%82%B9%E3%83%94%E3%83%BC%E3%83%89%E3%83%BB%E5%8D%B0%E5%88%B7%E3%82%B3%E3%82%B9%E3%83%88%282023%E5%B9%B42%E6%9C%88%E7%8F%BE%E5%9C%A8%29)

一方、3Dプリンタの印刷に使う消耗品は、公式ストアの1kgのフィラメントが2400円です。
1グラムあたり2.4円ですから、グラム数の2倍ちょっとの値段が材料費になります。

![ink-bambo.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/8de2127e-5582-4509-b057-5fc55f4f7eb9.png)

この写真のケースに使ったフィラメントは35グラム、84円です。

![case-in-hand.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/fc70de68-011a-4d83-95c3-e595be063eb3.jpeg)


インクジェットで印刷する写真の4枚分の料金です。
インクジェットプリンタと比べても、まずまずお安めです。

## 素材について

今回は3Dプリンタでよく使われるPLAに絞って触れます。

3Dプリンタで使うPLA（ポリ乳酸）は、トウモロコシなどの植物を原料にしたバイオマスプラスチックです。※画像は[RICHO:バイオマスプラスチック材料技術](https://jp.ricoh.com/technology/tech/018_biomass)より。

![018_1.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/26efcebe-3426-4ae8-9bb7-a7e980d9d5bc.jpeg)

かなりの硬さがありますが、衝撃や熱に弱く、燃えやすく、湿度で白っぽく変色します。
融点は170℃ほど、60℃程度まで加熱すると曲がり始めます。
[PLA(生分解性プラスチック)について](https://a-giken.co.jp/pla)

硬すぎて紙やすりでは歯が立たないものの、それ以外の加工はしやすい素材です。
プラスチック用の接着剤で接着することもできますし、両面テープで印刷物を貼り付けることもできます。マーカーで色を塗ることもできます。厚紙やマスキングテープを貼ったり、アクリル板や粘土を象嵌することもできます。

## 印刷中の騒音

Bambu Lab A1 miniの印刷中は、隣の部屋でかける掃除機くらいの音がします。

公式のページでは「超静音 3Dプリンター（48デシベル以下）」とありますが、たとえば布団乾燥機は通常モードが50～60デシベル、静音モードが30デシベルですから、どちらかといえば通常モードに近い音です。

また、環境省の[騒音に係る環境基準について](https://www.env.go.jp/kijun/oto1-1.html)では、夜間の音量が45デシベルを越えるものを騒音と定義しています。集合住宅に置けないような音ではないのですが、同居人が気にするだけの音ではあるので、それなりに気を使う必要はあります。

![noise.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/59817304-ee66-41cd-901e-c011737c1873.png)

## 印刷中の安全性

印刷中は熱を持ちます。
印刷開始時にゴミが弾き出されるのですが、少しだけ熱が残っているため、このゴミがレシートに当たると感熱で黒いシミになります。ゴミにレシートの紙が燃えるような温度はありませんし、ゴミにｈやけどするような温度もありません。

![dust.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/75cafd28-ac08-4d4b-a855-9b660224bf86.jpeg)

安全ですが、誤飲が怖い大きさでもあるので、ペットや幼児がいると気を使いそうです。

# 基本設定：three.jsの基本的な設定

一番簡単な導入手順は、Three.jsのテンプレートを実行することです。
https://github.com/SahilK-027/threejs-template

そうでない場合は以下の手順を踏みます。

## 導入方法

<details><summary>導入方法。長いため折りたたみます。クリックで開きます</summary>

Viteのテンプレートを作成します。

```bash
npm create vite@latest
```

作成したプロジェクトに、three.jsを導入します。

```bash
npm i three
```

HTMLファイルにcanvas要素を追加します。

```diff_html: index.html
<!DOCTYPE html>
<html lang="jp">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + TS</title>
  </head>
  <body>
+   <canvas class="webgl"></canvas>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>
```

CSSファイルに必要な設定を追加します。

```css: style.css
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
html {
  background: #121316;
}
.webgl {
  position: fixed;
  top: 0;
  left: 0;
  outline: none;
  z-index: -1;
}
```

Three.jsのテンプレートを確保します。

```typescript: three-js-base-module.ts
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { STLExporter } from "three/examples/jsm/exporters/STLExporter.js";

/**
 * Three.jsの基本的なモジュールを定義する
 * 実装のベース: https://github.com/SahilK-027/threejs-template
 */
export class ThreeJsBaseModule {
  public scene: THREE.Scene; // シーン
  private sizes: { width: number; height: number }; // 画面サイズ
  private camera: THREE.PerspectiveCamera | null = null; // カメラ
  private renderer: THREE.WebGLRenderer | null = null; // レンダラー
  private canvas: HTMLCanvasElement | null = null; // 出力先のHTML
  private controls: OrbitControls | null = null; // 画面のコントローラ
  private isBrowser: boolean = true; // ブラウザかどうかのフラグ

  constructor(props: {
    isBrowser: boolean; // ブラウザかどうかのフラグ
  }) {
    // シーンを確保、保持する
    this.scene = new THREE.Scene();
    // フラグを保持する
    this.isBrowser = props.isBrowser;
    // 画面サイズの初期値を定義する
    this.sizes = { width: 1, height: 1 };
  }

  /**
   * モジュールをHTMLに接続する
   */
  connectToHTMLCanvas(canvasName: string) {
    // 画面サイズを参照する
    this.sizes = {
      width: window.innerWidth,
      height: window.innerHeight,
    };
    // カメラを定義する
    this.camera = new THREE.PerspectiveCamera(
      75, // 視野角
      this.sizes.width / this.sizes.height,
      0.1, // 画面手前までの距離
      100 // 画面奥までの距離
    );
    // キャンバスを参照、レンダラーを作成する
    this.canvas = document.querySelector(canvasName);
    this.renderer = new THREE.WebGLRenderer({
      canvas: this.canvas!,
      alpha: true,
    });
    // 画面のコントローラを定義する
    this.controls = new OrbitControls(this.camera, this.canvas);

    // カメラの初期化をする
    this.initCamera(this.camera);
    // レンダラーを初期化する
    this.initRenderer(this.renderer);
    // 光源を初期化する
    this.initLights();
    // コントローラを初期化する
    this.initControls(this.controls);
    // イベントリスナーを追加する
    this.addEventListeners();
    // アニメーションを開始する
    this.animate(this.controls, this.camera, this.renderer);
  }

  /**
   * カメラを初期化する
   */
  initCamera(camera: THREE.PerspectiveCamera) {
    camera.position.z = 3;
    this.scene.add(camera);
  }

  /**
   * レンダラーを初期化する
   */
  initRenderer(renderer: THREE.WebGLRenderer) {
    renderer.setSize(this.sizes.width, this.sizes.height);
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  }

  /**
   * 光源を初期化する
   */
  initLights() {
    const ambientLight = new THREE.AmbientLight(0x404040);
    const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
    const hemisphereLight = new THREE.HemisphereLight(0x7444ff, 0xff00bb, 0.5);
    const pointLight = new THREE.PointLight(0x7444ff, 1, 100);
    pointLight.position.set(0, 3, 4);

    this.scene.add(ambientLight, directionalLight, hemisphereLight, pointLight);

    const size = 10;
    const divisions = 10;

    const gridHelper = new THREE.GridHelper(size, divisions);
    this.scene.add(gridHelper);
  }

  /**
   * コントローラを初期化する
   */
  initControls(controls: OrbitControls) {
    controls.enableDamping = true;
  }

  /**
   * リサイズイベントを登録する
   */
  addEventListeners() {
    window.addEventListener("resize", () => this.onResize());
  }

  /**
   * ウィンドウのリサイズ時の画面更新を定義する
   */
  onResize() {
    // 細心の画面サイズを反映する
    this.sizes.width = window.innerWidth;
    this.sizes.height = window.innerHeight;

    // カメラのアスペクト比を更新する
    if (this.camera !== null) {
      this.camera.aspect = this.sizes.width / this.sizes.height;
      this.camera.updateProjectionMatrix();
    }

    // レンダラーの設定を更新する
    if (this.renderer !== null) {
      this.renderer.setSize(this.sizes.width, this.sizes.height);
      this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    }
  }

  /**
   * アニメーションを実行する
   * ※コントローラの操作反映もアニメーションに含む
   */
  animate(
    controls: OrbitControls,
    camera: THREE.PerspectiveCamera,
    renderer: THREE.WebGLRenderer
  ) {
    // コントローラの操作を反映する
    controls.update();

    // レンダリングを実行する
    renderer.render(this.scene, camera);

    // 次のアニメーションをリクエストする
    window.requestAnimationFrame(() =>
      this.animate(controls, camera, renderer)
    );
  }

  /**
   * 現在のシーンを元に、STLファイルを出力する
   */
  export(props: { exportFileName: string }) {
    const stl = new STLExporter().parse(this.scene, {
      binary: false,
    });
    if (!this.isBrowser) {
      // Node.js環境での処理
      import("fs").then((fs) => {
        // ファイルを出力する
        fs.writeFileSync(props.exportFileName, stl, "utf8");
      });
    } else {
      // ファイルをブラウザ上でダウンロードする
      const blob = new Blob([stl], { type: "application/octet-stream" });
      var a = document.createElement("a");
      a.href = URL.createObjectURL(blob);
      a.target = "_blank";
      a.download = props.exportFileName;
      a.click();
    }
  }
}
```

テンプレートをmain.tsから呼び出します。

```typescript: main.ts
import "./style.css";
import { ThreeJsBaseModule } from "./three-js-base-module";

// テンプレートをセットアップする
const item = new ThreeJsBaseModule({
  isBrowser: true,
});
item.connectToHTMLCanvas("canvas.webgl");

/** 画面を作成する */
function createView() {
    // シーンに画面部品を追加します
    const scene = item.scene;
}

/** 3Dモデルをダウンロードする */
function download() {
  // ダウンロード処理を実行します
  item.export({
    exportFileName: "object.stl",
  });
}

createView()
```

createView関数にオブジェクトの作成処理を追加すると、シーンを作成できます。
※CLIから実行する場合は、ThreeJsBaseModuleのisBrowserをfalseにします。

ダウンロード処理を実行する場合は、ボタン要素のクリック操作にdownloadを紐づけます。

</details>

# 関数の使い方：three.jsで3Dプリントをする

three.jsの関数を使って、3Dプリントのための形を定義します。

## 四角い箱を定義する

たとえば、四角い箱を定義するには以下のように書きます。

```typescript: createBox.ts
import * as THREE from "three";

export function createBox(props: {
    centerX: number; // 箱の横方向の位置
    centerZ: number; // 箱の奥行き方向の位置
    widthX: number; // 箱の幅
    widthZ: number; // 箱の奥行き
    height: number; // 箱の高さ
    floorLevel: number; // 床面の高さ
}): THREE.BufferGeometry {
    const { centerX, centerZ, widthX, widthZ, height, floorLevel } = props;
    // 指定した大きさで箱を作る
    const box = new THREE.BoxGeometry(widthX, height, widthZ);
    // 指定の場所に箱を移動する
    box.translate(centerX, height / 2 + floorLevel, centerZ);
    // BufferGeometryの箱を返す
    return box;
}
```

BoxGeometryに箱の大きさを渡すと、指定した大きさの箱を作ることができます。

## マテリアルの設定

材質は以下のように定義します。

```typescript: material.ts
import * as THREE from "three";

export default class Material {
  static standard() {
    // StandardMaterialで、光の状態に応じて陰影をつける
    return new THREE.MeshStandardMaterial({
      color: "#7444ff", // 青色の素材にする
      flatShading: true, // 陰影を形がわかりやすいものにする
      side: THREE.DoubleSide, // 両面を表示する
    });
  }
}
```

flatShadingとsideのパラメータを入れることで、3Dプリンタの出力結果と近い見た目になります。

- **flatShadingをtrueする**  
Three.jsは、デフォルトで真ん中のような陰影のつけ方になります。ふち以外の輪郭は見分けがつかないため、フラットシェーディング（左のような陰影のつけ方）をするオプションを指定します。

![Image_2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/cb1d0a7e-c38f-4e5f-9d09-25c2192b9fae.gif)

- **DoubleSideを指定する**  
デフォルトでは物体の片面だけが見えるようになっています。  
3Dプリンタに表裏はないため、DoubleSideで両面出力をさせます。

## データの定義して出力する

定義した形と素材を使って、STLファイルを出力します。
なお、Three.jsの「1」の大きさの形をSTLに変換して、3Dプリンタで印刷すると、ちょうど1ミリメートルになります。

```typescript
import * as THREE from "three";
// 定義したものをインポートする
import { createBox } from "./createBox"
import Material from "./material"

// STLオブジェクトをダウンロードする
function downloadSTLObject() {
    // シーンを定義する
    const scene = new THREE.Scene();

    // 形をシーン上に配置する
    scene.add(new Mesh(
        // 箱を作って、印刷できる形にする
        createBox({
            centerX: 0,
            centerZ: 0,
            widthX: 10, // 10mmの大きさで印刷する
            widthZ: 10, // 10mmの大きさで印刷する
            height: 10, // 10mmの大きさで印刷する
            floorLevel: 0
        }),
        Material.standard()
    )));

    // シーンをSTLに変換する
    const stl = new STLExporter().parse(scene, {
        binary: false, // JSON形式でSTLファイルを出力する。trueだとファイルサイズが小さくなります。
    });

    // ブラウザの場合：作成したシーンをobject.stlのファイル名でダウンロードする
    const blob = new Blob([stl], { type: "application/octet-stream" });
    var a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.target = "_blank";
    a.download = "object.stl";
    a.click();
}
```

## SVGの読み込み

SVG画像の立体化は次のように行います。
SVG画像を用意する方法ですが、たとえば[Adobeの変換サービス](https://www.adobe.com/jp/express/feature/image/convert/svg)を使えば、PNG画像やJPG画像をSVGに変換することができます。[Google Fontsのアイコン](https://fonts.google.com/icons)を利用することもできます。

```typescript
import * as THREE from "three";
import { ExtrudeGeometry } from "three";
import {
  BufferGeometryUtils,
  SVGLoader,
} from "three/examples/jsm/Addons.js";

export function createSvgObject(props: {
    svgText: string; // SVGのデータ
    height: number; // 立体化する場合のSVGの厚み
}): THREE.BufferGeometry {
    // SVGデータを読み込んで、パスデータに変換する
    const svgData = new SVGLoader().parse(props.svgText);
    // SVGは複数のパスデータが集まってできているため、パスデータのそれぞれにアクセスする
    const geometries = svgData.paths.map((path) => {
        // パスデータをパスに変換する
        const shapes = SVGLoader.createShapes(path);
        // パスを立体化する
        const geometry = new ExtrudeGeometry(shapes, {
            depth: props.height,
            bevelSize: 0.0, // 0にすることで、正確な位置で立体化されます
            bevelThickness: 0.0, // 0にすることで、正確な位置で立体化されます
        });
        return geometry;
    });
    // 必要があればSVGを移動、回転、拡大縮小する
    geometries.forEach(geometry => {
        // SVGオブジェクトを座標移動、拡大縮小するなら、ここで実行する
        // 順番は拡大縮小、回転、移動の順で行う
        // geometry.scale(1, 1, 1); // 拡大縮小
        // geometry.rotateX(rx); // 回転
        // geometry.rotateY(ry); // 回転
        // geometry.rotateZ(rz); // 回転
        // geometry.translate(x, y, z); // 座標移動
    })
    // パスを一つのオブジェクトに結合、BufferGeometryを返す
    return BufferGeometryUtils.mergeGeometries(
        geometries,
        false // グループ化しない
    );
}
```

SVGを立体化しているExtrudeGeometryは、パスを押し出して立体を作る関数です。

mergeGeometriesを実行すると、複数の図形を1つの図形にまとめることができます。
まとめると関数の戻り値を他の図形と揃えることができるので、扱いやすくなります。

## テキストをSVGに変換する

text-to-svgを使うと、テキストをSVGに変換して、3Dプリンタで出力できるようになります。

```bash
npm i text-to-svg
```

どのフォントでも利用できますので、GoogleFontから使いたいTTFフォントをダウンロードして、publicディレクトリに置いておきます。

### text-to-svgを手直しする

なお、TextToSVGをViteで動かすには、インストールしたあとで少し手順を踏む必要があります。

以下のjsファイルを、TextToSVG.jsのファイル名でプロジェクト内にコピーします。
https://github.com/shrhdk/text-to-svg/blob/master/src/index.js

DEFAULT_FONTの定数はViteでは動かないため、削除します。

```diff_typescript: index.js
import * as opentype from 'opentype.js';

// 7行目を修正する
- const DEFAULT_FONT = require('path').join(__dirname, '../fonts/ipag.ttf');
+ // const DEFAULT_FONT = require('path').join(__dirname, '../fonts/ipag.ttf');

// 26行目を修正する
-  static loadSync(file = DEFAULT_FONT) {
+  static loadSync(file) {
    return new TextToSVG(opentype.loadSync(file));
  }
```

インポートするときは、型定義をtext-to-svgから、実体はローカルに置いたTextToSVGから取るようにします。

```typescript
import * as THREE from "three";
import _TextToSVG from "./TextToSVG.js";
import { type default as TextToSVG, type Anchor } from "text-to-svg";
import { createSvgObject } from "./create-svg-object";

export async function createTextObject(props: {
    text: string; // 立体化するテキスト
    fontUrl: string; // フォントのパス
    height: number;
}): Promise<THREE.BufferGeometry> {
    // テキスト文字列をSVGに変換する
    const converter = await new Promise<TextToSVG>((resolve: any) => {
      _TextToSVG.load(
        props.fontUrl,
        (_err: any, converter: TextToSVG) => {
          resolve(converter);
        }
      );
    });
    // 出力プロパティを定義する
    const writingProperty: TextToSVG.GenerationOptions = {
      x: 0, // 出力座標は固定でよい
      y: 0, // 出力座標は固定でよい
      fontSize: 12, // フォントサイズは固定でよい
      anchor: "right|bottom" as Anchor, // 文字の表示位置
      attributes: { fill: "black" }, // 文字色は反映されないため固定でよい
    };
    // 先ほど作ったcreateSvgObject関数にデータを渡して、SVGを立体化する
    // BufferGeometryを返す
    return createSvgObject({
        svgText: converter.getSVG(props.text, writingProperty),
        height: props.height,
    });
}
```

文字の大きさが1cmもあれば、たいていのゴシック体の文字や漢字が出力できます。
あまりにも細い線や小さな文字は3Dプリンターが出力できませんので、文字の大きさに気を付けてください。

## オブジェクトから別のオブジェクトをくり抜く

three-bvh-csgを使うと、オブジェクト同士を合成することができます。

```bash
npm i three-bvh-csg
```

合成をすると、図形を削って穴を開けることができます。
たとえば立方体を球体で削ると次のようになります。

ソースコードでは以下のように書きます。

```typescript
import * as THREE from "three";
import * as BufferGeometryUtils from "three/examples/jsm/utils/BufferGeometryUtils.js";
import { SUBTRACTION, ADDITION, Brush, Evaluator } from "three-bvh-csg";

export function mergeObjects(props: {
    base: THREE.BufferGeometry; // ベースになる形
    attach: THREE.BufferGeometry[]; // 掘削する形
}): THREE.BufferGeometry {
    // マージするためのライブラリ
    const evaluator = new Evaluator();

    // 処理対象のオブジェクトを結合する
    let sculptBrash = new Brush(props.attach[0]);
    sculptBrash.updateMatrixWorld(); // 位置をプロパティから読み込んで反映
    // 2番目以降のオブジェクトを、1番目のオブジェクトに結合する
    for (let i = 1; i < props.attach.length; i++) {
        // 2番目以降のオブジェクトを、先頭のオブジェクトに結合していく
        const brush = new Brush(props.attach[i]);
        brush.updateMatrixWorld(); // 位置をプロパティから読み込んで反映
        // オブジェクトを合成して、処理対象のオブジェクトを更新する
        sculptBrash = evaluator.evaluate(sculptBrash, brush, ADDITION);
    }

    // ベースになるオブジェクト
    let currentShape = new Brush(props.base);
    currentShape.updateMatrixWorld(); // 位置をプロパティから読み込んで反映

    // 結合したオブジェクトを使って、削る処理を行う
    // BufferGeometryを返す
    return evaluator.evaluate(currentShape, sculptBrash, SUBTRACTION).geometry;
}
```

この関数で「2」の目のサイコロを作るには、次のように書きます。

```typescript
mergeObject({
    base: createBox({ /* 立方体を描画する */ }),
    attach: [
        createSpere({ /* 立方体から右上の球体をくり抜く */ }),
        createSpere({ /* 立方体から左下の球体をくり抜く */ }),
    ]
})
```

基本的に、くりぬけるのは基本図形の組み合わせだけです。
SVGを立体化した図形でくり抜くことはできません。

# 実際にプリントしてみる

3Dモデルを作成して印刷します。
three.jsの使い方は前の章で説明した通りです。図形を組み合わせて目的のものを作ります。

## プロジェクトを準備する

図形を組み合わせる部分は座標が並ぶだけで目新しい情報はないため、座標を並べる部分は省略して、以下のソースコードで説明を続けます。

https://github.com/ShotaOki/threejs-3d-print/tree/main

ソースコードを取得したら、`npm i`で必要なライブラリをインストールします。
ブラウザから実行するときは、以下のコマンドを実行します。

```bash
npm run dev
```

ブラウザで実行せず、JEXLファイルやYAMLファイルを渡して直接STLに変換させることもできます。

```bash
npm run cli -- export --data public\shape-card-case\case.js --context public\shape-card-case\case-context.js --export ..\out.stl
```

また、Reactに埋め込むこともできます。手順はREADMEを参照してください。

## STLファイルを出力する

ブラウザで実行して画面を立ち上げると、モデルのプレビューが表示されます。
画面上をドラッグすると回転、右ドラッグするとオブジェクトが移動します。

左上の`Export`ボタンを押して、STLファイルをダウンロードします。

![window-preview.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/b5176d74-a932-4bc5-98e0-4fcbf9fdad4b.png)

## スライサーに取り込む

STLファイルを開いて、Bambu Studioにインポートします。

インポートしたら、「OBJ」タブを選んで、インポートしたファイルを選択します。

右上のツールでスケールを設定します。
※デフォルトではThree.jsの1＝印刷サイズの1ミリです。今回のプロジェクトは1/10の大きさで作っているため、1000%に拡大することで目的の大きさにします。

![set-scale.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/bad0b571-e4a0-4484-8b09-dabf1a5c26b4.png)

底面選択をして、立体の向きを選びます。
宙に浮いた部分（建物でいうベランダや梁の部分）は印刷中の自重で歪むため、できるだけ印刷中に下から支えてもらえる方向を選びます。

![floor-select.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/f693ee17-3bbe-444b-814e-3ff810fa9115.png)

右上の「スライス」→「造形開始」を順に実行すると、印刷が始まります。

![push-slice.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/8c4ae350-8abd-4134-b31d-79f0d738efc9.png)

印刷が終わった後も、冷めるまでは触らないようにします。
※印刷直後は柔らかいので、冷める前に剥がそうとすると印刷物が歪みます。

# まとめ

three.jsを使って3Dプリントをすることができます。

three.jsであれば、Web画面上で動かすことも、コマンドプロンプト上で動かすことも、Reactに埋め込んで動かすこともできます。今後3Dプリンターが普及すれば、様々なサービスに組み込むようなこともできるだろうと思います。

現在の3Dプリンタも、インクジェットプリンターとあまり変わらないシンプルな操作で出力できますし、消耗品にかかるコストにも大きな差はありません。

# おまけ

Bambu Lab A1 miniの印刷のテストとして、以下のようなものも作りました。
単色の3Dプリンターですが、着色したり、象嵌したり、スタンプとして使うことで、多色のように使うことができます。

**疑似的な多色印刷をしたキーホルダー**  
印刷物に色粘土を埋めこんで、乾いた粘土を#240のやすりで削り落としています。
着色、象嵌前は右の見かけです。<font color="#ccc">イマソ狩りさんところのメタモンが作りたかった。</font>
（データ作成時間：1時間。印刷時間：30分。フィラメント：20グラム）

![image-metamon.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/39171f51-0fbe-4e8b-8c5b-a1af486889de.jpeg)

**スタンプ**
日本語テキストを左右反転させたハンコです。
家庭用インクジェットプリンターには白インクがないのですが、3Dプリンタでハンコを作れば白インクで印刷できます。印刷物の表面に空く穴さえどうにかなれば使えそうです。
（データ作成時間：5分。印刷時間：15分。フィラメント：5グラム）

![image-stamp.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/aa8803d8-4499-4801-9d1b-f9f0864a43af.jpeg)