新世代レンタルサーバーをいち早く体験!
Oneレンタルサーバーβ版が【1年間無料】詳細へ

【Three.js入門】Webブラウザで動く簡易3D部屋レイアウトツールを作ろう!

2025年12月17日User Note

【Three.js入門】W簡易3D部屋レイアウトツール作成

はじめに

家具の配置を考えたい、新しいオフィスのレイアウトを計画したい、あるいはゲームのステージを作ってみたい。そんなとき、頭の中のイメージを実際に「見える化」できたら便利だと思いませんか?
そういったアプリケーションは沢山リリースされています。

これまで3Dコンテンツを作成するには、専門的なソフトウェアと学習コストが必要でした。しかし今、Webブラウザという誰もが持っているツールだけで、インタラクティブな3D世界を作り出せます。

この記事では、Three.jsのモジュール版(three.module.js)を使ったWebブラウザ上で動作する簡易3Dレイアウトツールの開発を行います。

3Dのアプリケーション作成が初めての方でも理解できるように、一歩ずつ丁寧に進めていきます。

この記事は下記のユーザーに最適なものです。

  • JavaScriptの基本的な知識があり、次に何を学ぶか探している方。
  • Webブラウザで動くリッチなコンテンツの開発に興味がある方。
  • Three.jsを使った3Dプログラミングの基礎をゼロから学びたい方。
本記事で作成する3Dレイアウトツールの機能
  • 3D空間(部屋やオフィスフロア)の構築。
  • 机、椅子、キャビネットなどの家具(3Dオブジェクト)の配置。
  • Blenderなどで作成した3Dモデルファイルを読み込んで配置。
  • 配置した家具をドラッグ&ドロップで移動・回転させることができます。
  • 作成したレイアウトを様々な角度から自由に見回すことができます。

Three.jsとモジュール形式:いまどきの3D開発

Three.jsはWebGLを簡単に扱えるようにしたJavaScriptライブラリですが、今回はESモジュール形式を使用します。これはJavaScript開発で標準的に使われている方法で必要な機能だけを効率的に読み込めます。プロジェクトの整理がしやすく、パフォーマンスも優れているのが特長です。

使用する主要技術

本ツールは、現代のWeb開発で広く利用されている技術をベースに構築します。

技術役割内容
HTML/CSSWEB構造とスタイル3Dキャンバスを配置し、UI要素(ボタンなど)を装飾する基礎となります。
JavaScriptプログラミング言語3Dライブラリを制御し、ユーザー操作(マウスイベント)を処理する核となります。
Three.js
(three.module.js)
3DグラフィックライブラリWebGLの複雑な処理を抽象化し、JavaScriptから簡単に3Dシーンを作成・操作できるようにするための主役です。
Web開発で利用する技術一覧

開発環境の準備

このプロジェクトに挑戦するために必要なもの
  • パソコン - Windows、Mac、Linuxどれでも可。
  • Webブラウザ - Chrome、Firefox、Safariなど。
  • テキストエディタ - モジュール開発に対応したエディタとして、Visual Studio Code(VS Code)を使用します。
  • ローカルサーバー環境 - VS Codeで利用できる「Live Server」拡張機能を使用します。

※注意点:three.module.jsを使用するには、ローカルサーバー環境が必要です。これはセキュリティ上の制約によるもので、ファイルを直接開く(file://対象ファイル)ことでは動作しません。でも心配ありません!VS Codeの「Live Server」拡張機能を利用すれば簡単に開発できます。

Webサーバーがあれば、ホームディレクトリに直接作成したファイルを配置すれば、ブラウザでURLにアクセスしても動作します。
Webサーバーで試すのであればGMO DigiRockが提供している無料レンタルサーバーXREAがありますので利用してみてください。

XREAを利用するメリット

完全無料プランあり:初期費用・月額費用0円
開発と公開を同時に:ローカル開発の手間を省き、実環境でテスト可能
SSL対応で安心:https://での安全なアクセス
完成後すぐ公開:友人や同僚とURL共有だけで作品を見せられる
ポートフォリオに最適:就職活動での実績アピールにも活用可能

作成したファイルをそのままアップロードするだけで、あなたの3Dツールが世界中からアクセス可能になります。

無料レンタルサーバー

XREA公式サイトをみる

今回、開発を進めていくにあたりマイクロソフトのVS Codeを利用します。既に使っている方はVS Codeのインストールを飛ばして次に進んでください。

VS Codeのインストール

・Visual Studio Codeのインストーラーをダウンロードする
https://code.visualstudio.com/downloadからパソコンのOSに合ったインストールファイルを選択してください。
本記事ではWindows環境で構築しています。この場合はダウンロード画面中からWindowsのロゴマークをクリックしてダウンロードし、デフォルトインストールをしてください。
VS Codeダウンロード

・インストール後、VS Codeを起動
拡張機能アイコンをクリックして以下の3つを検索してそれぞれインストールしてください。

  • Live Server (インストール必須、ローカルサーバー環境です。)
  • Live Preview(インストール推奨、VS Code内の画面でブラウジング結果がリアルタイムで表示できます。)
  • Japanese Language Pack for VS Code(インストール推奨、日本語パッケージです。)

VS Code拡張ライブラリ選択

開発に必要なファイル構成

本ツールは、現代のWeb開発で広く利用されている技術をベースに構築します。

ファイル名役割内容
index.htmlWeb構造新規作成
importmapを利用して、three.module.jsをロードしておきます。ボタンの配置
style.cssスタイル新規作成
Webページ全体、特に3Dキャンバスの表示設定を行います。

main.js
メインスクリプト新規作成
Three.jsのコードを記述し、3Dシーンの構築と描画を実行します。
three.module.jsライブラリThree.js の ES Module版(import/export方式)
ダウンロード先
https://unpkg.com/[email protected]/build/three.module.js
OrbitControls.jsライブラリThree.jsでカメラをマウスやタッチで操作できるようにするための拡張ライブラリ。
Three.jsの本体には含まれておらず、examples/jsm/controls/ にある追加モジュールです。
ダウンロード先
https://unpkg.com/[email protected]/examples/jsm/controls/OrbitControls.js
GLTFLoader.jsライブラリThree.jsでBlenderやZBrushなどで作成した「スカルプト(彫刻)」によって複雑に変形した3Dモデルを利用する。
Three.jsの本体には含まれておらず、examples/jsm/loaders/ にある追加モジュールです。
ダウンロード先
https://unpkg.com/[email protected]/examples/jsm/loaders/GLTFLoader.js
BufferGeometryUtils.jsライブラリBufferGeometry の操作を便利にするユーティリティ集。GLTFLoader.jsで使用されます。
Three.js の本体には含まれておらず、examples/jsm/utils/ にある追加モジュールです。
ダウンロード先
https://unpkg.com/[email protected]/examples/jsm/utils/BufferGeometryUtils.js
開発に必要なファイル構成

プロジェクトフォルダを作成して必要ライブラリを設置する

Windowsの例として、以下のようにフォルダを作成してください。

c:\projects\3d
c:\projects\3d\jsm\controls
c:\projects\3d\jsm\loaders
c:\projects\3d\jsm\utils

フォルダは適宜自身の環境で都合の良いディレクトリでかまいません。.\jsmフォルダは例のようにしてください。threeのライブラリが.\jsm配下を参照します。

開発に必要なファイル構成で4つのファイルthree.module.js、OrbitControls.js、GLTFLoader.js、BufferGeometryUtils.jsをダウンロードします。
ダウンロード先は表内のURLリンクを参照してください。アクセスするとjsファイルの内容がブラウザに表示されますので、ブラウザ上で右クリックし、名前を付けて所定のフォルダに保存してください。
フォルダ・ファイル構成
VS Codeを起動させ、「ファイル」-「フォルダを開く」を実行して作成したフォルダc:\projects\3dを選択して開いてください。
VS Codeフォルダーオープン画面

所定のディレクトリに正しくファイルが配置されていることを確認してください。
ここからVS Code上で、index.html、style.css、main.jsを作成し、このmain.jsに機能を追加する形で進めていきます。(ファイルの新規作成、更新、保存方法は他のエディターツールを同じように左上の「ファイル」から操作できます。)

index.html、style.css、main.jsファイルの作成

機能の追加は以下の順に従って拡張していきます。
各段階の動作はVS Codeの拡張機能Live Serverで確認できます。確認方法は後ほど記載します。

主な機能一覧
  1. 3Dシーンの作成
  2. 部屋(床と壁)の作成
  3. 簡単な直方体オブジェクトの設置(机、椅子および棚)
  4. 複数オブジェクトをグループ化したオブジェクトの設置(椅子)
  5. 3Dモデルの設置(龍の置物)
  6. ドラッグ&ドロップによるオブジェクトの移動
  7. 回転ガイドリング操作でオブジェクトの回転
  8. スポットライトの設置(影の生成)と移動、設定位置の保存と元に戻す機能の追加

※必要なソースコードは本記事中でステップごとに記載していますので、ソースコードをコピーしながら追加機能を実装し、動作確認ができるようにしています。
また、https://github.com/picolix/Three.js-Room-Layout-Editor/archive/refs/heads/main.zipにもステップ毎のソースコード、完成版のソースコードをアップしていますので参考にしてください。

 HTMLファイルの作成 (index.html)

Three.jsライブラリの読み込みと、JavaScriptファイルの読み込みを行う基本的なHTML構造を記述します。

index.html
VS Codeで「ファイル」-「新しいファイル」を実行してファイル名をindex.htmlとしてオープンしてください。
以下のコードをコピーして保存してください。このindex.htmlは今後の機能追加でも一切変更はありません。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>3Dレイアウトシミュレーター</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div id="controls">
        <button id="saveButton">保存</button>
        <button id="loadButton">戻す</button>
    </div>
    <div id="customDialog" class="hidden">
        <p id="dialogMessage"></p>
    </div>
    <script type="importmap">{
        "imports": {
          "three": "./three.module.js"
        }
      }
    </script>
    <script src="main.js" type="module"></script>
</body>
</html>

VS Code 新規ファイル作成

CSSファイルの作成(style.css)

3D描画エリア(canvas要素)がウィンドウ全体に広がるように、基本的なリセットとスタイルを設定します。これにより、全画面で3Dコンテンツを表示できます。
このstyle.cssも今後の機能追加でも一切変更はありません。

style.css

/* style.css */
body {
    margin: 0;
    overflow: hidden; /* スクロールバーを非表示 */
}
/* Three.jsが生成するキャンバス要素に対するスタイル設定 */
canvas {
    display: block; /* インライン要素による隙間を防ぐ */
}
/* 保存・戻すボタンのスタイル */
#controls {
    position: absolute;
    top: 10px;
    right: 10px;
    z-index: 101; /* 3Dキャンバスの上に表示 */
    display: flex;
    gap: 10px;
}
#controls button {
    padding: 10px 15px;
    font-size: 16px;
    cursor: pointer;
    background-color: #4CAF50;
    color: white;
    border: none;
    border-radius: 5px;
    box-shadow: 0 2px 5px rgba(0,0,0,0.2);
    transition: background-color 0.2s;
}
#controls button:hover {
    background-color: #45a049;
}

/* カスタムダイアログのスタイル */
#customDialog {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    background-color: rgba(0, 0, 0, 0.8); /* 通常(成功時)の背景色 */
    color: white;
    padding: 15px 30px;
    border-radius: 8px;
    box-shadow: 0 4px 10px rgba(0, 0, 0, 0.4);
    z-index: 1000;
    opacity: 1;
    transition: opacity 0.5s, visibility 0.5s;
    text-align: center;
}
#customDialog.hidden {
    opacity: 0;
    visibility: hidden; /* 非表示時にクリックできないようにする */
}
#customDialog p {
    margin: 0;
    font-weight: bold;
}
/* エラー時のスタイル */
#customDialog.error {
    background-color: #d32f2f; /* エラー(赤系)の背景色 */
}

VS Code 新規style.cssファイル作成

メインプログラムファイルの作成 (main.js)

main.jsファイルに、Three.jsを使った基本的な3Dシーン(世界)を構築するための初期設定コードを記述します。
3Dシーンを構築するために、まず以下の3つの核となるオブジェクトを定義します。

  1. Scene(シーン):3Dオブジェクト、ライト、カメラなど、すべてを配置する仮想世界の入れ物。
  2. Camera(カメラ):シーンの中の視点。ユーザーがどこから世界を見ているかを決定します。
  3. Renderer(レンダラー):シーンとカメラが設定された画像を生成し、HTMLの <canvas> 要素に描画する役割を担います。

基本となるプログラムmain.jsは以下です。機能追加はこのmain.jsを基に改造していきます。

main.js

// main.js
// [main-000-base]
// index.html の importmap の設定に基づき、ローカルファイルをインポート
import * as THREE from "three";
import { OrbitControls } from './jsm/controls/OrbitControls.js';

// 必要な変数をグローバルスコープで定義
let scene, camera, renderer, controls;

// 1. 初期化関数:3D世界の土台を作る
function init() {
    // === 1-1. シーン (Scene) の作成 ===
    scene = new THREE.Scene();
    scene.background = new THREE.Color(0xf0f0f0); // 背景色を薄いグレーに設定
    
    // 座標軸ヘルパーを追加(X軸:赤、Y軸:緑、Z軸:青)
    const axesHelper = new THREE.AxesHelper(10); // サイズ10
    scene.add(axesHelper);

    // === 1-2. カメラ (Camera) の設定 ===
    const fov = 75; // 視野角 (Field of View)
    const aspect = window.innerWidth / window.innerHeight; // アスペクト比
    const near = 0.1; // 描画開始距離
    const far = 1000; // 描画終了距離
    camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
    
    // カメラの初期位置を決定 (X, Y, Z)
    camera.position.set(10, 15, 15);
    camera.lookAt(0, 0, 0); // 原点(0, 0, 0)を見るように設定

    // === 1-3. レンダラー (Renderer) の作成 ===
    renderer = new THREE.WebGLRenderer({ antialias: true }); // アンチエイリアスで描画を滑らかに
    renderer.setSize(window.innerWidth, window.innerHeight); // 描画サイズをウィンドウサイズに合わせる
    
    // 生成された<canvas>要素をHTML<body>に追加
    document.body.appendChild(renderer.domElement);

    // === 1-4. カメラ操作 (OrbitControls) の設定 ===
    // インポートした OrbitControls を使って、マウスでカメラを動かせるようにする
    controls = new OrbitControls(camera, renderer.domElement);
    controls.enableDamping = true; // 動きを滑らかにする
    controls.dampingFactor = 0.05;

    // ウィンドウサイズが変更されたときのリサイズ処理を登録
    window.addEventListener('resize', onWindowResize, false);
}

// 2. 描画ループ関数:アニメーションの心臓部
function animate() {
    requestAnimationFrame(animate); // ブラウザの再描画タイミングに合わせてループを継続
    controls.update(); // カメラ操作のダンピング効果を適用するために更新が必要
    renderer.render(scene, camera); // シーンをカメラの視点から描画
}

// 3. ウィンドウリサイズ処理:画面サイズが変わっても表示を最適化
function onWindowResize() {
    camera.aspect = window.innerWidth / window.innerHeight;    // カメラのアスペクト比更新
    camera.updateProjectionMatrix(); // カメラの投影行列を更新
    renderer.setSize(window.innerWidth, window.innerHeight); // レンダラーのサイズ更新
}

// 実行
init();
animate();

VS Code 新規main.jsファイル作成

実行結果の確認

index.htmlファイルを右クリックし「プレビューの表示」をクリックしてください。
VS Codeプレビュー表示の実行

ビュワー領域上で背景が薄いグレーになり、原点を示す3色の座標軸が表示されていれば成功です。

  • 赤線:X軸 (左右)
  • 緑線:Y軸 (上下)
  • 青線:Z軸 (奥行き)

VS Code実行結果の確認
※画面下ステータス欄の「Go LIVE」をクリックするとブラウザが立ち上がって同じ画面が表示されます。開発しやすい環境で利用してください。
筆者は両方使用しています。ブラウザ利用ではなんらかのエラーが発生し画面が真っ白になった時にブラウザの開発ツールを起動させてエラー箇所と内容を確認しています。

マウスの左クリックで回転、右クリックで移動、ホイールでズームができるようになっているはずです。

ワンポイントヒントコンピューターグラフィックス(CG)や3Dモデリングにおける座標系の慣習

数学で一般的に使われるデカルト座標系ではX,Y軸が平面、Z軸が高さで表現されます。直感的にもこれが自然ですが、3Dツールでは慣習的な座標系であるY-up方式が採用されます。
多くの主要な3Dツール(例:Blender, Maya, Unity, Unreal Engine)では、この慣習が採用されています。これをY-upまたはY-軸が垂直(アッパー)方式と呼びます。

3D空間でXYZ軸を表示することは、3D開発における『はじめてのプログラミング』のようなものです。これができるようになれば、あなたも立派な3D開発の仲間入りです。

