diff --git a/PIPELINE.md b/PIPELINE.md index a7096fc..3bbfc96 100644 --- a/PIPELINE.md +++ b/PIPELINE.md @@ -47,7 +47,7 @@ Visual documentation showing how the GeoData pipeline transforms raw geospatial │ *.png (16-bit) │ *.jpg (2048²) │ *.glb │ *.csv │ │ *.pgw │ *.jgw │ │ trees_tiles/*.glb │ ├─────────────────┴─────────────────┴─────────────────┴───────────────────────┤ -│ tile_index.csv (manifest with bounds + elevation range + tile_key) │ +│ tile_index.csv (bounds + global/tile min/max + tile_key) │ ├─────────────────────────────────────────────────────────────────────────────┤ │ export_unity/ directory │ └────────────────────────────────────────┬────────────────────────────────────┘ @@ -118,7 +118,7 @@ Visual documentation showing how the GeoData pipeline transforms raw geospatial raw/dgm1/*.tif ──► work/dgm.vrt ──► Per-tile warp (1025×1025) │ ▼ - Scale to UInt16 + Scale to UInt16 (per-tile min/max by default) │ ▼ export_unity/height_png16/{tile}.png @@ -208,7 +208,7 @@ TREES-ENHANCED: | `trees/*.csv` | CSV | - | Tree positions (x,y,z,h,r) | | `trees_tiles/*.glb` | glTF Binary | - | Proxy geometry chunks | | `street_furniture/*.csv` | CSV | - | Furniture positions | -| `tile_index.csv` | CSV | - | Manifest (bounds, min/max, tile_key) | +| `tile_index.csv` | CSV | - | Manifest (bounds, global/tile min/max, tile_key) | --- @@ -255,7 +255,7 @@ Config ├── archives: ArchiveConfig # Archive staging ├── work: WorkConfig # Temp/intermediate ├── export: ExportConfig # Output directories -├── heightmap: HeightmapConfig # out_res=1025, tile_size_m=1000 +├── heightmap: HeightmapConfig # out_res=1025, tile_size_m=1000, use_tile_minmax=true ├── ortho: OrthoConfig # out_res=2048, quality=90 ├── tile_key: TileKeyConfig # tile_size_x/y=1000, overlap_x/y=0.5 ├── buildings: BuildingConfig # triangle budget 200-350k diff --git a/README.md b/README.md index fb9a647..99a8291 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ This repository converts DGM1 elevation tiles into Unity-ready 16-bit PNG height - `archive/` — offline storage for untouched downloads (e.g., zipped DOP/CityGML tiles, dop20 filelist). - `work/` — intermediates such as `dgm.vrt` and `_tmp.tif` files; safe to delete/regenerate. - `export_unity/height_png16/` — final 16-bit PNG heightmaps for Unity import. -- `export_unity/tile_index.csv` — manifest mapping tile IDs to world bounds and global min/max used for scaling, plus `tile_key`; built during heightmap export and required by orthophotos. +- `export_unity/tile_index.csv` — manifest mapping tile IDs to world bounds and scaling ranges (`global_min/global_max` plus per-tile `tile_min/tile_max`), plus `tile_key`; built during heightmap export and required by orthophotos. - `export_unity/ortho_jpg/` — cropped orthophoto tiles aligned to the terrain grid (JPEG + worldfiles). - `geodata_to_unity.py` — main CLI (uses `geodata_pipeline/` library modules). - `scripts/` — helpers to create the directory tree and fetch DOP20 inputs. @@ -35,10 +35,10 @@ This repository converts DGM1 elevation tiles into Unity-ready 16-bit PNG height # buildings: uv run python geodata_to_unity.py --export buildings # trees (CSV+GLB): uv run python geodata_to_unity.py --export trees ``` -4. Import the PNGs into Unity Terrains using `tile_index.csv` for placement and consistent height scaling (0–65535). +4. Import the PNGs into Unity Terrains using `tile_index.csv` for placement and height scaling (0–65535, per-tile by default). ### How the export works -- Heightmaps: the pipeline builds `work/dgm.vrt` from all `raw/dgm1/*.tif`, computes a global min/max once, and warps each tile footprint to `heightmap.out_res` with `srcNodata=-9999` and `dstNodata` set to the global min. PNGs are scaled to `[0, 65535]` with worldfiles and listed in `export_unity/tile_index.csv` with `tile_key = f"{floor((xmin + overlap_x) / tile_size_x)}_{floor((ymin + overlap_y) / tile_size_y)}"` (defaults: `tile_size_x=1000.0`, `tile_size_y=1000.0`, `overlap_x=0.5`, `overlap_y=0.5` in `[tile_key]`). +- Heightmaps: the pipeline builds `work/dgm.vrt` from all `raw/dgm1/*.tif`, computes a global min/max once (legacy fallback), and warps each tile footprint to `heightmap.out_res` with `srcNodata=-9999`. Per-tile min/max are computed from the warped tile and used to scale PNGs to `[0, 65535]` by default (`heightmap.use_tile_minmax=false` keeps global scaling). `export_unity/tile_index.csv` records `global_min/global_max`, `tile_min/tile_max`, and `tile_key = f"{floor((xmin + overlap_x) / tile_size_x)}_{floor((ymin + overlap_y) / tile_size_y)}"` (defaults: `tile_size_x=1000.0`, `tile_size_y=1000.0`, `overlap_x=0.5`, `overlap_y=0.5` in `[tile_key]`). - Orthophotos: `work/dop.vrt` is built from `raw/dop20/jp2/*.jp2`; the manifest drives the cropping bounds. JPEG tiles are written to `export_unity/ortho_jpg/` with matching `.jgw` worldfiles. If the manifest is missing, the orthophoto export aborts—run the heightmap export first or use `--export all`. - Archives: `--build-from-archive` expands every `*.zip` under `archive/*` into the matching `raw/*` directories and copies `archive/dop20/filelist.txt` next to `raw/dop20/` for the downloader. - Cleanup: temporary `_tmp.tif` and GDAL aux XML files under `work/` and `raw/dgm1/` are removed at the end of the heightmap export; avoid storing non-GDAL metadata in those folders. @@ -52,7 +52,7 @@ This repository converts DGM1 elevation tiles into Unity-ready 16-bit PNG height - Rebuild VRTs after moving data: add `--force-vrt`. ### Workflow Notes -- The pipeline computes a global min/max from the VRT to scale all tiles consistently; adjust `heightmap.out_res` or `heightmap.resample` in `geodata_config.toml` if your AOI or target resolution changes. +- Heightmaps normalize per tile using `tile_min/tile_max` by default; set `heightmap.use_tile_minmax=false` to restore global scaling across tiles. Adjust `heightmap.out_res` or `heightmap.resample` in `geodata_config.toml` if your AOI or target resolution changes. - `tile_key` config controls the tile grouping key in the manifest; defaults are `tile_size_x=1000.0`, `tile_size_y=1000.0`, `overlap_x=0.5`, `overlap_y=0.5` with `enabled=true`. - `_tmp.tif` files in `work/` are transient; you can delete `work/` to force a clean rebuild. - Keep file names stable to avoid churn in Unity scenes; re-exports overwrite in place. diff --git a/geodata_config.example.toml b/geodata_config.example.toml index 5edb347..2084265 100644 --- a/geodata_config.example.toml +++ b/geodata_config.example.toml @@ -25,6 +25,7 @@ manifest_path = "export_unity/tile_index.csv" out_res = 1025 resample = "bilinear" tile_size_m = 1000 +use_tile_minmax = true [tile_key] tile_size_x = 1000.0 diff --git a/geodata_pipeline/config.py b/geodata_pipeline/config.py index 8d7da46..ba189d3 100644 --- a/geodata_pipeline/config.py +++ b/geodata_pipeline/config.py @@ -46,6 +46,7 @@ class HeightmapConfig: out_res: int = 1025 resample: str = "bilinear" tile_size_m: int = 1000 + use_tile_minmax: bool = True @dataclass diff --git a/geodata_pipeline/heightmaps.py b/geodata_pipeline/heightmaps.py index 4dc4e86..452f61d 100644 --- a/geodata_pipeline/heightmaps.py +++ b/geodata_pipeline/heightmaps.py @@ -5,6 +5,7 @@ import math import os from typing import Iterable +import numpy as np from osgeo import gdal from .config import Config @@ -22,6 +23,29 @@ def _cleanup_patterns(raw_dir: str) -> Iterable[str]: ] +def _compute_tile_minmax(path: str) -> tuple[float | None, float | None, int]: + ds = open_dataset(path, f"Could not open {path} to compute tile min/max.") + band = ds.GetRasterBand(1) + nodata = band.GetNoDataValue() + data = band.ReadAsArray() + ds = None + + if data is None or data.size == 0: + return None, None, 0 + + valid = np.isfinite(data) + if nodata is not None and not math.isnan(nodata): + valid &= data != nodata + + valid_count = int(np.count_nonzero(valid)) + if valid_count == 0: + return None, None, 0 + + tile_min = float(data[valid].min()) + tile_max = float(data[valid].max()) + return tile_min, tile_max, valid_count + + def export_heightmaps(cfg: Config, *, force_vrt: bool = False) -> int: ensure_dir(cfg.work.work_dir) ensure_dir(cfg.export.heightmap_dir) @@ -40,7 +64,7 @@ def export_heightmaps(cfg: Config, *, force_vrt: bool = False) -> int: tile_key_seen: set[str] = set() with open(cfg.export.manifest_path, "w", encoding="utf-8") as f: - f.write("tile_id,xmin,ymin,xmax,ymax,global_min,global_max,out_res,tile_key\n") + f.write("tile_id,xmin,ymin,xmax,ymax,global_min,global_max,out_res,tile_key,tile_min,tile_max\n") skipped = 0 written = 0 @@ -91,9 +115,31 @@ def export_heightmaps(cfg: Config, *, force_vrt: bool = False) -> int: skipped += 1 continue + try: + tile_min, tile_max, valid_count = _compute_tile_minmax(tmp_path) + except SystemExit as exc: + print(exc) + skipped += 1 + continue + + tile_valid = valid_count > 0 + if not tile_valid: + print(f"Warning: {tile_id} has no valid samples; using global min/max.") + tile_min = gmin + tile_max = gmax + + scale_min = gmin + scale_max = gmax + if cfg.heightmap.use_tile_minmax and tile_valid: + scale_min = tile_min + scale_max = tile_max + if scale_max <= scale_min: + print(f"Warning: {tile_id} has flat elevation range; using epsilon for scaling.") + scale_max = scale_min + 1e-3 + trans_opts = gdal.TranslateOptions( outputType=gdal.GDT_UInt16, - scaleParams=[(gmin, gmax, 0, 65535)], + scaleParams=[(scale_min, scale_max, 0, 65535)], format="PNG", creationOptions=["WORLDFILE=YES"], ) @@ -107,7 +153,8 @@ def export_heightmaps(cfg: Config, *, force_vrt: bool = False) -> int: safe_remove(f"{tmp_path}.aux.xml") f.write( - f"{tile_id},{xmin},{ymin},{xmax},{ymax},{gmin},{gmax},{cfg.heightmap.out_res},{tile_key}\n" + f"{tile_id},{xmin},{ymin},{xmax},{ymax},{gmin},{gmax}," + f"{cfg.heightmap.out_res},{tile_key},{tile_min},{tile_max}\n" ) print(f"Wrote {out_path}") written += 1 diff --git a/scripts_unity/Editor/GeoTileImporter.cs b/scripts_unity/Editor/GeoTileImporter.cs index 482f7ac..3b1148a 100644 --- a/scripts_unity/Editor/GeoTileImporter.cs +++ b/scripts_unity/Editor/GeoTileImporter.cs @@ -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.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++; } diff --git a/scripts_unity/Editor/GeoTilePrefabImporter.cs b/scripts_unity/Editor/GeoTilePrefabImporter.cs index 3b2f933..830a80c 100644 --- a/scripts_unity/Editor/GeoTilePrefabImporter.cs +++ b/scripts_unity/Editor/GeoTilePrefabImporter.cs @@ -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) diff --git a/scripts_unity/GeoDataUtils/GeoTileMetadata.cs b/scripts_unity/GeoDataUtils/GeoTileMetadata.cs index e48e676..3b1bfee 100644 --- a/scripts_unity/GeoDataUtils/GeoTileMetadata.cs +++ b/scripts_unity/GeoDataUtils/GeoTileMetadata.cs @@ -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; /// /// Returns the world position this tile should be placed at, given a global origin. /// 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) ); }