0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PLATEAU の3D都市モデルを three.js へ、約1GBを54MBに変換して配信

0
Posted at

はじめに

PLATEAU の 3D Tiles は、京都市の中京区・東山区・下京区の建築物 LOD3 だけで 880 MB あります(pipeline/README.md のデータセット表より。都市設備などを足した入力 8 件の合計は約 960 MB)。これをビルド工程もサーバーもなしに、スマートフォンでも開けることを目指した作品(実機での動作は本稿では確認していません)にするとき、どこを削ってどこを残せばよいのでしょうか。しかも置き場所の最初の候補は、pack.mjs のコメントによると、バイナリの拡張子を配信できないホストでした。

題材は、京都の先斗町・鴨川・祇園白川をドバトの目で歩いて飛ぶブラウザ作品「鳩の目、京都。」です。筆者が Claude Fable 5.2 に依頼して実装してもらった作品で、依存は three.js r170 だけです。AI とのやり取りは別の記事で扱うので、この記事ではコードと数値に絞ります。

対象読者は、three.js や WebGL で大きな 3D データをブラウザへ配信したい開発者と、PLATEAU のデータを Web で使いたい方です。

TL;DR

  • 頂点は、位置と UV を Uint16、法線を Int8 に量子化し、頂点色のアルファに環境遮蔽(AO)を焼き込んでいます。実行時にライトマップは使いません
  • 独自の pak 形式(deflate-raw)を base64 の .txt にして配り、ブラウザで base64 を手書きのループで戻してから DecompressionStream('deflate-raw') で展開します。配布中の core.txt を復号すると、646,087 三角形・1,931,839 頂点が入っていました
  • テクスチャは「見えている割合」で密度を決めてアトラスを詰め直し、距離に応じて 4 段階の WebP を出し入れします
  • ハトの目の 320° パノラマは、CubeCamera で 6 面を描いてから全画面シェーダで方位角と仰角に引き直しています

3D Tiles から .txt までの全体像

変換は Node.js の 6 段のパイプラインで行い、最後に base64 化の後処理を挟みます。段ごとの説明はリポジトリの README に譲り、この記事では段 4(テクスチャ)と段 6(書き出し)、それを読むブラウザ側を追います。

出力は 226 ファイル、約 54 MB です。起動時に読むのは約 21 MB で、残りは背景で追いかけます(README の規模表より)。

頂点を 16 bit と 8 bit に量子化する

書き出しの addMesh は、メッシュごとに AABB(軸に沿った外接箱)を取り、その箱の中で位置を 0〜65535 に正規化します。

// pipeline/export.mjs:28-34(整形・抜粋)
const sx = Math.max(1e-3, x1 - x0) /* sy, sz も同様 */;
const P = new Uint16Array(nv * 3);
const N = new Int8Array(nv * 3 + ((4 - (nv * 3) % 4) % 4)); // 4 バイト境界まで詰め物
const C = new Uint8Array(nv * 4);
for (let i = 0; i < nv; i++) {
  P[i * 3] = Math.round((x - x0) / sx * 65535);   // y, z も同様
  N[i * 3] = Math.round(nx * 127);                // 単位化した法線
  C[i * 4 + 3] = Math.round(ao * 255);            // RGBA の A に AO
}
const it = { bb: [x0, y0, z0, x1, y1, z1].map(v => +v.toFixed(3)), nv,
             pos: pak.add(P), nor: pak.add(N), col: pak.add(C) };
// UV も 0〜1 を Uint16 に。インデックスは頂点数 65535 以下なら Uint16
it.idx = nv <= 65535 ? pak.add(Uint16Array.from(m.idx)) : pak.add(Uint32Array.from(m.idx));

量子化の 1 段は「AABB の幅 ÷ 65535」です。周囲の簡略表示の建物は 260 m 四方のセルでメッシュを分けているので、1 段はおよそ 4 mm になります。テクスチャのない街路設備は種類ごと・48 m セルごとに分けており、こちらは 1 mm 前後です。分割の細かさが、そのまま精度とインデックス幅(Uint16 で済むか)を決めています。

テクスチャ付きの頂点で比べると、Float32 なら位置・法線・UV で 32 バイトのところ、この形式は位置 6・法線 3・UV 4 の 13 バイトです。頂点色の 4 バイト(AO の入れ物)を足しても 17 バイトで、Float32 に頂点色を足した 36 バイトの半分弱です。

