ドキュメント / 使い方

ファイルをセルにして表示する

点群や 3D モデルのファイルを、ブラウザの中でセルの集まりに変換して、WebGL2 で表示できます。ファイルをサーバに送る必要はありません。この文書では、VoxelizerJs でファイルをセルにする方法と、@mirgrid/web-sdk/render.mjs で画面に描く方法を説明します。

大まかな流れは次のとおりです。

  1. レベルを決めて VoxelizerJs を作ります。
  2. setInput で、入力の座標系を指定します。
  3. pushFile でファイルの中身を渡します。点や三角形を直接渡すこともできます。
  4. 結果を読むか、faces() で表に出ている面を作って描画器に渡します。
  5. 使い終わったら free() で解放します。

点群をセルにする

例として、静岡県が公開している「駿府城点群データ(令和7年度)」(CC BY 4.0)から、東御門橋のあたりを切り出した LAZ ファイルを使います。点は 217,869 個です。座標は平面直角座標系の VIII 系(EPSG:6676)で、高さは標高です。この場所のジオイド高は 40.53 m です。

次の例は Node.js で動かすときの書き方です。点群を一辺 0.25 m のセル(レベル 26)にしています。

import { readFileSync } from 'node:fs';
import init, { VoxelizerJs } from '@mirgrid/web-sdk';

const wasmUrl = new URL(import.meta.resolve('@mirgrid/web-sdk/voxel_core_bg.wasm'));
await init({ module_or_path: readFileSync(wasmUrl) });

const voxelizer = new VoxelizerJs(26);
voxelizer.setInput({ coordSystem: '6676', geoidHeightM: 40.53 });
const summary = voxelizer.pushFile(
  readFileSync('sunpu-higashigomon-bridge.laz'),
  'sunpu-higashigomon-bridge.laz',
);
console.log(summary);
console.log(voxelizer.cellCount);
{
  format: 'laz',
  points: 217869,
  triangles: 0,
  samples: 0,
  declaredEpsg: 6676
}
8712

21 万あまりの点が、8,712 個のセルにまとまりました。

ブラウザでは、ファイル選択やドラッグ & ドロップで受け取った File の中身を Uint8Array にして渡します。

const bytes = new Uint8Array(await file.arrayBuffer());
voxelizer.pushFile(bytes, file.name);

対応しているファイル形式

pushFile が読めるのは、PLY・LAS・LAZ・glTF・GLB・OBJ・STL の 7 つの形式です。2 つ目の引数には、'laz' のような形式名か、拡張子の付いたファイル名を渡します。ほかの形式を渡すと Error になります。

glTF は、外部のファイルを参照していないもの(GLB か、データを data: URI で埋め込んだ glTF)だけを読めます。

pushFile の戻り値には、読み込んだ内容がまとめられています。

項目 内容
format 読み込んだ形式
points 点の数
triangles 三角形の数
samples 三角形の表面から取り出した点の数
declaredEpsg LAS・LAZ のヘッダに書かれている EPSG(無ければ null)

3D モデルの三角形は、表面に点を散らしてからセルにします。点の密度(1 m² あたりの点の数)は setSurfaceDensity で変えられます。

declaredEpsg はファイルに書かれている情報を示すだけで、入力の座標系は自動では切り替わりません。座標系は setInput で必ず指定してください。

入力の座標系を指定する

setInput には、ファイルの座標がどの座標系で書かれているかを指定します。指定は、そのあとに渡す点や三角形から効きます。指定しなかったときは ECEF として扱います。

coordSystem には次のどれかを書きます。

coordSystem 座標の意味
'ecef' ECEF の x・y・z(m)
'latlon' 経度・緯度(度)と楕円体高(m)
'local' origin に指定した地点から見た東・北・上(m)
'utm' UTM の東・北(m)と楕円体高(m)
'6669'〜'6687' 平面直角座標系の東・北(m)と楕円体高(m)

'utm' のときは、utmZone(ゾーンの番号)と utmHemisphere('north' か 'south')も指定します。平面直角座標系は、'6676' のように EPSG の番号を文字列で書きます。

