// 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 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); 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++) for (int x = 0; x < w; x++) heights[y, x] = raw[y * w + x] / 65535f; usedU16 = true; } } catch { // fallback below } if (!usedU16) { var pixels = tex.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); 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 layer = new TerrainLayer { diffuseTexture = orthoTex, tileSize = new Vector2(tileSizeMeters, tileSizeMeters), tileOffset = Vector2.zero }; 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; } var inst = PrefabUtility.InstantiatePrefab(prefab) as GameObject ?? Instantiate(prefab); inst.name = tileId; inst.transform.SetParent(parent.transform, false); inst.transform.position = new Vector3(ux, gmin, uz); inst.transform.localRotation = Quaternion.Euler(0f, 180f, 0f); 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 var tileContainer = new GameObject($"Trees_{tileId}"); tileContainer.transform.SetParent(parent.transform, false); tileContainer.transform.position = new Vector3(ux, gmin, uz); tileContainer.transform.localRotation = Quaternion.Euler(0f, 180f, 0f); 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}'."); } }