Add SWE LOD exports and improve Unity importers

- add swe_lod config and exporter for height/porosity/building EXRs

- write river mask PNGs and add tree water/bridge masking

- update Unity importers with tile selection, ortho fallback, and building prefabs
This commit is contained in:
2026-02-03 23:02:37 +01:00
parent 196c8b9890
commit 1a0ab1e4b6
11 changed files with 1967 additions and 280 deletions

5
.gitignore vendored
View File

@@ -42,3 +42,8 @@ temp/
scripts_unity/**/*.meta
docs
GEMINI.md
AGENTS.md
conductor
export_swe
*.7z

View File

@@ -33,7 +33,6 @@ source_path = "raw/hydrorivers/hydrorivers_eu_shp/HydroRIVERS_v10_eu_shp/HydroRI
source_layer = "HydroRIVERS_v10_eu"
output_dir = "export_unity/height_png16_river"
manifest_vr = "export_unity/tile_index_river_vr.csv"
manifest_server = "export_unity/tile_index_river_server.csv"
[river_erosion.vr]
order_field = "ORD_STRA"
@@ -50,21 +49,6 @@ depth_min_m = 0.3
depth_max_m = 6.0
smooth_sigma_m = 3.0
[river_erosion.server]
order_field = "ORD_FLOW"
invert_order = true
invert_max_order = 10
min_order = 2
width_base_m = 5.0
width_per_order_m = 7.0
width_min_m = 3.0
width_max_m = 60.0
depth_base_m = 0.4
depth_per_order_m = 0.3
depth_min_m = 0.2
depth_max_m = 4.0
smooth_sigma_m = 1.5
[tile_key]
tile_size_x = 1000.0
tile_size_y = 1000.0
@@ -108,3 +92,55 @@ dom1_dir = "raw/geo_rlp/dom1"
chunk_size = 5000000
use_lpg_validation = true
use_lpo_refinement = true
[swe_lod]
enabled = true
out_dir = "export_swe"
tile_index_path = "export_swe/swe_tile_index.csv"
manifest_path = "export_swe/swe_manifest.json"
height_source = "river_erosion"
height_source_dir = ""
height_source_manifest = ""
origin_x = ""
origin_y = ""
height_resample = "bilinear"
height_nodata = ""
porosity_source = ""
porosity_resample = "average"
porosity_nodata = 0.0
solid_bias = 3.0
include_buildings = false
building_height_source = ""
building_resample = "average"
building_nodata = 0.0
prefer_float16 = true
lpo_dir = ""
lpo_base_res = 1025
lpo_density_threshold = 2
lpo_height_percentile = 95.0
lpo_min_height_m = 0.5
[[swe_lod.lods]]
name = "lod0"
tile_size_m = 1000.0
resolution = 128
[[swe_lod.lods]]
name = "lod1"
tile_size_m = 1000.0
resolution = 256
[[swe_lod.lods]]
name = "lod2"
tile_size_m = 500.0
resolution = 256
[[swe_lod.lods]]
name = "lod3"
tile_size_m = 250.0
resolution = 256
[[swe_lod.lods]]
name = "lod4"
tile_size_m = 125.0
resolution = 256

View File

@@ -29,9 +29,8 @@ tile_size_m = 1000
enabled = true
source_path = "raw/hydrorivers/hydrorivers_eu_shp/HydroRIVERS_v10_eu_shp/HydroRIVERS_v10_eu.shp"
source_layer = "HydroRIVERS_v10_eu"
output_dir = "export_unity/height_png16_river"
output_dir = "export_unity/height_png16_vr"
manifest_vr = "export_unity/tile_index_river_vr.csv"
manifest_server = "export_unity/tile_index_river_server.csv"
[river_erosion.lidar]
enabled = true
@@ -59,21 +58,6 @@ fallback_depth_m = 2.0
flow_distance_field = "DIST_DN_KM"
flow_slope_m_per_km = 0.2
[river_erosion.server]
order_field = "ORD_FLOW"
invert_order = true
invert_max_order = 10
min_order = 2
width_base_m = 5.0
width_per_order_m = 7.0
width_min_m = 3.0
width_max_m = 60.0
depth_base_m = 0.4
depth_per_order_m = 0.3
depth_min_m = 0.2
depth_max_m = 4.0
smooth_sigma_m = 1.5
[tile_key]
tile_size_x = 1000.0
tile_size_y = 1000.0
@@ -86,6 +70,7 @@ out_res = 2048
jpeg_quality = 90
apply_water_mask = true
water_blend = 0.85
bridge_blend = 0.85
water_fallback_rgb = [92.0, 72.0, 52.0]
water_mask_threshold = 0.1
river_dir = "export_unity/ortho_jpg_river"
@@ -123,3 +108,55 @@ dom1_dir = "raw/geo_rlp/dom1"
chunk_size = 5000000
use_lpg_validation = true
use_lpo_refinement = true
[swe_lod]
enabled = true
out_dir = "export_swe"
tile_index_path = "export_swe/swe_tile_index.csv"
manifest_path = "export_swe/swe_manifest.json"
height_source = "river_erosion"
height_source_dir = ""
height_source_manifest = ""
origin_x = ""
origin_y = ""
height_resample = "bilinear"
height_nodata = ""
porosity_source = "work/swe_lpo/porosity.vrt"
porosity_resample = "average"
porosity_nodata = 0.0
solid_bias = 3.0
include_buildings = true
building_height_source = "work/swe_lpo/buildings.vrt"
building_resample = "average"
building_nodata = 0.0
prefer_float16 = true
lpo_dir = ""
lpo_base_res = 1025
lpo_density_threshold = 2
lpo_height_percentile = 95.0
lpo_min_height_m = 0.5
[[swe_lod.lods]]
name = "lod0"
tile_size_m = 1000.0
resolution = 128
[[swe_lod.lods]]
name = "lod1"
tile_size_m = 1000.0
resolution = 256
[[swe_lod.lods]]
name = "lod2"
tile_size_m = 500.0
resolution = 256
[[swe_lod.lods]]
name = "lod3"
tile_size_m = 250.0
resolution = 256
[[swe_lod.lods]]
name = "lod4"
tile_size_m = 125.0
resolution = 256

View File

@@ -100,24 +100,6 @@ def _default_river_profile_vr() -> "RiverErosionProfileConfig":
)
def _default_river_profile_server() -> "RiverErosionProfileConfig":
return RiverErosionProfileConfig(
order_field="ORD_FLOW",
invert_order=True,
invert_max_order=10,
min_order=2,
width_base_m=5.0,
width_per_order_m=7.0,
width_min_m=3.0,
width_max_m=60.0,
depth_base_m=0.4,
depth_per_order_m=0.3,
depth_min_m=0.2,
depth_max_m=4.0,
smooth_sigma_m=1.5,
)
@dataclass
class RiverErosionConfig:
enabled: bool = True
@@ -125,10 +107,16 @@ class RiverErosionConfig:
source_layer: str = "HydroRIVERS_v10_eu"
output_dir: str = "export_unity/height_png16_river"
manifest_vr: str = "export_unity/tile_index_river_vr.csv"
manifest_server: str = "export_unity/tile_index_river_server.csv"
vr: RiverErosionProfileConfig = field(default_factory=_default_river_profile_vr)
server: RiverErosionProfileConfig = field(default_factory=_default_river_profile_server)
lidar: RiverErosionLidarConfig = field(default_factory=RiverErosionLidarConfig)
bridge_source: str = "dom1"
bridge_height_min_m: float = 2.0
bridge_height_max_m: float = 12.0
bridge_near_water_m: float = 20.0
bridge_min_area_m2: float = 20.0
bridge_chm_smooth_m: float = 0.0
bridge_function_codes: list[str] = field(default_factory=lambda: ["1610"])
bridge_buffer_m: float = 0.0
@dataclass
@@ -137,6 +125,7 @@ class OrthoConfig:
jpeg_quality: int = 90
apply_water_mask: bool = False
water_blend: float = 0.85
bridge_blend: float = 0.85
water_fallback_rgb: tuple[float, float, float] = (30.0, 50.0, 60.0)
water_mask_threshold: float = 0.1
river_dir: str = "export_unity/ortho_jpg_river"
@@ -241,6 +230,53 @@ class EnhancedTreeConfig:
canopy_sample_radius_factor: float = 0.5
@dataclass
class SweLodLevelConfig:
name: str
tile_size_m: float
resolution: int
def _default_swe_lods() -> list["SweLodLevelConfig"]:
return [
SweLodLevelConfig(name="lod0", tile_size_m=1000.0, resolution=128),
SweLodLevelConfig(name="lod1", tile_size_m=1000.0, resolution=256),
SweLodLevelConfig(name="lod2", tile_size_m=500.0, resolution=256),
SweLodLevelConfig(name="lod3", tile_size_m=250.0, resolution=256),
SweLodLevelConfig(name="lod4", tile_size_m=125.0, resolution=256),
]
@dataclass
class SweLodConfig:
enabled: bool = True
out_dir: str = "export_swe"
tile_index_path: str = "export_swe/swe_tile_index.csv"
manifest_path: str = "export_swe/swe_manifest.json"
height_source: str = "river_erosion"
height_source_dir: str = ""
height_source_manifest: str = ""
lpo_dir: str = ""
lpo_base_res: int = 1025
lpo_density_threshold: int = 2
lpo_height_percentile: float = 95.0
lpo_min_height_m: float = 0.5
origin_x: float | None = None
origin_y: float | None = None
height_resample: str = "bilinear"
height_nodata: float | None = None
porosity_source: str = ""
porosity_resample: str = "average"
porosity_nodata: float | None = 0.0
solid_bias: float = 3.0
include_buildings: bool = False
building_height_source: str = ""
building_resample: str = "average"
building_nodata: float | None = 0.0
prefer_float16: bool = True
lods: list[SweLodLevelConfig] = field(default_factory=_default_swe_lods)
@dataclass
class Config:
raw: RawConfig = field(default_factory=RawConfig)
@@ -259,6 +295,7 @@ class Config:
buildings_enhanced: EnhancedBuildingConfig = field(default_factory=EnhancedBuildingConfig)
street_furniture: StreetFurnitureConfig = field(default_factory=StreetFurnitureConfig)
trees_enhanced: EnhancedTreeConfig = field(default_factory=EnhancedTreeConfig)
swe_lod: SweLodConfig = field(default_factory=SweLodConfig)
@classmethod
def default(cls) -> "Config":
@@ -301,6 +338,7 @@ class Config:
EnhancedTreeConfig,
data.get("trees_enhanced", {}),
)),
swe_lod=_swe_lod_from_dict(data.get("swe_lod", {})),
)
def to_dict(self) -> Dict[str, Any]:
@@ -354,9 +392,41 @@ def _river_erosion_from_dict(data: Dict[str, Any]) -> RiverErosionConfig:
return RiverErosionConfig()
base = _filter_kwargs(RiverErosionConfig, data)
base.pop("vr", None)
base.pop("server", None)
base.pop("lidar", None)
vr_cfg = RiverErosionProfileConfig(**_filter_kwargs(RiverErosionProfileConfig, data.get("vr", {})))
server_cfg = RiverErosionProfileConfig(**_filter_kwargs(RiverErosionProfileConfig, data.get("server", {})))
lidar_cfg = RiverErosionLidarConfig(**_filter_kwargs(RiverErosionLidarConfig, data.get("lidar", {})))
return RiverErosionConfig(**base, vr=vr_cfg, server=server_cfg, lidar=lidar_cfg)
return RiverErosionConfig(**base, vr=vr_cfg, lidar=lidar_cfg)
def _swe_lod_from_dict(data: Dict[str, Any]) -> SweLodConfig:
if not isinstance(data, dict):
return SweLodConfig()
base = _filter_kwargs(SweLodConfig, data)
for key in ("origin_x", "origin_y", "height_nodata", "porosity_nodata", "building_nodata"):
value = base.get(key)
if isinstance(value, str) and value.strip() == "":
base[key] = None
raw_lods = data.get("lods")
lods: list[SweLodLevelConfig] = []
if isinstance(raw_lods, list):
for entry in raw_lods:
if not isinstance(entry, dict):
continue
name = entry.get("name", "")
tile_size_m = entry.get("tile_size_m")
resolution = entry.get("resolution")
if tile_size_m is None or resolution is None:
continue
lods.append(
SweLodLevelConfig(
name=str(name),
tile_size_m=float(tile_size_m),
resolution=int(resolution),
)
)
if lods:
base["lods"] = lods
return SweLodConfig(**base)

View File