ほかに、次の項目を指定できます。

項目 内容
origin 'local' の原点 [経度, 緯度, 楕円体高]
unitScale 入力の 1 単位が何 m か(mm で書かれたファイルなら 0.001、既定は 1)
geoidHeightM 高さが標高のときに、標高に足すジオイド高(m)
rotationDeg 鉛直の軸まわりに回す角度(度、正の値で反時計回り)
translation 東・北・上への平行移動 [東, 北, 上](m)
pivot 回転の中心(入力と同じ単位)

知らない項目を書くと Error になります。たとえば geoidHeightM を geoidHeight と書き間違えると、すぐに気づけます。

ジオイド高については、位置と空間アドレスでも説明しています。

点や三角形を直接渡す

ファイルではなく、アプリが持っている点や三角形をセルにすることもできます。座標は setInput で指定した座標系で書きます。

  • pushPoints(xyz, rgb): 点を [x, y, z, x, y, z, …] と並べた Float64Array を渡します。rgb は 1 点 3 バイトの Uint8Array です。rgb を省くと、灰色(180, 180, 180)になります。
  • pushTriangles(xyz, indices, rgb): 三角形の頂点を渡します。indices を null にすると、頂点を 3 つずつ 1 つの三角形とみなします。rgb は 1 三角形 3 バイトです。戻り値は、表面から取り出した点の数です。
const shapes = new VoxelizerJs(24);
shapes.setInput({ coordSystem: 'local', origin: [139.767125, 35.681236, 40] });
shapes.pushPoints(new Float64Array([0, 0, 0, 3, 0, 0]), new Uint8Array([255, 0, 0, 0, 0, 255]));
const samples = shapes.pushTriangles(
  new Float64Array([0, 0, 0, 4, 0, 0, 0, 3, 0]),
  null,
  new Uint8Array([200, 200, 200]),
);
console.log(samples, shapes.cellCount);
113 16

セルの数の上限を決める

細かいレベルで大きなファイルをセルにすると、セルが増えてメモリを使い切ることがあります。setMaxCells で上限を決めておくと、上限を超えたところで pushFile などが Error になります。null か 0 を渡すと上限はなくなります。

const limited = new VoxelizerJs(26);
limited.setInput({ coordSystem: '6676', geoidHeightM: 40.53 });
limited.setMaxCells(1000);
try {
  limited.pushFile(readFileSync('sunpu-higashigomon-bridge.laz'), 'sunpu-higashigomon-bridge.laz');
} catch (error) {
  console.log(error.message);
}
voxelize の一意セル数が上限 1000 セルを超えた

Error になった VoxelizerJs には、途中までの結果が残っています。free() で解放してから、レベルを粗くして作り直してください。

現場に揃えた格子と地球基準の格子

セルの並べ方(格子)には 2 種類あります。

地球基準の格子 現場に揃えた格子
作り方 new VoxelizerJs(level) VoxelizerJs.siteAuto(level)
セルの向き ECEF の軸に沿った向き(地上からは傾いて見える) その場所の東・北・上に沿った向き(地面に水平)
セルの ID 世界で共通の空間アドレス 現場の格子用の ID(32 桁の 16 進)
向いている使い方 ほかのデータとの、空間アドレスでの突き合わせ 建物や構造物の形の確認や計測

地球基準の格子は、位置と空間アドレスで説明したセルそのものです。どこで作っても同じ空間アドレスになるので、ほかのデータと重ね合わせやすくなります。ただし、セルの面は地面と平行になりません。

現場に揃えた格子は、最初に渡した点の近くを基準点(anchor)にして、その場所の東・北・上に沿ってセルを並べます。基準点は、最初の点を含むレベル 16 のセル(一辺 256 m)です。データは基準点から 2,048 m 以内に収まっている必要があります。

