Analyze Ground Zero landforms
This commit is contained in:
@@ -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.
|
||||
|
||||
+189
@@ -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."
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user