129 lines
5.9 KiB
Python
129 lines
5.9 KiB
Python
"""The world map manifest: RawContent/World/MapArt/layers.json, which says which planet-wide images become the
|
|
layers of the map view. Pure Python (no numpy, no engine), shared by create_world_map.py and anything else that
|
|
needs to know where the art is.
|
|
|
|
Why a third manifest. World.json describes the numpy pipeline's square canvas and Region.json describes the
|
|
window of a planet map that becomes L_World's landscapes. This describes a *picture* of that same window, which
|
|
is a different thing again: it has a resolution rather than a vertex count, it carries no elevation contract of
|
|
its own, and adding a layer to it changes nothing about the ground. It reads Region.json for the geometry so
|
|
the map and the landscape can never disagree about how big the world is.
|
|
"""
|
|
import json
|
|
import os
|
|
|
|
from region_manifest import MANIFEST_PATH as REGION_PATH, load_manifest as load_region
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
PROJECT_ROOT = os.path.normpath(os.path.join(HERE, "..", ".."))
|
|
MAPART_DIR = os.path.join(PROJECT_ROOT, "RawContent", "World", "MapArt")
|
|
MANIFEST_PATH = os.path.join(MAPART_DIR, "layers.json")
|
|
REPORT_PATH = os.path.join(MAPART_DIR, "mapart.json")
|
|
|
|
|
|
class Layer:
|
|
def __init__(self, data):
|
|
self.id = data["id"]
|
|
self.name = data.get("name", data["id"])
|
|
self.file = data["file"]
|
|
self.render = data.get("render", "copy")
|
|
self.is_default = bool(data.get("default", False))
|
|
self.note = data.get("note", "")
|
|
|
|
@property
|
|
def output_name(self):
|
|
"""What Tools/MapArt writes. Named after the layer rather than the source, because the source is an
|
|
Orogen export whose filename carries an export number nobody chose."""
|
|
return f"map_{self.id}.png"
|
|
|
|
@property
|
|
def asset_name(self):
|
|
return f"T_WorldMap_{self.id[:1].upper()}{self.id[1:]}"
|
|
|
|
|
|
class WorldMapManifest:
|
|
def __init__(self, data, path=MANIFEST_PATH):
|
|
self.path = path
|
|
self.source_dir = data.get("source_dir", "RawContent/World/Orogen Gens")
|
|
self.output_dir = data.get("output_dir", "RawContent/World/MapArt")
|
|
self.region_path = data.get("region", "RawContent/World/Region.json")
|
|
output = data.get("output", {})
|
|
self.output_width = int(output.get("width", 4096))
|
|
self.output_height = int(output.get("height", 2048))
|
|
self.package = data.get("package", "/Game/World/Maps").rstrip("/")
|
|
self.definition_name = data.get("definition", "DA_WorldMap_L_World")
|
|
self.level = data.get("level", "/Game/Maps/L_World")
|
|
self.layers = [Layer(entry) for entry in data["layers"]]
|
|
# None means "work it out from the window": see wraps_x below.
|
|
self.wraps_x_override = data.get("wraps_x", None)
|
|
|
|
if not self.layers:
|
|
raise ValueError(f"{path}: no layers, so there is no map to build")
|
|
ids = [layer.id for layer in self.layers]
|
|
if len(set(ids)) != len(ids):
|
|
raise ValueError(f"{path}: two layers share an id: {ids}")
|
|
|
|
@property
|
|
def default_layer_id(self):
|
|
for layer in self.layers:
|
|
if layer.is_default:
|
|
return layer.id
|
|
return self.layers[0].id
|
|
|
|
def resolve(self, relative):
|
|
return relative if os.path.isabs(relative) else os.path.normpath(os.path.join(PROJECT_ROOT, relative))
|
|
|
|
def output_path(self, layer):
|
|
return os.path.join(self.resolve(self.output_dir), layer.output_name)
|
|
|
|
def region(self):
|
|
return load_region(self.resolve(self.region_path))
|
|
|
|
def report(self):
|
|
"""What the last Tools/MapArt run wrote, or None if it has not run. Carries each source's own size,
|
|
which is the one number needed to tell a whole cylinder from a crop of one."""
|
|
if not os.path.exists(REPORT_PATH):
|
|
return None
|
|
with open(REPORT_PATH, "r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
|
|
def wraps_x(self, region, report):
|
|
"""Does the map's left edge join its right? Only when the window is the whole width of the source: a
|
|
crop of a cylinder has two edges, and panning across one of those would jump halfway round the world.
|
|
|
|
Explicit in the manifest when `wraps_x` is set; otherwise the window is compared with the source's own
|
|
width, which only the mapart report knows - a PNG's size is not in any manifest and should not be.
|
|
"""
|
|
if self.wraps_x_override is not None:
|
|
return bool(self.wraps_x_override)
|
|
x0, _, width, _ = region.source_window
|
|
if report is None:
|
|
# No report means the art has not been built, and guessing "it wraps" would put a seam artefact in
|
|
# the middle of a crop. Say no; a rerun of Tools/MapArt settles it properly.
|
|
return False
|
|
widths = {entry.get("source_width") for entry in report.get("layers", [])}
|
|
return x0 == 0 and len(widths) == 1 and width in widths
|
|
|
|
def projection(self, region, report):
|
|
"""The numbers FWorldMapProjection wants, all derived from Region.json so they cannot drift from the
|
|
ground. The centre is the world origin because region_manifest.tile_centre_cm lays the grid out
|
|
straddling it; if that ever changes this is the other place that has to."""
|
|
return {
|
|
"width_m": region.width_m,
|
|
"height_m": region.height_m,
|
|
"centre_m": (0.0, 0.0),
|
|
"wraps_x": self.wraps_x(region, report),
|
|
"elevation_min_m": region.elevation_min_m,
|
|
"elevation_max_m": region.elevation_max_m,
|
|
"sea_level_m": region.sea_level_m,
|
|
}
|
|
|
|
def describe(self, region):
|
|
return (f"{len(self.layers)} layers at {self.output_width}x{self.output_height} over "
|
|
f"{region.width_m / 1000:.2f} x {region.height_m / 1000:.2f} km "
|
|
f"({region.width_m / self.output_width:.2f} m a pixel)")
|
|
|
|
|
|
def load_manifest(path=MANIFEST_PATH):
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
return WorldMapManifest(json.load(f), path)
|