const site = VoxelizerJs.siteAuto(26);
site.setInput({ coordSystem: '6676', geoidHeightM: 40.53 });
site.pushFile(readFileSync('sunpu-higashigomon-bridge.laz'), 'sunpu-higashigomon-bridge.laz');
console.log(site.cellCount);
console.log(site.frameDescriptor());
8095
{
  kind: 'site',
  datum: 'JGD2011',
  epoch: 2011.4,
  anchor: '0000000002e94f721e16540000000000',
  yaw: 0
}

frameDescriptor() は、格子の決め方を表すオブジェクト(格子の記述子)を返します。基準点は最初の点で決まるので、点を渡す前は null です。

格子の向きを変える

siteAuto の 3 つ目の引数で、格子を水平に回せます。0 なら格子が東西南北に揃います。角度は度ではなく、1 周を 65536 に分けた整数で渡します。度からは spatial_yaw_from_deg で変換してください。角度は真北から東回りに測ります。

import { spatial_yaw_from_deg } from '@mirgrid/web-sdk';

console.log(spatial_yaw_from_deg(30), spatial_yaw_from_deg(90));
5461 16384

2 つ目の引数は測地系の名前で、省くと 'JGD2011' になります。向きだけを変えるときは、VoxelizerJs.siteAuto(26, 'JGD2011', spatial_yaw_from_deg(30)) のように書きます。

同じ格子にもう 1 つのファイルを重ねる

2 つのファイルを同じ格子でセルにしたいときは、1 つ目の frameDescriptor() を 2 つ目の VoxelizerJs に渡します。

const second = new VoxelizerJs(26, site.frameDescriptor());
console.log(second.frameDescriptor().anchor);
0000000002e94f721e16540000000000

FrameJs で格子を扱う

FrameJs は、格子をあらわすオブジェクトです。FrameJs.ecef() は地球基準の格子、FrameJs.fromDescriptor(descriptor) は記述子から作った格子です。cellAt(ix, iy, iz, level) で、その格子のセルの ID と中心の ECEF を求められます。

import { FrameJs } from '@mirgrid/web-sdk';

const frame = FrameJs.fromDescriptor(site.frameDescriptor());
const firstCell = site.cellInfo(0);
const at = frame.cellAt(firstCell.index[0], firstCell.index[1], firstCell.index[2], 26);
console.log(frame.kind(), at.cellId === firstCell.cellId);
site true

matrix() は、現場の格子の座標を ECEF に移す 4 × 4 の行列を、列優先の Float64Array で返します。three.js の Matrix4.fromArray にそのまま渡せます。FrameJs も WebAssembly のメモリを使うので、使い終わったら free() してください。

結果を読む

セルにした結果は、セルの番号の順(x・y・z の辞書順)に並んでいます。

メソッド・プロパティ 返すもの
cellCount セルの数
level レベル
indices() セルの番号を [x, y, z, …] と並べた Float64Array
colors() セルの色を 1 セル 3 バイト(r, g, b)で並べた Uint8Array
counts() セルに入った点(3D モデルでは表面から取り出した点)の数の Float64Array
cellInfo(i) i 番目のセルの情報
toCellSet() 同じセルを入れた CellSetJs

セルの色は、セルに入った点の色の平均です。cellInfo(i) は次のような値を返します。

console.log(voxelizer.cellInfo(0));
{
  index: [ 17908406, 47452843, 48097938 ],
  cellId: '0000000002e94f721e16575d09dbf000',
  rgb: [ 126, 121, 116 ],
  count: 2,
  centerEcef: [ -3911506.3828125, 3474602.8671875, 3635876.6171875 ],
  sourceType: 2
}

cellId は、地球基準の格子なら空間アドレス、現場に揃えた格子なら現場の格子用の ID です。centerEcef はセルの中心の ECEF です。sourceType はセルの由来で、3D モデルから作ったセルは 1、点群から作ったセルは 2、両方が入ったセルは 3 です。

toCellSet() で作った集合を使うと、和・積・差や距離の計算ができます(セルの集合を参照)。

表に出ている面を作る

画面に描くときは、まず faces() で、セルの面のうち表に出ているものを集めます。隣のセルと接している面は、外から見えないので除かれます。

