Top View


Author matsuyama

会議室の利用状況を 3D 表示してみた

2026/06/02

X (Twitter) シェア

会議室が増えました!

Fusic ではメンバーの増加に伴い会議室も増えていきました。

これ を書いたときは会議室が 5 つ、サテライト (オンライン会議向けの個室) が 6 つでした。

今ではフロアが 4 つで会議室 13 個、サテライト 10 個と倍以上に増えました。

会議室監視アプリを運用しているのですが、2D 表現では、

  • 一画面に全ての会議室を表示するの厳しい
  • 名前検索にすると会議室の名前が分からないといけない
  • フロア選択 > 会議室選択 だと操作が増えてしまう

ということで、「今すぐ使いたいけど、どこが空いているのかパッと見たい」というユースケースでは使いにくい。

そこで、3D を利用した UI にしてみました!

サテライト番長

Microsoft Graph API と IoT デバイスからのデータを元に、緑が空いている、黄がもうすぐ使われる、赤が使われている部屋を示しています。

シークバーから時刻を選択することで、その時間の予約を確認でき、

時刻を選択

空いている緑色の部屋をクリックすれば、すぐに予約ができます。

予約を確認

AWS 構成

AWS 構成はこんな感じです。

AWS 構成

  • フロントから Microsoft Graph で全会議室の予約情報を取得するのには時間がかかったので、別途、EventBridge Scheduler で毎分 Microsoft Graph からカレンダー情報を取得し DynamoDB に snapshot を保存しておき、フロントからはこの snapshot を表示するようにしています。
  • そのままだと情報が古くなるので Subscription して、差分があれば更新しています。
  • IoT デバイスからの情報も Subscription して、最新の情報に更新れるようにしています。

3D Tiles の作成

これ では Google の Photorealistic 3D Tiles を使いましたが、お金がかかるので、今回は PLATEAU の Open Data から 3D Tiles を作成して S3 と CloudFront で配信するようにしました。

CityGML のダウンロード

3D都市モデル(Project PLATEAU)福岡市(2024年度) から CityGML(v4) をダウンロードします。

CityGML(v4)をダウンロード

3D Tiles, MVT(v4) もありますが、3D Tiles は建物だけで、道路と地形の 3D Tiles も欲しかったので、CityGML をダウンロードして 3D Tiles に変換しました。

3D Tiles 変換

変換には PLATEAU GIS Converter を使います。

索引図 を頼りに、使いたい CityGML ファイルを探して、 索引図

PLATEAU GIS Converter で 3D Tiles に変換します。 PLATEAU GIS Converter

作成した 3D Tiles を S3 に置いて配信すれば使えます。

最初は福岡市全域の 3D Tiles を作りましたが、tileset.json だけでも大きくなってしまったので会社付近のみに絞りました。

React + Three.js で表示

React Three Fiber3d-tiles-renderer を使って実装すると、以下のような感じです (ほぼ生成 AI)。

import { GLTFExtensionsPlugin, ReorientationPlugin } from "3d-tiles-renderer/plugins";
import { TilesPlugin, TilesRenderer } from "3d-tiles-renderer/r3f";
import { useEffect, useMemo } from "react";
import { MathUtils, type Mesh, type Object3D } from "three";
import { DRACOLoader } from "three/addons/loaders/DRACOLoader.js";

/** タイルモデル読込後のメッシュ加工(`PlateauTilesetLayer` が解釈) */
export type PlateauTilesetMeshStyle =
  | { readonly kind: "semiTransparent"; readonly opacity: number }
  | { readonly kind: "opaqueTint"; readonly color: number };

export type PlateauTilesetLayerDef = {
  readonly url: string;
  readonly meshStyle: PlateauTilesetMeshStyle;
  /** Reorientation 後ローカル系 Y(m)のずらし。地形との重なり解消用 */
  readonly yOffsetMeters?: number;
};

const DRACO_DECODER_BASE =
  "https://cdn.jsdelivr.net/npm/three@0.184.0/examples/jsm/libs/draco/gltf/"

