Table of Contents
会議室が増えました!
Fusic ではメンバーの増加に伴い会議室も増えていきました。
これ を書いたときは会議室が 5 つ、サテライト (オンライン会議向けの個室) が 6 つでした。
今ではフロアが 4 つで会議室 13 個、サテライト 10 個と倍以上に増えました。
会議室監視アプリを運用しているのですが、2D 表現では、
- 一画面に全ての会議室を表示するの厳しい
- 名前検索にすると会議室の名前が分からないといけない
フロア選択>会議室選択だと操作が増えてしまう
ということで、「今すぐ使いたいけど、どこが空いているのかパッと見たい」というユースケースでは使いにくい。
そこで、3D を利用した UI にしてみました!

Microsoft Graph API と IoT デバイスからのデータを元に、緑が空いている、黄がもうすぐ使われる、赤が使われている部屋を示しています。
シークバーから時刻を選択することで、その時間の予約を確認でき、

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

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) をダウンロードします。

3D Tiles, MVT(v4) もありますが、3D Tiles は建物だけで、道路と地形の 3D Tiles も欲しかったので、CityGML をダウンロードして 3D Tiles に変換しました。
3D Tiles 変換
変換には PLATEAU GIS Converter を使います。
索引図 を頼りに、使いたい CityGML ファイルを探して、

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

作成した 3D Tiles を S3 に置いて配信すれば使えます。
最初は福岡市全域の 3D Tiles を作りましたが、tileset.json だけでも大きくなってしまったので会社付近のみに絞りました。
React + Three.js で表示
React Three Fiber と 3d-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 のオープンデータで、良い感じに見えるよ
Related Posts
shiro seike / せいけ しろう / 清家 史郎
2026/03/30
shiro seike / せいけ しろう / 清家 史郎
2025/12/21
Guiart Thomas
2025/12/16