3D世界にオブジェクトの設置

これで3D世界の土台に部屋の構造やオブジェクトを作っていきます。元のmain.jsに機能を追加していきます。

部屋(床と壁)の作成

main.jsファイルに、Three.jsを使った基本的な3Dシーン(世界)を構築するための初期設定コードを記述します。
3Dシーンを構築するために、まず以下の3つの核となるライト、床、壁のオブジェクトを定義し部屋を構築します。

main.js追加内容
  • レイアウトツールの基本となる空間を定義します。
  • // === 1-5. ライト (Light) の追加 ===
    3Dオブジェクトは光が当たらないと真っ黒(同一色)に見えるため、まず部屋全体を均等に照らす環境光を追加します。
  • // === 1-6 & 1-7. 部屋の構造(床と壁)とグリッド定規の作成 ===
    createRoomStructure()の呼び出し
  • createRoomStructure()関数の追加
    • 10m x 10m x 2mの部屋を作成します。
    • 視覚的定規(グリッド)の表示
      床にはサイズが分かりやすいように、THREE.GridHelperを使用して1m間隔の視覚的定規(グリッド)を表示しています。
    • 壁の表示
      THREE.BoxGeometryで単純な直方体で表示します。createWall()関数で4つの壁を作成します。
    • 壁の透明化
      オブジェクトを配置した場所によって隠れて見えなくなるのを防ぐために壁を半透明化させておきます。THREE.MeshStandardMaterialのパラメーターtransparent: trueにより透明化できます。

以下変更したmain.jsを記載します。行番号欄が背景赤色の箇所が今回追加したコードです。(ソースコードが長いのでスクロールすると参照できます)

// main.js
// [main-001-room]
// index.html の importmap の設定に基づき、ローカルファイルをインポート
import * as THREE from "three";
import { OrbitControls } from './jsm/controls/OrbitControls.js';

// 必要な変数をグローバルスコープで定義
let scene, camera, renderer, controls;
let roomGroup; // 床、壁、家具をまとめるグループ
const roomSize = 10; // 部屋のサイズ (10m x 10m)
const furniture = []; // 複数の家具オブジェクトを順序付けして保管
// 1. 初期化関数:3D世界の土台を作る
function init() {
    // === 1-1. シーン (Scene) の作成 ===
    scene = new THREE.Scene();
    scene.background = new THREE.Color(0xf0f0f0); // 背景色を薄いグレーに設定
    roomGroup = new THREE.Group();
    scene.add(roomGroup);

    // 座標軸ヘルパーを追加(X軸:赤、Y軸:緑、Z軸:青)
    const axesHelper = new THREE.AxesHelper(10); // サイズ10
    scene.add(axesHelper);

    // === 1-2. カメラ (Camera) の設定 ===
    const fov = 75; // 視野角 (Field of View)
    const aspect = window.innerWidth / window.innerHeight; // アスペクト比
    const near = 0.1; // 描画開始距離
    const far = 1000; // 描画終了距離
    camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
    
    // カメラの初期位置を決定 (X, Y, Z)
    camera.position.set(10, 15, 15);
    camera.lookAt(0, 0, 0); // 原点(0, 0, 0)を見るように設定

    // === 1-3. レンダラー (Renderer) の作成 ===
    renderer = new THREE.WebGLRenderer({ antialias: true }); // アンチエイリアスで描画を滑らかに
    renderer.setSize(window.innerWidth, window.innerHeight); // 描画サイズをウィンドウサイズに合わせる
    renderer.outputEncoding = THREE.sRGBEncoding; // レンダリング品質改善
    renderer.shadowMap.enabled = true; // 影の描画を有効化

    // 生成された<canvas>要素をHTML<body>に追加
    document.body.appendChild(renderer.domElement);

    // === 1-4. カメラ操作 (OrbitControls) の設定 ===
    // インポートした OrbitControls を使って、マウスでカメラを動かせるようにする
    controls = new OrbitControls(camera, renderer.domElement);
    controls.enableDamping = true; // 動きを滑らかにする
    controls.dampingFactor = 0.05;

    // === 1-5. ライト (Lighting) の追加 ===
    // 部屋全体を均等に照らす環境光
    const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); // 弱めの光
    scene.add(ambientLight);

    // 特定の方向から照らす指向性光(影を落とすために必要)
    const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0);
    directionalLight.position.set(20, 30, 10);
    scene.add(directionalLight);

    // === 1-6 & 1-7. 部屋の構造(床と壁)とグリッド定規の作成 ===
    createRoomStructure();
    // ウィンドウサイズが変更されたときのリサイズ処理を登録
    window.addEventListener('resize', onWindowResize, false);
}

// 2. 描画ループ関数:アニメーションの心臓部
function animate() {
    requestAnimationFrame(animate); // ブラウザの再描画タイミングに合わせてループを継続
    controls.update(); // カメラ操作のダンピング効果を適用するために更新が必要
    renderer.render(scene, camera); // シーンをカメラの視点から描画
}

// 3. ウィンドウリサイズ処理:画面サイズが変わっても表示を最適化
function onWindowResize() {
    camera.aspect = window.innerWidth / window.innerHeight;    // カメラのアスペクト比更新
    camera.updateProjectionMatrix(); // カメラの投影行列を更新
    renderer.setSize(window.innerWidth, window.innerHeight); // レンダラーのサイズ更新
}

// 4. 部屋全体構造の作成
function createRoomStructure() {
    const wallHeight = 2; 

    // 床の作成
    const floorGeometry = new THREE.PlaneGeometry(roomSize, roomSize); 
    const floorMaterial = new THREE.MeshStandardMaterial({ 
        color: 0xf8feff,
        side: THREE.DoubleSide
    });
    const floor = new THREE.Mesh(floorGeometry, floorMaterial);
    floor.rotation.x = Math.PI / 2;
    floor.position.y = 0; 
    floor.receiveShadow = true; 
    roomGroup.add(floor); 
    
    // 視覚的定規(GridHelper)の追加 (1m間隔)
    const gridHelper = new THREE.GridHelper(
        roomSize, roomSize, 0x444444, 0x888888        
    );
    gridHelper.position.y = 0.01; 
    roomGroup.add(gridHelper); 

    // 壁の半透明マテリアルを作成 
    const wallMaterial = new THREE.MeshStandardMaterial({ 
        color: 0xf0e68c, 
        transparent: true, 
        opacity: 0.3,      
        side: THREE.DoubleSide 
    });
    
    // 壁の作成ヘルパー
    function createWall(x, z, length, height, rotationY) {
        const wallGeometry = new THREE.BoxGeometry(length, height, 0.1); 
        const wall = new THREE.Mesh(wallGeometry, wallMaterial); 
        
        wall.position.y = height / 2;
        wall.position.x = x;
        wall.position.z = z;
        wall.rotation.y = rotationY;
        wall.receiveShadow = true;
        roomGroup.add(wall); 
    }
    
    // 4つの壁を作成
    createWall(0, -roomSize / 2, roomSize, wallHeight, 0); 
    createWall(0, roomSize / 2, roomSize, wallHeight, 0); 
    createWall(-roomSize / 2, 0, roomSize, wallHeight, Math.PI / 2); 
    createWall(roomSize / 2, 0, roomSize, wallHeight, Math.PI / 2); 
}

// 実行
init();
animate();

これで明るい床と4つの壁で構成されたシンプルな箱型の部屋が表示されます。レイアウトを行うための空間が完成です。部屋をマウスの左クリックで回転、右クリックで移動、ホイールでズームができるようになっているはずです。
VS Code実行結果ー部屋表示の確認
※保存と戻すボタンが表示されていますが、表示のみでこの段階では機能していません。この機能は最後に追加します。
今後、createRoomStructure()で作成したTHREE.js グループオブジェクトroomGroupに対してオブジェクトの追加、操作をしていくことになります。

家具(オブジェクト)の作成

部屋の構造ができたので、次にレイアウトの中心となる家具のプレースホルダーを作成します。ここでは、基本的な形状であるTHREE.BoxGeometryを使って、直方体で表現したデスクと椅子、棚を配置します。

main.js 追加内容
  • //=== 1-8. 家具(プレースホルダー)の作成と配置 ===
    サイズ設定とaddFurniture()関数の呼び出し
  • addFurniture()関数の追加
    直方体の形状をTHREE.BoxGeometry()、THREE.MeshStandardMaterial()でTHREE.Meshにします。作成したMeshをroomGroup.add()で部屋に設置します。
// main.js
// [main-002-object]
// index.html の importmap の設定に基づき、ローカルファイルをインポート
import * as THREE from "three";
import { OrbitControls } from './jsm/controls/OrbitControls.js';

// 必要な変数をグローバルスコープで定義
let scene, camera, renderer, controls;
let roomGroup; // 床、壁、家具をまとめるグループ

const roomSize = 10; // 部屋のサイズ (10m x 10m)
const furniture = []; // 複数の家具オブジェクトを順序付けして保管

// 1. 初期化関数:3D世界の土台を作る
function init() {
    // === 1-1. シーン (Scene) の作成 ===
    scene = new THREE.Scene();
    scene.background = new THREE.Color(0xf0f0f0); // 背景色を薄いグレーに設定
    roomGroup = new THREE.Group();
    scene.add(roomGroup);

    // 座標軸ヘルパーを追加(X軸:赤、Y軸:緑、Z軸:青)
    const axesHelper = new THREE.AxesHelper(10); // サイズ10
    scene.add(axesHelper);

    // === 1-2. カメラ (Camera) の設定 ===
    const fov = 75; // 視野角 (Field of View)
    const aspect = window.innerWidth / window.innerHeight; // アスペクト比
    const near = 0.1; // 描画開始距離
    const far = 1000; // 描画終了距離
    camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
    
    // カメラの初期位置を決定 (X, Y, Z)
    camera.position.set(10, 15, 15);
    camera.lookAt(0, 0, 0); // 原点(0, 0, 0)を見るように設定

    // === 1-3. レンダラー (Renderer) の作成 ===
    renderer = new THREE.WebGLRenderer({ antialias: true }); // アンチエイリアスで描画を滑らかに
    renderer.setSize(window.innerWidth, window.innerHeight); // 描画サイズをウィンドウサイズに合わせる
    renderer.outputEncoding = THREE.sRGBEncoding; // レンダリング品質改善
    renderer.shadowMap.enabled = true; // 影の描画を有効化

    // 生成された<canvas>要素をHTML<body>に追加
    document.body.appendChild(renderer.domElement);

    // === 1-4. カメラ操作 (OrbitControls) の設定 ===
    // インポートした OrbitControls を使って、マウスでカメラを動かせるようにする
    controls = new OrbitControls(camera, renderer.domElement);
    controls.enableDamping = true; // 動きを滑らかにする
    controls.dampingFactor = 0.05;

    // === 1-5. ライト (Lighting) の追加 ===
    // 部屋全体を均等に照らす環境光
    const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); // 弱めの光
    scene.add(ambientLight);

    // 特定の方向から照らす指向性光(影を落とすために必要)
    const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0);
    directionalLight.position.set(20, 30, 10);
    scene.add(directionalLight);

    // === 1-6 & 1-7. 部屋の構造(床と壁)とグリッド定規の作成 ===
    createRoomStructure();

    // === 1-8. 家具(プレースホルダー)の作成と配置 ===
    // 家具の配置例
    const deskHeight = 0.75;
    const chairHeight = 0.5;

    addFurniture(2, 2, 1, 3, 2.0, 0xa0522d); // 棚
    addFurniture(-3, -3, 3, 1.5, deskHeight, 0xdeb887); // デスク
    addFurniture(-3, -1, 0.5, 0.5, chairHeight, 0x800000); // チェア
    
    // ウィンドウサイズが変更されたときのリサイズ処理を登録
    window.addEventListener('resize', onWindowResize, false);
}

// 2. 描画ループ関数:アニメーションの心臓部
function animate() {
    requestAnimationFrame(animate); // ブラウザの再描画タイミングに合わせてループを継続
    controls.update(); // カメラ操作のダンピング効果を適用するために更新が必要
    renderer.render(scene, camera); // シーンをカメラの視点から描画
}

// 3. ウィンドウリサイズ処理:画面サイズが変わっても表示を最適化
function onWindowResize() {
    camera.aspect = window.innerWidth / window.innerHeight;    // カメラのアスペクト比更新
    camera.updateProjectionMatrix(); // カメラの投影行列を更新
    renderer.setSize(window.innerWidth, window.innerHeight); // レンダラーのサイズ更新
}

// 4. 部屋全体構造の作成
function createRoomStructure() {
    const wallHeight = 2; 

    // 床の作成
    const floorGeometry = new THREE.PlaneGeometry(roomSize, roomSize); 
    const floorMaterial = new THREE.MeshStandardMaterial({ 
        color: 0xf8feff,
        side: THREE.DoubleSide
    });
    const floor = new THREE.Mesh(floorGeometry, floorMaterial);
    floor.rotation.x = Math.PI / 2;
    floor.position.y = 0; 
    floor.receiveShadow = true; 
    roomGroup.add(floor); 
    
    // 視覚的定規(GridHelper)の追加 (1m間隔)
    const gridHelper = new THREE.GridHelper(
        roomSize, roomSize, 0x444444, 0x888888        
    );
    gridHelper.position.y = 0.01; 
    roomGroup.add(gridHelper); 

    // 壁の半透明マテリアルを作成 
    const wallMaterial = new THREE.MeshStandardMaterial({ 
        color: 0xf0e68c, 
        transparent: true, 
        opacity: 0.3,      
        side: THREE.DoubleSide 
    });
    
    // 壁の作成ヘルパー
    function createWall(x, z, length, height, rotationY) {
        const wallGeometry = new THREE.BoxGeometry(length, height, 0.1); 
        const wall = new THREE.Mesh(wallGeometry, wallMaterial); 
        
        wall.position.y = height / 2;
        wall.position.x = x;
        wall.position.z = z;
        wall.rotation.y = rotationY;
        wall.receiveShadow = true;
        roomGroup.add(wall); 
    }
    
    // 4つの壁を作成
    createWall(0, -roomSize / 2, roomSize, wallHeight, 0); 
    createWall(0, roomSize / 2, roomSize, wallHeight, 0); 
    createWall(-roomSize / 2, 0, roomSize, wallHeight, Math.PI / 2); 
    createWall(roomSize / 2, 0, roomSize, wallHeight, Math.PI / 2); 
}

// 5.単純な家具(BoxGeometry)を追加する
function addFurniture(x, z, width, depth, height, color) {
    // BoxGeometry: 立方体/直方体の形状
    const geometry = new THREE.BoxGeometry(width, height, depth); 
    const material = new THREE.MeshStandardMaterial({ color: color });
    const mesh = new THREE.Mesh(geometry, material);
    // 家具のY軸の位置を床から半分の高さに設定
    mesh.position.set(x, height / 2, z); 
    mesh.castShadow = true; 
    mesh.receiveShadow = true; 
    // IDを設定し、保存・読み込み時に一貫性を保つ
    mesh.userData.draggable = true;
    mesh.userData.id = furniture.length; 
    // このメッシュが後にRaycasterによる選択の対象となるように設定
    mesh.userData.name = `furniture_${mesh.userData.id}`;
    
    roomGroup.add(mesh);  // roomGroupに追加
    furniture.push(mesh); // 家具の位置状態を保管
    
    return mesh;
}

// 実行
init();
animate();

  • addFurniture()関数で直方体の形状をTHREE.BoxGeometry()、THREE.MeshStandardMaterial()でTHREE.Meshにします。
  • 作成したMeshをroomGroup.add()で部屋に設置します。

VS Code実行結果ーオブジェクト表示の確認

家具が配置されているか確認してください。
THREE.DirectionalLightによって直方体の3面に光が当たって各面が認識できます。これを設定しないと各面が同一色になり立体であることが判別できません。

また光源の設定をしましたが床に影は投影されません。後でスポットライトを設置して影も投影できるようにします。

オブジェクトをグループ化して椅子の作成

椅子といっても直方体ですので、椅子らしくするために簡単な方法として複数のオブジェクトを一つにして椅子を表現してみます。

main.js 追加内容
  • // === 1-8. 家具(プレースホルダー)の作成と配置 ===
    addFurniture(-3, -1, 0.5, 0.5, chairHeight, 0x800000);をコメントアウトし、addChair()関数で複数の直方体オブジェクトを組み合わせて椅子を作成します。オブジェクトの配置はこれまでと同様にroomGroup.add()で追加します。
  • addChair()関数の追加
    THREE.Groupの導入: 椅子を構成する座面、背もたれ、4本の脚をそれぞれTHREE.Meshとして作成し、それらを一つのTHREE.Groupにまとめます。
// main.js
// [main-003-objects]
// index.html の importmap の設定に基づき、ローカルファイルをインポート
import * as THREE from "three";
import { OrbitControls } from './jsm/controls/OrbitControls.js';

