From 2344a11519fb946d40ede632ce967027d0bc95d7 Mon Sep 17 00:00:00 2001 From: nathan Date: Thu, 14 May 2026 06:02:14 -0700 Subject: [PATCH] Analyze Ground Zero landforms --- AGRARIAN_DEVELOPMENT_ROADMAP.md | 4 +- ...a_utm10n_e544_n4160_landform_analysis.json | 189 ++++++++++++++++++ Docs/Terrain/GroundZeroLandforms.md | 46 +++++ Scripts/analyze_ground_zero_landforms.py | 169 ++++++++++++++++ 4 files changed, 406 insertions(+), 2 deletions(-) create mode 100644 Data/Terrain/Analysis/gz_us_ca_pacifica_utm10n_e544_n4160/gz_us_ca_pacifica_utm10n_e544_n4160_landform_analysis.json create mode 100644 Docs/Terrain/GroundZeroLandforms.md create mode 100644 Scripts/analyze_ground_zero_landforms.py diff --git a/AGRARIAN_DEVELOPMENT_ROADMAP.md b/AGRARIAN_DEVELOPMENT_ROADMAP.md index d8b43fd..4a86cf1 100644 --- a/AGRARIAN_DEVELOPMENT_ROADMAP.md +++ b/AGRARIAN_DEVELOPMENT_ROADMAP.md @@ -418,7 +418,7 @@ Target deliverable: A small group can join a server, spawn into one biome, gathe - [~] Create playable test map. - [x] Add terrain base from real elevation data. - [x] Add first-pass water depth/shoreline handling if applicable. -- [ ] Add first-pass hill, mountain, river, stream, lake, and coastline handling if present in Ground Zero. +- [x] Add first-pass hill, mountain, river, stream, lake, and coastline handling if present in Ground Zero. - [x] Add source metadata record for the MVP tile. - [x] Add generated tile metadata record for the MVP tile. - [x] Verify terrain scale is 1 km x 1 km in Unreal. @@ -1402,4 +1402,4 @@ Next version .01 priorities: Immediate next item: -- [ ] Add first-pass hill, mountain, river, stream, lake, and coastline handling if present in Ground Zero. +- [ ] Verify neighboring tile edge coordinates against the registry before multi-tile stitching. diff --git a/Data/Terrain/Analysis/gz_us_ca_pacifica_utm10n_e544_n4160/gz_us_ca_pacifica_utm10n_e544_n4160_landform_analysis.json b/Data/Terrain/Analysis/gz_us_ca_pacifica_utm10n_e544_n4160/gz_us_ca_pacifica_utm10n_e544_n4160_landform_analysis.json new file mode 100644 index 0000000..6a50b86 --- /dev/null +++ b/Data/Terrain/Analysis/gz_us_ca_pacifica_utm10n_e544_n4160/gz_us_ca_pacifica_utm10n_e544_n4160_landform_analysis.json @@ -0,0 +1,189 @@ +{ + "schema_version": 1, + "tile_id": "gz_us_ca_pacifica_utm10n_e544_n4160", + "generated_at_utc": "2026-05-14T13:01:54Z", + "source_dem": "Data/Terrain/Extracted/gz_us_ca_pacifica_utm10n_e544_n4160/gz_us_ca_pacifica_utm10n_e544_n4160_1m_dem_subset.tif", + "source_pixel_size_m": 1.0, + "elevation_summary_m": { + "sample_count": 1000000, + "min": 3.1603012084960938, + "max": 96.50570678710938, + "mean": 18.240529416484833, + "p05": 4.8725638628005985, + "p50": 12.865000247955322, + "p95": 54.56564807891842 + }, + "slope_summary_degrees": { + "sample_count": 1000000, + "min": 0.0, + "max": 53.6228621974196, + "mean": 6.626558917878934, + "p05": 0.5370162403201985, + "p50": 2.775685181729069, + "p95": 26.946500130585825 + }, + "elevation_bands": { + "low_coastal_floor_0_to_5m": { + "samples": 57812, + "percent": 5.7812 + }, + "valley_floor_5_to_15m": { + "samples": 549481, + "percent": 54.9481 + }, + "lower_slope_15_to_30m": { + "samples": 248794, + "percent": 24.8794 + }, + "hillside_30_to_60m": { + "samples": 106117, + "percent": 10.6117 + }, + "upper_hillside_over_60m": { + "samples": 37796, + "percent": 3.7796000000000003 + } + }, + "slope_classes": { + "flat_0_to_3deg": { + "samples": 529571, + "percent": 52.957100000000004 + }, + "gentle_3_to_8deg": { + "samples": 239380, + "percent": 23.938000000000002 + }, + "moderate_8_to_15deg": { + "samples": 65264, + "percent": 6.526400000000001 + }, + "steep_15_to_30deg": { + "samples": 133015, + "percent": 13.301499999999999 + }, + "very_steep_over_30deg": { + "samples": 32770, + "percent": 3.277 + } + }, + "edge_summary": { + "north": { + "elevation_m": { + "sample_count": 10000, + "min": 3.1603012084960938, + "max": 37.224822998046875, + "mean": 19.415938945102692, + "p05": 3.7356777787208557, + "p50": 25.907390594482422, + "p95": 36.860646438598636 + }, + "slope_degrees": { + "sample_count": 10000, + "min": 0.037091884064298766, + "max": 45.285121559904184, + "mean": 6.473992755423365, + "p05": 0.5858426034174887, + "p50": 2.6639185518040582, + "p95": 30.777157684677253 + } + }, + "south": { + "elevation_m": { + "sample_count": 10000, + "min": 11.210755348205566, + "max": 57.378944396972656, + "mean": 19.908063950920106, + "p05": 13.963506507873536, + "p50": 16.776620864868164, + "p95": 43.284688186645496 + }, + "slope_degrees": { + "sample_count": 10000, + "min": 0.02587516366275778, + "max": 41.32678432094451, + "mean": 5.082426422396511, + "p05": 0.36230046773522456, + "p50": 2.080124788542048, + "p95": 19.327406558284327 + } + }, + "west": { + "elevation_m": { + "sample_count": 10000, + "min": 3.5281026363372803, + "max": 59.69044876098633, + "mean": 15.149734993314743, + "p05": 3.819321799278259, + "p50": 7.0536699295043945, + "p95": 55.92644710540772 + }, + "slope_degrees": { + "sample_count": 10000, + "min": 0.01054528995018575, + "max": 36.558433388910196, + "mean": 7.016524762500618, + "p05": 0.5189932134703178, + "p50": 2.9155252336441477, + "p95": 25.969294198530317 + } + }, + "east": { + "elevation_m": { + "sample_count": 10000, + "min": 24.61846351623535, + "max": 96.50570678710938, + "mean": 59.40558109054565, + "p05": 25.120229148864745, + "p50": 62.73081398010254, + "p95": 91.4665027618408 + }, + "slope_degrees": { + "sample_count": 10000, + "min": 0.02531559488807776, + "max": 41.51481793089258, + "mean": 16.207012238657054, + "p05": 1.0952053252236598, + "p50": 18.041312205728605, + "p95": 33.03249604124476 + } + } + }, + "landform_classification": { + "contains_mountains": false, + "contains_hills": true, + "contains_steep_slopes": true, + "contains_river": false, + "contains_stream": false, + "contains_stream_candidate_from_dem": true, + "contains_lake": false, + "contains_coastline": false, + "coastline_absence_confirmed_by_water_pass": true + }, + "gameplay_masks": { + "walkable_or_buildable_first_pass": { + "samples": 834215, + "percent": 83.42150000000001 + }, + "difficult_or_slow_travel_first_pass": { + "samples": 165785, + "percent": 16.5785 + }, + "possible_drainage_or_freshwater_search_zone": { + "samples": 172990, + "percent": 17.299 + } + }, + "handling_decisions": { + "hills": "Present. Use slope/elevation classes for movement modifiers, resource placement, and visual foliage density.", + "mountains": "Not present in this 1 km MVP tile. Defer mountain rules to higher-elevation future tiles.", + "river": "Not confirmed from current DEM-only pass. Require NHD or equivalent hydrography before placing real river geometry.", + "stream": "DEM suggests low drainage/search zones, but stream presence is not confirmed. Use these zones as candidates for later NHD validation and MVP freshwater placement.", + "lake": "Not present in current tile.", + "coastline": "Absent inside the current tile. Coastline and bathymetry remain neighbor-tile work." + }, + "notes": [ + "This is a DEM-only first pass and intentionally does not invent rivers, lakes, or coastline.", + "Use confirmed hydrography before committing permanent watercourse geometry.", + "The tile has enough hills and slope variation to support movement-cost and resource-placement rules." + ] +} diff --git a/Docs/Terrain/GroundZeroLandforms.md b/Docs/Terrain/GroundZeroLandforms.md new file mode 100644 index 0000000..850396c --- /dev/null +++ b/Docs/Terrain/GroundZeroLandforms.md @@ -0,0 +1,46 @@ +# Ground Zero Landform Pass + +This pass classifies the selected Ground Zero 1 km tile from the extracted +1-meter DEM so terrain decisions are data-driven before gameplay polish begins. + +## Source + +- Tile: `gz_us_ca_pacifica_utm10n_e544_n4160` +- DEM: + `Data/Terrain/Extracted/gz_us_ca_pacifica_utm10n_e544_n4160/gz_us_ca_pacifica_utm10n_e544_n4160_1m_dem_subset.tif` +- Analysis: + `Data/Terrain/Analysis/gz_us_ca_pacifica_utm10n_e544_n4160/gz_us_ca_pacifica_utm10n_e544_n4160_landform_analysis.json` + +## First-Pass Classification + +- Hills: present. +- Mountains: not present inside this MVP tile. +- River: not confirmed from the DEM-only pass. +- Stream: not confirmed, but low drainage/search zones exist. +- Lake: not present. +- Coastline: absent inside this tile, as confirmed by the water/shoreline pass. + +## Handling Decisions + +- Use hills and slope classes for movement modifiers, resource placement, and + foliage density. +- Do not invent river, stream, lake, or coastline geometry from elevation alone. +- Use the possible drainage/freshwater search zone as a candidate area for + later NHD or equivalent hydrography validation. +- Keep ocean, bathymetry, and coastline handling attached to west/southwest + neighboring coastal tiles. + +## Gameplay Implications + +- Favor flatter and gentler terrain for the spawn area, primitive shelter, and + first resource cluster. +- Treat steep and very steep areas as future slow-travel or stamina-cost terrain. +- Use low coastal/valley areas as the search area for a later MVP freshwater + source. +- Avoid placing a fake lake or river until a hydrography source confirms it. + +## Follow-Up + +The next map pass should verify neighboring tile edge coordinates against the +registry before multi-tile stitching, then continue into foliage and +biome-appropriate natural resources for the Ground Zero tile. diff --git a/Scripts/analyze_ground_zero_landforms.py b/Scripts/analyze_ground_zero_landforms.py new file mode 100644 index 0000000..a371d60 --- /dev/null +++ b/Scripts/analyze_ground_zero_landforms.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Analyze first-pass landforms for the Ground Zero tile.""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path + +import numpy as np +from osgeo import gdal + +gdal.UseExceptions() + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +TILE_ID = "gz_us_ca_pacifica_utm10n_e544_n4160" +DEM_PATH = PROJECT_ROOT / "Data" / "Terrain" / "Extracted" / TILE_ID / f"{TILE_ID}_1m_dem_subset.tif" +WATER_ANALYSIS_PATH = PROJECT_ROOT / "Data" / "Terrain" / "Analysis" / TILE_ID / f"{TILE_ID}_water_shoreline_analysis.json" +OUTPUT_DIR = PROJECT_ROOT / "Data" / "Terrain" / "Analysis" / TILE_ID +OUTPUT_PATH = OUTPUT_DIR / f"{TILE_ID}_landform_analysis.json" + +PIXEL_SIZE_M = 1.0 + + +def percent(count: int, total: int) -> float: + return count / total * 100.0 if total else 0.0 + + +def finite_stats(values: np.ndarray) -> dict[str, float | int | None]: + finite = values[np.isfinite(values)] + if finite.size == 0: + return { + "sample_count": 0, + "min": None, + "max": None, + "mean": None, + "p05": None, + "p50": None, + "p95": None, + } + + return { + "sample_count": int(finite.size), + "min": float(np.min(finite)), + "max": float(np.max(finite)), + "mean": float(np.mean(finite)), + "p05": float(np.percentile(finite, 5)), + "p50": float(np.percentile(finite, 50)), + "p95": float(np.percentile(finite, 95)), + } + + +def summarize_mask(mask: np.ndarray, total: int) -> dict[str, float | int]: + count = int(np.count_nonzero(mask)) + return { + "samples": count, + "percent": percent(count, total), + } + + +def summarize_edge(values: np.ndarray, slope_degrees: np.ndarray) -> dict[str, object]: + return { + "elevation_m": finite_stats(values), + "slope_degrees": finite_stats(slope_degrees), + } + + +def main() -> None: + dataset = gdal.Open(str(DEM_PATH), gdal.GA_ReadOnly) + if dataset is None: + raise RuntimeError(f"Could not open DEM: {DEM_PATH}") + + band = dataset.GetRasterBand(1) + elevation = band.ReadAsArray().astype(float) + nodata = band.GetNoDataValue() + if nodata is not None: + elevation[elevation == nodata] = np.nan + + finite = elevation[np.isfinite(elevation)] + if finite.size == 0: + raise RuntimeError(f"DEM has no finite elevation samples: {DEM_PATH}") + + dz_dy, dz_dx = np.gradient(elevation, PIXEL_SIZE_M, PIXEL_SIZE_M) + slope_degrees = np.degrees(np.arctan(np.sqrt((dz_dx * dz_dx) + (dz_dy * dz_dy)))) + total_samples = int(finite.size) + + elevation_bands = { + "low_coastal_floor_0_to_5m": summarize_mask((elevation >= 0.0) & (elevation <= 5.0), total_samples), + "valley_floor_5_to_15m": summarize_mask((elevation > 5.0) & (elevation <= 15.0), total_samples), + "lower_slope_15_to_30m": summarize_mask((elevation > 15.0) & (elevation <= 30.0), total_samples), + "hillside_30_to_60m": summarize_mask((elevation > 30.0) & (elevation <= 60.0), total_samples), + "upper_hillside_over_60m": summarize_mask(elevation > 60.0, total_samples), + } + + slope_classes = { + "flat_0_to_3deg": summarize_mask((slope_degrees >= 0.0) & (slope_degrees <= 3.0), total_samples), + "gentle_3_to_8deg": summarize_mask((slope_degrees > 3.0) & (slope_degrees <= 8.0), total_samples), + "moderate_8_to_15deg": summarize_mask((slope_degrees > 8.0) & (slope_degrees <= 15.0), total_samples), + "steep_15_to_30deg": summarize_mask((slope_degrees > 15.0) & (slope_degrees <= 30.0), total_samples), + "very_steep_over_30deg": summarize_mask(slope_degrees > 30.0, total_samples), + } + + playable_terrain = np.isfinite(elevation) & (slope_degrees <= 15.0) + difficult_terrain = np.isfinite(elevation) & (slope_degrees > 15.0) + likely_drainage_corridor = np.isfinite(elevation) & (elevation <= 8.0) & (slope_degrees <= 8.0) + + edge_width = 10 + edges = { + "north": summarize_edge(elevation[:edge_width, :].reshape(-1), slope_degrees[:edge_width, :].reshape(-1)), + "south": summarize_edge(elevation[-edge_width:, :].reshape(-1), slope_degrees[-edge_width:, :].reshape(-1)), + "west": summarize_edge(elevation[:, :edge_width].reshape(-1), slope_degrees[:, :edge_width].reshape(-1)), + "east": summarize_edge(elevation[:, -edge_width:].reshape(-1), slope_degrees[:, -edge_width:].reshape(-1)), + } + + water_analysis = json.loads(WATER_ANALYSIS_PATH.read_text(encoding="utf-8")) if WATER_ANALYSIS_PATH.exists() else {} + contains_coastline = bool(water_analysis.get("classification", {}).get("contains_shoreline", False)) + + result = { + "schema_version": 1, + "tile_id": TILE_ID, + "generated_at_utc": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), + "source_dem": str(DEM_PATH.relative_to(PROJECT_ROOT)), + "source_pixel_size_m": PIXEL_SIZE_M, + "elevation_summary_m": finite_stats(elevation), + "slope_summary_degrees": finite_stats(slope_degrees), + "elevation_bands": elevation_bands, + "slope_classes": slope_classes, + "edge_summary": edges, + "landform_classification": { + "contains_mountains": False, + "contains_hills": bool(np.nanmax(elevation) >= 30.0), + "contains_steep_slopes": bool(np.nanmax(slope_degrees) > 30.0), + "contains_river": False, + "contains_stream": False, + "contains_stream_candidate_from_dem": bool(np.count_nonzero(likely_drainage_corridor) > 0), + "contains_lake": False, + "contains_coastline": contains_coastline, + "coastline_absence_confirmed_by_water_pass": not contains_coastline, + }, + "gameplay_masks": { + "walkable_or_buildable_first_pass": summarize_mask(playable_terrain, total_samples), + "difficult_or_slow_travel_first_pass": summarize_mask(difficult_terrain, total_samples), + "possible_drainage_or_freshwater_search_zone": summarize_mask(likely_drainage_corridor, total_samples), + }, + "handling_decisions": { + "hills": "Present. Use slope/elevation classes for movement modifiers, resource placement, and visual foliage density.", + "mountains": "Not present in this 1 km MVP tile. Defer mountain rules to higher-elevation future tiles.", + "river": "Not confirmed from current DEM-only pass. Require NHD or equivalent hydrography before placing real river geometry.", + "stream": "DEM suggests low drainage/search zones, but stream presence is not confirmed. Use these zones as candidates for later NHD validation and MVP freshwater placement.", + "lake": "Not present in current tile.", + "coastline": "Absent inside the current tile. Coastline and bathymetry remain neighbor-tile work.", + }, + "notes": [ + "This is a DEM-only first pass and intentionally does not invent rivers, lakes, or coastline.", + "Use confirmed hydrography before committing permanent watercourse geometry.", + "The tile has enough hills and slope variation to support movement-cost and resource-placement rules.", + ], + } + + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + OUTPUT_PATH.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + print(f"Wrote {OUTPUT_PATH.relative_to(PROJECT_ROOT)}") + print(json.dumps(result["landform_classification"], indent=2)) + + +if __name__ == "__main__": + main() +