diff --git a/.gitignore b/.gitignore index 19e3ebc..b29151b 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,8 @@ temp/ scripts_unity/**/*.meta docs +GEMINI.md +AGENTS.md +conductor +export_swe +*.7z diff --git a/geodata_config.example.toml b/geodata_config.example.toml index 2624501..ee37fcc 100644 --- a/geodata_config.example.toml +++ b/geodata_config.example.toml @@ -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 diff --git a/geodata_config.toml b/geodata_config.toml index a78915b..8a5024a 100644 --- a/geodata_config.toml +++ b/geodata_config.toml @@ -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 diff --git a/geodata_pipeline/config.py b/geodata_pipeline/config.py index 325fcce..daf9f5f 100644 --- a/geodata_pipeline/config.py +++ b/geodata_pipeline/config.py @@ -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) diff --git a/geodata_pipeline/river_erosion.py b/geodata_pipeline/river_erosion.py index 84348c1..717d572 100644 --- a/geodata_pipeline/river_erosion.py +++ b/geodata_pipeline/river_erosion.py @@ -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: diff --git a/geodata_pipeline/setup_helpers.py b/geodata_pipeline/setup_helpers.py index d432552..5767ba3 100644 --- a/geodata_pipeline/setup_helpers.py +++ b/geodata_pipeline/setup_helpers.py @@ -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, ] diff --git a/geodata_pipeline/swe_lods.py b/geodata_pipeline/swe_lods.py new file mode 100644 index 0000000..c042982 --- /dev/null +++ b/geodata_pipeline/swe_lods.py @@ -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"), + ] diff --git a/geodata_pipeline/trees.py b/geodata_pipeline/trees.py index b0db4d3..cb8c19f 100644 --- a/geodata_pipeline/trees.py +++ b/geodata_pipeline/trees.py @@ -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: diff --git a/geodata_to_unity.py b/geodata_to_unity.py index be48ad0..65518e9 100644 --- a/geodata_to_unity.py +++ b/geodata_to_unity.py @@ -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 diff --git a/scripts_unity/Editor/GeoTileImporter.cs b/scripts_unity/Editor/GeoTileImporter.cs index 3b1148a..6f15c7c 100644 --- a/scripts_unity/Editor/GeoTileImporter.cs +++ b/scripts_unity/Editor/GeoTileImporter.cs @@ -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 tileIndexCache = new List(); + private readonly List tileKeyOptions = new List(); + private readonly Dictionary tileKeyLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + private string[] tileKeyStrings = Array.Empty(); + 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(); + 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 GetTopRightOptions(TileKeyOption bottomLeft) + { + var options = new List(); + 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 ApplySelection(List tiles) + { + if (!tileKeyLookup.TryGetValue(bottomLeftKey, out var bottomLeft) || + !tileKeyLookup.TryGetValue(topRightKey, out var topRight)) + return tiles; + + var selected = new List(); + 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 ParseTilesCsv() + { + var tiles = new List(); + 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 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 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(); + 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++; } diff --git a/scripts_unity/Editor/GeoTilePrefabImporter.cs b/scripts_unity/Editor/GeoTilePrefabImporter.cs index 830a80c..c755b1b 100644 --- a/scripts_unity/Editor/GeoTilePrefabImporter.cs +++ b/scripts_unity/Editor/GeoTilePrefabImporter.cs @@ -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 tileIndexCache = new List(); + private readonly List tileKeyOptions = new List(); + private readonly Dictionary tileKeyLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + private string[] tileKeyStrings = Array.Empty(); + 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(); + 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 GetTopRightOptions(TileKeyOption bottomLeft) + { + var options = new List(); + 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 ApplySelection(List tiles) + { + if (!tileKeyLookup.TryGetValue(bottomLeftKey, out var bottomLeft) || + !tileKeyLookup.TryGetValue(topRightKey, out var topRight)) + return tiles; + + var selected = new List(); + 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 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 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 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(); + 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(); + 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)