// 必要な変数をグローバルスコープで定義
let scene, camera, renderer, controls;
let roomGroup; // 床、壁、家具をまとめるグループ

const roomSize = 10; // 部屋のサイズ (10m x 10m)
const furniture = []; // 複数の家具オブジェクトを順序付けして保管

// 1. 初期化関数:3D世界の土台を作る
function init() {
    // === 1-1. シーン (Scene) の作成 ===
    scene = new THREE.Scene();
    scene.background = new THREE.Color(0xf0f0f0); // 背景色を薄いグレーに設定
    roomGroup = new THREE.Group();
    scene.add(roomGroup);

    // 座標軸ヘルパーを追加(X軸:赤、Y軸:緑、Z軸:青)
    const axesHelper = new THREE.AxesHelper(10); // サイズ10
    scene.add(axesHelper);

    // === 1-2. カメラ (Camera) の設定 ===
    const fov = 75; // 視野角 (Field of View)
    const aspect = window.innerWidth / window.innerHeight; // アスペクト比
    const near = 0.1; // 描画開始距離
    const far = 1000; // 描画終了距離
    camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
    
    // カメラの初期位置を決定 (X, Y, Z)
    camera.position.set(10, 15, 15);
    camera.lookAt(0, 0, 0); // 原点(0, 0, 0)を見るように設定

    // === 1-3. レンダラー (Renderer) の作成 ===
    renderer = new THREE.WebGLRenderer({ antialias: true }); // アンチエイリアスで描画を滑らかに
    renderer.setSize(window.innerWidth, window.innerHeight); // 描画サイズをウィンドウサイズに合わせる
    renderer.outputEncoding = THREE.sRGBEncoding; // レンダリング品質改善
    renderer.shadowMap.enabled = true; // 影の描画を有効化

    // 生成された<canvas>要素をHTML<body>に追加
    document.body.appendChild(renderer.domElement);

    // === 1-4. カメラ操作 (OrbitControls) の設定 ===
    // インポートした OrbitControls を使って、マウスでカメラを動かせるようにする
    controls = new OrbitControls(camera, renderer.domElement);
    controls.enableDamping = true; // 動きを滑らかにする
    controls.dampingFactor = 0.05;

    // === 1-5. ライト (Lighting) の追加 ===
    // 部屋全体を均等に照らす環境光
    const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); // 弱めの光
    scene.add(ambientLight);

    // 特定の方向から照らす指向性光(影を落とすために必要)
    const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0);
    directionalLight.position.set(20, 30, 10);
    scene.add(directionalLight);

    // === 1-6 & 1-7. 部屋の構造(床と壁)とグリッド定規の作成 ===
    createRoomStructure();

    // === 1-8. 家具(プレースホルダー)の作成と配置 ===
    // 家具の配置例
    const deskHeight = 0.75;
    const chairHeight = 0.5;

    addFurniture(2, 2, 1, 3, 2.0, 0xa0522d); // 棚
    addFurniture(-3, -3, 3, 1.5, deskHeight, 0xdeb887); // デスク
    //addFurniture(-3, -1, 0.5, 0.5, chairHeight, 0x800000); // チェア
    addChair(-3, -1, 0x800000); // チェア
    
    // ウィンドウサイズが変更されたときのリサイズ処理を登録
    window.addEventListener('resize', onWindowResize, false);
}

// 2. 描画ループ関数:アニメーションの心臓部
function animate() {
    requestAnimationFrame(animate); // ブラウザの再描画タイミングに合わせてループを継続
    controls.update(); // カメラ操作のダンピング効果を適用するために更新が必要
    renderer.render(scene, camera); // シーンをカメラの視点から描画
}

// 3. ウィンドウリサイズ処理:画面サイズが変わっても表示を最適化
function onWindowResize() {
    camera.aspect = window.innerWidth / window.innerHeight;    // カメラのアスペクト比更新
    camera.updateProjectionMatrix(); // カメラの投影行列を更新
    renderer.setSize(window.innerWidth, window.innerHeight); // レンダラーのサイズ更新
}

// 4. 部屋全体構造の作成
function createRoomStructure() {
    const wallHeight = 2; 

    // 床の作成
    const floorGeometry = new THREE.PlaneGeometry(roomSize, roomSize); 
    const floorMaterial = new THREE.MeshStandardMaterial({ 
        color: 0xf8feff,
        side: THREE.DoubleSide
    });
    const floor = new THREE.Mesh(floorGeometry, floorMaterial);
    floor.rotation.x = Math.PI / 2;
    floor.position.y = 0; 
    floor.receiveShadow = true; 
    roomGroup.add(floor); 
    
    // 視覚的定規(GridHelper)の追加 (1m間隔)
    const gridHelper = new THREE.GridHelper(
        roomSize, roomSize, 0x444444, 0x888888        
    );
    gridHelper.position.y = 0.01; 
    roomGroup.add(gridHelper); 

    // 壁の半透明マテリアルを作成 
    const wallMaterial = new THREE.MeshStandardMaterial({ 
        color: 0xf0e68c, 
        transparent: true, 
        opacity: 0.3,      
        side: THREE.DoubleSide 
    });
    
    // 壁の作成ヘルパー
    function createWall(x, z, length, height, rotationY) {
        const wallGeometry = new THREE.BoxGeometry(length, height, 0.1); 
        const wall = new THREE.Mesh(wallGeometry, wallMaterial); 
        
        wall.position.y = height / 2;
        wall.position.x = x;
        wall.position.z = z;
        wall.rotation.y = rotationY;
        wall.receiveShadow = true;
        roomGroup.add(wall); 
    }
    
    // 4つの壁を作成
    createWall(0, -roomSize / 2, roomSize, wallHeight, 0); 
    createWall(0, roomSize / 2, roomSize, wallHeight, 0); 
    createWall(-roomSize / 2, 0, roomSize, wallHeight, Math.PI / 2); 
    createWall(roomSize / 2, 0, roomSize, wallHeight, Math.PI / 2); 
}

// 5.単純な家具(BoxGeometry)を追加する
function addFurniture(x, z, width, depth, height, color) {
    // BoxGeometry: 立方体/直方体の形状
    const geometry = new THREE.BoxGeometry(width, height, depth); 
    const material = new THREE.MeshStandardMaterial({ color: color });
    const mesh = new THREE.Mesh(geometry, material);
    // 家具のY軸の位置を床から半分の高さに設定
    mesh.position.set(x, height / 2, z); 
    mesh.castShadow = true; 
    mesh.receiveShadow = true; 
    // IDを設定し、保存・読み込み時に一貫性を保つ
    mesh.userData.draggable = true;
    mesh.userData.id = furniture.length; 
    // このメッシュが後にRaycasterによる選択の対象となるように設定
    mesh.userData.name = `furniture_${mesh.userData.id}`;
    
    roomGroup.add(mesh);  // roomGroupに追加
    furniture.push(mesh); // 家具の位置状態を保管
    
    return mesh;
}

// 6.複合オブジェクト(椅子)を追加する、複数のMeshをTHREE.Groupにまとめる
function addChair(x, z, color) {
    const CHAIR_W = 0.5;
    const CHAIR_D = 0.5;
    const SEAT_H = 0.05;
    const SEAT_Y = 0.5; // 座面高さ(床からの距離)
    const LEG_D = 0.05;
    const BACK_H = 0.4;
    
    const chairGroup = new THREE.Group();
    chairGroup.userData.draggable = true;
    chairGroup.userData.isComposite = true; // 複合オブジェクトのフラグ
    
    // 全ての子オブジェクトに共通の材質を適用
    const material = new THREE.MeshStandardMaterial({ color: color });

    // 1. 座面
    const seatGeometry = new THREE.BoxGeometry(CHAIR_W, SEAT_H, CHAIR_D);
    const seat = new THREE.Mesh(seatGeometry, material);
    // 座面の中心がY=SEAT_Yになるように配置
    seat.position.y = SEAT_Y - SEAT_H / 2;
    seat.castShadow = true; 
    seat.receiveShadow = true;
    chairGroup.add(seat);

    // 2. 背もたれ
    const backGeometry = new THREE.BoxGeometry(CHAIR_W, BACK_H, 0.05); // 奥行き 5cm
    const back = new THREE.Mesh(backGeometry, material);
    // Y位置: 座面の上面 (SEAT_Y) + 背もたれ高さの半分 (BACK_H/2)
    // Z位置: 椅子の奥行き半分 (CHAIR_D/2) - 背もたれの奥行き半分 (0.05/2)
    back.position.set(0, SEAT_Y + BACK_H / 2, CHAIR_D / 2 - 0.05 / 2);
    back.castShadow = true; 
    back.receiveShadow = true;
    chairGroup.add(back);

    // 3. 4本の脚
    const legH = SEAT_Y - SEAT_H; // 脚の高さは床から座面の下まで
    const legGeometry = new THREE.BoxGeometry(LEG_D, legH, LEG_D);
    
    function addLeg(lx, lz) {
        const leg = new THREE.Mesh(legGeometry, material);
        // X/Z位置: 椅子の幅/奥行きから、脚の奥行き/幅の半分を引いた位置
        // Y位置: 脚の高さの半分 (legH/2)
        leg.position.set(
            lx * (CHAIR_W / 2 - LEG_D / 2), 
            legH / 2, 
            lz * (CHAIR_D / 2 - LEG_D / 2)
        );
        leg.castShadow = true; 
        leg.receiveShadow = true;
        chairGroup.add(leg);
    }
    
    addLeg(1, 1);   // 右奥
    addLeg(1, -1);  // 右前
    addLeg(-1, 1);  // 左奥
    addLeg(-1, -1); // 左前
    
    // Group自体の位置は、ピボット(原点)を床(Y=0)に設定
    chairGroup.position.set(x, 0, z); 
    
    chairGroup.userData.id = furniture.length; 
    chairGroup.userData.name = `chair_${chairGroup.userData.id}`;
    
    roomGroup.add(chairGroup);
    furniture.push(chairGroup); 
    
    return chairGroup;
}

// 実行
init();
animate();

VS Code実行結果ーグループオブジェクト表示の確認

ワンポイントヒントThree.jsで生成できるオブジェクトの種類

直方体(BoxGeometry)、平面(PlaneGeometry)以外にもいろんな形状のプリミティブ(基本図形)が利用できます。プリミティブ立体、平面・リング、多面体、カスタム・複雑な形状の関数が用意されており、それらを組み合わせることで多様な構造物を作成できます。これらのクラスは、3Dオブジェクトの形状データ(頂点、面、エッジ)を定義し、メッシュ(THREE.Mesh) の作成に使用されます。
本記事では下表のBoxGeometrySphereGeometryPlaneGeometryRingGeometryBufferGeometryを使っています。

分類 クラス名 形状 概要
プリミティブ立体 BoxGeometry BoxGeometry直方/立方体 幅、高さ、奥行きを指定して作成する基本の箱型立体です。
SphereGeometry SphereGeometry球体 半径と分割数を指定して完全な球体を作成します。
CylinderGeometry CylinderGeometry円柱/円錐 上下両面の半径、高さ、分割数を指定して円柱や円錐を作成します。
TorusGeometry TorusGeometryトーラス ドーナツ型(浮き輪型)の3D形状を作成します。
平面・リング PlaneGeometry PlaneGeometry平面 幅と高さを指定して平らな長方形の面を作成します。
CircleGeometry CircleGeometry円/扇形 半径と分割数を指定して平らな円または扇形を作成します。
RingGeometry RingGeometryリング 内径と外径を指定してドーナツ型の平らなリングを作成します。
多面体 TetrahedronGeometry TetrahedronGeometry正四面体 4つの面を持つ立体を作成します。
OctahedronGeometry OctahedronGeometry正八面体 8つの面を持つ立体を作成します。
IcosahedronGeometry IcosahedronGeometry正二十面体 20の面を持つ立体を作成します。
カスタム・複雑な形状 ExtrudeGeometry ExtrudeGeometry押出形状 2Dの形状を3次元方向に押し出して立体を作成します(例:3Dロゴ)。
LatheGeometry LatheGeometry回転体 2Dのプロファイルを軸を中心に回転させて作成する形状(例:花瓶)。
TextGeometry TextGeometry3Dテキスト 指定したフォントと文字列から3Dの文字オブジェクトを作成します。
BufferGeometry BufferGeometryカスタム 頂点座標などを配列として手動で定義するための、最も柔軟な基本クラスです。

3Dモデルのスカルプトデータをロードしてオブジェクトを設置する

BlenderZBrushなどで作成した「スカルプト(彫刻)」、あるいは生成AIで作成した3Dモデルデータを「.glb」ファイルにエクスポートしそれを利用することもできます。
これによって複雑な形状を持つ家具、装飾品、彫刻などのモデルをシーンに読み込むことができます。

今回使用する龍のオブジェクトはTripo 3Dの生成AIで作成しました。
※Tripo 3Dの招待コード:BG8U9Gを利用すれば300クレジットが無料で提供されますので興味ある方はご利用ください。参考として龍のオブジェクトは35クレジットの消費で作成できました。

3D龍

龍のオブジェクトはhttps://github.com/picolix/sculpdata/archive/refs/heads/main.zipをダウンロードできます。解凍するとdragon-3d-model-2.glbができますのでフォルダのトップ階層にコピーしてください。

このglbファイルをGLTFLoader()でロードして部屋に設置します。これによって複雑な形状を持つ家具、装飾品、彫刻などのモデルをシーンに読み込むことができます。

main.js 追加内容
  • // === 1-8. 家具(プレースホルダー)の作成と配置 ===
    addLoadSculptedModel('./dragon-3d-model-2.glb',-3, 1.3,-2.8,Math.PI / 2,2.0);を追加しました。
    引数:X,Y,Z=(-3,1.3,-2.8)の机の上に配置します。Math.PI / 2は90度回転させる。2.0は2倍のサイズにします。
  • addLoadSculptedModel()関数の追加
    GLBファイルを非同期でロードしroomGroupに追加します。