const faces = voxelizer.faces();
console.log(faces.cellCount, faces.faceCount, faces.cellSizeM);
console.log(faces.originLlh);
8712 14552 0.25
[ 138.3851385356091, 34.9777374735036, 62.81689990963787 ]

faces() の結果は、JavaScript の型付き配列だけでできています。free() は要りません。Worker から送るときは、配列の ArrayBuffer を transfer で渡せます。

描画の空間は、originLlh(経度・緯度・楕円体高)を原点にした ENU(m)です。原点を省くと、地球基準の格子ではセル全体を囲む箱の中心、現場に揃えた格子では基準点の真上になります。原点を決めたいときは、faces([経度, 緯度, 楕円体高]) のように渡します。

隠れた面も残したいときは、faces(null, { occlusion: false }) とします。

画面に描く

@mirgrid/web-sdk/render.mjs の createVoxelRenderer で描画器を作り、setLayer で面を渡します。描画器は WebGL2 を使います。WebGL2 が使えないときは、createVoxelRenderer が Error を投げます。

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

await init();
const canvas = document.querySelector('canvas');
const renderer = createVoxelRenderer(canvas);

let voxelizer = null;
document.querySelector('input[type=file]').addEventListener('change', async (event) => {
  const file = event.target.files[0];
  const next = new VoxelizerJs(26);
  next.setInput({ coordSystem: '6676', geoidHeightM: 40.53 });
  next.pushFile(new Uint8Array(await file.arrayBuffer()), file.name);
  renderer.setLayer('scan', next.faces());
  voxelizer?.free();
  voxelizer = next;
});

描画器は canvas の大きさの変化に合わせて、自動で描き直します。canvas の大きさは CSS で決めてください。

マウスでは、左ドラッグで回転、右ドラッグか Shift を押しながらのドラッグで移動、ホイールで拡大・縮小ができます。タッチでは、1 本指で回転、2 本指でつまんで拡大・縮小と移動ができます。

レイヤを重ねる

setLayer(id, faces) は、id ごとにレイヤを置きます。同じ id を渡すと置き換えます。setVisible(id, false) で隠し、removeLayer(id) で取り除けます。

1 つの描画器に重ねるレイヤは、すべて同じ原点で作る必要があります。2 つ目からは、faces(renderer.originLlh) のように描画器の原点を渡してください。原点が違うと、setLayer が Error を投げます。

視点を決める

視点は、最初のレイヤを置いたときに自動で合わせます。fitView() を呼ぶと、見えているレイヤ全体が収まるように合わせ直します。

setView で視点を指定することもできます。方位角 azimuthDeg は、東から反時計回りに測った角度です。

renderer.setView({ azimuthDeg: -90, elevationDeg: 90 }); // 真上から見る
renderer.setView({ azimuthDeg: -90, elevationDeg: 0 }); // 南から北を見る
renderer.setView({ targetEnu: [0, 0, 0], distanceM: 30 }); // 原点を中心に 30 m 離れて見る

今の視点は renderer.view で読めます。描画器を作るときに onRender を渡すと、描くたびに視点を受け取れます。view.axesOnScreen には東・北・上が画面のどちらを向いているかが入っているので、方位の印を描くのに使えます。

クリックしたセルを調べる

pick(clientX, clientY) は、画面の座標の下にある面を返します。何も無ければ null です。戻り値の cell を cellInfo に渡すと、そのセルの情報が分かります。

回転のためのドラッグをクリックと取り違えないように、押した位置と離した位置が近いときだけ調べます。

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 && hit.layerId === 'scan') {
    console.log(voxelizer.cellInfo(hit.cell));
  }
});

hit には、レイヤの id(layerId)、面の番号(face)、セルの番号(cell)、面の向き(direction。0〜5 が +X・−X・+Y・−Y・+Z・−Z)が入っています。

描画器が要らなくなったら、renderer.dispose() でイベントの登録と GPU の資源を外します。

重い処理を Worker に回す

pushFile は、ファイルの中身を一度に WebAssembly のメモリに写してセルにします。大きなファイルでは時間がかかり、その間は画面が止まります。セル化は module Worker で行い、結果の面だけを画面のスレッドに送るようにしましょう。

