Files
2026-09-25 17:02:24 +03:00

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)