// main.js
// [main-004-sclup]
// index.html の importmap の設定に基づき、ローカルファイルをインポート
import * as THREE from "three";
import { OrbitControls } from './jsm/controls/OrbitControls.js';
import { GLTFLoader } from './jsm/loaders/GLTFLoader.js';
// 必要な変数をグローバルスコープで定義
let scene, camera, renderer, controls;
let roomGroup; // 床、壁、家具をまとめるグループ
let gltfLoader; // GLTFローダー
const roomSize = 10; // 部屋のサイズ (10m x 10m)
const furniture = []; // 複数の家具オブジェクトを順序付けして保管
// 1. 初期化関数:3D世界の土台を作る
function init() {
    // === 1-1. シーン (Scene) の作成 ===
    scene = new THREE.Scene();
    scene.background = new THREE.Color(0xf0f0f0); // 背景色を薄いグレーに設定
    roomGroup = new THREE.Group();
    scene.add(roomGroup);
    // 座標軸ヘルパーを追加(X軸:赤、Y軸:緑、Z軸:青)
    const axesHelper = new THREE.AxesHelper(10); // サイズ10
    scene.add(axesHelper);

    // === 1-2. カメラ (Camera) の設定 ===
    const fov = 75; // 視野角 (Field of View)
    const aspect = window.innerWidth / window.innerHeight; // アスペクト比
    const near = 0.1; // 描画開始距離
    const far = 1000; // 描画終了距離
    camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
    // カメラの初期位置を決定 (X, Y, Z)
    camera.position.set(10, 15, 15);
    camera.lookAt(0, 0, 0); // 原点(0, 0, 0)を見るように設定

    // === 1-3. レンダラー (Renderer) の作成 ===
    renderer = new THREE.WebGLRenderer({ antialias: true }); // アンチエイリアスで描画を滑らかに
    renderer.setSize(window.innerWidth, window.innerHeight); // 描画サイズをウィンドウサイズに合わせる
    renderer.outputEncoding = THREE.sRGBEncoding; // レンダリング品質改善
    renderer.shadowMap.enabled = true; // 影の描画を有効化

    // GLTFローダーの初期化
    gltfLoader = new GLTFLoader(); 

    // 生成された<canvas>要素をHTML<body>に追加
    document.body.appendChild(renderer.domElement);

    // === 1-4. カメラ操作 (OrbitControls) の設定 ===
    // インポートした OrbitControls を使って、マウスでカメラを動かせるようにする
    controls = new OrbitControls(camera, renderer.domElement);
    controls.enableDamping = true; // 動きを滑らかにする
    controls.dampingFactor = 0.05;

    // === 1-5. ライト (Lighting) の追加 ===
    // 部屋全体を均等に照らす環境光
    const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); // 弱めの光
    scene.add(ambientLight);
    // 特定の方向から照らす指向性光(影を落とすために必要)
    const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0);
    directionalLight.position.set(20, 30, 10);
    scene.add(directionalLight);

    // === 1-6 & 1-7. 部屋の構造(床と壁)とグリッド定規の作成 ===
    createRoomStructure();

    // === 1-8. 家具(プレースホルダー)の作成と配置 ===
    // 家具の配置例
    const deskHeight = 0.75;
    const chairHeight = 0.5;
    addFurniture(2, 2, 1, 3, 2.0, 0xa0522d); // 棚
    addFurniture(-3, -3, 3, 1.5, deskHeight, 0xdeb887); // デスク
    //addFurniture(-3, -1, 0.5, 0.5, chairHeight, 0x800000); // チェア
    addChair(-3, -1, 0x800000); // チェア
    
    // スカルプトモデル(龍)のロードと配置
    addLoadSculptedModel('./dragon-3d-model-2.glb',-3, 1.3,-2.8,Math.PI / 2,2.0);

    // ウィンドウサイズが変更されたときのリサイズ処理を登録
    window.addEventListener('resize', onWindowResize, false);
}
// 2. 描画ループ関数:アニメーションの心臓部
function animate() {
    requestAnimationFrame(animate); // ブラウザの再描画タイミングに合わせてループを継続
    controls.update(); // カメラ操作のダンピング効果を適用するために更新が必要
    renderer.render(scene, camera); // シーンをカメラの視点から描画
}
// 3. ウィンドウリサイズ処理:画面サイズが変わっても表示を最適化
function onWindowResize() {
    camera.aspect = window.innerWidth / window.innerHeight;    // カメラのアスペクト比更新
    camera.updateProjectionMatrix(); // カメラの投影行列を更新
    renderer.setSize(window.innerWidth, window.innerHeight); // レンダラーのサイズ更新
}
// 4. 部屋全体構造の作成
function createRoomStructure() {
    const wallHeight = 2; 
    // 床の作成
    const floorGeometry = new THREE.PlaneGeometry(roomSize, roomSize); 
    const floorMaterial = new THREE.MeshStandardMaterial({ 
        color: 0xf8feff,
        side: THREE.DoubleSide
    });
    const floor = new THREE.Mesh(floorGeometry, floorMaterial);
    floor.rotation.x = Math.PI / 2;
    floor.position.y = 0; 
    floor.receiveShadow = true; 
    roomGroup.add(floor); 
    
    // 視覚的定規(GridHelper)の追加 (1m間隔)
    const gridHelper = new THREE.GridHelper(
        roomSize, roomSize, 0x444444, 0x888888        
    );
    gridHelper.position.y = 0.01; 
    roomGroup.add(gridHelper); 
    // 壁の半透明マテリアルを作成 
    const wallMaterial = new THREE.MeshStandardMaterial({ 
        color: 0xf0e68c,
        transparent: true, 
        opacity: 0.3,      
        side: THREE.DoubleSide 
    });
    
    // 壁の作成ヘルパー
    function createWall(x, z, length, height, rotationY) {
        const wallGeometry = new THREE.BoxGeometry(length, height, 0.1); 
        const wall = new THREE.Mesh(wallGeometry, wallMaterial); 
        
        wall.position.y = height / 2;
        wall.position.x = x;
        wall.position.z = z;
        wall.rotation.y = rotationY;
        wall.receiveShadow = true;
        roomGroup.add(wall); 
    }
    
    // 4つの壁を作成
    createWall(0, -roomSize / 2, roomSize, wallHeight, 0); 
    createWall(0, roomSize / 2, roomSize, wallHeight, 0); 
    createWall(-roomSize / 2, 0, roomSize, wallHeight, Math.PI / 2); 
    createWall(roomSize / 2, 0, roomSize, wallHeight, Math.PI / 2); 
}
// 5.単純な家具(BoxGeometry)を追加する
function addFurniture(x, z, width, depth, height, color) {
    // BoxGeometry: 立方体/直方体の形状
    const geometry = new THREE.BoxGeometry(width, height, depth); 
    const material = new THREE.MeshStandardMaterial({ color: color });
    const mesh = new THREE.Mesh(geometry, material);
    // 家具のY軸の位置を床から半分の高さに設定
    mesh.position.set(x, height / 2, z); 
    mesh.castShadow = true; 
    mesh.receiveShadow = true;
    // IDを設定し、保存・読み込み時に一貫性を保つ
    mesh.userData.draggable = true;
    mesh.userData.id = furniture.length; 
    // このメッシュが後にRaycasterによる選択の対象となるように設定
    mesh.userData.name = `furniture_${mesh.userData.id}`;
    
    roomGroup.add(mesh);  // roomGroupに追加
    furniture.push(mesh); // 家具の位置状態を保管
    
    return mesh;
}
// 6.複合オブジェクト(椅子)を追加する、複数のMeshをTHREE.Groupにまとめる
function addChair(x, z, color) {
    const CHAIR_W = 0.5;
    const CHAIR_D = 0.5;
    const SEAT_H = 0.05;
    const SEAT_Y = 0.5; // 座面高さ(床からの距離)
    const LEG_D = 0.05;
    const BACK_H = 0.4;
    
    const chairGroup = new THREE.Group();
    chairGroup.userData.draggable = true;
    chairGroup.userData.isComposite = true; // 複合オブジェクトのフラグ
    
    // 全ての子オブジェクトに共通の材質を適用
    const material = new THREE.MeshStandardMaterial({ color: color });
    // 1. 座面
    const seatGeometry = new THREE.BoxGeometry(CHAIR_W, SEAT_H, CHAIR_D);
    const seat = new THREE.Mesh(seatGeometry, material);
    // 座面の中心がY=SEAT_Yになるように配置
    seat.position.y = SEAT_Y - SEAT_H / 2;
    seat.castShadow = true; 
    seat.receiveShadow = true;
    chairGroup.add(seat);
    // 2. 背もたれ
    const backGeometry = new THREE.BoxGeometry(CHAIR_W, BACK_H, 0.05); // 奥行き 5cm
    const back = new THREE.Mesh(backGeometry, material);
    // Y位置: 座面の上面 (SEAT_Y) + 背もたれ高さの半分 (BACK_H/2)
    // Z位置: 椅子の奥行き半分 (CHAIR_D/2) - 背もたれの奥行き半分 (0.05/2)
    back.position.set(0, SEAT_Y + BACK_H / 2, CHAIR_D / 2 - 0.05 / 2);
    back.castShadow = true; 
    back.receiveShadow = true;
    chairGroup.add(back);
    // 3. 4本の脚
    const legH = SEAT_Y - SEAT_H; // 脚の高さは床から座面の下まで
    const legGeometry = new THREE.BoxGeometry(LEG_D, legH, LEG_D);
    
    function addLeg(lx, lz) {
        const leg = new THREE.Mesh(legGeometry, material);
        // X/Z位置: 椅子の幅/奥行きから、脚の奥行き/幅の半分を引いた位置
        // Y位置: 脚の高さの半分 (legH/2)
        leg.position.set(
            lx * (CHAIR_W / 2 - LEG_D / 2), 
            legH / 2, 
            lz * (CHAIR_D / 2 - LEG_D / 2)
        );
        leg.castShadow = true;
        leg.receiveShadow = true;
        chairGroup.add(leg);
    }
    
    addLeg(1, 1);   // 右奥
    addLeg(1, -1);  // 右前
    addLeg(-1, 1);  // 左奥
    addLeg(-1, -1); // 左前
    
    // Group自体の位置は、ピボット(原点)を床(Y=0)に設定
    chairGroup.position.set(x, 0, z); 
    
    chairGroup.userData.id = furniture.length; 
    chairGroup.userData.name = `chair_${chairGroup.userData.id}`;
    
    roomGroup.add(chairGroup);
    furniture.push(chairGroup); 
    
    return chairGroup;
}
//  7. 3Dモデルファイル(GLTF/GLB)をロードしシーンに追加する
function addLoadSculptedModel(url, x, y , z,rotationY = 0,scale = 1.0) {
    if (!gltfLoader) {
        console.error("GLTFLoader is not initialized.");
        return;
    }
    
    console.log(`Loading model from: ${url}`);
    
    gltfLoader.load(
        url,
        function (gltf) {
            const model = gltf.scene;
            
            // モデル全体を roomGroupの子として配置し、ドラッグ可能にする
            model.position.set(x, y, z); 
            model.rotation.y = rotationY; //初期回転角度を適用
            model.scale.set(scale, scale, scale); // スケールを適用
            // 既存の家具と同じプロパティを設定
            model.userData.draggable = true;
            model.userData.id = furniture.length; 
            model.userData.name = `sculpture_${model.userData.id}`;
            
            // 影の設定とマテリアルの更新
            model.traverse(function (child) {
                if (child.isMesh) {
                    child.castShadow = true;
                    child.receiveShadow = true; 
                    // モデルのマテリアルがスポットライトに反応するように設定
                    if (Array.isArray(child.material)) {
                        child.material.forEach(m => m.needsUpdate = true);
                    } else if (child.material) {
                        child.material.needsUpdate = true;
                    }
                }
            });
            
            roomGroup.add(model); 
            furniture.push(model);
        },
        undefined, 
        // エラーハンドリング
        function (error) {
            console.error(`An error occurred while loading model: ${url}`, error);
        }
    );
}

// 実行
init();
animate();

VS Code実行結果ー3Dオブジェクト表示の確認

ワンポイントヒントthree.jsのGLTFLoaderとBufferGeometryについて

three.jsのGLTFLoaderライブラリは、gLTFファイル(.gltfまたは.glb)をロードする際に、モデルのジオメトリを表現するためにBufferGeometryが使われています。
gLTFフォーマット自体が、ジオメトリデータをコンパクトなバイナリデータ(バッファ)として効率的に格納するように設計されており、GLTFLoaderはこれを直接BufferGeometryの属性にマッピングします。したがって、GLTFLoaderを使用してロードされたメッシュのジオメトリプロパティは、常にTHREE.BufferGeometryのインスタンスになります。

龍の置物が表示されれば正しく動作しています。
失敗した場合は表示されませんので、dragon-3d-model-2.glbのファイルが存在するか、もしくはプログラムがエラーしていますので再確認してください。

 オブジェクトの移動機能(ドラッグ&ドロップ)の実装

今まではオブジェクトの設置でしたが、マウスで家具を選んでドラッグ&ドロップで移動させる機能を実装します。
ここでは、Raycaster(光線投射)とドラッグ&ドロップを手動で実装します。

main.js 追加内容
  • ドラッグ&ドロップに必要な変数を追加
     オブジェクトの中心とクリック位置のズレを吸収するためにTHREE.Vector3()を使います。
  • // === 1-9. ドラッグ&ドロップ機能の初期化 ===
    Raycaster、マウスイベントのリスナー、そしてドラッグに使用する仮想的な平面(Plane)を初期化します。
  • マウスイベント関数の実装
    ドラッグ&ドロップの核心となる3つのイベントハンドラー関数であるonPointerDown()、onPointerMove()、onPointerUp()を追加します。
  •  // マウスイベントのリスナーを追加
    onPointerDown、onPointerMove、onPointerUpをrenderer.domElement.addEventListener()で追加します。
// main.js
// [main-005-move]
// index.html の importmap の設定に基づき、ローカルファイルをインポート
import * as THREE from "three";
import { OrbitControls } from './jsm/controls/OrbitControls.js';
import { GLTFLoader } from './jsm/loaders/GLTFLoader.js';

// 必要な変数をグローバルスコープで定義
let scene, camera, renderer, controls;
let roomGroup; // 床、壁、家具をまとめるグループ
let gltfLoader; // GLTFローダー

const roomSize = 10; // 部屋のサイズ (10m x 10m)
const furniture = []; // 複数の家具オブジェクトを順序付けして保管

// --- ドラッグ&ドロップに必要な変数 ---
let raycaster;
let mouse;
let isDragging = false; // 移動中フラグ
let selectedObject = null;
let plane;
let offset = new THREE.Vector3();

// 1. 初期化関数:3D世界の土台を作る
function init() {
    //=== 1-1. シーン (Scene) の作成 ===
    scene = new THREE.Scene();
    scene.background = new THREE.Color(0xf0f0f0);  // 背景色を薄いグレーに設定
    roomGroup = new THREE.Group();
    scene.add(roomGroup);
    // 座標軸ヘルパーを追加(X軸:赤、Y軸:緑、Z軸:青)
    const axesHelper = new THREE.AxesHelper(10); // サイズ10
    scene.add(axesHelper);

    // === 1-2. カメラ (Camera) の設定 ===
    const fov = 75; // 視野角 (Field of View)
    const aspect = window.innerWidth / window.innerHeight;  // アスペクト比
    const near = 0.1; // 描画開始距離
    const far = 1000; // 描画終了距離
    camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
    // カメラの初期位置を決定 (X, Y, Z)
    camera.position.set(10, 15, 15);
    camera.lookAt(0, 0, 0); // 原点(0, 0, 0)を見るように設定

    // === 1-3. レンダラー (Renderer) の作成 ===
    renderer = new THREE.WebGLRenderer({ antialias: true }); // アンチエイリアスで描画を滑らかに
    renderer.setSize(window.innerWidth, window.innerHeight); // 描画サイズをウィンドウサイズに合わせる
    renderer.outputEncoding = THREE.sRGBEncoding; // レンダリング品質改善
    renderer.shadowMap.enabled = true; // 影の描画を有効化

    // GLTFローダーの初期化
    gltfLoader = new GLTFLoader(); 

    // 生成された<canvas>要素をHTML<body>に追加
    document.body.appendChild(renderer.domElement);

    // === 1-4. カメラ操作 (OrbitControls) の設定 ===
    // インポートした OrbitControls を使って、マウスでカメラを動かせるようにする
    controls = new OrbitControls(camera, renderer.domElement);
    controls.enableDamping = true;  // 動きを滑らかにする
    controls.dampingFactor = 0.05;

    // === 1-5. ライト (Light) の追加 ===
    // 部屋全体を均等に照らす環境光
    const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); // 弱めの光
    scene.add(ambientLight);

    // 特定の方向から照らす指向性光(影を落とすために必要)
    const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0);
    directionalLight.position.set(20, 30, 10);
    scene.add(directionalLight);

    // === 1-6 & 1-7. 部屋の構造(床と壁)とグリッド定規の作成 ===
    createRoomStructure();

    // === 1-8. 家具(プレースホルダー)の作成と配置 ===
    // 家具の配置例
    const deskHeight = 0.75;
    const chairHeight = 0.5;

    addFurniture(2, 2, 1, 3, 2.0, 0xa0522d); // 棚
    addFurniture(-3, -3, 3, 1.5, deskHeight, 0xdeb887); // デスク
    //addFurniture(-3, -1, 0.5, 0.5, chairHeight, 0x800000); // チェア
    addChair(-3, -1, 0x800000); // チェア

    // スカルプトモデル(龍)のロードと配置
    addLoadSculptedModel('./dragon-3d-model-2.glb',-3, 1.3,-2.8,Math.PI / 2,2.0);

    // === 1-9. ドラッグ&ドロップ機能の初期化 ===
    raycaster = new THREE.Raycaster();
    mouse = new THREE.Vector2();
    plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); 

    // イベントリスナー
    renderer.domElement.addEventListener('pointerdown', onPointerDown, false);
    renderer.domElement.addEventListener('pointermove', onPointerMove, false);
    renderer.domElement.addEventListener('pointerup', onPointerUp, false);
    
    window.addEventListener('resize', onWindowResize, false);
}

// 2. 描画ループ関数:アニメーションの心臓部
function animate() {
    requestAnimationFrame(animate); // ブラウザの再描画タイミングに合わせてループを継続
    controls.update();// カメラ操作のダンピング効果を適用するために更新が必要
    renderer.render(scene, camera); // シーンをカメラの視点から描画 
}

// 3. ウィンドウリサイズ処理
function onWindowResize() {
    camera.aspect = window.innerWidth / window.innerHeight;    // カメラのアスペクト比更新
    camera.updateProjectionMatrix(); // カメラの投影行列を更新
    renderer.setSize(window.innerWidth, window.innerHeight); // レンダラーのサイズ更新
}

// 4.部屋全体構造の作成

function createRoomStructure() {
    const wallHeight = 2; 

    // 床の作成
    const floorGeometry = new THREE.PlaneGeometry(roomSize, roomSize); 
    const floorMaterial = new THREE.MeshStandardMaterial({ 
        color: 0xf8feff,
        side: THREE.DoubleSide
    });
    const floor = new THREE.Mesh(floorGeometry, floorMaterial);
    floor.rotation.x = Math.PI / 2;
    floor.position.y = 0; 
    floor.receiveShadow = true; 
    roomGroup.add(floor); 
    
    // 視覚的定規(GridHelper)の追加 (1m間隔)
    const gridHelper = new THREE.GridHelper(
        roomSize, roomSize, 0x444444, 0x888888        
    );
    gridHelper.position.y = 0.01; 
    roomGroup.add(gridHelper); 

    // 壁の半透明マテリアルを作成 
    const wallMaterial = new THREE.MeshStandardMaterial({ 
        color: 0xf0e68c, 
        transparent: true, 
        opacity: 0.3,      
        side: THREE.DoubleSide 
    });
    
    // 壁の作成ヘルパー
    function createWall(x, z, length, height, rotationY) {
        const wallGeometry = new THREE.BoxGeometry(length, height, 0.1); 
        const wall = new THREE.Mesh(wallGeometry, wallMaterial); 
        
        wall.position.y = height / 2;
        wall.position.x = x;
        wall.position.z = z;
        wall.rotation.y = rotationY;
        wall.receiveShadow = true;
        roomGroup.add(wall); 
    }
    
    // 4つの壁を作成
    createWall(0, -roomSize / 2, roomSize, wallHeight, 0); 
    createWall(0, roomSize / 2, roomSize, wallHeight, 0); 
    createWall(-roomSize / 2, 0, roomSize, wallHeight, Math.PI / 2); 
    createWall(roomSize / 2, 0, roomSize, wallHeight, Math.PI / 2);  
}

