Tooling
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
"""The region manifest: RawContent/World/Region.json, which says how a window of a cylindrical planet map
|
||||
becomes a tiled Unreal landscape. Pure Python (no numpy, no engine), shared by generate_region_tiles.py (writes
|
||||
the PNGs) and create_region_world.py (imports them), so both agree without either knowing about the other.
|
||||
|
||||
Why a second manifest beside World.json. World.json describes one landscape built by the numpy pipeline from
|
||||
noise; this describes a *window cut from a finished planet map* and laid out as a grid of landscapes, because
|
||||
2549 km2 at a 2 m quad is 637 M vertices and no single Landscape actor is going to take that in one import. The
|
||||
two share the height contract (`elevation_m` spans the 16-bit range, world Z 0 is elevation 0 m) and nothing
|
||||
else.
|
||||
|
||||
**The grid is rectangular.** `tiles.columns` run along world X and `tiles.rows` along world Y, because a
|
||||
cylindrical planet map is 2:1 and forcing it into a square either crops it or stretches it. Set the two so that
|
||||
columns/rows matches the window's width/height in pixels and the ground is undistorted;
|
||||
`generate_region_tiles.py --scout` prints the metres-per-pixel of each axis and complains when they disagree.
|
||||
|
||||
The window. The source is read as a *flat* image: `source.metres_per_pixel` says what one of its pixels is worth
|
||||
and `source.window` is a rectangle of pixels in it (the whole image, by default). A cylindrical map read flat is
|
||||
stretched east-west by 1/cos(latitude), which is a few per cent at middle latitudes and severe near the poles;
|
||||
reading it flat is deliberate, because unprojecting needs a circumference the map does not carry and every
|
||||
projection of a window this size distorts more than the flat reading does.
|
||||
|
||||
The tiles. `columns` x `rows` landscapes of `tiles.vertices` a side, each its own actor at its own place in one
|
||||
world-partitioned level. Neighbours *share* their edge vertices: tile (tx, ty) covers global vertices
|
||||
[tx * quads_per_tile, tx * quads_per_tile + quads_per_tile], so the last column of one tile is the first column
|
||||
of the next and the seam is closed by construction rather than by blending. Pick `tiles.vertices` as 255 * N + 1
|
||||
so the engine gives every tile N x N components of 255 quads; see RawContent/World/README.md for why the
|
||||
component count is what matters.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
from world_manifest import ENGINE_SPAN_M_AT_SCALE_100, PROJECT_ROOT, WORLD_DIR
|
||||
|
||||
MANIFEST_PATH = os.path.join(WORLD_DIR, "Region.json")
|
||||
TILE_DIR = os.path.join(WORLD_DIR, "RegionTiles")
|
||||
|
||||
# The paint layers a manifest gets when it names none, which is every manifest written before D-74: the three
|
||||
# Elite_RockyMeadows' landscape material blends. The names mislead and are the pack's - Base_Layer is the rock,
|
||||
# Layer_02 the meadow grass, Layer_03 the high rock - and they are kept because the material blends by name.
|
||||
LEGACY_PAINT = (
|
||||
{"name": "Base_Layer", "rule": "slope"},
|
||||
{"name": "Layer_03", "rule": "altitude"},
|
||||
{"name": "Layer_02", "rule": "remainder"},
|
||||
)
|
||||
|
||||
# Still exported: region_overview.py and create_region_world.py imported it before the paint list existed, and
|
||||
# a manifest with no `paint` block still produces exactly this.
|
||||
LAYER_SUFFIXES = {entry["name"]: entry["name"] for entry in LEGACY_PAINT}
|
||||
|
||||
# Every rule a paint layer may carry. `slope`, `altitude` and `beach` are read off the tile's own height;
|
||||
# `class` and `climate` are read off a biome mask that Tools/MapArt wrote; `remainder` is whatever no other
|
||||
# layer claimed, and exactly one layer must be it or the weights do not sum to 255.
|
||||
PAINT_RULES = ("slope", "altitude", "beach", "class", "climate", "remainder")
|
||||
|
||||
|
||||
class PaintLayer:
|
||||
"""One paint layer: what the landscape material blends, what drives it, and whether it is built yet.
|
||||
|
||||
`enabled` is what lets the biome layers land before the substances do. A layer that is not enabled is
|
||||
written by nobody and imported by nobody, so today's three-layer world is untouched, but `mapart biomes`
|
||||
still renders its mask - which is what makes a biome inspectable before there is any material for it.
|
||||
"""
|
||||
|
||||
def __init__(self, data):
|
||||
self.name = data["name"]
|
||||
self.rule = data.get("rule", "remainder")
|
||||
# The file suffix defaults to the layer's name, so L_World_x0_y0_Sand.png needs no second spelling.
|
||||
self.suffix = data.get("suffix", self.name)
|
||||
self.enabled = bool(data.get("enabled", True))
|
||||
self.classes = list(data.get("classes", []))
|
||||
self.koppen = list(data.get("koppen", []))
|
||||
self.note = data.get("note", "")
|
||||
if self.rule not in PAINT_RULES:
|
||||
raise ValueError(f"paint layer {self.name!r}: unknown rule {self.rule!r}; expected one of {PAINT_RULES}")
|
||||
if self.rule == "class" and not self.classes:
|
||||
raise ValueError(f"paint layer {self.name!r}: rule 'class' names no classes")
|
||||
if self.rule == "climate" and not self.koppen:
|
||||
raise ValueError(f"paint layer {self.name!r}: rule 'climate' names no Koppen codes")
|
||||
|
||||
@property
|
||||
def reads_mask(self):
|
||||
"""True when this layer's weight comes from a mask file rather than from the tile's own height."""
|
||||
return self.rule in ("class", "climate")
|
||||
|
||||
def __repr__(self):
|
||||
return f"PaintLayer({self.name!r}, {self.rule!r}, enabled={self.enabled})"
|
||||
HEIGHT_SUFFIX = "Height"
|
||||
MARKS_SUFFIX = "Marks" # reserved for the overlay's 8-bit mark index (D-57). Nothing writes one yet
|
||||
|
||||
|
||||
class RegionManifest:
|
||||
def __init__(self, data, path=MANIFEST_PATH):
|
||||
self.path = path
|
||||
# The one world level. Tile files are named after its last segment, so changing `level` renames every
|
||||
# tile the manifest expects - rename the PNGs in RegionTiles/ to match or they are all regenerated.
|
||||
self.level = data.get("level", "/Game/Maps/L_World")
|
||||
self.quad_cm = float(data["quad_cm"])
|
||||
tiles = data["tiles"]
|
||||
# `count` is the old square spelling; columns/rows supersede it.
|
||||
square = int(tiles["count"]) if "count" in tiles else None
|
||||
self.tiles_x = int(tiles.get("columns", square))
|
||||
self.tiles_y = int(tiles.get("rows", square))
|
||||
self.vertices_per_tile = int(tiles["vertices"])
|
||||
self.elevation_min_m = float(data["elevation_m"]["min"])
|
||||
self.elevation_max_m = float(data["elevation_m"]["max"])
|
||||
self.sea_level_m = float(data.get("sea_level_m", 0.0))
|
||||
self.spawn_pad_m = float(data.get("spawn_pad_m", 150.0))
|
||||
self.streaming_grid_components = int(data.get("streaming_grid_components", 4))
|
||||
self.source = dict(data["source"])
|
||||
self.overlay = dict(data.get("overlay", {}))
|
||||
self.layers = dict(data.get("layers", {}))
|
||||
|
||||
# The paint layers, in order. A manifest with no `paint` block gets the three the pack's material
|
||||
# blends, which is what every manifest written before D-74 means.
|
||||
self.paint_layers = [PaintLayer(entry) for entry in self.layers.get("paint", LEGACY_PAINT)]
|
||||
names = [layer.name for layer in self.paint_layers]
|
||||
if len(set(names)) != len(names):
|
||||
raise ValueError(f"{path}: two paint layers share a name: {names}")
|
||||
remainders = [layer.name for layer in self.paint_layers if layer.rule == "remainder"]
|
||||
if len(remainders) != 1:
|
||||
# Not a warning. With none the weights do not reach 255 and the ground shows the material's first
|
||||
# layer wherever nothing claimed it; with two they fight over the same leftover.
|
||||
raise ValueError(f"{path}: exactly one paint layer must have rule 'remainder', found {remainders}")
|
||||
if not any(layer.enabled for layer in self.paint_layers):
|
||||
raise ValueError(f"{path}: no paint layer is enabled, so the landscape would have no weightmaps")
|
||||
|
||||
# Where the biome masks come from and where Tools/MapArt puts them.
|
||||
self.biomes = dict(self.layers.get("biomes", {}))
|
||||
if min(self.tiles_x, self.tiles_y) < 1 or self.vertices_per_tile < 2:
|
||||
raise ValueError(f"{path}: tiles.columns/rows must be at least 1 and tiles.vertices at least 2")
|
||||
if self.elevation_max_m <= self.elevation_min_m or self.quad_cm <= 0:
|
||||
raise ValueError(f"{path}: quad_cm must be positive and elevation_m ordered")
|
||||
|
||||
# Geometry: one tile, then the whole window.
|
||||
@property
|
||||
def quads_per_tile(self):
|
||||
return self.vertices_per_tile - 1
|
||||
|
||||
@property
|
||||
def tile_side_m(self):
|
||||
return self.quads_per_tile * self.quad_cm / 100.0
|
||||
|
||||
@property
|
||||
def quads_x(self):
|
||||
return self.quads_per_tile * self.tiles_x
|
||||
|
||||
@property
|
||||
def quads_y(self):
|
||||
return self.quads_per_tile * self.tiles_y
|
||||
|
||||
@property
|
||||
def vertices_x(self):
|
||||
"""Distinct vertices across the window in X. Tiles share their edges, so it is not columns * vertices."""
|
||||
return self.quads_x + 1
|
||||
|
||||
@property
|
||||
def vertices_y(self):
|
||||
return self.quads_y + 1
|
||||
|
||||
@property
|
||||
def width_m(self):
|
||||
return self.quads_x * self.quad_cm / 100.0
|
||||
|
||||
@property
|
||||
def height_m(self):
|
||||
return self.quads_y * self.quad_cm / 100.0
|
||||
|
||||
@property
|
||||
def area_km2(self):
|
||||
return (self.width_m / 1000.0) * (self.height_m / 1000.0)
|
||||
|
||||
@property
|
||||
def side_m(self):
|
||||
"""How big this world is, for anything that only needs one number: the sky dome's radius, the sea
|
||||
plane's size, the fog's density. The longer axis, so those all still cover the whole world.
|
||||
rocky_meadows.py reads this and World.json's manifest has it too."""
|
||||
return max(self.width_m, self.height_m)
|
||||
|
||||
def centre_vertex(self):
|
||||
"""(tx, ty, i, j) of the vertex at the centre of the window: which tile it is in and where in that
|
||||
tile's PNG, columns then rows. On an odd grid the centre is mid-tile rather than on a corner, which is
|
||||
why this is worked out rather than assumed to be tile (n/2, n/2) at [0, 0]."""
|
||||
gi, gj = self.quads_x // 2, self.quads_y // 2
|
||||
tx, i = divmod(gi, self.quads_per_tile)
|
||||
ty, j = divmod(gj, self.quads_per_tile)
|
||||
if tx >= self.tiles_x: # the far edge belongs to the last tile's last vertex
|
||||
tx, i = self.tiles_x - 1, self.quads_per_tile
|
||||
if ty >= self.tiles_y:
|
||||
ty, j = self.tiles_y - 1, self.quads_per_tile
|
||||
return tx, ty, i, j
|
||||
|
||||
@property
|
||||
def tile_count(self):
|
||||
return self.tiles_x * self.tiles_y
|
||||
|
||||
def quads_along(self, axis):
|
||||
"""Quads across the whole window along axis 0 (world X, the columns) or 1 (world Y, the rows)."""
|
||||
return self.quads_x if axis == 0 else self.quads_y
|
||||
|
||||
def tiles_along(self, axis):
|
||||
return self.tiles_x if axis == 0 else self.tiles_y
|
||||
|
||||
# The height contract, identical to World.json's.
|
||||
@property
|
||||
def elevation_span_m(self):
|
||||
return self.elevation_max_m - self.elevation_min_m
|
||||
|
||||
@property
|
||||
def elevation_mid_m(self):
|
||||
return (self.elevation_max_m + self.elevation_min_m) / 2.0
|
||||
|
||||
@property
|
||||
def z_scale(self):
|
||||
"""The landscape actor's Z scale, so the 16-bit range spans exactly the manifest's elevation range."""
|
||||
return self.elevation_span_m / ENGINE_SPAN_M_AT_SCALE_100 * 100.0
|
||||
|
||||
@property
|
||||
def landscape_z_cm(self):
|
||||
"""Every tile's Z: value 32768 sits at elevation_mid, so elevation 0 m lands on world Z 0."""
|
||||
return self.elevation_mid_m * 100.0
|
||||
|
||||
@property
|
||||
def sea_level_z_cm(self):
|
||||
return self.sea_level_m * 100.0
|
||||
|
||||
def metres_to_value(self, metres):
|
||||
return (metres - self.elevation_min_m) / self.elevation_span_m * 65535.0
|
||||
|
||||
# The source window.
|
||||
@property
|
||||
def source_path(self):
|
||||
"""The image the tiles are cut from. Not every manifest has one: World Orogen's Unreal export
|
||||
renders the tiles itself and records where they came from instead of naming a file, so `kind` is
|
||||
`orogen_render` and there is nothing here to re-cut from. Only generate_region_tiles.py asks for
|
||||
this, and it is the one thing that cannot run against such a manifest - said plainly here, because
|
||||
the path into it is `create_region_world.ensure_tiles` noticing a *missing tile file*, and a bare
|
||||
KeyError three frames down does not explain that the tiles have to come from Orogen again."""
|
||||
if "path" not in self.source:
|
||||
raise RuntimeError(
|
||||
f"{self.path}: source.kind is {self.source.get('kind', 'unset')!r} and names no file, so the "
|
||||
f"tiles cannot be cut here - they were written by World Orogen's Unreal landscape export. "
|
||||
f"Re-export them from Orogen into {TILE_DIR}, or point source.path at a planet heightmap to "
|
||||
f"use generate_region_tiles.py instead."
|
||||
)
|
||||
return self.resolve(self.source["path"])
|
||||
|
||||
@property
|
||||
def source_elevation(self):
|
||||
"""What the source's 0 and 65535 mean in metres. Orogen's heightmap export is a fixed -5000..6000 ramp
|
||||
whatever the planet, which is why this is a manifest number rather than something read from the file."""
|
||||
elevation = self.source.get("elevation_m", {"min": -5000.0, "max": 6000.0})
|
||||
return float(elevation["min"]), float(elevation["max"])
|
||||
|
||||
@property
|
||||
def source_window(self):
|
||||
"""(x, y, width, height) in source pixels. x is taken modulo the image width, so a window may cross the
|
||||
map's seam. `size` is the old square spelling."""
|
||||
window = self.source["window"]
|
||||
if "size" in window:
|
||||
return int(window["x"]), int(window["y"]), int(window["size"]), int(window["size"])
|
||||
return int(window["x"]), int(window["y"]), int(window["width"]), int(window["height"])
|
||||
|
||||
@property
|
||||
def sea_scale(self):
|
||||
"""Everything below sea level in the source is multiplied by this. The Orogen export puts its abyss at
|
||||
-5000 m on a fixed ramp built for a whole planet; left alone it would either clip against
|
||||
`elevation_m.min` or force an elevation range so wide the land loses its precision."""
|
||||
return float(self.source.get("sea_scale", 1.0))
|
||||
|
||||
def metres_per_pixel(self, axis):
|
||||
"""Ground metres one source pixel is worth along axis 0 (X) or 1 (Y). Derived from the window and the
|
||||
world, so the two can never drift apart; the manifest's own `metres_per_pixel` is documentation."""
|
||||
_, _, width, height = self.source_window
|
||||
return (self.width_m / width) if axis == 0 else (self.height_m / height)
|
||||
|
||||
# Files.
|
||||
def tile_name(self, tx, ty):
|
||||
return f"{os.path.basename(self.level)}_x{tx}_y{ty}"
|
||||
|
||||
def tile_path(self, tx, ty, suffix):
|
||||
return os.path.join(TILE_DIR, f"{self.tile_name(tx, ty)}_{suffix}.png")
|
||||
|
||||
def height_path(self, tx, ty):
|
||||
return self.tile_path(tx, ty, HEIGHT_SUFFIX)
|
||||
|
||||
# The paint layers.
|
||||
@property
|
||||
def enabled_layers(self):
|
||||
"""The layers that are written, imported and blended. The others exist in the manifest and have masks,
|
||||
and are waiting for a substance (D-74)."""
|
||||
return [layer for layer in self.paint_layers if layer.enabled]
|
||||
|
||||
def find_layer(self, layer_name):
|
||||
for layer in self.paint_layers:
|
||||
if layer.name == layer_name:
|
||||
return layer
|
||||
raise KeyError(f"{self.path}: no paint layer named {layer_name!r}")
|
||||
|
||||
def weight_path(self, tx, ty, layer_name):
|
||||
return self.tile_path(tx, ty, self.find_layer(layer_name).suffix)
|
||||
|
||||
def marks_path(self, tx, ty):
|
||||
return self.tile_path(tx, ty, MARKS_SUFFIX)
|
||||
|
||||
def tile_files(self, tx, ty):
|
||||
"""Every file a tile needs to exist before it can be imported. Only the enabled layers: a tile is not
|
||||
missing because a biome nobody has a substance for has no weightmap."""
|
||||
return ([self.height_path(tx, ty)]
|
||||
+ [self.weight_path(tx, ty, layer.name) for layer in self.enabled_layers])
|
||||
|
||||
# The biome masks, which Tools/MapArt writes and generate_region_tiles.py samples.
|
||||
@property
|
||||
def masks_dir(self):
|
||||
return self.resolve(self.biomes.get("masks_dir", "RawContent/World/Biomes"))
|
||||
|
||||
def mask_path(self, layer):
|
||||
"""The mask for a class- or climate-driven layer. Named after the layer, not after what it reads, so
|
||||
two layers reading the same class still get one file each."""
|
||||
return os.path.join(self.masks_dir, f"mask_{layer.name.lower()}.png")
|
||||
|
||||
@property
|
||||
def biome_blend_m(self):
|
||||
return float(self.biomes.get("blend_m", 400.0))
|
||||
|
||||
def tiles(self):
|
||||
for ty in range(self.tiles_y):
|
||||
for tx in range(self.tiles_x):
|
||||
yield tx, ty
|
||||
|
||||
# Placement. The landscape library centres a landscape on the Location it is given, so a tile's location is
|
||||
# its own centre, measured from the window's centre so the whole grid straddles the origin.
|
||||
def tile_centre_cm(self, tx, ty):
|
||||
cx = (tx * self.quads_per_tile + self.quads_per_tile / 2.0) - self.quads_x / 2.0
|
||||
cy = (ty * self.quads_per_tile + self.quads_per_tile / 2.0) - self.quads_y / 2.0
|
||||
return cx * self.quad_cm, cy * self.quad_cm
|
||||
|
||||
def resolve(self, relative):
|
||||
"""A manifest path is relative to the project root unless it is absolute."""
|
||||
return relative if os.path.isabs(relative) else os.path.normpath(os.path.join(PROJECT_ROOT, relative))
|
||||
|
||||
def describe(self):
|
||||
return (f"{self.tiles_x}x{self.tiles_y} tiles of {self.vertices_per_tile} vertices at {self.quad_cm:g} cm: "
|
||||
f"{self.tile_side_m / 1000:.3f} km a tile, {self.width_m / 1000:.2f} x {self.height_m / 1000:.2f} km, "
|
||||
f"{self.area_km2:.0f} km2; elevation {self.elevation_min_m:g}..{self.elevation_max_m:g} m "
|
||||
f"(Z scale {self.z_scale:g}, actor Z {self.landscape_z_cm:g} cm)")
|
||||
|
||||
|
||||
def load_manifest(path=MANIFEST_PATH):
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return RegionManifest(json.load(f), path)
|
||||
Reference in New Issue
Block a user