サンプル

点群をセルにして表示する

点群ファイルをブラウザの中で現場に揃えた格子のセルにまとめ、3D で表示します。クリックしたセルの情報を調べたり、手元のファイルで試したりできます。

新しいタブで開く

使っているデータ

コード

ビルドせずにブラウザで読み込める形で書いています。index.html の import map で、@mirgrid/web-sdk をこのサイトに置いた SDK に結び付けています。npm でインストールしてバンドラを使うプロジェクトでは、import map は要りません。

main.js

import init, { VoxelizerJs, spatial_ecef_to_llh_rad, spatial_ecef_height } from '@mirgrid/web-sdk';
import { createVoxelRenderer } from '@mirgrid/web-sdk/render.mjs';

await init();

const $ = (id) => document.getElementById(id);
const canvas = $('view');
const levelSelect = $('level');
const coordSelect = $('coord');
const geoidInput = $('geoid');
const status = $('status');

const MAX_CELLS = 1_000_000;
const TOKYO_STATION = [139.7671248, 35.6812362, 40]; // ローカル座標の原点(経度・緯度・楕円体高)
const number = new Intl.NumberFormat('ja-JP');

// 同梱の点群は平面直角座標系 VIII 系で、高さは標高。この場所のジオイド高は 40.53 m。
const bridge = {
  name: '東御門橋の点群',
  fileName: 'sunpu-higashigomon-bridge.laz',
  input: { coordSystem: '6676', geoidHeightM: 40.53 },
};

let renderer;
try {
  renderer = createVoxelRenderer(canvas);
} catch {
  showError('このブラウザでは WebGL2 が使えないため、表示できません。');
  throw new Error('WebGL2 is not available');
}

let voxelizer = null; // 表示中のセル
let shown = null; // 表示中のデータ
let localFile = null; // 手元から選んだファイル

// data をレベル level のセルにして表示する。データが変わったときは視点を合わせ直す。
async function show(data, level) {
  setStatus('セルにしています…');
  // 重い処理の前に、上の文言を画面に出しておく
  await new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve)));

  // この例は画面のスレッドで処理します(同梱の点群なら 0.1 秒ほど)。
  // 大きなファイルでは画面が止まるので、文書の「重い処理を Worker に回す」のように Worker で処理してください。
  const next = VoxelizerJs.siteAuto(level); // 現場に揃えた格子
  let summary;
  let faces;
  const start = performance.now();
  try {
    next.setInput(data.input);
    next.setMaxCells(MAX_CELLS);
    summary = next.pushFile(data.bytes, data.fileName);
    faces = next.faces();
  } catch (error) {
    next.free();
    levelSelect.value = String(voxelizer?.level ?? level);
    showError(explain(error));
    return;
  }
  const elapsed = performance.now() - start;

  renderer.setLayer('cells', faces);
  if (data !== shown) renderer.fitView();
  voxelizer?.free();
  voxelizer = next;
  shown = data;

  $('dataset').textContent = data.name;
  $('credit').hidden = data !== bridge;
  $('back').hidden = data === bridge;
  $('points').textContent = number.format(summary.points + summary.samples);
  $('cells').textContent = number.format(next.cellCount);
  $('faces').textContent = number.format(faces.faceCount);
  $('time').textContent = `${number.format(Math.round(elapsed))} ms`;
  showEpsgNote(summary.declaredEpsg, data.input.coordSystem);
  clearCell();
  setStatus('');
}

// SDK のエラーを、次に何をすればよいか分かる文に直す
function explain(error) {
  const message = String(error?.message ?? error);
  if (message.includes('セル数が上限')) {
    return `セルが ${number.format(MAX_CELLS)} 個を超えました。レベルを小さくして(セルを大きくして)試してください。`;
  }
  if (message.includes('対応していない形式')) {
    return 'この形式のファイルは読めません。PLY・LAS・LAZ・glTF・GLB・OBJ・STL のファイルを選んでください。';
  }
  if (message.includes('binary_little_endian')) {
    return 'テキスト形式(ASCII)の PLY は読めません。バイナリ形式で保存した PLY を選んでください。';
  }
  if (message.includes('範囲外')) {
    return 'データが最初の点から 2 km ほどの範囲に収まっていません。座標系の選択が合っているか確かめてください。';
  }
  if (message.includes('サンプル点が 0')) {
    return 'ファイルの中に点も面も見つかりませんでした。';
  }
  return `ファイルを読めませんでした。壊れていないか確かめてください。(${message})`;
}

function showEpsgNote(declared, chosen) {
  const note = $('epsg-note');
  note.hidden = declared == null || String(declared) === chosen;
  note.textContent = `ファイルには EPSG:${declared} と書かれています。座標系の選択が合っているか確かめてください。`;
}

function setStatus(text) {
  status.textContent = text;
  status.classList.remove('error');
}

function showError(text) {
  status.textContent = text;
  status.classList.add('error');
}