// 5.単純な家具(BoxGeometry)を追加する
function addFurniture(x, z, width, depth, height, color) {
    // BoxGeometry: 立方体/直方体の形状
    const geometry = new THREE.BoxGeometry(width, height, depth); 
    const material = new THREE.MeshStandardMaterial({ color: color });
    const mesh = new THREE.Mesh(geometry, material);
    
    mesh.position.set(x, height / 2, z); 
    mesh.castShadow = true; 
    mesh.receiveShadow = true;
    // 後で保存・元の位置に戻す時に一貫性を保つIDを設定しておく
    mesh.userData.draggable = true;
    mesh.userData.id = furniture.length; 
    mesh.userData.name = `furniture_${mesh.userData.id}`;

    roomGroup.add(mesh); // roomGroupに追加
    furniture.push(mesh); // 家具の位置状態を保管

    return mesh;
}

// 6.複合オブジェクト(椅子)を追加する。複数のMeshをTHREE.Groupにまとめます。

function addChair(x, z, color) {
    const CHAIR_W = 0.5;
    const CHAIR_D = 0.5;
    const SEAT_H = 0.05;
    const SEAT_Y = 0.5; // 座面高さ(床からの距離)
    const LEG_D = 0.05;
    const BACK_H = 0.4;
    
    const chairGroup = new THREE.Group();
    chairGroup.userData.draggable = true;
    chairGroup.userData.isComposite = true; // 複合オブジェクトのフラグ
    
    // 全ての子オブジェクトに共通の材質を適用
    const material = new THREE.MeshStandardMaterial({ color: color });

    // 1. 座面
    const seatGeometry = new THREE.BoxGeometry(CHAIR_W, SEAT_H, CHAIR_D);
    const seat = new THREE.Mesh(seatGeometry, material);
    // 座面の中心がY=SEAT_Yになるように配置
    seat.position.y = SEAT_Y - SEAT_H / 2;
    seat.castShadow = true; 
    seat.receiveShadow = true;
    chairGroup.add(seat);

    // 2. 背もたれ
    const backGeometry = new THREE.BoxGeometry(CHAIR_W, BACK_H, 0.05); // 奥行き 5cm
    const back = new THREE.Mesh(backGeometry, material);
    // Y位置: 座面の上面 (SEAT_Y) + 背もたれ高さの半分 (BACK_H/2)
    // Z位置: 椅子の奥行き半分 (CHAIR_D/2) - 背もたれの奥行き半分 (0.05/2)
    back.position.set(0, SEAT_Y + BACK_H / 2, CHAIR_D / 2 - 0.05 / 2);
    back.castShadow = true;
    back.receiveShadow = true;
    chairGroup.add(back);

    // 3. 4本の脚
    const legH = SEAT_Y - SEAT_H; // 脚の高さは床から座面の下まで
    const legGeometry = new THREE.BoxGeometry(LEG_D, legH, LEG_D);
    
    function addLeg(lx, lz) {
        const leg = new THREE.Mesh(legGeometry, material);
        // X/Z位置: 椅子の幅/奥行きから、脚の奥行き/幅の半分を引いた位置
        // Y位置: 脚の高さの半分 (legH/2)
        leg.position.set(
            lx * (CHAIR_W / 2 - LEG_D / 2), 
            legH / 2, 
            lz * (CHAIR_D / 2 - LEG_D / 2)
        );
        leg.castShadow = true; 
        leg.receiveShadow = true;
        chairGroup.add(leg);
    }
    
    addLeg(1, 1);   // 右奥
    addLeg(1, -1);  // 右前
    addLeg(-1, 1);  // 左奥
    addLeg(-1, -1); // 左前
    
    // Group自体の位置は、ピボット(原点)を床(Y=0)に設定
    chairGroup.position.set(x, 0, z); 
    
    chairGroup.userData.id = furniture.length; 
    chairGroup.userData.name = `chair_${chairGroup.userData.id}`;
    
    roomGroup.add(chairGroup);
    furniture.push(chairGroup); 
    
    return chairGroup;
}

//  7. 3Dモデルファイル(GLTF/GLB)をロードしシーンに追加する
function addLoadSculptedModel(url, x, y , z,rotationY = 0,scale = 1.0) {
    if (!gltfLoader) {
        console.error("GLTFLoader is not initialized.");
        return;
    }
    
    console.log(`Loading model from: ${url}`);
    
    gltfLoader.load(
        url,
        function (gltf) {
            const model = gltf.scene;
            
            // モデル全体を roomGroupの子として配置し、ドラッグ可能にする
            model.position.set(x, y, z); 
            model.rotation.y = rotationY; //初期回転角度を適用
            model.scale.set(scale, scale, scale); // スケールを適用
            // 既存の家具と同じプロパティを設定
            model.userData.draggable = true;
            model.userData.id = furniture.length; 
            model.userData.name = `sculpture_${model.userData.id}`;
            
            // 影の設定とマテリアルの更新
            model.traverse(function (child) {
                if (child.isMesh) {
                    child.castShadow = true;
                    child.receiveShadow = true;
                    // モデルのマテリアルがスポットライトに反応するように設定
                    if (Array.isArray(child.material)) {
                        child.material.forEach(m => m.needsUpdate = true);
                    } else if (child.material) {
                        child.material.needsUpdate = true;
                    }
                }
            });
            
            roomGroup.add(model); 
            furniture.push(model);
        },
        undefined, 
        // エラーハンドリング
        function (error) {
            console.error(`An error occurred while loading model: ${url}`, error);
        }
    );
}
// 8. --- マウスイベントハンドラー ---

function updateMouse(event) {
    const rect = renderer.domElement.getBoundingClientRect();
    mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
    mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
}

// 9. マウスボタンが押されたとき (ドラッグ)
function onPointerDown(event) {
    updateMouse(event);

    raycaster.setFromCamera(mouse, camera);
    // Raycastingの対象をroomGroupの子要素に限定
    const intersects = raycaster.intersectObjects(roomGroup.children, true);

    if (intersects.length > 0) {
        let hitObject = intersects[0].object;

        // 複合オブジェクト、GLTFモデルの子オブジェクトがヒットした場合、親(Group/Scene)を選択
        while (hitObject.parent && !hitObject.userData.draggable && hitObject.parent !== roomGroup) {
            hitObject = hitObject.parent;
        }

        // 単純なBoxGeometry(デスクや棚)が直接当たった場合
        if (hitObject.userData.draggable) { 
            selectedObject = hitObject;
        } else {
             // 当たったが、ドラッグ不可(例:床)の場合
            selectedObject = null;
            return;
        }

        // selectedObject の Y位置 (ピボットの位置) を取得
        const objectY = selectedObject.position.y; 

        isDragging = true;
           
        // 移動モードの場合のみオフセットを計算
        if (raycaster.ray.intersectPlane(plane, offset)) {
            offset.sub(selectedObject.position); 
        }

        controls.enabled = false;
    } else {
        // 何も選択されなかった場合は、選択を解除する
        selectedObject = null;
        isDragging = false;
        controls.enabled = true;
    }
}

// 10.マウスが移動したとき (ドラッグ中)
function onPointerMove(event) {
    if (!selectedObject) return;

    updateMouse(event);
    raycaster.setFromCamera(mouse, camera);
    
    if (isDragging) { // === 移動ロジック ===
        let intersection = new THREE.Vector3();
        if (raycaster.ray.intersectPlane(plane, intersection)) {
            selectedObject.position.copy(intersection.sub(offset));
            selectedObject.position.y = selectedObject.geometry.parameters.height / 2;
        }
    } 
}

// 11.マウスボタンが離されたとき (終了)
function onPointerUp(event) {
    if (isDragging) { 
        isDragging = false;
        selectedObject = null;
        
        // カメラ操作を再有効化
        controls.enabled = true;
    }
}

// 実行
init();
animate();

VS Code実行結果ーオブジェクトの移動

オブジェクトをマウスの左クリックでつまんで移動させることができれば動作は正常です。水平面に対してのみ移動させることができます。

オブジェクトの回転機能の実装

平面上での移動ができましたので、回転機能を実装します。
回転操作を視覚的に行いたいので、回転ガイドリング、オブジェクトのX,Y中心軸延長表示も追加します。操作はこの回転ガイドリングをマウスでつまんで回転させるようにします。

main.js 追加内容
  • // === 1-10. 回転ガイドリングの初期化 ===
    createRotationGuide()の呼び出し。
  • createRotationGuide()の追加
    回転ガイド(リングと目盛)を作成する
  • updateExtensionLinesの追加
     オブジェクトの現在の回転に合わせて引き出しガイド線を作成・更新する。
  • 回転操作イベントリスナーの追加
    Raycaster、マウスイベントのリスナー、そしてドラッグに使用する仮想的な平面(Plane)を初期化します。
  • マウスイベント関数の修正
    ドラッグ中に特定のキー(Shift キー)を押している間に回転するように onPointerMoveを改造します。また回転リングの表示、中心線のガイド表示も行います。
    マウスが移動したとき、ドラッグ中/回転中であるかの判定も追加していますので、ソースコードの変更箇所を参考にしてください。
// main.js
// [main-006-rotate]
// index.html の importmap の設定に基づき、ローカルファイルをインポート
import * as THREE from "three";
import { OrbitControls } from './jsm/controls/OrbitControls.js';
import { GLTFLoader } from './jsm/loaders/GLTFLoader.js';

// 必要な変数をグローバルスコープで定義
let scene, camera, renderer, controls;
let roomGroup; // 床、壁、家具をまとめるグループ
let gltfLoader; // GLTFローダー

const roomSize = 10; // 部屋のサイズ (10m x 10m)
const furniture = []; // 複数の家具オブジェクトを順序付けして保管

// --- ドラッグ&ドロップに必要な変数 ---
let raycaster;
let mouse;
let isDragging = false; // 移動中フラグ
let selectedObject = null;
let plane;
let offset = new THREE.Vector3();
// --- 回転に必要な変数 ---
let dragPlane;
let isRotating = false; 
let rotationGuide = null;

// 1. 初期化関数:3D世界の土台を作る
function init() {
    //=== 1-1. シーン (Scene) の作成 ===
    scene = new THREE.Scene();
    scene.background = new THREE.Color(0xf0f0f0);  // 背景色を薄いグレーに設定
    roomGroup = new THREE.Group();
    scene.add(roomGroup);

    // 座標軸ヘルパーを追加(X軸:赤、Y軸:緑、Z軸:青)
    const axesHelper = new THREE.AxesHelper(10); // サイズ10
    scene.add(axesHelper);

    // === 1-2. カメラ (Camera) の設定 ===
    const fov = 75; // 視野角 (Field of View)
    const aspect = window.innerWidth / window.innerHeight;  // アスペクト比
    const near = 0.1; // 描画開始距離
    const far = 1000; // 描画終了距離
    camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
    // カメラの初期位置を決定 (X, Y, Z)
    camera.position.set(10, 15, 15);
    camera.lookAt(0, 0, 0); // 原点(0, 0, 0)を見るように設定

    // === 1-3. レンダラー (Renderer) の作成 ===
    renderer = new THREE.WebGLRenderer({ antialias: true }); // アンチエイリアスで描画を滑らかに
    renderer.setSize(window.innerWidth, window.innerHeight); // 描画サイズをウィンドウサイズに合わせる
    
    renderer.outputEncoding = THREE.sRGBEncoding; // レンダリング品質改善
    renderer.shadowMap.enabled = true; // 影の描画を有効化

    // GLTFローダーの初期化
    gltfLoader = new GLTFLoader(); 

    // 生成された<canvas>要素をHTML<body>に追加
    document.body.appendChild(renderer.domElement);

    // === 1-4. カメラ操作 (OrbitControls) の設定 ===
    // インポートした OrbitControls を使って、マウスでカメラを動かせるようにする
    controls = new OrbitControls(camera, renderer.domElement);
    controls.enableDamping = true;  // 動きを滑らかにする
    controls.dampingFactor = 0.05;

    // === 1-5. ライト (Light) の追加 ===
    // 部屋全体を均等に照らす環境光
    const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); // 弱めの光
    scene.add(ambientLight);

    // 特定の方向から照らす指向性光(影を落とすために必要)
    const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0);
    directionalLight.position.set(20, 30, 10);
    scene.add(directionalLight);

    // === 1-6 & 1-7. 部屋の構造(床と壁)とグリッド定規の作成 ===
    createRoomStructure();

    // === 1-8. 家具(プレースホルダー)の作成と配置 ===
    // 家具の配置例
    const deskHeight = 0.75;
    const chairHeight = 0.5;

    addFurniture(2, 2, 1, 3, 2.0, 0xa0522d); // 棚
    addFurniture(-3, -3, 3, 1.5, deskHeight, 0xdeb887); // デスク
    //addFurniture(-3, -1, 0.5, 0.5, chairHeight, 0x800000); // チェア
    addChair(-3, -1, 0x800000); // チェア

    // スカルプトモデル(龍)のロードと配置
    addLoadSculptedModel('./dragon-3d-model-2.glb',-3, 1.3,-2.8,Math.PI / 2,2.0);

    // === 1-9. ドラッグ&ドロップ機能の初期化 ===
    raycaster = new THREE.Raycaster();
    mouse = new THREE.Vector2();
    plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); 
    dragPlane = plane; 
    // === 1-10. 回転ガイドリングの初期化 ===
    rotationGuide = createRotationGuide(); 

    // イベントリスナー
    renderer.domElement.addEventListener('pointerdown', onPointerDown, false);
    renderer.domElement.addEventListener('pointermove', onPointerMove, false);
    renderer.domElement.addEventListener('pointerup', onPointerUp, false);
    
    window.addEventListener('resize', onWindowResize, false);
}

// 2. 描画ループ関数:アニメーションの心臓部
function animate() { // カメラのアスペクト比を更新
    requestAnimationFrame(animate); // ブラウザの再描画タイミングに合わせてループを継続
    controls.update();// カメラ操作のダンピング効果を適用するために更新が必要
    renderer.render(scene, camera); // シーンをカメラの視点から描画 
}

// 3. ウィンドウリサイズ処理
function onWindowResize() {
    camera.aspect = window.innerWidth / window.innerHeight;    // カメラのアスペクト比更新
    camera.updateProjectionMatrix(); // カメラの投影行列を更新
    renderer.setSize(window.innerWidth, window.innerHeight); // レンダラーのサイズ更新
}

// 4.部屋全体構造の作成
function createRoomStructure() {
    const wallHeight = 2; 

    // 床の作成
    const floorGeometry = new THREE.PlaneGeometry(roomSize, roomSize); 
    const floorMaterial = new THREE.MeshStandardMaterial({ 
        color: 0xf8feff,
        side: THREE.DoubleSide
    });
    const floor = new THREE.Mesh(floorGeometry, floorMaterial);
    floor.rotation.x = Math.PI / 2;
    floor.position.y = 0; 
    floor.receiveShadow = true; 
    roomGroup.add(floor); 
    
    // 視覚的定規(GridHelper)の追加 (1m間隔)
    const gridHelper = new THREE.GridHelper(
        roomSize, roomSize, 0x444444, 0x888888        
    );
    gridHelper.position.y = 0.01; 
    roomGroup.add(gridHelper); 

    // 壁の半透明マテリアルを作成 
    const wallMaterial = new THREE.MeshStandardMaterial({ 
        color: 0xf0e68c, 
        transparent: true, 
        opacity: 0.3,      
        side: THREE.DoubleSide 
    });
    
    // 壁の作成ヘルパー
    function createWall(x, z, length, height, rotationY) {
        const wallGeometry = new THREE.BoxGeometry(length, height, 0.1); 
        const wall = new THREE.Mesh(wallGeometry, wallMaterial); 
        
        wall.position.y = height / 2;
        wall.position.x = x;
        wall.position.z = z;
        wall.rotation.y = rotationY;
        wall.receiveShadow = true;
        roomGroup.add(wall); 
    }
    
    // 4つの壁を作成
    createWall(0, -roomSize / 2, roomSize, wallHeight, 0); 
    createWall(0, roomSize / 2, roomSize, wallHeight, 0); 
    createWall(-roomSize / 2, 0, roomSize, wallHeight, Math.PI / 2); 
    createWall(roomSize / 2, 0, roomSize, wallHeight, Math.PI / 2);  
}

