"""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)