ブラウザ側は次のように戻します。

// app/js/world.js:414-420(整形・抜粋)
const b = it.bb, P = pak.view(Uint16Array, it.pos), pos = new Float32Array(nv * 3);
const sx = (b[3] - b[0]) / 65535; // sy, sz も同様
for (let i = 0; i < nv; i++) pos[i * 3] = b[0] + P[i * 3] * sx; // y, z も同様
g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
g.setAttribute('normal', new THREE.BufferAttribute(new Int8Array(pak.buf, pak.base + it.nor[0], nv * 3), 3, true));
g.setAttribute('color', new THREE.BufferAttribute(pak.view(Uint8Array, it.col), 4, true));
if (it.uv) g.setAttribute('uv', new THREE.BufferAttribute(pak.view(Uint16Array, it.uv), 2, true));

法線・色・UV は normalized: true(第 3 引数)の BufferAttribute に TypedArray のビューを直接渡しており、コピーが発生しません。法線だけ view() を使わず長さを nv * 3 で切っているのは、書き出し側で 4 バイト境界まで詰め物をしているためです。

頂点 AO を頂点色のアルファに焼き込む

AO(環境遮蔽、物陰が暗くなる効果)は、段 3 で作った 25 cm 格子の高さラスタを使って頂点ごとに計算します。

// pipeline/export.mjs:14-19(整形・抜粋)
const DIRS = []; for (let a = 0; a < 8; a++) DIRS.push([Math.cos(a * Math.PI / 4), Math.sin(a * Math.PI / 4)]);
function aoAt(x, y, z, nx, ny, nz) {
  const wall = Math.abs(ny) < 0.6, /* hx, hz: 水平方向の法線 */
        px = x + nx * 0.22, py = y + ny * 0.22 + 0.03, pz = z + nz * 0.22;
  let vis = 0, ws = 0;
  for (const [dx, dz] of DIRS) {
    const w = wall ? Math.max(0, dx * hx + dz * hz) + 0.12 : 1; let ms = 0;
    for (const d of [0.5, 1, 2, 4, 8]) {
      const s = (heightAt(px + dx * d, pz + dz * d) - py) / d; if (s > ms) ms = s;
    }
    vis += w * (1 - ms / Math.sqrt(1 + ms * ms)); ws += w;
  }
  let a = vis / ws;
  if (wall && y - groundAt(x + hx * 0.3, z + hz * 0.3) < 0.35) a *= 0.82; // 壁の足元
  return Math.max(0, Math.min(1, 0.28 + 0.72 * Math.pow(a, 0.9)));
}

8 方向それぞれで 0.5〜8 m 先の高さを見て、最大の勾配 ms(= 水平線の仰角 θ の tan)を取ります。ms / sqrt(1 + ms²) は sin θ なので、各方向の値は 1 − sin θ です。これは、その方位の空の立体角に対する「見える割合」に当たります(余弦の重みは掛けていません)。壁は法線の向きに近い方向ほど重みを大きくし、地面から 35 cm 以内はさらに 0.82 倍します。最終値は 0.28〜1 に収めているので、真っ黒にはなりません。

実行時は、この値を間接光に強く、直射光に弱く掛けています。

// app/js/world.js:122(抜粋)
float aoK = mix(1.0, hAO, 0.86); reflectedLight.indirectDiffuse *= aoK;
reflectedLight.directDiffuse *= mix(1.0, hAO, 0.25);

焼き込みのコストは書き出し時に一度だけで、実行時の追加テクスチャもありません。路地の奥や軒下が暗く沈むのは、主にこの 4 バイト目の効果です。

pak 形式と、バイナリを配れないホストへの base64 .txt

書き出し側

pak は「8 バイトのヘッダ、JSON、ペイロード」を連結して deflate-raw で固めただけの形式です。