// 5.単純な家具(BoxGeometry)を追加する
function addFurniture(x, z, width, depth, height, color) {
    // BoxGeometry: 立方体/直方体の形状
    const geometry = new THREE.BoxGeometry(width, height, depth); 
    const material = new THREE.MeshStandardMaterial({ color: color });
    const mesh = new THREE.Mesh(geometry, material);
    
    mesh.position.set(x, height / 2, z); 
    mesh.castShadow = true; 
    mesh.receiveShadow = true;
    // 後で保存・元の位置に戻す時に一貫性を保つIDを設定しておく
    mesh.userData.draggable = true;
    mesh.userData.id = furniture.length; 
    mesh.userData.name = `furniture_${mesh.userData.id}`;

    roomGroup.add(mesh); // roomGroupに追加
    furniture.push(mesh); // 家具の位置状態を保管

    return mesh;
}

// 6.複合オブジェクト(椅子)を追加する。複数のMeshをTHREE.Groupにまとめます。
function addChair(x, z, color) {
    const CHAIR_W = 0.5;
    const CHAIR_D = 0.5;
    const SEAT_H = 0.05;
    const SEAT_Y = 0.5; // 座面高さ(床からの距離)
    const LEG_D = 0.05;
    const BACK_H = 0.4;
    
    const chairGroup = new THREE.Group();
    chairGroup.userData.draggable = true;
    chairGroup.userData.isComposite = true; // 複合オブジェクトのフラグ
    
    // 全ての子オブジェクトに共通の材質を適用
    const material = new THREE.MeshStandardMaterial({ color: color });

    // 1. 座面
    const seatGeometry = new THREE.BoxGeometry(CHAIR_W, SEAT_H, CHAIR_D);
    const seat = new THREE.Mesh(seatGeometry, material);
    // 座面の中心がY=SEAT_Yになるように配置
    seat.position.y = SEAT_Y - SEAT_H / 2;
    seat.castShadow = true;
    seat.receiveShadow = true;
    chairGroup.add(seat);

    // 2. 背もたれ
    const backGeometry = new THREE.BoxGeometry(CHAIR_W, BACK_H, 0.05); // 奥行き 5cm
    const back = new THREE.Mesh(backGeometry, material);
    // Y位置: 座面の上面 (SEAT_Y) + 背もたれ高さの半分 (BACK_H/2)
    // Z位置: 椅子の奥行き半分 (CHAIR_D/2) - 背もたれの奥行き半分 (0.05/2)
    back.position.set(0, SEAT_Y + BACK_H / 2, CHAIR_D / 2 - 0.05 / 2);
    back.castShadow = true;
    back.receiveShadow = true;
    chairGroup.add(back);

    // 3. 4本の脚
    const legH = SEAT_Y - SEAT_H; // 脚の高さは床から座面の下まで
    const legGeometry = new THREE.BoxGeometry(LEG_D, legH, LEG_D);
    
    function addLeg(lx, lz) {
        const leg = new THREE.Mesh(legGeometry, material);
        // X/Z位置: 椅子の幅/奥行きから、脚の奥行き/幅の半分を引いた位置
        // Y位置: 脚の高さの半分 (legH/2)
        leg.position.set(
            lx * (CHAIR_W / 2 - LEG_D / 2), 
            legH / 2, 
            lz * (CHAIR_D / 2 - LEG_D / 2)
        );
        leg.castShadow = true;
        leg.receiveShadow = true;
        chairGroup.add(leg);
    }
    
    addLeg(1, 1);   // 右奥
    addLeg(1, -1);  // 右前
    addLeg(-1, 1);  // 左奥
    addLeg(-1, -1); // 左前
    
    // Group自体の位置は、ピボット(原点)を床(Y=0)に設定
    chairGroup.position.set(x, 0, z); 
    
    chairGroup.userData.id = furniture.length; 
    chairGroup.userData.name = `chair_${chairGroup.userData.id}`;
    
    roomGroup.add(chairGroup);
    furniture.push(chairGroup); 
    
    return chairGroup;
}
//  7. 3Dモデルファイル(GLTF/GLB)をロードしシーンに追加する
function addLoadSculptedModel(url, x, y , z,rotationY = 0,scale = 1.0) {
    if (!gltfLoader) {
        console.error("GLTFLoader is not initialized.");
        return;
    }
    
    console.log(`Loading model from: ${url}`);
    
    gltfLoader.load(
        url,
        function (gltf) {
            const model = gltf.scene;
            
            // モデル全体を roomGroupの子として配置し、ドラッグ可能にする
            model.position.set(x, y, z); 
            model.rotation.y = rotationY; //初期回転角度を適用
            model.scale.set(scale, scale, scale); // スケールを適用
            // 既存の家具と同じプロパティを設定
            model.userData.draggable = true;
            model.userData.id = furniture.length; 
            model.userData.name = `sculpture_${model.userData.id}`;
            
            // 影の設定とマテリアルの更新
            model.traverse(function (child) {
                if (child.isMesh) {
                    child.castShadow = true;
                    child.receiveShadow = true;
                    // モデルのマテリアルがスポットライトに反応するように設定
                    if (Array.isArray(child.material)) {
                        child.material.forEach(m => m.needsUpdate = true);
                    } else if (child.material) {
                        child.material.needsUpdate = true;
                    }
                }
            });

            
            roomGroup.add(model); 
            furniture.push(model);
        },
        undefined, 
        // エラーハンドリング
        function (error) {
            console.error(`An error occurred while loading model: ${url}`, error);
        }
    );
}

// 8. --- マウスイベントハンドラー ---

function updateMouse(event) {
    const rect = renderer.domElement.getBoundingClientRect();
    mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
    mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
}

// 9. マウスボタンが押されたとき (ドラッグ/回転開始)
function onPointerDown(event) {
    updateMouse(event);
    raycaster.setFromCamera(mouse, camera);
    // Raycastingの対象をroomGroupの子要素に限定
    const intersects = raycaster.intersectObjects(roomGroup.children, true);

    if (intersects.length > 0) {
        let hitObject = intersects[0].object;
        
        // 複合オブジェクト、GLTFモデルの子オブジェクトがヒットした場合、親(Group/Scene)を選択
        while (hitObject.parent && !hitObject.userData.draggable && hitObject.parent !== roomGroup) {
            hitObject = hitObject.parent;
        }

        // 複合オブジェクト(THREE.Group)の一部である場合、親のGroupを選択する
        if (hitObject.parent.userData.isComposite) {
            selectedObject = hitObject.parent;
        } else if (hitObject.userData.draggable) { 
            // 単純なBoxGeometry(デスクや棚)が直接当たった場合
            selectedObject = hitObject;
        } else {
            // 当たったが、ドラッグ不可(例:床)の場合
            selectedObject = null;
            return;
        }

        // selectedObject の Y位置 (ピボットの位置) を取得
        const objectY = selectedObject.position.y; 

        dragPlane = plane; // Y=0平面
        // Shiftキーが押されていたら回転モード、そうでなければ移動モード
        if (event.shiftKey) { 
            isRotating = true;
            isDragging = false; 

            // リングの高さをオブジェクトのピボット (Y位置) に設定
            rotationGuide.position.set(selectedObject.position.x, objectY, selectedObject.position.z);
            rotationGuide.rotation.y = 0; 
            rotationGuide.visible = true;
            updateExtensionLines(selectedObject, rotationGuide);

        } else {
            isDragging = true;
            isRotating = false; 

            rotationGuide.visible = false;

            // 移動モードの場合のみオフセットを計算
            if(raycaster.ray.intersectPlane(dragPlane, offset)){
                isDragging = true;
            }else{
                isDragging = false; 
                selectedObject = null;
            }
        }
                
        controls.enabled = false;
    } else {
        // 何も選択されなかった場合は、選択を解除、ガイドを非表示
        selectedObject = null;
        isDragging = false;
        isRotating = false;
        rotationGuide.visible = false; 
        rotationGuide.children.find(c => c.userData.isExtension)?.clear();
        controls.enabled = true;
    }
}

// 10.マウスが移動したとき (ドラッグ中)
function onPointerMove(event) {
    if (!selectedObject) return;

    updateMouse(event);
    raycaster.setFromCamera(mouse, camera);

    const objectY = selectedObject.position.y; 

    if (isDragging) { // === 移動ロジック ===
        let intersection = new THREE.Vector3();
        if (raycaster.ray.intersectPlane(plane, intersection)) {
            // 家具: Y=0平面上での移動
            // 1. 現在の交点 (intersection) と前回の交点 (offset) の差分を計算
            const delta = intersection.clone().sub(offset); // delta = World_Move_Vector
            // 2. 選択オブジェクトの現在の位置 (ローカル) に差分を加える
            selectedObject.position.add(delta);
            // 3. Y位置を元の高さに固定
            selectedObject.position.y = objectY; 
            // 4. 次のフレームのために offset を現在の交点に更新
            offset.copy(intersection);

            if (rotationGuide.visible) {
                 rotationGuide.position.set(selectedObject.position.x, objectY, selectedObject.position.z);
            }
        }
    } else if (isRotating) { // === 回転ロジック (回転ガイドへの Raycastによる角度指定) ===
        const guideRing = rotationGuide.children.find(c => c.type === 'Mesh');

        if (guideRing) {
            const intersects = raycaster.intersectObject(guideRing, true);

            if (intersects.length > 0) {
                const intersectionPoint = intersects[0].point;
                const centerPoint = selectedObject.position.clone();
                const vector = intersectionPoint.clone().sub(centerPoint);
                
                let newAngleRad = Math.atan2(vector.x, vector.z); 
                
                const snappedAngleDeg = Math.round(THREE.MathUtils.radToDeg(newAngleRad));
                const finalAngleRad = THREE.MathUtils.degToRad(snappedAngleDeg);
                selectedObject.rotation.y = finalAngleRad;
                rotationGuide.position.copy(selectedObject.position);
                
                updateExtensionLines(selectedObject, rotationGuide);
            }
        }
    }
}

// 11.マウスボタンが離されたとき (終了)
function onPointerUp(event) {
    if (isDragging || isRotating) { 
        isDragging = false;
        isRotating = false;
        selectedObject = null;
        // ガイドを非表示にする
        rotationGuide.visible = false;
        rotationGuide.children.find(c => c.userData.isExtension)?.clear();
        // カメラ操作を再有効化
        controls.enabled = true;
    }
}

// 12.オブジェクトの現在の回転に合わせて引き出しガイド線を作成・更新する
function updateExtensionLines(object, guide) {
    const ringInnerRadius = 2.8; 
    const extensionLinesGroup = guide.children.find(c => c.userData.isExtension);
    if (!extensionLinesGroup) return;

    extensionLinesGroup.clear(); 

    const lineMaterial = new THREE.LineBasicMaterial({ color: 0xff0000, linewidth: 2 });
    
    const rotationMatrix = new THREE.Matrix4().makeRotationY(object.rotation.y);
    
    // 1. Z軸方向の線
    const startZ = new THREE.Vector3(0, 0, -ringInnerRadius);
    const endZ = new THREE.Vector3(0, 0, ringInnerRadius);
    startZ.applyMatrix4(rotationMatrix); 
    endZ.applyMatrix4(rotationMatrix); 
    const pointsZ = [];
    pointsZ.push(startZ);
    pointsZ.push(endZ);
    const geoZ = new THREE.BufferGeometry().setFromPoints(pointsZ);
    const lineZ = new THREE.Line(geoZ, lineMaterial);
    
    // 2. X軸方向の線 
    const startX = new THREE.Vector3(-ringInnerRadius, 0, 0);
    const endX = new THREE.Vector3(ringInnerRadius, 0, 0);
    startX.applyMatrix4(rotationMatrix); 
    endX.applyMatrix4(rotationMatrix); 
    const pointsX = [];
    pointsX.push(startX);
    pointsX.push(endX);
    const geoX = new THREE.BufferGeometry().setFromPoints(pointsX);
    const lineX = new THREE.Line(geoX, lineMaterial);

    extensionLinesGroup.add(lineZ);
    extensionLinesGroup.add(lineX);
}

// 13.回転ガイド(リングと目盛)を作成する 
function createRotationGuide() {
    const guideGroup = new THREE.Group();
    
    const innerRadius = 2.8; 
    const outerRadius = 3.3; 
    const segments = 360;    
    
    // 1. リング本体 (Raycastターゲット)
    const ringGeometry = new THREE.RingGeometry(innerRadius, outerRadius, segments, 1, 0, Math.PI * 2);
    const ringMaterial = new THREE.MeshBasicMaterial({ 
        color: 0xffff00, 
        side: THREE.DoubleSide,
        transparent: true,
        opacity: 0.8
    });
    const ring = new THREE.Mesh(ringGeometry, ringMaterial);
    ring.rotation.x = Math.PI / 2;
    ring.position.y = 0.05; 
    guideGroup.add(ring);

    // 2. 5度ごとの目盛線を作成
    const majorLineMaterial = new THREE.LineBasicMaterial({ color: 0x000000, linewidth: 3 }); 
    const minorLineMaterial = new THREE.LineBasicMaterial({ color: 0x000000, linewidth: 1 }); 
    
    const majorLineVertices = []; 
    const minorLineVertices = []; 
    
    for (let i = 0; i < 360; i += 5) { 
        const angle = THREE.MathUtils.degToRad(i);
        
        let lineLength;
        let isMajor = false;

        if (i % 90 === 0) { 
            lineLength = 0.4; 
            isMajor = true;
        } else if (i % 45 === 0) { 
            lineLength = 0.3;
        } else if (i % 15 === 0) { 
            lineLength = 0.2;
        } else { 
            lineLength = 0.1;
        }
        
        const currentOuterRadius = outerRadius + lineLength; 
        
        const targetVertices = isMajor ? majorLineVertices : minorLineVertices;

        targetVertices.push(Math.cos(angle) * innerRadius, 0.06, Math.sin(angle) * innerRadius); 
        targetVertices.push(Math.cos(angle) * currentOuterRadius, 0.06, Math.sin(angle) * currentOuterRadius);
    }
    
    // 細い線を追加
    const minorLineGeometry = new THREE.BufferGeometry();
    minorLineGeometry.setAttribute('position', new THREE.Float32BufferAttribute(minorLineVertices, 3));
    const minorLines = new THREE.LineSegments(minorLineGeometry, minorLineMaterial);
    guideGroup.add(minorLines);
    
    // 太い線を追加
    const majorLineGeometry = new THREE.BufferGeometry();
    majorLineGeometry.setAttribute('position', new THREE.Float32BufferAttribute(majorLineVertices, 3));
    const majorLines = new THREE.LineSegments(majorLineGeometry, majorLineMaterial);
    guideGroup.add(majorLines);


    // 3. 引き出し線用のグループを作成し追加
    const extensionLinesGroup = new THREE.Group();
    extensionLinesGroup.position.y = 0.07; 
    extensionLinesGroup.userData.isExtension = true;
    guideGroup.add(extensionLinesGroup); 

    guideGroup.userData.isGuide = true; 
    guideGroup.visible = false; 
    roomGroup.add(guideGroup); // roomGroupに追加
    
    return guideGroup;
}

// 実行
init();
animate();

UI設計においては、マウスドラッグによる操作と回転を組み合わせることで、ユーザーは意識することなく、より直感的にオブジェクトを操作できるようになり、利便性が向上します

VS Code実行結果ーオブジェクトの回転

スポットライトの設置・移動と位置情報の保存機能の追加

オブジェクトの設置と移動・回転がひととおりできましたので、最後にスポットライトを設置し、各オブジェクトの場所を保存できるようにします。
※保存は一時的でブラウザを終了すると初期化されます。続けて利用したい場合はファイルもしくはデータベースに情報を保存、呼び出す機能を別途追加する必要があります。
index.htmlは既に「保存」「戻す」ボタンを配置済みです。このボタンアクションの処理を追加します。

main.js 追加内容
  • // === 1-5. ライト (Light) の追加 ===
    • 既存の指向性ライトをスポットライト(THREE.SpotLight)に置き換えます。
    • THREE.SpotLight()で追加します。これで床に影が落ちるようになります。
    • THREE.SphereGeometryを使ってスポットライトの位置に球体を設置します。任意の位置から光源の効果を確認するためにドラッグ操作の対象とします。
    • スポットライトのターゲットは床の中央に固定します。
  • マウスイベント関数の修正
    onPointerMove、onPointerDownを改造します。ソースコードの変更箇所を参考にしてください。
  • 家具などのオブジェクトの状態を追跡し、localStorage を使って状態の保存と戻すを行うロジックを実装します。
    • getFurnitureState() :現在の家具、スポットライト、カメラの状態を収集する
    • setFurnitureState():保存された状態を家具、スポットライト、
    • saveState():現在の状態を localStorage に保存する.
    • loadState():localStorage から状態を読み込む
  • 保存/戻すボタンのイベントリスナーを追加します。