// 手元のファイル用の setInput の指定を、画面の選択から作る
function localInput() {
  const coordSystem = coordSelect.value;
  if (coordSystem === 'local') return { coordSystem, origin: TOKYO_STATION };
  if (coordSystem === 'ecef') return { coordSystem };
  return { coordSystem, geoidHeightM: Number(geoidInput.value) || 0 };
}

function updateGeoidInput() {
  // ジオイド高を足すのは、高さが標高になりうる平面直角座標系と経度・緯度だけ
  geoidInput.disabled = ['local', 'ecef'].includes(coordSelect.value);
}

async function showLocalFile() {
  if (!localFile) return;
  // 座標系を変えて読み直すと置き場所が変わるので、毎回新しいデータとして扱う
  localFile = { ...localFile, input: localInput() };
  await show(localFile, Number(levelSelect.value));
}

function clearCell() {
  $('cell-hint').hidden = false;
  $('cell-facts').hidden = true;
}

function showCell(i) {
  const cell = voxelizer.cellInfo(i);
  const [x, y, z] = cell.centerEcef;
  const { lon, lat } = spatial_ecef_to_llh_rad(x, y, z); // ラジアンで返る
  const [r, g, b] = cell.rgb;
  $('cell-id').textContent = cell.cellId;
  $('cell-index').textContent = cell.index.join(', ');
  $('cell-swatch').style.background = `rgb(${r} ${g} ${b})`;
  $('cell-rgb').textContent = `${r}, ${g}, ${b}`;
  $('cell-count').textContent = number.format(cell.count);
  $('cell-lon').textContent = `${((lon * 180) / Math.PI).toFixed(7)}°`;
  $('cell-lat').textContent = `${((lat * 180) / Math.PI).toFixed(7)}°`;
  $('cell-height').textContent = `${spatial_ecef_height(x, y, z).toFixed(3)} m`;
  $('cell-hint').hidden = true;
  $('cell-facts').hidden = false;
}

// 回転のためのドラッグと区別するため、押した位置と離した位置が近いときだけクリックとみなす
let down = null;
canvas.addEventListener('pointerdown', (event) => {
  down = { x: event.clientX, y: event.clientY };
});
canvas.addEventListener('pointerup', (event) => {
  if (!down || Math.hypot(event.clientX - down.x, event.clientY - down.y) > 4) return;
  const hit = renderer.pick(event.clientX, event.clientY);
  if (hit?.layerId === 'cells') showCell(hit.cell);
  else clearCell();
});

levelSelect.addEventListener('change', () => shown && show(shown, Number(levelSelect.value)));

$('file').addEventListener('change', async (event) => {
  const file = event.target.files[0];
  if (!file) return;
  localFile = { name: file.name, fileName: file.name, bytes: new Uint8Array(await file.arrayBuffer()) };
  await showLocalFile();
});
coordSelect.addEventListener('change', () => {
  updateGeoidInput();
  showLocalFile();
});
geoidInput.addEventListener('change', showLocalFile);

$('back').addEventListener('click', () => show(bridge, Number(levelSelect.value)));

updateGeoidInput();
try {
  const response = await fetch(bridge.fileName);
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  bridge.bytes = new Uint8Array(await response.arrayBuffer());
  await show(bridge, Number(levelSelect.value));
} catch (error) {
  showError(`点群ファイルを読み込めませんでした。(${error.message})`);
}

index.html

