見出し画像

UnityでGeoJSON/KMLファイルをアセットとして扱う

はじめに

Unityで経緯度など地理情報と連携したアプリの開発を行う際に、一般的な地理情報データのフォーマットを扱いたい場合があります。 今回はGeoJSON/KMLファイルをUnityのプロジェクトへインポートし、ScriptableObjectとして扱えるようにする方法を紹介します。


開発環境

  • Unity 2022.3.36

  • NuGetForUnity 4.1.1

  • NewtonsoftJson 3.2.1

  • SharpKml.Core 6.1.0

各フォーマットについて

GeoJSON/KMLは共に点や線、ポリゴンなどのジオメトリの表現が可能です。 KMLの方がより複雑ですが、その分表現力が高いです。 今回は基本的なジオメトリの点と線をインポートの対象とします。

MapBox GL Style Specのリンクはこちら

サンプルデータの作成

geojson.ioを利用します。 geojson.ioは地図上でジオメトリの追加や編集を行い、GeoJSONやKMLを手軽に作成できるWebサービスです。

  1. 地図右上にあるボタンから各ジオメトリを追加してください

  2. 地図の編集が完了したら、地図左上のSaveボタンからGeoJSONとKMLを保存します

ScriptedImporter

.geojsonと.kmlはUnity標準ではサポートしていないファイル形式です。 このような形式のファイルは、ScriptedImporterで対応させる事ができます。 ScriptedImporterは、Unityのカスタムアセットインポートシステムで、開発者が特定のファイル形式に独自のインポートロジックを定義できます。

以下はgeojsonかkmlファイルをインポートした時にファイル形式をログ出力するサンプルです。

// GeographicImporter.cs

namespace GeographicImporter.Editor
{
    [ScriptedImporter(1, new[] {  "geojson", "kml" })]
    public sealed class GeographicDataImporter : ScriptedImporter
    {
        public override void OnImportAsset(AssetImportContext ctx)
        {
            try
            {
                var content = File.ReadAllText(ctx.assetPath);
                switch (Path.GetExtension(ctx.assetPath).ToLower())
                {
                    case ".geojson":
                        Debug.Log($"geojson file detected: {ctx.assetPath}");
                        break;
                    case ".kml":
                        Debug.Log($"kml file detected: {ctx.assetPath}");
                        break;
                    default:
                        break;
                }
            }
            catch (Exception ex)
            {
                Debug.LogError($"Error importing geographic data: {ex.Message}");
            }
        }
    }
}

インポート対象とするデータの定義

今回はGeoJSONとKMLの表現可能なジオメトリのうち、点と線をアセットとして保存できるようにします。 GeographicDataの中に地物リストがあり、各地物にはジオメトリの種類や座標リストが含まれる構造となっています。

namespace GeographicImporter.Runtime
{
    [Serializable]
    public class GeographicData
    {
        public string name;
        public string description;
        public List<Feature> features = new();
    }

    /// <summary>
    /// 地物
    /// </summary>
    [Serializable]
    public class Feature
    {
        public string name;
        public string description;
        public GeometryType geometryType;
        public List<Coordinates> coordinates = new();
        public Dictionary<string, object> properties = new();
        public AltitudeMode altitudeMode;
    }

    /// <summary>
    /// 緯度、 経度、 高度の座標
    /// </summary>
    [Serializable]
    public struct Coordinates
    {
        public double latitude;
        public double longitude;
        public double altitude;

        public Coordinates(double latitude, double longitude, double altitude = 0)
        {
            this.latitude = latitude;
            this.longitude = longitude;
            this.altitude = altitude;
        }
        public override string ToString() => $"({latitude}, {longitude}, {altitude})";
    }

    // 今回使うのはPoint, LineStringのみ
    public enum GeometryType
    {
        Point,
        LineString,
        Polygon,
        MultiGeometry,
        MultiPoint,
        MultiLineString,
        MultiPolygon
    }

