Add Unity importer scripts and handle CityJSON transform
This commit is contained in:
975
scripts_unity/Editor/GeoTileImporter.cs
Normal file
975
scripts_unity/Editor/GeoTileImporter.cs
Normal file
@@ -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<GeoTileImporter>("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<string, int> BuildHeaderMap(string headerLine)
|
||||
{
|
||||
var map = new Dictionary<string, int>();
|
||||
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<string, int> 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<Texture2D>(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<ushort>(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>();
|
||||
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<Texture2D>(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<GameObject>(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<GameObject>(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<Renderer>();
|
||||
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<Renderer>();
|
||||
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<Renderer>();
|
||||
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}'.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user