// pipeline/export.mjs:22-24(整形・抜粋)
class Pak {
  add(arr) { // 各配列を 4 バイト境界に揃えて追記し、[offset, 要素数] を返す
    const b = Buffer.from(arr.buffer, arr.byteOffset, arr.byteLength), pad = (4 - (this.off % 4)) % 4;
    if (pad) { this.parts.push(Buffer.alloc(pad)); this.off += pad; }
    const r = [this.off, arr.length]; this.parts.push(b); this.off += b.length; return r;
  }
  write(file, extra = {}) {
    const js = Buffer.from(JSON.stringify({ items: this.items, ...extra }), 'utf8'), pad = (4 - (js.length % 4)) % 4;
    const head = Buffer.alloc(8); head.writeUInt32LE(0x4b415048, 0); head.writeUInt32LE(js.length + pad, 4);
    const raw = Buffer.concat([head, js, Buffer.alloc(pad, 0x20), ...this.parts]); // JSON の後ろは空白で詰める
    fs.writeFileSync(file, zlib.deflateRawSync(raw, { level: 9 }));
  }
}

JSON の後ろを空白(0x20)で詰めているので、JSON.parse はそのまま通り、ペイロードの先頭も 4 バイト境界に乗ります。ペイロード内の各配列も 4 バイト境界から始まるので、ブラウザ側では new Uint32Array(buf, offset, length) のようなビューをコピーなしで作れます。

ここまでで .bin ができますが、アプリは .bin を読みません。後処理の pack.mjs が base64 の .txt に置き換えます。

// pipeline/pack.mjs:2, 7-10(抜粋)
// The first host of the app could not serve binary file extensions, so the app reads d/*.txt and t/{b,m}.txt.
for (const [dir, ext] of [['d', '.bin'], ['t', '.pak']]) for (const f of fs.readdirSync(`${OUT}/${dir}`)) {
  if (!f.endsWith(ext)) continue;
  const src = `${OUT}/${dir}/${f}`, dst = src.slice(0, -ext.length) + '.txt';
  fs.writeFileSync(dst, fs.readFileSync(src).toString('base64')); fs.rmSync(src);
}

読み込み側

ブラウザでは、fetch で受けたバイト列を 256 要素の逆引き表で base64 から戻し、DecompressionStream で展開します。atob は使わず、受け取った Uint8Array のまま処理しています。