    public enum AltitudeMode
    {
        ClampToGround,
        RelativeToGround,
        Absolute
    }
}

このデータを保存するScriptableObjectを用意します。

namespace GeographicImporter.Runtime
{
    public class GeographicDataAsset : ScriptableObject
    {
        [SerializeField]
        public GeographicData data;
    }
}

データを読み込む

GeoJSONとKMLから地理情報のデータを読み込めるようにします。 次のインターフェースをGeoJSONとKML用に実装します。

namespace GeographicImporter.Runtime.Parser
{
    public interface IGeoParser
    {
        GeographicData Parse(string content);
    }
}

GeoJSON

GeoJSONはJSONベースのフォーマットのため、Newtonsoft Jsonを利用します。 PackageManagerから com.unity.nuget.newtonsoft-json をインポートしてください。

namespace GeographicImporter.Runtime.Parser
{
    public sealed class GeoJsonParser : IGeoParser
    {
        private static class GeoJsonKeys
        {
            public const string Name = "name";
            public const string Type = "type";
            public const string Features = "features";
            public const string Properties = "properties";
            public const string Geometry = "geometry";
            public const string Coordinates = "coordinates";
            public const string Description = "description";
        }

        // https://geojson.org/
        private static class GeometryType
        {
            public const string Point = "Point";
            public const string LineString = "LineString";
            public const string Polygon = "Polygon";
            public const string MultiPoint = "MultiPoint";
            public const string MultiLineString = "MultiLineString";
            public const string MultiPolygon = "MultiPolygon";
            public const string FeatureCollection = "FeatureCollection";
            public const string Feature = "Feature";
        }

        public GeographicData Parse(string content)
        {
            var json = JObject.Parse(content);

            var geographicData = new GeographicData
            {
                name = json[GeoJsonKeys.Name]?.ToString()
            };

            var type = json[GeoJsonKeys.Type]?.ToString() ?? throw new InvalidDataException($"Invalid GeoJSON: '{GeoJsonKeys.Type}' is missing or invalid.");

            switch (type)
            {
                case GeometryType.FeatureCollection:
                    if (json[GeoJsonKeys.Features] is JArray features)
                    {
                        geographicData.features.AddRange(features.Select(ToFeature));
                    }
                    else
                    {
                        throw new InvalidDataException($"Invalid GeoJSON: '{GeoJsonKeys.Features}' is missing or not an array in FeatureCollection.");
                    }
                    break;
                case GeometryType.Feature:
                    geographicData.features.Add(ToFeature(json));
                    break;
                default:
                    throw new InvalidDataException($"Invalid GeoJSON: Root element must be FeatureCollection or Feature, but got {type}.");
            }

            return geographicData;
        }

        private static Feature ToFeature(JToken featureJson)
        {
            var properties = featureJson[GeoJsonKeys.Properties] as JObject;
            var feature = new Feature
            {
                name = properties?[GeoJsonKeys.Name]?.ToString(),
                description = properties?[GeoJsonKeys.Description]?.ToString()
            };

            var geometry = featureJson[GeoJsonKeys.Geometry] as JObject ?? throw new InvalidDataException($"Invalid GeoJSON: '{GeoJsonKeys.Geometry}' is missing or invalid in Feature.");
            var geometryType = geometry[GeoJsonKeys.Type]?.ToString() ?? throw new InvalidDataException($"Invalid GeoJSON: '{GeoJsonKeys.Type}' is missing or invalid in geometry.");

            feature.geometryType = ToGeometryType(geometryType);
            feature.coordinates = ToCoordinates(geometry[GeoJsonKeys.Coordinates] as JArray ?? throw new InvalidDataException($"Invalid GeoJSON: '{GeoJsonKeys.Coordinates}' is missing or not an array in geometry."), feature.geometryType);

            if (properties == null) return feature;

            foreach (var prop in properties.Properties())
            {
                feature.properties[prop.Name] = prop.Value.ToObject<object>() ?? throw new InvalidDataException($"Invalid property value for '{prop.Name}'");
            }

            return feature;
        }

