Use per-tile heightmap scaling

This commit is contained in:
2026-01-23 16:40:51 +01:00
parent c930d5f1e1
commit 9947e9df74
8 changed files with 183 additions and 50 deletions

View File

@@ -116,7 +116,7 @@ public class GeoTileImporter : EditorWindow
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" +
"Absolute elevation mapping: Terrain Y = tile_min (or global_min), Terrain height = (tile_max - tile_min).\n" +
"CSV is header-driven (order-independent). Optionally applies ortho JPGs and instantiates buildings/trees GLBs.",
MessageType.Info);
}
@@ -298,6 +298,9 @@ public class GeoTileImporter : EditorWindow
int IDX_GMIN = headerMap["global_min"];
int IDX_GMAX = headerMap["global_max"];
int IDX_RES = headerMap["out_res"];
bool hasTileMin = headerMap.TryGetValue("tile_min", out int IDX_TMIN);
bool hasTileMax = headerMap.TryGetValue("tile_max", out int IDX_TMAX);
bool useTileRange = hasTileMin && hasTileMax;
// Compute origin from min xmin/ymin
double originX = double.PositiveInfinity;
@@ -312,6 +315,8 @@ public class GeoTileImporter : EditorWindow
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 (useTileRange)
needMaxIndex = Math.Max(needMaxIndex, Math.Max(IDX_TMIN, IDX_TMAX));
if (parts.Length <= needMaxIndex)
{
Debug.LogWarning($"[GeoTileImporter] Origin scan: skipping line {i + 1} (too few columns: {parts.Length}). Line: '{line}'");
@@ -342,7 +347,7 @@ public class GeoTileImporter : EditorWindow
int imported = 0, skipped = 0;
int importedTextures = 0;
var placements = new List<(string tileId, float ux, float uz, float gmin)>();
var placements = new List<(string tileId, float ux, float uz, float baseMin)>();
for (int i = 1; i < lines.Length; i++)
{
@@ -351,6 +356,8 @@ public class GeoTileImporter : EditorWindow
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 (useTileRange)
needMaxIndex = Math.Max(needMaxIndex, Math.Max(IDX_TMIN, IDX_TMAX));
if (parts.Length <= needMaxIndex)
{
skipped++;
@@ -361,6 +368,9 @@ public class GeoTileImporter : EditorWindow
string tileId = parts[IDX_TILE].Trim();
double xmin, ymin, gmin, gmax;
double tileMin = 0.0;
double tileMax = 0.0;
bool tileRangeValid = false;
int outRes;
try
{
@@ -377,15 +387,40 @@ public class GeoTileImporter : EditorWindow
continue;
}
if (useTileRange)
{
if (double.TryParse(parts[IDX_TMIN], NumberStyles.Float, ci, out double tmin) &&
double.TryParse(parts[IDX_TMAX], NumberStyles.Float, ci, out double tmax))
{
tileMin = tmin;
tileMax = tmax;
tileRangeValid = true;
}
else
{
Debug.LogWarning($"[GeoTileImporter] Tile {tileId}: invalid tile_min/tile_max; falling back to global range.");
}
}
if (outRes != heightmapResolution)
Debug.LogWarning($"[GeoTileImporter] Tile {tileId}: out_res={outRes} but importer expects {heightmapResolution}.");
float heightRange = (float)(gmax - gmin);
double baseMin = tileRangeValid ? tileMin : gmin;
double baseMax = tileRangeValid ? tileMax : gmax;
float heightRange = (float)(baseMax - baseMin);
if (heightRange <= 0.0001f)
{
skipped++;
Debug.LogWarning($"[GeoTileImporter] Tile {tileId}: invalid height range (global_max <= global_min). Skipping.");
continue;
if (tileRangeValid)
{
Debug.LogWarning($"[GeoTileImporter] Tile {tileId}: flat tile range; using epsilon height range.");
heightRange = 0.001f;
}
else
{
skipped++;
Debug.LogWarning($"[GeoTileImporter] Tile {tileId}: invalid height range (global_max <= global_min). Skipping.");
continue;
}
}
string pngPath = Path.Combine(heightmapsDir, $"{tileId}.png").Replace("\\", "/");
@@ -452,7 +487,7 @@ public class GeoTileImporter : EditorWindow
float ux = (float)(xmin - originX);
float uz = (float)(ymin - originY);
go.transform.position = new Vector3(ux, (float)gmin, uz);
go.transform.position = new Vector3(ux, (float)baseMin, uz);
var terrain = go.GetComponent<Terrain>();
terrain.drawInstanced = true;
@@ -493,9 +528,9 @@ public class GeoTileImporter : EditorWindow
}
}
Debug.Log($"[GeoTileImporter] Imported {tileId} @ XZ=({ux},{uz}) Y={gmin} heightRange={heightRange} usedU16={usedU16}");
Debug.Log($"[GeoTileImporter] Imported {tileId} @ XZ=({ux},{uz}) Y={baseMin} heightRange={heightRange} usedU16={usedU16}");
imported++;
placements.Add((tileId, ux, uz, (float)gmin));
placements.Add((tileId, ux, uz, (float)baseMin));
}
Debug.Log($"[GeoTileImporter] DONE. Imported={imported}, Skipped={skipped}, OrthoApplied={importedTextures} under '{parentName}'.");
@@ -509,7 +544,7 @@ public class GeoTileImporter : EditorWindow
ImportEnhancedTrees(placements);
}
private void ImportBuildings(List<(string tileId, float ux, float uz, float gmin)> placements)
private void ImportBuildings(List<(string tileId, float ux, float uz, float baseMin)> placements)
{
if (!importBuildings)
return;
@@ -533,7 +568,7 @@ public class GeoTileImporter : EditorWindow
}
int imported = 0, missing = 0;
foreach (var (tileId, ux, uz, gmin) in placements)
foreach (var (tileId, ux, uz, baseMin) in placements)
{
string glbPath = Path.Combine(activeDir, $"{tileId}.glb").Replace("\\", "/");
if (!File.Exists(glbPath))
@@ -554,7 +589,7 @@ public class GeoTileImporter : EditorWindow
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.position = new Vector3(ux, baseMin, uz);
inst.transform.localRotation = Quaternion.Euler(0f, 180f, 0f);
inst.isStatic = true;
imported++;
@@ -563,7 +598,7 @@ public class GeoTileImporter : EditorWindow
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)
private void ImportTrees(List<(string tileId, float ux, float uz, float baseMin)> placements)
{
if (!importTrees)
return;
@@ -588,7 +623,7 @@ public class GeoTileImporter : EditorWindow
}
int importedTiles = 0, importedChunks = 0, missingTiles = 0;
foreach (var (tileId, ux, uz, gmin) in placements)
foreach (var (tileId, ux, uz, baseMin) in placements)
{
// Look for chunk files: {tileId}_0_0.glb, {tileId}_0_1.glb, etc.
// Standard tree export creates 4x4 chunks per tile
@@ -612,7 +647,7 @@ public class GeoTileImporter : EditorWindow
// 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.position = new Vector3(ux, baseMin, uz);
tileContainer.transform.localRotation = Quaternion.Euler(0f, 180f, 0f);
tileContainer.isStatic = true;
@@ -640,7 +675,7 @@ public class GeoTileImporter : EditorWindow
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)
private void ImportFurniture(List<(string tileId, float ux, float uz, float baseMin)> placements)
{
if (!importFurniture)
return;
@@ -662,7 +697,7 @@ public class GeoTileImporter : EditorWindow
int imported = 0, skipped = 0;
var ci = CultureInfo.InvariantCulture;
foreach (var (tileId, ux, uz, gmin) in placements)
foreach (var (tileId, ux, uz, baseMin) in placements)
{
string csvPath = Path.Combine(furnitureDir, $"{tileId}.csv").Replace("\\", "/");
if (!File.Exists(csvPath))
@@ -696,7 +731,7 @@ public class GeoTileImporter : EditorWindow
// Create tile container
var tileContainer = new GameObject($"Furniture_{tileId}");
tileContainer.transform.SetParent(parent.transform, false);
tileContainer.transform.position = new Vector3(ux, gmin, uz);
tileContainer.transform.position = new Vector3(ux, baseMin, uz);
tileContainer.isStatic = true;
for (int i = 1; i < lines.Length; i++)
@@ -767,7 +802,7 @@ public class GeoTileImporter : EditorWindow
}
obj.transform.SetParent(tileContainer.transform, false);
obj.transform.localPosition = new Vector3(xLocal, zGround - gmin, yLocal);
obj.transform.localPosition = new Vector3(xLocal, zGround - baseMin, yLocal);
obj.isStatic = true;
imported++;
}
@@ -782,7 +817,7 @@ public class GeoTileImporter : EditorWindow
Debug.Log($"[GeoTileImporter] Furniture imported={imported}, skipped={skipped} under '{furnitureParentName}'.");
}
private void ImportEnhancedTrees(List<(string tileId, float ux, float uz, float gmin)> placements)
private void ImportEnhancedTrees(List<(string tileId, float ux, float uz, float baseMin)> placements)
{
if (!importEnhancedTrees)
return;
@@ -804,7 +839,7 @@ public class GeoTileImporter : EditorWindow
int imported = 0, skipped = 0;
var ci = CultureInfo.InvariantCulture;
foreach (var (tileId, ux, uz, gmin) in placements)
foreach (var (tileId, ux, uz, baseMin) in placements)
{
string csvPath = Path.Combine(enhancedTreesDir, $"{tileId}.csv").Replace("\\", "/");
if (!File.Exists(csvPath))
@@ -842,7 +877,7 @@ public class GeoTileImporter : EditorWindow
// Create tile container
var tileContainer = new GameObject($"Trees_{tileId}");
tileContainer.transform.SetParent(parent.transform, false);
tileContainer.transform.position = new Vector3(ux, gmin, uz);
tileContainer.transform.position = new Vector3(ux, baseMin, uz);
tileContainer.isStatic = true;
for (int i = 1; i < lines.Length; i++)
@@ -940,7 +975,7 @@ public class GeoTileImporter : EditorWindow
}
treeObj.transform.SetParent(tileContainer.transform, false);
treeObj.transform.localPosition = new Vector3(xLocal, zGround - gmin, yLocal);
treeObj.transform.localPosition = new Vector3(xLocal, zGround - baseMin, yLocal);
treeObj.isStatic = true;
imported++;
}

