Three.jsとCSGライブラリによる3Dメッシュの切断とジオメトリ補正

外部モデルのCSG処理における課題と対策

3Dデータのパイプラインにおいて、Constructive Solid Geometry(CSG)演算は複雑な形状の分割や結合に不可欠ですが、GLBやSTL形式の外部メッシュをそのまま処理対象にすると、ジオメトリ構造の不一致によって演算が失敗するケースが頻発します。特に非索引化(non-indexed)のバッファ属性や、頂点配列に混入した非数値データ(NaN/Infinity)は、BVH構築段階でライブラリ内部のエラーを引き起こします。安定した切断ワークフローを実現するには、読み込み直後のジオメトリに対して厳格な正規化と属性のクリーンアップを行う工程が必須となります。

ジオメトリの正規化と無効値のフィルタリング

CSG演算器に渡す前に、頂点属性の再構成と座標の検証を行う関数を実装します。この処理は、レンダリングの破綻を防ぐと同時に、演算結果のメッシュが物理演算やエクスポート処理に適した状態であることを保証します。

function prepareCSGInput(rawGeom) {
    const normalized = new THREE.BufferGeometry();
    const posAttr = rawGeom.getAttribute('position');
    const normAttr = rawGeom.getAttribute('normal');

    // 配列のコピーと非数値の置換
    const cleanPositions = new Float32Array(posAttr.array.length);
    for (let i = 0; i < posAttr.array.length; i++) {
        cleanPositions[i] = Number.isFinite(posAttr.array[i]) ? posAttr.array[i] : 0;
    }

    normalized.setAttribute('position', new THREE.BufferAttribute(cleanPositions, 3));
    if (normAttr) {
        normalized.setAttribute('normal', new THREE.BufferAttribute(normAttr.array, 3));
    }

    // グループの初期化と単一プリミティブへの統合
    normalized.clearGroups();
    normalized.addGroup(0, normalized.attributes.position.count, 0);

    // 法線と境界情報の再計算
    normalized.computeVertexNormals();
    normalized.computeBoundingBox();
    return normalized;
}

モデル読み込みと分割パイプライン

GLTFおよびSTLローダーは返却されるオブジェクトの階層構造が異なるため、抽象化関数で統一します。また、CSG演算はローカル座標系ではなくワールド座標系で行う必要があるため、親オブジェクトの変換行列を確実に適用します。

import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { Brush, Evaluator, INTERSECTION } from "three-bvh-csg";

// シーン構成
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a1a);

const camera = new THREE.PerspectiveCamera(55, innerWidth / innerHeight, 0.1, 500);
camera.position.set(4, 5, 6);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);

const orbit = new OrbitControls(camera, renderer.domElement);
orbit.enableDamping = true;

// ライティング
scene.add(new THREE.HemisphereLight(0x88ccff, 0x444466, 0.6));
const dirLight = new THREE.DirectionalLight(0xffffff, 1.2);
dirLight.position.set(5, 8, 5);
dirLight.castShadow = true;
scene.add(dirLight);

const csgOp = new Evaluator();

// 読み込みと切断処理の本体
const loader = new GLTFLoader();
loader.load('target_model.glb', (gltf) => {
    const rootObject = gltf.scene;
    scene.add(rootObject);
    rootObject.updateMatrixWorld(true);

    // 全体のアライメントボックス取得
    const globalBounds = new THREE.Box3().setFromObject(rootObject);
    const splitHeight = globalBounds.getCenter(new THREE.Vector3()).y;

    // 切断用ボリュームの定義(大きな値で半空間を模倣)
    const halfSpan = 2000;
    const upperCutter = new Brush(new THREE.BoxGeometry(halfSpan, halfSpan, halfSpan));
    upperCutter.position.set(0, splitHeight + halfSpan / 2, 0);

    const lowerCutter = new Brush(new THREE.BoxGeometry(halfSpan, halfSpan, halfSpan));
    lowerCutter.position.set(0, splitHeight - halfSpan / 2, 0);

    const upperResultGroup = new THREE.Group();
    const lowerResultGroup = new THREE.Group();

    // メッシュ単位の処理
    rootObject.traverse((child) => {
        if (!child.isMesh) return;

        // ワールド変換適用 & 属性クリーンアップ
        const cleanedGeo = prepareCSGInput(child.geometry);
        if (!cleanedGeo) return;

        const srcBrush = new Brush(cleanedGeo);

        // 上半分の抽出
        const topPart = csgOp.evaluate(srcBrush, upperCutter, INTERSECTION);
        if (topPart?.geometry?.attributes?.position) {
            const topMesh = new THREE.Mesh(topPart.geometry, child.material.clone());
            topMesh.castShadow = true;
            upperResultGroup.add(topMesh);
        }

        // 下半分の抽出
        const bottomPart = csgOp.evaluate(srcBrush, lowerCutter, INTERSECTION);
        if (bottomPart?.geometry?.attributes?.position) {
            const botMesh = new THREE.Mesh(bottomPart.geometry, child.material.clone());
            botMesh.position.x += globalBounds.getSize(new THREE.Vector3()).x * 1.5;
            botMesh.castShadow = true;
            lowerResultGroup.add(botMesh);
        }
    });

    scene.add(upperResultGroup);
    scene.add(lowerResultGroup);
    rootObject.visible = false;
    orbit.update();
}, undefined, (err) => console.error("Assets load error:", err));

// レンダリングループ
function renderLoop() {
    requestAnimationFrame(renderLoop);
    orbit.update();
    renderer.render(scene, camera);
}
renderLoop();

window.addEventListener("resize", () => {
    camera.aspect = innerWidth / innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(innerWidth, innerHeight);
});

演算手法の選択と技術的考察

従来の実装では`SUBTRACTION`演算が用いられることが多かったですが、この手法は内部のトポロジー依存性が強く、面が重なったり法線が反転しているメッシュに対して不安定になりやすい傾向があります。代わりに、巨大な空間ボリュームとの`INTERSECTION`(論理積)を採用するアプローチは、計算が決定論的になりやすく、境界面の生成失敗率を大幅に低下させます。

さらに、`applyMatrix4`によるワールド座標系への変換は、CSG演算が親オブジェクトのスケールや回転を正しく認識するために不可欠です。索引化されていないBufferGeometryは、BVHツリーの構築時にインデックスバッファの欠落エラーを引き起こすため、読み込み直後のジオメトリに対しては必ず`index`属性の生成または頂点マージ処理を挟む必要があります。頂点配列内の非数値データを事前フィルタリングすることで、レンダリングパイプラインのクラッシュを防ぎ、3Dプリント用データや物理シミュレーションへの移行をスムーズに実行できます。

タグ: Three.js three-bvh-csg constructive-solid-geometry WebGL 3d-computing

8月13日 16:25 投稿