😎

iPadでスキャンした3DデータをThree.jsで表示してみた

に公開

はじめに

弊社NCDCには2日間のハッカソンがあり、私は「iPadのLidarスキャナで撮ったデータをThree.jsで表示する」というテーマに挑戦してみました。

本記事では、ハッカソンで制作した成果物の紹介と、制作過程で得た知見を共有したいと思います。

目的

このハッカソンで特に焦点を当てたのは次の 2 点です。

  1. LiDAR スキャンで得た生データは、どれだけシンプルな手順で Web(Three.js)上に再現・閲覧可能にできるのかを検証する。
  2. 「敷居が高い」と感じていた Three.js の実装部分を、どこまで AIに委ねて開発速度を上げられるか探る。

実装した機能

モデルをweb上にアップロードし、まずは3Dモデルに関する基本的な操作が行えるアプリを目指しました。

  • GLBアップロード(LiDARスキャンデータ対応)
  • モデルの複数配置
  • モデルの位置 / 回転編集
  • 2点間距離の計測(定規ツール)

技術スタック

  • フロントエンド: React / TypeScript / Three.js(@react-three/fiber, @react-three/drei)
  • バックエンド & ストレージ: Supabase(Postgres, Storage)

3Dデータをスキャンしてみる

https://apps.apple.com/jp/app/scaniverse-3d-scanner/id1541433223

scaniverseを使用して、ハッカソン会場である会議室の一部をLidarスキャンし、ファイル形式はGLBでファイルを保存しました。

webにアップロード→配置

webへのアップロードはエクスポートしたglbファイルをsupabaseのストレージに上げるだけのものなので割愛させていただきます。

配置は、対応している拡張子のloaderが存在していれば
useLoader に URL を渡すだけで非同期に GLTF を取得できます。
最低限の表示コンポーネントは次のようになります:

// 単純な GLB ローダ
const Model = ({ url }: { url: string }) => {
  const { scene } = useLoader(GLTFLoader, url);
  return <primitive object={scene} />;
};

複数表示する際もReactをリストで作成するようにmapでループするだけです。

// 複数モデルを配列
const models = [
  { url: 'room.glb', position: [0, 0, 0]},
  { url: 'tree.glb', position: [2, 0, 1]},
  { url: 'chair.glb', position: [-1, 0, -2]},
];
const MultiModels: React.FC = () => {
  return (
    <>
      {models.map((m, i) => (
        <group key={i} position={m.position}>
          <Model url={m.url} />
        </group>
      ))}
    </>
  );
};

@react-three/fiber を使うと Three.js を JSX で書けるようになり、位置や回転などを React の state で管理できます。普段の React コンポーネント感覚で 3D を組み立てられるのが利点です。

スキャンした会議室のデータとフリー素材の木を配置してみました。

モデル位置・角度変更機能

位置と回転は React の state をそのまま position / rotation に渡すだけで実現できました。回転は Three.js がラジアンを期待するため度数法→ラジアン変換を挟みます。

// 位置と回転(度→ラジアン変換)
const EditableModel = ({ url }: { url: string }) => {
  const [p, setP] = useState({ x: 0, y: 0, z: 0 });
  const [r, setR] = useState({ x: 0, y: 0, z: 0 }); // deg
  const { scene } = useLoader(GLTFLoader, url);
  const rad = (d: number) => d * Math.PI / 180;
  return (
    <primitive
      object={scene}
      position={[p.x, p.y, p.z]}
      rotation={[rad(r.x), rad(r.y), rad(r.z)]}
    />
  );
};

定規機能

左クリックで2点を指定するとその距離(m)をライン・球・数値で表示します。3点目クリックで自動リセットして再計測でき、グリッド線などの補助線・透明メッシュは除外して誤クリックを減らしています。

クリック座標を取得して、Three.js の Raycaster[1]で最初のヒット点を取り、2点揃った時に distanceTo で距離を求め描画しています。
表示部分は動的にラインの頂点配列を組み立て、端点を小さな球で可視化し距離値は Html でオーバーレイ表示しています。

実装コード
rulerProvider.tsx
import React, { useState, useCallback } from 'react';
import * as THREE from 'three';
import { RulerContext } from './RulerContext';

// 2点の計測状態を保持するコンテキストプロバイダー
export const RulerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const [active, setActive] = useState(false);
  const [points, setPoints] = useState<THREE.Vector3[]>([]);
  const [distance, setDistance] = useState<number | null>(null);

  const toggle = useCallback(() => {
    setActive(a => !a);
    setPoints([]);
    setDistance(null);
  }, []);

  // 計測点を追加(2点揃ったら距離計算。3点目でリセットして新規計測開始)
  const addPoint = useCallback((p: THREE.Vector3) => {
    setPoints(prev => {
      if (prev.length >= 2) {
        // 3点目クリックで新しい計測を開始
        setDistance(null);
        return [p.clone()];
      }
      const next = [...prev, p.clone()];
      if (next.length === 2) {
        setDistance(next[0].distanceTo(next[1]));
      }
      return next;
    });
  }, []);

  const reset = useCallback(() => {
    setPoints([]);
    setDistance(null);
  }, []);

  return (
    <RulerContext.Provider value={{ active, points, distance, toggle, addPoint, reset }}>
      {children}
    </RulerContext.Provider>
  );
};

export default RulerProvider;
useRuler.tsx
import { useContext } from 'react';
import { RulerContext } from './RulerContext';

// RulerContext 用カスタムフック
export const useRuler = () => {
  const ctx = useContext(RulerContext);
  if (!ctx) throw new Error('useRuler must be used within RulerProvider');
  return ctx;
};
rulerTool.tsx
import React, { useRef, useCallback, useEffect } from 'react';
import * as THREE from 'three';
import { useThree } from '@react-three/fiber';
import { Html } from '@react-three/drei';
import { useRuler } from './useRuler';

