"""Imports the world map's art and writes the definition asset the map view reads. Scripts/Authoring/build_world_map.sh # the whole thing: render the art, then this UnrealEditor-Cmd.exe Salty.uproject -run=pythonscript -script="/create_world_map.py --dry-run" Two inputs and nothing else: RawContent/World/MapArt/layers.json (which pictures, how big) and RawContent/World/Region.json (how big the world is, where its heights sit). The PNGs themselves are written by Tools/MapArt, which is a Go tool because the engine's Python has no image library that can decode 33 megapixels of RGB. This script does not render anything; if a layer's PNG is missing it says so and stops rather than importing a stale one. Idempotent. A rerun re-imports the textures in place and rewrites the definition, which is what makes changing a layer a line in layers.json and one command. Nothing is ever deleted: a layer removed from the manifest leaves its texture behind, unreferenced, for a person to decide about. The projection is copied out of Region.json rather than typed here, so the map and the landscape can never disagree about how big the world is - the one bug in a map view that looks like a plausible map. """ import os import sys import unreal HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) from world_map_manifest import load_manifest # noqa: E402 asset_lib = unreal.EditorAssetLibrary asset_tools = unreal.AssetToolsHelpers.get_asset_tools() DRY_RUN = "--dry-run" in sys.argv def log(message): unreal.log(message) def import_texture(png_path, package, asset_name, wraps_x): """One PNG to one UTexture2D, replacing whatever is at that path. Returns the texture, or None.""" asset_path = f"{package}/{asset_name}" if DRY_RUN: verb = "would re-import" if asset_lib.does_asset_exist(asset_path) else "would import" log(f" {verb} {os.path.basename(png_path)} -> {asset_path}") return None task = unreal.AssetImportTask() task.set_editor_property("filename", png_path) task.set_editor_property("destination_path", package) task.set_editor_property("destination_name", asset_name) task.set_editor_property("automated", True) task.set_editor_property("replace_existing", True) task.set_editor_property("save", False) # saved once at the end, with the definition asset_tools.import_asset_tasks([task]) texture = asset_lib.load_asset(asset_path) if texture is None: raise RuntimeError(f"{png_path} did not import to {asset_path}") # Mips matter here more than anywhere: the map is almost always minified - the whole 4096 px world fitted # into a panel a thousand pixels wide - and an unmipped map shimmers with every pan. texture.set_editor_property("mip_gen_settings", unreal.TextureMipGenSettings.TMGS_SIMPLE_AVERAGE) texture.set_editor_property("compression_settings", unreal.TextureCompressionSettings.TC_DEFAULT) texture.set_editor_property("srgb", True) texture.set_editor_property("filter", unreal.TextureFilter.TF_BILINEAR) # X wraps when the art is a whole cylinder, so bilinear at the seam samples the other side of the world # instead of clamping to the edge column. Y never wraps: there is no route across a pole. texture.set_editor_property("address_x", unreal.TextureAddress.TA_WRAP if wraps_x else unreal.TextureAddress.TA_CLAMP) texture.set_editor_property("address_y", unreal.TextureAddress.TA_CLAMP) size = (texture.blueprint_get_size_x(), texture.blueprint_get_size_y()) log(f" {os.path.basename(png_path)} -> {asset_path} {size[0]}x{size[1]}") return texture def make_projection(values): projection = unreal.WorldMapProjection() projection.set_editor_property("width_m", values["width_m"]) projection.set_editor_property("height_m", values["height_m"]) projection.set_editor_property("centre_m", unreal.Vector2D(values["centre_m"][0], values["centre_m"][1])) projection.set_editor_property("wraps_x", values["wraps_x"]) projection.set_editor_property("elevation_min_m", values["elevation_min_m"]) projection.set_editor_property("elevation_max_m", values["elevation_max_m"]) projection.set_editor_property("sea_level_m", values["sea_level_m"]) return projection def ensure_definition(manifest): """The definition asset, created if it is not there. Never deleted and recreated: a Blueprint or a level that already points at it would lose the reference.""" path = f"{manifest.package}/{manifest.definition_name}" if asset_lib.does_asset_exist(path): return asset_lib.load_asset(path) if DRY_RUN: log(f" would create {path}") return None factory = unreal.DataAssetFactory() factory.set_editor_property("data_asset_class", unreal.WorldMapDefinition) asset = asset_tools.create_asset(manifest.definition_name, manifest.package, None, factory) if asset is None: raise RuntimeError(f"could not create {path}") log(f" created {path}") return asset def verify(definition, manifest, projection, textures): """Reads every property back off the asset and refuses to go on if one of them did not take. This exists because a property set that silently does nothing looks exactly like one that worked, and this asset has already had that failure once: every property here was EditDefaultsOnly, which Python declines to write on an instance, and a UDataAsset is an instance. A save is not evidence; the value is. `level` is read with export_text() rather than str(): an FSoftObjectPath's fields are not UPROPERTYs, so Python reprs it as an empty struct whether it holds a path or not. str() here reports a correct asset as broken, which cost an afternoon once and should not cost another. """ problems = [] def check(what, got, want): if got != want: problems.append(f"{what}: the asset says {got!r}, the manifests say {want!r}") stored = definition.get_editor_property("projection") for key in ("width_m", "height_m", "wraps_x", "elevation_min_m", "elevation_max_m", "sea_level_m"): check(f"projection.{key}", stored.get_editor_property(key), projection[key]) centre = stored.get_editor_property("centre_m") check("projection.centre_m", (centre.x, centre.y), tuple(projection["centre_m"])) level = definition.get_editor_property("level") check("level", level.export_text(), f"{manifest.level}.{manifest.level.rsplit('/', 1)[1]}") check("default_layer", str(definition.get_editor_property("default_layer")), manifest.default_layer_id) stored_layers = definition.get_editor_property("layers") check("layer count", len(stored_layers), len(manifest.layers)) want_address = unreal.TextureAddress.TA_WRAP if projection["wraps_x"] else unreal.TextureAddress.TA_CLAMP for entry, want in zip(stored_layers, manifest.layers): check(f"layer[{want.id}].id", str(entry.get_editor_property("id")), want.id) texture = entry.get_editor_property("texture") check(f"layer[{want.id}].texture", texture.get_name() if texture else None, want.asset_name) if texture: check(f"layer[{want.id}].size", (texture.blueprint_get_size_x(), texture.blueprint_get_size_y()), (manifest.output_width, manifest.output_height)) # Compared as the enum, not as a string: str() on a Python enum is "". check(f"layer[{want.id}].address_x", texture.get_editor_property("address_x"), want_address) if problems: for line in problems: unreal.log_error(f" {line}") raise RuntimeError(f"the definition did not take {len(problems)} of its values; nothing saved") log(f" verified: projection, level, {len(stored_layers)} layer(s), " f"{'wrapping' if projection['wraps_x'] else 'non-wrapping'} textures") def main(): manifest = load_manifest() region = manifest.region() report = manifest.report() projection = manifest.projection(region, report) log(f"world map: {manifest.describe(region)}") log(f" projection: {projection['width_m'] / 1000:.2f} x {projection['height_m'] / 1000:.2f} km, " f"elevation {projection['elevation_min_m']:.0f}..{projection['elevation_max_m']:.0f} m, " f"{'wraps in X' if projection['wraps_x'] else 'does not wrap'}") if report is None: log(" (no mapart.json: the art has not been rendered, so the map is assumed not to wrap)") missing = [layer for layer in manifest.layers if not os.path.exists(manifest.output_path(layer))] if missing: names = ", ".join(layer.output_name for layer in missing) raise RuntimeError( f"{names} not in {manifest.output_dir}. Render the art first:\n" f" cd Tools/MapArt && go run . build") textures = {} for layer in manifest.layers: textures[layer.id] = import_texture( manifest.output_path(layer), manifest.package, layer.asset_name, projection["wraps_x"]) definition = ensure_definition(manifest) if definition is None: log("dry run: nothing written") return layers = [] for layer in manifest.layers: entry = unreal.WorldMapLayer() entry.set_editor_property("id", unreal.Name(layer.id)) entry.set_editor_property("display_name", unreal.Text(layer.name)) entry.set_editor_property("texture", textures[layer.id]) entry.set_editor_property("note", layer.note) layers.append(entry) definition.set_editor_property("projection", make_projection(projection)) definition.set_editor_property("level", unreal.SoftObjectPath(f"{manifest.level}.{manifest.level.rsplit('/', 1)[1]}")) definition.set_editor_property("layers", layers) definition.set_editor_property("default_layer", unreal.Name(manifest.default_layer_id)) definition.set_editor_property("built_from", ( f"Scripts/Authoring/create_world_map.py from {os.path.relpath(manifest.path, manifest.resolve('.'))} " f"and {manifest.region_path}; art at {manifest.output_width}x{manifest.output_height}")) verify(definition, manifest, projection, textures) saved = [f"{manifest.package}/{manifest.definition_name}"] saved += [f"{manifest.package}/{layer.asset_name}" for layer in manifest.layers] for path in saved: if not asset_lib.save_asset(path, only_if_is_dirty=False): raise RuntimeError(f"could not save {path}") log(f"world map saved: {len(manifest.layers)} layer(s), default '{manifest.default_layer_id}', " f"definition {manifest.package}/{manifest.definition_name}") main()