点群をセルにして表示する
点群ファイルをブラウザの中で現場に揃えた格子のセルにまとめ、3D で表示します。クリックしたセルの情報を調べたり、手元のファイルで試したりできます。
使っているデータ
- 駿府城 東御門橋の点群(静岡県「駿府城点群データ(令和7年度)」、CC BY 4.0)。東御門橋のあたりを切り出し、5 cm ごとに 1 点に間引いたもの
コード
ビルドせずにブラウザで読み込める形で書いています。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>