@@ -499,6 +499,34 @@ def _write_manifest(
writer.writerow(out)
def _write_mask_png(
out_path: str,
mask: np.ndarray,
bounds: Tuple[float, float, float, float],
tile_srs: osr.SpatialReference,
*,
bands: int = 1,
) -> None:
ensure_parent(out_path)
height, width = mask.shape[-2], mask.shape[-1]
gt = _tile_geotransform(bounds, width, height)
driver = gdal.GetDriverByName("MEM")
ds = driver.Create("", width, height, bands, gdal.GDT_Byte)
ds.SetGeoTransform(gt)
ds.SetProjection(tile_srs.ExportToWkt())
if bands == 1:
ds.GetRasterBand(1).WriteArray(mask.astype(np.uint8))
else:
for idx in range(bands):
ds.GetRasterBand(idx + 1).WriteArray(mask[idx].astype(np.uint8))
options = gdal.TranslateOptions(format="PNG", creationOptions=["WORLDFILE=YES"])
try:
gdal.Translate(out_path, ds, options=options)
except RuntimeError as exc:
print(f"[river_erosion] Mask write failed for {out_path}: {exc}")
ds = None
def _resolve_hydrorivers_path(source_path: str) -> Optional[str]:
if source_path and os.path.exists(source_path):
return source_path
@@ -607,7 +635,7 @@ def erode_rivers(cfg: Config) -> int:
fill_radius = getattr(lidar_cfg, "fill_holes_radius", 3)
smooth_sigma = getattr(lidar_cfg, "bank_slope_sigma", depth_profile.smooth_sigma_m)
output_dir = os.path.join(re_cfg.output_dir, "vr")
output_dir = re_cfg.output_dir
manifest_out = re_cfg.manifest_vr
ensure_dir(output_dir)
@@ -809,6 +837,15 @@ def erode_rivers(cfg: Config) -> int:
mask_center = mask[y0_center : y0_center + out_res, x0_center : x0_center + out_res]
_apply_water_mask_to_ortho(tile_id, mask_center, cfg)
mask_center = mask[y0_center : y0_center + out_res, x0_center : x0_center + out_res]
threshold = float(cfg.ortho.water_mask_threshold)
water_bin = mask_center >= threshold
mask_dir = os.path.join(cfg.work.work_dir, "river_masks")
ensure_dir(mask_dir)
mask_path = os.path.join(mask_dir, f"{tile_id}_mask.png")
_write_mask_png(mask_path, (water_bin.astype(np.uint8) * 255), center_bounds, tile_srs)
tile_min = float(np.min(cropped))
tile_max = float(np.max(cropped))
if tile_max <= tile_min:

View File

@@ -27,6 +27,7 @@ def _paths_from_config(cfg: Config) -> Iterable[str]:
cfg.work.work_dir,
cfg.export.heightmap_dir,
cfg.export.ortho_dir,
cfg.swe_lod.out_dir,
]

View File