// app/js/util.js:23-26, 28-35, 37-41(整形・抜粋)
export async function inflateRaw(bytes) {
  const ds = new DecompressionStream('deflate-raw'); const w = ds.writable.getWriter(); w.write(bytes); w.close();
  return await new Response(ds.readable).arrayBuffer();
}
const B64 = new Uint8Array(256);
for (let i = 0; i < 64; i++) B64['ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.charCodeAt(i)] = i;
export function b64bytes(src) { // src は Uint8Array のまま。末尾の = と改行・空白を除いて 4 文字 → 3 バイト
  /* ... */
  for (; i + 3 < n; i += 4) {
    const v = (B64[src[i]] << 18) | (B64[src[i + 1]] << 12) | (B64[src[i + 2]] << 6) | B64[src[i + 3]];
    out[o++] = v >> 16; out[o++] = (v >> 8) & 255; out[o++] = v & 255;
  }
  /* 端数 2〜3 文字の処理 */
}
export async function loadPak(url, onProg, expect) {
  const buf = await inflateRaw(await fetchData(url, onProg, expect)), dv = new DataView(buf), jl = dv.getUint32(4, true);
  const json = JSON.parse(new TextDecoder().decode(new Uint8Array(buf, 8, jl))), base = 8 + jl;
  return { json, buf, base, view(T, ref) { return new T(buf, base + ref[0], ref[1]); } };
}

view(T, ref) は JSON の [offset, 要素数] を受けて TypedArray を返すだけなので、メッシュを何百個並べても、展開後の ArrayBuffer 1 本を共有します。

配布データを復号して数え直した

記事のために、配布中の app/d/*.txt を Node.js(v22)で復号し、中身を数え直しました。ブラウザと同じ順序(base64 → deflate-raw 展開 → ヘッダの JSON)を Node 標準の Buffer と zlib でなぞるスクリプトです。

// pakdump.mjs(記事用に作成)
import fs from 'node:fs'; import zlib from 'node:zlib';
const dir = process.argv[2];
for (const f of ['core', 'ctx', 'nav']) {
  const b64 = fs.readFileSync(`${dir}/d/${f}.txt`, 'utf8').replace(/\s/g, '');
  const bin = Buffer.from(b64, 'base64'), raw = zlib.inflateRawSync(bin);
  const jl = raw.readUInt32LE(4), j = JSON.parse(raw.subarray(8, 8 + jl).toString('utf8'));
  let tris = 0, verts = 0; for (const it of j.items) { verts += it.nv; tris += it.ni / 3; }
  console.log(f, 'txt', b64.length, 'bin', bin.length, 'raw', raw.length, 'items', j.items?.length, 'verts', verts, 'tris', Math.round(tris));
}
ファイル .txt 展開後 頂点 三角形
core.txt(舞台の中心) 15,216,300 B 約 33.6 MB 1,931,839 646,087
ctx.txt(周囲の簡略表示) 約 5.7 MB 約 9.3 MB 622,524 207,508
nav.txt(歩行・高さのグリッド) 約 3.0 MB 約 14.2 MB - -

core は 195 メッシュで、内訳はテクスチャ付き LOD3 が 63、LOD2 が 23、街路設備(テクスチャ付き)が 15、テクスチャなし街路設備が 89 などです。三角形数は README の「約 64.6 万三角形」と一致しました。core.txt の 15,216,300 バイトは、world.js:407 に進捗表示用の期待値として書かれた数値とも一致します。

base64 化前の core.bin は 11,412,225 バイトで、.txt との比は 1.333 です。deflate で 33.6 MB を約 34% に縮め、base64 で 33% 戻している計算になります。

読み込みの順番は次のとおりです(world.js:402-411)。

テクスチャ予算: 見えている割合でアトラスを詰め直す

段 4 の atlas.mjs は、PLATEAU の元のテクスチャアトラスを場所ごとに作り直します。UV の連結成分(チャート)ごとに縮小率を決め、地物を Morton 曲線(Z-order 曲線)の順に並べてから 2048 px のアトラスへ詰め直します。近い地物が同じアトラスに入るので、アトラス単位で「近いものだけ高解像度」が成立します。

// pipeline/atlas.mjs:51-52(整形・抜粋)
for (const c of charts) {
  let s = BASE_SCALE; const visShare = c.vis / Math.max(1e-6, c.area);
  if (visShare < 0.25) s *= (c.roof >= c.hid ? ROOF_SCALE : HIDDEN_SCALE); // 既定はどちらも 0.25
  const dens = Math.sqrt(w * h / Math.max(0.01, c.area)); // 1 m あたりの画素数
  while (dens * s > MAXDENS && s > 1 / 16) s /= 2;        // 既定 128 px/m
}

vis と hid は、壁の三角形の重心から法線方向へ 0.45 m から 0.2 m 刻み(上限 1.25 m)で探り、高さラスタ上で隣の建物に当たるかどうかで分けた面積です(atlas.mjs:42-44)。見える面が 25% 未満のチャートは、屋根が多ければ屋根として、そうでなければ隠れた壁として 1/4 に縮めます。

詰め直したアトラスは 4 段階の WebP にします。

// pipeline/atlas.mjs:94([名前, 縮小率, WebP 品質])
const lv = [['u', 1, 80], ['h', 2, 78], ['m', 4, 76], ['b', 8, 74]];

実行時は、カメラからアトラスの AABB までの距離で段階を選びます(world.js:75-76)。

let want = d < Q.texU ? 3 : d < Q.texH ? 2 : d < Q.texM ? 1 : 0; if (want > Q.texMax) want = Q.texMax;

高画質の設定では 24 m 以内が最高段階、70 m 以内が高、190 m 以内が中です。中・軽の設定は texMax: 2 で最高段階を読みません(main.js:14-16)。リポジトリの docs/article.md によると、各段階の合計は最小 1.2 MB、中 3.2 MB、高 6.6 MB(101 枚)、最高 17.3 MB(101 枚)です。最小と中は {名前: [offset, length]} の索引を付けて 1 ファイルにまとめ、高と最高は個別の WebP として必要な分だけ取りに行きます。WebP はすでに圧縮済みなので、こちらは deflate を掛けずに連結だけしています(export.mjs:86)。

ハトの目の 320° パノラマを CubeCamera で描く

ドバトの視野は左右合わせて約 320° とされ、両眼で重なる帯は最大 27° です(27° は Martin & Young 1983 の計測で、出典は NOTICE.md にあります)。人間向けの 60° のカメラでは、この広さは出せません。そこで頭の位置に CubeCamera を置いて 6 面を描き、全画面のシェーダで引き直しています。

ハトの目のパノラマ表示(ヘッドレス Chrome・低画質設定での描画)
ヘッドレス Chrome(ソフトウェア描画・q=low)で撮影した、ハトの目のパノラマ。地形・建物は国土交通省 Project PLATEAU と国土地理院の地理院タイルを加工して作成しています。遠景の地表の色には、Landsat8 画像(GSI, TSIC, GEO Grid/AIST、courtesy of the U.S. Geological Survey)と海底地形(GEBCO)に由来するデータが含まれる場合があります。

// app/js/post.js:40-48(整形)
uniform samplerCube cube; uniform vec3 uF, uR, uUp; uniform float asp, hfov, vfov, binoc, rows;
void main(){ float yaw, pit;
  if (rows > 1.5) { // 縦持ち: 上段に左目、下段に右目
    float top = step(0.5, vUv.y), v = fract(vUv.y * 2.0);
    yaw = mix(mix(-0.2618, -hfov * 0.5, top), mix(hfov * 0.5, 0.2618, top), vUv.x); pit = (v - 0.5) * vfov;
  } else { yaw = (vUv.x - 0.5) * hfov; pit = (vUv.y - 0.5) * vfov; }
  vec3 d = cos(pit) * (cos(yaw) * uF + sin(yaw) * uR) + sin(pit) * uUp;
  vec4 c = textureCube(cube, d);
  float ay = abs(yaw);
  c.rgb *= 1.0 - smoothstep(hfov * 0.5 - 0.14, hfov * 0.5, ay);                 // 視野の端は死角へ
  c.rgb *= 1.0 + 0.06 * (1.0 - smoothstep(binoc - 0.02, binoc + 0.02, ay));  // 両眼視の帯を 6% 明るく
  if (rows > 1.5) c.rgb *= smoothstep(0.0, 0.004, abs(vUv.y - 0.5));          // 上下段の境目
  gl_FragColor = c; }

画面の横を方位角、縦を仰角に線形に割り当てる正距円筒型の展開です。binoc は 0.2356 rad(13.5°)で、左右合わせて 27° の帯になります。縦持ち(幅 ÷ 高さが 0.9 未満、game.js:156)では上下 2 段に分け、各段が −160°〜+15° と −15°〜+160° を受け持ちます。

// app/js/post.js:74-77(整形・抜粋)
if (!this.cubeRT) {
  this.cubeRT = new THREE.WebGLCubeRenderTarget(Q.panoSize, { type: this.type, generateMipmaps: false });
  this.cubeCam = new THREE.CubeCamera(0.012, 6000, this.cubeRT); // near は 1.2 cm
}
this.cubeCam.position.copy(eye); this.cubeCam.updateMatrixWorld(); this.cubeCam.update(r, scene);
const hf = 320 * Math.PI / 180, rows = this.panoRows || 1;
u.vfov.value = rows > 1 ? Math.min(1.75, (hf / 2 + 0.2618) * (this.h / 2) / this.w) : Math.min(2.0, hf * this.h / this.w);

縦の画角は、横 320° と同じ角度密度になるよう h / w を掛け、2.0 rad(約 115°)で頭打ちにしています。1280×720 なら計算上 3.14 rad になるので、2.0 rad が使われます。

CubeCamera はシーンを 6 回描きます。面の解像度は画質ごとに 768 / 512 / 384 px(main.js:14-16)で、高画質なら 768² × 6 ≈ 354 万画素と、1280×720(約 92 万画素)の約 3.8 倍を描く計算です。軽では 384² × 6 ≈ 88 万画素に収まります。なお、パノラマ表示中は水面の反射パスを描きません(main.js:47-49)。

確認した範囲と、確認していないこと

執筆にあたり、次の 3 点を実行して確認しました。

  • 配布データの復号: 上の pakdump.mjs で、三角形数・頂点数・ファイルサイズを数え直しました
  • 構文チェック: リポジトリの tools/jscheck.sh を実行し、all modules parse の出力を確認しました(node --check は .js をモジュールとして扱わないため、.mjs に写して調べるスクリプトです)
  • ヘッドレス描画: app/ を python3 -m http.server で配信し、ヘッドレス Chrome(SwiftShader によるソフトウェア描画)で ?auto=1&m=eye&t=9.6&q=low を撮影しました。ハトの目のパノラマが描かれ、「左目の視野」「両眼視 約27°」「右目の視野」「死角」のラベルとくちばしが出ることを画像で確認しました
google-chrome --headless=new --no-sandbox --use-angle=swiftshader --use-gl=angle \
  --enable-unsafe-swiftshader --ignore-gpu-blocklist --window-size=1280,720 \
  --virtual-time-budget=120000 --screenshot=shot_eye.png \
  "http://localhost:8765/?auto=1&m=eye&t=9.6&q=low"

一方、次の点は確認していません。

  • パイプライン(段 1〜6)の再実行。入力の 3D Tiles(約 1 GB)を取得していません。pipeline/README.md も、確かめたのは最後の段(export と pack)の出力がバイト単位で一致することだけだと書いています
  • 実機の GPU でのフレームレート、スマートフォンでの動作
  • Playwright を使う 3 エンジンの自動チェック(tools/run_checks.sh)と、ブラウザのコンソールエラー

コードを読み直して気づいたこと

記事のためにコードを読み直すと、手を入れる余地が 3 つありました。いずれも未検証の改善候補です。

1 つ目は、位置の量子化が転送サイズにしか効いていない点です。法線・色・UV は正規化属性として GPU へそのまま渡していますが、位置だけは読み込み時に Float32 へ戻しています(world.js:414-416)。core の 1,931,839 頂点なら、Float32 の位置だけで約 23 MB の頂点バッファになる計算です。Uint16 の正規化属性のまま渡し、AABB の原点と幅をメッシュの position と scale に持たせれば半分にできるはずです。ただし、当たり判定など位置配列を CPU で使う箇所との兼ね合いは調べていません。

2 つ目は、base64 化が独立した後処理になっている点です。pack.mjs のコメントによると、アプリの最初のホストはバイナリ拡張子を配れませんでした。バイナリを配れる静的ホスティングに移すなら、pack.mjs を外し、読み込み側の fetchData を fetchBytes に、参照パスを .bin に差し替えるだけで、core だけでファイルサイズが約 3.8 MB(15.2 MB − 11.4 MB)小さくなります(配信時の転送量では、ホストの圧縮設定しだいで差が変わるため未確認です)。制約への対処を変換パイプライン本体に混ぜず、外せる形で残しておいたのは良い分け方だと筆者は考えています。

3 つ目は細かい点で、読み込み側はヘッダのマジックナンバー(0x4b415048)を検査していません(util.js:38 はオフセット 4 の JSON 長だけを読みます)。別のファイルを取り違えても JSON.parse まで進んでから失敗します。また、AABB は小数 3 桁に丸めて保存しているのに、量子化は丸める前の値で行っているので、復元位置は最大 0.5 mm ほどずれます。ハトの目の near が 1.2 cm であることを考えると、実害はない大きさです。

おわりに

約 1 GB の 3D Tiles を 54 MB にした手段は、特別なライブラリではなく、量子化・焼き込み・見えない面の切り捨て・距離による出し入れという素朴な手の組み合わせでした。その上で「バイナリを置けない」というホストの制約を、base64 の後処理と DecompressionStream の 2 か所だけで吸収しています。

PLATEAU のデータを Web で見せたい場合、3D Tiles をそのままストリーミングする方法のほかに、範囲を絞って実行時専用の形式へ作り直す道があります。作品は こちらのアーティファクト で動かせます。ソースは GitHub にあります。

出典・ライセンス・参考リンク

本作品は、国土交通省 Project PLATEAU の 3D 都市モデル(京都市、2025 年度)と、国土地理院の地理院タイル(標高タイル、シームレス空中写真)を加工して作成したものです。国土交通省、国土地理院、京都市が内容を保証するものではありません。データごとの出典と利用条件は NOTICE.md にあります。コードの抜粋は MIT License(Copyright (c) 2026 kai-kou)のもとにあります。PLATEAU・地理院由来のデータ(app/d と app/t)と、それを使った画像は、元データの利用条件に従います。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?