<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>点群をセルにして表示する</title>
<link rel="stylesheet" href="/assets/sample.css">
<style>
  [hidden] { display: none; }
  canvas { background: #e6ebf2; }
  .viewer aside ul { margin: 0; padding-left: 1.2em; font-size: 12.5px; color: var(--muted); }
  .viewer aside .fields { grid-template-columns: minmax(0, 1fr); }
  input:disabled { opacity: 0.5; }
</style>
<script type="importmap">
{ "imports": { "@mirgrid/web-sdk": "/sdk/web-sdk/voxel_core.js", "@mirgrid/web-sdk/": "/sdk/web-sdk/" } }
</script>
<script type="module" src="main.js"></script>
</head>
<body>
<main class="viewer">
  <canvas id="view" aria-label="セルにした点群の 3D 表示"></canvas>
  <aside>
    <section>
      <h2 id="dataset">東御門橋の点群</h2>
      <p id="credit" class="credit">出典: 静岡県「<a href="https://www.geospatial.jp/ckan/dataset/sumpu-castle" target="_blank" rel="noopener">駿府城点群データ(令和7年度)</a>」(<a href="https://creativecommons.org/licenses/by/4.0/deed.ja" target="_blank" rel="noopener">CC BY 4.0</a>)。東御門橋のあたりを切り出し、5 cm ごとに 1 点に間引いて使っています。</p>
      <label>レベル(セルの一辺)
        <select id="level">
          <option value="24">24(1 m)</option>
          <option value="25">25(50 cm)</option>
          <option value="26" selected>26(25 cm)</option>
          <option value="27">27(12.5 cm)</option>
          <option value="28">28(6.25 cm)</option>
        </select>
      </label>
      <p id="status" class="status" role="status">準備しています…</p>
      <p id="epsg-note" class="note" hidden></p>
      <dl class="facts">
        <dt>点の数</dt><dd id="points">–</dd>
        <dt>セルの数</dt><dd id="cells">–</dd>
        <dt>表に出ている面の数</dt><dd id="faces">–</dd>
        <dt>かかった時間</dt><dd id="time">–</dd>
      </dl>
    </section>

    <section>
      <h2>選んだセル</h2>
      <p id="cell-hint" class="note">セルをクリックすると、そのセルの情報を表示します。</p>
      <dl id="cell-facts" class="facts" hidden>
        <dt>現場の格子の ID</dt><dd id="cell-id"></dd>
        <dt>セルの番号</dt><dd id="cell-index"></dd>
        <dt>色</dt><dd><span id="cell-swatch" class="swatch"></span><span id="cell-rgb"></span></dd>
        <dt>点の数</dt><dd id="cell-count"></dd>
        <dt>中心の経度</dt><dd id="cell-lon"></dd>
        <dt>中心の緯度</dt><dd id="cell-lat"></dd>
        <dt>中心の楕円体高</dt><dd id="cell-height"></dd>
      </dl>
    </section>

    <section>
      <h2>手元のファイルで試す</h2>
      <p class="note">PLY・LAS・LAZ・glTF・GLB・OBJ・STL のファイルを読めます。選んだファイルはこのページの中だけで処理し、どこにも送信しません。</p>
      <div class="fields">
        <label>ファイル
          <input id="file" type="file" accept=".ply,.las,.laz,.gltf,.glb,.obj,.stl">
        </label>
        <label>座標系
          <select id="coord">
            <optgroup label="平面直角座標系(東・北・楕円体高)">
              <option value="6669">I 系(EPSG:6669)長崎県、鹿児島県の一部の島</option>
              <option value="6670">II 系(EPSG:6670)福岡県、佐賀県、熊本県、大分県、宮崎県、鹿児島県</option>
              <option value="6671">III 系(EPSG:6671)山口県、島根県、広島県</option>
              <option value="6672">IV 系(EPSG:6672)香川県、愛媛県、徳島県、高知県</option>
              <option value="6673">V 系(EPSG:6673)兵庫県、鳥取県、岡山県</option>
              <option value="6674">VI 系(EPSG:6674)京都府、大阪府、福井県、滋賀県、三重県、奈良県、和歌山県</option>
              <option value="6675">VII 系(EPSG:6675)石川県、富山県、岐阜県、愛知県</option>
              <option value="6676">VIII 系(EPSG:6676)新潟県、長野県、山梨県、静岡県</option>
              <option value="6677">IX 系(EPSG:6677)東京都(本土と伊豆諸島)、福島県、栃木県、茨城県、埼玉県、千葉県、群馬県、神奈川県</option>
              <option value="6678">X 系(EPSG:6678)青森県、秋田県、山形県、岩手県、宮城県</option>
              <option value="6679">XI 系(EPSG:6679)北海道西部(小樽市、函館市など)</option>
              <option value="6680">XII 系(EPSG:6680)北海道中央部(札幌市、旭川市など)</option>
              <option value="6681">XIII 系(EPSG:6681)北海道東部(帯広市、釧路市、北見市など)</option>
              <option value="6682">XIV 系(EPSG:6682)東京都(小笠原諸島)</option>
              <option value="6683">XV 系(EPSG:6683)沖縄県(沖縄本島など)</option>
              <option value="6684">XVI 系(EPSG:6684)沖縄県(宮古島、石垣島など)</option>
              <option value="6685">XVII 系(EPSG:6685)沖縄県(大東諸島)</option>
              <option value="6686">XVIII 系(EPSG:6686)東京都(沖ノ鳥島)</option>
              <option value="6687">XIX 系(EPSG:6687)東京都(南鳥島)</option>
            </optgroup>
            <optgroup label="そのほか">
              <option value="latlon">経度・緯度・楕円体高</option>
              <option value="ecef">ECEF</option>
              <option value="local" selected>ローカル(東京駅を原点にした東・北・上)</option>
            </optgroup>
          </select>
        </label>
        <label>ジオイド高(m)
          <input id="geoid" type="number" step="0.01" value="0">
        </label>
      </div>
      <p class="note">高さが標高で書かれているファイルでは、その場所のジオイド高を入れてください。楕円体高なら 0 のままにします。ローカルは東京駅(経度 139.7671248、緯度 35.6812362、楕円体高 40 m)を原点にして、x を東、y を北、z を上として置きます。</p>
      <button id="back" class="secondary" type="button" hidden>東御門橋の点群に戻す</button>
    </section>

    <section>
      <h2>操作</h2>
      <ul>
        <li>左ドラッグで回転、右ドラッグか Shift + ドラッグで移動、ホイールで拡大・縮小</li>
        <li>タッチでは 1 本指で回転、2 本指でつまんで拡大・縮小と移動</li>
      </ul>
    </section>
  </aside>
</main>
</body>
</html>