        private static Runtime.GeometryType ToGeometryType(string type) => type switch
        {
            GeometryType.Point => Runtime.GeometryType.Point,
            GeometryType.LineString => Runtime.GeometryType.LineString,
            GeometryType.Polygon => Runtime.GeometryType.Polygon,
            GeometryType.MultiPoint => Runtime.GeometryType.MultiPoint,
            GeometryType.MultiLineString => Runtime.GeometryType.MultiLineString,
            GeometryType.MultiPolygon => Runtime.GeometryType.MultiPolygon,
            _ => throw new NotSupportedException($"Unsupported geometry type: {type}")
        };

        private static List<Coordinates> ToCoordinates(JArray coordinatesJson, Runtime.GeometryType geometryType) => geometryType switch
        {
            Runtime.GeometryType.Point => new List<Coordinates> { ToCoordinates(coordinatesJson) },
            Runtime.GeometryType.LineString => coordinatesJson.Select(ToCoordinates).ToList(),
            Runtime.GeometryType.Polygon => coordinatesJson.FirstOrDefault()?.Select(ToCoordinates).ToList()
                ?? throw new InvalidDataException("Invalid GeoJSON: Empty polygon coordinates."),
            Runtime.GeometryType.MultiPoint or Runtime.GeometryType.MultiLineString or Runtime.GeometryType.MultiPolygon =>
                throw new NotSupportedException($"Complex geometry type {geometryType} is not fully supported."),
            _ => throw new InvalidOperationException($"Unexpected geometry type: {geometryType}")
        };

        private static Coordinates ToCoordinates(JToken coord)
        {
            if (coord is not JArray array || array.Count < 2)
            {
                throw new InvalidDataException("Invalid GeoJSON: Invalid coordinate format.");
            }

            return new Coordinates(
                latitude: (double)array[1]?.Value<double>(),
                longitude: (double)array[0]?.Value<double>(),
                altitude: array.Count > 2 ? array[2]?.Value<double>() ?? 0 : 0
            );
        }
    }
}

KML

KMLはXMLベースのフォーマットなのでXDocumentなどを使用することもできますが、今回はKML用のライブラリを導入してみます。 NuGetで配布されているsharpkmlを利用します。

NuGetのパッケージなので、まずはNuGetForUnityをインポートします。

インポートが完了したら、NuGet For Unityのウィンドウを開き、SharpKml.Coreをインストールします。

namespace GeographicImporter.Runtime.Parser
{
    public sealed class KmlParser : IGeoParser
    {
        public GeographicData Parse(string content)
        {
            using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
            var kmlFile = KmlFile.Load(stream);

            var kml = kmlFile.Root as Kml ?? throw new InvalidDataException("Invalid KML file: Root element is not Kml.");
            var rootFeature = kml.Feature ?? throw new InvalidDataException("Invalid KML file: No root feature found.");

            var geographicData = new GeographicData();

            if (rootFeature is Document document)
            {
                geographicData.name = document.Name;
                geographicData.description = document.Description?.Text;

                geographicData.features = document.Features.SelectMany(ProcessKmlFeature).ToList();
            }
            else
            {
                geographicData.features = ProcessKmlFeature(rootFeature).ToList();
            }

            return geographicData;
        }

        private static IEnumerable<Feature> ProcessKmlFeature(SharpKml.Dom.Feature feature)
        {
            // KmlにはFolderなどがあるが、今のところはPlacemarkのみ対応
            switch (feature)
            {
                case SharpKml.Dom.Placemark p:
                    yield return ProcessPlacemark(p);
                    break;
                default:
                    Debug.LogError($"Unsupported feature type: {feature.GetType().Name}");
                    break;
            }
        }