// main.js
// [main-007-spotlight] 完成版
// index.html の importmap の設定に基づき、ローカルファイルをインポート
import * as THREE from "three";
import { OrbitControls } from './jsm/controls/OrbitControls.js';
import { GLTFLoader } from './jsm/loaders/GLTFLoader.js';
// 必要な変数をグローバルスコープで定義
let scene, camera, renderer, controls;
let roomGroup; // 床、壁、家具をまとめるグループ
let gltfLoader; // GLTFローダー
const roomSize = 10; // 部屋のサイズ (10m x 10m)
const furniture = []; // 複数の家具オブジェクトを順序付けして保管
// --- ドラッグ&ドロップに必要な変数 ---
let raycaster;
let mouse;
let isDragging = false; // 移動中フラグ
let selectedObject = null;
let plane;
let offset = new THREE.Vector3();
// --- 回転に必要な変数 ---
let dragPlane;
let isRotating = false; 
let rotationGuide = null;
// --- ライト制御用の変数 ---
let spotLight;
let spotLightControlMesh; // オレンジ色の球体 (位置)
let spotLightTargetControlMesh; // 青色の球体 (ターゲット)
// 1. 初期化関数:3D世界の土台を作る
function init() {
    //=== 1-1. シーン (Scene) の作成 ===
    scene = new THREE.Scene();
    scene.background = new THREE.Color(0xf0f0f0);  // 背景色を薄いグレーに設定
    roomGroup = new THREE.Group();
    scene.add(roomGroup);
    // 座標軸ヘルパーを追加(X軸:赤、Y軸:緑、Z軸:青)
    const axesHelper = new THREE.AxesHelper(10); // サイズ10
    scene.add(axesHelper);

    // === 1-2. カメラ (Camera) の設定 ===
    const fov = 75; // 視野角 (Field of View)
    const aspect = window.innerWidth / window.innerHeight;  // アスペクト比
    const near = 0.1; // 描画開始距離
    const far = 1000; // 描画終了距離
    camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
    // カメラの初期位置を決定 (X, Y, Z)
    camera.position.set(10, 15, 15);
    camera.lookAt(0, 0, 0); // 原点(0, 0, 0)を見るように設定

    // === 1-3. レンダラー (Renderer) の作成 ===
    renderer = new THREE.WebGLRenderer({ antialias: true }); // アンチエイリアスで描画を滑らかに
    renderer.setSize(window.innerWidth, window.innerHeight); // 描画サイズをウィンドウサイズに合わせる
    
    renderer.outputEncoding = THREE.sRGBEncoding; // レンダリング品質改善
    renderer.shadowMap.enabled = true; // 影の描画を有効化
    // GLTFローダーの初期化
    gltfLoader = new GLTFLoader();

    // 生成された<canvas>要素をHTML<body>に追加
    document.body.appendChild(renderer.domElement);

    // === 1-4. カメラ操作 (OrbitControls) の設定 ===
    // インポートした OrbitControls を使って、マウスでカメラを動かせるようにする
    controls = new OrbitControls(camera, renderer.domElement);
    controls.enableDamping = true;  // 動きを滑らかにする
    controls.dampingFactor = 0.05;

    // === 1-5. ライト (Light) の追加 ===
    // 部屋全体を均等に照らす環境光
    const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); // 弱めの光
    scene.add(ambientLight);
    // 特定の方向から照らす指向性光(影を落とすために必要)
    //const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0);
    //directionalLight.position.set(20, 30, 10);
    //scene.add(directionalLight);
    // スポットライトの設定を更新(床にも影が落ちる)
    spotLight = new THREE.SpotLight(0xffffff, 5.0); 
    spotLight.position.set(0, 7, 0);
    spotLight.angle = Math.PI / 4;
    spotLight.penumbra = 0.2; 
    spotLight.decay = 0.5; 
    spotLight.distance = 20; 
    spotLight.castShadow = true;
    spotLight.shadow.mapSize.width = 1024;
    spotLight.shadow.mapSize.height = 1024;
    spotLight.shadow.camera.near = 0.1;
    spotLight.shadow.camera.far = 10; 
    scene.add(spotLight)
    const meshSize = 0.4; 
    const meshGeometry = new THREE.SphereGeometry(meshSize / 2, 16, 16);
    
    // 1. スポットライト位置制御メッシュ (オレンジ色の球体)
    const meshMaterial = new THREE.MeshBasicMaterial({ 
        color: 0xffa500,
        transparent: true,
        opacity: 0.8
    });
    spotLightControlMesh = new THREE.Mesh(meshGeometry, meshMaterial);
    spotLightControlMesh.position.copy(spotLight.position); 
    spotLightControlMesh.userData.draggable = true;
    spotLightControlMesh.userData.isLightControl = true; // ライト位置制御用フラグ
    spotLightControlMesh.userData.name = `light_control`;
    roomGroup.add(spotLightControlMesh); 
    
    // 2. スポットライトターゲット制御メッシュ (青色の球体)
    const targetMeshMaterial = new THREE.MeshBasicMaterial({ 
        color: 0x0000ff,
        transparent: true,
        opacity: 0.8
    });
    spotLightTargetControlMesh = new THREE.Mesh(meshGeometry, targetMeshMaterial); // ★サイズは統一
    spotLightTargetControlMesh.position.set(0, 0.05, 0); 
    spotLightTargetControlMesh.userData.draggable = true;
    spotLightTargetControlMesh.userData.isLightTargetControl = true; // ライトターゲット制御用フラグ
    spotLightTargetControlMesh.userData.name = `light_target_control`;
    roomGroup.add(spotLightTargetControlMesh); 
    
    // SpotLightのターゲットを制御メッシュのワールド位置に同期させる
    spotLight.target.position.set(0, 0.05, 0);
    scene.add(spotLight.target);

    // === 1-6 & 1-7. 部屋の構造(床と壁)とグリッド定規の作成 ===
    createRoomStructure();

    // === 1-8. 家具(プレースホルダー)の作成と配置 ===
    // 家具の配置例
    const deskHeight = 0.75;
    const chairHeight = 0.5;
    addFurniture(2, 2, 1, 3, 2.0, 0xa0522d); // 棚
    addFurniture(-3, -3, 3, 1.5, deskHeight, 0xdeb887); // デスク
    //addFurniture(-3, -1, 0.5, 0.5, chairHeight, 0x800000); // チェア
    addChair(-3, -1, 0x800000); // チェア

    // スカルプトモデル(龍)のロードと配置
    addLoadSculptedModel('./dragon-3d-model-2.glb',-3, 1.3,-2.8,Math.PI / 2,2.0);

    // === 1-9. ドラッグ&ドロップ機能の初期化 ===
    raycaster = new THREE.Raycaster();
    mouse = new THREE.Vector2();
    plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); 
    dragPlane = plane; 
    // === 1-10. 回転ガイドリングの初期化 ===
    rotationGuide = createRotationGuide(); 
    // イベントリスナー
    renderer.domElement.addEventListener('pointerdown', onPointerDown, false);
    renderer.domElement.addEventListener('pointermove', onPointerMove, false);
    renderer.domElement.addEventListener('pointerup', onPointerUp, false);
    
    window.addEventListener('resize', onWindowResize, false);
    // 保存/戻すボタンのイベントリスナー
    document.getElementById('saveButton').addEventListener('click', saveState);
    document.getElementById('loadButton').addEventListener('click', loadState);
}
// 2. 描画ループ関数:アニメーションの心臓部
function animate() {
    requestAnimationFrame(animate); // ブラウザの再描画タイミングに合わせてループを継続
    controls.update();// カメラ操作のダンピング効果を適用するために更新が必要
    // スポットライトのターゲット位置を制御メッシュのワールド位置に同期
    if (spotLightTargetControlMesh) {
        // roomGroupの子要素として動かしているので、そのワールド位置を取得
        spotLightTargetControlMesh.updateWorldMatrix(true, false);
        spotLight.target.position.setFromMatrixPosition(spotLightTargetControlMesh.matrixWorld);
    }
    renderer.render(scene, camera); // シーンをカメラの視点から描画 
}
// 3. ウィンドウリサイズ処理
function onWindowResize() {
    camera.aspect = window.innerWidth / window.innerHeight;    // カメラのアスペクト比更新
    camera.updateProjectionMatrix(); // カメラの投影行列を更新
    renderer.setSize(window.innerWidth, window.innerHeight); // レンダラーのサイズ更新
}
// 4.部屋全体構造の作成
function createRoomStructure() {
    const wallHeight = 2; 
    // 床の作成
    const floorGeometry = new THREE.PlaneGeometry(roomSize, roomSize); 
    const floorMaterial = new THREE.MeshStandardMaterial({ 
        color: 0xf8feff,
        side: THREE.DoubleSide
    });
    const floor = new THREE.Mesh(floorGeometry, floorMaterial);
    floor.rotation.x = Math.PI / 2;
    floor.position.y = 0; 
    floor.receiveShadow = true; 
    roomGroup.add(floor); 
    
    // 視覚的定規(GridHelper)の追加 (1m間隔)
    const gridHelper = new THREE.GridHelper(
        roomSize, roomSize, 0x444444, 0x888888        
    );
    gridHelper.position.y = 0.01; 
    roomGroup.add(gridHelper); 
    // 壁の半透明マテリアルを作成 
    const wallMaterial = new THREE.MeshStandardMaterial({ 
        color: 0xf0e68c, 
        transparent: true, 
        opacity: 0.3,      
        side: THREE.DoubleSide 
    });
    
    // 壁の作成ヘルパー
    function createWall(x, z, length, height, rotationY) {
        const wallGeometry = new THREE.BoxGeometry(length, height, 0.1); 
        const wall = new THREE.Mesh(wallGeometry, wallMaterial); 
        
        wall.position.y = height / 2;
        wall.position.x = x;
        wall.position.z = z;
        wall.rotation.y = rotationY;
        wall.receiveShadow = true;
        roomGroup.add(wall); 
    }
    
    // 4つの壁を作成
    createWall(0, -roomSize / 2, roomSize, wallHeight, 0); 
    createWall(0, roomSize / 2, roomSize, wallHeight, 0); 
    createWall(-roomSize / 2, 0, roomSize, wallHeight, Math.PI / 2); 
    createWall(roomSize / 2, 0, roomSize, wallHeight, Math.PI / 2);  
}
// 5.単純な家具(BoxGeometry)を追加する
function addFurniture(x, z, width, depth, height, color) {
    // BoxGeometry: 立方体/直方体の形状
    const geometry = new THREE.BoxGeometry(width, height, depth); 
    const material = new THREE.MeshStandardMaterial({ color: color });
    const mesh = new THREE.Mesh(geometry, material);
    
    mesh.position.set(x, height / 2, z);
    mesh.castShadow = true; 
    mesh.receiveShadow = true;
    // 後で保存・元の位置に戻す時に一貫性を保つIDを設定しておく
    mesh.userData.draggable = true;
    mesh.userData.id = furniture.length; 
    mesh.userData.name = `furniture_${mesh.userData.id}`;
    roomGroup.add(mesh); // roomGroupに追加
    furniture.push(mesh); // 家具の位置状態を保管

    return mesh;
}
// 6.複合オブジェクト(椅子)を追加する。複数のMeshをTHREE.Groupにまとめます。
function addChair(x, z, color) {
    const CHAIR_W = 0.5;
    const CHAIR_D = 0.5;
    const SEAT_H = 0.05;
    const SEAT_Y = 0.5; // 座面高さ(床からの距離)
    const LEG_D = 0.05;
    const BACK_H = 0.4;
    
    const chairGroup = new THREE.Group();
    chairGroup.userData.draggable = true;
    chairGroup.userData.isComposite = true; // 複合オブジェクトのフラグ
    
    // 全ての子オブジェクトに共通の材質を適用
    const material = new THREE.MeshStandardMaterial({ color: color });
    // 1. 座面
    const seatGeometry = new THREE.BoxGeometry(CHAIR_W, SEAT_H, CHAIR_D);
    const seat = new THREE.Mesh(seatGeometry, material);
    // 座面の中心がY=SEAT_Yになるように配置
    seat.position.y = SEAT_Y - SEAT_H / 2;
    seat.castShadow = true;
    seat.receiveShadow = true;
    chairGroup.add(seat);
    // 2. 背もたれ
    const backGeometry = new THREE.BoxGeometry(CHAIR_W, BACK_H, 0.05); // 奥行き 5cm
    const back = new THREE.Mesh(backGeometry, material);
    // Y位置: 座面の上面 (SEAT_Y) + 背もたれ高さの半分 (BACK_H/2)
    // Z位置: 椅子の奥行き半分 (CHAIR_D/2) - 背もたれの奥行き半分 (0.05/2)
    back.position.set(0, SEAT_Y + BACK_H / 2, CHAIR_D / 2 - 0.05 / 2);
    back.castShadow = true;
    back.receiveShadow = true;
    chairGroup.add(back);
    // 3. 4本の脚
    const legH = SEAT_Y - SEAT_H; // 脚の高さは床から座面の下まで
    const legGeometry = new THREE.BoxGeometry(LEG_D, legH, LEG_D);
    
    function addLeg(lx, lz) {
        const leg = new THREE.Mesh(legGeometry, material);
        // X/Z位置: 椅子の幅/奥行きから、脚の奥行き/幅の半分を引いた位置
        // Y位置: 脚の高さの半分 (legH/2)
        leg.position.set(
            lx * (CHAIR_W / 2 - LEG_D / 2), 
            legH / 2, 
            lz * (CHAIR_D / 2 - LEG_D / 2)
        );
        leg.castShadow = true;
        leg.receiveShadow = true;
        chairGroup.add(leg);
    }
    
    addLeg(1, 1);   // 右奥
    addLeg(1, -1);  // 右前
    addLeg(-1, 1);  // 左奥
    addLeg(-1, -1); // 左前
    
    // Group自体の位置は、ピボット(原点)を床(Y=0)に設定
    chairGroup.position.set(x, 0, z); 
    
    chairGroup.userData.id = furniture.length; 
    chairGroup.userData.name = `chair_${chairGroup.userData.id}`;
    
    roomGroup.add(chairGroup);
    furniture.push(chairGroup); 
    
    return chairGroup;
}
//  7. 3Dモデルファイル(GLTF/GLB)をロードしシーンに追加する
function addLoadSculptedModel(url, x, y , z,rotationY = 0,scale = 1.0) {
    if (!gltfLoader) {
        console.error("GLTFLoader is not initialized.");
        return;
    }
    
    console.log(`Loading model from: ${url}`);
    
    gltfLoader.load(
        url,
        function (gltf) {
            const model = gltf.scene;
            
            // モデル全体を roomGroupの子として配置し、ドラッグ可能にする
            model.position.set(x, y, z); 
            model.rotation.y = rotationY; //初期回転角度を適用
            model.scale.set(scale, scale, scale); // スケールを適用
            // 既存の家具と同じプロパティを設定
            model.userData.draggable = true;
            model.userData.id = furniture.length; 
            model.userData.name = `sculpture_${model.userData.id}`;
            
            // 影の設定とマテリアルの更新
            model.traverse(function (child) {
                if (child.isMesh) {
                    child.castShadow = true;
                    child.receiveShadow = true;
                    // モデルのマテリアルがスポットライトに反応するように設定
                    if (Array.isArray(child.material)) {
                        child.material.forEach(m => m.needsUpdate = true);
                    } else if (child.material) {
                        child.material.needsUpdate = true;
                    }
                }
            });
            
            roomGroup.add(model); 
            furniture.push(model);
        },
        undefined, 
        // エラーハンドリング
        function (error) {
            console.error(`An error occurred while loading model: ${url}`, error);
            //showCustomDialog(`3Dモデル「${url}」のロードに失敗しました。`, true);
        }
    );
}
// 8. --- マウスイベントハンドラー ---
function updateMouse(event) {
    const rect = renderer.domElement.getBoundingClientRect();
    mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
    mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
}
// 9. マウスボタンが押されたとき (ドラッグ/回転開始)
function onPointerDown(event) {
    updateMouse(event);
    raycaster.setFromCamera(mouse, camera);
    // Raycastingの対象をroomGroupの子要素に限定
    const intersects = raycaster.intersectObjects(roomGroup.children, true);
    if (intersects.length > 0) {
        let hitObject = intersects[0].object;
        // 複合オブジェクト、GLTFモデルの子オブジェクトがヒットした場合、親(Group/Scene)を選択
        while (hitObject.parent && !hitObject.userData.draggable && hitObject.parent !== roomGroup) {
            hitObject = hitObject.parent;
        }
        
        // 複合オブジェクト(THREE.Group)の一部である場合、親のGroupを選択する
        if (hitObject.parent.userData.isComposite) {
            selectedObject = hitObject.parent;
        } else if (hitObject.userData.draggable) { 
            // 単純なBoxGeometry(デスクや棚)が直接当たった場合
            selectedObject = hitObject;
        } else {
            // 当たったが、ドラッグ不可(例:床)の場合
            selectedObject = null;
            return;
        }
        // selectedObject の Y位置 (ピボットの位置) を取得
        const objectY = selectedObject.position.y; 
        // ドラッグ平面と操作モードを決定
        if (selectedObject.userData.isLightControl || selectedObject.userData.isLightTargetControl) {
            // ライト制御メッシュ (位置/ターゲット) の場合、カメラ視線に垂直な平面で移動(3Dドラッグ)
            dragPlane = new THREE.Plane();
            const worldPosition = new THREE.Vector3();
            selectedObject.getWorldPosition(worldPosition);
            
            // カメラの視線方向に垂直な平面の法線を設定
            camera.getWorldDirection(dragPlane.normal); 
            dragPlane.normal.negate(); 
            
            // 平面の定数 (ワールド座標での距離) を計算
            dragPlane.constant = -dragPlane.normal.dot(worldPosition);
            // ライト制御メッシュは常に移動モード
            isDragging = true;
            isRotating = false; 
            rotationGuide.visible = false;
            offset.set(0, 0, 0); 
            
        } else {
            dragPlane = plane; // Y=0平面
            // Shiftキーが押されていたら回転モード、そうでなければ移動モード
            if (event.shiftKey) { 
                isRotating = true;
                isDragging = false; 
                
                // リングの高さをオブジェクトのピボット (Y位置) に設定
                rotationGuide.position.set(selectedObject.position.x, objectY, selectedObject.position.z);
                rotationGuide.rotation.y = 0; 
                rotationGuide.visible = true;
                updateExtensionLines(selectedObject, rotationGuide);
            } else {
                isDragging = true;
                isRotating = false;

                rotationGuide.visible = false;
                // 移動モードの場合のみオフセットを計算
                if(raycaster.ray.intersectPlane(dragPlane, offset)){
        		isDragging = true;
                }else{
                        isDragging = false; 
                        selectedObject = null;
                }
            }
        }        
        controls.enabled = false;
    } else {
        // 何も選択されなかった場合は、選択を解除、ガイドを非表示
        selectedObject = null;
        isDragging = false;
        isRotating = false;
        rotationGuide.visible = false; 
        rotationGuide.children.find(c => c.userData.isExtension)?.clear();
        controls.enabled = true;
    }
}
// 10.マウスが移動したとき (ドラッグ中)
function onPointerMove(event) {
    
    if (!selectedObject) return;

    updateMouse(event);
    raycaster.setFromCamera(mouse, camera);
    
    const objectY = selectedObject.position.y; 
    
    if (isDragging) { // === 移動ロジック ===
        let intersection = new THREE.Vector3();
        if (raycaster.ray.intersectPlane(dragPlane, intersection)) {
            
            if (selectedObject.userData.isLightControl) {
                // ライト制御メッシュ (位置): 3D空間で自由に移動
                selectedObject.position.copy(intersection);
                spotLight.position.copy(selectedObject.position); // ライトの位置も更新
                
            } else if (selectedObject.userData.isLightTargetControl) {
                // ライトターゲットメッシュ (向き): 3D空間で自由に移動
                selectedObject.position.copy(intersection);
                // SpotLightのターゲット位置はanimate()で更新される
            } else {
                // 家具: Y=0平面上での移動
                // 1. 現在の交点 (intersection) と前回の交点 (offset) の差分を計算
                const delta = intersection.clone().sub(offset); // delta = World_Move_Vector
                // 2. 選択オブジェクトの現在の位置 (ローカル) に差分を加える
                selectedObject.position.add(delta);
                // 3. Y位置を元の高さに固定
                selectedObject.position.y = objectY; 
                // 4. 次のフレームのために offset を現在の交点に更新
                offset.copy(intersection);

                if (rotationGuide.visible) {
                     rotationGuide.position.set(selectedObject.position.x, objectY, selectedObject.position.z);
                }
            }
        }
    } else if (isRotating) { // === 回転ロジック (回転ガイドへの Raycastによる角度指定) ===
        const guideRing = rotationGuide.children.find(c => c.type === 'Mesh');

        if (guideRing) {
            const intersects = raycaster.intersectObject(guideRing, true);
            if (intersects.length > 0) {
                const intersectionPoint = intersects[0].point;
                const centerPoint = selectedObject.position.clone();
                const vector = intersectionPoint.clone().sub(centerPoint);
                
                let newAngleRad = Math.atan2(vector.x, vector.z); 
                
                const snappedAngleDeg = Math.round(THREE.MathUtils.radToDeg(newAngleRad));
                const finalAngleRad = THREE.MathUtils.degToRad(snappedAngleDeg);
                selectedObject.rotation.y = finalAngleRad;
                rotationGuide.position.copy(selectedObject.position);
                
                updateExtensionLines(selectedObject, rotationGuide);
            }
        }
    }
}
// 11.マウスボタンが離されたとき (終了)
function onPointerUp(event) {
    if (isDragging || isRotating) { 
        isDragging = false;
        isRotating = false;
        selectedObject = null;
        // ガイドを非表示にする
        rotationGuide.visible = false;
        rotationGuide.children.find(c => c.userData.isExtension)?.clear();
        // カメラ操作を再有効化
        controls.enabled = true;
    }
}
// 12.オブジェクトの現在の回転に合わせて引き出しガイド線を作成・更新する
function updateExtensionLines(object, guide) {
    const ringInnerRadius = 2.8; 
    const extensionLinesGroup = guide.children.find(c => c.userData.isExtension);
    if (!extensionLinesGroup) return;
    extensionLinesGroup.clear(); 
    const lineMaterial = new THREE.LineBasicMaterial({ color: 0xff0000, linewidth: 2 });
    
    const rotationMatrix = new THREE.Matrix4().makeRotationY(object.rotation.y);
    
    // 1. Z軸方向の線
    const startZ = new THREE.Vector3(0, 0, -ringInnerRadius);
    const endZ = new THREE.Vector3(0, 0, ringInnerRadius);
    startZ.applyMatrix4(rotationMatrix); 
    endZ.applyMatrix4(rotationMatrix); 
    const pointsZ = [];
    pointsZ.push(startZ);
    pointsZ.push(endZ);
    const geoZ = new THREE.BufferGeometry().setFromPoints(pointsZ);
    const lineZ = new THREE.Line(geoZ, lineMaterial);
    
    // 2. X軸方向の線 
    const startX = new THREE.Vector3(-ringInnerRadius, 0, 0);
    const endX = new THREE.Vector3(ringInnerRadius, 0, 0);
    startX.applyMatrix4(rotationMatrix); 
    endX.applyMatrix4(rotationMatrix); 
    const pointsX = [];
    pointsX.push(startX);
    pointsX.push(endX);
    const geoX = new THREE.BufferGeometry().setFromPoints(pointsX);
    const lineX = new THREE.Line(geoX, lineMaterial);
    extensionLinesGroup.add(lineZ);
    extensionLinesGroup.add(lineX);
}
// 13.回転ガイド(リングと目盛)を作成する 
function createRotationGuide() {
    const guideGroup = new THREE.Group();
    
    const innerRadius = 2.8; 
    const outerRadius = 3.3; 
    const segments = 360;    
    
    // 1. リング本体 (Raycastターゲット)
    const ringGeometry = new THREE.RingGeometry(innerRadius, outerRadius, segments, 1, 0, Math.PI * 2);
    const ringMaterial = new THREE.MeshBasicMaterial({ 
        color: 0xffff00, 
        side: THREE.DoubleSide,
        transparent: true,
        opacity: 0.8
    });
    const ring = new THREE.Mesh(ringGeometry, ringMaterial);
    ring.rotation.x = Math.PI / 2;
    ring.position.y = 0.05; 
    guideGroup.add(ring);
    // 2. 5度ごとの目盛線を作成
    const majorLineMaterial = new THREE.LineBasicMaterial({ color: 0x000000, linewidth: 3 }); 
    const minorLineMaterial = new THREE.LineBasicMaterial({ color: 0x000000, linewidth: 1 }); 
    
    const majorLineVertices = []; 
    const minorLineVertices = []; 
    
    for (let i = 0; i  {
        dialog.classList.add('hidden');
    }, 2000);
}
// --- 状態保存・読み込み関数 ---
// 15.現在の家具とカメラの状態を収集する
function getFurnitureState() {
    // スポットライトの位置をstateに追加
    const lightState = {
        id: spotLightControlMesh.userData.name,
        position: {
            x: spotLightControlMesh.position.x, // 制御メッシュのローカル位置を保存
            y: spotLightControlMesh.position.y,
            z: spotLightControlMesh.position.z
        }
    };
    
    // スポットライトのターゲット位置をstateに追加
    const targetState = {
        id: spotLightTargetControlMesh.userData.name,
        position: {
            x: spotLightTargetControlMesh.position.x, // 制御メッシュのローカル位置を保存
            y: spotLightTargetControlMesh.position.y,
            z: spotLightTargetControlMesh.position.z
        }
    };
    
    // furniture配列にはlightControlMesh/targetControlMeshは含まれていないため、別途収集
    const furnitureState = furniture.map(mesh => ({
        id: mesh.userData.id,
        position: {
            // 家具はroomGroupの子要素なので、ローカル座標を保存
            x: mesh.position.x,
            y: mesh.position.y,
            z: mesh.position.z
        },
        rotation: {
            y: mesh.rotation.y
        }
    }));
    
    return [lightState, targetState, ...furnitureState]; // ターゲットの状態を追加
}
// 16.保存された状態を家具とカメラに適用する
function setFurnitureState(state) {
    if (!state || !state.furniture || state.furniture.length === 0) {
        console.warn("No furniture state found to load.");
        return;
    }
    state.furniture.forEach(savedItem => {
        // IDでライトの位置を検索
        if (savedItem.id === spotLightControlMesh.userData.name) {
            spotLightControlMesh.position.set(savedItem.position.x, savedItem.position.y, savedItem.position.z);
            spotLight.position.copy(spotLightControlMesh.position); // ライト自体も更新
            return;
        }
        
        // IDでライトのターゲットを検索
        if (savedItem.id === spotLightTargetControlMesh.userData.name) {
            spotLightTargetControlMesh.position.set(savedItem.position.x, savedItem.position.y, savedItem.position.z);
            // SpotLightのtargetはanimate()で更新される
            return;
        }
        
        // IDで家具を検索
        const mesh = furniture.find(f => f.userData.id === savedItem.id);
        
        if (mesh) {
            // 位置と回転を適用
            mesh.position.set(savedItem.position.x, savedItem.position.y, savedItem.position.z);
            mesh.rotation.y = savedItem.rotation.y;
        }
    });
    // カメラ位置と注視点を適用
    if (state.camera && state.controlsTarget) {
        camera.position.set(state.camera.x, state.camera.y, state.camera.z);
        controls.target.set(state.controlsTarget.x, state.controlsTarget.y, state.controlsTarget.z);
        controls.update();
    }
}
// 17.現在の状態を localStorage に保存する
function saveState() {
    const state = {
        furniture: getFurnitureState(),
        camera: {
            x: camera.position.x,
            y: camera.position.y,
            z: camera.position.z
        },
        controlsTarget: {
            x: controls.target.x,
            y: controls.target.y,
            z: controls.target.z
        }
    };
    
    try {
        localStorage.setItem('threejsLayoutState', JSON.stringify(state));
        console.log('Layout state saved successfully!');
        // 成功メッセージをカスタムダイアログで表示
        showCustomDialog('現在の位置を保存しました。'); 
    } catch (e) {
        console.error('Failed to save state to localStorage:', e);
        showCustomDialog('レイアウトの保存に失敗しました。', true);
    }
}
// 18.localStorage から状態を読み込む
function loadState() {
    try {
        const savedState = localStorage.getItem('threejsLayoutState');
        if (savedState) {
            const state = JSON.parse(savedState);
            setFurnitureState(state);
            console.log('Layout state loaded successfully!');
        } else {
            console.log('No saved state found.');
            showCustomDialog('保存されたレイアウトは見つかりませんでした。', true); 
        }
    } catch (e) {
        console.error('Failed to load state from localStorage:', e);
        showCustomDialog('レイアウトの読み込みに失敗しました。', true);
    }
}