View File

@@ -48,6 +48,8 @@ public class GeoTilePrefabImporter : EditorWindow
public string TileId;
public double Xmin, Ymin, Xmax, Ymax;
public double GlobalMin, GlobalMax;
public double TileMin, TileMax;
public bool HasTileMinMax;
public int OutRes;
}
@@ -288,6 +290,9 @@ public class GeoTilePrefabImporter : EditorWindow
int IDX_GMIN = headerMap["global_min"];
int IDX_GMAX = headerMap["global_max"];
int IDX_RES = headerMap["out_res"];
bool hasTileMin = headerMap.TryGetValue("tile_min", out int IDX_TMIN);
bool hasTileMax = headerMap.TryGetValue("tile_max", out int IDX_TMAX);
bool useTileRange = hasTileMin && hasTileMax;
for (int i = 1; i < lines.Length; i++)
{
@@ -296,6 +301,8 @@ public class GeoTilePrefabImporter : EditorWindow
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 (useTileRange)
maxIdx = Math.Max(maxIdx, Math.Max(IDX_TMIN, IDX_TMAX));
if (parts.Length <= maxIdx)
{
Debug.LogWarning($"[GeoTilePrefabImporter] Skipping line {i + 1}: too few columns.");
@@ -304,6 +311,26 @@ public class GeoTilePrefabImporter : EditorWindow
try
{
double gmin = double.Parse(parts[IDX_GMIN], ci);
double gmax = double.Parse(parts[IDX_GMAX], ci);
double tileMin = gmin;
double tileMax = gmax;
bool tileRangeValid = false;
if (useTileRange)
{
if (double.TryParse(parts[IDX_TMIN], NumberStyles.Float, ci, out double tmin) &&
double.TryParse(parts[IDX_TMAX], NumberStyles.Float, ci, out double tmax))
{
tileMin = tmin;
tileMax = tmax;
tileRangeValid = true;
}
else
{
Debug.LogWarning($"[GeoTilePrefabImporter] Tile {parts[IDX_TILE].Trim()}: invalid tile_min/tile_max; falling back to global range.");
}
}
tiles.Add(new TileMetadata
{
TileId = parts[IDX_TILE].Trim(),
@@ -311,8 +338,11 @@ public class GeoTilePrefabImporter : EditorWindow
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),
GlobalMin = gmin,
GlobalMax = gmax,
TileMin = tileMin,
TileMax = tileMax,
HasTileMinMax = tileRangeValid,
OutRes = int.Parse(parts[IDX_RES], ci)
});
}
@@ -328,11 +358,21 @@ public class GeoTilePrefabImporter : EditorWindow
private bool CreateTilePrefab(TileMetadata tile)
{
// Validate height range
float heightRange = (float)(tile.GlobalMax - tile.GlobalMin);
double baseMin = tile.HasTileMinMax ? tile.TileMin : tile.GlobalMin;
double baseMax = tile.HasTileMinMax ? tile.TileMax : tile.GlobalMax;
float heightRange = (float)(baseMax - baseMin);
if (heightRange <= 0.0001f)
{
Debug.LogWarning($"[GeoTilePrefabImporter] Tile {tile.TileId}: invalid height range. Skipping.");
return false;
if (tile.HasTileMinMax)
{
Debug.LogWarning($"[GeoTilePrefabImporter] Tile {tile.TileId}: flat tile range; using epsilon height range.");
heightRange = 0.001f;
}
else
{
Debug.LogWarning($"[GeoTilePrefabImporter] Tile {tile.TileId}: invalid height range. Skipping.");
return false;
}
}
// Load heightmap
@@ -412,6 +452,9 @@ public class GeoTilePrefabImporter : EditorWindow
metadata.ymin = tile.Ymin;
metadata.globalMin = tile.GlobalMin;
metadata.globalMax = tile.GlobalMax;
metadata.tileMin = tile.TileMin;
metadata.tileMax = tile.TileMax;
metadata.hasTileMinMax = tile.HasTileMinMax;
// Add child components
if (includeBuildings)
@@ -494,16 +537,17 @@ public class GeoTilePrefabImporter : EditorWindow
return;
}
float baseMin = (float)(tile.HasTileMinMax ? tile.TileMin : tile.GlobalMin);
// 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)
// Since prefab root will be at Y=baseMin when placed, offset buildings by -baseMin
// so building world Y = baseMin + (-baseMin) + 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.transform.localPosition = new Vector3(0f, -baseMin, 0f);
instance.transform.localRotation = Quaternion.Euler(0f, 180f, 0f);
instance.isStatic = true;
}
@@ -524,12 +568,13 @@ public class GeoTilePrefabImporter : EditorWindow
return;
}
float baseMin = (float)(tile.HasTileMinMax ? tile.TileMin : tile.GlobalMin);
// 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)
// Since prefab root will be at Y=baseMin when placed, offset trees by -baseMin
// so tree world Y = baseMin + (-baseMin) + GLB_Y = GLB_Y (correct absolute elevation)
var treesContainer = new GameObject("Trees");
treesContainer.transform.SetParent(root.transform, false);
treesContainer.transform.localPosition = new Vector3(0f, -(float)tile.GlobalMin, 0f);
treesContainer.transform.localPosition = new Vector3(0f, -baseMin, 0f);
treesContainer.transform.localRotation = Quaternion.Euler(0f, 180f, 0f);
treesContainer.isStatic = true;
@@ -589,7 +634,7 @@ public class GeoTilePrefabImporter : EditorWindow
furnitureContainer.transform.localPosition = Vector3.zero;
furnitureContainer.isStatic = true;
float gmin = (float)tile.GlobalMin;
float baseMin = (float)(tile.HasTileMinMax ? tile.TileMin : tile.GlobalMin);
for (int i = 1; i < lines.Length; i++)
{
@@ -609,7 +654,7 @@ public class GeoTilePrefabImporter : EditorWindow
GameObject obj = CreateFurnitureObject(furnitureType, height, i);
obj.transform.SetParent(furnitureContainer.transform, false);
obj.transform.localPosition = new Vector3(xLocal, zGround - gmin, yLocal);
obj.transform.localPosition = new Vector3(xLocal, zGround - baseMin, yLocal);
obj.isStatic = true;
}
catch (Exception e)
@@ -704,7 +749,7 @@ public class GeoTilePrefabImporter : EditorWindow
treesContainer.transform.localPosition = Vector3.zero;
treesContainer.isStatic = true;
float gmin = (float)tile.GlobalMin;
float baseMin = (float)(tile.HasTileMinMax ? tile.TileMin : tile.GlobalMin);
for (int i = 1; i < lines.Length; i++)
{
@@ -734,7 +779,7 @@ public class GeoTilePrefabImporter : EditorWindow
GameObject treeObj = CreateEnhancedTree(height, radius, canopyColor, i);
treeObj.transform.SetParent(treesContainer.transform, false);
treeObj.transform.localPosition = new Vector3(xLocal, zGround - gmin, yLocal);
treeObj.transform.localPosition = new Vector3(xLocal, zGround - baseMin, yLocal);
treeObj.isStatic = true;
}
catch (Exception e)

View File

@@ -11,15 +11,19 @@ public class GeoTileMetadata : MonoBehaviour
public double ymin;
public double globalMin;
public double globalMax;
public double tileMin;
public double tileMax;
public bool hasTileMinMax;
/// <summary>
/// Returns the world position this tile should be placed at, given a global origin.
/// </summary>
public Vector3 GetWorldPosition(double originX, double originY)
{
double baseMin = hasTileMinMax ? tileMin : globalMin;
return new Vector3(
(float)(xmin - originX),
(float)globalMin,
(float)baseMin,
(float)(ymin - originY)
);
}