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?

【egui】RustでデスクトップGISを作成する方法 ラスタータイル描画とレイヤーコントローラーの実装

0
Last updated at Posted at 2026-10-12

きっかけ

Rustで1からGISを作っている例が少なかったので作ってみようと思いました。

内容

  • 今回実装したのはGISの機能のほんの一部ですが、基礎的な部分から作っています。学習資料として見ていただければと思います。
  • マルチスレッド処理やベクターデータの表示は次回以降でやろうと思います。

使用した言語・ライブラリ

  • Rust言語
  • egui (デスクトップアプリ用ライブラリ)
  • proj-core (投影法の変換ライブラリ)
  • ogc-tms (XYZ形式の変換ライブラリ)

実装した主な機能

  • ラスタータイル(地理院地図などのベースマップ)の表示
  • ズーム・視点移動などの2D地図の基礎的な操作
  • レイヤー表示切替とスライダーによるレイヤー透明度設定
  • ドラッグによる表示順の入れ替え

実装したアプリの見た目(レイヤーコントローラーと地図)

image.png

動作イメージ

Video Project 3.gif

今回作成したデモアプリのソースコード

地図を表示するのに必要な情報

地図を画面に表示したければ、最初に緯度・経度・ズームレベルを指定する必要があります。

ラスタータイル(ベースマップ)について

下に示すのは地理院地図のラスタータイルの取得URLです。ラスタータイルを表示したければ、サーバー上の画像をXYZ形式のタイル番号を指定して取得する必要があります。
なお、1枚のタイルは、256pxの正方形です。

https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png

地図エンジン(計算を担当する部分)に必要な引数と返り値

今回は地図エンジンを一つの関数(create_map_obj)で作ることにしました。引数と返り値を示します。

引数

#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MapXYZ {
    pub latitude: f64, //緯度
    pub longitude: f64, //経度
    pub zoom: u32,//ズームレベル
}

#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MoveXY {
    pub move_x: f64,//移動ピクセルx
    pub move_y: f64,//移動ピクセルy
}

#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Screen {
    pub width: f64,//スクリーンの幅
    pub height: f64,//スクリーンの高さ
}

#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MapEngineInput {
    pub map_xyz: MapXYZ,
    pub screen: Screen,
    pub movement: MoveXY,
}

上に示したMapEngineInputが地図エンジンの引数となります。

返り値

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TileZXY {
    pub x: u32,
    pub y: u32,
    pub z: u32,
}

#[derive(Clone, Debug, PartialEq)]
pub struct MapObject {
    // WGS84: [longitude, latitude]
    pub center: [f64; 2],

    // WGS84: [min_lon, min_lat, max_lon, max_lat]
    pub bbox: [f64; 4],

    pub tiles: Vec<TileZXY>,

    // 最初のタイルの画面上の配置位置 (px)
    pub offset: [f64; 2],
}

返り値は、center、bbox、タイル一覧、左上のタイルのオフセット位置を返します。

地図エンジンのソースコード

crates\engine\src\lib.rs

pub use domain::types::gis_engine_type::{
    MapEngineInput, MapObject, MapXYZ, MoveXY, Screen, TileZXY,
};
use ogc_tms::{BoundingBox, TileMatrixSet};
use proj_core::Transform;

/// MapXYZ が有効かどうかを検証する。
pub fn validate_map_xyz(xyz: &MapXYZ) -> bool {
    if !xyz.latitude.is_finite() || !(-90.0..=90.0).contains(&xyz.latitude) {
        return false;
    }

    if !xyz.longitude.is_finite() || !(-180.0..=180.0).contains(&xyz.longitude) {
        return false;
    }

    xyz.zoom <= 30
}

/// MoveXY が有効かどうかを検証する。
pub fn validate_move(movement: &MoveXY) -> bool {
    movement.move_x.is_finite() && movement.move_y.is_finite()
}