@@ -0,0 +1,689 @@
from __future__ import annotations
import json
import math
import os
from dataclasses import asdict
from typing import Iterable
import numpy as np
from osgeo import gdal
from .config import Config, SweLodConfig, SweLodLevelConfig
from .gdal_utils import build_vrt, cleanup_aux_files, ensure_dir, ensure_parent, open_dataset, safe_remove
from .pointcloud import find_pointcloud_file, read_pointcloud_file
gdal.UseExceptions()
def export_swe_lods(cfg: Config, *, force_vrt: bool = False) -> int:
swe_cfg = cfg.swe_lod
if not swe_cfg.enabled:
print("[swe_lods] SWE LOD export disabled in config.")
return 0
ensure_dir(cfg.work.work_dir)
ensure_dir(swe_cfg.out_dir)
ensure_parent(swe_cfg.tile_index_path)
ensure_parent(swe_cfg.manifest_path)
height_ds = _prepare_height_source(cfg, swe_cfg, force_vrt=force_vrt)
xmin, ymin, xmax, ymax = _dataset_bounds(height_ds)
base_tile_size = max(level.tile_size_m for level in swe_cfg.lods)
origin_x = swe_cfg.origin_x
origin_y = swe_cfg.origin_y
if origin_x is None:
origin_x = math.floor(xmin / base_tile_size) * base_tile_size
if origin_y is None:
origin_y = math.floor(ymin / base_tile_size) * base_tile_size
porosity_ds = _open_optional_raster(swe_cfg.porosity_source, "porosity")
building_ds = _open_optional_raster(swe_cfg.building_height_source, "building height")
tile_rows = []
skipped = 0
written = 0
for level_index, level in enumerate(swe_cfg.lods):
lod_name = level.name or f"lod{level_index}"
lod_dir = os.path.join(swe_cfg.out_dir, lod_name)
height_dir = os.path.join(lod_dir, "height")
porosity_dir = os.path.join(lod_dir, "porosity")
building_dir = os.path.join(lod_dir, "buildings")
ensure_dir(height_dir)
ensure_dir(porosity_dir)
if swe_cfg.include_buildings:
ensure_dir(building_dir)
for tile_id, bounds in _iter_tiles(origin_x, origin_y, xmin, ymin, xmax, ymax, level.tile_size_m):
try:
height = _warp_array(
height_ds,
bounds,
level.resolution,
level.resolution,
swe_cfg.height_resample,
swe_cfg.height_nodata,
)
except RuntimeError as exc:
print(f"[swe_lods] Warp failed for {lod_name} {tile_id}: {exc}")
skipped += 1
continue
height_path = os.path.join(height_dir, f"height_{tile_id}.exr")
_write_exr(height_path, height, swe_cfg.prefer_float16)
porosity_path = os.path.join(porosity_dir, f"porosity_{tile_id}.exr")
porosity = _build_porosity(
porosity_ds,
bounds,
level.resolution,
swe_cfg,
)
_write_exr(porosity_path, porosity, swe_cfg.prefer_float16)
building_path = ""
if swe_cfg.include_buildings:
building_path = os.path.join(building_dir, f"buildings_{tile_id}.exr")
if building_ds is None:
building = np.zeros((level.resolution, level.resolution), dtype=np.float32)
else:
building = _warp_array(
building_ds,
bounds,
level.resolution,
level.resolution,
swe_cfg.building_resample,
swe_cfg.building_nodata,
)
_write_exr(building_path, building, swe_cfg.prefer_float16)
tile_rows.append(
{
"lod": lod_name,
"tile_x": tile_id.split("_")[0],
"tile_y": tile_id.split("_")[1],
"xmin": bounds[0],
"ymin": bounds[1],
"xmax": bounds[2],
"ymax": bounds[3],
"tile_size_m": level.tile_size_m,
"resolution": level.resolution,
"height_path": height_path,
"porosity_path": porosity_path,
"building_path": building_path,
}
)
written += 1
_write_tile_index(swe_cfg.tile_index_path, tile_rows)
_write_manifest(
swe_cfg.manifest_path,
swe_cfg,
origin_x,
origin_y,
xmin,
ymin,
xmax,
ymax,
)
removed = cleanup_aux_files(_cleanup_patterns(cfg.raw.dgm1_dir))
print(f"[swe_lods] Summary: wrote {written} tiles; skipped {skipped}.")
print(f"[swe_lods] Cleanup removed {removed} temporary files/sidecars.")
return 1 if skipped else 0
def export_swe_porosity(cfg: Config, *, force_vrt: bool = False) -> int:
swe_cfg = cfg.swe_lod
if not swe_cfg.enabled:
print("[swe_porosity] SWE LOD export disabled in config.")
return 0
ensure_dir(cfg.work.work_dir)
ensure_dir(swe_cfg.out_dir)
ensure_parent(swe_cfg.tile_index_path)
ensure_parent(swe_cfg.manifest_path)
porosity_source = swe_cfg.porosity_source
building_source = swe_cfg.building_height_source
if _uses_lpo(porosity_source) or _uses_lpo(building_source):
porosity_source, building_source = _ensure_lpo_sources(cfg, swe_cfg, force_vrt=force_vrt)
porosity_ds = _open_optional_raster(porosity_source, "porosity")
building_ds = _open_optional_raster(building_source, "building height")
if porosity_ds is None and building_ds is None:
raise SystemExit("[swe_porosity] No porosity or building height source configured.")
bounds_ds = porosity_ds or building_ds
xmin, ymin, xmax, ymax = _dataset_bounds(bounds_ds)
base_tile_size = max(level.tile_size_m for level in swe_cfg.lods)
origin_x = swe_cfg.origin_x
origin_y = swe_cfg.origin_y
if origin_x is None:
origin_x = math.floor(xmin / base_tile_size) * base_tile_size
if origin_y is None:
origin_y = math.floor(ymin / base_tile_size) * base_tile_size
tile_rows = []
skipped = 0
written = 0
for level_index, level in enumerate(swe_cfg.lods):
lod_name = level.name or f"lod{level_index}"
lod_dir = os.path.join(swe_cfg.out_dir, lod_name)
height_dir = os.path.join(lod_dir, "height")
porosity_dir = os.path.join(lod_dir, "porosity")
building_dir = os.path.join(lod_dir, "buildings")
ensure_dir(height_dir)
ensure_dir(porosity_dir)
if swe_cfg.include_buildings:
ensure_dir(building_dir)
for tile_id, bounds in _iter_tiles(origin_x, origin_y, xmin, ymin, xmax, ymax, level.tile_size_m):
porosity_path = os.path.join(porosity_dir, f"porosity_{tile_id}.exr")
porosity = _build_porosity(
porosity_ds,
bounds,
level.resolution,
swe_cfg,
)
_write_exr(porosity_path, porosity, swe_cfg.prefer_float16)
building_path = ""
if swe_cfg.include_buildings:
building_path = os.path.join(building_dir, f"buildings_{tile_id}.exr")
if building_ds is None:
building = np.zeros((level.resolution, level.resolution), dtype=np.float32)
else:
building = _warp_array(
building_ds,
bounds,
level.resolution,
level.resolution,
swe_cfg.building_resample,
swe_cfg.building_nodata,
)
_write_exr(building_path, building, swe_cfg.prefer_float16)
tile_rows.append(
{
"lod": lod_name,
"tile_x": tile_id.split("_")[0],
"tile_y": tile_id.split("_")[1],
"xmin": bounds[0],
"ymin": bounds[1],
"xmax": bounds[2],
"ymax": bounds[3],
"tile_size_m": level.tile_size_m,
"resolution": level.resolution,
"height_path": "",
"porosity_path": porosity_path,
"building_path": building_path,
}
)
written += 1
_write_tile_index(swe_cfg.tile_index_path, tile_rows)
_write_manifest(
swe_cfg.manifest_path,
swe_cfg,
origin_x,
origin_y,
xmin,
ymin,
xmax,
ymax,
)
removed = cleanup_aux_files(_cleanup_patterns(cfg.raw.dgm1_dir))
print(f"[swe_porosity] Summary: wrote {written} tiles; skipped {skipped}.")
print(f"[swe_porosity] Cleanup removed {removed} temporary files/sidecars.")
return 1 if skipped else 0
def _uses_lpo(value: str | None) -> bool:
if value is None:
return False
return str(value).strip().lower() == "lpo"
def _ensure_lpo_sources(cfg: Config, swe_cfg: SweLodConfig, *, force_vrt: bool):
lpo_dir = swe_cfg.lpo_dir or cfg.pointcloud.lpo_dir
if not lpo_dir:
raise SystemExit("[swe_porosity] LPO directory not configured.")
manifest = _resolve_height_manifest(cfg, swe_cfg)
if not os.path.exists(manifest):
raise SystemExit(f"[swe_porosity] Height manifest missing: {manifest}")
work_dir = os.path.join(cfg.work.work_dir, "swe_lpo")
porosity_tiles = os.path.join(work_dir, "porosity_tiles")
building_tiles = os.path.join(work_dir, "building_tiles")
ensure_dir(porosity_tiles)
ensure_dir(building_tiles)
porosity_paths = []
building_paths = []
tiles = _read_manifest_tiles(manifest)
if not tiles:
raise SystemExit(f"[swe_porosity] No tiles found in {manifest}")
for tile_id, bounds in tiles:
lpo_path = find_pointcloud_file(lpo_dir, tile_id)
if not lpo_path:
continue
porosity_path = os.path.join(porosity_tiles, f"{tile_id}.tif")
building_path = os.path.join(building_tiles, f"{tile_id}.tif")
if not force_vrt and os.path.exists(porosity_path) and os.path.exists(building_path):
porosity_paths.append(porosity_path)
building_paths.append(building_path)
continue
points = read_pointcloud_file(
lpo_path,
bounds=bounds,
chunk_size=cfg.pointcloud.chunk_size,
)
porosity, building = _rasterize_lpo(
points.x,
points.y,
points.z,
bounds,
swe_cfg.lpo_base_res,
swe_cfg.lpo_density_threshold,
swe_cfg.lpo_height_percentile,
swe_cfg.lpo_min_height_m,
)
gt = _geotransform_from_bounds(bounds, swe_cfg.lpo_base_res)
_write_geotiff(porosity_path, porosity, gt, "")
_write_geotiff(building_path, building, gt, "")
porosity_paths.append(porosity_path)
building_paths.append(building_path)
if not porosity_paths and not building_paths:
raise SystemExit("[swe_porosity] No LPO tiles were processed.")
porosity_vrt = os.path.join(work_dir, "porosity.vrt")
building_vrt = os.path.join(work_dir, "buildings.vrt")
build_vrt(porosity_vrt, porosity_paths, force=True)
build_vrt(building_vrt, building_paths, force=True)
return porosity_vrt, building_vrt
def _prepare_height_source(cfg: Config, swe_cfg: SweLodConfig, *, force_vrt: bool) :
source = (swe_cfg.height_source or "dgm1").lower()
if source == "river_erosion":
return _prepare_eroded_height_source(cfg, swe_cfg, force_vrt=force_vrt)
if source == "dgm1":
tif_paths = sorted(_collect_height_sources(cfg.raw.dgm1_dir))
if not tif_paths:
raise SystemExit(f"[swe_lods] No heightmap sources found in {cfg.raw.dgm1_dir}.")
build_vrt(cfg.work.heightmap_vrt, tif_paths, force=force_vrt)
return open_dataset(cfg.work.heightmap_vrt, f"[swe_lods] Could not open {cfg.work.heightmap_vrt}")
raise SystemExit(f"[swe_lods] Unknown height_source '{swe_cfg.height_source}'.")
def _prepare_eroded_height_source(cfg: Config, swe_cfg: SweLodConfig, *, force_vrt: bool):
from csv import DictReader
source_dir = swe_cfg.height_source_dir or cfg.river_erosion.output_dir
manifest = swe_cfg.height_source_manifest or cfg.river_erosion.manifest_vr
if not os.path.exists(manifest):
raise SystemExit(f"[swe_lods] River erosion manifest missing: {manifest}")
work_dir = os.path.join(cfg.work.work_dir, "swe_eroded")
ensure_dir(work_dir)
tif_paths = []
with open(manifest, newline="", encoding="utf-8") as handle:
reader = DictReader(handle)
for row in reader:
tile_id = (row.get("tile_id") or "").strip()
if not tile_id:
continue
png_path = os.path.join(source_dir, f"{tile_id}.png")
if not os.path.exists(png_path):
print(f"[swe_lods] Missing eroded PNG {png_path}")
continue
tile_min = _parse_float(row.get("tile_min"))
tile_max = _parse_float(row.get("tile_max"))
global_min = _parse_float(row.get("global_min"))
global_max = _parse_float(row.get("global_max"))
if tile_min is None or tile_max is None:
tile_min = global_min
tile_max = global_max
if tile_min is None or tile_max is None:
print(f"[swe_lods] Missing min/max for {tile_id}; skipping.")
continue
if tile_max <= tile_min:
tile_max = tile_min + 1e-3
tif_path = os.path.join(work_dir, f"{tile_id}.tif")
if force_vrt and os.path.exists(tif_path):
safe_remove(tif_path)
if not os.path.exists(tif_path):
ds_png = open_dataset(png_path, f"[swe_lods] Could not open {png_path}")
band = ds_png.GetRasterBand(1)
raw = band.ReadAsArray().astype(np.float32)
gt = ds_png.GetGeoTransform()
proj = ds_png.GetProjection()
ds_png = None
height = tile_min + (raw / 65535.0) * (tile_max - tile_min)
_write_geotiff(tif_path, height, gt, proj)
tif_paths.append(tif_path)
if not tif_paths:
raise SystemExit("[swe_lods] No eroded height tiles available to build VRT.")
vrt_path = os.path.join(work_dir, "swe_eroded.vrt")
build_vrt(vrt_path, tif_paths, force=True)
return open_dataset(vrt_path, f"[swe_lods] Could not open {vrt_path}")
def _collect_height_sources(raw_dir: str) -> Iterable[str]:
return sorted(
[
os.path.join(raw_dir, name)
for name in os.listdir(raw_dir)
if name.lower().endswith(".tif")
]
)
def _dataset_bounds(ds) -> tuple[float, float, float, float]:
gt = ds.GetGeoTransform()
ulx, xres, _, uly, _, yres = gt
xmax = ulx + xres * ds.RasterXSize
ymin = uly + yres * ds.RasterYSize
xmin = ulx
ymax = uly
return min(xmin, xmax), min(ymin, ymax), max(xmin, xmax), max(ymin, ymax)
def _iter_tiles(
origin_x: float,
origin_y: float,
xmin: float,
ymin: float,
xmax: float,
ymax: float,
tile_size: float,
):
start_x = int(math.floor((xmin - origin_x) / tile_size))
end_x = int(math.ceil((xmax - origin_x) / tile_size))
start_y = int(math.floor((ymin - origin_y) / tile_size))
end_y = int(math.ceil((ymax - origin_y) / tile_size))
for ty in range(start_y, end_y):
for tx in range(start_x, end_x):
tile_min_x = origin_x + tx * tile_size
tile_min_y = origin_y + ty * tile_size
tile_max_x = tile_min_x + tile_size
tile_max_y = tile_min_y + tile_size
if tile_max_x <= xmin or tile_min_x >= xmax or tile_max_y <= ymin or tile_min_y >= ymax:
continue
tile_id = f"{tx}_{ty}"
yield tile_id, (tile_min_x, tile_min_y, tile_max_x, tile_max_y)
def _warp_array(
ds,
bounds: tuple[float, float, float, float],
width: int,
height: int,
resample: str,
dst_nodata: float | None,
) -> np.ndarray:
warp_opts = gdal.WarpOptions(
format="MEM",
outputBounds=bounds,
width=width,
height=height,
resampleAlg=resample,
dstNodata=dst_nodata,
)
warped = gdal.Warp("", ds, options=warp_opts)
if warped is None:
raise RuntimeError("GDAL Warp returned None.")
band = warped.GetRasterBand(1)
data = band.ReadAsArray()
return data.astype(np.float32, copy=False)
def _build_porosity(
porosity_ds,
bounds: tuple[float, float, float, float],
resolution: int,
cfg: SweLodConfig,
) -> np.ndarray:
if porosity_ds is None:
return np.ones((resolution, resolution), dtype=np.float32)
porosity = _warp_array(
porosity_ds,
bounds,
resolution,
resolution,
cfg.porosity_resample,
cfg.porosity_nodata,
)
porosity = np.clip(porosity, 0.0, 1.0)
if cfg.solid_bias <= 0.0:
return porosity
denom = porosity + (1.0 - porosity) * (1.0 + cfg.solid_bias)
with np.errstate(divide="ignore", invalid="ignore"):
biased = np.divide(porosity, denom, out=np.zeros_like(porosity), where=denom != 0)
return np.clip(biased, 0.0, 1.0)
def _write_exr(path: str, data: np.ndarray, prefer_float16: bool) -> None:
driver = gdal.GetDriverByName("EXR")
if driver is None:
raise SystemExit("[swe_lods] GDAL EXR driver not available.")
out_type = gdal.GDT_Float32
if prefer_float16 and hasattr(gdal, "GDT_Float16"):
out_type = gdal.GDT_Float16
height, width = data.shape
ds = driver.Create(path, width, height, 1, out_type)
if ds is None:
raise RuntimeError(f"Could not create EXR output at {path}")
band = ds.GetRasterBand(1)
band.WriteArray(data.astype(np.float32, copy=False))
band.FlushCache()
ds.FlushCache()
ds = None
def _write_geotiff(path: str, data: np.ndarray, geo_transform, projection: str) -> None:
driver = gdal.GetDriverByName("GTiff")
if driver is None:
raise SystemExit("[swe_lods] GDAL GTiff driver not available.")
height, width = data.shape
ds = driver.Create(path, width, height, 1, gdal.GDT_Float32)
if ds is None:
raise RuntimeError(f"Could not create GeoTIFF at {path}")
ds.SetGeoTransform(geo_transform)
if projection:
ds.SetProjection(projection)
band = ds.GetRasterBand(1)
band.WriteArray(data.astype(np.float32, copy=False))
band.FlushCache()
ds.FlushCache()
ds = None
def _rasterize_lpo(
x: np.ndarray,
y: np.ndarray,
z: np.ndarray,
bounds: tuple[float, float, float, float],
resolution: int,
density_threshold: int,
height_percentile: float,
min_height: float,
) -> tuple[np.ndarray, np.ndarray]:
xmin, ymin, xmax, ymax = bounds
resolution = max(2, int(resolution))
step = (xmax - xmin) / (resolution - 1)
if step <= 0:
return np.ones((resolution, resolution), dtype=np.float32), np.zeros((resolution, resolution), dtype=np.float32)
mask = z >= min_height
if mask.any():
x = x[mask]
y = y[mask]
z = z[mask]
if x.size == 0:
porosity = np.ones((resolution, resolution), dtype=np.float32)
building = np.zeros((resolution, resolution), dtype=np.float32)
return porosity, building
ix = np.floor((x - xmin) / step).astype(np.int32)
iy = np.floor((y - ymin) / step).astype(np.int32)
ix = np.clip(ix, 0, resolution - 1)
iy = np.clip(iy, 0, resolution - 1)
counts = np.zeros((resolution, resolution), dtype=np.int32)
np.add.at(counts, (iy, ix), 1)
porosity = np.where(counts >= max(1, density_threshold), 0.0, 1.0).astype(np.float32)
building = np.zeros((resolution * resolution,), dtype=np.float32)
cell_index = iy * resolution + ix
order = np.argsort(cell_index)
cell_sorted = cell_index[order]
z_sorted = z[order]
unique_cells, start_idx = np.unique(cell_sorted, return_index=True)
for idx, cell in enumerate(unique_cells):
start = start_idx[idx]
end = start_idx[idx + 1] if idx + 1 < len(start_idx) else len(cell_sorted)
vals = z_sorted[start:end]
if vals.size == 0:
continue
if height_percentile >= 100.0:
height = float(np.max(vals))
else:
height = float(np.percentile(vals, height_percentile))
building[cell] = height
building = building.reshape((resolution, resolution))
return porosity, building
def _geotransform_from_bounds(bounds: tuple[float, float, float, float], resolution: int):
xmin, ymin, xmax, ymax = bounds
step = (xmax - xmin) / (resolution - 1)
return (xmin, step, 0.0, ymax, 0.0, -step)
def _resolve_height_manifest(cfg: Config, swe_cfg: SweLodConfig) -> str:
if swe_cfg.height_source_manifest:
return swe_cfg.height_source_manifest
if swe_cfg.height_source.lower() == "river_erosion":
return cfg.river_erosion.manifest_vr
return cfg.export.manifest_path
def _read_manifest_tiles(path: str) -> list[tuple[str, tuple[float, float, float, float]]]:
import csv
tiles = []
with open(path, newline="", encoding="utf-8") as handle:
reader = csv.DictReader(handle)
for row in reader:
tile_id = (row.get("tile_id") or "").strip()
if not tile_id:
continue
xmin = _parse_float(row.get("xmin"))
ymin = _parse_float(row.get("ymin"))
xmax = _parse_float(row.get("xmax"))
ymax = _parse_float(row.get("ymax"))
if xmin is None or ymin is None or xmax is None or ymax is None:
continue
tiles.append((tile_id, (xmin, ymin, xmax, ymax)))
return tiles
def _parse_float(value: str | None) -> float | None:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _write_tile_index(path: str, rows: list[dict]) -> None:
with open(path, "w", encoding="utf-8") as fh:
fh.write("lod,tile_x,tile_y,xmin,ymin,xmax,ymax,tile_size_m,resolution,height_path,porosity_path,building_path\n")
for row in rows:
fh.write(
f"{row['lod']},{row['tile_x']},{row['tile_y']},"
f"{row['xmin']},{row['ymin']},{row['xmax']},{row['ymax']},"
f"{row['tile_size_m']},{row['resolution']},"
f"{row['height_path']},{row['porosity_path']},{row['building_path']}\n"
)
def _write_manifest(
path: str,
cfg: SweLodConfig,
origin_x: float,
origin_y: float,
xmin: float,
ymin: float,
xmax: float,
ymax: float,
) -> None:
payload = {
"origin_x": origin_x,
"origin_y": origin_y,
"bounds": [xmin, ymin, xmax, ymax],
"solid_bias": cfg.solid_bias,
"lods": [asdict(level) for level in cfg.lods],
}
with open(path, "w", encoding="utf-8") as fh:
json.dump(payload, fh, indent=2)
def _open_optional_raster(path: str | None, label: str):
if not path:
return None
if not os.path.exists(path):
print(f"[swe_lods] {label} raster not found: {path}.")
return None
try:
return open_dataset(path, f"[swe_lods] Could not open {label} raster {path}")
except SystemExit as exc:
print(exc)
return None
def _cleanup_patterns(raw_dir: str) -> Iterable[str]:
return [
os.path.join("work", "*_tmp.tif"),
os.path.join("work", "*_tmp.tif.aux.xml"),
os.path.join("work", "*.aux.xml"),
os.path.join(raw_dir, "*.aux.xml"),
]

