Analyze Ground Zero landforms

This commit is contained in:
2026-05-14 06:02:14 -07:00
parent fcb3d18b0c
commit 2344a11519
4 changed files with 406 additions and 2 deletions
+2 -2
View File
@@ -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.
@@ -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."
]
}
+46
View File
@@ -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.
+169
View File
@@ -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()