/// Screen が有効かどうかを検証する。
pub fn validate_screen(screen: &Screen) -> bool {
    screen.width.is_finite()
        && screen.height.is_finite()
        && screen.width > 0.0
        && screen.height > 0.0
}

/// マップオブジェクトを作成する。
///
/// movement は画面上のドラッグ移動量 (px)。
/// 右方向・下方向へのドラッグを正の値とする。
pub fn create_map_obj(input: &MapEngineInput) -> Result<MapObject, Box<dyn std::error::Error>> {
    let xyz = input.map_xyz;
    let screen = input.screen;
    let movement = input.movement;

    // 入力値の検証
    if !validate_map_xyz(&xyz) {
        return Err("緯度、経度、ズームレベルが無効です".into());
    }

    if !validate_move(&movement) {
        return Err("不正なピクセル移動量です".into());
    }

    if !validate_screen(&screen) {
        return Err("Screenの大きさが不正です".into());
    }

    let zoom = xyz.zoom as u8;

    // XYZ / WebMercatorQuad
    let base_tms = ogc_tms::web_mercator_quad();
    let tms: TileMatrixSet = base_tms.extended_to(zoom)?;

    // WGS84 <-> Web Mercator
    let forward = Transform::new("EPSG:4326", "EPSG:3857")?;
    let inverse = forward.inverse()?;

    // Web Mercator の有効な緯度範囲に制限
    let lat = xyz
        .latitude
        .clamp(-ogc_tms::MERCATOR_LAT_LIMIT, ogc_tms::MERCATOR_LAT_LIMIT);

    // 移動前の中心座標を投影
    let (cx, cy) = forward.convert((xyz.longitude, lat))?;

    // 解像度 (m/px)
    let resolution = tms.resolution(zoom)?;

    // ドラッグ移動量を投影座標に反映
    // 右ドラッグ: 中心は西へ移動
    // 下ドラッグ: 中心は北へ移動
    let moved_cx = cx - movement.move_x * resolution;
    let moved_cy = cy + movement.move_y * resolution;

    // 移動後の中心座標を WGS84 に変換
    let (center_lon, center_lat) = inverse.convert((moved_cx, moved_cy))?;

    let center = [center_lon, center_lat];

    // 表示範囲を計算
    let half_w = screen.width * resolution / 2.0;
    let half_h = screen.height * resolution / 2.0;

    let left = moved_cx - half_w;
    let right = moved_cx + half_w;
    let bottom = moved_cy - half_h;
    let top = moved_cy + half_h;

    let projected_bbox = BoundingBox {
        left,
        bottom,
        right,
        top,
    };

    // 表示範囲に交差するタイルを列挙
    let mut tiles: Vec<TileZXY> = tms
        .tiles(projected_bbox, zoom..=zoom)?
        .map(|tile| -> Result<TileZXY, std::num::TryFromIntError> {
            Ok(TileZXY {
                x: u32::try_from(tile.x)?,
                y: u32::try_from(tile.y)?,
                z: u32::try_from(tile.z)?,
            })
        })
        .collect::<Result<Vec<_>, _>>()?;

    // 上から下、左から右の順番にする
    tiles.sort_by_key(|tile| (tile.y, tile.x));

    // 左上の座標を含むタイル
    let first_tile = tms.xy_tile(left, top, zoom)?;
    let first_bounds = tms.xy_bounds(first_tile)?;

    // 最初のタイルの画面上の配置位置 (px)
    let offset = [
        (first_bounds.left - left) / resolution,
        (top - first_bounds.top) / resolution,
    ];

    // 表示範囲の対角の座標を WGS84 に変換
    let (min_lon, min_lat) = inverse.convert((left, bottom))?;
    let (max_lon, max_lat) = inverse.convert((right, top))?;

    let bbox = [min_lon, min_lat, max_lon, max_lat];

    Ok(MapObject {
        center,
        bbox,
        tiles,
        offset,
    })
}

→引数(緯度経度など)をセットすると、返り値として地図表示に必要なXYZ形式のタイル番号の配列を受け取れる

