339 lines
13 KiB
C#
339 lines
13 KiB
C#
// 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 float tileSizeMeters = 1000f;
|
|
private int heightmapResolution = 1025;
|
|
|
|
private string parentName = "Geo_Terrain_Tiles";
|
|
private bool deleteExisting = false;
|
|
|
|
[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);
|
|
|
|
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);
|
|
|
|
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).",
|
|
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.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;
|
|
}
|
|
|
|
if (changed) ti.SaveAndReimport();
|
|
}
|
|
|
|
private static string NormalizeHeader(string s)
|
|
=> (s ?? "").Trim().ToLowerInvariant();
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
|
|
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++)
|
|
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>();
|
|
terrain.drawInstanced = true;
|
|
|
|
Debug.Log($"[GeoTileImporter] Imported {tileId} @ XZ=({ux},{uz}) Y={gmin} heightRange={heightRange} usedU16={usedU16}");
|
|
imported++;
|
|
}
|
|
|
|
Debug.Log($"[GeoTileImporter] DONE. Imported={imported}, Skipped={skipped} under '{parentName}'.");
|
|
|
|
if (imported == 0)
|
|
Debug.LogError("[GeoTileImporter] Imported 0 tiles. Scroll up for warnings/errors (missing columns, parse issues, missing PNGs).");
|
|
}
|
|
}
|