        private static Feature ProcessPlacemark(SharpKml.Dom.Placemark kmlPlacemark)
        {
            var placemark = new Feature
            {
                name = kmlPlacemark.Name,
                description = kmlPlacemark.Description?.Text,
            };

            switch (kmlPlacemark.Geometry)
            {
                case SharpKml.Dom.Point point:
                    placemark.geometryType = GeometryType.Point;
                    placemark.coordinates.Add(ToCoordinate(point.Coordinate));
                    placemark.altitudeMode = ConvertAltitudeMode(point.AltitudeMode);
                    break;
                case SharpKml.Dom.LineString lineString:
                    placemark.geometryType = GeometryType.LineString;
                    placemark.coordinates.AddRange(lineString.Coordinates.Select(ToCoordinate));
                    placemark.altitudeMode = ConvertAltitudeMode(lineString.AltitudeMode);
                    break;
                case SharpKml.Dom.Polygon polygon:
                    placemark.geometryType = GeometryType.Polygon;
                    placemark.coordinates.AddRange(polygon.OuterBoundary.LinearRing.Coordinates.Select(ToCoordinate));
                    placemark.altitudeMode = ConvertAltitudeMode(polygon.AltitudeMode);
                    break;
                default:
                    throw new NotSupportedException($"Unsupported geometry type: {kmlPlacemark.Geometry?.GetType().Name ?? "null"}");
            }

            return placemark;
        }

        private static AltitudeMode ConvertAltitudeMode(SharpKml.Dom.AltitudeMode? kmlAltitudeMode) =>
            kmlAltitudeMode switch
            {
                SharpKml.Dom.AltitudeMode.ClampToGround => AltitudeMode.ClampToGround,
                SharpKml.Dom.AltitudeMode.RelativeToGround => AltitudeMode.RelativeToGround,
                SharpKml.Dom.AltitudeMode.Absolute => AltitudeMode.Absolute,
                _ => AltitudeMode.ClampToGround
            };

        private static Coordinates ToCoordinate(SharpKml.Base.Vector v) => new(v.Latitude, v.Longitude, v.Altitude ?? 0d);
    }
}

ScriptedImporter

GeographicDataImporterクラスを次のように変更します。

namespace GeographicImporter.Editor
{
    [ScriptedImporter(1, new[] { "kml", "geojson" })]
    public sealed class GeographicDataImporter : ScriptedImporter
    {
        public override void OnImportAsset(AssetImportContext ctx)
        {
            try
            {
                var content = File.ReadAllText(ctx.assetPath);

                IGeoParser parser = Path.GetExtension(ctx.assetPath).ToLower() switch
                {
                    ".kml" => new KmlParser(),
                    ".geojson" => new GeoJsonParser(),
                    _ => throw new NotSupportedException($"Unsupported file format: {Path.GetExtension(ctx.assetPath)}")
                };

                var geographicData = parser.Parse(content);

                var geographicAsset = ScriptableObject.CreateInstance<GeographicDataAsset>();
                geographicAsset.data = geographicData;

                ctx.AddObjectToAsset("geographicData", geographicAsset);
                ctx.SetMainObject(geographicAsset);

            }
            catch (Exception ex)
            {
                Debug.LogError($"Error importing geographic data: {ex.Message}");
            }
        }
    }
}

これで、KMLまたはGeoJSONファイルをプロジェクトへインポートするとScriptableObjectとして読み込まれるようになりました。

まとめ

Unityが標準で対応していないフォーマットでも、ScriptedImporterを使って手軽にアセット化することができます。

また、ファイル内容の解析を自前実装するのが大変な場合でも、NuGetのパッケージが利用できるケースも多いです。

Unityプロジェクトで使用するライブラリを探す際にOpenUPMだけでなくNuGetも含めておくと選択の幅が大きく広がる(特に非ゲーム領域)ため、ぜひ検討してみてください。

いいなと思ったら応援しよう!