[Astro] #110 Point Cloud Viewer v5 — ボックス選択・断面ポリライン・Octree LOD・座標系変換

[Astro] #110 Point Cloud Viewer v5 — ボックス選択・断面ポリライン・Octree LOD・座標系変換

概要

前回[#109]でIntensity/Classification表示とLAS/PCDエクスポートを追加してPoint Cloud Viewerの基本機能が完成した。今回のv5では「閲覧」から「編集」へ踏み込む4機能を一気に追加する。

v5 新機能

機能概要
ボックス選択+削除矩形ドラッグで範囲選択→不要な点群を削除
断面ポリラインスライス断面のプロファイル生成+DXFエクスポート
Octree LOD八分木による距離ベースレベルオブディテール
座標系選択proj4によるCRS変換(JGD2011全19系/UTM/WGS84)

スクリーンショット

ボックス選択+削除

Point Cloud Viewer — ボックス選択 Point Cloud Viewer — ボックス選択 Point Cloud Viewer — ボックス選択

Octree LOD

Point Cloud Viewer — Octree LOD

座標系選択

Point Cloud Viewer — 座標系選択

動画 (GIF)

[Astro] #110 Point Cloud Viewer v5 — ボックス選択・断面ポリライン・Octree LOD・座標系変換

ボックス選択+削除

仕組み

TOOLセクションに「Box Select」モードを追加。マウスドラッグで2D矩形を描き、矩形内に投影される3D点群を選択する。

選択の核心はNDC(Normalized Device Coordinates)投影。全点をカメラの投影行列でスクリーン座標に変換し、矩形内に収まるかを判定する。

// カメラの投影行列(projection × view)
const projMatrix = new THREE.Matrix4().multiplyMatrices(
  camera.projectionMatrix,
  camera.matrixWorldInverse
);
const v = new THREE.Vector4();

for (let i = 0; i < count; i++) {
  v.set(positions.getX(i), positions.getY(i), positions.getZ(i), 1);
  v.applyMatrix4(projMatrix);

  // カメラの背面にある点はスキップ
  if (v.w <= 0) continue;

  // NDC座標(-1〜1)に変換
  const nx = v.x / v.w;
  const ny = v.y / v.w;

  if (nx >= ndcX1 && nx <= ndcX2 && ny >= ndcY1 && ny <= ndcY2) {
    selected.add(i);
  }
}

200万点の全点投影でも実用的な速度で動く。THREE.Vector4と行列乗算1回で済むため、Raycasterより高速。

削除

選択した点はマゼンタ(#ff0066)でハイライト表示される。「Delete Selected」で選択点をジオメトリから除外。position/color/intensity/classificationの全属性を保持したまま新しいBufferGeometryを構築する。

for (let i = 0; i < count; i++) {
  if (selectedIndices.has(i)) continue;
  newPos.push(positions.getX(i), positions.getY(i), positions.getZ(i));
  if (hasColor) {
    newCol.push(
      geo.attributes.color.getX(i),
      geo.attributes.color.getY(i),
      geo.attributes.color.getZ(i)
    );
  }
  if (hasIntensity) newInt.push(geo.attributes.intensity.getX(i));
  if (hasClassification) newCls.push(geo.attributes.classification.getX(i));
}

削除後のデータはそのままエクスポート可能。SOR(統計的外れ値除去)が自動ノイズ除去なら、ボックス選択は手動の精密除去。

断面ポリライン

プロファイル生成

既存のSlice機能(軸平行の矩形断面)の上に、断面のプロファイルライン生成を追加した。

スライスで薄い断面を取ると、その断面に含まれる点群が得られる。これを水平軸でソートし、ビン分割して各ビンの最高点(地表面)を抽出することで、断面のプロファイルラインが生成される。

// ビン分割 — 各ビンの最高Y(地表面)を取得
const bins = Array(BINS).fill(null);
for (const p of sliced) {
  const b = Math.min(BINS - 1, Math.floor((p.sort - minS) / binW));
  if (!bins[b] || p.val > bins[b].val) {
    bins[b] = { xyz: p.xyz, val: p.val };
  }
}

生成されたプロファイルは緑のポリラインとして3Dビュー上に表示される。頂点マーカーも20個程度間引いて表示。

DXFエクスポート

プロファイルラインはDXF形式でエクスポートできる。3D POLYLINEエンティティとして出力し、CADソフト(AutoCAD、QGIS等)で開ける。

let dxf = '0\nSECTION\n2\nHEADER\n0\nENDSEC\n';
dxf += '0\nSECTION\n2\nENTITIES\n';
dxf += '0\nPOLYLINE\n8\nPROFILE\n66\n1\n70\n8\n';

for (const p of profilePoints) {
  dxf += '0\nVERTEX\n8\nPROFILE\n';
  // Three.js → CAD座標変換
  dxf += `10\n${p.x.toFixed(6)}\n20\n${(-p.z).toFixed(6)}\n30\n${p.y.toFixed(6)}\n`;
  dxf += '70\n32\n';
}

dxf += '0\nSEQEND\n8\nPROFILE\n';
dxf += '0\nENDSEC\n0\nEOF\n';

Octree LOD

八分木の構築

点群を空間的に八分木(Octree)に分割し、カメラからの距離に応じて表示する点の密度を動的に変える。近い部分は高密度、遠い部分は間引いて表示する。

export interface OctreeNode {
  min: [number, number, number];
  max: [number, number, number];
  center: [number, number, number];
  halfSize: number;
  children: (OctreeNode | null)[];
  indices: number[];       // リーフの全点インデックス
  lodSamples: number[];    // 遠距離用サンプル(64点)
  pointCount: number;
}

構築は再帰的に行う。最大深度5、最小256点でリーフノードとする。各ノードにはLOD用のサンプル(64点を等間隔抽出)を保持する。

// 8つのオクタントに分割
const buckets: number[][] = [[], [], [], [], [], [], [], []];
for (const idx of indices) {
  const x = pos[idx * 3], y = pos[idx * 3 + 1], z = pos[idx * 3 + 2];
  const oct = (x >= center[0] ? 1 : 0)
            | (y >= center[1] ? 2 : 0)
            | (z >= center[2] ? 4 : 0);
  buckets[oct].push(idx);
}

200万点の構築時間は約250ms。

LOD選択

useFrameで15フレームごとにLOD選択を実行。各ノードについて:

  1. Frustum Culling — カメラの視錐台外のノードを丸ごとスキップ
  2. スクリーンサイズ判定 — ノードの投影サイズが小さければLODサンプルを使用、大きければ子ノードに再帰
const dist = Math.max(0.001, camPos.distanceTo(nodeCenter));
const screenSize = (node.halfSize * 2 / dist) * screenHeight;

if (isLeaf || screenSize < 80) {
  // 遠い or リーフ → LODサンプルまたは全点を返す
  const pts = (isLeaf && screenSize >= 80) ? node.indices : node.lodSamples;
  // ...
}

子ノードは近い順にソートして処理し、予算(budget)を近い領域に優先配分する。

バッファ直接更新

LOD選択で得られたインデックスに基づき、pre-allocatedバッファに直接コピーする。メモリ確保なし、drawRangeで描画範囲を制御。

for (let i = 0; i < indices.length; i++) {
  const s = indices[i] * 3;
  const d = i * 3;
  dstPos[d] = srcPos[s];
  dstPos[d + 1] = srcPos[s + 1];
  dstPos[d + 2] = srcPos[s + 2];
  // color も同様
}
lodGeo.setDrawRange(0, indices.length);
lodGeo.attributes.position.needsUpdate = true;

ハッシュ比較で変更がなければバッファ更新をスキップし、不要な描画コストを回避。

座標系選択 + CRS変換

coordSystems.ts

proj4を動的ロードし、主要な測地系の定義を一括登録する。

export const CRS_LIST: CRSDef[] = [
  { code: 'EPSG:4326', name: 'WGS84 (Lat/Lon)', proj4def: '...' },
  { code: 'EPSG:3857', name: 'Web Mercator', proj4def: '...' },
  // UTM 51N〜56N(日本周辺)
  { code: 'EPSG:32654', name: 'UTM 54N', proj4def: '...' },
  // JGD2011 平面直角座標系 I〜XIX
  { code: 'EPSG:6669', name: 'JGD2011 / CS I (長崎)', proj4def: '...' },
  { code: 'EPSG:6677', name: 'JGD2011 / CS IX (東京)', proj4def: '...' },
  // ... 全19系
  { code: 'LOCAL', name: 'ローカル座標 (変換なし)', proj4def: '' },
];

対応CRS:

  • WGS84 (EPSG:4326)
  • Web Mercator (EPSG:3857)
  • UTM 51N〜56N (EPSG:32651〜32656)
  • JGD2011 平面直角座標系 I〜XIX (EPSG:6669〜6687)
  • ローカル座標(変換なし)

proj4の動的ロード

Viteの静的解析を回避するため、new Functionで動的importを行う。proj4が未インストールでもアプリは正常に動作し、変換だけスキップされる。

async function loadProj4(): Promise<any> {
  try {
    const modName = 'proj4';
    const mod = await (new Function('m', 'return import(m)'))(modName);
    _proj4 = mod.default || mod;
    // CRS定義を一括登録
    for (const def of CRS_LIST) {
      if (def.proj4def) _proj4.defs(def.code, def.proj4def);
    }
    return _proj4;
  } catch {
    console.warn('proj4 not installed — CRS transform disabled');
    return null;
  }
}

エクスポート時の座標変換

Source CRSとExport CRSが異なる場合、LASエクスポート時に各点の座標を変換する。

// Three.js → LAS座標に逆変換
let lasX = positions.getX(i);
let lasY = -positions.getZ(i);
let lasZ = positions.getY(i);

// CRS変換(水平座標のみ、標高はそのまま)
if (sourceCRS !== exportCRS) {
  [lasX, lasY, lasZ] = transformPointSync(lasX, lasY, lasZ, sourceCRS, exportCRS);
}

新ファイル構成

ファイル役割
octree.ts八分木構築 + LOD点選択
coordSystems.tsCRS定義 + proj4ラッパー

既存ファイルの変更:

ファイル変更内容
PointCloudApp.tsxToolMode追加、選択/プロファイル/CRS状態管理、削除ハンドラ
PointCloudScene.tsxボックス選択イベント、LOD useFrame、プロファイル描画
ControlPanel.tsxBox Select/LOD/CRS UI追加

まとめ

Point Cloud Viewer v5で閲覧ツールから編集ツールへと進化した。

開発経緯:

  • v1: PLY読み込み、ヒートマップ、スライス、計測、SOR
  • v2: LAS/PCD読み込み、200万点サンプリング、正規化
  • v3: LAZ読み込み、XYZ/CSV入出力、ローディング
  • v4: Intensity/Classification表示、LAS/PCD/スクショエクスポート
  • v5: ボックス選択+削除、断面ポリライン+DXF、Octree LOD、座標系変換

v5の実装ポイント:

  • ボックス選択はNDC投影で全点をスクリーン座標に変換。Raycasterより高速
  • Octree LODはfustum culling+スクリーンサイズ判定。pre-allocatedバッファで毎フレームのGCを回避
  • proj4のVite動的インポートはnew Functionでバンドラーの静的解析を回避
  • DXFエクスポートはThree.js→CAD座標の変換が必要(Y↔Z、Z符号反転)

今後の課題

  • 元座標オフセットの保持(正規化で失われる地理座標の復元)
  • Octreeに全点(サンプリング前)を格納して本当の大規模対応
  • 任意角度の断面ポリライン
  • Web Worker分離(パース・SORをメインスレッドから分離)
  • メッシュ化(Delaunay三角形分割 / Poisson Reconstruction)