// クリックで点を追加しライン/距離表示する Three.js 上の可視化コンポーネント
export const RulerTool: React.FC = () => {
    const { active, points, distance, addPoint } = useRuler();
    const raycaster = useRef(new THREE.Raycaster());
    const pointer = useRef(new THREE.Vector2());
    const tmp = useRef(new THREE.Vector3());
    const { gl, camera, scene } = useThree();

    const handleDomPointerDown = useCallback((ev: PointerEvent) => {
    // クリック位置を NDC に変換 → Raycast → 最初のヒット座標を計測点として addPoint
        if (!active) return;
        if (ev.button !== 0) return;
        const rect = gl.domElement.getBoundingClientRect();
        pointer.current.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
        pointer.current.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
        raycaster.current.setFromCamera(pointer.current, camera);
        const intersects = raycaster.current.intersectObjects(
            scene.children.filter(obj => {
                if (obj instanceof THREE.GridHelper) return false;
                if (obj instanceof THREE.AxesHelper) return false;
                if (obj instanceof THREE.Line) return false;
                if (obj instanceof THREE.Mesh) {
                    const mat = obj.material;
                    interface MaybeTransparent { transparent?: boolean; opacity?: number; }
                    //透明メッシュ判定
                    const isInvisible = (material: THREE.Material): boolean => {
                        const mt = material as THREE.Material & MaybeTransparent;
                        return !!mt.transparent && typeof mt.opacity === 'number' && mt.opacity === 0;
                    };
                    if (Array.isArray(mat)) {
                        if (mat.every(m => isInvisible(m))) return false;
                    } else if (mat && isInvisible(mat)) {
                        return false;
                    }
                }
                return true;
            }),
            true
        );
        if (intersects.length === 0) return;
        tmp.current.copy(intersects[0].point);
        addPoint(tmp.current);
    }, [active, camera, gl, scene, addPoint]);

    useEffect(() => {
        const el = gl.domElement;
        el.addEventListener('pointerdown', handleDomPointerDown);
        return () => {
            el.removeEventListener('pointerdown', handleDomPointerDown);
        };
    }, [handleDomPointerDown, gl]);

    return (
        <group>
            {/* ライン */}
            {active && points.length === 2 && (
                <line>
                    <bufferGeometry>
                        <bufferAttribute
                            attach="attributes-position"
                            count={2}
                            array={new Float32Array([
                                points[0].x, points[0].y, points[0].z,
                                points[1].x, points[1].y, points[1].z,
                            ])}
                            itemSize={3}
                        />
                    </bufferGeometry>
                    <lineBasicMaterial color="yellow" />
                </line>
            )}
            {active && points.map((p, i) => (
                <mesh key={i} position={p}>
                    <sphereGeometry args={[0.035, 16, 16]} />
                    <meshStandardMaterial color={i === 0 ? 'cyan' : 'magenta'} />
                </mesh>
            ))}
            {active && distance !== null && points.length === 2 && (
                <Html
                    position={[points[0].x, points[0].y + 0.12, points[0].z]}
                    style={{
                        background: 'rgba(0,0,0,0.6)',
                        padding: '4px 6px',
                        borderRadius: 4,
                        fontSize: 12,
                        color: '#fff',
                        whiteSpace: 'nowrap'
                    }}
                >
                    {distance.toFixed(3)} m
                </Html>
            )}
        </group>
    );
};

export default RulerTool;

LiDARスキャナでスキャンした物体は実際の距離情報を保持しているので、スクリーンの縦幅はだいたい1.5mだということがわかりました。(実物を図り忘れたので検証はできなかったですが...)

目的の振り返り

1.LiDAR スキャンで得た生データは、どれだけシンプルな手順で Web(Three.js)上に再現・閲覧可能にできるのかを検証する。

スキャン→表示までScaniverseからGLBをそのままエクスポート→Supabase にアップロード→Three.jsのGLTFLoader で読み込み→<primitive /> で配置、という短い手順で完了し、中間変換や追加アセット管理などの障壁は実質ゼロでした
さらに@react-three/fiber によりThree.js を React コンポーネントとして扱え、位置・回転を普段どおりstate管理できた点はReactエンジニアとして非常に助かりました。

2.「敷居が高い」と感じていた Three.js の実装部分を、どこまで AIに委ねて開発速度を上げられるか探る。

開発では GitHub Copilot のエージェント機能をフル活用し、主要機能の約8 割は生成されたコードをベースにしました。自分が行ったのは軽微なリファクタリング・UI 調整・命名整理が中心でした。ハッカソン全体の作業時間は約12 時間でしたが、バックエンド接続周りでのしょうもないつまづきなどを除けばコア機能到達は体感 8~9 時間程度まで圧縮できた手応えがあります。

おまけ: ピン機能とシーン保存機能


ハッカソン後にピンを立てる機能とシーンの配置情報を保存する機能を追加してみました。
ピンは定規機能で使った Raycaster のヒット座標を使い回してクリック地点にマーカーを置くだけ、配置保存は各モデルのワールド座標/回転を JSON 化してそのまま DB に書き込むシンプルな構成です。
こちらは2~3時間で実装できました。

クレジット

  • LiDAR スキャンアプリ: Scaniverse
  • コード参考: three.js examples GLTFLoader snippet(MIT)一部抜粋
  • ケヤキ樹木モデル/ ©ホロラボ
脚注
  1. カメラから画面上の任意の点へ1本のレイを飛ばし、衝突したオブジェクト群を距離順で返すユーティリティ ↩︎

NCDCエンジニアブログ

Discussion