// 実行
init();
animate();

VS Code実行結果ースポットライトの表示

オレンジ色の球体をつまんで動かしてみてください光源が移動するに伴って影の位置も変化します。床にだけではなく壁、家具類にも影が落ちます。
保存機能は一時的にローカルのPC上に保存されます。(ブラウザを終了すると情報は消えます)

まとめ

本記事を通じて、私たちはWebブラウザ上で動作する3Dレイアウトシミュレーターの基礎を構築しました。

  1. Three.jsとES Modulesを使用して、モダンで安定した開発環境を確立しました。
  2. シーン、カメラ、レンダラーを設定し、3D空間の土台を作成しました。
  3. ライトと幾何学オブジェクトを用いて、部屋の構造(床と壁)および家具のプレースホルダーを作成しました。
  4. 3Dモデルツール作成のモデルファイルを読み込んでシーンに配置することができました。
  5. 最も重要な機能であるRaycasterを用いたドラッグ&ドロップ機能により、家具をマウス操作で自由に配置・移動・回転できるレイアウトツールを実現しました。
  6. 簡易的なオブジェクトの位置情報を保存、呼び出すことができました。

これらの基本構造があれば、あなたのレイアウトツールはすでに実用の一歩を踏み出しています。

Three.jsは今回使用した基本的な関数以外に多数の有用な関数が用意されています。Three.jsの公式サイトを参考にしてください。

プログラミングの学習方法について

プログラミングの学習方法は時代とともに大きく変化しています。

  • 従来の学習方法:古くは技術書専門雑誌から知識を得ていました。その後、インターネットの普及に伴い、技術紹介ブログ(例:Qiita)オープンソースのコード(例:GitHub)を参考にしながら独学でスキルを習得するのが主流となりました。
  • 最新の学習方法(生成AIの活用):現在ではそれに加えて、生成AI(例:ChatGPT、GitHub Copilot)に実現したい機能をインプットし、コードを生成させながらその仕組みを理解する、という新しい学習スタイルも確立されてきています。さらに、AIがアプリケーション全体を作成する「AI駆動開発」の時代に突入しています。

AI時代に求められるスキル

しかし、AIがコードを生成してくれるからといって、プログラミングスキルが不要になったわけではありません。

生成されたコードが意図通りに動作するかを検証し、バグを修正したり、より効率的な形に改善したりするためには、そのコードを正しく理解するスキルが不可欠です。

まずは一つの言語を極める

プログラミング言語の基本的な考え方(ロジックや構文)は、言語が変わっても共通しています。
そのため、一つの言語に深く精通しておけば、他の言語を学ぶ際にも同様の考え方でスムーズに習得が可能です。

これから学習を始める方には、汎用性が高く、AI分野でも広く使われているPythonなどの言語から習得することをおすすめします。まずは「これなら完璧に使える」という言語を一つ身につけましょう。

今回のソースコード一式は以下にアップしておきます。
https://github.com/picolix/Three.js-Room-Layout-Editor/archive/refs/heads/main.zip
各機能段階でのmain.jsも同梱していますのでソースコード内のコメントをみながら理解を含めてください。

あなたの作品を公開してみましょう

完成した3Dレイアウトツールを、友人や同僚、採用担当者に見せたいと思いませんか?

  • 無料ではじめるならXREA
    開発したファイルをそのままアップロードするだけで、すぐに公開可能です。
  • 本格運用ならコアサーバー
    月額390円~で500GBの大容量
    ドメイン永久無料:サーバーとドメイン同時申し込みで、ドメイン費用が永久無料
    30日間無料お試し:リスクゼロで性能を体験できる

あなたの3D作品が、次のキャリアへの扉を開くかもしれません!

▽キャンペーン開催中!
コアサーバーでは、V2プランとドメインの同時申し込みで
ドメインが実質0(年間最大3,882円お得)になるサーバーセット割特典
を展開中です。
是非、お得なこの機会にご利用ください!
最新のキャンペーンは
こちらから

超高速化を実現するレンタルサーバー CORESERVER

ユーザーノートの記事は、弊社サービスをご利用のお客様に執筆いただいております。

執筆者:苗場 翔様

医療メーカーで新素材研究開発後、電機メーカーで制御器系システム開発を経てIT系マルチエンジニアをしています。またデザイン思考を実践し、アート思考などのいろんな思考方法に興味があります。

Posted by admin-dev


おすすめ関連記事

service

Value Domain
ドメイン取得&レンタルサーバーなら
Value Domain
ドメイン登録実績600万件を誇るドメイン取得・管理サービスと、高速・高機能・高品質なレンタルサーバーや、SSL証明書などを提供するドメイン・ホスティング総合サービスです。
目次へ目次へ