View File

@@ -11,8 +11,10 @@ from hashlib import blake2b
from typing import Iterable, List, Tuple
import numpy as np
import pdal
from numpy.lib.stride_tricks import sliding_window_view
from osgeo import gdal, ogr
from scipy import ndimage
from .citygml_utils import find_citygml_lod2
from .config import Config
@@ -86,6 +88,118 @@ def _building_mask(
return band.ReadAsArray()
def _function_matches_bridge(value: object, codes: list[str]) -> bool:
if not value or not codes:
return False
text = str(value).strip()
if not text:
return False
suffix = text.split("_")[-1]
return text in codes or suffix in codes
def _filter_small_components(mask: np.ndarray, min_area_m2: float, pixel_size: float) -> np.ndarray:
if min_area_m2 <= 0.0 or not np.any(mask):
return mask
min_pixels = int(math.ceil(min_area_m2 / max(pixel_size * pixel_size, 1e-6)))
if min_pixels <= 1:
return mask
labels, num = ndimage.label(mask)
if num == 0:
return mask
counts = np.bincount(labels.ravel())
remove = counts < min_pixels
if remove.size:
remove[0] = False
return np.where(remove[labels], 0, mask)
def _bridge_mask_from_chm(
chm: np.ndarray,
water_mask: np.ndarray | None,
cfg: Config,
pixel_size: float,
) -> np.ndarray | None:
source = str(getattr(cfg.river_erosion, "bridge_source", "dom1") or "dom1").strip().lower()
if source in {"none", "off", "false", "0"}:
return None
if source == "citygml":
return None
min_h = float(getattr(cfg.river_erosion, "bridge_height_min_m", 2.0))
max_h = float(getattr(cfg.river_erosion, "bridge_height_max_m", 12.0))
bridge = np.isfinite(chm) & (chm >= min_h) & (chm <= max_h)
near_water_m = float(getattr(cfg.river_erosion, "bridge_near_water_m", 0.0))
if water_mask is not None and near_water_m > 0.0:
water_bin = water_mask > 0
if not np.any(water_bin):
return None
dist = ndimage.distance_transform_edt(~water_bin, sampling=[pixel_size, pixel_size])
bridge &= dist <= near_water_m
min_area_m2 = float(getattr(cfg.river_erosion, "bridge_min_area_m2", 0.0))
bridge = _filter_small_components(bridge.astype(np.uint8), min_area_m2, pixel_size)
return bridge.astype(np.uint8) if np.any(bridge) else None
def _water_mask(tile_id: str, bounds: Tuple[float, float, float, float], like_ds: gdal.Dataset, cfg: Config) -> np.ndarray | None:
lidar_cfg = getattr(cfg.river_erosion, "lidar", None)
source_dir = getattr(lidar_cfg, "source_dir", "raw/bdom20rgbi")
water_class = getattr(lidar_cfg, "classification_water", 9)
parts = tile_id.split("_")
if len(parts) < 6:
return None
x_idx = parts[2]
y_idx = parts[3]
laz_name = f"bdom20rgbi_32_{x_idx}_{y_idx}_2_rp.laz"
laz_path = os.path.join(source_dir, laz_name)
if not os.path.exists(laz_path):
return None
pipeline_json = [
{"type": "readers.las", "filename": laz_path},
{"type": "filters.range", "limits": f"Classification[{water_class}:{water_class}]"},
]
try:
pipeline = pdal.Pipeline(json.dumps(pipeline_json))
count = pipeline.execute()
except RuntimeError:
return None
if count == 0:
return None
arrays = pipeline.arrays
if not arrays:
return None
xs = arrays[0]["X"]
ys = arrays[0]["Y"]
gt = like_ds.GetGeoTransform()
xmin = gt[0]
ymax = gt[3]
xres = gt[1]
yres = abs(gt[5])
width = like_ds.RasterXSize
height = like_ds.RasterYSize
col = ((xs - xmin) / xres).astype(int)
row = ((ymax - ys) / yres).astype(int)
valid = (col >= 0) & (col < width) & (row >= 0) & (row < height)
if not np.any(valid):
return None
mask = np.zeros((height, width), dtype=np.uint8)
mask[row[valid], col[valid]] = 1
dilate_px = max(1, int(round(1.5 / max(cfg.trees.grid_res_m, 0.1))))
mask = _dilate_mask(mask, dilate_px)
return mask
def _dilate_mask(mask: np.ndarray, radius_px: int) -> np.ndarray:
if radius_px <= 0:
return mask
@@ -671,6 +785,13 @@ def export_trees(cfg: Config, *, force_vrt: bool = False) -> int:
bmask = _dilate_mask(bmask, px_buffer)
chm = np.where(bmask == 1, np.nan, chm)
wmask = _water_mask(tile_id, bounds, dtm_ds, cfg)
brmask = _bridge_mask_from_chm(chm, wmask, cfg, cfg.trees.grid_res_m)
if wmask is not None:
chm = np.where(wmask == 1, np.nan, chm)
if brmask is not None:
chm = np.where(brmask == 1, np.nan, chm)
spacing_px = max(2, int(math.ceil((cfg.trees.grid_res_m * 2.5) / cfg.trees.grid_res_m)))
maxima = _local_maxima(chm, cfg.trees.min_height_m, spacing_px)
if not maxima:

View File

@@ -24,6 +24,7 @@ from geodata_pipeline.orthophotos import export_orthophotos
from geodata_pipeline.river_erosion import erode_rivers
from geodata_pipeline.setup_helpers import ensure_directories, materialize_archives
from geodata_pipeline.street_furniture import export_street_furniture
from geodata_pipeline.swe_lods import export_swe_lods, export_swe_porosity
from geodata_pipeline.trees import export_trees
from geodata_pipeline.trees_enhanced import export_trees_enhanced
@@ -40,7 +41,7 @@ def parse_args(argv: Iterable[str] | None = None) -> argparse.Namespace:
choices=[
"heightmap", "textures", "buildings", "trees", "all",
"heightmap-enhanced", "buildings-enhanced", "trees-enhanced",
"street-furniture", "all-enhanced"
"street-furniture", "all-enhanced", "swe-lods", "swe-porosity"
],
default=None,
help="Which assets to export. Enhanced options use point cloud data.",
@@ -229,12 +230,21 @@ def main(argv: Iterable[str] | None = None) -> int:
# Standard exports
if target_export in ("heightmap", "all"):
exit_codes.append(export_heightmaps(cfg, force_vrt=args.force_vrt))
if target_export == "all" and cfg.river_erosion.enabled:
exit_codes.append(erode_rivers(cfg))
if target_export in ("textures", "all"):
exit_codes.append(export_orthophotos(cfg, force_vrt=args.force_vrt))
if target_export in ("buildings", "all"):
exit_codes.append(export_buildings(cfg))
if target_export in ("trees", "all"):
exit_codes.append(export_trees(cfg, force_vrt=args.force_vrt))
if target_export in ("swe-lods",):
exit_codes.append(export_swe_lods(cfg, force_vrt=args.force_vrt))
if target_export in ("swe-porosity",):
exit_codes.append(export_swe_porosity(cfg, force_vrt=args.force_vrt))
if target_export == "all":
exit_codes.append(export_swe_lods(cfg, force_vrt=args.force_vrt))
exit_codes.append(export_swe_porosity(cfg, force_vrt=args.force_vrt))
# Enhanced exports (use point cloud data)
# Order matters: heightmap-enhanced creates tile_index.csv needed by others

View File

@@ -16,6 +16,7 @@ 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 orthoDirFallback = "Assets/GeoData/ortho_jpg_river";
private string buildingsDir = "Assets/GeoData/buildings_tiles";
private string buildingsEnhancedDir = "Assets/GeoData/buildings_enhanced";
private string treesDir = "Assets/GeoData/trees_tiles";
@@ -34,6 +35,7 @@ public class GeoTileImporter : EditorWindow
private bool applyOrthoTextures = true;
private bool importBuildings = true;
private bool useEnhancedBuildings = false;
private bool importBuildingsEvenTilesOnly = true;
private bool importTrees = true;
private bool importFurniture = false;
private bool deleteExistingBuildings = false;
@@ -43,6 +45,24 @@ public class GeoTileImporter : EditorWindow
private bool deleteExistingEnhancedTrees = false;
private string enhancedTreesParentName = "Geo_Trees_Enhanced";
private Vector2 scrollPosition;
private float tileKeySizeX = 1000f;
private float tileKeySizeY = 1000f;
private float tileKeyOverlapX = 0.5f;
private float tileKeyOverlapY = 0.5f;
private string bottomLeftKey = "";
private string topRightKey = "";
private readonly List<TileRecord> tileIndexCache = new List<TileRecord>();
private readonly List<TileKeyOption> tileKeyOptions = new List<TileKeyOption>();
private readonly Dictionary<string, TileKeyOption> tileKeyLookup = new Dictionary<string, TileKeyOption>(StringComparer.OrdinalIgnoreCase);
private string[] tileKeyStrings = Array.Empty<string>();
private string cachedTileCsvPath = "";
private DateTime cachedTileCsvTimeUtc = DateTime.MinValue;
private float cachedTileKeySizeX = 0f;
private float cachedTileKeySizeY = 0f;
private float cachedTileOverlapX = 0f;
private float cachedTileOverlapY = 0f;
// Prefabs for trees and furniture (assign in editor)
private GameObject treePrefab;
private GameObject lampPrefab;
@@ -60,10 +80,13 @@ public class GeoTileImporter : EditorWindow
private void OnGUI()
{
scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition);
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);
orthoDir = EditorGUILayout.TextField("ortho_jpg dir (primary)", orthoDir);
orthoDirFallback = EditorGUILayout.TextField("ortho_jpg dir (fallback)", orthoDirFallback);
buildingsDir = EditorGUILayout.TextField("buildings_glb dir", buildingsDir);
treesDir = EditorGUILayout.TextField("trees_glb dir", treesDir);
treeProxyPath = EditorGUILayout.TextField("tree_proxies.glb", treeProxyPath);
@@ -83,6 +106,7 @@ public class GeoTileImporter : EditorWindow
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);
importBuildingsEvenTilesOnly = EditorGUILayout.ToggleLeft("Import 2km buildings once (even X/Y tiles only)", importBuildingsEvenTilesOnly);
GUILayout.Space(5);
treesParentName = EditorGUILayout.TextField("Trees parent name", treesParentName);
@@ -110,15 +134,30 @@ public class GeoTileImporter : EditorWindow
bollardPrefab = (GameObject)EditorGUILayout.ObjectField("Bollard Prefab", bollardPrefab, typeof(GameObject), false);
defaultFurniturePrefab = (GameObject)EditorGUILayout.ObjectField("Default Furniture Prefab", defaultFurniturePrefab, typeof(GameObject), false);
GUILayout.Space(12);
GUILayout.Label("Tile Key Config", EditorStyles.boldLabel);
tileKeySizeX = EditorGUILayout.FloatField("Tile Size X (m)", tileKeySizeX);
tileKeySizeY = EditorGUILayout.FloatField("Tile Size Y (m)", tileKeySizeY);
tileKeyOverlapX = EditorGUILayout.FloatField("Overlap X (m)", tileKeyOverlapX);
tileKeyOverlapY = EditorGUILayout.FloatField("Overlap Y (m)", tileKeyOverlapY);
RefreshTileIndexCache();
GUILayout.Space(10);
GUILayout.Label("Tile Selection", EditorStyles.boldLabel);
DrawTileSelectionUI();
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 = tile_min (or global_min), Terrain height = (tile_max - tile_min).\n" +
"Absolute elevation mapping: Terrain Y = tile_min (fallback 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);
EditorGUILayout.EndScrollView();
}
private static void EnsureHeightmapImportSettings(string assetPath)
@@ -252,12 +291,19 @@ public class GeoTileImporter : EditorWindow
Debug.LogError($"[GeoTileImporter] Heightmap dir not found: {heightmapsDir}");
return;
}
if (applyOrthoTextures && !Directory.Exists(orthoDir))
if (applyOrthoTextures)
{
Debug.LogWarning($"[GeoTileImporter] Ortho dir not found: {orthoDir} (textures will be skipped).");
applyOrthoTextures = false;
bool primaryExists = Directory.Exists(orthoDir);
bool fallbackExists = !string.IsNullOrWhiteSpace(orthoDirFallback) && Directory.Exists(orthoDirFallback);
if (!primaryExists && !fallbackExists)
{
Debug.LogWarning($"[GeoTileImporter] Ortho dirs not found: primary={orthoDir}, fallback={orthoDirFallback} (textures will be skipped).");
applyOrthoTextures = false;
}
}
RefreshTileIndexCache();
var parent = GameObject.Find(parentName);
if (parent == null) parent = new GameObject(parentName);
@@ -267,160 +313,58 @@ public class GeoTileImporter : EditorWindow
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)
var tiles = ParseTilesCsv();
if (tiles == null || tiles.Count == 0)
{
Debug.LogError("[GeoTileImporter] CSV has no data rows (need header + at least 1 row).");
Debug.LogError("[GeoTileImporter] No valid tiles found in CSV.");
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))
var selectedTiles = ApplySelection(tiles);
if (selectedTiles.Count == 0)
{
Debug.LogError("[GeoTileImporter] CSV missing required columns. Required: " +
string.Join(", ", required) +
"\nFound: " + string.Join(", ", headerMap.Keys));
Debug.LogError("[GeoTileImporter] Selection is empty. Adjust Tile Selection and try again.");
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"];
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;
double originY = double.PositiveInfinity;
int validRowsForOrigin = 0;
for (int i = 1; i < lines.Length; i++)
for (int i = 0; i < selectedTiles.Count; 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 (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}'");
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}");
}
originX = Math.Min(originX, selectedTiles[i].Xmin);
originY = Math.Min(originY, selectedTiles[i].Ymin);
}
if (validRowsForOrigin == 0 || double.IsInfinity(originX) || double.IsInfinity(originY))
if (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.");
Debug.Log($"[GeoTileImporter] Origin: ({originX}, {originY}) from {selectedTiles.Count} selected tiles.");
int imported = 0, skipped = 0;
int importedTextures = 0;
var placements = new List<(string tileId, float ux, float uz, float baseMin)>();
var placements = new List<(string tileId, float ux, float uz, float baseY)>();
for (int i = 1; i < lines.Length; i++)
for (int i = 0; i < selectedTiles.Count; i++)
{
var line = lines[i].Trim();
if (string.IsNullOrWhiteSpace(line)) continue;
var tile = selectedTiles[i];
var tileId = tile.TileId;
double xmin = tile.Xmin;
double ymin = tile.Ymin;
double baseMin = tile.TileMin;
double baseMax = tile.TileMax;
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++;
Debug.LogWarning($"[GeoTileImporter] Skipping line {i + 1} (too few columns: {parts.Length}). Line: '{line}'");
continue;
}
if (tile.OutRes != heightmapResolution)
Debug.LogWarning($"[GeoTileImporter] Tile {tileId}: out_res={tile.OutRes} but importer expects {heightmapResolution}.");
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
{
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 (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}.");
double baseMin = tileRangeValid ? tileMin : gmin;
double baseMax = tileRangeValid ? tileMax : gmax;
float heightRange = (float)(baseMax - baseMin);
if (heightRange <= 0.0001f)
{
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;
}
skipped++;
Debug.LogWarning($"[GeoTileImporter] Tile {tileId}: invalid height range (tile_max <= tile_min). Skipping.");
continue;
}
string pngPath = Path.Combine(heightmapsDir, $"{tileId}.png").Replace("\\", "/");
@@ -496,6 +440,12 @@ public class GeoTileImporter : EditorWindow
if (applyOrthoTextures)
{
string orthoPath = Path.Combine(orthoDir, $"{tileId}.jpg").Replace("\\", "/");
if (!File.Exists(orthoPath) && !string.IsNullOrWhiteSpace(orthoDirFallback))
{
string fallbackPath = Path.Combine(orthoDirFallback, $"{tileId}.jpg").Replace("\\", "/");
if (File.Exists(fallbackPath))
orthoPath = fallbackPath;
}
if (File.Exists(orthoPath))
{
EnsureOrthoImportSettings(orthoPath);
@@ -528,7 +478,7 @@ public class GeoTileImporter : EditorWindow
}
}
Debug.Log($"[GeoTileImporter] Imported {tileId} @ XZ=({ux},{uz}) Y={baseMin} heightRange={heightRange} usedU16={usedU16}");
Debug.Log($"[GeoTileImporter] Imported {tileId} ({tile.TileKey}) @ XZ=({ux},{uz}) Y={baseMin} heightRange={heightRange} usedU16={usedU16}");
imported++;
placements.Add((tileId, ux, uz, (float)baseMin));
}
@@ -544,7 +494,384 @@ public class GeoTileImporter : EditorWindow
ImportEnhancedTrees(placements);
}
private void ImportBuildings(List<(string tileId, float ux, float uz, float baseMin)> placements)
private void RefreshTileIndexCache()
{
if (!File.Exists(tilesCsvPath))
{
tileIndexCache.Clear();
tileKeyOptions.Clear();
tileKeyLookup.Clear();
tileKeyStrings = Array.Empty<string>();
return;
}
var writeTime = File.GetLastWriteTimeUtc(tilesCsvPath);
if (tilesCsvPath == cachedTileCsvPath &&
writeTime == cachedTileCsvTimeUtc &&
Mathf.Approximately(tileKeySizeX, cachedTileKeySizeX) &&
Mathf.Approximately(tileKeySizeY, cachedTileKeySizeY) &&
Mathf.Approximately(tileKeyOverlapX, cachedTileOverlapX) &&
Mathf.Approximately(tileKeyOverlapY, cachedTileOverlapY))
{
return;
}
tileIndexCache.Clear();
tileIndexCache.AddRange(ParseTilesCsv());
BuildTileKeyOptions();
cachedTileCsvPath = tilesCsvPath;
cachedTileCsvTimeUtc = writeTime;
cachedTileKeySizeX = tileKeySizeX;
cachedTileKeySizeY = tileKeySizeY;
cachedTileOverlapX = tileKeyOverlapX;
cachedTileOverlapY = tileKeyOverlapY;
}
private void BuildTileKeyOptions()
{
tileKeyOptions.Clear();
tileKeyLookup.Clear();
for (int i = 0; i < tileIndexCache.Count; i++)
{
var tile = tileIndexCache[i];
if (string.IsNullOrWhiteSpace(tile.TileKey))
continue;
if (tileKeyLookup.ContainsKey(tile.TileKey))
continue;
var option = new TileKeyOption
{
Key = tile.TileKey,
X = tile.XKey,
Y = tile.YKey
};
tileKeyOptions.Add(option);
tileKeyLookup[tile.TileKey] = option;
}
tileKeyOptions.Sort((a, b) =>
{
int cmp = a.X.CompareTo(b.X);
return cmp != 0 ? cmp : a.Y.CompareTo(b.Y);
});
tileKeyStrings = new string[tileKeyOptions.Count];
for (int i = 0; i < tileKeyOptions.Count; i++)
tileKeyStrings[i] = tileKeyOptions[i].Key;
}
private void DrawTileSelectionUI()
{
if (tileKeyOptions.Count == 0)
{
EditorGUILayout.HelpBox("No tile keys available. Check the tile index CSV and key config.", MessageType.Info);
EditorGUILayout.LabelField("Tiles in selection", "0");
return;
}
if (string.IsNullOrEmpty(bottomLeftKey) || !tileKeyLookup.ContainsKey(bottomLeftKey))
bottomLeftKey = tileKeyOptions[0].Key;
var bottomLeft = tileKeyLookup[bottomLeftKey];
var topRightOptions = GetTopRightOptions(bottomLeft);
if (topRightOptions.Count == 0)
topRightOptions.Add(bottomLeft);
if (string.IsNullOrEmpty(topRightKey) || !ContainsKey(topRightOptions, topRightKey))
topRightKey = topRightOptions[topRightOptions.Count - 1].Key;
int bottomLeftIndex = Array.IndexOf(tileKeyStrings, bottomLeftKey);
int newBottomLeftIndex = EditorGUILayout.Popup("Bottom-Left Key", bottomLeftIndex, tileKeyStrings);
if (newBottomLeftIndex != bottomLeftIndex)
{
bottomLeftKey = tileKeyStrings[newBottomLeftIndex];
bottomLeft = tileKeyLookup[bottomLeftKey];
topRightOptions = GetTopRightOptions(bottomLeft);
if (topRightOptions.Count == 0)
topRightOptions.Add(bottomLeft);
if (!ContainsKey(topRightOptions, topRightKey))
topRightKey = topRightOptions[topRightOptions.Count - 1].Key;
}
var topRightKeys = BuildKeyArray(topRightOptions);
int topRightIndex = Array.IndexOf(topRightKeys, topRightKey);
if (topRightIndex < 0)
topRightIndex = topRightKeys.Length - 1;
int newTopRightIndex = EditorGUILayout.Popup("Top-Right Key", topRightIndex, topRightKeys);
if (newTopRightIndex != topRightIndex)
topRightKey = topRightKeys[newTopRightIndex];
EditorGUILayout.LabelField("Tiles in selection", CountSelectedTiles().ToString());
}
private List<TileKeyOption> GetTopRightOptions(TileKeyOption bottomLeft)
{
var options = new List<TileKeyOption>();
for (int i = 0; i < tileKeyOptions.Count; i++)
{
var option = tileKeyOptions[i];
if (option.X >= bottomLeft.X && option.Y >= bottomLeft.Y)
options.Add(option);
}
return options;
}
private int CountSelectedTiles()
{
if (!tileKeyLookup.TryGetValue(bottomLeftKey, out var bottomLeft) ||
!tileKeyLookup.TryGetValue(topRightKey, out var topRight))
return 0;
int count = 0;
for (int i = 0; i < tileIndexCache.Count; i++)
{
var tile = tileIndexCache[i];
if (tile.XKey >= bottomLeft.X && tile.XKey <= topRight.X &&
tile.YKey >= bottomLeft.Y && tile.YKey <= topRight.Y)
count++;
}
return count;
}
private List<TileRecord> ApplySelection(List<TileRecord> tiles)
{
if (!tileKeyLookup.TryGetValue(bottomLeftKey, out var bottomLeft) ||
!tileKeyLookup.TryGetValue(topRightKey, out var topRight))
return tiles;
var selected = new List<TileRecord>();
for (int i = 0; i < tiles.Count; i++)
{
var tile = tiles[i];
if (tile.XKey >= bottomLeft.X && tile.XKey <= topRight.X &&
tile.YKey >= bottomLeft.Y && tile.YKey <= topRight.Y)
selected.Add(tile);
}
return selected;
}
private List<TileRecord> ParseTilesCsv()
{
var tiles = new List<TileRecord>();
var ci = CultureInfo.InvariantCulture;
var lines = File.ReadAllLines(tilesCsvPath);
if (lines.Length < 2)
{
Debug.LogError("[GeoTileImporter] CSV has no data rows (need header + at least 1 row).");
return tiles;
}
var headerLine = lines[0].Trim();
var headerMap = BuildHeaderMap(headerLine);
Debug.Log($"[GeoTileImporter] Header columns mapped: {string.Join(", ", headerMap.Keys)}");
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 tiles;
}
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"];
int IDX_TMIN = headerMap.ContainsKey("tile_min") ? headerMap["tile_min"] : -1;
int IDX_TMAX = headerMap.ContainsKey("tile_max") ? headerMap["tile_max"] : -1;
int IDX_TILE_KEY = headerMap.ContainsKey("tile_key") ? headerMap["tile_key"] : -1;
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 (IDX_TILE_KEY >= 0)
needMaxIndex = Math.Max(needMaxIndex, IDX_TILE_KEY);
if (IDX_TMIN >= 0)
needMaxIndex = Math.Max(needMaxIndex, IDX_TMIN);
if (IDX_TMAX >= 0)
needMaxIndex = Math.Max(needMaxIndex, IDX_TMAX);
if (parts.Length <= needMaxIndex)
{
Debug.LogWarning($"[GeoTileImporter] Skipping line {i + 1} (too few columns: {parts.Length}).");
continue;
}
try
{
var xmin = double.Parse(parts[IDX_XMIN], ci);
var ymin = double.Parse(parts[IDX_YMIN], ci);
var tileKeyRaw = IDX_TILE_KEY >= 0 ? parts[IDX_TILE_KEY].Trim() : "";
var tileKey = ResolveTileKey(tileKeyRaw, xmin, ymin, out var xKey, out var yKey);
var globalMin = double.Parse(parts[IDX_GMIN], ci);
var globalMax = double.Parse(parts[IDX_GMAX], ci);
double tileMin = globalMin;
double tileMax = globalMax;
if (IDX_TMIN >= 0 && IDX_TMIN < parts.Length &&
double.TryParse(parts[IDX_TMIN], NumberStyles.Float, ci, out var parsedTileMin))
tileMin = parsedTileMin;
if (IDX_TMAX >= 0 && IDX_TMAX < parts.Length &&
double.TryParse(parts[IDX_TMAX], NumberStyles.Float, ci, out var parsedTileMax))
tileMax = parsedTileMax;
tiles.Add(new TileRecord
{
TileId = parts[IDX_TILE].Trim(),
TileKey = tileKey,
XKey = xKey,
YKey = yKey,
Xmin = xmin,
Ymin = ymin,
GlobalMin = globalMin,
GlobalMax = globalMax,
TileMin = tileMin,
TileMax = tileMax,
OutRes = int.Parse(parts[IDX_RES], ci)
});
}
catch (Exception e)
{
Debug.LogWarning($"[GeoTileImporter] Parse error line {i + 1}: {e.Message}");
}
}
return tiles;
}
private string ResolveTileKey(string tileKeyRaw, double xmin, double ymin, out int xKey, out int yKey)
{
if (!string.IsNullOrWhiteSpace(tileKeyRaw) && TryParseTileKey(tileKeyRaw, out xKey, out yKey))
return tileKeyRaw;
float sizeX = tileKeySizeX <= 0f ? 1f : tileKeySizeX;
float sizeY = tileKeySizeY <= 0f ? 1f : tileKeySizeY;
xKey = (int)Math.Floor((xmin + tileKeyOverlapX) / sizeX);
yKey = (int)Math.Floor((ymin + tileKeyOverlapY) / sizeY);
return $"{xKey}_{yKey}";
}
private static bool TryParseTileKey(string tileKey, out int xKey, out int yKey)
{
xKey = 0;
yKey = 0;
if (string.IsNullOrWhiteSpace(tileKey))
return false;
var parts = tileKey.Split('_');
if (parts.Length != 2)
return false;
return int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out xKey)
&& int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out yKey);
}
private static bool ContainsKey(List<TileKeyOption> options, string key)
{
for (int i = 0; i < options.Count; i++)
{
if (string.Equals(options[i].Key, key, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
private static string[] BuildKeyArray(List<TileKeyOption> options)
{
var keys = new string[options.Count];
for (int i = 0; i < options.Count; i++)
keys[i] = options[i].Key;
return keys;
}
private struct TileRecord
{
public string TileId;
public string TileKey;
public int XKey;
public int YKey;
public double Xmin;
public double Ymin;
public double GlobalMin;
public double GlobalMax;
public double TileMin;
public double TileMax;
public int OutRes;
}
private struct TileKeyOption
{
public string Key;
public int X;
public int Y;
}
private static bool TryGetTileXY(string tileId, out int x, out int y)
{
x = 0;
y = 0;
if (string.IsNullOrWhiteSpace(tileId))
return false;
var parts = tileId.Split('_');
var coords = new List<int>();
for (int i = 0; i < parts.Length; i++)
{
string part = parts[i];
if (part.Length < 3)
continue;
bool allDigits = true;
for (int j = 0; j < part.Length; j++)
{
if (!char.IsDigit(part[j]))
{
allDigits = false;
break;
}
}
if (!allDigits)
continue;
if (int.TryParse(part, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value))
coords.Add(value);
}
if (coords.Count < 2)
return false;
x = coords[coords.Count - 2];
y = coords[coords.Count - 1];
return true;
}
private static bool ShouldImportBuildingsForTile(string tileId, bool evenTilesOnly)
{
if (!evenTilesOnly)
return true;
if (!TryGetTileXY(tileId, out int x, out int y))
return true;
return (x % 2 == 0) && (y % 2 == 0);
}
private void ImportBuildings(List<(string tileId, float ux, float uz, float baseY)> placements)
{
if (!importBuildings)
return;
@@ -567,9 +894,15 @@ public class GeoTileImporter : EditorWindow
DestroyImmediate(parent.transform.GetChild(i).gameObject);
}
int imported = 0, missing = 0;
foreach (var (tileId, ux, uz, baseMin) in placements)
int imported = 0, missing = 0, skipped = 0;
foreach (var (tileId, ux, uz, baseY) in placements)
{
if (!ShouldImportBuildingsForTile(tileId, importBuildingsEvenTilesOnly))
{
skipped++;
continue;
}
string glbPath = Path.Combine(activeDir, $"{tileId}.glb").Replace("\\", "/");
if (!File.Exists(glbPath))
{
@@ -586,19 +919,25 @@ public class GeoTileImporter : EditorWindow
continue;
}
var tileContainer = new GameObject($"Buildings_{tileId}");
tileContainer.transform.SetParent(parent.transform, false);
tileContainer.transform.position = new Vector3(ux, baseY, uz);
tileContainer.isStatic = true;
var inst = PrefabUtility.InstantiatePrefab(prefab) as GameObject ?? Instantiate(prefab);
inst.name = tileId;
inst.transform.SetParent(parent.transform, false);
inst.transform.position = new Vector3(ux, baseMin, uz);
inst.transform.SetParent(tileContainer.transform, false);
// GLB vertices store absolute elevation; offset by -baseY to keep world Y absolute.
inst.transform.localPosition = new Vector3(0f, -baseY, 0f);
inst.transform.localRotation = Quaternion.Euler(0f, 180f, 0f);
inst.isStatic = true;
imported++;
}
Debug.Log($"[GeoTileImporter] Buildings ({sourceLabel}) imported={imported}, missing/failed={missing} under '{buildingsParentName}'.");
Debug.Log($"[GeoTileImporter] Buildings ({sourceLabel}) imported={imported}, skipped={skipped}, missing/failed={missing} under '{buildingsParentName}'.");
}
private void ImportTrees(List<(string tileId, float ux, float uz, float baseMin)> placements)
private void ImportTrees(List<(string tileId, float ux, float uz, float baseY)> placements)
{
if (!importTrees)
return;
@@ -623,7 +962,7 @@ public class GeoTileImporter : EditorWindow
}
int importedTiles = 0, importedChunks = 0, missingTiles = 0;
foreach (var (tileId, ux, uz, baseMin) in placements)
foreach (var (tileId, ux, uz, baseY) in placements)
{
// Look for chunk files: {tileId}_0_0.glb, {tileId}_0_1.glb, etc.
// Standard tree export creates 4x4 chunks per tile
@@ -647,7 +986,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, baseMin, uz);
tileContainer.transform.position = new Vector3(ux, baseY, uz);
tileContainer.transform.localRotation = Quaternion.Euler(0f, 180f, 0f);
tileContainer.isStatic = true;
@@ -664,7 +1003,8 @@ public class GeoTileImporter : EditorWindow
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
// GLB vertices store absolute elevation; offset by -baseY to keep world Y absolute.
inst.transform.localPosition = new Vector3(0f, -baseY, 0f);
inst.isStatic = true;
importedChunks++;
}
@@ -675,7 +1015,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 baseMin)> placements)
private void ImportFurniture(List<(string tileId, float ux, float uz, float baseY)> placements)
{
if (!importFurniture)
return;
@@ -697,7 +1037,7 @@ public class GeoTileImporter : EditorWindow
int imported = 0, skipped = 0;
var ci = CultureInfo.InvariantCulture;
foreach (var (tileId, ux, uz, baseMin) in placements)
foreach (var (tileId, ux, uz, baseY) in placements)
{
string csvPath = Path.Combine(furnitureDir, $"{tileId}.csv").Replace("\\", "/");
if (!File.Exists(csvPath))
@@ -731,7 +1071,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, baseMin, uz);
tileContainer.transform.position = new Vector3(ux, baseY, uz);
tileContainer.isStatic = true;
for (int i = 1; i < lines.Length; i++)
@@ -802,7 +1142,7 @@ public class GeoTileImporter : EditorWindow
}
obj.transform.SetParent(tileContainer.transform, false);
obj.transform.localPosition = new Vector3(xLocal, zGround - baseMin, yLocal);
obj.transform.localPosition = new Vector3(xLocal, zGround - baseY, yLocal);
obj.isStatic = true;
imported++;
}
@@ -817,7 +1157,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 baseMin)> placements)
private void ImportEnhancedTrees(List<(string tileId, float ux, float uz, float baseY)> placements)
{
if (!importEnhancedTrees)
return;
@@ -839,7 +1179,7 @@ public class GeoTileImporter : EditorWindow
int imported = 0, skipped = 0;
var ci = CultureInfo.InvariantCulture;
foreach (var (tileId, ux, uz, baseMin) in placements)
foreach (var (tileId, ux, uz, baseY) in placements)
{
string csvPath = Path.Combine(enhancedTreesDir, $"{tileId}.csv").Replace("\\", "/");
if (!File.Exists(csvPath))
@@ -877,7 +1217,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, baseMin, uz);
tileContainer.transform.position = new Vector3(ux, baseY, uz);
tileContainer.isStatic = true;
for (int i = 1; i < lines.Length; i++)
@@ -975,7 +1315,7 @@ public class GeoTileImporter : EditorWindow
}
treeObj.transform.SetParent(tileContainer.transform, false);
treeObj.transform.localPosition = new Vector3(xLocal, zGround - baseMin, yLocal);
treeObj.transform.localPosition = new Vector3(xLocal, zGround - baseY, yLocal);
treeObj.isStatic = true;
imported++;
}