const origin = window.location.origin;
const tilesBase = `${origin}/tiles`;
const PLATEAU_TILESETS: readonly PlateauTilesetLayerDef[] = [
  {
    url: `${tilesBase}/40130_fukuoka-shi_city_2024_3dtiles_2_op/bldg/503033/tileset.json`,
    meshStyle: { kind: "semiTransparent", opacity: 0.65 },
  },
  {
    url: `${tilesBase}/40130_fukuoka-shi_city_2024_3dtiles_2_op/dem/503033/tileset.json`,
    meshStyle: { kind: "opaqueTint", color: 0x4a9f5f },
  },
  {
    url: `${tilesBase}/40130_fukuoka-shi_city_2024_3dtiles_2_op/tran/503033/tileset.json`,
    meshStyle: { kind: "opaqueTint", color: 0x7d8288 },
    yOffsetMeters: 5,
  },
];

function applySemiTransparentMeshes(scene: Object3D, opacity: number) {
  scene.traverse((obj) => {
    const mesh = obj as Mesh;
    if (!(mesh.isMesh && mesh.material)) return;
    const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
    for (const m of materials) {
      const mat = m as typeof m & { depthWrite?: boolean };
      mat.transparent = true;
      mat.opacity = opacity;
      mat.depthWrite = false;
    }
  });
}

function applyOpaqueTintMeshes(scene: Object3D, color: number) {
  scene.traverse((obj) => {
    const mesh = obj as Mesh;
    if (!(mesh.isMesh && mesh.material)) return;
    const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
    for (const m of materials) {
      const mat = m as typeof m & {
        color?: { set: (hex: number) => void };
        depthWrite?: boolean;
      };
      mat.transparent = false;
      mat.opacity = 1;
      mat.depthWrite = true;
      if ("color" in mat && mat.color?.set) {
        mat.color.set(color);
      }
    }
  });
}

function applyMeshStyle(scene: Object3D, style: PlateauTilesetMeshStyle) {
  switch (style.kind) {
    case "semiTransparent":
      applySemiTransparentMeshes(scene, style.opacity);
      break;
    case "opaqueTint":
      applyOpaqueTintMeshes(scene, style.color);
      break;
  }
}

function FukuokaPlateauTilesets({
  layers,
  dracoLoader,
}: {
  layers: readonly PlateauTilesetLayerDef[];
  dracoLoader: DRACOLoader;
}) {
  const reorientArgs = useMemo(
    () => [
      {
        lat: MathUtils.degToRad(33.593606290552586),
        lon: MathUtils.degToRad(130.4010896982509),
        height: 0,
        recenter: true,
      },
    ],
    [],
  );

  return layers.map(({ url, meshStyle, yOffsetMeters = 0 }) => (
    <group key={url} position={[0, yOffsetMeters, 0]}>
      <TilesRenderer
        url={url}
        onLoadModel={(e: { scene: Object3D }) => applyMeshStyle(e.scene, meshStyle)}
      >
        <TilesPlugin plugin={ReorientationPlugin} args={reorientArgs} />
        <TilesPlugin plugin={GLTFExtensionsPlugin} args={[{ dracoLoader, autoDispose: false }]} />
      </TilesRenderer>
    </group>
  ));
}

export function PlateauTilesStack() {
  const dracoLoader = useMemo(() => {
    const l = new DRACOLoader();
    l.setDecoderPath(DRACO_DECODER_BASE);
    return l;
  }, []);

  useEffect(() => {
    return () => {
      dracoLoader.dispose();
    };
  }, [dracoLoader]);

  return <FukuokaPlateauTilesets layers={PLATEAU_TILESETS} dracoLoader={dracoLoader} />;
}
  • 建物と道路と地形の tileset.json を読み込み
  • onLoadModel で建物は半透明に、地形は緑色に、道路は灰色に設定
  • 道路が地形に埋没しないように 5 m 上にあげる
  • dracoLoader を忘れずに

まとめ

  • 位置関係を可視化したい場合は 3D を活用すると見やすいよ
  • PLATEAU のオープンデータで、良い感じに見えるよ
matsuyama

matsuyama

Twitter X

最近は Unity とか Three.js とか 3D に関わることが多いです。