Add SWE LOD exports and improve Unity importers
- add swe_lod config and exporter for height/porosity/building EXRs - write river mask PNGs and add tree water/bridge masking - update Unity importers with tile selection, ortho fallback, and building prefabs
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -42,3 +42,8 @@ temp/
|
||||
scripts_unity/**/*.meta
|
||||
|
||||
docs
|
||||
GEMINI.md
|
||||
AGENTS.md
|
||||
conductor
|
||||
export_swe
|
||||
*.7z
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
|
||||
689
geodata_pipeline/swe_lods.py
Normal file
689
geodata_pipeline/swe_lods.py
Normal file
@@ -0,0 +1,689 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from dataclasses import asdict
|
||||
from typing import Iterable
|
||||
|
||||
import numpy as np
|
||||
from osgeo import gdal
|
||||
|
||||
from .config import Config, SweLodConfig, SweLodLevelConfig
|
||||
from .gdal_utils import build_vrt, cleanup_aux_files, ensure_dir, ensure_parent, open_dataset, safe_remove
|
||||
from .pointcloud import find_pointcloud_file, read_pointcloud_file
|
||||
|
||||
gdal.UseExceptions()
|
||||
|
||||
|
||||
def export_swe_lods(cfg: Config, *, force_vrt: bool = False) -> int:
|
||||
swe_cfg = cfg.swe_lod
|
||||
if not swe_cfg.enabled:
|
||||
print("[swe_lods] SWE LOD export disabled in config.")
|
||||
return 0
|
||||
|
||||
ensure_dir(cfg.work.work_dir)
|
||||
ensure_dir(swe_cfg.out_dir)
|
||||
ensure_parent(swe_cfg.tile_index_path)
|
||||
ensure_parent(swe_cfg.manifest_path)
|
||||
|
||||
height_ds = _prepare_height_source(cfg, swe_cfg, force_vrt=force_vrt)
|
||||
|
||||
xmin, ymin, xmax, ymax = _dataset_bounds(height_ds)
|
||||
|
||||
base_tile_size = max(level.tile_size_m for level in swe_cfg.lods)
|
||||
origin_x = swe_cfg.origin_x
|
||||
origin_y = swe_cfg.origin_y
|
||||
if origin_x is None:
|
||||
origin_x = math.floor(xmin / base_tile_size) * base_tile_size
|
||||
if origin_y is None:
|
||||
origin_y = math.floor(ymin / base_tile_size) * base_tile_size
|
||||
|
||||
porosity_ds = _open_optional_raster(swe_cfg.porosity_source, "porosity")
|
||||
building_ds = _open_optional_raster(swe_cfg.building_height_source, "building height")
|
||||
|
||||
tile_rows = []
|
||||
skipped = 0
|
||||
written = 0
|
||||
|
||||
for level_index, level in enumerate(swe_cfg.lods):
|
||||
lod_name = level.name or f"lod{level_index}"
|
||||
lod_dir = os.path.join(swe_cfg.out_dir, lod_name)
|
||||
height_dir = os.path.join(lod_dir, "height")
|
||||
porosity_dir = os.path.join(lod_dir, "porosity")
|
||||
building_dir = os.path.join(lod_dir, "buildings")
|
||||
|
||||
ensure_dir(height_dir)
|
||||
ensure_dir(porosity_dir)
|
||||
if swe_cfg.include_buildings:
|
||||
ensure_dir(building_dir)
|
||||
|
||||
for tile_id, bounds in _iter_tiles(origin_x, origin_y, xmin, ymin, xmax, ymax, level.tile_size_m):
|
||||
try:
|
||||
height = _warp_array(
|
||||
height_ds,
|
||||
bounds,
|
||||
level.resolution,
|
||||
level.resolution,
|
||||
swe_cfg.height_resample,
|
||||
swe_cfg.height_nodata,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
print(f"[swe_lods] Warp failed for {lod_name} {tile_id}: {exc}")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
height_path = os.path.join(height_dir, f"height_{tile_id}.exr")
|
||||
_write_exr(height_path, height, swe_cfg.prefer_float16)
|
||||
|
||||
porosity_path = os.path.join(porosity_dir, f"porosity_{tile_id}.exr")
|
||||
porosity = _build_porosity(
|
||||
porosity_ds,
|
||||
bounds,
|
||||
level.resolution,
|
||||
swe_cfg,
|
||||
)
|
||||
_write_exr(porosity_path, porosity, swe_cfg.prefer_float16)
|
||||
|
||||
building_path = ""
|
||||
if swe_cfg.include_buildings:
|
||||
building_path = os.path.join(building_dir, f"buildings_{tile_id}.exr")
|
||||
if building_ds is None:
|
||||
building = np.zeros((level.resolution, level.resolution), dtype=np.float32)
|
||||
else:
|
||||
building = _warp_array(
|
||||
building_ds,
|
||||
bounds,
|
||||
level.resolution,
|
||||
level.resolution,
|
||||
swe_cfg.building_resample,
|
||||
swe_cfg.building_nodata,
|
||||
)
|
||||
_write_exr(building_path, building, swe_cfg.prefer_float16)
|
||||
|
||||
tile_rows.append(
|
||||
{
|
||||
"lod": lod_name,
|
||||
"tile_x": tile_id.split("_")[0],
|
||||
"tile_y": tile_id.split("_")[1],
|
||||
"xmin": bounds[0],
|
||||
"ymin": bounds[1],
|
||||
"xmax": bounds[2],
|
||||
"ymax": bounds[3],
|
||||
"tile_size_m": level.tile_size_m,
|
||||
"resolution": level.resolution,
|
||||
"height_path": height_path,
|
||||
"porosity_path": porosity_path,
|
||||
"building_path": building_path,
|
||||
}
|
||||
)
|
||||
written += 1
|
||||
|
||||
_write_tile_index(swe_cfg.tile_index_path, tile_rows)
|
||||
_write_manifest(
|
||||
swe_cfg.manifest_path,
|
||||
swe_cfg,
|
||||
origin_x,
|
||||
origin_y,
|
||||
xmin,
|
||||
ymin,
|
||||
xmax,
|
||||
ymax,
|
||||
)
|
||||
|
||||
removed = cleanup_aux_files(_cleanup_patterns(cfg.raw.dgm1_dir))
|
||||
print(f"[swe_lods] Summary: wrote {written} tiles; skipped {skipped}.")
|
||||
print(f"[swe_lods] Cleanup removed {removed} temporary files/sidecars.")
|
||||
return 1 if skipped else 0
|
||||
|
||||
|
||||
def export_swe_porosity(cfg: Config, *, force_vrt: bool = False) -> int:
|
||||
swe_cfg = cfg.swe_lod
|
||||
if not swe_cfg.enabled:
|
||||
print("[swe_porosity] SWE LOD export disabled in config.")
|
||||
return 0
|
||||
|
||||
ensure_dir(cfg.work.work_dir)
|
||||
ensure_dir(swe_cfg.out_dir)
|
||||
ensure_parent(swe_cfg.tile_index_path)
|
||||
ensure_parent(swe_cfg.manifest_path)
|
||||
|
||||
porosity_source = swe_cfg.porosity_source
|
||||
building_source = swe_cfg.building_height_source
|
||||
|
||||
if _uses_lpo(porosity_source) or _uses_lpo(building_source):
|
||||
porosity_source, building_source = _ensure_lpo_sources(cfg, swe_cfg, force_vrt=force_vrt)
|
||||
|
||||
porosity_ds = _open_optional_raster(porosity_source, "porosity")
|
||||
building_ds = _open_optional_raster(building_source, "building height")
|
||||
if porosity_ds is None and building_ds is None:
|
||||
raise SystemExit("[swe_porosity] No porosity or building height source configured.")
|
||||
|
||||
bounds_ds = porosity_ds or building_ds
|
||||
xmin, ymin, xmax, ymax = _dataset_bounds(bounds_ds)
|
||||
|
||||
base_tile_size = max(level.tile_size_m for level in swe_cfg.lods)
|
||||
origin_x = swe_cfg.origin_x
|
||||
origin_y = swe_cfg.origin_y
|
||||
if origin_x is None:
|
||||
origin_x = math.floor(xmin / base_tile_size) * base_tile_size
|
||||
if origin_y is None:
|
||||
origin_y = math.floor(ymin / base_tile_size) * base_tile_size
|
||||
|
||||
tile_rows = []
|
||||
skipped = 0
|
||||
written = 0
|
||||
|
||||
for level_index, level in enumerate(swe_cfg.lods):
|
||||
lod_name = level.name or f"lod{level_index}"
|
||||
lod_dir = os.path.join(swe_cfg.out_dir, lod_name)
|
||||
height_dir = os.path.join(lod_dir, "height")
|
||||
porosity_dir = os.path.join(lod_dir, "porosity")
|
||||
building_dir = os.path.join(lod_dir, "buildings")
|
||||
|
||||
ensure_dir(height_dir)
|
||||
ensure_dir(porosity_dir)
|
||||
if swe_cfg.include_buildings:
|
||||
ensure_dir(building_dir)
|
||||
|
||||
for tile_id, bounds in _iter_tiles(origin_x, origin_y, xmin, ymin, xmax, ymax, level.tile_size_m):
|
||||
porosity_path = os.path.join(porosity_dir, f"porosity_{tile_id}.exr")
|
||||
porosity = _build_porosity(
|
||||
porosity_ds,
|
||||
bounds,
|
||||
level.resolution,
|
||||
swe_cfg,
|
||||
)
|
||||
_write_exr(porosity_path, porosity, swe_cfg.prefer_float16)
|
||||
|
||||
building_path = ""
|
||||
if swe_cfg.include_buildings:
|
||||
building_path = os.path.join(building_dir, f"buildings_{tile_id}.exr")
|
||||
if building_ds is None:
|
||||
building = np.zeros((level.resolution, level.resolution), dtype=np.float32)
|
||||
else:
|
||||
building = _warp_array(
|
||||
building_ds,
|
||||
bounds,
|
||||
level.resolution,
|
||||
level.resolution,
|
||||
swe_cfg.building_resample,
|
||||
swe_cfg.building_nodata,
|
||||
)
|
||||
_write_exr(building_path, building, swe_cfg.prefer_float16)
|
||||
|
||||
tile_rows.append(
|
||||
{
|
||||
"lod": lod_name,
|
||||
"tile_x": tile_id.split("_")[0],
|
||||
"tile_y": tile_id.split("_")[1],
|
||||
"xmin": bounds[0],
|
||||
"ymin": bounds[1],
|
||||
"xmax": bounds[2],
|
||||
"ymax": bounds[3],
|
||||
"tile_size_m": level.tile_size_m,
|
||||
"resolution": level.resolution,
|
||||
"height_path": "",
|
||||
"porosity_path": porosity_path,
|
||||
"building_path": building_path,
|
||||
}
|
||||
)
|
||||
written += 1
|
||||
|
||||
_write_tile_index(swe_cfg.tile_index_path, tile_rows)
|
||||
_write_manifest(
|
||||
swe_cfg.manifest_path,
|
||||
swe_cfg,
|
||||
origin_x,
|
||||
origin_y,
|
||||
xmin,
|
||||
ymin,
|
||||
xmax,
|
||||
ymax,
|
||||
)
|
||||
|
||||
removed = cleanup_aux_files(_cleanup_patterns(cfg.raw.dgm1_dir))
|
||||
print(f"[swe_porosity] Summary: wrote {written} tiles; skipped {skipped}.")
|
||||
print(f"[swe_porosity] Cleanup removed {removed} temporary files/sidecars.")
|
||||
return 1 if skipped else 0
|
||||
|
||||
|
||||
def _uses_lpo(value: str | None) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
return str(value).strip().lower() == "lpo"
|
||||
|
||||
|
||||
def _ensure_lpo_sources(cfg: Config, swe_cfg: SweLodConfig, *, force_vrt: bool):
|
||||
lpo_dir = swe_cfg.lpo_dir or cfg.pointcloud.lpo_dir
|
||||
if not lpo_dir:
|
||||
raise SystemExit("[swe_porosity] LPO directory not configured.")
|
||||
|
||||
manifest = _resolve_height_manifest(cfg, swe_cfg)
|
||||
if not os.path.exists(manifest):
|
||||
raise SystemExit(f"[swe_porosity] Height manifest missing: {manifest}")
|
||||
|
||||
work_dir = os.path.join(cfg.work.work_dir, "swe_lpo")
|
||||
porosity_tiles = os.path.join(work_dir, "porosity_tiles")
|
||||
building_tiles = os.path.join(work_dir, "building_tiles")
|
||||
ensure_dir(porosity_tiles)
|
||||
ensure_dir(building_tiles)
|
||||
|
||||
porosity_paths = []
|
||||
building_paths = []
|
||||
|
||||
tiles = _read_manifest_tiles(manifest)
|
||||
if not tiles:
|
||||
raise SystemExit(f"[swe_porosity] No tiles found in {manifest}")
|
||||
|
||||
for tile_id, bounds in tiles:
|
||||
lpo_path = find_pointcloud_file(lpo_dir, tile_id)
|
||||
if not lpo_path:
|
||||
continue
|
||||
|
||||
porosity_path = os.path.join(porosity_tiles, f"{tile_id}.tif")
|
||||
building_path = os.path.join(building_tiles, f"{tile_id}.tif")
|
||||
if not force_vrt and os.path.exists(porosity_path) and os.path.exists(building_path):
|
||||
porosity_paths.append(porosity_path)
|
||||
building_paths.append(building_path)
|
||||
continue
|
||||
|
||||
points = read_pointcloud_file(
|
||||
lpo_path,
|
||||
bounds=bounds,
|
||||
chunk_size=cfg.pointcloud.chunk_size,
|
||||
)
|
||||
porosity, building = _rasterize_lpo(
|
||||
points.x,
|
||||
points.y,
|
||||
points.z,
|
||||
bounds,
|
||||
swe_cfg.lpo_base_res,
|
||||
swe_cfg.lpo_density_threshold,
|
||||
swe_cfg.lpo_height_percentile,
|
||||
swe_cfg.lpo_min_height_m,
|
||||
)
|
||||
|
||||
gt = _geotransform_from_bounds(bounds, swe_cfg.lpo_base_res)
|
||||
_write_geotiff(porosity_path, porosity, gt, "")
|
||||
_write_geotiff(building_path, building, gt, "")
|
||||
|
||||
porosity_paths.append(porosity_path)
|
||||
building_paths.append(building_path)
|
||||
|
||||
if not porosity_paths and not building_paths:
|
||||
raise SystemExit("[swe_porosity] No LPO tiles were processed.")
|
||||
|
||||
porosity_vrt = os.path.join(work_dir, "porosity.vrt")
|
||||
building_vrt = os.path.join(work_dir, "buildings.vrt")
|
||||
build_vrt(porosity_vrt, porosity_paths, force=True)
|
||||
build_vrt(building_vrt, building_paths, force=True)
|
||||
return porosity_vrt, building_vrt
|
||||
|
||||
|
||||
def _prepare_height_source(cfg: Config, swe_cfg: SweLodConfig, *, force_vrt: bool) :
|
||||
source = (swe_cfg.height_source or "dgm1").lower()
|
||||
if source == "river_erosion":
|
||||
return _prepare_eroded_height_source(cfg, swe_cfg, force_vrt=force_vrt)
|
||||
if source == "dgm1":
|
||||
tif_paths = sorted(_collect_height_sources(cfg.raw.dgm1_dir))
|
||||
if not tif_paths:
|
||||
raise SystemExit(f"[swe_lods] No heightmap sources found in {cfg.raw.dgm1_dir}.")
|
||||
build_vrt(cfg.work.heightmap_vrt, tif_paths, force=force_vrt)
|
||||
return open_dataset(cfg.work.heightmap_vrt, f"[swe_lods] Could not open {cfg.work.heightmap_vrt}")
|
||||
raise SystemExit(f"[swe_lods] Unknown height_source '{swe_cfg.height_source}'.")
|
||||
|
||||
|
||||
def _prepare_eroded_height_source(cfg: Config, swe_cfg: SweLodConfig, *, force_vrt: bool):
|
||||
from csv import DictReader
|
||||
|
||||
source_dir = swe_cfg.height_source_dir or cfg.river_erosion.output_dir
|
||||
manifest = swe_cfg.height_source_manifest or cfg.river_erosion.manifest_vr
|
||||
|
||||
if not os.path.exists(manifest):
|
||||
raise SystemExit(f"[swe_lods] River erosion manifest missing: {manifest}")
|
||||
|
||||
work_dir = os.path.join(cfg.work.work_dir, "swe_eroded")
|
||||
ensure_dir(work_dir)
|
||||
tif_paths = []
|
||||
|
||||
with open(manifest, newline="", encoding="utf-8") as handle:
|
||||
reader = DictReader(handle)
|
||||
for row in reader:
|
||||
tile_id = (row.get("tile_id") or "").strip()
|
||||
if not tile_id:
|
||||
continue
|
||||
png_path = os.path.join(source_dir, f"{tile_id}.png")
|
||||
if not os.path.exists(png_path):
|
||||
print(f"[swe_lods] Missing eroded PNG {png_path}")
|
||||
continue
|
||||
|
||||
tile_min = _parse_float(row.get("tile_min"))
|
||||
tile_max = _parse_float(row.get("tile_max"))
|
||||
global_min = _parse_float(row.get("global_min"))
|
||||
global_max = _parse_float(row.get("global_max"))
|
||||
if tile_min is None or tile_max is None:
|
||||
tile_min = global_min
|
||||
tile_max = global_max
|
||||
if tile_min is None or tile_max is None:
|
||||
print(f"[swe_lods] Missing min/max for {tile_id}; skipping.")
|
||||
continue
|
||||
if tile_max <= tile_min:
|
||||
tile_max = tile_min + 1e-3
|
||||
|
||||
tif_path = os.path.join(work_dir, f"{tile_id}.tif")
|
||||
if force_vrt and os.path.exists(tif_path):
|
||||
safe_remove(tif_path)
|
||||
|
||||
if not os.path.exists(tif_path):
|
||||
ds_png = open_dataset(png_path, f"[swe_lods] Could not open {png_path}")
|
||||
band = ds_png.GetRasterBand(1)
|
||||
raw = band.ReadAsArray().astype(np.float32)
|
||||
gt = ds_png.GetGeoTransform()
|
||||
proj = ds_png.GetProjection()
|
||||
ds_png = None
|
||||
|
||||
height = tile_min + (raw / 65535.0) * (tile_max - tile_min)
|
||||
_write_geotiff(tif_path, height, gt, proj)
|
||||
|
||||
tif_paths.append(tif_path)
|
||||
|
||||
if not tif_paths:
|
||||
raise SystemExit("[swe_lods] No eroded height tiles available to build VRT.")
|
||||
|
||||
vrt_path = os.path.join(work_dir, "swe_eroded.vrt")
|
||||
build_vrt(vrt_path, tif_paths, force=True)
|
||||
return open_dataset(vrt_path, f"[swe_lods] Could not open {vrt_path}")
|
||||
|
||||
|
||||
def _collect_height_sources(raw_dir: str) -> Iterable[str]:
|
||||
return sorted(
|
||||
[
|
||||
os.path.join(raw_dir, name)
|
||||
for name in os.listdir(raw_dir)
|
||||
if name.lower().endswith(".tif")
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _dataset_bounds(ds) -> tuple[float, float, float, float]:
|
||||
gt = ds.GetGeoTransform()
|
||||
ulx, xres, _, uly, _, yres = gt
|
||||
xmax = ulx + xres * ds.RasterXSize
|
||||
ymin = uly + yres * ds.RasterYSize
|
||||
xmin = ulx
|
||||
ymax = uly
|
||||
return min(xmin, xmax), min(ymin, ymax), max(xmin, xmax), max(ymin, ymax)
|
||||
|
||||
|
||||
def _iter_tiles(
|
||||
origin_x: float,
|
||||
origin_y: float,
|
||||
xmin: float,
|
||||
ymin: float,
|
||||
xmax: float,
|
||||
ymax: float,
|
||||
tile_size: float,
|
||||
):
|
||||
start_x = int(math.floor((xmin - origin_x) / tile_size))
|
||||
end_x = int(math.ceil((xmax - origin_x) / tile_size))
|
||||
start_y = int(math.floor((ymin - origin_y) / tile_size))
|
||||
end_y = int(math.ceil((ymax - origin_y) / tile_size))
|
||||
|
||||
for ty in range(start_y, end_y):
|
||||
for tx in range(start_x, end_x):
|
||||
tile_min_x = origin_x + tx * tile_size
|
||||
tile_min_y = origin_y + ty * tile_size
|
||||
tile_max_x = tile_min_x + tile_size
|
||||
tile_max_y = tile_min_y + tile_size
|
||||
if tile_max_x <= xmin or tile_min_x >= xmax or tile_max_y <= ymin or tile_min_y >= ymax:
|
||||
continue
|
||||
tile_id = f"{tx}_{ty}"
|
||||
yield tile_id, (tile_min_x, tile_min_y, tile_max_x, tile_max_y)
|
||||
|
||||
|
||||
def _warp_array(
|
||||
ds,
|
||||
bounds: tuple[float, float, float, float],
|
||||
width: int,
|
||||
height: int,
|
||||
resample: str,
|
||||
dst_nodata: float | None,
|
||||
) -> np.ndarray:
|
||||
warp_opts = gdal.WarpOptions(
|
||||
format="MEM",
|
||||
outputBounds=bounds,
|
||||
width=width,
|
||||
height=height,
|
||||
resampleAlg=resample,
|
||||
dstNodata=dst_nodata,
|
||||
)
|
||||
warped = gdal.Warp("", ds, options=warp_opts)
|
||||
if warped is None:
|
||||
raise RuntimeError("GDAL Warp returned None.")
|
||||
band = warped.GetRasterBand(1)
|
||||
data = band.ReadAsArray()
|
||||
return data.astype(np.float32, copy=False)
|
||||
|
||||
|
||||
def _build_porosity(
|
||||
porosity_ds,
|
||||
bounds: tuple[float, float, float, float],
|
||||
resolution: int,
|
||||
cfg: SweLodConfig,
|
||||
) -> np.ndarray:
|
||||
if porosity_ds is None:
|
||||
return np.ones((resolution, resolution), dtype=np.float32)
|
||||
|
||||
porosity = _warp_array(
|
||||
porosity_ds,
|
||||
bounds,
|
||||
resolution,
|
||||
resolution,
|
||||
cfg.porosity_resample,
|
||||
cfg.porosity_nodata,
|
||||
)
|
||||
|
||||
porosity = np.clip(porosity, 0.0, 1.0)
|
||||
if cfg.solid_bias <= 0.0:
|
||||
return porosity
|
||||
|
||||
denom = porosity + (1.0 - porosity) * (1.0 + cfg.solid_bias)
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
biased = np.divide(porosity, denom, out=np.zeros_like(porosity), where=denom != 0)
|
||||
return np.clip(biased, 0.0, 1.0)
|
||||
|
||||
|
||||
def _write_exr(path: str, data: np.ndarray, prefer_float16: bool) -> None:
|
||||
driver = gdal.GetDriverByName("EXR")
|
||||
if driver is None:
|
||||
raise SystemExit("[swe_lods] GDAL EXR driver not available.")
|
||||
|
||||
out_type = gdal.GDT_Float32
|
||||
if prefer_float16 and hasattr(gdal, "GDT_Float16"):
|
||||
out_type = gdal.GDT_Float16
|
||||
|
||||
height, width = data.shape
|
||||
ds = driver.Create(path, width, height, 1, out_type)
|
||||
if ds is None:
|
||||
raise RuntimeError(f"Could not create EXR output at {path}")
|
||||
band = ds.GetRasterBand(1)
|
||||
band.WriteArray(data.astype(np.float32, copy=False))
|
||||
band.FlushCache()
|
||||
ds.FlushCache()
|
||||
ds = None
|
||||
|
||||
|
||||
def _write_geotiff(path: str, data: np.ndarray, geo_transform, projection: str) -> None:
|
||||
driver = gdal.GetDriverByName("GTiff")
|
||||
if driver is None:
|
||||
raise SystemExit("[swe_lods] GDAL GTiff driver not available.")
|
||||
height, width = data.shape
|
||||
ds = driver.Create(path, width, height, 1, gdal.GDT_Float32)
|
||||
if ds is None:
|
||||
raise RuntimeError(f"Could not create GeoTIFF at {path}")
|
||||
ds.SetGeoTransform(geo_transform)
|
||||
if projection:
|
||||
ds.SetProjection(projection)
|
||||
band = ds.GetRasterBand(1)
|
||||
band.WriteArray(data.astype(np.float32, copy=False))
|
||||
band.FlushCache()
|
||||
ds.FlushCache()
|
||||
ds = None
|
||||
|
||||
|
||||
def _rasterize_lpo(
|
||||
x: np.ndarray,
|
||||
y: np.ndarray,
|
||||
z: np.ndarray,
|
||||
bounds: tuple[float, float, float, float],
|
||||
resolution: int,
|
||||
density_threshold: int,
|
||||
height_percentile: float,
|
||||
min_height: float,
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
xmin, ymin, xmax, ymax = bounds
|
||||
resolution = max(2, int(resolution))
|
||||
step = (xmax - xmin) / (resolution - 1)
|
||||
if step <= 0:
|
||||
return np.ones((resolution, resolution), dtype=np.float32), np.zeros((resolution, resolution), dtype=np.float32)
|
||||
|
||||
mask = z >= min_height
|
||||
if mask.any():
|
||||
x = x[mask]
|
||||
y = y[mask]
|
||||
z = z[mask]
|
||||
|
||||
if x.size == 0:
|
||||
porosity = np.ones((resolution, resolution), dtype=np.float32)
|
||||
building = np.zeros((resolution, resolution), dtype=np.float32)
|
||||
return porosity, building
|
||||
|
||||
ix = np.floor((x - xmin) / step).astype(np.int32)
|
||||
iy = np.floor((y - ymin) / step).astype(np.int32)
|
||||
ix = np.clip(ix, 0, resolution - 1)
|
||||
iy = np.clip(iy, 0, resolution - 1)
|
||||
|
||||
counts = np.zeros((resolution, resolution), dtype=np.int32)
|
||||
np.add.at(counts, (iy, ix), 1)
|
||||
porosity = np.where(counts >= max(1, density_threshold), 0.0, 1.0).astype(np.float32)
|
||||
|
||||
building = np.zeros((resolution * resolution,), dtype=np.float32)
|
||||
cell_index = iy * resolution + ix
|
||||
order = np.argsort(cell_index)
|
||||
cell_sorted = cell_index[order]
|
||||
z_sorted = z[order]
|
||||
|
||||
unique_cells, start_idx = np.unique(cell_sorted, return_index=True)
|
||||
for idx, cell in enumerate(unique_cells):
|
||||
start = start_idx[idx]
|
||||
end = start_idx[idx + 1] if idx + 1 < len(start_idx) else len(cell_sorted)
|
||||
vals = z_sorted[start:end]
|
||||
if vals.size == 0:
|
||||
continue
|
||||
if height_percentile >= 100.0:
|
||||
height = float(np.max(vals))
|
||||
else:
|
||||
height = float(np.percentile(vals, height_percentile))
|
||||
building[cell] = height
|
||||
|
||||
building = building.reshape((resolution, resolution))
|
||||
return porosity, building
|
||||
|
||||
|
||||
def _geotransform_from_bounds(bounds: tuple[float, float, float, float], resolution: int):
|
||||
xmin, ymin, xmax, ymax = bounds
|
||||
step = (xmax - xmin) / (resolution - 1)
|
||||
return (xmin, step, 0.0, ymax, 0.0, -step)
|
||||
|
||||
|
||||
def _resolve_height_manifest(cfg: Config, swe_cfg: SweLodConfig) -> str:
|
||||
if swe_cfg.height_source_manifest:
|
||||
return swe_cfg.height_source_manifest
|
||||
if swe_cfg.height_source.lower() == "river_erosion":
|
||||
return cfg.river_erosion.manifest_vr
|
||||
return cfg.export.manifest_path
|
||||
|
||||
|
||||
def _read_manifest_tiles(path: str) -> list[tuple[str, tuple[float, float, float, float]]]:
|
||||
import csv
|
||||
|
||||
tiles = []
|
||||
with open(path, newline="", encoding="utf-8") as handle:
|
||||
reader = csv.DictReader(handle)
|
||||
for row in reader:
|
||||
tile_id = (row.get("tile_id") or "").strip()
|
||||
if not tile_id:
|
||||
continue
|
||||
xmin = _parse_float(row.get("xmin"))
|
||||
ymin = _parse_float(row.get("ymin"))
|
||||
xmax = _parse_float(row.get("xmax"))
|
||||
ymax = _parse_float(row.get("ymax"))
|
||||
if xmin is None or ymin is None or xmax is None or ymax is None:
|
||||
continue
|
||||
tiles.append((tile_id, (xmin, ymin, xmax, ymax)))
|
||||
return tiles
|
||||
|
||||
|
||||
def _parse_float(value: str | None) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _write_tile_index(path: str, rows: list[dict]) -> None:
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write("lod,tile_x,tile_y,xmin,ymin,xmax,ymax,tile_size_m,resolution,height_path,porosity_path,building_path\n")
|
||||
for row in rows:
|
||||
fh.write(
|
||||
f"{row['lod']},{row['tile_x']},{row['tile_y']},"
|
||||
f"{row['xmin']},{row['ymin']},{row['xmax']},{row['ymax']},"
|
||||
f"{row['tile_size_m']},{row['resolution']},"
|
||||
f"{row['height_path']},{row['porosity_path']},{row['building_path']}\n"
|
||||
)
|
||||
|
||||
|
||||
def _write_manifest(
|
||||
path: str,
|
||||
cfg: SweLodConfig,
|
||||
origin_x: float,
|
||||
origin_y: float,
|
||||
xmin: float,
|
||||
ymin: float,
|
||||
xmax: float,
|
||||
ymax: float,
|
||||
) -> None:
|
||||
payload = {
|
||||
"origin_x": origin_x,
|
||||
"origin_y": origin_y,
|
||||
"bounds": [xmin, ymin, xmax, ymax],
|
||||
"solid_bias": cfg.solid_bias,
|
||||
"lods": [asdict(level) for level in cfg.lods],
|
||||
}
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(payload, fh, indent=2)
|
||||
|
||||
|
||||
def _open_optional_raster(path: str | None, label: str):
|
||||
if not path:
|
||||
return None
|
||||
if not os.path.exists(path):
|
||||
print(f"[swe_lods] {label} raster not found: {path}.")
|
||||
return None
|
||||
try:
|
||||
return open_dataset(path, f"[swe_lods] Could not open {label} raster {path}")
|
||||
except SystemExit as exc:
|
||||
print(exc)
|
||||
return None
|
||||
|
||||
|
||||
def _cleanup_patterns(raw_dir: str) -> Iterable[str]:
|
||||
return [
|
||||
os.path.join("work", "*_tmp.tif"),
|
||||
os.path.join("work", "*_tmp.tif.aux.xml"),
|
||||
os.path.join("work", "*.aux.xml"),
|
||||
os.path.join(raw_dir, "*.aux.xml"),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,6 +16,7 @@ public class GeoTileImporter : EditorWindow
|
||||
private string tilesCsvPath = "Assets/GeoData/tile_index.csv";
|
||||
private string heightmapsDir = "Assets/GeoData/height_png16";
|
||||
private string orthoDir = "Assets/GeoData/ortho_jpg";
|
||||
private string orthoDirFallback = "Assets/GeoData/ortho_jpg_river";
|
||||
private string buildingsDir = "Assets/GeoData/buildings_tiles";
|
||||
private string buildingsEnhancedDir = "Assets/GeoData/buildings_enhanced";
|
||||
private string treesDir = "Assets/GeoData/trees_tiles";
|
||||
@@ -34,6 +35,7 @@ public class GeoTileImporter : EditorWindow
|
||||
private bool applyOrthoTextures = true;
|
||||
private bool importBuildings = true;
|
||||
private bool useEnhancedBuildings = false;
|
||||
private bool importBuildingsEvenTilesOnly = true;
|
||||
private bool importTrees = true;
|
||||
private bool importFurniture = false;
|
||||
private bool deleteExistingBuildings = false;
|
||||
@@ -43,6 +45,24 @@ public class GeoTileImporter : EditorWindow
|
||||
private bool deleteExistingEnhancedTrees = false;
|
||||
private string enhancedTreesParentName = "Geo_Trees_Enhanced";
|
||||
|
||||
private Vector2 scrollPosition;
|
||||
private float tileKeySizeX = 1000f;
|
||||
private float tileKeySizeY = 1000f;
|
||||
private float tileKeyOverlapX = 0.5f;
|
||||
private float tileKeyOverlapY = 0.5f;
|
||||
private string bottomLeftKey = "";
|
||||
private string topRightKey = "";
|
||||
private readonly List<TileRecord> tileIndexCache = new List<TileRecord>();
|
||||
private readonly List<TileKeyOption> tileKeyOptions = new List<TileKeyOption>();
|
||||
private readonly Dictionary<string, TileKeyOption> tileKeyLookup = new Dictionary<string, TileKeyOption>(StringComparer.OrdinalIgnoreCase);
|
||||
private string[] tileKeyStrings = Array.Empty<string>();
|
||||
private string cachedTileCsvPath = "";
|
||||
private DateTime cachedTileCsvTimeUtc = DateTime.MinValue;
|
||||
private float cachedTileKeySizeX = 0f;
|
||||
private float cachedTileKeySizeY = 0f;
|
||||
private float cachedTileOverlapX = 0f;
|
||||
private float cachedTileOverlapY = 0f;
|
||||
|
||||
// Prefabs for trees and furniture (assign in editor)
|
||||
private GameObject treePrefab;
|
||||
private GameObject lampPrefab;
|
||||
@@ -60,10 +80,13 @@ public class GeoTileImporter : EditorWindow
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition);
|
||||
|
||||
GUILayout.Label("Inputs", EditorStyles.boldLabel);
|
||||
tilesCsvPath = EditorGUILayout.TextField("tile_index.csv", tilesCsvPath);
|
||||
heightmapsDir = EditorGUILayout.TextField("height_png16 dir", heightmapsDir);
|
||||
orthoDir = EditorGUILayout.TextField("ortho_jpg dir", orthoDir);
|
||||
orthoDir = EditorGUILayout.TextField("ortho_jpg dir (primary)", orthoDir);
|
||||
orthoDirFallback = EditorGUILayout.TextField("ortho_jpg dir (fallback)", orthoDirFallback);
|
||||
buildingsDir = EditorGUILayout.TextField("buildings_glb dir", buildingsDir);
|
||||
treesDir = EditorGUILayout.TextField("trees_glb dir", treesDir);
|
||||
treeProxyPath = EditorGUILayout.TextField("tree_proxies.glb", treeProxyPath);
|
||||
@@ -83,6 +106,7 @@ public class GeoTileImporter : EditorWindow
|
||||
deleteExistingBuildings = EditorGUILayout.ToggleLeft("Delete existing buildings under parent", deleteExistingBuildings);
|
||||
importBuildings = EditorGUILayout.ToggleLeft("Import buildings (GLB per tile)", importBuildings);
|
||||
useEnhancedBuildings = EditorGUILayout.ToggleLeft("Use enhanced buildings (from buildings_enhanced/)", useEnhancedBuildings);
|
||||
importBuildingsEvenTilesOnly = EditorGUILayout.ToggleLeft("Import 2km buildings once (even X/Y tiles only)", importBuildingsEvenTilesOnly);
|
||||
|
||||
GUILayout.Space(5);
|
||||
treesParentName = EditorGUILayout.TextField("Trees parent name", treesParentName);
|
||||
@@ -110,15 +134,30 @@ public class GeoTileImporter : EditorWindow
|
||||
bollardPrefab = (GameObject)EditorGUILayout.ObjectField("Bollard Prefab", bollardPrefab, typeof(GameObject), false);
|
||||
defaultFurniturePrefab = (GameObject)EditorGUILayout.ObjectField("Default Furniture Prefab", defaultFurniturePrefab, typeof(GameObject), false);
|
||||
|
||||
GUILayout.Space(12);
|
||||
GUILayout.Label("Tile Key Config", EditorStyles.boldLabel);
|
||||
tileKeySizeX = EditorGUILayout.FloatField("Tile Size X (m)", tileKeySizeX);
|
||||
tileKeySizeY = EditorGUILayout.FloatField("Tile Size Y (m)", tileKeySizeY);
|
||||
tileKeyOverlapX = EditorGUILayout.FloatField("Overlap X (m)", tileKeyOverlapX);
|
||||
tileKeyOverlapY = EditorGUILayout.FloatField("Overlap Y (m)", tileKeyOverlapY);
|
||||
|
||||
RefreshTileIndexCache();
|
||||
|
||||
GUILayout.Space(10);
|
||||
GUILayout.Label("Tile Selection", EditorStyles.boldLabel);
|
||||
DrawTileSelectionUI();
|
||||
|
||||
GUILayout.Space(12);
|
||||
if (GUILayout.Button("Import / Rebuild"))
|
||||
ImportTiles();
|
||||
|
||||
EditorGUILayout.HelpBox(
|
||||
"Creates one Unity Terrain per CSV row and positions tiles on a meter grid.\n" +
|
||||
"Absolute elevation mapping: Terrain Y = tile_min (or global_min), Terrain height = (tile_max - tile_min).\n" +
|
||||
"Absolute elevation mapping: Terrain Y = tile_min (fallback global_min), Terrain height = (tile_max - tile_min).\n" +
|
||||
"CSV is header-driven (order-independent). Optionally applies ortho JPGs and instantiates buildings/trees GLBs.",
|
||||
MessageType.Info);
|
||||
|
||||
EditorGUILayout.EndScrollView();
|
||||
}
|
||||
|
||||
private static void EnsureHeightmapImportSettings(string assetPath)
|
||||
@@ -252,12 +291,19 @@ public class GeoTileImporter : EditorWindow
|
||||
Debug.LogError($"[GeoTileImporter] Heightmap dir not found: {heightmapsDir}");
|
||||
return;
|
||||
}
|
||||
if (applyOrthoTextures && !Directory.Exists(orthoDir))
|
||||
if (applyOrthoTextures)
|
||||
{
|
||||
Debug.LogWarning($"[GeoTileImporter] Ortho dir not found: {orthoDir} (textures will be skipped).");
|
||||
applyOrthoTextures = false;
|
||||
bool primaryExists = Directory.Exists(orthoDir);
|
||||
bool fallbackExists = !string.IsNullOrWhiteSpace(orthoDirFallback) && Directory.Exists(orthoDirFallback);
|
||||
if (!primaryExists && !fallbackExists)
|
||||
{
|
||||
Debug.LogWarning($"[GeoTileImporter] Ortho dirs not found: primary={orthoDir}, fallback={orthoDirFallback} (textures will be skipped).");
|
||||
applyOrthoTextures = false;
|
||||
}
|
||||
}
|
||||
|
||||
RefreshTileIndexCache();
|
||||
|
||||
var parent = GameObject.Find(parentName);
|
||||
if (parent == null) parent = new GameObject(parentName);
|
||||
|
||||
@@ -267,160 +313,58 @@ public class GeoTileImporter : EditorWindow
|
||||
DestroyImmediate(parent.transform.GetChild(i).gameObject);
|
||||
}
|
||||
|
||||
var ci = CultureInfo.InvariantCulture;
|
||||
var lines = File.ReadAllLines(tilesCsvPath);
|
||||
|
||||
Debug.Log($"[GeoTileImporter] Read {lines.Length} lines.");
|
||||
if (lines.Length < 2)
|
||||
var tiles = ParseTilesCsv();
|
||||
if (tiles == null || tiles.Count == 0)
|
||||
{
|
||||
Debug.LogError("[GeoTileImporter] CSV has no data rows (need header + at least 1 row).");
|
||||
Debug.LogError("[GeoTileImporter] No valid tiles found in CSV.");
|
||||
return;
|
||||
}
|
||||
|
||||
var headerLine = lines[0].Trim();
|
||||
var headerMap = BuildHeaderMap(headerLine);
|
||||
Debug.Log($"[GeoTileImporter] Header: {headerLine}");
|
||||
Debug.Log($"[GeoTileImporter] Header columns mapped: {string.Join(", ", headerMap.Keys)}");
|
||||
|
||||
// Required columns (order-independent)
|
||||
string[] required = { "tile_id", "xmin", "ymin", "global_min", "global_max", "out_res" };
|
||||
if (!HasAll(headerMap, required))
|
||||
var selectedTiles = ApplySelection(tiles);
|
||||
if (selectedTiles.Count == 0)
|
||||
{
|
||||
Debug.LogError("[GeoTileImporter] CSV missing required columns. Required: " +
|
||||
string.Join(", ", required) +
|
||||
"\nFound: " + string.Join(", ", headerMap.Keys));
|
||||
Debug.LogError("[GeoTileImporter] Selection is empty. Adjust Tile Selection and try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
int IDX_TILE = headerMap["tile_id"];
|
||||
int IDX_XMIN = headerMap["xmin"];
|
||||
int IDX_YMIN = headerMap["ymin"];
|
||||
int IDX_GMIN = headerMap["global_min"];
|
||||
int IDX_GMAX = headerMap["global_max"];
|
||||
int IDX_RES = headerMap["out_res"];
|
||||
bool hasTileMin = headerMap.TryGetValue("tile_min", out int IDX_TMIN);
|
||||
bool hasTileMax = headerMap.TryGetValue("tile_max", out int IDX_TMAX);
|
||||
bool useTileRange = hasTileMin && hasTileMax;
|
||||
|
||||
// Compute origin from min xmin/ymin
|
||||
double originX = double.PositiveInfinity;
|
||||
double originY = double.PositiveInfinity;
|
||||
|
||||
int validRowsForOrigin = 0;
|
||||
for (int i = 1; i < lines.Length; i++)
|
||||
for (int i = 0; i < selectedTiles.Count; i++)
|
||||
{
|
||||
var line = lines[i].Trim();
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
|
||||
var parts = line.Split(',');
|
||||
// Robust: just ensure indices exist in this row
|
||||
int needMaxIndex = Math.Max(Math.Max(Math.Max(Math.Max(IDX_TILE, IDX_XMIN), IDX_YMIN), IDX_GMIN), Math.Max(IDX_GMAX, IDX_RES));
|
||||
if (useTileRange)
|
||||
needMaxIndex = Math.Max(needMaxIndex, Math.Max(IDX_TMIN, IDX_TMAX));
|
||||
if (parts.Length <= needMaxIndex)
|
||||
{
|
||||
Debug.LogWarning($"[GeoTileImporter] Origin scan: skipping line {i + 1} (too few columns: {parts.Length}). Line: '{line}'");
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
double xmin = double.Parse(parts[IDX_XMIN], ci);
|
||||
double ymin = double.Parse(parts[IDX_YMIN], ci);
|
||||
originX = Math.Min(originX, xmin);
|
||||
originY = Math.Min(originY, ymin);
|
||||
validRowsForOrigin++;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogWarning($"[GeoTileImporter] Origin scan parse failed line {i + 1}: '{line}'\n{e.Message}");
|
||||
}
|
||||
originX = Math.Min(originX, selectedTiles[i].Xmin);
|
||||
originY = Math.Min(originY, selectedTiles[i].Ymin);
|
||||
}
|
||||
|
||||
if (validRowsForOrigin == 0 || double.IsInfinity(originX) || double.IsInfinity(originY))
|
||||
if (double.IsInfinity(originX) || double.IsInfinity(originY))
|
||||
{
|
||||
Debug.LogError("[GeoTileImporter] Could not compute origin (no valid rows parsed). Check CSV numeric format.");
|
||||
return;
|
||||
}
|
||||
|
||||
Debug.Log($"[GeoTileImporter] Origin: ({originX}, {originY}) from {validRowsForOrigin} valid rows.");
|
||||
Debug.Log($"[GeoTileImporter] Origin: ({originX}, {originY}) from {selectedTiles.Count} selected tiles.");
|
||||
|
||||
int imported = 0, skipped = 0;
|
||||
int importedTextures = 0;
|
||||
var placements = new List<(string tileId, float ux, float uz, float baseMin)>();
|
||||
var placements = new List<(string tileId, float ux, float uz, float baseY)>();
|
||||
|
||||
for (int i = 1; i < lines.Length; i++)
|
||||
for (int i = 0; i < selectedTiles.Count; i++)
|
||||
{
|
||||
var line = lines[i].Trim();
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
var tile = selectedTiles[i];
|
||||
var tileId = tile.TileId;
|
||||
double xmin = tile.Xmin;
|
||||
double ymin = tile.Ymin;
|
||||
double baseMin = tile.TileMin;
|
||||
double baseMax = tile.TileMax;
|
||||
|
||||
var parts = line.Split(',');
|
||||
int needMaxIndex = Math.Max(Math.Max(Math.Max(Math.Max(IDX_TILE, IDX_XMIN), IDX_YMIN), IDX_GMIN), Math.Max(IDX_GMAX, IDX_RES));
|
||||
if (useTileRange)
|
||||
needMaxIndex = Math.Max(needMaxIndex, Math.Max(IDX_TMIN, IDX_TMAX));
|
||||
if (parts.Length <= needMaxIndex)
|
||||
{
|
||||
skipped++;
|
||||
Debug.LogWarning($"[GeoTileImporter] Skipping line {i + 1} (too few columns: {parts.Length}). Line: '{line}'");
|
||||
continue;
|
||||
}
|
||||
if (tile.OutRes != heightmapResolution)
|
||||
Debug.LogWarning($"[GeoTileImporter] Tile {tileId}: out_res={tile.OutRes} but importer expects {heightmapResolution}.");
|
||||
|
||||
string tileId = parts[IDX_TILE].Trim();
|
||||
|
||||
double xmin, ymin, gmin, gmax;
|
||||
double tileMin = 0.0;
|
||||
double tileMax = 0.0;
|
||||
bool tileRangeValid = false;
|
||||
int outRes;
|
||||
try
|
||||
{
|
||||
xmin = double.Parse(parts[IDX_XMIN], ci);
|
||||
ymin = double.Parse(parts[IDX_YMIN], ci);
|
||||
gmin = double.Parse(parts[IDX_GMIN], ci);
|
||||
gmax = double.Parse(parts[IDX_GMAX], ci);
|
||||
outRes = int.Parse(parts[IDX_RES], ci);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
skipped++;
|
||||
Debug.LogWarning($"[GeoTileImporter] Parse failed line {i + 1} tile '{tileId}': {e.Message}\nLine: '{line}'");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (useTileRange)
|
||||
{
|
||||
if (double.TryParse(parts[IDX_TMIN], NumberStyles.Float, ci, out double tmin) &&
|
||||
double.TryParse(parts[IDX_TMAX], NumberStyles.Float, ci, out double tmax))
|
||||
{
|
||||
tileMin = tmin;
|
||||
tileMax = tmax;
|
||||
tileRangeValid = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"[GeoTileImporter] Tile {tileId}: invalid tile_min/tile_max; falling back to global range.");
|
||||
}
|
||||
}
|
||||
|
||||
if (outRes != heightmapResolution)
|
||||
Debug.LogWarning($"[GeoTileImporter] Tile {tileId}: out_res={outRes} but importer expects {heightmapResolution}.");
|
||||
|
||||
double baseMin = tileRangeValid ? tileMin : gmin;
|
||||
double baseMax = tileRangeValid ? tileMax : gmax;
|
||||
float heightRange = (float)(baseMax - baseMin);
|
||||
if (heightRange <= 0.0001f)
|
||||
{
|
||||
if (tileRangeValid)
|
||||
{
|
||||
Debug.LogWarning($"[GeoTileImporter] Tile {tileId}: flat tile range; using epsilon height range.");
|
||||
heightRange = 0.001f;
|
||||
}
|
||||
else
|
||||
{
|
||||
skipped++;
|
||||
Debug.LogWarning($"[GeoTileImporter] Tile {tileId}: invalid height range (global_max <= global_min). Skipping.");
|
||||
continue;
|
||||
}
|
||||
skipped++;
|
||||
Debug.LogWarning($"[GeoTileImporter] Tile {tileId}: invalid height range (tile_max <= tile_min). Skipping.");
|
||||
continue;
|
||||
}
|
||||
|
||||
string pngPath = Path.Combine(heightmapsDir, $"{tileId}.png").Replace("\\", "/");
|
||||
@@ -496,6 +440,12 @@ public class GeoTileImporter : EditorWindow
|
||||
if (applyOrthoTextures)
|
||||
{
|
||||
string orthoPath = Path.Combine(orthoDir, $"{tileId}.jpg").Replace("\\", "/");
|
||||
if (!File.Exists(orthoPath) && !string.IsNullOrWhiteSpace(orthoDirFallback))
|
||||
{
|
||||
string fallbackPath = Path.Combine(orthoDirFallback, $"{tileId}.jpg").Replace("\\", "/");
|
||||
if (File.Exists(fallbackPath))
|
||||
orthoPath = fallbackPath;
|
||||
}
|
||||
if (File.Exists(orthoPath))
|
||||
{
|
||||
EnsureOrthoImportSettings(orthoPath);
|
||||
@@ -528,7 +478,7 @@ public class GeoTileImporter : EditorWindow
|
||||
}
|
||||
}
|
||||
|
||||
Debug.Log($"[GeoTileImporter] Imported {tileId} @ XZ=({ux},{uz}) Y={baseMin} heightRange={heightRange} usedU16={usedU16}");
|
||||
Debug.Log($"[GeoTileImporter] Imported {tileId} ({tile.TileKey}) @ XZ=({ux},{uz}) Y={baseMin} heightRange={heightRange} usedU16={usedU16}");
|
||||
imported++;
|
||||
placements.Add((tileId, ux, uz, (float)baseMin));
|
||||
}
|
||||
@@ -544,7 +494,384 @@ public class GeoTileImporter : EditorWindow
|
||||
ImportEnhancedTrees(placements);
|
||||
}
|
||||
|
||||
private void ImportBuildings(List<(string tileId, float ux, float uz, float baseMin)> placements)
|
||||
private void RefreshTileIndexCache()
|
||||
{
|
||||
if (!File.Exists(tilesCsvPath))
|
||||
{
|
||||
tileIndexCache.Clear();
|
||||
tileKeyOptions.Clear();
|
||||
tileKeyLookup.Clear();
|
||||
tileKeyStrings = Array.Empty<string>();
|
||||
return;
|
||||
}
|
||||
|
||||
var writeTime = File.GetLastWriteTimeUtc(tilesCsvPath);
|
||||
if (tilesCsvPath == cachedTileCsvPath &&
|
||||
writeTime == cachedTileCsvTimeUtc &&
|
||||
Mathf.Approximately(tileKeySizeX, cachedTileKeySizeX) &&
|
||||
Mathf.Approximately(tileKeySizeY, cachedTileKeySizeY) &&
|
||||
Mathf.Approximately(tileKeyOverlapX, cachedTileOverlapX) &&
|
||||
Mathf.Approximately(tileKeyOverlapY, cachedTileOverlapY))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
tileIndexCache.Clear();
|
||||
tileIndexCache.AddRange(ParseTilesCsv());
|
||||
BuildTileKeyOptions();
|
||||
|
||||
cachedTileCsvPath = tilesCsvPath;
|
||||
cachedTileCsvTimeUtc = writeTime;
|
||||
cachedTileKeySizeX = tileKeySizeX;
|
||||
cachedTileKeySizeY = tileKeySizeY;
|
||||
cachedTileOverlapX = tileKeyOverlapX;
|
||||
cachedTileOverlapY = tileKeyOverlapY;
|
||||
}
|
||||
|
||||
private void BuildTileKeyOptions()
|
||||
{
|
||||
tileKeyOptions.Clear();
|
||||
tileKeyLookup.Clear();
|
||||
|
||||
for (int i = 0; i < tileIndexCache.Count; i++)
|
||||
{
|
||||
var tile = tileIndexCache[i];
|
||||
if (string.IsNullOrWhiteSpace(tile.TileKey))
|
||||
continue;
|
||||
|
||||
if (tileKeyLookup.ContainsKey(tile.TileKey))
|
||||
continue;
|
||||
|
||||
var option = new TileKeyOption
|
||||
{
|
||||
Key = tile.TileKey,
|
||||
X = tile.XKey,
|
||||
Y = tile.YKey
|
||||
};
|
||||
|
||||
tileKeyOptions.Add(option);
|
||||
tileKeyLookup[tile.TileKey] = option;
|
||||
}
|
||||
|
||||
tileKeyOptions.Sort((a, b) =>
|
||||
{
|
||||
int cmp = a.X.CompareTo(b.X);
|
||||
return cmp != 0 ? cmp : a.Y.CompareTo(b.Y);
|
||||
});
|
||||
|
||||
tileKeyStrings = new string[tileKeyOptions.Count];
|
||||
for (int i = 0; i < tileKeyOptions.Count; i++)
|
||||
tileKeyStrings[i] = tileKeyOptions[i].Key;
|
||||
}
|
||||
|
||||
private void DrawTileSelectionUI()
|
||||
{
|
||||
if (tileKeyOptions.Count == 0)
|
||||
{
|
||||
EditorGUILayout.HelpBox("No tile keys available. Check the tile index CSV and key config.", MessageType.Info);
|
||||
EditorGUILayout.LabelField("Tiles in selection", "0");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(bottomLeftKey) || !tileKeyLookup.ContainsKey(bottomLeftKey))
|
||||
bottomLeftKey = tileKeyOptions[0].Key;
|
||||
|
||||
var bottomLeft = tileKeyLookup[bottomLeftKey];
|
||||
var topRightOptions = GetTopRightOptions(bottomLeft);
|
||||
if (topRightOptions.Count == 0)
|
||||
topRightOptions.Add(bottomLeft);
|
||||
|
||||
if (string.IsNullOrEmpty(topRightKey) || !ContainsKey(topRightOptions, topRightKey))
|
||||
topRightKey = topRightOptions[topRightOptions.Count - 1].Key;
|
||||
|
||||
int bottomLeftIndex = Array.IndexOf(tileKeyStrings, bottomLeftKey);
|
||||
int newBottomLeftIndex = EditorGUILayout.Popup("Bottom-Left Key", bottomLeftIndex, tileKeyStrings);
|
||||
if (newBottomLeftIndex != bottomLeftIndex)
|
||||
{
|
||||
bottomLeftKey = tileKeyStrings[newBottomLeftIndex];
|
||||
bottomLeft = tileKeyLookup[bottomLeftKey];
|
||||
topRightOptions = GetTopRightOptions(bottomLeft);
|
||||
if (topRightOptions.Count == 0)
|
||||
topRightOptions.Add(bottomLeft);
|
||||
|
||||
if (!ContainsKey(topRightOptions, topRightKey))
|
||||
topRightKey = topRightOptions[topRightOptions.Count - 1].Key;
|
||||
}
|
||||
|
||||
var topRightKeys = BuildKeyArray(topRightOptions);
|
||||
int topRightIndex = Array.IndexOf(topRightKeys, topRightKey);
|
||||
if (topRightIndex < 0)
|
||||
topRightIndex = topRightKeys.Length - 1;
|
||||
|
||||
int newTopRightIndex = EditorGUILayout.Popup("Top-Right Key", topRightIndex, topRightKeys);
|
||||
if (newTopRightIndex != topRightIndex)
|
||||
topRightKey = topRightKeys[newTopRightIndex];
|
||||
|
||||
EditorGUILayout.LabelField("Tiles in selection", CountSelectedTiles().ToString());
|
||||
}
|
||||
|
||||
private List<TileKeyOption> GetTopRightOptions(TileKeyOption bottomLeft)
|
||||
{
|
||||
var options = new List<TileKeyOption>();
|
||||
for (int i = 0; i < tileKeyOptions.Count; i++)
|
||||
{
|
||||
var option = tileKeyOptions[i];
|
||||
if (option.X >= bottomLeft.X && option.Y >= bottomLeft.Y)
|
||||
options.Add(option);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
private int CountSelectedTiles()
|
||||
{
|
||||
if (!tileKeyLookup.TryGetValue(bottomLeftKey, out var bottomLeft) ||
|
||||
!tileKeyLookup.TryGetValue(topRightKey, out var topRight))
|
||||
return 0;
|
||||
|
||||
int count = 0;
|
||||
for (int i = 0; i < tileIndexCache.Count; i++)
|
||||
{
|
||||
var tile = tileIndexCache[i];
|
||||
if (tile.XKey >= bottomLeft.X && tile.XKey <= topRight.X &&
|
||||
tile.YKey >= bottomLeft.Y && tile.YKey <= topRight.Y)
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private List<TileRecord> ApplySelection(List<TileRecord> tiles)
|
||||
{
|
||||
if (!tileKeyLookup.TryGetValue(bottomLeftKey, out var bottomLeft) ||
|
||||
!tileKeyLookup.TryGetValue(topRightKey, out var topRight))
|
||||
return tiles;
|
||||
|
||||
var selected = new List<TileRecord>();
|
||||
for (int i = 0; i < tiles.Count; i++)
|
||||
{
|
||||
var tile = tiles[i];
|
||||
if (tile.XKey >= bottomLeft.X && tile.XKey <= topRight.X &&
|
||||
tile.YKey >= bottomLeft.Y && tile.YKey <= topRight.Y)
|
||||
selected.Add(tile);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
private List<TileRecord> ParseTilesCsv()
|
||||
{
|
||||
var tiles = new List<TileRecord>();
|
||||
var ci = CultureInfo.InvariantCulture;
|
||||
var lines = File.ReadAllLines(tilesCsvPath);
|
||||
|
||||
if (lines.Length < 2)
|
||||
{
|
||||
Debug.LogError("[GeoTileImporter] CSV has no data rows (need header + at least 1 row).");
|
||||
return tiles;
|
||||
}
|
||||
|
||||
var headerLine = lines[0].Trim();
|
||||
var headerMap = BuildHeaderMap(headerLine);
|
||||
Debug.Log($"[GeoTileImporter] Header columns mapped: {string.Join(", ", headerMap.Keys)}");
|
||||
|
||||
string[] required = { "tile_id", "xmin", "ymin", "global_min", "global_max", "out_res" };
|
||||
if (!HasAll(headerMap, required))
|
||||
{
|
||||
Debug.LogError("[GeoTileImporter] CSV missing required columns. Required: " +
|
||||
string.Join(", ", required) +
|
||||
"\nFound: " + string.Join(", ", headerMap.Keys));
|
||||
return tiles;
|
||||
}
|
||||
|
||||
int IDX_TILE = headerMap["tile_id"];
|
||||
int IDX_XMIN = headerMap["xmin"];
|
||||
int IDX_YMIN = headerMap["ymin"];
|
||||
int IDX_GMIN = headerMap["global_min"];
|
||||
int IDX_GMAX = headerMap["global_max"];
|
||||
int IDX_RES = headerMap["out_res"];
|
||||
int IDX_TMIN = headerMap.ContainsKey("tile_min") ? headerMap["tile_min"] : -1;
|
||||
int IDX_TMAX = headerMap.ContainsKey("tile_max") ? headerMap["tile_max"] : -1;
|
||||
int IDX_TILE_KEY = headerMap.ContainsKey("tile_key") ? headerMap["tile_key"] : -1;
|
||||
|
||||
for (int i = 1; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i].Trim();
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
continue;
|
||||
|
||||
var parts = line.Split(',');
|
||||
int needMaxIndex = Math.Max(Math.Max(Math.Max(Math.Max(IDX_TILE, IDX_XMIN), IDX_YMIN), IDX_GMIN), Math.Max(IDX_GMAX, IDX_RES));
|
||||
if (IDX_TILE_KEY >= 0)
|
||||
needMaxIndex = Math.Max(needMaxIndex, IDX_TILE_KEY);
|
||||
if (IDX_TMIN >= 0)
|
||||
needMaxIndex = Math.Max(needMaxIndex, IDX_TMIN);
|
||||
if (IDX_TMAX >= 0)
|
||||
needMaxIndex = Math.Max(needMaxIndex, IDX_TMAX);
|
||||
|
||||
if (parts.Length <= needMaxIndex)
|
||||
{
|
||||
Debug.LogWarning($"[GeoTileImporter] Skipping line {i + 1} (too few columns: {parts.Length}).");
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var xmin = double.Parse(parts[IDX_XMIN], ci);
|
||||
var ymin = double.Parse(parts[IDX_YMIN], ci);
|
||||
var tileKeyRaw = IDX_TILE_KEY >= 0 ? parts[IDX_TILE_KEY].Trim() : "";
|
||||
var tileKey = ResolveTileKey(tileKeyRaw, xmin, ymin, out var xKey, out var yKey);
|
||||
var globalMin = double.Parse(parts[IDX_GMIN], ci);
|
||||
var globalMax = double.Parse(parts[IDX_GMAX], ci);
|
||||
double tileMin = globalMin;
|
||||
double tileMax = globalMax;
|
||||
if (IDX_TMIN >= 0 && IDX_TMIN < parts.Length &&
|
||||
double.TryParse(parts[IDX_TMIN], NumberStyles.Float, ci, out var parsedTileMin))
|
||||
tileMin = parsedTileMin;
|
||||
if (IDX_TMAX >= 0 && IDX_TMAX < parts.Length &&
|
||||
double.TryParse(parts[IDX_TMAX], NumberStyles.Float, ci, out var parsedTileMax))
|
||||
tileMax = parsedTileMax;
|
||||
|
||||
tiles.Add(new TileRecord
|
||||
{
|
||||
TileId = parts[IDX_TILE].Trim(),
|
||||
TileKey = tileKey,
|
||||
XKey = xKey,
|
||||
YKey = yKey,
|
||||
Xmin = xmin,
|
||||
Ymin = ymin,
|
||||
GlobalMin = globalMin,
|
||||
GlobalMax = globalMax,
|
||||
TileMin = tileMin,
|
||||
TileMax = tileMax,
|
||||
OutRes = int.Parse(parts[IDX_RES], ci)
|
||||
});
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogWarning($"[GeoTileImporter] Parse error line {i + 1}: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return tiles;
|
||||
}
|
||||
|
||||
private string ResolveTileKey(string tileKeyRaw, double xmin, double ymin, out int xKey, out int yKey)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(tileKeyRaw) && TryParseTileKey(tileKeyRaw, out xKey, out yKey))
|
||||
return tileKeyRaw;
|
||||
|
||||
float sizeX = tileKeySizeX <= 0f ? 1f : tileKeySizeX;
|
||||
float sizeY = tileKeySizeY <= 0f ? 1f : tileKeySizeY;
|
||||
xKey = (int)Math.Floor((xmin + tileKeyOverlapX) / sizeX);
|
||||
yKey = (int)Math.Floor((ymin + tileKeyOverlapY) / sizeY);
|
||||
return $"{xKey}_{yKey}";
|
||||
}
|
||||
|
||||
private static bool TryParseTileKey(string tileKey, out int xKey, out int yKey)
|
||||
{
|
||||
xKey = 0;
|
||||
yKey = 0;
|
||||
if (string.IsNullOrWhiteSpace(tileKey))
|
||||
return false;
|
||||
|
||||
var parts = tileKey.Split('_');
|
||||
if (parts.Length != 2)
|
||||
return false;
|
||||
|
||||
return int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out xKey)
|
||||
&& int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out yKey);
|
||||
}
|
||||
|
||||
private static bool ContainsKey(List<TileKeyOption> options, string key)
|
||||
{
|
||||
for (int i = 0; i < options.Count; i++)
|
||||
{
|
||||
if (string.Equals(options[i].Key, key, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string[] BuildKeyArray(List<TileKeyOption> options)
|
||||
{
|
||||
var keys = new string[options.Count];
|
||||
for (int i = 0; i < options.Count; i++)
|
||||
keys[i] = options[i].Key;
|
||||
return keys;
|
||||
}
|
||||
|
||||
private struct TileRecord
|
||||
{
|
||||
public string TileId;
|
||||
public string TileKey;
|
||||
public int XKey;
|
||||
public int YKey;
|
||||
public double Xmin;
|
||||
public double Ymin;
|
||||
public double GlobalMin;
|
||||
public double GlobalMax;
|
||||
public double TileMin;
|
||||
public double TileMax;
|
||||
public int OutRes;
|
||||
}
|
||||
|
||||
private struct TileKeyOption
|
||||
{
|
||||
public string Key;
|
||||
public int X;
|
||||
public int Y;
|
||||
}
|
||||
|
||||
private static bool TryGetTileXY(string tileId, out int x, out int y)
|
||||
{
|
||||
x = 0;
|
||||
y = 0;
|
||||
if (string.IsNullOrWhiteSpace(tileId))
|
||||
return false;
|
||||
|
||||
var parts = tileId.Split('_');
|
||||
var coords = new List<int>();
|
||||
for (int i = 0; i < parts.Length; i++)
|
||||
{
|
||||
string part = parts[i];
|
||||
if (part.Length < 3)
|
||||
continue;
|
||||
|
||||
bool allDigits = true;
|
||||
for (int j = 0; j < part.Length; j++)
|
||||
{
|
||||
if (!char.IsDigit(part[j]))
|
||||
{
|
||||
allDigits = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!allDigits)
|
||||
continue;
|
||||
|
||||
if (int.TryParse(part, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value))
|
||||
coords.Add(value);
|
||||
}
|
||||
|
||||
if (coords.Count < 2)
|
||||
return false;
|
||||
|
||||
x = coords[coords.Count - 2];
|
||||
y = coords[coords.Count - 1];
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ShouldImportBuildingsForTile(string tileId, bool evenTilesOnly)
|
||||
{
|
||||
if (!evenTilesOnly)
|
||||
return true;
|
||||
|
||||
if (!TryGetTileXY(tileId, out int x, out int y))
|
||||
return true;
|
||||
|
||||
return (x % 2 == 0) && (y % 2 == 0);
|
||||
}
|
||||
|
||||
private void ImportBuildings(List<(string tileId, float ux, float uz, float baseY)> placements)
|
||||
{
|
||||
if (!importBuildings)
|
||||
return;
|
||||
@@ -567,9 +894,15 @@ public class GeoTileImporter : EditorWindow
|
||||
DestroyImmediate(parent.transform.GetChild(i).gameObject);
|
||||
}
|
||||
|
||||
int imported = 0, missing = 0;
|
||||
foreach (var (tileId, ux, uz, baseMin) in placements)
|
||||
int imported = 0, missing = 0, skipped = 0;
|
||||
foreach (var (tileId, ux, uz, baseY) in placements)
|
||||
{
|
||||
if (!ShouldImportBuildingsForTile(tileId, importBuildingsEvenTilesOnly))
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
string glbPath = Path.Combine(activeDir, $"{tileId}.glb").Replace("\\", "/");
|
||||
if (!File.Exists(glbPath))
|
||||
{
|
||||
@@ -586,19 +919,25 @@ public class GeoTileImporter : EditorWindow
|
||||
continue;
|
||||
}
|
||||
|
||||
var tileContainer = new GameObject($"Buildings_{tileId}");
|
||||
tileContainer.transform.SetParent(parent.transform, false);
|
||||
tileContainer.transform.position = new Vector3(ux, baseY, uz);
|
||||
tileContainer.isStatic = true;
|
||||
|
||||
var inst = PrefabUtility.InstantiatePrefab(prefab) as GameObject ?? Instantiate(prefab);
|
||||
inst.name = tileId;
|
||||
inst.transform.SetParent(parent.transform, false);
|
||||
inst.transform.position = new Vector3(ux, baseMin, uz);
|
||||
inst.transform.SetParent(tileContainer.transform, false);
|
||||
// GLB vertices store absolute elevation; offset by -baseY to keep world Y absolute.
|
||||
inst.transform.localPosition = new Vector3(0f, -baseY, 0f);
|
||||
inst.transform.localRotation = Quaternion.Euler(0f, 180f, 0f);
|
||||
inst.isStatic = true;
|
||||
imported++;
|
||||
}
|
||||
|
||||
Debug.Log($"[GeoTileImporter] Buildings ({sourceLabel}) imported={imported}, missing/failed={missing} under '{buildingsParentName}'.");
|
||||
Debug.Log($"[GeoTileImporter] Buildings ({sourceLabel}) imported={imported}, skipped={skipped}, missing/failed={missing} under '{buildingsParentName}'.");
|
||||
}
|
||||
|
||||
private void ImportTrees(List<(string tileId, float ux, float uz, float baseMin)> placements)
|
||||
private void ImportTrees(List<(string tileId, float ux, float uz, float baseY)> placements)
|
||||
{
|
||||
if (!importTrees)
|
||||
return;
|
||||
@@ -623,7 +962,7 @@ public class GeoTileImporter : EditorWindow
|
||||
}
|
||||
|
||||
int importedTiles = 0, importedChunks = 0, missingTiles = 0;
|
||||
foreach (var (tileId, ux, uz, baseMin) in placements)
|
||||
foreach (var (tileId, ux, uz, baseY) in placements)
|
||||
{
|
||||
// Look for chunk files: {tileId}_0_0.glb, {tileId}_0_1.glb, etc.
|
||||
// Standard tree export creates 4x4 chunks per tile
|
||||
@@ -647,7 +986,7 @@ public class GeoTileImporter : EditorWindow
|
||||
// Create container for this tile's tree chunks
|
||||
var tileContainer = new GameObject($"Trees_{tileId}");
|
||||
tileContainer.transform.SetParent(parent.transform, false);
|
||||
tileContainer.transform.position = new Vector3(ux, baseMin, uz);
|
||||
tileContainer.transform.position = new Vector3(ux, baseY, uz);
|
||||
tileContainer.transform.localRotation = Quaternion.Euler(0f, 180f, 0f);
|
||||
tileContainer.isStatic = true;
|
||||
|
||||
@@ -664,7 +1003,8 @@ public class GeoTileImporter : EditorWindow
|
||||
var inst = PrefabUtility.InstantiatePrefab(prefab) as GameObject ?? Instantiate(prefab);
|
||||
inst.name = Path.GetFileNameWithoutExtension(chunkPath);
|
||||
inst.transform.SetParent(tileContainer.transform, false);
|
||||
inst.transform.localPosition = Vector3.zero; // Chunks already have correct local positions
|
||||
// GLB vertices store absolute elevation; offset by -baseY to keep world Y absolute.
|
||||
inst.transform.localPosition = new Vector3(0f, -baseY, 0f);
|
||||
inst.isStatic = true;
|
||||
importedChunks++;
|
||||
}
|
||||
@@ -675,7 +1015,7 @@ public class GeoTileImporter : EditorWindow
|
||||
Debug.Log($"[GeoTileImporter] Trees imported: {importedTiles} tiles, {importedChunks} chunks, {missingTiles} missing under '{treesParentName}'.");
|
||||
}
|
||||
|
||||
private void ImportFurniture(List<(string tileId, float ux, float uz, float baseMin)> placements)
|
||||
private void ImportFurniture(List<(string tileId, float ux, float uz, float baseY)> placements)
|
||||
{
|
||||
if (!importFurniture)
|
||||
return;
|
||||
@@ -697,7 +1037,7 @@ public class GeoTileImporter : EditorWindow
|
||||
int imported = 0, skipped = 0;
|
||||
var ci = CultureInfo.InvariantCulture;
|
||||
|
||||
foreach (var (tileId, ux, uz, baseMin) in placements)
|
||||
foreach (var (tileId, ux, uz, baseY) in placements)
|
||||
{
|
||||
string csvPath = Path.Combine(furnitureDir, $"{tileId}.csv").Replace("\\", "/");
|
||||
if (!File.Exists(csvPath))
|
||||
@@ -731,7 +1071,7 @@ public class GeoTileImporter : EditorWindow
|
||||
// Create tile container
|
||||
var tileContainer = new GameObject($"Furniture_{tileId}");
|
||||
tileContainer.transform.SetParent(parent.transform, false);
|
||||
tileContainer.transform.position = new Vector3(ux, baseMin, uz);
|
||||
tileContainer.transform.position = new Vector3(ux, baseY, uz);
|
||||
tileContainer.isStatic = true;
|
||||
|
||||
for (int i = 1; i < lines.Length; i++)
|
||||
@@ -802,7 +1142,7 @@ public class GeoTileImporter : EditorWindow
|
||||
}
|
||||
|
||||
obj.transform.SetParent(tileContainer.transform, false);
|
||||
obj.transform.localPosition = new Vector3(xLocal, zGround - baseMin, yLocal);
|
||||
obj.transform.localPosition = new Vector3(xLocal, zGround - baseY, yLocal);
|
||||
obj.isStatic = true;
|
||||
imported++;
|
||||
}
|
||||
@@ -817,7 +1157,7 @@ public class GeoTileImporter : EditorWindow
|
||||
Debug.Log($"[GeoTileImporter] Furniture imported={imported}, skipped={skipped} under '{furnitureParentName}'.");
|
||||
}
|
||||
|
||||
private void ImportEnhancedTrees(List<(string tileId, float ux, float uz, float baseMin)> placements)
|
||||
private void ImportEnhancedTrees(List<(string tileId, float ux, float uz, float baseY)> placements)
|
||||
{
|
||||
if (!importEnhancedTrees)
|
||||
return;
|
||||
@@ -839,7 +1179,7 @@ public class GeoTileImporter : EditorWindow
|
||||
int imported = 0, skipped = 0;
|
||||
var ci = CultureInfo.InvariantCulture;
|
||||
|
||||
foreach (var (tileId, ux, uz, baseMin) in placements)
|
||||
foreach (var (tileId, ux, uz, baseY) in placements)
|
||||
{
|
||||
string csvPath = Path.Combine(enhancedTreesDir, $"{tileId}.csv").Replace("\\", "/");
|
||||
if (!File.Exists(csvPath))
|
||||
@@ -877,7 +1217,7 @@ public class GeoTileImporter : EditorWindow
|
||||
// Create tile container
|
||||
var tileContainer = new GameObject($"Trees_{tileId}");
|
||||
tileContainer.transform.SetParent(parent.transform, false);
|
||||
tileContainer.transform.position = new Vector3(ux, baseMin, uz);
|
||||
tileContainer.transform.position = new Vector3(ux, baseY, uz);
|
||||
tileContainer.isStatic = true;
|
||||
|
||||
for (int i = 1; i < lines.Length; i++)
|
||||
@@ -975,7 +1315,7 @@ public class GeoTileImporter : EditorWindow
|
||||
}
|
||||
|
||||
treeObj.transform.SetParent(tileContainer.transform, false);
|
||||
treeObj.transform.localPosition = new Vector3(xLocal, zGround - baseMin, yLocal);
|
||||
treeObj.transform.localPosition = new Vector3(xLocal, zGround - baseY, yLocal);
|
||||
treeObj.isStatic = true;
|
||||
imported++;
|
||||
}
|
||||
|
||||
@@ -15,14 +15,18 @@ public class GeoTilePrefabImporter : EditorWindow
|
||||
private string tilesCsvPath = "Assets/GeoData/tile_index.csv";
|
||||
private string heightmapsDir = "Assets/GeoData/height_png16";
|
||||
private string orthoDir = "Assets/GeoData/ortho_jpg";
|
||||
private string orthoDirFallback = "Assets/GeoData/ortho_jpg_river";
|
||||
private string buildingsDir = "Assets/GeoData/buildings_tiles";
|
||||
private string treesDir = "Assets/GeoData/trees_tiles";
|
||||
private string furnitureDir = "Assets/GeoData/street_furniture";
|
||||
private string enhancedTreesDir = "Assets/GeoData/trees_enhanced";
|
||||
|
||||
// Output settings
|
||||
private string prefabOutputDir = "Assets/GeoData/TilePrefabs";
|
||||
private string prefabOutputDir = "Assets/TilePrefabs";
|
||||
private bool overwriteExisting = false;
|
||||
private string buildingPrefabsDir = "Assets/TilePrefabs_Buildings";
|
||||
private bool exportBuildingPrefabs = true;
|
||||
private bool overwriteBuildingPrefabs = false;
|
||||
|
||||
// Terrain settings
|
||||
private float tileSizeMeters = 1000f;
|
||||
@@ -31,10 +35,29 @@ public class GeoTilePrefabImporter : EditorWindow
|
||||
// Component toggles
|
||||
private bool applyOrthoTextures = true;
|
||||
private bool includeBuildings = true;
|
||||
private bool includeBuildingsEvenTilesOnly = true;
|
||||
private bool includeTrees = true;
|
||||
private bool includeFurniture = false;
|
||||
private bool includeEnhancedTrees = false;
|
||||
|
||||
private Vector2 scrollPosition;
|
||||
private float tileKeySizeX = 1000f;
|
||||
private float tileKeySizeY = 1000f;
|
||||
private float tileKeyOverlapX = 0.5f;
|
||||
private float tileKeyOverlapY = 0.5f;
|
||||
private string bottomLeftKey = "";
|
||||
private string topRightKey = "";
|
||||
private readonly List<TileMetadata> tileIndexCache = new List<TileMetadata>();
|
||||
private readonly List<TileKeyOption> tileKeyOptions = new List<TileKeyOption>();
|
||||
private readonly Dictionary<string, TileKeyOption> tileKeyLookup = new Dictionary<string, TileKeyOption>(StringComparer.OrdinalIgnoreCase);
|
||||
private string[] tileKeyStrings = Array.Empty<string>();
|
||||
private string cachedTileCsvPath = "";
|
||||
private DateTime cachedTileCsvTimeUtc = DateTime.MinValue;
|
||||
private float cachedTileKeySizeX = 0f;
|
||||
private float cachedTileKeySizeY = 0f;
|
||||
private float cachedTileOverlapX = 0f;
|
||||
private float cachedTileOverlapY = 0f;
|
||||
|
||||
// Prefabs for furniture (optional)
|
||||
private GameObject lampPrefab;
|
||||
private GameObject benchPrefab;
|
||||
@@ -45,11 +68,13 @@ public class GeoTilePrefabImporter : EditorWindow
|
||||
|
||||
private struct TileMetadata
|
||||
{
|
||||
public string TileKey;
|
||||
public string TileId;
|
||||
public int XKey;
|
||||
public int YKey;
|
||||
public double Xmin, Ymin, Xmax, Ymax;
|
||||
public double GlobalMin, GlobalMax;
|
||||
public double TileMin, TileMax;
|
||||
public bool HasTileMinMax;
|
||||
public int OutRes;
|
||||
}
|
||||
|
||||
@@ -62,10 +87,13 @@ public class GeoTilePrefabImporter : EditorWindow
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition);
|
||||
|
||||
GUILayout.Label("Input Paths", EditorStyles.boldLabel);
|
||||
tilesCsvPath = EditorGUILayout.TextField("tile_index.csv", tilesCsvPath);
|
||||
heightmapsDir = EditorGUILayout.TextField("height_png16 dir", heightmapsDir);
|
||||
orthoDir = EditorGUILayout.TextField("ortho_jpg dir", orthoDir);
|
||||
orthoDir = EditorGUILayout.TextField("ortho_jpg dir (primary)", orthoDir);
|
||||
orthoDirFallback = EditorGUILayout.TextField("ortho_jpg dir (fallback)", orthoDirFallback);
|
||||
buildingsDir = EditorGUILayout.TextField("buildings_tiles dir", buildingsDir);
|
||||
treesDir = EditorGUILayout.TextField("trees_tiles dir", treesDir);
|
||||
furnitureDir = EditorGUILayout.TextField("street_furniture dir", furnitureDir);
|
||||
@@ -75,6 +103,9 @@ public class GeoTilePrefabImporter : EditorWindow
|
||||
GUILayout.Label("Output Settings", EditorStyles.boldLabel);
|
||||
prefabOutputDir = EditorGUILayout.TextField("Prefab output dir", prefabOutputDir);
|
||||
overwriteExisting = EditorGUILayout.ToggleLeft("Overwrite existing prefabs", overwriteExisting);
|
||||
buildingPrefabsDir = EditorGUILayout.TextField("Building prefab output dir", buildingPrefabsDir);
|
||||
exportBuildingPrefabs = EditorGUILayout.ToggleLeft("Export building-only prefabs (2km blocks)", exportBuildingPrefabs);
|
||||
overwriteBuildingPrefabs = EditorGUILayout.ToggleLeft("Overwrite building prefabs", overwriteBuildingPrefabs);
|
||||
|
||||
GUILayout.Space(10);
|
||||
GUILayout.Label("Terrain Settings", EditorStyles.boldLabel);
|
||||
@@ -85,6 +116,7 @@ public class GeoTilePrefabImporter : EditorWindow
|
||||
GUILayout.Label("Include Components", EditorStyles.boldLabel);
|
||||
applyOrthoTextures = EditorGUILayout.ToggleLeft("Apply ortho textures", applyOrthoTextures);
|
||||
includeBuildings = EditorGUILayout.ToggleLeft("Include buildings (GLB)", includeBuildings);
|
||||
includeBuildingsEvenTilesOnly = EditorGUILayout.ToggleLeft("Include 2km buildings once (even X/Y tiles only)", includeBuildingsEvenTilesOnly);
|
||||
includeTrees = EditorGUILayout.ToggleLeft("Include trees (GLB chunks)", includeTrees);
|
||||
includeFurniture = EditorGUILayout.ToggleLeft("Include street furniture (CSV)", includeFurniture);
|
||||
includeEnhancedTrees = EditorGUILayout.ToggleLeft("Include enhanced trees (CSV)", includeEnhancedTrees);
|
||||
@@ -98,6 +130,19 @@ public class GeoTilePrefabImporter : EditorWindow
|
||||
bollardPrefab = (GameObject)EditorGUILayout.ObjectField("Bollard Prefab", bollardPrefab, typeof(GameObject), false);
|
||||
defaultFurniturePrefab = (GameObject)EditorGUILayout.ObjectField("Default Furniture", defaultFurniturePrefab, typeof(GameObject), false);
|
||||
|
||||
GUILayout.Space(12);
|
||||
GUILayout.Label("Tile Key Config", EditorStyles.boldLabel);
|
||||
tileKeySizeX = EditorGUILayout.FloatField("Tile Size X (m)", tileKeySizeX);
|
||||
tileKeySizeY = EditorGUILayout.FloatField("Tile Size Y (m)", tileKeySizeY);
|
||||
tileKeyOverlapX = EditorGUILayout.FloatField("Overlap X (m)", tileKeyOverlapX);
|
||||
tileKeyOverlapY = EditorGUILayout.FloatField("Overlap Y (m)", tileKeyOverlapY);
|
||||
|
||||
RefreshTileIndexCache();
|
||||
|
||||
GUILayout.Space(10);
|
||||
GUILayout.Label("Tile Selection", EditorStyles.boldLabel);
|
||||
DrawTileSelectionUI();
|
||||
|
||||
GUILayout.Space(15);
|
||||
if (GUILayout.Button("Generate Prefabs"))
|
||||
ImportTilesAsPrefabs();
|
||||
@@ -110,8 +155,11 @@ public class GeoTilePrefabImporter : EditorWindow
|
||||
"Creates one .prefab asset per tile in the manifest.\n" +
|
||||
"Each prefab contains: Terrain (with TerrainData), Buildings, Trees, Furniture.\n" +
|
||||
"TerrainData and TerrainLayers are saved as separate .asset files.\n" +
|
||||
"Per-tile elevation: uses tile_min/tile_max when present (falls back to global_min/global_max).\n" +
|
||||
"IMPORTANT: Use 'Place All Prefabs in Scene' to position tiles correctly.",
|
||||
MessageType.Info);
|
||||
|
||||
EditorGUILayout.EndScrollView();
|
||||
}
|
||||
|
||||
private void PlaceAllPrefabsInScene()
|
||||
@@ -211,8 +259,11 @@ public class GeoTilePrefabImporter : EditorWindow
|
||||
EnsureDirectoryExists(prefabOutputDir);
|
||||
EnsureDirectoryExists($"{prefabOutputDir}/TerrainData");
|
||||
EnsureDirectoryExists($"{prefabOutputDir}/TerrainLayers");
|
||||
if (exportBuildingPrefabs)
|
||||
EnsureDirectoryExists(buildingPrefabsDir);
|
||||
|
||||
// Parse CSV
|
||||
RefreshTileIndexCache();
|
||||
var tiles = ParseTilesCsv();
|
||||
if (tiles == null || tiles.Count == 0)
|
||||
{
|
||||
@@ -220,32 +271,54 @@ public class GeoTilePrefabImporter : EditorWindow
|
||||
return;
|
||||
}
|
||||
|
||||
Debug.Log($"[GeoTilePrefabImporter] Found {tiles.Count} tiles to process.");
|
||||
var selectedTiles = ApplySelection(tiles);
|
||||
if (selectedTiles.Count == 0)
|
||||
{
|
||||
Debug.LogError("[GeoTilePrefabImporter] Selection is empty. Adjust Tile Selection and try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
Debug.Log($"[GeoTilePrefabImporter] Found {selectedTiles.Count} tiles to process.");
|
||||
|
||||
int created = 0, skipped = 0, failed = 0;
|
||||
int buildingsCreated = 0, buildingsSkipped = 0, buildingsFailed = 0;
|
||||
|
||||
for (int i = 0; i < tiles.Count; i++)
|
||||
for (int i = 0; i < selectedTiles.Count; i++)
|
||||
{
|
||||
var tile = tiles[i];
|
||||
var tile = selectedTiles[i];
|
||||
EditorUtility.DisplayProgressBar(
|
||||
"Creating Tile Prefabs",
|
||||
$"Processing {tile.TileId} ({i + 1}/{tiles.Count})",
|
||||
(float)i / tiles.Count);
|
||||
$"Processing {tile.TileId} ({i + 1}/{selectedTiles.Count})",
|
||||
(float)i / selectedTiles.Count);
|
||||
|
||||
string prefabPath = $"{prefabOutputDir}/{tile.TileId}.prefab";
|
||||
if (File.Exists(prefabPath) && !overwriteExisting)
|
||||
bool skipTerrain = File.Exists(prefabPath) && !overwriteExisting;
|
||||
if (skipTerrain)
|
||||
{
|
||||
Debug.Log($"[GeoTilePrefabImporter] Skipping existing: {tile.TileId}");
|
||||
Debug.Log($"[GeoTilePrefabImporter] Skipping existing terrain prefab: {tile.TileId}");
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (CreateTilePrefab(tile))
|
||||
created++;
|
||||
else
|
||||
failed++;
|
||||
if (!skipTerrain)
|
||||
{
|
||||
if (CreateTilePrefab(tile))
|
||||
created++;
|
||||
else
|
||||
failed++;
|
||||
}
|
||||
|
||||
if (exportBuildingPrefabs)
|
||||
{
|
||||
var result = CreateBuildingPrefab(tile);
|
||||
if (result == BuildResult.Created)
|
||||
buildingsCreated++;
|
||||
else if (result == BuildResult.Skipped)
|
||||
buildingsSkipped++;
|
||||
else
|
||||
buildingsFailed++;
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
@@ -258,7 +331,170 @@ public class GeoTilePrefabImporter : EditorWindow
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
Debug.Log($"[GeoTilePrefabImporter] DONE. Created={created}, Skipped={skipped}, Failed={failed}");
|
||||
Debug.Log($"[GeoTilePrefabImporter] DONE. Created={created}, Skipped={skipped}, Failed={failed}" +
|
||||
(exportBuildingPrefabs ? $", Buildings Created={buildingsCreated}, Skipped={buildingsSkipped}, Failed={buildingsFailed}" : ""));
|
||||
}
|
||||
|
||||
private void RefreshTileIndexCache()
|
||||
{
|
||||
if (!File.Exists(tilesCsvPath))
|
||||
{
|
||||
tileIndexCache.Clear();
|
||||
tileKeyOptions.Clear();
|
||||
tileKeyLookup.Clear();
|
||||
tileKeyStrings = Array.Empty<string>();
|
||||
return;
|
||||
}
|
||||
|
||||
var writeTime = File.GetLastWriteTimeUtc(tilesCsvPath);
|
||||
if (tilesCsvPath == cachedTileCsvPath &&
|
||||
writeTime == cachedTileCsvTimeUtc &&
|
||||
Mathf.Approximately(tileKeySizeX, cachedTileKeySizeX) &&
|
||||
Mathf.Approximately(tileKeySizeY, cachedTileKeySizeY) &&
|
||||
Mathf.Approximately(tileKeyOverlapX, cachedTileOverlapX) &&
|
||||
Mathf.Approximately(tileKeyOverlapY, cachedTileOverlapY))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
tileIndexCache.Clear();
|
||||
tileIndexCache.AddRange(ParseTilesCsv());
|
||||
BuildTileKeyOptions();
|
||||
|
||||
cachedTileCsvPath = tilesCsvPath;
|
||||
cachedTileCsvTimeUtc = writeTime;
|
||||
cachedTileKeySizeX = tileKeySizeX;
|
||||
cachedTileKeySizeY = tileKeySizeY;
|
||||
cachedTileOverlapX = tileKeyOverlapX;
|
||||
cachedTileOverlapY = tileKeyOverlapY;
|
||||
}
|
||||
|
||||
private void BuildTileKeyOptions()
|
||||
{
|
||||
tileKeyOptions.Clear();
|
||||
tileKeyLookup.Clear();
|
||||
|
||||
for (int i = 0; i < tileIndexCache.Count; i++)
|
||||
{
|
||||
var tile = tileIndexCache[i];
|
||||
if (string.IsNullOrWhiteSpace(tile.TileKey))
|
||||
continue;
|
||||
|
||||
if (tileKeyLookup.ContainsKey(tile.TileKey))
|
||||
continue;
|
||||
|
||||
var option = new TileKeyOption
|
||||
{
|
||||
Key = tile.TileKey,
|
||||
X = tile.XKey,
|
||||
Y = tile.YKey
|
||||
};
|
||||
|
||||
tileKeyOptions.Add(option);
|
||||
tileKeyLookup[tile.TileKey] = option;
|
||||
}
|
||||
|
||||
tileKeyOptions.Sort((a, b) =>
|
||||
{
|
||||
int cmp = a.X.CompareTo(b.X);
|
||||
return cmp != 0 ? cmp : a.Y.CompareTo(b.Y);
|
||||
});
|
||||
|
||||
tileKeyStrings = new string[tileKeyOptions.Count];
|
||||
for (int i = 0; i < tileKeyOptions.Count; i++)
|
||||
tileKeyStrings[i] = tileKeyOptions[i].Key;
|
||||
}
|
||||
|
||||
private void DrawTileSelectionUI()
|
||||
{
|
||||
if (tileKeyOptions.Count == 0)
|
||||
{
|
||||
EditorGUILayout.HelpBox("No tile keys available. Check the tile index CSV and key config.", MessageType.Info);
|
||||
EditorGUILayout.LabelField("Tiles in selection", "0");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(bottomLeftKey) || !tileKeyLookup.ContainsKey(bottomLeftKey))
|
||||
bottomLeftKey = tileKeyOptions[0].Key;
|
||||
|
||||
var bottomLeft = tileKeyLookup[bottomLeftKey];
|
||||
var topRightOptions = GetTopRightOptions(bottomLeft);
|
||||
if (topRightOptions.Count == 0)
|
||||
topRightOptions.Add(bottomLeft);
|
||||
|
||||
if (string.IsNullOrEmpty(topRightKey) || !ContainsKey(topRightOptions, topRightKey))
|
||||
topRightKey = topRightOptions[topRightOptions.Count - 1].Key;
|
||||
|
||||
int bottomLeftIndex = Array.IndexOf(tileKeyStrings, bottomLeftKey);
|
||||
int newBottomLeftIndex = EditorGUILayout.Popup("Bottom-Left Key", bottomLeftIndex, tileKeyStrings);
|
||||
if (newBottomLeftIndex != bottomLeftIndex)
|
||||
{
|
||||
bottomLeftKey = tileKeyStrings[newBottomLeftIndex];
|
||||
bottomLeft = tileKeyLookup[bottomLeftKey];
|
||||
topRightOptions = GetTopRightOptions(bottomLeft);
|
||||
if (topRightOptions.Count == 0)
|
||||
topRightOptions.Add(bottomLeft);
|
||||
|
||||
if (!ContainsKey(topRightOptions, topRightKey))
|
||||
topRightKey = topRightOptions[topRightOptions.Count - 1].Key;
|
||||
}
|
||||
|
||||
var topRightKeys = BuildKeyArray(topRightOptions);
|
||||
int topRightIndex = Array.IndexOf(topRightKeys, topRightKey);
|
||||
if (topRightIndex < 0)
|
||||
topRightIndex = topRightKeys.Length - 1;
|
||||
|
||||
int newTopRightIndex = EditorGUILayout.Popup("Top-Right Key", topRightIndex, topRightKeys);
|
||||
if (newTopRightIndex != topRightIndex)
|
||||
topRightKey = topRightKeys[newTopRightIndex];
|
||||
|
||||
EditorGUILayout.LabelField("Tiles in selection", CountSelectedTiles().ToString());
|
||||
}
|
||||
|
||||
private List<TileKeyOption> GetTopRightOptions(TileKeyOption bottomLeft)
|
||||
{
|
||||
var options = new List<TileKeyOption>();
|
||||
for (int i = 0; i < tileKeyOptions.Count; i++)
|
||||
{
|
||||
var option = tileKeyOptions[i];
|
||||
if (option.X >= bottomLeft.X && option.Y >= bottomLeft.Y)
|
||||
options.Add(option);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
private int CountSelectedTiles()
|
||||
{
|
||||
if (!tileKeyLookup.TryGetValue(bottomLeftKey, out var bottomLeft) ||
|
||||
!tileKeyLookup.TryGetValue(topRightKey, out var topRight))
|
||||
return 0;
|
||||
|
||||
int count = 0;
|
||||
for (int i = 0; i < tileIndexCache.Count; i++)
|
||||
{
|
||||
var tile = tileIndexCache[i];
|
||||
if (tile.XKey >= bottomLeft.X && tile.XKey <= topRight.X &&
|
||||
tile.YKey >= bottomLeft.Y && tile.YKey <= topRight.Y)
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private List<TileMetadata> ApplySelection(List<TileMetadata> tiles)
|
||||
{
|
||||
if (!tileKeyLookup.TryGetValue(bottomLeftKey, out var bottomLeft) ||
|
||||
!tileKeyLookup.TryGetValue(topRightKey, out var topRight))
|
||||
return tiles;
|
||||
|
||||
var selected = new List<TileMetadata>();
|
||||
for (int i = 0; i < tiles.Count; i++)
|
||||
{
|
||||
var tile = tiles[i];
|
||||
if (tile.XKey >= bottomLeft.X && tile.XKey <= topRight.X &&
|
||||
tile.YKey >= bottomLeft.Y && tile.YKey <= topRight.Y)
|
||||
selected.Add(tile);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
private List<TileMetadata> ParseTilesCsv()
|
||||
@@ -289,10 +525,10 @@ public class GeoTilePrefabImporter : EditorWindow
|
||||
int IDX_YMAX = headerMap["ymax"];
|
||||
int IDX_GMIN = headerMap["global_min"];
|
||||
int IDX_GMAX = headerMap["global_max"];
|
||||
int IDX_TMIN = headerMap.ContainsKey("tile_min") ? headerMap["tile_min"] : -1;
|
||||
int IDX_TMAX = headerMap.ContainsKey("tile_max") ? headerMap["tile_max"] : -1;
|
||||
int IDX_RES = headerMap["out_res"];
|
||||
bool hasTileMin = headerMap.TryGetValue("tile_min", out int IDX_TMIN);
|
||||
bool hasTileMax = headerMap.TryGetValue("tile_max", out int IDX_TMAX);
|
||||
bool useTileRange = hasTileMin && hasTileMax;
|
||||
int IDX_TILE_KEY = headerMap.ContainsKey("tile_key") ? headerMap["tile_key"] : -1;
|
||||
|
||||
for (int i = 1; i < lines.Length; i++)
|
||||
{
|
||||
@@ -300,9 +536,15 @@ public class GeoTilePrefabImporter : EditorWindow
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
|
||||
var parts = line.Split(',');
|
||||
int maxIdx = Math.Max(Math.Max(Math.Max(IDX_TILE, IDX_XMAX), IDX_YMAX), Math.Max(IDX_GMAX, IDX_RES));
|
||||
if (useTileRange)
|
||||
maxIdx = Math.Max(maxIdx, Math.Max(IDX_TMIN, IDX_TMAX));
|
||||
int maxIdx = Math.Max(
|
||||
Math.Max(Math.Max(IDX_TILE, IDX_XMIN), Math.Max(IDX_YMIN, IDX_XMAX)),
|
||||
Math.Max(Math.Max(IDX_YMAX, IDX_GMIN), Math.Max(IDX_GMAX, IDX_RES)));
|
||||
if (IDX_TILE_KEY >= 0)
|
||||
maxIdx = Math.Max(maxIdx, IDX_TILE_KEY);
|
||||
if (IDX_TMIN >= 0)
|
||||
maxIdx = Math.Max(maxIdx, IDX_TMIN);
|
||||
if (IDX_TMAX >= 0)
|
||||
maxIdx = Math.Max(maxIdx, IDX_TMAX);
|
||||
if (parts.Length <= maxIdx)
|
||||
{
|
||||
Debug.LogWarning($"[GeoTilePrefabImporter] Skipping line {i + 1}: too few columns.");
|
||||
@@ -311,38 +553,35 @@ public class GeoTilePrefabImporter : EditorWindow
|
||||
|
||||
try
|
||||
{
|
||||
double gmin = double.Parse(parts[IDX_GMIN], ci);
|
||||
double gmax = double.Parse(parts[IDX_GMAX], ci);
|
||||
double tileMin = gmin;
|
||||
double tileMax = gmax;
|
||||
bool tileRangeValid = false;
|
||||
if (useTileRange)
|
||||
{
|
||||
if (double.TryParse(parts[IDX_TMIN], NumberStyles.Float, ci, out double tmin) &&
|
||||
double.TryParse(parts[IDX_TMAX], NumberStyles.Float, ci, out double tmax))
|
||||
{
|
||||
tileMin = tmin;
|
||||
tileMax = tmax;
|
||||
tileRangeValid = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"[GeoTilePrefabImporter] Tile {parts[IDX_TILE].Trim()}: invalid tile_min/tile_max; falling back to global range.");
|
||||
}
|
||||
}
|
||||
var xmin = double.Parse(parts[IDX_XMIN], ci);
|
||||
var ymin = double.Parse(parts[IDX_YMIN], ci);
|
||||
var tileKeyRaw = IDX_TILE_KEY >= 0 ? parts[IDX_TILE_KEY].Trim() : "";
|
||||
var tileKey = ResolveTileKey(tileKeyRaw, xmin, ymin, out var xKey, out var yKey);
|
||||
var globalMin = double.Parse(parts[IDX_GMIN], ci);
|
||||
var globalMax = double.Parse(parts[IDX_GMAX], ci);
|
||||
double tileMin = globalMin;
|
||||
double tileMax = globalMax;
|
||||
if (IDX_TMIN >= 0 && IDX_TMIN < parts.Length &&
|
||||
double.TryParse(parts[IDX_TMIN], NumberStyles.Float, ci, out var parsedTileMin))
|
||||
tileMin = parsedTileMin;
|
||||
if (IDX_TMAX >= 0 && IDX_TMAX < parts.Length &&
|
||||
double.TryParse(parts[IDX_TMAX], NumberStyles.Float, ci, out var parsedTileMax))
|
||||
tileMax = parsedTileMax;
|
||||
|
||||
tiles.Add(new TileMetadata
|
||||
{
|
||||
TileKey = tileKey,
|
||||
TileId = parts[IDX_TILE].Trim(),
|
||||
Xmin = double.Parse(parts[IDX_XMIN], ci),
|
||||
Ymin = double.Parse(parts[IDX_YMIN], ci),
|
||||
XKey = xKey,
|
||||
YKey = yKey,
|
||||
Xmin = xmin,
|
||||
Ymin = ymin,
|
||||
Xmax = double.Parse(parts[IDX_XMAX], ci),
|
||||
Ymax = double.Parse(parts[IDX_YMAX], ci),
|
||||
GlobalMin = gmin,
|
||||
GlobalMax = gmax,
|
||||
GlobalMin = globalMin,
|
||||
GlobalMax = globalMax,
|
||||
TileMin = tileMin,
|
||||
TileMax = tileMax,
|
||||
HasTileMinMax = tileRangeValid,
|
||||
OutRes = int.Parse(parts[IDX_RES], ci)
|
||||
});
|
||||
}
|
||||
@@ -355,24 +594,81 @@ public class GeoTilePrefabImporter : EditorWindow
|
||||
return tiles;
|
||||
}
|
||||
|
||||
private string ResolveTileKey(string tileKeyRaw, double xmin, double ymin, out int xKey, out int yKey)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(tileKeyRaw) && TryParseTileKey(tileKeyRaw, out xKey, out yKey))
|
||||
return tileKeyRaw;
|
||||
|
||||
float sizeX = tileKeySizeX <= 0f ? 1f : tileKeySizeX;
|
||||
float sizeY = tileKeySizeY <= 0f ? 1f : tileKeySizeY;
|
||||
xKey = (int)Math.Floor((xmin + tileKeyOverlapX) / sizeX);
|
||||
yKey = (int)Math.Floor((ymin + tileKeyOverlapY) / sizeY);
|
||||
return $"{xKey}_{yKey}";
|
||||
}
|
||||
|
||||
private static bool TryParseTileKey(string tileKey, out int xKey, out int yKey)
|
||||
{
|
||||
xKey = 0;
|
||||
yKey = 0;
|
||||
if (string.IsNullOrWhiteSpace(tileKey))
|
||||
return false;
|
||||
|
||||
var parts = tileKey.Split('_');
|
||||
if (parts.Length != 2)
|
||||
return false;
|
||||
|
||||
return int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out xKey)
|
||||
&& int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out yKey);
|
||||
}
|
||||
|
||||
private static bool ContainsKey(List<TileKeyOption> options, string key)
|
||||
{
|
||||
for (int i = 0; i < options.Count; i++)
|
||||
{
|
||||
if (string.Equals(options[i].Key, key, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string[] BuildKeyArray(List<TileKeyOption> options)
|
||||
{
|
||||
var keys = new string[options.Count];
|
||||
for (int i = 0; i < options.Count; i++)
|
||||
keys[i] = options[i].Key;
|
||||
return keys;
|
||||
}
|
||||
|
||||
private struct TileKeyOption
|
||||
{
|
||||
public string Key;
|
||||
public int X;
|
||||
public int Y;
|
||||
}
|
||||
|
||||
private static bool ShouldIncludeBuildings(TileMetadata tile, bool evenTilesOnly)
|
||||
{
|
||||
if (!evenTilesOnly)
|
||||
return true;
|
||||
|
||||
return (tile.XKey % 2 == 0) && (tile.YKey % 2 == 0);
|
||||
}
|
||||
|
||||
private enum BuildResult
|
||||
{
|
||||
Created,
|
||||
Skipped,
|
||||
Failed
|
||||
}
|
||||
|
||||
private bool CreateTilePrefab(TileMetadata tile)
|
||||
{
|
||||
// Validate height range
|
||||
double baseMin = tile.HasTileMinMax ? tile.TileMin : tile.GlobalMin;
|
||||
double baseMax = tile.HasTileMinMax ? tile.TileMax : tile.GlobalMax;
|
||||
float heightRange = (float)(baseMax - baseMin);
|
||||
float heightRange = (float)(tile.TileMax - tile.TileMin);
|
||||
if (heightRange <= 0.0001f)
|
||||
{
|
||||
if (tile.HasTileMinMax)
|
||||
{
|
||||
Debug.LogWarning($"[GeoTilePrefabImporter] Tile {tile.TileId}: flat tile range; using epsilon height range.");
|
||||
heightRange = 0.001f;
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"[GeoTilePrefabImporter] Tile {tile.TileId}: invalid height range. Skipping.");
|
||||
return false;
|
||||
}
|
||||
Debug.LogWarning($"[GeoTilePrefabImporter] Tile {tile.TileId}: invalid height range. Skipping.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Load heightmap
|
||||
@@ -447,6 +743,7 @@ public class GeoTilePrefabImporter : EditorWindow
|
||||
|
||||
// Store metadata as component for later use
|
||||
var metadata = root.AddComponent<GeoTileMetadata>();
|
||||
metadata.tileKey = tile.TileKey;
|
||||
metadata.tileId = tile.TileId;
|
||||
metadata.xmin = tile.Xmin;
|
||||
metadata.ymin = tile.Ymin;
|
||||
@@ -454,7 +751,6 @@ public class GeoTilePrefabImporter : EditorWindow
|
||||
metadata.globalMax = tile.GlobalMax;
|
||||
metadata.tileMin = tile.TileMin;
|
||||
metadata.tileMax = tile.TileMax;
|
||||
metadata.hasTileMinMax = tile.HasTileMinMax;
|
||||
|
||||
// Add child components
|
||||
if (includeBuildings)
|
||||
@@ -483,9 +779,53 @@ public class GeoTilePrefabImporter : EditorWindow
|
||||
return true;
|
||||
}
|
||||
|
||||
private BuildResult CreateBuildingPrefab(TileMetadata tile)
|
||||
{
|
||||
if (!exportBuildingPrefabs)
|
||||
return BuildResult.Skipped;
|
||||
if (!ShouldIncludeBuildings(tile, includeBuildingsEvenTilesOnly))
|
||||
return BuildResult.Skipped;
|
||||
|
||||
string glbPath = Path.Combine(buildingsDir, $"{tile.TileId}.glb").Replace("\\", "/");
|
||||
if (!File.Exists(glbPath))
|
||||
return BuildResult.Skipped;
|
||||
|
||||
string prefabPath = $"{buildingPrefabsDir}/{tile.TileId}.prefab";
|
||||
if (File.Exists(prefabPath))
|
||||
{
|
||||
if (!overwriteBuildingPrefabs)
|
||||
return BuildResult.Skipped;
|
||||
AssetDatabase.DeleteAsset(prefabPath);
|
||||
}
|
||||
|
||||
var root = new GameObject(tile.TileId);
|
||||
var metadata = root.AddComponent<GeoTileMetadata>();
|
||||
metadata.tileKey = tile.TileKey;
|
||||
metadata.tileId = tile.TileId;
|
||||
metadata.xmin = tile.Xmin;
|
||||
metadata.ymin = tile.Ymin;
|
||||
metadata.globalMin = tile.GlobalMin;
|
||||
metadata.globalMax = tile.GlobalMax;
|
||||
metadata.tileMin = tile.TileMin;
|
||||
metadata.tileMax = tile.TileMax;
|
||||
|
||||
AddBuildings(root, tile);
|
||||
PrefabUtility.SaveAsPrefabAsset(root, prefabPath);
|
||||
Debug.Log($"[GeoTilePrefabImporter] Created building prefab: {prefabPath}");
|
||||
DestroyImmediate(root);
|
||||
|
||||
return BuildResult.Created;
|
||||
}
|
||||
|
||||
private void ApplyOrthoTexture(TerrainData terrainData, string tileId)
|
||||
{
|
||||
string orthoPath = Path.Combine(orthoDir, $"{tileId}.jpg").Replace("\\", "/");
|
||||
if (!File.Exists(orthoPath) && !string.IsNullOrWhiteSpace(orthoDirFallback))
|
||||
{
|
||||
string fallbackPath = Path.Combine(orthoDirFallback, $"{tileId}.jpg").Replace("\\", "/");
|
||||
if (File.Exists(fallbackPath))
|
||||
orthoPath = fallbackPath;
|
||||
}
|
||||
if (!File.Exists(orthoPath))
|
||||
{
|
||||
Debug.LogWarning($"[GeoTilePrefabImporter] Ortho texture missing for {tileId}: {orthoPath}");
|
||||
@@ -526,6 +866,9 @@ public class GeoTilePrefabImporter : EditorWindow
|
||||
|
||||
private void AddBuildings(GameObject root, TileMetadata tile)
|
||||
{
|
||||
if (!ShouldIncludeBuildings(tile, includeBuildingsEvenTilesOnly))
|
||||
return;
|
||||
|
||||
string glbPath = Path.Combine(buildingsDir, $"{tile.TileId}.glb").Replace("\\", "/");
|
||||
if (!File.Exists(glbPath))
|
||||
return;
|
||||
@@ -537,17 +880,16 @@ public class GeoTilePrefabImporter : EditorWindow
|
||||
return;
|
||||
}
|
||||
|
||||
float baseMin = (float)(tile.HasTileMinMax ? tile.TileMin : tile.GlobalMin);
|
||||
// Building GLB vertices have absolute Z (elevation) from CityGML.
|
||||
// Since prefab root will be at Y=baseMin when placed, offset buildings by -baseMin
|
||||
// so building world Y = baseMin + (-baseMin) + GLB_Y = GLB_Y (correct absolute elevation)
|
||||
// Since prefab root will be at Y=tile_min when placed, offset buildings by -tile_min
|
||||
// so building world Y = tile_min + (-tile_min) + GLB_Y = GLB_Y (correct absolute elevation)
|
||||
var instance = PrefabUtility.InstantiatePrefab(buildingPrefab) as GameObject;
|
||||
if (instance == null)
|
||||
instance = Instantiate(buildingPrefab);
|
||||
|
||||
instance.name = "Buildings";
|
||||
instance.transform.SetParent(root.transform, false);
|
||||
instance.transform.localPosition = new Vector3(0f, -baseMin, 0f);
|
||||
instance.transform.localPosition = new Vector3(0f, -(float)tile.TileMin, 0f);
|
||||
instance.transform.localRotation = Quaternion.Euler(0f, 180f, 0f);
|
||||
instance.isStatic = true;
|
||||
}
|
||||
@@ -568,13 +910,12 @@ public class GeoTilePrefabImporter : EditorWindow
|
||||
return;
|
||||
}
|
||||
|
||||
float baseMin = (float)(tile.HasTileMinMax ? tile.TileMin : tile.GlobalMin);
|
||||
// Tree GLB vertices use absolute elevation (z_ground from DGM).
|
||||
// Since prefab root will be at Y=baseMin when placed, offset trees by -baseMin
|
||||
// so tree world Y = baseMin + (-baseMin) + GLB_Y = GLB_Y (correct absolute elevation)
|
||||
// Since prefab root will be at Y=tile_min when placed, offset trees by -tile_min
|
||||
// so tree world Y = tile_min + (-tile_min) + GLB_Y = GLB_Y (correct absolute elevation)
|
||||
var treesContainer = new GameObject("Trees");
|
||||
treesContainer.transform.SetParent(root.transform, false);
|
||||
treesContainer.transform.localPosition = new Vector3(0f, -baseMin, 0f);
|
||||
treesContainer.transform.localPosition = new Vector3(0f, -(float)tile.TileMin, 0f);
|
||||
treesContainer.transform.localRotation = Quaternion.Euler(0f, 180f, 0f);
|
||||
treesContainer.isStatic = true;
|
||||
|
||||
@@ -634,7 +975,7 @@ public class GeoTilePrefabImporter : EditorWindow
|
||||
furnitureContainer.transform.localPosition = Vector3.zero;
|
||||
furnitureContainer.isStatic = true;
|
||||
|
||||
float baseMin = (float)(tile.HasTileMinMax ? tile.TileMin : tile.GlobalMin);
|
||||
float baseY = (float)tile.TileMin;
|
||||
|
||||
for (int i = 1; i < lines.Length; i++)
|
||||
{
|
||||
@@ -654,7 +995,7 @@ public class GeoTilePrefabImporter : EditorWindow
|
||||
|
||||
GameObject obj = CreateFurnitureObject(furnitureType, height, i);
|
||||
obj.transform.SetParent(furnitureContainer.transform, false);
|
||||
obj.transform.localPosition = new Vector3(xLocal, zGround - baseMin, yLocal);
|
||||
obj.transform.localPosition = new Vector3(xLocal, zGround - baseY, yLocal);
|
||||
obj.isStatic = true;
|
||||
}
|
||||
catch (Exception e)
|
||||
@@ -749,7 +1090,7 @@ public class GeoTilePrefabImporter : EditorWindow
|
||||
treesContainer.transform.localPosition = Vector3.zero;
|
||||
treesContainer.isStatic = true;
|
||||
|
||||
float baseMin = (float)(tile.HasTileMinMax ? tile.TileMin : tile.GlobalMin);
|
||||
float baseY = (float)tile.TileMin;
|
||||
|
||||
for (int i = 1; i < lines.Length; i++)
|
||||
{
|
||||
@@ -779,7 +1120,7 @@ public class GeoTilePrefabImporter : EditorWindow
|
||||
|
||||
GameObject treeObj = CreateEnhancedTree(height, radius, canopyColor, i);
|
||||
treeObj.transform.SetParent(treesContainer.transform, false);
|
||||
treeObj.transform.localPosition = new Vector3(xLocal, zGround - baseMin, yLocal);
|
||||
treeObj.transform.localPosition = new Vector3(xLocal, zGround - baseY, yLocal);
|
||||
treeObj.isStatic = true;
|
||||
}
|
||||
catch (Exception e)
|
||||
|
||||
Reference in New Issue
Block a user