地図画面の描画のソースコード

crates\window\src\apps\central_panel\central_panel_main.rs

use crate::apps::store::map_engine_store::{MapEngineStore, MapEngineStoreTrait};
use crate::apps::store::raster_tile_layer_store::{
    RasterTileLayerStore, RasterTileLayerStoreTrait,
};
use domain::params::map_config::{MAP_MAX_ZOOM_LEVEL, MAP_MIN_ZOOM_LEVEL, MAP_TILE_SIZE};
use domain::types::gis_engine_type::{MapObject, MapXYZ, MoveXY, Screen};
use eframe::egui;
use engine::create_map_obj;

pub struct CentralPanelMain {
    pub map_engine_store: MapEngineStore,
    pub raster_tile_layer_store: RasterTileLayerStore,
}

impl Default for CentralPanelMain {
    fn default() -> Self {
        Self {
            map_engine_store: MapEngineStore::default(),
            raster_tile_layer_store: RasterTileLayerStore::default(),
        }
    }
}

impl CentralPanelMain {
    /// 中央パネルを描画する。
    ///
    /// # Arguments
    /// * `ui` - 描画するUIコンテキスト
    /// * `ctx` - eguiのコンテキスト
    /// * `engine_store` - 地図エンジンの状態を管理するストア
    /// * `raster_tile_layer_store` - ラスタータイルレイヤーの状態を管理するストア
    pub fn use_central_panel_main(
        &mut self,
        ui: &mut egui::Ui,
        engine_store: &mut MapEngineStore,
        raster_tile_layer_store: &RasterTileLayerStore,
    ) {
        let ctx = ui.ctx().clone();

        // サイドパネルを除いた中央パネルのサイズを取得
        let viewport = ui.available_rect_before_wrap();
        let available_size = viewport.size();

        if available_size.x <= 0.0 || available_size.y <= 0.0 {
            return;
        }

        // 描画領域のサイズを設定
        engine_store.set_screen(Screen {
            width: available_size.x as f64,
            height: available_size.y as f64,
        });

        // 地図領域の入力を取得
        let response = ui.interact(
            viewport,
            ui.id().with("map_viewport"),
            egui::Sense::click_and_drag(),
        );

        // ズーム処理
        Self::handle_zoom_event(&response, &ctx, engine_store);

        // パン移動量はフレームごとに適用し、前回の地図中心を基準にする。
        let movement = Self::handle_pan_event(&response, &ctx);

        // 地図エンジンに渡す入力を組み立てる。
        let mut engine_input = engine_store.get_map_engine_input();
        let previous_map_center = engine_store
            .get_map_object()
            .map(|map_object| map_object.center);
        if let Some(center) = previous_map_center {
            engine_input.map_xyz = MapXYZ {
                latitude: center[1],
                longitude: center[0],
                zoom: engine_store.get_zoom(),
            };
        }
        engine_input.movement = movement;

        let map_obj = match create_map_obj(&engine_input) {
            Ok(map_obj) => map_obj,
            Err(error) => {
                eprintln!("GISエンジンのエラーです: {error}");
                return;
            }
        };
        let map_xyz = MapXYZ {
            latitude: map_obj.center[1],
            longitude: map_obj.center[0],
            zoom: engine_input.map_xyz.zoom,
        };

        // ラスタータイル描画
        Self::render_raster_tiles(ui, &map_obj, raster_tile_layer_store);
        engine_store.set_xyz(map_xyz);
        engine_store.set_map_object(map_obj);
    }