Worker の側では、VoxelizerJs を Worker の中に持ち続けます。面の型付き配列は transfer で送るので、コピーは起きません。

// voxelize.worker.js
import init, { VoxelizerJs } from '@mirgrid/web-sdk';

const ready = init();
let voxelizer = null;

const handlers = {
  voxelize({ bytes, fileName, level, input }) {
    const next = new VoxelizerJs(level);
    try {
      next.setInput(input);
      next.pushFile(new Uint8Array(bytes), fileName);
      const faces = next.faces();
      voxelizer?.free();
      voxelizer = next;
      const transfer = [faces.start, faces.coords, faces.colors, faces.cells, faces.gridToRender]
        .map((array) => array.buffer);
      return [{ faces, cellCount: next.cellCount }, transfer];
    } catch (error) {
      next.free();
      throw error;
    }
  },
  cellInfo({ cell }) {
    return [{ info: voxelizer.cellInfo(cell) }, []];
  },
};

self.onmessage = async ({ data }) => {
  try {
    await ready;
    const [result, transfer] = handlers[data.kind](data);
    self.postMessage({ id: data.id, ok: true, ...result }, transfer);
  } catch (error) {
    self.postMessage({ id: data.id, ok: false, error: error.message });
  }
};

画面の側では、要求ごとに id を振って、返事を Promise で受け取ります。

const worker = new Worker(new URL('./voxelize.worker.js', import.meta.url), { type: 'module' });
const pending = new Map();
let nextId = 1;

worker.onmessage = ({ data }) => {
  const { resolve, reject } = pending.get(data.id);
  pending.delete(data.id);
  if (data.ok) resolve(data);
  else reject(new Error(data.error));
};

function call(kind, payload, transfer = []) {
  return new Promise((resolve, reject) => {
    const id = nextId++;
    pending.set(id, { resolve, reject });
    worker.postMessage({ id, kind, ...payload }, transfer);
  });
}

const bytes = await file.arrayBuffer();
const result = await call('voxelize', {
  bytes,
  fileName: file.name,
  level: 26,
  input: { coordSystem: '6676', geoidHeightM: 40.53 },
}, [bytes]);
renderer.setLayer('scan', result.faces);

ファイルの ArrayBuffer も transfer で渡しているので、送ったあとは画面の側では使えなくなります。設定を変えて何度もセル化し直すときは、bytes.slice(0) でコピーを作ってから送ってください。

クリックしたセルを調べるときは、call('cellInfo', { cell: hit.cell }) のように Worker に問い合わせます。

MGVX に保存する

toPacket() は、セル化した結果を MGVX packet(MIRGRID の配信形式)のバイト列にします。セルの色も保たれます。

const packet = voxelizer.toPacket();
console.log(packet.length);
148148

packet のバイト列には、格子の情報が入っていません。現場に揃えた格子で作った packet を保存するときは、frameDescriptor() を JSON にして一緒に保存してください。

const sitePacket = site.toPacket();
const siteFrame = JSON.stringify(site.frameDescriptor());
console.log(sitePacket.length, siteFrame);
137659 {"kind":"site","datum":"JGD2011","epoch":2011.4,"anchor":"0000000002e94f721e16540000000000","yaw":0}

記述子を付けずに読むと、地球基準の格子の番号として読まれ、まったく別の場所に出てしまいます。保存した packet を読み込んで描く方法は、配信データを読むで説明しています。

使い終わったら解放する

VoxelizerJs と FrameJs は WebAssembly のメモリを使います。使い終わったら free() で解放してください。

for (const object of [voxelizer, shapes, limited, site, second, frame]) {
  object.free();
}

faces() の結果と pushFile の戻り値は普通の JavaScript の値なので、解放は要りません。

関連する文書

セルの番号や ENU の考え方は位置と空間アドレスで、セルの集合の計算はセルの集合で説明しています。保存した packet や配信データを読むときは配信データを読むへ進んでください。関数の引数の一覧はAPI リファレンスにあります。