| """Integration tests for run_pipeline and the area / route pipeline modes. |
| |
| All tests mock fetch_osm_data and fetch_elevation to avoid network calls. |
| They verify that the pipeline produces the correct output files with the |
| expected structure for a range of configurations. |
| """ |
|
|
| import json |
| import sys |
| import zipfile |
| from unittest.mock import patch |
|
|
| import geopandas as gpd |
| import numpy as np |
| import pytest |
| from shapely.geometry import LineString, Polygon |
|
|
| from main import main, run_pipeline |
|
|
| |
| |
| |
|
|
| _LAT, _LON, _RADIUS = 51.5, -0.12, 300.0 |
|
|
| |
| |
| _ELEV_ARRAY = np.full((30, 60), 10.0, dtype=np.float32) |
| _ELEV_HEADER = { |
| "ncols": 60.0, |
| "nrows": 30.0, |
| "xllcorner": -0.17, |
| "yllcorner": 51.47, |
| "cellsize": 0.002, |
| "cellsize_y": 0.002, |
| "nodata_value": -9999.0, |
| } |
|
|
| |
| _FAST = dict( |
| lat=_LAT, |
| lon=_LON, |
| radius=_RADIUS, |
| grid_size=10, |
| color_grid_size=20, |
| no_cache=True, |
| ) |
|
|
| _OSM_LAYERS = [ |
| "buildings", |
| "building_parts", |
| "roads", |
| "railways", |
| "water_area", |
| "waterways", |
| "water_landuse", |
| "coastlines", |
| "parks", |
| "landuse_green", |
| "natural_green", |
| "leisure_green", |
| "cemeteries", |
| "aeroways", |
| "parking", |
| "pedestrian_areas", |
| "sand", |
| "piers", |
| ] |
|
|
|
|
| |
| |
| |
|
|
|
|
| def _empty_osm() -> dict: |
| return {layer: gpd.GeoDataFrame() for layer in _OSM_LAYERS} |
|
|
|
|
| def _wgs84_gdf(*geoms, **cols) -> gpd.GeoDataFrame: |
| data = {"geometry": list(geoms)} |
| data.update({k: list(v) for k, v in cols.items()}) |
| return gpd.GeoDataFrame(data, crs="EPSG:4326") |
|
|
|
|
| def _flat_elev(): |
| return (_ELEV_ARRAY.copy(), dict(_ELEV_HEADER)) |
|
|
|
|
| |
| |
| |
|
|
|
|
| @pytest.fixture |
| def patch_fetchers(): |
| """Patch both fetch functions for tests that need only empty OSM data.""" |
| with ( |
| patch("terrology.fetcher.fetch_osm_data", return_value=_empty_osm()), |
| patch("terrology.fetcher.fetch_elevation", return_value=_flat_elev()), |
| ): |
| yield |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_basic_creates_expected_files(tmp_path, patch_fetchers): |
| run_pipeline(**_FAST, output_dir=tmp_path, colors=4) |
| assert (tmp_path / "model.obj").exists() |
| assert (tmp_path / "model.mtl").exists() |
| assert (tmp_path / "model.3mf").exists() |
| assert (tmp_path / "terrain.stl").exists() |
|
|
|
|
| def test_obj_references_mtl_and_has_terrain_objects(tmp_path, patch_fetchers): |
| run_pipeline(**_FAST, output_dir=tmp_path, colors=4, skip_stls=True) |
| obj = (tmp_path / "model.obj").read_text() |
| assert "mtllib model.mtl" in obj |
| assert "o terrain_base" in obj |
| assert "o terrain_top" in obj |
| assert "usemtl terrain" in obj |
|
|
|
|
| def test_mtl_contains_terrain_material(tmp_path, patch_fetchers): |
| run_pipeline(**_FAST, output_dir=tmp_path, colors=4, skip_stls=True) |
| mtl = (tmp_path / "model.mtl").read_text() |
| assert "newmtl terrain" in mtl |
|
|
|
|
| def test_3mf_is_valid_zip(tmp_path, patch_fetchers): |
| run_pipeline(**_FAST, output_dir=tmp_path, colors=4, skip_stls=True) |
| assert zipfile.is_zipfile(tmp_path / "model.3mf") |
|
|
|
|
| def test_3mf_contains_model_xml(tmp_path, patch_fetchers): |
| run_pipeline(**_FAST, output_dir=tmp_path, colors=4, skip_stls=True) |
| with zipfile.ZipFile(tmp_path / "model.3mf") as zf: |
| assert "3D/3dmodel.model" in zf.namelist() |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_skip_stls_suppresses_stl_files(tmp_path, patch_fetchers): |
| run_pipeline(**_FAST, output_dir=tmp_path, skip_stls=True) |
| assert not (tmp_path / "terrain.stl").exists() |
| assert not (tmp_path / "buildings.stl").exists() |
| assert (tmp_path / "model.obj").exists() |
| assert (tmp_path / "model.3mf").exists() |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_no_buildings_flag_suppresses_stl(tmp_path, patch_fetchers): |
| run_pipeline(**_FAST, output_dir=tmp_path, no_buildings=True) |
| assert not (tmp_path / "buildings.stl").exists() |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_with_buildings_creates_stl(tmp_path): |
| """Synthetic 100 m × 70 m building near the map centre produces buildings.stl.""" |
| footprint = Polygon( |
| [ |
| (_LON - 0.0005, _LAT - 0.0003), |
| (_LON + 0.0005, _LAT - 0.0003), |
| (_LON + 0.0005, _LAT + 0.0003), |
| (_LON - 0.0005, _LAT + 0.0003), |
| ] |
| ) |
| osm = _empty_osm() |
| osm["buildings"] = _wgs84_gdf(footprint, building=["yes"], height=["10"]) |
|
|
| with ( |
| patch("terrology.fetcher.fetch_osm_data", return_value=osm), |
| patch("terrology.fetcher.fetch_elevation", return_value=_flat_elev()), |
| ): |
| run_pipeline(**_FAST, output_dir=tmp_path, colors=4) |
|
|
| assert (tmp_path / "buildings.stl").exists() |
| assert (tmp_path / "buildings.stl").stat().st_size > 0 |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_buildings_clipped_to_circle_polygon(tmp_path): |
| """Building straddling a circle clip boundary must be clipped, not extend beyond it.""" |
| from pyproj import CRS, Transformer |
| from shapely.geometry import Point |
| from shapely.ops import transform as _shp_transform |
|
|
| from terrology.builder import _utm_crs |
|
|
| utm_crs = _utm_crs(_LON, _LAT) |
| wgs84 = CRS.from_epsg(4326) |
| to_utm = Transformer.from_crs(wgs84, utm_crs, always_xy=True) |
| from_utm = Transformer.from_crs(utm_crs, wgs84, always_xy=True) |
|
|
| |
| cx, cy = to_utm.transform(_LON, _LAT) |
| circle_utm = Point(cx, cy).buffer(200, quad_segs=32) |
| clip_wgs84 = _shp_transform(lambda x, y: from_utm.transform(x, y), circle_utm) |
|
|
| |
| bldg_cx, bldg_cy = cx + 190, cy |
| footprint_utm = Polygon( |
| [ |
| (bldg_cx - 30, bldg_cy - 30), |
| (bldg_cx + 30, bldg_cy - 30), |
| (bldg_cx + 30, bldg_cy + 30), |
| (bldg_cx - 30, bldg_cy + 30), |
| ] |
| ) |
| footprint_wgs84 = _shp_transform( |
| lambda x, y: from_utm.transform(x, y), footprint_utm |
| ) |
|
|
| osm = _empty_osm() |
| osm["buildings"] = _wgs84_gdf(footprint_wgs84, building=["yes"], height=["10"]) |
|
|
| with ( |
| patch("terrology.fetcher.fetch_osm_data", return_value=osm), |
| patch("terrology.fetcher.fetch_elevation", return_value=_flat_elev()), |
| ): |
| run_pipeline( |
| **_FAST, |
| clip_polygon_wgs84=clip_wgs84, |
| output_dir=tmp_path, |
| skip_stls=True, |
| ) |
|
|
| obj = (tmp_path / "model.obj").read_text() |
| |
| assert "buildings" in obj or "terrain" in obj |
| |
| |
| assert (tmp_path / "model.obj").stat().st_size > 0 |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_1color_mtl_terrain_only(tmp_path, patch_fetchers): |
| run_pipeline(**_FAST, output_dir=tmp_path, colors=1, skip_stls=True) |
| mtl = (tmp_path / "model.mtl").read_text() |
| assert "newmtl terrain" in mtl |
| for mat in ("water", "parks", "roads", "railways", "sand"): |
| assert f"newmtl {mat}" not in mtl |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_inland_lake_produces_water_material(tmp_path): |
| """Synthetic lake at model centre causes water material to appear in MTL.""" |
| lake = Polygon( |
| [ |
| (_LON - 0.001, _LAT - 0.001), |
| (_LON + 0.001, _LAT - 0.001), |
| (_LON + 0.001, _LAT + 0.001), |
| (_LON - 0.001, _LAT + 0.001), |
| ] |
| ) |
| osm = _empty_osm() |
| osm["water_area"] = _wgs84_gdf(lake, natural=["water"]) |
|
|
| with ( |
| patch("terrology.fetcher.fetch_osm_data", return_value=osm), |
| patch("terrology.fetcher.fetch_elevation", return_value=_flat_elev()), |
| ): |
| run_pipeline(**_FAST, output_dir=tmp_path, colors=4, skip_stls=True) |
|
|
| mtl = (tmp_path / "model.mtl").read_text() |
| assert "newmtl water" in mtl |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_7color_road_and_water_in_mtl(tmp_path): |
| """Road and water features in OSM data cause those materials to appear in MTL.""" |
| lake = Polygon( |
| [ |
| (_LON - 0.001, _LAT - 0.001), |
| (_LON + 0.001, _LAT - 0.001), |
| (_LON + 0.001, _LAT + 0.001), |
| (_LON - 0.001, _LAT + 0.001), |
| ] |
| ) |
| road = LineString([(_LON - 0.002, _LAT), (_LON + 0.002, _LAT)]) |
| osm = _empty_osm() |
| osm["water_area"] = _wgs84_gdf(lake, natural=["water"]) |
| osm["roads"] = _wgs84_gdf(road, highway=["primary"]) |
|
|
| with ( |
| patch("terrology.fetcher.fetch_osm_data", return_value=osm), |
| patch("terrology.fetcher.fetch_elevation", return_value=_flat_elev()), |
| ): |
| |
| |
| params = {**_FAST, "color_grid_size": 30} |
| run_pipeline(**params, output_dir=tmp_path, colors=7, skip_stls=False) |
|
|
| mtl = (tmp_path / "model.mtl").read_text() |
| assert "newmtl water" in mtl |
| assert "newmtl roads" in mtl |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_coastal_sea_produces_water_material(tmp_path): |
| """East-going coastline north of centre creates a sea polygon covering the |
| north strip; those faces are coloured water so water appears in the MTL.""" |
| |
| |
| coast = LineString( |
| [ |
| (_LON - 0.008, _LAT + 0.001), |
| (_LON + 0.008, _LAT + 0.001), |
| ] |
| ) |
| osm = _empty_osm() |
| osm["coastlines"] = _wgs84_gdf(coast) |
|
|
| with ( |
| patch("terrology.fetcher.fetch_osm_data", return_value=osm), |
| patch("terrology.fetcher.fetch_elevation", return_value=_flat_elev()), |
| ): |
| run_pipeline(**_FAST, output_dir=tmp_path, colors=4, skip_stls=True) |
|
|
| mtl = (tmp_path / "model.mtl").read_text() |
| assert "newmtl water" in mtl |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_border_appears_in_obj(tmp_path, patch_fetchers): |
| run_pipeline( |
| **_FAST, |
| output_dir=tmp_path, |
| border_width_mm=6.0, |
| skip_stls=True, |
| ) |
| obj = (tmp_path / "model.obj").read_text() |
| assert "o border" in obj |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_contour_interval_runs_without_error(tmp_path, patch_fetchers): |
| run_pipeline(**_FAST, output_dir=tmp_path, contour_interval=10.0, skip_stls=True) |
| assert (tmp_path / "model.obj").exists() |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_output_dir_created_automatically(tmp_path, patch_fetchers): |
| new_dir = tmp_path / "subdir" / "nested" |
| assert not new_dir.exists() |
| run_pipeline(**_FAST, output_dir=new_dir, skip_stls=True) |
| assert new_dir.exists() |
| assert (new_dir / "model.obj").exists() |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_exag_1_and_3_both_produce_output(tmp_path, patch_fetchers): |
| for exag, name in [(1.0, "exag1"), (3.0, "exag3")]: |
| d = tmp_path / name |
| run_pipeline(**_FAST, output_dir=d, terrain_exag=exag, skip_stls=True) |
| assert (d / "model.obj").exists() |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_park_feature_produces_parks_material(tmp_path): |
| """Synthetic park polygon at model centre causes parks material in MTL.""" |
| park = Polygon( |
| [ |
| (_LON - 0.001, _LAT - 0.001), |
| (_LON + 0.001, _LAT - 0.001), |
| (_LON + 0.001, _LAT + 0.001), |
| (_LON - 0.001, _LAT + 0.001), |
| ] |
| ) |
| osm = _empty_osm() |
| osm["parks"] = _wgs84_gdf(park, leisure=["park"]) |
|
|
| with ( |
| patch("terrology.fetcher.fetch_osm_data", return_value=osm), |
| patch("terrology.fetcher.fetch_elevation", return_value=_flat_elev()), |
| ): |
| run_pipeline(**_FAST, output_dir=tmp_path, colors=4, skip_stls=True) |
|
|
| mtl = (tmp_path / "model.mtl").read_text() |
| assert "newmtl parks" in mtl |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_2color_no_parks_or_roads_in_mtl(tmp_path): |
| """With colors=2 the only non-terrain colour is water; parks/roads collapse.""" |
| lake = Polygon( |
| [ |
| (_LON - 0.001, _LAT - 0.001), |
| (_LON + 0.001, _LAT - 0.001), |
| (_LON + 0.001, _LAT + 0.001), |
| (_LON - 0.001, _LAT + 0.001), |
| ] |
| ) |
| park = Polygon( |
| [ |
| (_LON + 0.001, _LAT - 0.001), |
| (_LON + 0.002, _LAT - 0.001), |
| (_LON + 0.002, _LAT + 0.001), |
| (_LON + 0.001, _LAT + 0.001), |
| ] |
| ) |
| road = LineString([(_LON - 0.002, _LAT + 0.0015), (_LON + 0.002, _LAT + 0.0015)]) |
| osm = _empty_osm() |
| osm["water_area"] = _wgs84_gdf(lake, natural=["water"]) |
| osm["parks"] = _wgs84_gdf(park, leisure=["park"]) |
| osm["roads"] = _wgs84_gdf(road, highway=["primary"]) |
|
|
| with ( |
| patch("terrology.fetcher.fetch_osm_data", return_value=osm), |
| patch("terrology.fetcher.fetch_elevation", return_value=_flat_elev()), |
| ): |
| run_pipeline(**_FAST, output_dir=tmp_path, colors=2, skip_stls=True) |
|
|
| mtl = (tmp_path / "model.mtl").read_text() |
| assert "newmtl terrain" in mtl |
| assert "newmtl water" in mtl |
| assert "newmtl parks" not in mtl |
| assert "newmtl roads" not in mtl |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_invalid_colors_raises_value_error(tmp_path, patch_fetchers): |
| with pytest.raises(ValueError, match="colors"): |
| run_pipeline(**_FAST, output_dir=tmp_path, colors=8, skip_stls=True) |
|
|
|
|
| |
| |
| |
|
|
| |
| _AREA_GEOJSON = { |
| "type": "Polygon", |
| "coordinates": [ |
| [ |
| [-0.130, 51.496], |
| [-0.110, 51.496], |
| [-0.110, 51.504], |
| [-0.130, 51.504], |
| [-0.130, 51.496], |
| ] |
| ], |
| } |
|
|
|
|
| def _write_geojson(path): |
| path.write_text(json.dumps(_AREA_GEOJSON)) |
| return str(path) |
|
|
|
|
| def _run_main(argv: list[str], osm=None, elev=None): |
| """Patch sys.argv and both fetchers, then call main().""" |
| with ( |
| patch.object(sys, "argv", argv), |
| patch( |
| "terrology.fetcher.fetch_osm_data", |
| return_value=(osm if osm is not None else _empty_osm()), |
| ), |
| patch( |
| "terrology.fetcher.fetch_elevation", |
| return_value=(elev if elev is not None else _flat_elev()), |
| ), |
| ): |
| main() |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_area_mode_creates_expected_files(tmp_path): |
| geojson = _write_geojson(tmp_path / "area.geojson") |
| _run_main( |
| [ |
| "main.py", |
| "--area", |
| geojson, |
| "--output", |
| str(tmp_path), |
| "--grid-size", |
| "10", |
| "--color-grid-size", |
| "20", |
| "--no-cache", |
| ] |
| ) |
| assert (tmp_path / "model.obj").exists() |
| assert (tmp_path / "model.mtl").exists() |
| assert (tmp_path / "model.3mf").exists() |
| assert (tmp_path / "terrain.stl").exists() |
|
|
|
|
| def test_area_mode_obj_has_terrain_objects(tmp_path): |
| geojson = _write_geojson(tmp_path / "area.geojson") |
| _run_main( |
| [ |
| "main.py", |
| "--area", |
| geojson, |
| "--output", |
| str(tmp_path), |
| "--grid-size", |
| "10", |
| "--color-grid-size", |
| "20", |
| "--no-cache", |
| ] |
| ) |
| obj = (tmp_path / "model.obj").read_text() |
| assert "o terrain_base" in obj |
| assert "o terrain_top" in obj |
|
|
|
|
| def test_area_mode_with_border(tmp_path): |
| geojson = _write_geojson(tmp_path / "area.geojson") |
| _run_main( |
| [ |
| "main.py", |
| "--area", |
| geojson, |
| "--output", |
| str(tmp_path), |
| "--grid-size", |
| "10", |
| "--color-grid-size", |
| "20", |
| "--border-width", |
| "6", |
| "--no-cache", |
| ] |
| ) |
| obj = (tmp_path / "model.obj").read_text() |
| assert "o border" in obj |
|
|
|
|
| def test_area_mode_smooth_boundary(tmp_path): |
| """--smooth-boundary should run without error and produce output.""" |
| geojson = _write_geojson(tmp_path / "area.geojson") |
| _run_main( |
| [ |
| "main.py", |
| "--area", |
| geojson, |
| "--output", |
| str(tmp_path), |
| "--grid-size", |
| "10", |
| "--color-grid-size", |
| "20", |
| "--smooth-boundary", |
| "3", |
| "--no-cache", |
| ] |
| ) |
| assert (tmp_path / "model.obj").exists() |
|
|
|
|
| def test_area_mode_7_colors(tmp_path): |
| """Area mode with 7 colors and a synthetic water feature produces water in MTL.""" |
| lake = Polygon( |
| [ |
| (-0.125, 51.499), |
| (-0.120, 51.499), |
| (-0.120, 51.501), |
| (-0.125, 51.501), |
| ] |
| ) |
| osm = _empty_osm() |
| osm["water_area"] = _wgs84_gdf(lake, natural=["water"]) |
| geojson = _write_geojson(tmp_path / "area.geojson") |
| _run_main( |
| [ |
| "main.py", |
| "--area", |
| geojson, |
| "--output", |
| str(tmp_path), |
| "--grid-size", |
| "10", |
| "--color-grid-size", |
| "20", |
| "--colors", |
| "7", |
| "--no-cache", |
| ], |
| osm=osm, |
| ) |
| mtl = (tmp_path / "model.mtl").read_text() |
| assert "newmtl water" in mtl |
|
|
|
|
| |
| |
| |
|
|
| _GPX_TEMPLATE = """\ |
| <?xml version="1.0" encoding="UTF-8"?> |
| <gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1"> |
| <trk><trkseg> |
| <trkpt lat="{lat0}" lon="{lon0}"/> |
| <trkpt lat="{lat1}" lon="{lon1}"/> |
| <trkpt lat="{lat2}" lon="{lon2}"/> |
| <trkpt lat="{lat3}" lon="{lon3}"/> |
| <trkpt lat="{lat4}" lon="{lon4}"/> |
| </trkseg></trk> |
| </gpx> |
| """ |
|
|
|
|
| def _write_gpx(path): |
| |
| gpx = _GPX_TEMPLATE.format( |
| lat0=51.497, |
| lon0=-0.128, |
| lat1=51.498, |
| lon1=-0.124, |
| lat2=51.500, |
| lon2=-0.120, |
| lat3=51.502, |
| lon3=-0.116, |
| lat4=51.503, |
| lon4=-0.112, |
| ) |
| path.write_text(gpx) |
| return str(path) |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_route_mode_creates_expected_files(tmp_path): |
| gpx = _write_gpx(tmp_path / "route.gpx") |
| _run_main( |
| [ |
| "main.py", |
| "--route", |
| gpx, |
| "--output", |
| str(tmp_path), |
| "--grid-size", |
| "10", |
| "--color-grid-size", |
| "20", |
| "--no-cache", |
| ] |
| ) |
| assert (tmp_path / "model.obj").exists() |
| assert (tmp_path / "model.mtl").exists() |
| assert (tmp_path / "model.3mf").exists() |
| assert (tmp_path / "terrain.stl").exists() |
|
|
|
|
| def test_route_mode_no_per_colour_stls(tmp_path): |
| """Route mode skips per-colour STL export (no water.stl, roads.stl etc.).""" |
| gpx = _write_gpx(tmp_path / "route.gpx") |
| _run_main( |
| [ |
| "main.py", |
| "--route", |
| gpx, |
| "--output", |
| str(tmp_path), |
| "--grid-size", |
| "10", |
| "--color-grid-size", |
| "20", |
| "--no-cache", |
| ] |
| ) |
| for name in ("water.stl", "roads.stl", "parks.stl"): |
| assert not (tmp_path / name).exists(), f"{name} should not exist in route mode" |
|
|
|
|
| def test_route_mode_obj_has_terrain_objects(tmp_path): |
| gpx = _write_gpx(tmp_path / "route.gpx") |
| _run_main( |
| [ |
| "main.py", |
| "--route", |
| gpx, |
| "--output", |
| str(tmp_path), |
| "--grid-size", |
| "10", |
| "--color-grid-size", |
| "20", |
| "--no-cache", |
| ] |
| ) |
| obj = (tmp_path / "model.obj").read_text() |
| assert "o terrain_base" in obj |
| assert "o terrain_top" in obj |
|
|
|
|
| def test_route_mode_paints_route_colour(tmp_path): |
| """A wide route line on a fine colour grid should colour some faces as route.""" |
| gpx = _write_gpx(tmp_path / "route.gpx") |
| _run_main( |
| [ |
| "main.py", |
| "--route", |
| gpx, |
| "--output", |
| str(tmp_path), |
| "--grid-size", |
| "10", |
| "--color-grid-size", |
| "80", |
| "--route-width", |
| "8", |
| "--no-cache", |
| ] |
| ) |
| obj = (tmp_path / "model.obj").read_text() |
| assert "usemtl route" in obj |
| mtl = (tmp_path / "model.mtl").read_text() |
| assert "newmtl route" in mtl |
|
|
|
|
| |
| |
| |
|
|
| |
| _PT1 = "51.497,-0.128" |
| _PT2 = "51.503,-0.112" |
|
|
|
|
| |
| |
| |
|
|
|
|
| def test_two_point_creates_expected_files(tmp_path): |
| _run_main( |
| [ |
| "main.py", |
| _PT1, |
| "--to", |
| _PT2, |
| "--output", |
| str(tmp_path), |
| "--grid-size", |
| "10", |
| "--color-grid-size", |
| "20", |
| "--no-cache", |
| ] |
| ) |
| assert (tmp_path / "model.obj").exists() |
| assert (tmp_path / "model.mtl").exists() |
| assert (tmp_path / "model.3mf").exists() |
| assert (tmp_path / "terrain.stl").exists() |
|
|
|
|
| def test_two_point_obj_has_terrain_objects(tmp_path): |
| _run_main( |
| [ |
| "main.py", |
| _PT1, |
| "--to", |
| _PT2, |
| "--output", |
| str(tmp_path), |
| "--grid-size", |
| "10", |
| "--color-grid-size", |
| "20", |
| "--no-cache", |
| ] |
| ) |
| obj = (tmp_path / "model.obj").read_text() |
| assert "o terrain_base" in obj |
| assert "o terrain_top" in obj |
|
|
|
|
| def test_two_point_with_border(tmp_path): |
| _run_main( |
| [ |
| "main.py", |
| _PT1, |
| "--to", |
| _PT2, |
| "--output", |
| str(tmp_path), |
| "--grid-size", |
| "10", |
| "--color-grid-size", |
| "20", |
| "--border-width", |
| "6", |
| "--no-cache", |
| ] |
| ) |
| obj = (tmp_path / "model.obj").read_text() |
| assert "o border" in obj |
|
|
|
|
| def test_two_point_water_feature_in_mtl(tmp_path): |
| """Synthetic lake between the two points produces water material in MTL.""" |
| lake = Polygon( |
| [ |
| (-0.124, 51.499), |
| (-0.118, 51.499), |
| (-0.118, 51.501), |
| (-0.124, 51.501), |
| ] |
| ) |
| osm = _empty_osm() |
| osm["water_area"] = _wgs84_gdf(lake, natural=["water"]) |
| _run_main( |
| [ |
| "main.py", |
| _PT1, |
| "--to", |
| _PT2, |
| "--output", |
| str(tmp_path), |
| "--grid-size", |
| "10", |
| "--color-grid-size", |
| "20", |
| "--colors", |
| "4", |
| "--no-cache", |
| ], |
| osm=osm, |
| ) |
| mtl = (tmp_path / "model.mtl").read_text() |
| assert "newmtl water" in mtl |
|
|
|
|
| def test_two_point_explicit_scale(tmp_path): |
| """--scale overrides the auto-calculated scale in two-point mode.""" |
| _run_main( |
| [ |
| "main.py", |
| _PT1, |
| "--to", |
| _PT2, |
| "--scale", |
| "5000", |
| "--output", |
| str(tmp_path), |
| "--grid-size", |
| "10", |
| "--color-grid-size", |
| "20", |
| "--no-cache", |
| ] |
| ) |
| assert (tmp_path / "model.obj").exists() |
|
|
|
|
| |
| |
| |
| |
|
|
|
|
| def test_run_pipeline_with_route_points_utm(tmp_path): |
| """run_pipeline accepts route_points_utm and paints route colour on the surface. |
| |
| This is the primary path for web-app route maps — previously the web app |
| had no way to request route painting because run_pipeline lacked the param. |
| """ |
| from pyproj import CRS, Transformer |
|
|
| from terrology.builder import _utm_crs |
|
|
| utm_crs = _utm_crs(_LON, _LAT) |
| wgs84 = CRS.from_epsg(4326) |
| to_utm = Transformer.from_crs(wgs84, utm_crs, always_xy=True) |
|
|
| |
| route_latlon = [ |
| (51.497, -0.128), |
| (51.500, -0.120), |
| (51.503, -0.112), |
| ] |
| route_utm = [to_utm.transform(lon, lat) for lat, lon in route_latlon] |
|
|
| params = {**_FAST, "color_grid_size": 80} |
| with ( |
| patch("terrology.fetcher.fetch_osm_data", return_value=_empty_osm()), |
| patch("terrology.fetcher.fetch_elevation", return_value=_flat_elev()), |
| ): |
| run_pipeline( |
| **params, |
| output_dir=tmp_path, |
| route_points_utm=route_utm, |
| route_width=8.0, |
| skip_stls=True, |
| ) |
|
|
| obj = (tmp_path / "model.obj").read_text() |
| assert "usemtl route" in obj |
| mtl = (tmp_path / "model.mtl").read_text() |
| assert "newmtl route" in mtl |
|
|
|
|
| def test_run_pipeline_with_bbox_utm(tmp_path): |
| """run_pipeline accepts a pre-computed bbox_utm and uses it instead of radius.""" |
| from pyproj import CRS, Transformer |
|
|
| from terrology.builder import _utm_crs |
|
|
| utm_crs = _utm_crs(_LON, _LAT) |
| wgs84 = CRS.from_epsg(4326) |
| to_utm = Transformer.from_crs(wgs84, utm_crs, always_xy=True) |
|
|
| cx, cy = to_utm.transform(_LON, _LAT) |
| half = 300.0 |
| bbox = (cx - half, cx + half, cy - half, cy + half) |
|
|
| with ( |
| patch("terrology.fetcher.fetch_osm_data", return_value=_empty_osm()), |
| patch("terrology.fetcher.fetch_elevation", return_value=_flat_elev()), |
| ): |
| run_pipeline( |
| lat=_LAT, |
| lon=_LON, |
| bbox_utm=bbox, |
| grid_size=10, |
| color_grid_size=20, |
| no_cache=True, |
| output_dir=tmp_path, |
| skip_stls=True, |
| ) |
|
|
| assert (tmp_path / "model.obj").exists() |
|
|
|
|
| def test_run_pipeline_with_clip_polygon_utm(tmp_path): |
| """run_pipeline accepts a pre-projected UTM clip polygon.""" |
| from pyproj import CRS, Transformer |
| from shapely.geometry import Point |
|
|
| from terrology.builder import _utm_crs |
|
|
| utm_crs = _utm_crs(_LON, _LAT) |
| wgs84 = CRS.from_epsg(4326) |
| to_utm = Transformer.from_crs(wgs84, utm_crs, always_xy=True) |
|
|
| cx, cy = to_utm.transform(_LON, _LAT) |
| circle_utm = Point(cx, cy).buffer(250, quad_segs=16) |
|
|
| with ( |
| patch("terrology.fetcher.fetch_osm_data", return_value=_empty_osm()), |
| patch("terrology.fetcher.fetch_elevation", return_value=_flat_elev()), |
| ): |
| run_pipeline( |
| lat=_LAT, |
| lon=_LON, |
| radius=_RADIUS, |
| clip_polygon_utm=circle_utm, |
| grid_size=10, |
| color_grid_size=20, |
| no_cache=True, |
| output_dir=tmp_path, |
| skip_stls=True, |
| ) |
|
|
| assert (tmp_path / "model.obj").exists() |
|
|
|
|
| def test_run_pipeline_no_terrain(tmp_path): |
| """run_pipeline with no_terrain=True skips terrain files but still produces OBJ.""" |
| from shapely.geometry import Polygon |
|
|
| footprint = Polygon( |
| [ |
| (_LON - 0.0005, _LAT - 0.0003), |
| (_LON + 0.0005, _LAT - 0.0003), |
| (_LON + 0.0005, _LAT + 0.0003), |
| (_LON - 0.0005, _LAT + 0.0003), |
| ] |
| ) |
| osm = _empty_osm() |
| osm["buildings"] = _wgs84_gdf(footprint, building=["yes"], height=["10"]) |
|
|
| with ( |
| patch("terrology.fetcher.fetch_osm_data", return_value=osm), |
| patch( |
| "terrology.fetcher.fetch_overture_buildings", |
| return_value=gpd.GeoDataFrame(), |
| ), |
| ): |
| run_pipeline( |
| **_FAST, |
| output_dir=tmp_path, |
| no_terrain=True, |
| skip_stls=True, |
| ) |
|
|
| assert not (tmp_path / "terrain.stl").exists() |
| |
| assert (tmp_path / "model.obj").exists() |
|
|