    /// マウスホイールによるズーム操作を処理する。
    ///
    /// # Arguments
    /// * `ui` - 描画するUIコンテキスト
    /// * `ctx` - eguiのコンテキスト
    /// * `engine_store` - 地図エンジンの状態を管理する
    fn handle_zoom_event(
        response: &egui::Response,
        ctx: &egui::Context,
        engine_store: &mut MapEngineStore,
    ) {
        if !response.hovered() {
            return;
        }

        let scroll_directions: Vec<f32> = ctx.input(|input| {
            input
                .events
                .iter()
                .filter_map(|event| {
                    if let egui::Event::MouseWheel { delta, .. } = event {
                        (delta.y != 0.0).then_some(delta.y.signum())
                    } else {
                        None
                    }
                })
                .collect()
        });

        if scroll_directions.is_empty() {
            return;
        }

        let mut zoom = engine_store.get_zoom();

        for direction in scroll_directions {
            zoom = if direction > 0.0 {
                //ズームレベルが最大値でない場合、ズームレベルを1つ増やす
                zoom.saturating_add(1).min(MAP_MAX_ZOOM_LEVEL)
            } else {
                //ズームレベルが最小値でない場合、ズームレベルを1つ減らす
                zoom.saturating_sub(1).max(MAP_MIN_ZOOM_LEVEL)
            };
        }

        engine_store.set_zoom(zoom);
        ctx.request_repaint();
    }

    /// マウスドラッグによるフレーム単位の移動量を返す。
    fn handle_pan_event(response: &egui::Response, ctx: &egui::Context) -> MoveXY {
        if response.dragged() || response.drag_stopped() {
            let delta = ctx.input(|input| input.pointer.delta());
            ctx.request_repaint();
            MoveXY {
                move_x: f64::from(delta.x),
                move_y: f64::from(delta.y),
            }
        } else {
            MoveXY {
                move_x: 0.0,
                move_y: 0.0,
            }
        }
    }

    /// ラスタータイルを描画する。
    ///
    /// # Arguments
    /// * `ui` - 描画するUIコンテキスト
    /// * `map_obj` - 地図オブジェクト
    /// * `raster_tile_layer_store` - ラスタータイルレイヤーの状態を管理するストア
    fn render_raster_tiles(
        ui: &mut egui::Ui,
        map_obj: &MapObject,
        raster_tile_layer_store: &RasterTileLayerStore,
    ) {
        // タイルがない場合は描画しない
        if map_obj.tiles.is_empty() {
            return;
        }

        let viewport = ui.available_rect_before_wrap();
        let origin = viewport.min;

        // 最初のタイルを基準に配置
        let first_tile = &map_obj.tiles[0];

        let offset_x = map_obj.offset[0] as f32;
        let offset_y = map_obj.offset[1] as f32;

        let layers = raster_tile_layer_store.get_all_raster_tile_layers();

        ui.scope_builder(
            egui::UiBuilder::new()
                .max_rect(viewport)
                .layout(egui::Layout::top_down(egui::Align::Min)),
            |ui| {
                ui.set_clip_rect(viewport);

                // 下のレイヤーから描画
                for layer in layers.iter().rev() {
                    if !layer.visible || layer.opacity <= 0.0 {
                        continue;
                    }

                    let alpha = (layer.opacity.clamp(0.0, 1.0) * 255.0).round() as u8;

                    for tile in &map_obj.tiles {
                        let x = origin.x
                            + offset_x
                            + (tile.x as f32 - first_tile.x as f32) * MAP_TILE_SIZE;
                        let y = origin.y
                            + offset_y
                            + (tile.y as f32 - first_tile.y as f32) * MAP_TILE_SIZE;

                        let rect = egui::Rect::from_min_size(
                            egui::pos2(x, y),
                            egui::vec2(MAP_TILE_SIZE, MAP_TILE_SIZE),
                        );
                        if !rect.intersects(viewport) {
                            continue;
                        }

                        let url = layer
                            .url
                            .replace("{z}", &tile.z.to_string())
                            .replace("{x}", &tile.x.to_string())
                            .replace("{y}", &tile.y.to_string());

                        ui.put(
                            rect,
                            egui::Image::new(url).tint(egui::Color32::from_white_alpha(alpha)),
                        );
                    }
                }
            },
        );
    }
}

地図エンジンを動かし返り値をストアに保存し、次の画面更新時の引数を作成する形でGISが動いています!

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?