View File

@@ -15,14 +15,18 @@ public class GeoTilePrefabImporter : 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 orthoDirFallback = "Assets/GeoData/ortho_jpg_river";
private string buildingsDir = "Assets/GeoData/buildings_tiles";
private string treesDir = "Assets/GeoData/trees_tiles";
private string furnitureDir = "Assets/GeoData/street_furniture";
private string enhancedTreesDir = "Assets/GeoData/trees_enhanced";
// Output settings
private string prefabOutputDir = "Assets/GeoData/TilePrefabs";
private string prefabOutputDir = "Assets/TilePrefabs";
private bool overwriteExisting = false;
private string buildingPrefabsDir = "Assets/TilePrefabs_Buildings";
private bool exportBuildingPrefabs = true;
private bool overwriteBuildingPrefabs = false;
// Terrain settings
private float tileSizeMeters = 1000f;
@@ -31,10 +35,29 @@ public class GeoTilePrefabImporter : EditorWindow
// Component toggles
private bool applyOrthoTextures = true;
private bool includeBuildings = true;
private bool includeBuildingsEvenTilesOnly = true;
private bool includeTrees = true;
private bool includeFurniture = false;
private bool includeEnhancedTrees = false;
private Vector2 scrollPosition;
private float tileKeySizeX = 1000f;
private float tileKeySizeY = 1000f;
private float tileKeyOverlapX = 0.5f;
private float tileKeyOverlapY = 0.5f;
private string bottomLeftKey = "";
private string topRightKey = "";
private readonly List<TileMetadata> tileIndexCache = new List<TileMetadata>();
private readonly List<TileKeyOption> tileKeyOptions = new List<TileKeyOption>();
private readonly Dictionary<string, TileKeyOption> tileKeyLookup = new Dictionary<string, TileKeyOption>(StringComparer.OrdinalIgnoreCase);
private string[] tileKeyStrings = Array.Empty<string>();
private string cachedTileCsvPath = "";
private DateTime cachedTileCsvTimeUtc = DateTime.MinValue;
private float cachedTileKeySizeX = 0f;
private float cachedTileKeySizeY = 0f;
private float cachedTileOverlapX = 0f;
private float cachedTileOverlapY = 0f;
// Prefabs for furniture (optional)
private GameObject lampPrefab;
private GameObject benchPrefab;
@@ -45,11 +68,13 @@ public class GeoTilePrefabImporter : EditorWindow
private struct TileMetadata
{
public string TileKey;
public string TileId;
public int XKey;
public int YKey;
public double Xmin, Ymin, Xmax, Ymax;
public double GlobalMin, GlobalMax;
public double TileMin, TileMax;
public bool HasTileMinMax;
public int OutRes;
}
@@ -62,10 +87,13 @@ public class GeoTilePrefabImporter : EditorWindow
private void OnGUI()
{
scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition);
GUILayout.Label("Input Paths", EditorStyles.boldLabel);
tilesCsvPath = EditorGUILayout.TextField("tile_index.csv", tilesCsvPath);
heightmapsDir = EditorGUILayout.TextField("height_png16 dir", heightmapsDir);
orthoDir = EditorGUILayout.TextField("ortho_jpg dir", orthoDir);
orthoDir = EditorGUILayout.TextField("ortho_jpg dir (primary)", orthoDir);
orthoDirFallback = EditorGUILayout.TextField("ortho_jpg dir (fallback)", orthoDirFallback);
buildingsDir = EditorGUILayout.TextField("buildings_tiles dir", buildingsDir);
treesDir = EditorGUILayout.TextField("trees_tiles dir", treesDir);
furnitureDir = EditorGUILayout.TextField("street_furniture dir", furnitureDir);
@@ -75,6 +103,9 @@ public class GeoTilePrefabImporter : EditorWindow
GUILayout.Label("Output Settings", EditorStyles.boldLabel);
prefabOutputDir = EditorGUILayout.TextField("Prefab output dir", prefabOutputDir);
overwriteExisting = EditorGUILayout.ToggleLeft("Overwrite existing prefabs", overwriteExisting);
buildingPrefabsDir = EditorGUILayout.TextField("Building prefab output dir", buildingPrefabsDir);
exportBuildingPrefabs = EditorGUILayout.ToggleLeft("Export building-only prefabs (2km blocks)", exportBuildingPrefabs);
overwriteBuildingPrefabs = EditorGUILayout.ToggleLeft("Overwrite building prefabs", overwriteBuildingPrefabs);
GUILayout.Space(10);
GUILayout.Label("Terrain Settings", EditorStyles.boldLabel);
@@ -85,6 +116,7 @@ public class GeoTilePrefabImporter : EditorWindow
GUILayout.Label("Include Components", EditorStyles.boldLabel);
applyOrthoTextures = EditorGUILayout.ToggleLeft("Apply ortho textures", applyOrthoTextures);
includeBuildings = EditorGUILayout.ToggleLeft("Include buildings (GLB)", includeBuildings);
includeBuildingsEvenTilesOnly = EditorGUILayout.ToggleLeft("Include 2km buildings once (even X/Y tiles only)", includeBuildingsEvenTilesOnly);
includeTrees = EditorGUILayout.ToggleLeft("Include trees (GLB chunks)", includeTrees);
includeFurniture = EditorGUILayout.ToggleLeft("Include street furniture (CSV)", includeFurniture);
includeEnhancedTrees = EditorGUILayout.ToggleLeft("Include enhanced trees (CSV)", includeEnhancedTrees);
@@ -98,6 +130,19 @@ public class GeoTilePrefabImporter : EditorWindow
bollardPrefab = (GameObject)EditorGUILayout.ObjectField("Bollard Prefab", bollardPrefab, typeof(GameObject), false);
defaultFurniturePrefab = (GameObject)EditorGUILayout.ObjectField("Default Furniture", defaultFurniturePrefab, typeof(GameObject), false);
GUILayout.Space(12);
GUILayout.Label("Tile Key Config", EditorStyles.boldLabel);
tileKeySizeX = EditorGUILayout.FloatField("Tile Size X (m)", tileKeySizeX);
tileKeySizeY = EditorGUILayout.FloatField("Tile Size Y (m)", tileKeySizeY);
tileKeyOverlapX = EditorGUILayout.FloatField("Overlap X (m)", tileKeyOverlapX);
tileKeyOverlapY = EditorGUILayout.FloatField("Overlap Y (m)", tileKeyOverlapY);
RefreshTileIndexCache();
GUILayout.Space(10);
GUILayout.Label("Tile Selection", EditorStyles.boldLabel);
DrawTileSelectionUI();
GUILayout.Space(15);
if (GUILayout.Button("Generate Prefabs"))
ImportTilesAsPrefabs();
@@ -110,8 +155,11 @@ public class GeoTilePrefabImporter : EditorWindow
"Creates one .prefab asset per tile in the manifest.\n" +
"Each prefab contains: Terrain (with TerrainData), Buildings, Trees, Furniture.\n" +
"TerrainData and TerrainLayers are saved as separate .asset files.\n" +
"Per-tile elevation: uses tile_min/tile_max when present (falls back to global_min/global_max).\n" +
"IMPORTANT: Use 'Place All Prefabs in Scene' to position tiles correctly.",
MessageType.Info);
EditorGUILayout.EndScrollView();
}
private void PlaceAllPrefabsInScene()
@@ -211,8 +259,11 @@ public class GeoTilePrefabImporter : EditorWindow
EnsureDirectoryExists(prefabOutputDir);
EnsureDirectoryExists($"{prefabOutputDir}/TerrainData");
EnsureDirectoryExists($"{prefabOutputDir}/TerrainLayers");
if (exportBuildingPrefabs)
EnsureDirectoryExists(buildingPrefabsDir);
// Parse CSV
RefreshTileIndexCache();
var tiles = ParseTilesCsv();
if (tiles == null || tiles.Count == 0)
{
@@ -220,32 +271,54 @@ public class GeoTilePrefabImporter : EditorWindow
return;
}
Debug.Log($"[GeoTilePrefabImporter] Found {tiles.Count} tiles to process.");
var selectedTiles = ApplySelection(tiles);
if (selectedTiles.Count == 0)
{
Debug.LogError("[GeoTilePrefabImporter] Selection is empty. Adjust Tile Selection and try again.");
return;
}
Debug.Log($"[GeoTilePrefabImporter] Found {selectedTiles.Count} tiles to process.");
int created = 0, skipped = 0, failed = 0;
int buildingsCreated = 0, buildingsSkipped = 0, buildingsFailed = 0;
for (int i = 0; i < tiles.Count; i++)
for (int i = 0; i < selectedTiles.Count; i++)
{
var tile = tiles[i];
var tile = selectedTiles[i];
EditorUtility.DisplayProgressBar(
"Creating Tile Prefabs",
$"Processing {tile.TileId} ({i + 1}/{tiles.Count})",
(float)i / tiles.Count);
$"Processing {tile.TileId} ({i + 1}/{selectedTiles.Count})",
(float)i / selectedTiles.Count);
string prefabPath = $"{prefabOutputDir}/{tile.TileId}.prefab";
if (File.Exists(prefabPath) && !overwriteExisting)
bool skipTerrain = File.Exists(prefabPath) && !overwriteExisting;
if (skipTerrain)
{
Debug.Log($"[GeoTilePrefabImporter] Skipping existing: {tile.TileId}");
Debug.Log($"[GeoTilePrefabImporter] Skipping existing terrain prefab: {tile.TileId}");
skipped++;
continue;
}
try
{
if (CreateTilePrefab(tile))
created++;
else
failed++;
if (!skipTerrain)
{
if (CreateTilePrefab(tile))
created++;
else
failed++;
}
if (exportBuildingPrefabs)
{
var result = CreateBuildingPrefab(tile);
if (result == BuildResult.Created)
buildingsCreated++;
else if (result == BuildResult.Skipped)
buildingsSkipped++;
else
buildingsFailed++;
}
}
catch (Exception e)
{
@@ -258,7 +331,170 @@ public class GeoTilePrefabImporter : EditorWindow
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log($"[GeoTilePrefabImporter] DONE. Created={created}, Skipped={skipped}, Failed={failed}");
Debug.Log($"[GeoTilePrefabImporter] DONE. Created={created}, Skipped={skipped}, Failed={failed}" +
(exportBuildingPrefabs ? $", Buildings Created={buildingsCreated}, Skipped={buildingsSkipped}, Failed={buildingsFailed}" : ""));
}
private void RefreshTileIndexCache()
{
if (!File.Exists(tilesCsvPath))
{
tileIndexCache.Clear();
tileKeyOptions.Clear();
tileKeyLookup.Clear();
tileKeyStrings = Array.Empty<string>();
return;
}
var writeTime = File.GetLastWriteTimeUtc(tilesCsvPath);
if (tilesCsvPath == cachedTileCsvPath &&
writeTime == cachedTileCsvTimeUtc &&
Mathf.Approximately(tileKeySizeX, cachedTileKeySizeX) &&
Mathf.Approximately(tileKeySizeY, cachedTileKeySizeY) &&
Mathf.Approximately(tileKeyOverlapX, cachedTileOverlapX) &&
Mathf.Approximately(tileKeyOverlapY, cachedTileOverlapY))
{
return;
}
tileIndexCache.Clear();
tileIndexCache.AddRange(ParseTilesCsv());
BuildTileKeyOptions();
cachedTileCsvPath = tilesCsvPath;
cachedTileCsvTimeUtc = writeTime;
cachedTileKeySizeX = tileKeySizeX;
cachedTileKeySizeY = tileKeySizeY;
cachedTileOverlapX = tileKeyOverlapX;
cachedTileOverlapY = tileKeyOverlapY;
}
private void BuildTileKeyOptions()
{
tileKeyOptions.Clear();
tileKeyLookup.Clear();
for (int i = 0; i < tileIndexCache.Count; i++)
{
var tile = tileIndexCache[i];
if (string.IsNullOrWhiteSpace(tile.TileKey))
continue;
if (tileKeyLookup.ContainsKey(tile.TileKey))
continue;
var option = new TileKeyOption
{
Key = tile.TileKey,
X = tile.XKey,
Y = tile.YKey
};
tileKeyOptions.Add(option);
tileKeyLookup[tile.TileKey] = option;
}
tileKeyOptions.Sort((a, b) =>
{
int cmp = a.X.CompareTo(b.X);
return cmp != 0 ? cmp : a.Y.CompareTo(b.Y);
});
tileKeyStrings = new string[tileKeyOptions.Count];
for (int i = 0; i < tileKeyOptions.Count; i++)
tileKeyStrings[i] = tileKeyOptions[i].Key;
}
private void DrawTileSelectionUI()
{
if (tileKeyOptions.Count == 0)
{
EditorGUILayout.HelpBox("No tile keys available. Check the tile index CSV and key config.", MessageType.Info);
EditorGUILayout.LabelField("Tiles in selection", "0");
return;
}
if (string.IsNullOrEmpty(bottomLeftKey) || !tileKeyLookup.ContainsKey(bottomLeftKey))
bottomLeftKey = tileKeyOptions[0].Key;
var bottomLeft = tileKeyLookup[bottomLeftKey];
var topRightOptions = GetTopRightOptions(bottomLeft);
if (topRightOptions.Count == 0)
topRightOptions.Add(bottomLeft);
if (string.IsNullOrEmpty(topRightKey) || !ContainsKey(topRightOptions, topRightKey))
topRightKey = topRightOptions[topRightOptions.Count - 1].Key;
int bottomLeftIndex = Array.IndexOf(tileKeyStrings, bottomLeftKey);
int newBottomLeftIndex = EditorGUILayout.Popup("Bottom-Left Key", bottomLeftIndex, tileKeyStrings);
if (newBottomLeftIndex != bottomLeftIndex)
{
bottomLeftKey = tileKeyStrings[newBottomLeftIndex];
bottomLeft = tileKeyLookup[bottomLeftKey];
topRightOptions = GetTopRightOptions(bottomLeft);
if (topRightOptions.Count == 0)
topRightOptions.Add(bottomLeft);
if (!ContainsKey(topRightOptions, topRightKey))
topRightKey = topRightOptions[topRightOptions.Count - 1].Key;
}
var topRightKeys = BuildKeyArray(topRightOptions);
int topRightIndex = Array.IndexOf(topRightKeys, topRightKey);
if (topRightIndex < 0)
topRightIndex = topRightKeys.Length - 1;
int newTopRightIndex = EditorGUILayout.Popup("Top-Right Key", topRightIndex, topRightKeys);
if (newTopRightIndex != topRightIndex)
topRightKey = topRightKeys[newTopRightIndex];
EditorGUILayout.LabelField("Tiles in selection", CountSelectedTiles().ToString());
}
private List<TileKeyOption> GetTopRightOptions(TileKeyOption bottomLeft)
{
var options = new List<TileKeyOption>();
for (int i = 0; i < tileKeyOptions.Count; i++)
{
var option = tileKeyOptions[i];
if (option.X >= bottomLeft.X && option.Y >= bottomLeft.Y)
options.Add(option);
}
return options;
}
private int CountSelectedTiles()
{
if (!tileKeyLookup.TryGetValue(bottomLeftKey, out var bottomLeft) ||
!tileKeyLookup.TryGetValue(topRightKey, out var topRight))
return 0;
int count = 0;
for (int i = 0; i < tileIndexCache.Count; i++)
{
var tile = tileIndexCache[i];
if (tile.XKey >= bottomLeft.X && tile.XKey <= topRight.X &&
tile.YKey >= bottomLeft.Y && tile.YKey <= topRight.Y)
count++;
}
return count;
}
private List<TileMetadata> ApplySelection(List<TileMetadata> tiles)
{
if (!tileKeyLookup.TryGetValue(bottomLeftKey, out var bottomLeft) ||
!tileKeyLookup.TryGetValue(topRightKey, out var topRight))
return tiles;
var selected = new List<TileMetadata>();
for (int i = 0; i < tiles.Count; i++)
{
var tile = tiles[i];
if (tile.XKey >= bottomLeft.X && tile.XKey <= topRight.X &&
tile.YKey >= bottomLeft.Y && tile.YKey <= topRight.Y)
selected.Add(tile);
}
return selected;
}
private List<TileMetadata> ParseTilesCsv()
@@ -289,10 +525,10 @@ public class GeoTilePrefabImporter : EditorWindow
int IDX_YMAX = headerMap["ymax"];
int IDX_GMIN = headerMap["global_min"];
int IDX_GMAX = headerMap["global_max"];
int IDX_TMIN = headerMap.ContainsKey("tile_min") ? headerMap["tile_min"] : -1;
int IDX_TMAX = headerMap.ContainsKey("tile_max") ? headerMap["tile_max"] : -1;
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;
int IDX_TILE_KEY = headerMap.ContainsKey("tile_key") ? headerMap["tile_key"] : -1;
for (int i = 1; i < lines.Length; i++)
{
@@ -300,9 +536,15 @@ public class GeoTilePrefabImporter : EditorWindow
if (string.IsNullOrWhiteSpace(line)) continue;
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));
int maxIdx = Math.Max(
Math.Max(Math.Max(IDX_TILE, IDX_XMIN), Math.Max(IDX_YMIN, IDX_XMAX)),
Math.Max(Math.Max(IDX_YMAX, IDX_GMIN), Math.Max(IDX_GMAX, IDX_RES)));
if (IDX_TILE_KEY >= 0)
maxIdx = Math.Max(maxIdx, IDX_TILE_KEY);
if (IDX_TMIN >= 0)
maxIdx = Math.Max(maxIdx, IDX_TMIN);
if (IDX_TMAX >= 0)
maxIdx = Math.Max(maxIdx, IDX_TMAX);
if (parts.Length <= maxIdx)
{
Debug.LogWarning($"[GeoTilePrefabImporter] Skipping line {i + 1}: too few columns.");
@@ -311,38 +553,35 @@ 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.");
}
}
var xmin = double.Parse(parts[IDX_XMIN], ci);
var ymin = double.Parse(parts[IDX_YMIN], ci);
var tileKeyRaw = IDX_TILE_KEY >= 0 ? parts[IDX_TILE_KEY].Trim() : "";
var tileKey = ResolveTileKey(tileKeyRaw, xmin, ymin, out var xKey, out var yKey);
var globalMin = double.Parse(parts[IDX_GMIN], ci);
var globalMax = double.Parse(parts[IDX_GMAX], ci);
double tileMin = globalMin;
double tileMax = globalMax;
if (IDX_TMIN >= 0 && IDX_TMIN < parts.Length &&
double.TryParse(parts[IDX_TMIN], NumberStyles.Float, ci, out var parsedTileMin))
tileMin = parsedTileMin;
if (IDX_TMAX >= 0 && IDX_TMAX < parts.Length &&
double.TryParse(parts[IDX_TMAX], NumberStyles.Float, ci, out var parsedTileMax))
tileMax = parsedTileMax;
tiles.Add(new TileMetadata
{
TileKey = tileKey,
TileId = parts[IDX_TILE].Trim(),
Xmin = double.Parse(parts[IDX_XMIN], ci),
Ymin = double.Parse(parts[IDX_YMIN], ci),
XKey = xKey,
YKey = yKey,
Xmin = xmin,
Ymin = ymin,
Xmax = double.Parse(parts[IDX_XMAX], ci),
Ymax = double.Parse(parts[IDX_YMAX], ci),
GlobalMin = gmin,
GlobalMax = gmax,
GlobalMin = globalMin,
GlobalMax = globalMax,
TileMin = tileMin,
TileMax = tileMax,
HasTileMinMax = tileRangeValid,
OutRes = int.Parse(parts[IDX_RES], ci)
});
}
@@ -355,24 +594,81 @@ public class GeoTilePrefabImporter : EditorWindow
return tiles;
}
private string ResolveTileKey(string tileKeyRaw, double xmin, double ymin, out int xKey, out int yKey)
{
if (!string.IsNullOrWhiteSpace(tileKeyRaw) && TryParseTileKey(tileKeyRaw, out xKey, out yKey))
return tileKeyRaw;
float sizeX = tileKeySizeX <= 0f ? 1f : tileKeySizeX;
float sizeY = tileKeySizeY <= 0f ? 1f : tileKeySizeY;
xKey = (int)Math.Floor((xmin + tileKeyOverlapX) / sizeX);
yKey = (int)Math.Floor((ymin + tileKeyOverlapY) / sizeY);
return $"{xKey}_{yKey}";
}
private static bool TryParseTileKey(string tileKey, out int xKey, out int yKey)
{
xKey = 0;
yKey = 0;
if (string.IsNullOrWhiteSpace(tileKey))
return false;
var parts = tileKey.Split('_');
if (parts.Length != 2)
return false;
return int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out xKey)
&& int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out yKey);
}
private static bool ContainsKey(List<TileKeyOption> options, string key)
{
for (int i = 0; i < options.Count; i++)
{
if (string.Equals(options[i].Key, key, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
private static string[] BuildKeyArray(List<TileKeyOption> options)
{
var keys = new string[options.Count];
for (int i = 0; i < options.Count; i++)
keys[i] = options[i].Key;
return keys;
}
private struct TileKeyOption
{
public string Key;
public int X;
public int Y;
}
private static bool ShouldIncludeBuildings(TileMetadata tile, bool evenTilesOnly)
{
if (!evenTilesOnly)
return true;
return (tile.XKey % 2 == 0) && (tile.YKey % 2 == 0);
}
private enum BuildResult
{
Created,
Skipped,
Failed
}
private bool CreateTilePrefab(TileMetadata tile)
{
// Validate height range
double baseMin = tile.HasTileMinMax ? tile.TileMin : tile.GlobalMin;
double baseMax = tile.HasTileMinMax ? tile.TileMax : tile.GlobalMax;
float heightRange = (float)(baseMax - baseMin);
float heightRange = (float)(tile.TileMax - tile.TileMin);
if (heightRange <= 0.0001f)
{
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;
}
Debug.LogWarning($"[GeoTilePrefabImporter] Tile {tile.TileId}: invalid height range. Skipping.");
return false;
}
// Load heightmap
@@ -447,6 +743,7 @@ public class GeoTilePrefabImporter : EditorWindow
// Store metadata as component for later use
var metadata = root.AddComponent<GeoTileMetadata>();
metadata.tileKey = tile.TileKey;
metadata.tileId = tile.TileId;
metadata.xmin = tile.Xmin;
metadata.ymin = tile.Ymin;
@@ -454,7 +751,6 @@ public class GeoTilePrefabImporter : EditorWindow
metadata.globalMax = tile.GlobalMax;
metadata.tileMin = tile.TileMin;
metadata.tileMax = tile.TileMax;
metadata.hasTileMinMax = tile.HasTileMinMax;
// Add child components
if (includeBuildings)
@@ -483,9 +779,53 @@ public class GeoTilePrefabImporter : EditorWindow
return true;
}
private BuildResult CreateBuildingPrefab(TileMetadata tile)
{
if (!exportBuildingPrefabs)
return BuildResult.Skipped;
if (!ShouldIncludeBuildings(tile, includeBuildingsEvenTilesOnly))
return BuildResult.Skipped;
string glbPath = Path.Combine(buildingsDir, $"{tile.TileId}.glb").Replace("\\", "/");
if (!File.Exists(glbPath))
return BuildResult.Skipped;
string prefabPath = $"{buildingPrefabsDir}/{tile.TileId}.prefab";
if (File.Exists(prefabPath))
{
if (!overwriteBuildingPrefabs)
return BuildResult.Skipped;
AssetDatabase.DeleteAsset(prefabPath);
}
var root = new GameObject(tile.TileId);
var metadata = root.AddComponent<GeoTileMetadata>();
metadata.tileKey = tile.TileKey;
metadata.tileId = tile.TileId;
metadata.xmin = tile.Xmin;
metadata.ymin = tile.Ymin;
metadata.globalMin = tile.GlobalMin;
metadata.globalMax = tile.GlobalMax;
metadata.tileMin = tile.TileMin;
metadata.tileMax = tile.TileMax;
AddBuildings(root, tile);
PrefabUtility.SaveAsPrefabAsset(root, prefabPath);
Debug.Log($"[GeoTilePrefabImporter] Created building prefab: {prefabPath}");
DestroyImmediate(root);
return BuildResult.Created;
}
private void ApplyOrthoTexture(TerrainData terrainData, string tileId)
{
string orthoPath = Path.Combine(orthoDir, $"{tileId}.jpg").Replace("\\", "/");
if (!File.Exists(orthoPath) && !string.IsNullOrWhiteSpace(orthoDirFallback))
{
string fallbackPath = Path.Combine(orthoDirFallback, $"{tileId}.jpg").Replace("\\", "/");
if (File.Exists(fallbackPath))
orthoPath = fallbackPath;
}
if (!File.Exists(orthoPath))
{
Debug.LogWarning($"[GeoTilePrefabImporter] Ortho texture missing for {tileId}: {orthoPath}");
@@ -526,6 +866,9 @@ public class GeoTilePrefabImporter : EditorWindow
private void AddBuildings(GameObject root, TileMetadata tile)
{
if (!ShouldIncludeBuildings(tile, includeBuildingsEvenTilesOnly))
return;
string glbPath = Path.Combine(buildingsDir, $"{tile.TileId}.glb").Replace("\\", "/");
if (!File.Exists(glbPath))
return;
@@ -537,17 +880,16 @@ 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=baseMin when placed, offset buildings by -baseMin
// so building world Y = baseMin + (-baseMin) + GLB_Y = GLB_Y (correct absolute elevation)
// Since prefab root will be at Y=tile_min when placed, offset buildings by -tile_min
// so building world Y = tile_min + (-tile_min) + 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, -baseMin, 0f);
instance.transform.localPosition = new Vector3(0f, -(float)tile.TileMin, 0f);
instance.transform.localRotation = Quaternion.Euler(0f, 180f, 0f);
instance.isStatic = true;
}
@@ -568,13 +910,12 @@ 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=baseMin when placed, offset trees by -baseMin
// so tree world Y = baseMin + (-baseMin) + GLB_Y = GLB_Y (correct absolute elevation)
// Since prefab root will be at Y=tile_min when placed, offset trees by -tile_min
// so tree world Y = tile_min + (-tile_min) + GLB_Y = GLB_Y (correct absolute elevation)
var treesContainer = new GameObject("Trees");
treesContainer.transform.SetParent(root.transform, false);
treesContainer.transform.localPosition = new Vector3(0f, -baseMin, 0f);
treesContainer.transform.localPosition = new Vector3(0f, -(float)tile.TileMin, 0f);
treesContainer.transform.localRotation = Quaternion.Euler(0f, 180f, 0f);
treesContainer.isStatic = true;
@@ -634,7 +975,7 @@ public class GeoTilePrefabImporter : EditorWindow
furnitureContainer.transform.localPosition = Vector3.zero;
furnitureContainer.isStatic = true;
float baseMin = (float)(tile.HasTileMinMax ? tile.TileMin : tile.GlobalMin);
float baseY = (float)tile.TileMin;
for (int i = 1; i < lines.Length; i++)
{
@@ -654,7 +995,7 @@ public class GeoTilePrefabImporter : EditorWindow
GameObject obj = CreateFurnitureObject(furnitureType, height, i);
obj.transform.SetParent(furnitureContainer.transform, false);
obj.transform.localPosition = new Vector3(xLocal, zGround - baseMin, yLocal);
obj.transform.localPosition = new Vector3(xLocal, zGround - baseY, yLocal);
obj.isStatic = true;
}
catch (Exception e)
@@ -749,7 +1090,7 @@ public class GeoTilePrefabImporter : EditorWindow
treesContainer.transform.localPosition = Vector3.zero;
treesContainer.isStatic = true;
float baseMin = (float)(tile.HasTileMinMax ? tile.TileMin : tile.GlobalMin);
float baseY = (float)tile.TileMin;
for (int i = 1; i < lines.Length; i++)
{
@@ -779,7 +1120,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 - baseMin, yLocal);
treeObj.transform.localPosition = new Vector3(xLocal, zGround - baseY, yLocal);
treeObj.isStatic = true;
}
catch (Exception e)