diff --git a/geodata_pipeline/buildings.py b/geodata_pipeline/buildings.py index 9e15158..6ba8392 100644 --- a/geodata_pipeline/buildings.py +++ b/geodata_pipeline/buildings.py @@ -350,12 +350,20 @@ def _rebase_cityjson(path: str, bounds: Tuple[float, float, float, float], out_p if not data: return False xmin, ymin, _, _ = bounds + transform = data.get("transform") or {} + scale = transform.get("scale") or [1.0, 1.0, 1.0] + translate = transform.get("translate") or [0.0, 0.0, 0.0] verts = data.get("vertices") or [] rebased: list[list[float]] = [] for v in verts: if len(v) >= 3: - rebased.append([v[0] - xmin, v[1] - ymin, v[2]]) + x = v[0] * scale[0] + translate[0] - xmin + y = v[1] * scale[1] + translate[1] - ymin + z = v[2] * scale[2] + translate[2] + rebased.append([x, y, z]) data["vertices"] = rebased + if "transform" in data: + data.pop("transform", None) # Update geographicalExtent if present xs = [v[0] for v in rebased] ys = [v[1] for v in rebased] @@ -420,6 +428,12 @@ def _ensure_cityjson_for_tile(tile_id: str, bounds: Tuple[float, float, float, f ok = _rebase_cityjson(tri_path, bounds, rebased_path) if not ok: return None + else: + existing = _load_cityjson(rebased_path) or {} + if existing.get("transform"): + ok = _rebase_cityjson(tri_path, bounds, rebased_path) + if not ok: + return None return rebased_path diff --git a/scripts_unity/Editor/GeoBoundsDebug.cs b/scripts_unity/Editor/GeoBoundsDebug.cs new file mode 100644 index 0000000..cdf88e1 --- /dev/null +++ b/scripts_unity/Editor/GeoBoundsDebug.cs @@ -0,0 +1,35 @@ +using UnityEditor; +using UnityEngine; + +public static class GeoBoundsDebug +{ + [MenuItem("Tools/Geo/Print Selected Mesh Bounds")] + public static void PrintSelectedMeshBounds() + { + foreach (var go in Selection.gameObjects) + { + var mf = go.GetComponent(); + if (mf == null || mf.sharedMesh == null) + { + Debug.Log($"{go.name}: no MeshFilter/mesh"); + continue; + } + + var mesh = mf.sharedMesh; + var b = mesh.bounds; + + // Mesh bounds are in local mesh space + var centerWorld = go.transform.TransformPoint(b.center); + + // Approximate size in world space (ignores rotation, good enough for diagnosis) + var lossy = go.transform.lossyScale; + var sizeWorld = new Vector3( + Mathf.Abs(b.size.x * lossy.x), + Mathf.Abs(b.size.y * lossy.y), + Mathf.Abs(b.size.z * lossy.z) + ); + + Debug.Log($"{go.name}: worldCenter={centerWorld}, worldSize={sizeWorld}"); + } + } +} diff --git a/scripts_unity/Editor/GeoTileImporter.cs b/scripts_unity/Editor/GeoTileImporter.cs new file mode 100644 index 0000000..33b494d --- /dev/null +++ b/scripts_unity/Editor/GeoTileImporter.cs @@ -0,0 +1,975 @@ +// Assets/Editor/GeoTileImporter.cs +// Robust terrain tile importer for Unity (URP or Built-in). +// - Parses CSV header -> column name mapping (order-independent) +// - Validates required columns exist +// - Imports 16-bit PNG heightmaps (tries Single Channel R16 + ushort pixel read; falls back safely) + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using UnityEditor; +using UnityEngine; + +public class GeoTileImporter : EditorWindow +{ + private string tilesCsvPath = "Assets/GeoData/tile_index.csv"; + private string heightmapsDir = "Assets/GeoData/height_png16"; + private string orthoDir = "Assets/GeoData/ortho_jpg"; + private string buildingsDir = "Assets/GeoData/buildings_tiles"; + private string buildingsEnhancedDir = "Assets/GeoData/buildings_enhanced"; + private string treesDir = "Assets/GeoData/trees_tiles"; + private string treeProxyPath = "Assets/GeoData/tree_proxies.glb"; + private string furnitureDir = "Assets/GeoData/street_furniture"; + private string enhancedTreesDir = "Assets/GeoData/trees_enhanced"; + + private float tileSizeMeters = 1000f; + private int heightmapResolution = 1025; + private bool flipTerrainNorthSouth = true; + + private string parentName = "Geo_Terrain_Tiles"; + private string buildingsParentName = "Geo_Buildings"; + private string treesParentName = "Geo_Trees"; + private string furnitureParentName = "Geo_Furniture"; + private bool deleteExisting = false; + private bool applyOrthoTextures = true; + private bool importBuildings = true; + private bool useEnhancedBuildings = false; + private bool importTrees = true; + private bool importFurniture = false; + private bool deleteExistingBuildings = false; + private bool deleteExistingTrees = false; + private bool deleteExistingFurniture = false; + private bool importEnhancedTrees = false; + private bool deleteExistingEnhancedTrees = false; + private string enhancedTreesParentName = "Geo_Trees_Enhanced"; + + // Prefabs for trees and furniture (assign in editor) + private GameObject treePrefab; + private GameObject lampPrefab; + private GameObject benchPrefab; + private GameObject signPrefab; + private GameObject bollardPrefab; + private GameObject defaultFurniturePrefab; // Fallback for unknown types + + [MenuItem("Tools/Geo Tiles/Import Terrain Tiles (PNG16)")] + public static void ShowWindow() + { + var win = GetWindow("Geo Tile Importer"); + win.minSize = new Vector2(620, 300); + } + + private void OnGUI() + { + GUILayout.Label("Inputs", EditorStyles.boldLabel); + tilesCsvPath = EditorGUILayout.TextField("tile_index.csv", tilesCsvPath); + heightmapsDir = EditorGUILayout.TextField("height_png16 dir", heightmapsDir); + orthoDir = EditorGUILayout.TextField("ortho_jpg dir", orthoDir); + buildingsDir = EditorGUILayout.TextField("buildings_glb dir", buildingsDir); + treesDir = EditorGUILayout.TextField("trees_glb dir", treesDir); + treeProxyPath = EditorGUILayout.TextField("tree_proxies.glb", treeProxyPath); + + GUILayout.Space(10); + GUILayout.Label("Terrain", EditorStyles.boldLabel); + tileSizeMeters = EditorGUILayout.FloatField("Tile size (m)", tileSizeMeters); + heightmapResolution = EditorGUILayout.IntField("Heightmap resolution", heightmapResolution); + flipTerrainNorthSouth = EditorGUILayout.ToggleLeft("Flip terrain north/south", flipTerrainNorthSouth); + + GUILayout.Space(10); + GUILayout.Label("Scene", EditorStyles.boldLabel); + parentName = EditorGUILayout.TextField("Parent object name", parentName); + deleteExisting = EditorGUILayout.ToggleLeft("Delete existing terrains under parent", deleteExisting); + applyOrthoTextures = EditorGUILayout.ToggleLeft("Apply ortho texture per tile", applyOrthoTextures); + buildingsParentName = EditorGUILayout.TextField("Buildings parent name", buildingsParentName); + buildingsEnhancedDir = EditorGUILayout.TextField("Buildings enhanced dir", buildingsEnhancedDir); + deleteExistingBuildings = EditorGUILayout.ToggleLeft("Delete existing buildings under parent", deleteExistingBuildings); + importBuildings = EditorGUILayout.ToggleLeft("Import buildings (GLB per tile)", importBuildings); + useEnhancedBuildings = EditorGUILayout.ToggleLeft("Use enhanced buildings (from buildings_enhanced/)", useEnhancedBuildings); + + GUILayout.Space(5); + treesParentName = EditorGUILayout.TextField("Trees parent name", treesParentName); + deleteExistingTrees = EditorGUILayout.ToggleLeft("Delete existing trees under parent", deleteExistingTrees); + importTrees = EditorGUILayout.ToggleLeft("Import trees (GLB chunks per tile)", importTrees); + + GUILayout.Space(5); + furnitureParentName = EditorGUILayout.TextField("Furniture parent name", furnitureParentName); + furnitureDir = EditorGUILayout.TextField("Furniture CSV dir", furnitureDir); + deleteExistingFurniture = EditorGUILayout.ToggleLeft("Delete existing furniture under parent", deleteExistingFurniture); + importFurniture = EditorGUILayout.ToggleLeft("Import street furniture (from CSV)", importFurniture); + + GUILayout.Space(5); + enhancedTreesParentName = EditorGUILayout.TextField("Enhanced trees parent", enhancedTreesParentName); + enhancedTreesDir = EditorGUILayout.TextField("Enhanced trees CSV dir", enhancedTreesDir); + deleteExistingEnhancedTrees = EditorGUILayout.ToggleLeft("Delete existing enhanced trees", deleteExistingEnhancedTrees); + importEnhancedTrees = EditorGUILayout.ToggleLeft("Import enhanced trees (CSV with canopy colors)", importEnhancedTrees); + + GUILayout.Space(10); + GUILayout.Label("Prefabs (optional)", EditorStyles.boldLabel); + treePrefab = (GameObject)EditorGUILayout.ObjectField("Tree Prefab", treePrefab, typeof(GameObject), false); + lampPrefab = (GameObject)EditorGUILayout.ObjectField("Lamp Prefab", lampPrefab, typeof(GameObject), false); + benchPrefab = (GameObject)EditorGUILayout.ObjectField("Bench Prefab", benchPrefab, typeof(GameObject), false); + signPrefab = (GameObject)EditorGUILayout.ObjectField("Sign Prefab", signPrefab, typeof(GameObject), false); + bollardPrefab = (GameObject)EditorGUILayout.ObjectField("Bollard Prefab", bollardPrefab, typeof(GameObject), false); + defaultFurniturePrefab = (GameObject)EditorGUILayout.ObjectField("Default Furniture Prefab", defaultFurniturePrefab, typeof(GameObject), false); + + GUILayout.Space(12); + if (GUILayout.Button("Import / Rebuild")) + ImportTiles(); + + EditorGUILayout.HelpBox( + "Creates one Unity Terrain per CSV row and positions tiles on a meter grid.\n" + + "Absolute elevation mapping: Terrain Y = global_min, Terrain height = (global_max - global_min).\n" + + "CSV is header-driven (order-independent). Optionally applies ortho JPGs and instantiates buildings/trees GLBs.", + MessageType.Info); + } + + private static void EnsureHeightmapImportSettings(string assetPath) + { + var ti = (TextureImporter)AssetImporter.GetAtPath(assetPath); + if (ti == null) return; + + bool changed = false; + + if (!ti.isReadable) { ti.isReadable = true; changed = true; } + if (ti.sRGBTexture) { ti.sRGBTexture = false; changed = true; } + + if (ti.textureCompression != TextureImporterCompression.Uncompressed) + { + ti.textureCompression = TextureImporterCompression.Uncompressed; + changed = true; + } + + if (ti.npotScale != TextureImporterNPOTScale.None) + { + ti.npotScale = TextureImporterNPOTScale.None; // keep 1025x1025 + changed = true; + } + + if (ti.textureType != TextureImporterType.SingleChannel) + { + ti.textureType = TextureImporterType.SingleChannel; + changed = true; + } + + var ps = ti.GetDefaultPlatformTextureSettings(); + if (ps.format != TextureImporterFormat.R16) + { + ps.format = TextureImporterFormat.R16; + ti.SetPlatformTextureSettings(ps); + changed = true; + } + + changed |= EnsurePlatformR16(ti, "Standalone"); + changed |= EnsurePlatformR16(ti, "Android"); + changed |= EnsurePlatformR16(ti, "iPhone"); + + if (changed) ti.SaveAndReimport(); + } + + private static bool EnsurePlatformR16(TextureImporter ti, string platform) + { + var ps = ti.GetPlatformTextureSettings(platform); + + bool changed = false; + if (ps.name != platform) + { + ps = new TextureImporterPlatformSettings + { + name = platform, + overridden = true, + format = TextureImporterFormat.R16, + textureCompression = TextureImporterCompression.Uncompressed, + }; + ti.SetPlatformTextureSettings(ps); + return true; + } + + if (!ps.overridden) { ps.overridden = true; changed = true; } + if (ps.format != TextureImporterFormat.R16) { ps.format = TextureImporterFormat.R16; changed = true; } + if (ps.textureCompression != TextureImporterCompression.Uncompressed) + { + ps.textureCompression = TextureImporterCompression.Uncompressed; + changed = true; + } + + if (changed) ti.SetPlatformTextureSettings(ps); + return changed; + } + + private static string NormalizeHeader(string s) + => (s ?? "").Trim().ToLowerInvariant(); + + private static void EnsureOrthoImportSettings(string assetPath) + { + var ti = (TextureImporter)AssetImporter.GetAtPath(assetPath); + if (ti == null) return; + + bool changed = false; + if (!ti.isReadable) { ti.isReadable = true; changed = true; } + if (!ti.sRGBTexture) { ti.sRGBTexture = true; changed = true; } + if (ti.npotScale != TextureImporterNPOTScale.None) { ti.npotScale = TextureImporterNPOTScale.None; changed = true; } + if (ti.wrapMode != TextureWrapMode.Clamp) { ti.wrapMode = TextureWrapMode.Clamp; changed = true; } + if (ti.textureCompression != TextureImporterCompression.Uncompressed) + { + ti.textureCompression = TextureImporterCompression.Uncompressed; + changed = true; + } + + if (changed) ti.SaveAndReimport(); + } + + private static Dictionary BuildHeaderMap(string headerLine) + { + var map = new Dictionary(); + var cols = headerLine.Split(','); + for (int i = 0; i < cols.Length; i++) + { + var key = NormalizeHeader(cols[i]); + if (string.IsNullOrEmpty(key)) continue; + if (!map.ContainsKey(key)) + map[key] = i; + } + return map; + } + + private static bool HasAll(Dictionary map, params string[] required) + { + foreach (var r in required) + if (!map.ContainsKey(NormalizeHeader(r))) + return false; + return true; + } + + private void ImportTiles() + { + Debug.Log($"[GeoTileImporter] START\n csv={tilesCsvPath}\n pngDir={heightmapsDir}"); + + if (!File.Exists(tilesCsvPath)) + { + Debug.LogError($"[GeoTileImporter] CSV not found: {tilesCsvPath}"); + return; + } + if (!Directory.Exists(heightmapsDir)) + { + Debug.LogError($"[GeoTileImporter] Heightmap dir not found: {heightmapsDir}"); + return; + } + if (applyOrthoTextures && !Directory.Exists(orthoDir)) + { + Debug.LogWarning($"[GeoTileImporter] Ortho dir not found: {orthoDir} (textures will be skipped)."); + applyOrthoTextures = false; + } + + var parent = GameObject.Find(parentName); + if (parent == null) parent = new GameObject(parentName); + + if (deleteExisting) + { + for (int i = parent.transform.childCount - 1; i >= 0; i--) + DestroyImmediate(parent.transform.GetChild(i).gameObject); + } + + var ci = CultureInfo.InvariantCulture; + var lines = File.ReadAllLines(tilesCsvPath); + + Debug.Log($"[GeoTileImporter] Read {lines.Length} lines."); + if (lines.Length < 2) + { + Debug.LogError("[GeoTileImporter] CSV has no data rows (need header + at least 1 row)."); + return; + } + + var headerLine = lines[0].Trim(); + var headerMap = BuildHeaderMap(headerLine); + Debug.Log($"[GeoTileImporter] Header: {headerLine}"); + Debug.Log($"[GeoTileImporter] Header columns mapped: {string.Join(", ", headerMap.Keys)}"); + + // Required columns (order-independent) + string[] required = { "tile_id", "xmin", "ymin", "global_min", "global_max", "out_res" }; + if (!HasAll(headerMap, required)) + { + Debug.LogError("[GeoTileImporter] CSV missing required columns. Required: " + + string.Join(", ", required) + + "\nFound: " + string.Join(", ", headerMap.Keys)); + return; + } + + int IDX_TILE = headerMap["tile_id"]; + int IDX_XMIN = headerMap["xmin"]; + int IDX_YMIN = headerMap["ymin"]; + int IDX_GMIN = headerMap["global_min"]; + int IDX_GMAX = headerMap["global_max"]; + int IDX_RES = headerMap["out_res"]; + + // Compute origin from min xmin/ymin + double originX = double.PositiveInfinity; + double originY = double.PositiveInfinity; + + int validRowsForOrigin = 0; + for (int i = 1; i < lines.Length; i++) + { + var line = lines[i].Trim(); + if (string.IsNullOrWhiteSpace(line)) continue; + + var parts = line.Split(','); + // Robust: just ensure indices exist in this row + int needMaxIndex = Math.Max(Math.Max(Math.Max(Math.Max(IDX_TILE, IDX_XMIN), IDX_YMIN), IDX_GMIN), Math.Max(IDX_GMAX, IDX_RES)); + if (parts.Length <= needMaxIndex) + { + Debug.LogWarning($"[GeoTileImporter] Origin scan: skipping line {i + 1} (too few columns: {parts.Length}). Line: '{line}'"); + continue; + } + + try + { + double xmin = double.Parse(parts[IDX_XMIN], ci); + double ymin = double.Parse(parts[IDX_YMIN], ci); + originX = Math.Min(originX, xmin); + originY = Math.Min(originY, ymin); + validRowsForOrigin++; + } + catch (Exception e) + { + Debug.LogWarning($"[GeoTileImporter] Origin scan parse failed line {i + 1}: '{line}'\n{e.Message}"); + } + } + + if (validRowsForOrigin == 0 || double.IsInfinity(originX) || double.IsInfinity(originY)) + { + Debug.LogError("[GeoTileImporter] Could not compute origin (no valid rows parsed). Check CSV numeric format."); + return; + } + + Debug.Log($"[GeoTileImporter] Origin: ({originX}, {originY}) from {validRowsForOrigin} valid rows."); + + int imported = 0, skipped = 0; + int importedTextures = 0; + var placements = new List<(string tileId, float ux, float uz, float gmin)>(); + + for (int i = 1; i < lines.Length; i++) + { + var line = lines[i].Trim(); + if (string.IsNullOrWhiteSpace(line)) continue; + + var parts = line.Split(','); + int needMaxIndex = Math.Max(Math.Max(Math.Max(Math.Max(IDX_TILE, IDX_XMIN), IDX_YMIN), IDX_GMIN), Math.Max(IDX_GMAX, IDX_RES)); + if (parts.Length <= needMaxIndex) + { + skipped++; + Debug.LogWarning($"[GeoTileImporter] Skipping line {i + 1} (too few columns: {parts.Length}). Line: '{line}'"); + continue; + } + + string tileId = parts[IDX_TILE].Trim(); + + double xmin, ymin, gmin, gmax; + int outRes; + try + { + xmin = double.Parse(parts[IDX_XMIN], ci); + ymin = double.Parse(parts[IDX_YMIN], ci); + gmin = double.Parse(parts[IDX_GMIN], ci); + gmax = double.Parse(parts[IDX_GMAX], ci); + outRes = int.Parse(parts[IDX_RES], ci); + } + catch (Exception e) + { + skipped++; + Debug.LogWarning($"[GeoTileImporter] Parse failed line {i + 1} tile '{tileId}': {e.Message}\nLine: '{line}'"); + continue; + } + + if (outRes != heightmapResolution) + Debug.LogWarning($"[GeoTileImporter] Tile {tileId}: out_res={outRes} but importer expects {heightmapResolution}."); + + float heightRange = (float)(gmax - gmin); + if (heightRange <= 0.0001f) + { + skipped++; + Debug.LogWarning($"[GeoTileImporter] Tile {tileId}: invalid height range (global_max <= global_min). Skipping."); + continue; + } + + string pngPath = Path.Combine(heightmapsDir, $"{tileId}.png").Replace("\\", "/"); + if (!File.Exists(pngPath)) + { + skipped++; + Debug.LogError($"[GeoTileImporter] Missing PNG for {tileId}: {pngPath}"); + continue; + } + + EnsureHeightmapImportSettings(pngPath); + + var tex = AssetDatabase.LoadAssetAtPath(pngPath); + if (tex == null) + { + skipped++; + Debug.LogError($"[GeoTileImporter] Could not load Texture2D asset: {pngPath}"); + continue; + } + + if (tex.width != heightmapResolution || tex.height != heightmapResolution) + Debug.LogWarning($"[GeoTileImporter] Tile {tileId}: PNG {tex.width}x{tex.height}, expected {heightmapResolution}x{heightmapResolution}."); + + var terrainData = new TerrainData + { + heightmapResolution = heightmapResolution, + size = new Vector3(tileSizeMeters, heightRange, tileSizeMeters), + }; + + int w = tex.width; + int h = tex.height; + var heights = new float[h, w]; + + bool usedU16 = false; + try + { + var raw = tex.GetPixelData(0); + if (raw.Length == w * h) + { + for (int y = 0; y < h; y++) + { + int srcY = flipTerrainNorthSouth ? (h - 1 - y) : y; + for (int x = 0; x < w; x++) + heights[y, x] = raw[srcY * w + x] / 65535f; + } + usedU16 = true; + } + } + catch + { + // fallback below + } + + if (!usedU16) + { + var pixels = tex.GetPixels(); + for (int y = 0; y < h; y++) + { + int srcY = flipTerrainNorthSouth ? (h - 1 - y) : y; + for (int x = 0; x < w; x++) + heights[y, x] = pixels[srcY * w + x].r; + } + } + + terrainData.SetHeights(0, 0, heights); + + var go = Terrain.CreateTerrainGameObject(terrainData); + go.name = tileId; + go.transform.parent = parent.transform; + + float ux = (float)(xmin - originX); + float uz = (float)(ymin - originY); + go.transform.position = new Vector3(ux, (float)gmin, uz); + + var terrain = go.GetComponent(); + terrain.drawInstanced = true; + + // Optional ortho texture application + if (applyOrthoTextures) + { + string orthoPath = Path.Combine(orthoDir, $"{tileId}.jpg").Replace("\\", "/"); + if (File.Exists(orthoPath)) + { + EnsureOrthoImportSettings(orthoPath); + var orthoTex = AssetDatabase.LoadAssetAtPath(orthoPath); + if (orthoTex == null) + { + Debug.LogWarning($"[GeoTileImporter] Could not load ortho texture for {tileId}: {orthoPath}"); + } + else + { + var orthoTileSize = flipTerrainNorthSouth + ? new Vector2(tileSizeMeters, -tileSizeMeters) + : new Vector2(tileSizeMeters, tileSizeMeters); + var orthoTileOffset = flipTerrainNorthSouth + ? new Vector2(0f, -tileSizeMeters) + : Vector2.zero; + var layer = new TerrainLayer + { + diffuseTexture = orthoTex, + tileSize = orthoTileSize, + tileOffset = orthoTileOffset + }; + terrainData.terrainLayers = new[] { layer }; + terrainData.alphamapResolution = 16; + var alpha = new float[terrainData.alphamapHeight, terrainData.alphamapWidth, 1]; + for (int ay = 0; ay < terrainData.alphamapHeight; ay++) + for (int ax = 0; ax < terrainData.alphamapWidth; ax++) + alpha[ay, ax, 0] = 1f; + terrainData.SetAlphamaps(0, 0, alpha); + importedTextures++; + } + } + else + { + Debug.LogWarning($"[GeoTileImporter] Ortho texture missing for {tileId}: {orthoPath}"); + } + } + + Debug.Log($"[GeoTileImporter] Imported {tileId} @ XZ=({ux},{uz}) Y={gmin} heightRange={heightRange} usedU16={usedU16}"); + imported++; + placements.Add((tileId, ux, uz, (float)gmin)); + } + + Debug.Log($"[GeoTileImporter] DONE. Imported={imported}, Skipped={skipped}, OrthoApplied={importedTextures} under '{parentName}'."); + + if (imported == 0) + Debug.LogError("[GeoTileImporter] Imported 0 tiles. Scroll up for warnings/errors (missing columns, parse issues, missing PNGs)."); + + ImportBuildings(placements); + ImportTrees(placements); + ImportFurniture(placements); + ImportEnhancedTrees(placements); + } + + private void ImportBuildings(List<(string tileId, float ux, float uz, float gmin)> placements) + { + if (!importBuildings) + return; + + // Choose directory based on enhanced toggle + string activeDir = useEnhancedBuildings ? buildingsEnhancedDir : buildingsDir; + string sourceLabel = useEnhancedBuildings ? "enhanced" : "standard"; + + if (!Directory.Exists(activeDir)) + { + Debug.LogWarning($"[GeoTileImporter] Buildings dir ({sourceLabel}) not found: {activeDir} (skipping buildings)."); + return; + } + + var parent = GameObject.Find(buildingsParentName); + if (parent == null) parent = new GameObject(buildingsParentName); + if (deleteExistingBuildings) + { + for (int i = parent.transform.childCount - 1; i >= 0; i--) + DestroyImmediate(parent.transform.GetChild(i).gameObject); + } + + int imported = 0, missing = 0; + foreach (var (tileId, ux, uz, gmin) in placements) + { + string glbPath = Path.Combine(activeDir, $"{tileId}.glb").Replace("\\", "/"); + if (!File.Exists(glbPath)) + { + missing++; + Debug.LogWarning($"[GeoTileImporter] Building GLB ({sourceLabel}) missing for {tileId}: {glbPath}"); + continue; + } + + var prefab = AssetDatabase.LoadAssetAtPath(glbPath); + if (prefab == null) + { + missing++; + Debug.LogWarning($"[GeoTileImporter] Could not load building GLB for {tileId}: {glbPath}"); + continue; + } + + // Note: Building GLB vertices have absolute Z (elevation) from CityGML, + // so container Y should be 0, not gmin (which would add gmin twice) + var inst = PrefabUtility.InstantiatePrefab(prefab) as GameObject ?? Instantiate(prefab); + inst.name = tileId; + inst.transform.SetParent(parent.transform, false); + inst.transform.position = new Vector3(ux, 0f, uz); + inst.isStatic = true; + imported++; + } + + Debug.Log($"[GeoTileImporter] Buildings ({sourceLabel}) imported={imported}, missing/failed={missing} under '{buildingsParentName}'."); + } + + private void ImportTrees(List<(string tileId, float ux, float uz, float gmin)> placements) + { + if (!importTrees) + return; + + if (!Directory.Exists(treesDir)) + { + Debug.LogWarning($"[GeoTileImporter] Trees dir not found: {treesDir} (skipping trees)."); + return; + } + + var parent = GameObject.Find(treesParentName); + if (parent == null) parent = new GameObject(treesParentName); + if (deleteExistingTrees) + { + for (int i = parent.transform.childCount - 1; i >= 0; i--) + DestroyImmediate(parent.transform.GetChild(i).gameObject); + } + + if (!File.Exists(treeProxyPath)) + { + Debug.LogWarning($"[GeoTileImporter] Tree proxy GLB not found (for reference/materials): {treeProxyPath}"); + } + + int importedTiles = 0, importedChunks = 0, missingTiles = 0; + foreach (var (tileId, ux, uz, gmin) in placements) + { + // Look for chunk files: {tileId}_0_0.glb, {tileId}_0_1.glb, etc. + // Standard tree export creates 4x4 chunks per tile + var chunkFiles = Directory.GetFiles(treesDir, $"{tileId}_*.glb"); + + if (chunkFiles.Length == 0) + { + // Try single file as fallback + string singlePath = Path.Combine(treesDir, $"{tileId}.glb").Replace("\\", "/"); + if (File.Exists(singlePath)) + { + chunkFiles = new[] { singlePath }; + } + else + { + missingTiles++; + continue; + } + } + + // Create container for this tile's tree chunks + // Note: Tree GLB vertices use absolute elevation (z_ground from DGM), + // so container Y should be 0, not gmin (which would add gmin twice) + // Scale Z by -1 to correct coordinate system mismatch (Python negates Z in export) + var tileContainer = new GameObject($"Trees_{tileId}"); + tileContainer.transform.SetParent(parent.transform, false); + tileContainer.transform.position = new Vector3(ux, 0f, uz); + tileContainer.transform.localScale = new Vector3(1f, 1f, -1f); + tileContainer.isStatic = true; + + foreach (var chunkPath in chunkFiles) + { + string assetPath = chunkPath.Replace("\\", "/"); + var prefab = AssetDatabase.LoadAssetAtPath(assetPath); + if (prefab == null) + { + Debug.LogWarning($"[GeoTileImporter] Could not load tree chunk: {assetPath}"); + continue; + } + + var inst = PrefabUtility.InstantiatePrefab(prefab) as GameObject ?? Instantiate(prefab); + inst.name = Path.GetFileNameWithoutExtension(chunkPath); + inst.transform.SetParent(tileContainer.transform, false); + inst.transform.localPosition = Vector3.zero; // Chunks already have correct local positions + inst.isStatic = true; + importedChunks++; + } + + importedTiles++; + } + + Debug.Log($"[GeoTileImporter] Trees imported: {importedTiles} tiles, {importedChunks} chunks, {missingTiles} missing under '{treesParentName}'."); + } + + private void ImportFurniture(List<(string tileId, float ux, float uz, float gmin)> placements) + { + if (!importFurniture) + return; + + if (!Directory.Exists(furnitureDir)) + { + Debug.LogWarning($"[GeoTileImporter] Furniture dir not found: {furnitureDir} (skipping furniture)."); + return; + } + + var parent = GameObject.Find(furnitureParentName); + if (parent == null) parent = new GameObject(furnitureParentName); + if (deleteExistingFurniture) + { + for (int i = parent.transform.childCount - 1; i >= 0; i--) + DestroyImmediate(parent.transform.GetChild(i).gameObject); + } + + int imported = 0, skipped = 0; + var ci = CultureInfo.InvariantCulture; + + foreach (var (tileId, ux, uz, gmin) in placements) + { + string csvPath = Path.Combine(furnitureDir, $"{tileId}.csv").Replace("\\", "/"); + if (!File.Exists(csvPath)) + { + continue; // No furniture for this tile + } + + var lines = File.ReadAllLines(csvPath); + if (lines.Length < 2) + continue; + + // Parse header + var header = lines[0].Split(','); + int idxXLocal = -1, idxYLocal = -1, idxZGround = -1, idxHeight = -1, idxType = -1; + for (int i = 0; i < header.Length; i++) + { + var col = header[i].Trim().ToLowerInvariant(); + if (col == "x_local") idxXLocal = i; + else if (col == "y_local") idxYLocal = i; + else if (col == "z_ground") idxZGround = i; + else if (col == "height") idxHeight = i; + else if (col == "type") idxType = i; + } + + if (idxXLocal < 0 || idxYLocal < 0 || idxType < 0) + { + Debug.LogWarning($"[GeoTileImporter] Furniture CSV missing required columns: {csvPath}"); + continue; + } + + // Create tile container + var tileContainer = new GameObject($"Furniture_{tileId}"); + tileContainer.transform.SetParent(parent.transform, false); + tileContainer.transform.position = new Vector3(ux, gmin, uz); + tileContainer.isStatic = true; + + for (int i = 1; i < lines.Length; i++) + { + var parts = lines[i].Split(','); + if (parts.Length <= Math.Max(Math.Max(idxXLocal, idxYLocal), idxType)) + { + skipped++; + continue; + } + + try + { + float xLocal = float.Parse(parts[idxXLocal].Trim(), ci); + float yLocal = float.Parse(parts[idxYLocal].Trim(), ci); + float zGround = idxZGround >= 0 && idxZGround < parts.Length + ? float.Parse(parts[idxZGround].Trim(), ci) + : 0f; + float height = idxHeight >= 0 && idxHeight < parts.Length + ? float.Parse(parts[idxHeight].Trim(), ci) + : 1f; + string furnitureType = parts[idxType].Trim().ToLowerInvariant(); + + // Select prefab based on type, or create placeholder + GameObject obj = null; + GameObject prefabToUse = null; + Vector3 fallbackScale = Vector3.one; + + switch (furnitureType) + { + case "lamp": + prefabToUse = lampPrefab; + fallbackScale = new Vector3(0.3f, height, 0.3f); + break; + case "bench": + prefabToUse = benchPrefab; + fallbackScale = new Vector3(1.5f, height, 0.5f); + break; + case "sign": + prefabToUse = signPrefab; + fallbackScale = new Vector3(0.1f, height, 0.5f); + break; + case "bollard": + prefabToUse = bollardPrefab; + fallbackScale = new Vector3(0.2f, height, 0.2f); + break; + default: + prefabToUse = defaultFurniturePrefab; + fallbackScale = new Vector3(0.5f, height, 0.5f); + break; + } + + if (prefabToUse != null) + { + // Use prefab + obj = PrefabUtility.InstantiatePrefab(prefabToUse) as GameObject ?? Instantiate(prefabToUse); + obj.name = $"{furnitureType}_{i}"; + // Scale prefab to match height (assuming prefab is normalized to ~1m) + float scaleMultiplier = height; + obj.transform.localScale = Vector3.one * scaleMultiplier; + } + else + { + // Create placeholder cube + obj = GameObject.CreatePrimitive(PrimitiveType.Cube); + obj.name = $"{furnitureType}_{i}"; + obj.transform.localScale = fallbackScale; + } + + obj.transform.SetParent(tileContainer.transform, false); + obj.transform.localPosition = new Vector3(xLocal, zGround - gmin, yLocal); + obj.isStatic = true; + imported++; + } + catch (Exception e) + { + Debug.LogWarning($"[GeoTileImporter] Failed to parse furniture line {i} in {csvPath}: {e.Message}"); + skipped++; + } + } + } + + Debug.Log($"[GeoTileImporter] Furniture imported={imported}, skipped={skipped} under '{furnitureParentName}'."); + } + + private void ImportEnhancedTrees(List<(string tileId, float ux, float uz, float gmin)> placements) + { + if (!importEnhancedTrees) + return; + + if (!Directory.Exists(enhancedTreesDir)) + { + Debug.LogWarning($"[GeoTileImporter] Enhanced trees dir not found: {enhancedTreesDir} (skipping enhanced trees)."); + return; + } + + var parent = GameObject.Find(enhancedTreesParentName); + if (parent == null) parent = new GameObject(enhancedTreesParentName); + if (deleteExistingEnhancedTrees) + { + for (int i = parent.transform.childCount - 1; i >= 0; i--) + DestroyImmediate(parent.transform.GetChild(i).gameObject); + } + + int imported = 0, skipped = 0; + var ci = CultureInfo.InvariantCulture; + + foreach (var (tileId, ux, uz, gmin) in placements) + { + string csvPath = Path.Combine(enhancedTreesDir, $"{tileId}.csv").Replace("\\", "/"); + if (!File.Exists(csvPath)) + { + continue; // No enhanced trees for this tile + } + + var lines = File.ReadAllLines(csvPath); + if (lines.Length < 2) + continue; + + // Parse header + var header = lines[0].Split(','); + int idxXLocal = -1, idxYLocal = -1, idxZGround = -1, idxHeight = -1, idxRadius = -1; + int idxCanopyR = -1, idxCanopyG = -1, idxCanopyB = -1; + for (int i = 0; i < header.Length; i++) + { + var col = header[i].Trim().ToLowerInvariant(); + if (col == "x_local") idxXLocal = i; + else if (col == "y_local") idxYLocal = i; + else if (col == "z_ground") idxZGround = i; + else if (col == "height") idxHeight = i; + else if (col == "radius") idxRadius = i; + else if (col == "canopy_r") idxCanopyR = i; + else if (col == "canopy_g") idxCanopyG = i; + else if (col == "canopy_b") idxCanopyB = i; + } + + if (idxXLocal < 0 || idxYLocal < 0 || idxHeight < 0) + { + Debug.LogWarning($"[GeoTileImporter] Enhanced trees CSV missing required columns: {csvPath}"); + continue; + } + + // Create tile container + var tileContainer = new GameObject($"Trees_{tileId}"); + tileContainer.transform.SetParent(parent.transform, false); + tileContainer.transform.position = new Vector3(ux, gmin, uz); + tileContainer.isStatic = true; + + for (int i = 1; i < lines.Length; i++) + { + var parts = lines[i].Split(','); + if (parts.Length <= Math.Max(Math.Max(idxXLocal, idxYLocal), idxHeight)) + { + skipped++; + continue; + } + + try + { + float xLocal = float.Parse(parts[idxXLocal].Trim(), ci); + float yLocal = float.Parse(parts[idxYLocal].Trim(), ci); + float zGround = idxZGround >= 0 && idxZGround < parts.Length + ? float.Parse(parts[idxZGround].Trim(), ci) + : 0f; + float height = float.Parse(parts[idxHeight].Trim(), ci); + float radius = idxRadius >= 0 && idxRadius < parts.Length + ? float.Parse(parts[idxRadius].Trim(), ci) + : height * 0.25f; + + // Parse canopy color (default to green if missing) + int canopyR = 128, canopyG = 160, canopyB = 80; + if (idxCanopyR >= 0 && idxCanopyR < parts.Length) + canopyR = Mathf.Clamp(int.Parse(parts[idxCanopyR].Trim()), 0, 255); + if (idxCanopyG >= 0 && idxCanopyG < parts.Length) + canopyG = Mathf.Clamp(int.Parse(parts[idxCanopyG].Trim()), 0, 255); + if (idxCanopyB >= 0 && idxCanopyB < parts.Length) + canopyB = Mathf.Clamp(int.Parse(parts[idxCanopyB].Trim()), 0, 255); + + Color canopyColor = new Color(canopyR / 255f, canopyG / 255f, canopyB / 255f); + + GameObject treeObj; + if (treePrefab != null) + { + // Use prefab instance + treeObj = PrefabUtility.InstantiatePrefab(treePrefab) as GameObject ?? Instantiate(treePrefab); + treeObj.name = $"tree_{i}"; + + // Scale prefab based on height/radius + float scaleY = height / 10f; // Assuming prefab is ~10m tall + float scaleXZ = radius * 2f / 5f; // Assuming prefab canopy is ~5m wide + treeObj.transform.localScale = new Vector3(scaleXZ, scaleY, scaleXZ); + + // Apply canopy color to materials + var renderers = treeObj.GetComponentsInChildren(); + foreach (var rend in renderers) + { + foreach (var mat in rend.sharedMaterials) + { + if (mat != null) + { + // Create material instance to avoid modifying shared material + var matInst = new Material(mat); + matInst.color = canopyColor; + rend.material = matInst; + } + } + } + } + else + { + // Create procedural tree placeholder (cylinder trunk + sphere canopy) + treeObj = new GameObject($"tree_{i}"); + + // Trunk (cylinder) + var trunk = GameObject.CreatePrimitive(PrimitiveType.Cylinder); + trunk.name = "trunk"; + trunk.transform.SetParent(treeObj.transform, false); + trunk.transform.localPosition = new Vector3(0, height * 0.25f, 0); + trunk.transform.localScale = new Vector3(0.3f, height * 0.5f, 0.3f); + var trunkRend = trunk.GetComponent(); + if (trunkRend != null) + { + var trunkMat = new Material(Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard")); + trunkMat.color = new Color(0.4f, 0.25f, 0.15f); // Brown + trunkRend.material = trunkMat; + } + + // Canopy (sphere) + var canopy = GameObject.CreatePrimitive(PrimitiveType.Sphere); + canopy.name = "canopy"; + canopy.transform.SetParent(treeObj.transform, false); + canopy.transform.localPosition = new Vector3(0, height * 0.7f, 0); + canopy.transform.localScale = new Vector3(radius * 2f, height * 0.5f, radius * 2f); + var canopyRend = canopy.GetComponent(); + if (canopyRend != null) + { + var canopyMat = new Material(Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard")); + canopyMat.color = canopyColor; + canopyRend.material = canopyMat; + } + } + + treeObj.transform.SetParent(tileContainer.transform, false); + treeObj.transform.localPosition = new Vector3(xLocal, zGround - gmin, yLocal); + treeObj.isStatic = true; + imported++; + } + catch (Exception e) + { + Debug.LogWarning($"[GeoTileImporter] Failed to parse enhanced tree line {i} in {csvPath}: {e.Message}"); + skipped++; + } + } + } + + Debug.Log($"[GeoTileImporter] Enhanced trees imported={imported}, skipped={skipped} under '{enhancedTreesParentName}'."); + } +} diff --git a/scripts_unity/Editor/GeoTilePrefabImporter.cs b/scripts_unity/Editor/GeoTilePrefabImporter.cs new file mode 100644 index 0000000..22761fa --- /dev/null +++ b/scripts_unity/Editor/GeoTilePrefabImporter.cs @@ -0,0 +1,961 @@ +// Assets/Editor/GeoTilePrefabImporter.cs +// Creates prefab assets from geo tiles. Each prefab contains terrain + buildings + trees + furniture. +// Unlike GeoTileImporter (which creates scene objects), this importer produces reusable .prefab assets. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using UnityEditor; +using UnityEngine; + +public class GeoTilePrefabImporter : EditorWindow +{ + // Input paths (same as GeoTileImporter) + private string tilesCsvPath = "Assets/GeoData/tile_index.csv"; + private string heightmapsDir = "Assets/GeoData/height_png16"; + private string orthoDir = "Assets/GeoData/ortho_jpg"; + private string buildingsDir = "Assets/GeoData/buildings_tiles"; + private string treesDir = "Assets/GeoData/trees_tiles"; + private string furnitureDir = "Assets/GeoData/street_furniture"; + private string enhancedTreesDir = "Assets/GeoData/trees_enhanced"; + + // Output settings + private string prefabOutputDir = "Assets/GeoData/TilePrefabs"; + private bool overwriteExisting = false; + + // Terrain settings + private float tileSizeMeters = 1000f; + private int heightmapResolution = 1025; + + // Component toggles + private bool applyOrthoTextures = true; + private bool includeBuildings = true; + private bool includeTrees = true; + private bool includeFurniture = false; + private bool includeEnhancedTrees = false; + + // Prefabs for furniture (optional) + private GameObject lampPrefab; + private GameObject benchPrefab; + private GameObject signPrefab; + private GameObject bollardPrefab; + private GameObject defaultFurniturePrefab; + private GameObject treePrefab; + + private struct TileMetadata + { + public string TileId; + public double Xmin, Ymin, Xmax, Ymax; + public double GlobalMin, GlobalMax; + public int OutRes; + } + + [MenuItem("Tools/Geo Tiles/Import Tiles as Prefabs")] + public static void ShowWindow() + { + var win = GetWindow("Geo Tile Prefab Importer"); + win.minSize = new Vector2(620, 480); + } + + private void OnGUI() + { + GUILayout.Label("Input Paths", EditorStyles.boldLabel); + tilesCsvPath = EditorGUILayout.TextField("tile_index.csv", tilesCsvPath); + heightmapsDir = EditorGUILayout.TextField("height_png16 dir", heightmapsDir); + orthoDir = EditorGUILayout.TextField("ortho_jpg dir", orthoDir); + buildingsDir = EditorGUILayout.TextField("buildings_tiles dir", buildingsDir); + treesDir = EditorGUILayout.TextField("trees_tiles dir", treesDir); + furnitureDir = EditorGUILayout.TextField("street_furniture dir", furnitureDir); + enhancedTreesDir = EditorGUILayout.TextField("trees_enhanced dir", enhancedTreesDir); + + GUILayout.Space(10); + GUILayout.Label("Output Settings", EditorStyles.boldLabel); + prefabOutputDir = EditorGUILayout.TextField("Prefab output dir", prefabOutputDir); + overwriteExisting = EditorGUILayout.ToggleLeft("Overwrite existing prefabs", overwriteExisting); + + GUILayout.Space(10); + GUILayout.Label("Terrain Settings", EditorStyles.boldLabel); + tileSizeMeters = EditorGUILayout.FloatField("Tile size (m)", tileSizeMeters); + heightmapResolution = EditorGUILayout.IntField("Heightmap resolution", heightmapResolution); + + GUILayout.Space(10); + GUILayout.Label("Include Components", EditorStyles.boldLabel); + applyOrthoTextures = EditorGUILayout.ToggleLeft("Apply ortho textures", applyOrthoTextures); + includeBuildings = EditorGUILayout.ToggleLeft("Include buildings (GLB)", includeBuildings); + includeTrees = EditorGUILayout.ToggleLeft("Include trees (GLB chunks)", includeTrees); + includeFurniture = EditorGUILayout.ToggleLeft("Include street furniture (CSV)", includeFurniture); + includeEnhancedTrees = EditorGUILayout.ToggleLeft("Include enhanced trees (CSV)", includeEnhancedTrees); + + GUILayout.Space(10); + GUILayout.Label("Prefabs (optional, for furniture/trees)", EditorStyles.boldLabel); + treePrefab = (GameObject)EditorGUILayout.ObjectField("Tree Prefab", treePrefab, typeof(GameObject), false); + lampPrefab = (GameObject)EditorGUILayout.ObjectField("Lamp Prefab", lampPrefab, typeof(GameObject), false); + benchPrefab = (GameObject)EditorGUILayout.ObjectField("Bench Prefab", benchPrefab, typeof(GameObject), false); + signPrefab = (GameObject)EditorGUILayout.ObjectField("Sign Prefab", signPrefab, typeof(GameObject), false); + bollardPrefab = (GameObject)EditorGUILayout.ObjectField("Bollard Prefab", bollardPrefab, typeof(GameObject), false); + defaultFurniturePrefab = (GameObject)EditorGUILayout.ObjectField("Default Furniture", defaultFurniturePrefab, typeof(GameObject), false); + + GUILayout.Space(15); + if (GUILayout.Button("Generate Prefabs")) + ImportTilesAsPrefabs(); + + GUILayout.Space(10); + if (GUILayout.Button("Place All Prefabs in Scene")) + PlaceAllPrefabsInScene(); + + EditorGUILayout.HelpBox( + "Creates one .prefab asset per tile in the manifest.\n" + + "Each prefab contains: Terrain (with TerrainData), Buildings, Trees, Furniture.\n" + + "TerrainData and TerrainLayers are saved as separate .asset files.\n" + + "IMPORTANT: Use 'Place All Prefabs in Scene' to position tiles correctly.", + MessageType.Info); + } + + private void PlaceAllPrefabsInScene() + { + if (!Directory.Exists(prefabOutputDir)) + { + Debug.LogError($"[GeoTilePrefabImporter] Prefab directory not found: {prefabOutputDir}"); + return; + } + + var prefabFiles = Directory.GetFiles(prefabOutputDir, "*.prefab"); + if (prefabFiles.Length == 0) + { + Debug.LogError("[GeoTilePrefabImporter] No prefabs found. Generate prefabs first."); + return; + } + + // Load all prefabs and collect metadata to compute origin + var prefabsWithMeta = new List<(GameObject prefab, GeoTileMetadata meta)>(); + double originX = double.PositiveInfinity; + double originY = double.PositiveInfinity; + + foreach (var path in prefabFiles) + { + string assetPath = path.Replace("\\", "/"); + var prefab = AssetDatabase.LoadAssetAtPath(assetPath); + if (prefab == null) continue; + + var meta = prefab.GetComponent(); + if (meta == null) + { + Debug.LogWarning($"[GeoTilePrefabImporter] Prefab missing GeoTileMetadata: {assetPath}"); + continue; + } + + prefabsWithMeta.Add((prefab, meta)); + originX = Math.Min(originX, meta.xmin); + originY = Math.Min(originY, meta.ymin); + } + + if (prefabsWithMeta.Count == 0) + { + Debug.LogError("[GeoTilePrefabImporter] No valid prefabs with metadata found."); + return; + } + + // Create parent container + var parent = GameObject.Find("Geo_Tile_Prefabs"); + if (parent == null) parent = new GameObject("Geo_Tile_Prefabs"); + + // Instantiate each prefab at correct position + int placed = 0; + foreach (var (prefab, meta) in prefabsWithMeta) + { + // Check if already placed + var existing = parent.transform.Find(meta.tileId); + if (existing != null) + { + Debug.Log($"[GeoTilePrefabImporter] Tile already in scene: {meta.tileId}"); + continue; + } + + var instance = PrefabUtility.InstantiatePrefab(prefab) as GameObject; + if (instance == null) + { + instance = Instantiate(prefab); + instance.name = meta.tileId; + } + + instance.transform.SetParent(parent.transform, false); + instance.transform.position = meta.GetWorldPosition(originX, originY); + placed++; + + Debug.Log($"[GeoTilePrefabImporter] Placed {meta.tileId} at {instance.transform.position}"); + } + + Debug.Log($"[GeoTilePrefabImporter] Placed {placed} tile prefabs under 'Geo_Tile_Prefabs'. Origin: ({originX}, {originY})"); + } + + private void ImportTilesAsPrefabs() + { + Debug.Log($"[GeoTilePrefabImporter] START\n csv={tilesCsvPath}\n output={prefabOutputDir}"); + + // Validate inputs + if (!File.Exists(tilesCsvPath)) + { + Debug.LogError($"[GeoTilePrefabImporter] CSV not found: {tilesCsvPath}"); + return; + } + if (!Directory.Exists(heightmapsDir)) + { + Debug.LogError($"[GeoTilePrefabImporter] Heightmap dir not found: {heightmapsDir}"); + return; + } + + // Create output directories + EnsureDirectoryExists(prefabOutputDir); + EnsureDirectoryExists($"{prefabOutputDir}/TerrainData"); + EnsureDirectoryExists($"{prefabOutputDir}/TerrainLayers"); + + // Parse CSV + var tiles = ParseTilesCsv(); + if (tiles == null || tiles.Count == 0) + { + Debug.LogError("[GeoTilePrefabImporter] No valid tiles found in CSV."); + return; + } + + Debug.Log($"[GeoTilePrefabImporter] Found {tiles.Count} tiles to process."); + + int created = 0, skipped = 0, failed = 0; + + for (int i = 0; i < tiles.Count; i++) + { + var tile = tiles[i]; + EditorUtility.DisplayProgressBar( + "Creating Tile Prefabs", + $"Processing {tile.TileId} ({i + 1}/{tiles.Count})", + (float)i / tiles.Count); + + string prefabPath = $"{prefabOutputDir}/{tile.TileId}.prefab"; + if (File.Exists(prefabPath) && !overwriteExisting) + { + Debug.Log($"[GeoTilePrefabImporter] Skipping existing: {tile.TileId}"); + skipped++; + continue; + } + + try + { + if (CreateTilePrefab(tile)) + created++; + else + failed++; + } + catch (Exception e) + { + Debug.LogError($"[GeoTilePrefabImporter] Failed to create prefab for {tile.TileId}: {e.Message}\n{e.StackTrace}"); + failed++; + } + } + + EditorUtility.ClearProgressBar(); + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + + Debug.Log($"[GeoTilePrefabImporter] DONE. Created={created}, Skipped={skipped}, Failed={failed}"); + } + + private List ParseTilesCsv() + { + var tiles = new List(); + var ci = CultureInfo.InvariantCulture; + var lines = File.ReadAllLines(tilesCsvPath); + + if (lines.Length < 2) + { + Debug.LogError("[GeoTilePrefabImporter] CSV has no data rows."); + return null; + } + + var headerMap = BuildHeaderMap(lines[0]); + string[] required = { "tile_id", "xmin", "ymin", "xmax", "ymax", "global_min", "global_max", "out_res" }; + if (!HasAll(headerMap, required)) + { + Debug.LogError("[GeoTilePrefabImporter] CSV missing required columns. Required: " + + string.Join(", ", required)); + return null; + } + + int IDX_TILE = headerMap["tile_id"]; + int IDX_XMIN = headerMap["xmin"]; + int IDX_YMIN = headerMap["ymin"]; + int IDX_XMAX = headerMap["xmax"]; + int IDX_YMAX = headerMap["ymax"]; + int IDX_GMIN = headerMap["global_min"]; + int IDX_GMAX = headerMap["global_max"]; + int IDX_RES = headerMap["out_res"]; + + for (int i = 1; i < lines.Length; i++) + { + var line = lines[i].Trim(); + if (string.IsNullOrWhiteSpace(line)) continue; + + var parts = line.Split(','); + int maxIdx = Math.Max(Math.Max(Math.Max(IDX_TILE, IDX_XMAX), IDX_YMAX), Math.Max(IDX_GMAX, IDX_RES)); + if (parts.Length <= maxIdx) + { + Debug.LogWarning($"[GeoTilePrefabImporter] Skipping line {i + 1}: too few columns."); + continue; + } + + try + { + tiles.Add(new TileMetadata + { + TileId = parts[IDX_TILE].Trim(), + Xmin = double.Parse(parts[IDX_XMIN], ci), + Ymin = double.Parse(parts[IDX_YMIN], ci), + Xmax = double.Parse(parts[IDX_XMAX], ci), + Ymax = double.Parse(parts[IDX_YMAX], ci), + GlobalMin = double.Parse(parts[IDX_GMIN], ci), + GlobalMax = double.Parse(parts[IDX_GMAX], ci), + OutRes = int.Parse(parts[IDX_RES], ci) + }); + } + catch (Exception e) + { + Debug.LogWarning($"[GeoTilePrefabImporter] Parse error line {i + 1}: {e.Message}"); + } + } + + return tiles; + } + + private bool CreateTilePrefab(TileMetadata tile) + { + // Validate height range + float heightRange = (float)(tile.GlobalMax - tile.GlobalMin); + if (heightRange <= 0.0001f) + { + Debug.LogWarning($"[GeoTilePrefabImporter] Tile {tile.TileId}: invalid height range. Skipping."); + return false; + } + + // Load heightmap + string pngPath = Path.Combine(heightmapsDir, $"{tile.TileId}.png").Replace("\\", "/"); + if (!File.Exists(pngPath)) + { + Debug.LogError($"[GeoTilePrefabImporter] Missing heightmap for {tile.TileId}: {pngPath}"); + return false; + } + + EnsureHeightmapImportSettings(pngPath); + var heightmapTex = AssetDatabase.LoadAssetAtPath(pngPath); + if (heightmapTex == null) + { + Debug.LogError($"[GeoTilePrefabImporter] Could not load heightmap: {pngPath}"); + return false; + } + + // Create TerrainData + var terrainData = new TerrainData + { + heightmapResolution = heightmapResolution, + size = new Vector3(tileSizeMeters, heightRange, tileSizeMeters) + }; + + // Read heightmap pixels + int w = heightmapTex.width; + int h = heightmapTex.height; + var heights = new float[h, w]; + + bool usedU16 = false; + try + { + var raw = heightmapTex.GetPixelData(0); + if (raw.Length == w * h) + { + for (int y = 0; y < h; y++) + for (int x = 0; x < w; x++) + heights[y, x] = raw[y * w + x] / 65535f; + usedU16 = true; + } + } + catch { } + + if (!usedU16) + { + var pixels = heightmapTex.GetPixels(); + for (int y = 0; y < h; y++) + for (int x = 0; x < w; x++) + heights[y, x] = pixels[y * w + x].r; + } + + terrainData.SetHeights(0, 0, heights); + + // Save TerrainData as asset (MUST be done before creating prefab) + string terrainDataPath = $"{prefabOutputDir}/TerrainData/{tile.TileId}_TerrainData.asset"; + if (File.Exists(terrainDataPath)) + AssetDatabase.DeleteAsset(terrainDataPath); + AssetDatabase.CreateAsset(terrainData, terrainDataPath); + + // Apply ortho texture if enabled + if (applyOrthoTextures) + { + ApplyOrthoTexture(terrainData, tile.TileId); + } + + // Create root GameObject with Terrain + var root = new GameObject(tile.TileId); + var terrain = root.AddComponent(); + terrain.terrainData = terrainData; + terrain.drawInstanced = true; + + var collider = root.AddComponent(); + collider.terrainData = terrainData; + + // Store metadata as component for later use + var metadata = root.AddComponent(); + metadata.tileId = tile.TileId; + metadata.xmin = tile.Xmin; + metadata.ymin = tile.Ymin; + metadata.globalMin = tile.GlobalMin; + metadata.globalMax = tile.GlobalMax; + + // Add child components + if (includeBuildings) + AddBuildings(root, tile); + + if (includeTrees) + AddTrees(root, tile); + + if (includeFurniture) + AddFurniture(root, tile); + + if (includeEnhancedTrees) + AddEnhancedTrees(root, tile); + + // Save as prefab + string prefabPath = $"{prefabOutputDir}/{tile.TileId}.prefab"; + if (File.Exists(prefabPath)) + AssetDatabase.DeleteAsset(prefabPath); + + PrefabUtility.SaveAsPrefabAsset(root, prefabPath); + Debug.Log($"[GeoTilePrefabImporter] Created prefab: {prefabPath}"); + + // Cleanup temporary scene object + DestroyImmediate(root); + + return true; + } + + private void ApplyOrthoTexture(TerrainData terrainData, string tileId) + { + string orthoPath = Path.Combine(orthoDir, $"{tileId}.jpg").Replace("\\", "/"); + if (!File.Exists(orthoPath)) + { + Debug.LogWarning($"[GeoTilePrefabImporter] Ortho texture missing for {tileId}: {orthoPath}"); + return; + } + + EnsureOrthoImportSettings(orthoPath); + var orthoTex = AssetDatabase.LoadAssetAtPath(orthoPath); + if (orthoTex == null) + { + Debug.LogWarning($"[GeoTilePrefabImporter] Could not load ortho texture: {orthoPath}"); + return; + } + + // Create and save TerrainLayer as asset (required for prefab serialization) + var layer = new TerrainLayer + { + diffuseTexture = orthoTex, + tileSize = new Vector2(tileSizeMeters, tileSizeMeters), + tileOffset = Vector2.zero + }; + + string layerPath = $"{prefabOutputDir}/TerrainLayers/{tileId}_Layer.asset"; + if (File.Exists(layerPath)) + AssetDatabase.DeleteAsset(layerPath); + AssetDatabase.CreateAsset(layer, layerPath); + + terrainData.terrainLayers = new[] { layer }; + + // Setup alphamap (single layer, full coverage) + terrainData.alphamapResolution = 16; + var alpha = new float[16, 16, 1]; + for (int y = 0; y < 16; y++) + for (int x = 0; x < 16; x++) + alpha[y, x, 0] = 1f; + terrainData.SetAlphamaps(0, 0, alpha); + } + + private void AddBuildings(GameObject root, TileMetadata tile) + { + string glbPath = Path.Combine(buildingsDir, $"{tile.TileId}.glb").Replace("\\", "/"); + if (!File.Exists(glbPath)) + return; + + var buildingPrefab = AssetDatabase.LoadAssetAtPath(glbPath); + if (buildingPrefab == null) + { + Debug.LogWarning($"[GeoTilePrefabImporter] Could not load building GLB: {glbPath}"); + return; + } + + // Building GLB vertices have absolute Z (elevation) from CityGML. + // Since prefab root will be at Y=gmin when placed, offset buildings by -gmin + // so building world Y = gmin + (-gmin) + GLB_Y = GLB_Y (correct absolute elevation) + var instance = PrefabUtility.InstantiatePrefab(buildingPrefab) as GameObject; + if (instance == null) + instance = Instantiate(buildingPrefab); + + instance.name = "Buildings"; + instance.transform.SetParent(root.transform, false); + instance.transform.localPosition = new Vector3(0f, -(float)tile.GlobalMin, 0f); + instance.isStatic = true; + } + + private void AddTrees(GameObject root, TileMetadata tile) + { + if (!Directory.Exists(treesDir)) + return; + + // Look for chunk files or single file + var chunkFiles = Directory.GetFiles(treesDir, $"{tile.TileId}_*.glb"); + if (chunkFiles.Length == 0) + { + string singlePath = Path.Combine(treesDir, $"{tile.TileId}.glb").Replace("\\", "/"); + if (File.Exists(singlePath)) + chunkFiles = new[] { singlePath }; + else + return; + } + + // Tree GLB vertices use absolute elevation (z_ground from DGM). + // Since prefab root will be at Y=gmin when placed, offset trees by -gmin + // so tree world Y = gmin + (-gmin) + GLB_Y = GLB_Y (correct absolute elevation) + // Scale Z by -1 to correct coordinate system mismatch (Python negates Z in export) + var treesContainer = new GameObject("Trees"); + treesContainer.transform.SetParent(root.transform, false); + treesContainer.transform.localPosition = new Vector3(0f, -(float)tile.GlobalMin, 0f); + treesContainer.transform.localScale = new Vector3(1f, 1f, -1f); + treesContainer.isStatic = true; + + foreach (var chunkPath in chunkFiles) + { + string assetPath = chunkPath.Replace("\\", "/"); + var chunkPrefab = AssetDatabase.LoadAssetAtPath(assetPath); + if (chunkPrefab == null) + { + Debug.LogWarning($"[GeoTilePrefabImporter] Could not load tree chunk: {assetPath}"); + continue; + } + + var instance = PrefabUtility.InstantiatePrefab(chunkPrefab) as GameObject; + if (instance == null) + instance = Instantiate(chunkPrefab); + + instance.name = Path.GetFileNameWithoutExtension(chunkPath); + instance.transform.SetParent(treesContainer.transform, false); + instance.transform.localPosition = Vector3.zero; + instance.isStatic = true; + } + } + + private void AddFurniture(GameObject root, TileMetadata tile) + { + string csvPath = Path.Combine(furnitureDir, $"{tile.TileId}.csv").Replace("\\", "/"); + if (!File.Exists(csvPath)) + return; + + var lines = File.ReadAllLines(csvPath); + if (lines.Length < 2) + return; + + var ci = CultureInfo.InvariantCulture; + var header = lines[0].Split(','); + int idxXLocal = -1, idxYLocal = -1, idxZGround = -1, idxHeight = -1, idxType = -1; + + for (int i = 0; i < header.Length; i++) + { + var col = header[i].Trim().ToLowerInvariant(); + if (col == "x_local") idxXLocal = i; + else if (col == "y_local") idxYLocal = i; + else if (col == "z_ground") idxZGround = i; + else if (col == "height") idxHeight = i; + else if (col == "type") idxType = i; + } + + if (idxXLocal < 0 || idxYLocal < 0 || idxType < 0) + { + Debug.LogWarning($"[GeoTilePrefabImporter] Furniture CSV missing required columns: {csvPath}"); + return; + } + + var furnitureContainer = new GameObject("Furniture"); + furnitureContainer.transform.SetParent(root.transform, false); + furnitureContainer.transform.localPosition = Vector3.zero; + furnitureContainer.isStatic = true; + + float gmin = (float)tile.GlobalMin; + + for (int i = 1; i < lines.Length; i++) + { + var parts = lines[i].Split(','); + if (parts.Length <= Math.Max(Math.Max(idxXLocal, idxYLocal), idxType)) + continue; + + try + { + float xLocal = float.Parse(parts[idxXLocal].Trim(), ci); + float yLocal = float.Parse(parts[idxYLocal].Trim(), ci); + float zGround = idxZGround >= 0 && idxZGround < parts.Length + ? float.Parse(parts[idxZGround].Trim(), ci) : 0f; + float height = idxHeight >= 0 && idxHeight < parts.Length + ? float.Parse(parts[idxHeight].Trim(), ci) : 1f; + string furnitureType = parts[idxType].Trim().ToLowerInvariant(); + + GameObject obj = CreateFurnitureObject(furnitureType, height, i); + obj.transform.SetParent(furnitureContainer.transform, false); + obj.transform.localPosition = new Vector3(xLocal, zGround - gmin, yLocal); + obj.isStatic = true; + } + catch (Exception e) + { + Debug.LogWarning($"[GeoTilePrefabImporter] Failed to parse furniture line {i}: {e.Message}"); + } + } + } + + private GameObject CreateFurnitureObject(string type, float height, int index) + { + GameObject prefabToUse = null; + Vector3 fallbackScale = Vector3.one; + + switch (type) + { + case "lamp": + prefabToUse = lampPrefab; + fallbackScale = new Vector3(0.3f, height, 0.3f); + break; + case "bench": + prefabToUse = benchPrefab; + fallbackScale = new Vector3(1.5f, height, 0.5f); + break; + case "sign": + prefabToUse = signPrefab; + fallbackScale = new Vector3(0.1f, height, 0.5f); + break; + case "bollard": + prefabToUse = bollardPrefab; + fallbackScale = new Vector3(0.2f, height, 0.2f); + break; + default: + prefabToUse = defaultFurniturePrefab; + fallbackScale = new Vector3(0.5f, height, 0.5f); + break; + } + + GameObject obj; + if (prefabToUse != null) + { + obj = PrefabUtility.InstantiatePrefab(prefabToUse) as GameObject ?? Instantiate(prefabToUse); + obj.name = $"{type}_{index}"; + obj.transform.localScale = Vector3.one * height; + } + else + { + obj = GameObject.CreatePrimitive(PrimitiveType.Cube); + obj.name = $"{type}_{index}"; + obj.transform.localScale = fallbackScale; + } + + return obj; + } + + private void AddEnhancedTrees(GameObject root, TileMetadata tile) + { + string csvPath = Path.Combine(enhancedTreesDir, $"{tile.TileId}.csv").Replace("\\", "/"); + if (!File.Exists(csvPath)) + return; + + var lines = File.ReadAllLines(csvPath); + if (lines.Length < 2) + return; + + var ci = CultureInfo.InvariantCulture; + var header = lines[0].Split(','); + int idxXLocal = -1, idxYLocal = -1, idxZGround = -1, idxHeight = -1, idxRadius = -1; + int idxCanopyR = -1, idxCanopyG = -1, idxCanopyB = -1; + + for (int i = 0; i < header.Length; i++) + { + var col = header[i].Trim().ToLowerInvariant(); + if (col == "x_local") idxXLocal = i; + else if (col == "y_local") idxYLocal = i; + else if (col == "z_ground") idxZGround = i; + else if (col == "height") idxHeight = i; + else if (col == "radius") idxRadius = i; + else if (col == "canopy_r") idxCanopyR = i; + else if (col == "canopy_g") idxCanopyG = i; + else if (col == "canopy_b") idxCanopyB = i; + } + + if (idxXLocal < 0 || idxYLocal < 0 || idxHeight < 0) + { + Debug.LogWarning($"[GeoTilePrefabImporter] Enhanced trees CSV missing required columns: {csvPath}"); + return; + } + + var treesContainer = new GameObject("EnhancedTrees"); + treesContainer.transform.SetParent(root.transform, false); + treesContainer.transform.localPosition = Vector3.zero; + treesContainer.isStatic = true; + + float gmin = (float)tile.GlobalMin; + + for (int i = 1; i < lines.Length; i++) + { + var parts = lines[i].Split(','); + if (parts.Length <= Math.Max(Math.Max(idxXLocal, idxYLocal), idxHeight)) + continue; + + try + { + float xLocal = float.Parse(parts[idxXLocal].Trim(), ci); + float yLocal = float.Parse(parts[idxYLocal].Trim(), ci); + float zGround = idxZGround >= 0 && idxZGround < parts.Length + ? float.Parse(parts[idxZGround].Trim(), ci) : 0f; + float height = float.Parse(parts[idxHeight].Trim(), ci); + float radius = idxRadius >= 0 && idxRadius < parts.Length + ? float.Parse(parts[idxRadius].Trim(), ci) : height * 0.25f; + + int canopyR = 128, canopyG = 160, canopyB = 80; + if (idxCanopyR >= 0 && idxCanopyR < parts.Length) + canopyR = Mathf.Clamp(int.Parse(parts[idxCanopyR].Trim()), 0, 255); + if (idxCanopyG >= 0 && idxCanopyG < parts.Length) + canopyG = Mathf.Clamp(int.Parse(parts[idxCanopyG].Trim()), 0, 255); + if (idxCanopyB >= 0 && idxCanopyB < parts.Length) + canopyB = Mathf.Clamp(int.Parse(parts[idxCanopyB].Trim()), 0, 255); + + Color canopyColor = new Color(canopyR / 255f, canopyG / 255f, canopyB / 255f); + + GameObject treeObj = CreateEnhancedTree(height, radius, canopyColor, i); + treeObj.transform.SetParent(treesContainer.transform, false); + treeObj.transform.localPosition = new Vector3(xLocal, zGround - gmin, yLocal); + treeObj.isStatic = true; + } + catch (Exception e) + { + Debug.LogWarning($"[GeoTilePrefabImporter] Failed to parse enhanced tree line {i}: {e.Message}"); + } + } + } + + private GameObject CreateEnhancedTree(float height, float radius, Color canopyColor, int index) + { + if (treePrefab != null) + { + var treeObj = PrefabUtility.InstantiatePrefab(treePrefab) as GameObject ?? Instantiate(treePrefab); + treeObj.name = $"tree_{index}"; + + float scaleY = height / 10f; + float scaleXZ = radius * 2f / 5f; + treeObj.transform.localScale = new Vector3(scaleXZ, scaleY, scaleXZ); + + var renderers = treeObj.GetComponentsInChildren(); + foreach (var rend in renderers) + { + foreach (var mat in rend.sharedMaterials) + { + if (mat != null) + { + var matInst = new Material(mat); + matInst.color = canopyColor; + rend.material = matInst; + } + } + } + + return treeObj; + } + + // Create procedural placeholder + var tree = new GameObject($"tree_{index}"); + + var trunk = GameObject.CreatePrimitive(PrimitiveType.Cylinder); + trunk.name = "trunk"; + trunk.transform.SetParent(tree.transform, false); + trunk.transform.localPosition = new Vector3(0, height * 0.25f, 0); + trunk.transform.localScale = new Vector3(0.3f, height * 0.5f, 0.3f); + var trunkRend = trunk.GetComponent(); + if (trunkRend != null) + { + var trunkMat = new Material(Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard")); + trunkMat.color = new Color(0.4f, 0.25f, 0.15f); + trunkRend.material = trunkMat; + } + + var canopy = GameObject.CreatePrimitive(PrimitiveType.Sphere); + canopy.name = "canopy"; + canopy.transform.SetParent(tree.transform, false); + canopy.transform.localPosition = new Vector3(0, height * 0.7f, 0); + canopy.transform.localScale = new Vector3(radius * 2f, height * 0.5f, radius * 2f); + var canopyRend = canopy.GetComponent(); + if (canopyRend != null) + { + var canopyMat = new Material(Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard")); + canopyMat.color = canopyColor; + canopyRend.material = canopyMat; + } + + return tree; + } + + #region Helper Methods + + private static void EnsureDirectoryExists(string path) + { + if (!Directory.Exists(path)) + { + Directory.CreateDirectory(path); + AssetDatabase.Refresh(); + } + } + + private static void EnsureHeightmapImportSettings(string assetPath) + { + var ti = (TextureImporter)AssetImporter.GetAtPath(assetPath); + if (ti == null) return; + + bool changed = false; + + if (!ti.isReadable) { ti.isReadable = true; changed = true; } + if (ti.sRGBTexture) { ti.sRGBTexture = false; changed = true; } + if (ti.textureCompression != TextureImporterCompression.Uncompressed) + { + ti.textureCompression = TextureImporterCompression.Uncompressed; + changed = true; + } + if (ti.npotScale != TextureImporterNPOTScale.None) + { + ti.npotScale = TextureImporterNPOTScale.None; + changed = true; + } + if (ti.textureType != TextureImporterType.SingleChannel) + { + ti.textureType = TextureImporterType.SingleChannel; + changed = true; + } + + var ps = ti.GetDefaultPlatformTextureSettings(); + if (ps.format != TextureImporterFormat.R16) + { + ps.format = TextureImporterFormat.R16; + ti.SetPlatformTextureSettings(ps); + changed = true; + } + + changed |= EnsurePlatformR16(ti, "Standalone"); + changed |= EnsurePlatformR16(ti, "Android"); + changed |= EnsurePlatformR16(ti, "iPhone"); + + if (changed) ti.SaveAndReimport(); + } + + private static bool EnsurePlatformR16(TextureImporter ti, string platform) + { + var ps = ti.GetPlatformTextureSettings(platform); + + bool changed = false; + if (ps.name != platform) + { + ps = new TextureImporterPlatformSettings + { + name = platform, + overridden = true, + format = TextureImporterFormat.R16, + textureCompression = TextureImporterCompression.Uncompressed, + }; + ti.SetPlatformTextureSettings(ps); + return true; + } + + if (!ps.overridden) { ps.overridden = true; changed = true; } + if (ps.format != TextureImporterFormat.R16) { ps.format = TextureImporterFormat.R16; changed = true; } + if (ps.textureCompression != TextureImporterCompression.Uncompressed) + { + ps.textureCompression = TextureImporterCompression.Uncompressed; + changed = true; + } + + if (changed) ti.SetPlatformTextureSettings(ps); + return changed; + } + + private static void EnsureOrthoImportSettings(string assetPath) + { + var ti = (TextureImporter)AssetImporter.GetAtPath(assetPath); + if (ti == null) return; + + bool changed = false; + if (!ti.isReadable) { ti.isReadable = true; changed = true; } + if (!ti.sRGBTexture) { ti.sRGBTexture = true; changed = true; } + if (ti.npotScale != TextureImporterNPOTScale.None) { ti.npotScale = TextureImporterNPOTScale.None; changed = true; } + if (ti.wrapMode != TextureWrapMode.Clamp) { ti.wrapMode = TextureWrapMode.Clamp; changed = true; } + if (ti.textureCompression != TextureImporterCompression.Uncompressed) + { + ti.textureCompression = TextureImporterCompression.Uncompressed; + changed = true; + } + + if (changed) ti.SaveAndReimport(); + } + + private static string NormalizeHeader(string s) + => (s ?? "").Trim().ToLowerInvariant(); + + private static Dictionary BuildHeaderMap(string headerLine) + { + var map = new Dictionary(); + var cols = headerLine.Split(','); + for (int i = 0; i < cols.Length; i++) + { + var key = NormalizeHeader(cols[i]); + if (string.IsNullOrEmpty(key)) continue; + if (!map.ContainsKey(key)) + map[key] = i; + } + return map; + } + + private static bool HasAll(Dictionary map, params string[] required) + { + foreach (var r in required) + if (!map.ContainsKey(NormalizeHeader(r))) + return false; + return true; + } + + #endregion +} + +/// +/// Component attached to tile prefab roots to store geo metadata. +/// Useful for positioning prefabs in scene or querying tile info at runtime. +/// +public class GeoTileMetadata : MonoBehaviour +{ + public string tileId; + public double xmin; + public double ymin; + public double globalMin; + public double globalMax; + + /// + /// Returns the world position this tile should be placed at, given a global origin. + /// + public Vector3 GetWorldPosition(double originX, double originY) + { + return new Vector3( + (float)(xmin - originX), + (float)globalMin, + (float)(ymin - originY) + ); + } +}