"""Draws an overview of the region straight from the tiles, so there is something to navigate by that does not depend on the editor. python Scripts/Authoring/region_overview.py # height, shaded python Scripts/Authoring/region_overview.py --mode paint # the three paint layers as colour python Scripts/Authoring/region_overview.py --mode both # writes both Writes RawContent/World/Region_Overview.png (and Region_Paint.png for the paint mode). **Orientation.** A tile's PNG has world X across its columns and world Y down its rows - verified, not assumed: tile (0,0)'s last column is tile (1,0)'s first column, and its last row is tile (0,1)'s first row. The mosaic is therefore laid out rows = ty, columns = tx, with no transpose anywhere, which also means the result is the same way up as the crop it came from in the source planet map. An earlier version indexed the mosaic rows by tx and columns by ty, which transposed every tile inside its own square and scrambled the map. **Scale.** The hypsometric ramp runs to `--top-m`, an absolute ceiling in metres, not to the map's own maximum. Normalising by the maximum is how a 30 km window whose median ground is 151 m comes out uniformly dark green with the whole ramp spent on one peak - the same trap Docs record for the generator's preview.png. Every run prints the ceiling it used. """ import argparse import os import struct import sys import zlib HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) sys.path.insert(0, os.path.join(HERE, ".pylib")) import numpy as np # noqa: E402 import heightmap_io # noqa: E402 from region_manifest import MANIFEST_PATH, load_manifest # noqa: E402 # Height ramp, as fractions of the ceiling: water, shore, lowland, upland, rock, snow. HEIGHT_RAMP = [ (0.00, (58, 84, 120)), (0.04, (128, 150, 96)), (0.22, (108, 138, 80)), (0.45, (150, 142, 96)), (0.70, (146, 132, 120)), (0.88, (200, 198, 196)), (1.00, (248, 248, 250)), ] # What each paint layer is drawn as. The pack's names mislead: Base_Layer is rock, Layer_02 meadow grass, # Layer_03 high rock. PAINT_COLOURS = { "Base_Layer": (150, 128, 108), "Layer_02": (96, 138, 74), "Layer_03": (214, 214, 218), } def write_rgb_png(path, rgb): """An 8-bit colour PNG; heightmap_io only writes the greyscale the landscape wants.""" height, width, _ = rgb.shape raw = bytearray() for row in rgb: raw.append(0) raw.extend(row.tobytes()) def chunk(kind, payload): return (struct.pack(">I", len(payload)) + kind + payload + struct.pack(">I", zlib.crc32(kind + payload) & 0xFFFFFFFF)) with open(path, "wb") as f: f.write(b"\x89PNG\r\n\x1a\n") f.write(chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0))) f.write(chunk(b"IDAT", zlib.compress(bytes(raw), 6))) f.write(chunk(b"IEND", b"")) def ramp_colour(t, ramp): stops = np.array([s for s, _ in ramp], dtype=np.float32) cols = np.array([c for _, c in ramp], dtype=np.float32) out = np.empty(t.shape + (3,), dtype=np.float32) for channel in range(3): out[..., channel] = np.interp(t, stops, cols[:, channel]) return out def mosaic(manifest, per_tile, reader): """Every tile laid out rows = ty, columns = tx, which is the orientation the tiles are actually in.""" wide, high = per_tile * manifest.tiles_x, per_tile * manifest.tiles_y out = None for tx, ty in manifest.tiles(): block = reader(tx, ty) if block is None: continue step = max(1, block.shape[0] // per_tile) small = block[::step, ::step][:per_tile, :per_tile] if out is None: out = np.zeros((high, wide) + small.shape[2:], dtype=np.float32) out[ty * per_tile:(ty + 1) * per_tile, tx * per_tile:(tx + 1) * per_tile] = small return out def hillshade(metres, metres_per_px): """A proper hillshade: the cosine between the surface normal and a light from the north-west, 45 degrees up. The previous version multiplied by the raw gradient, which clipped to black and white wherever the ground was steep.""" gy, gx = np.gradient(metres.astype(np.float32), metres_per_px) nz = 1.0 / np.sqrt(gx * gx + gy * gy + 1.0) nx, ny = -gx * nz, -gy * nz lx, ly, lz = -0.5, -0.5, 0.7071 norm = np.sqrt(lx * lx + ly * ly + lz * lz) shade = (nx * lx + ny * ly + nz * lz) / norm return np.clip(0.45 + 0.85 * shade, 0.25, 1.35)[..., None] def draw_line(img, axis, index, colour, width=1): lo = max(0, index - width // 2) hi = min(img.shape[axis], lo + width) if lo >= hi: return if axis == 0: img[lo:hi, :, :] = colour else: img[:, lo:hi, :] = colour def decorate(img, manifest, per_tile): high, wide = img.shape[0], img.shape[1] px_per_km_x = wide / (manifest.width_m / 1000.0) px_per_km_y = high / (manifest.height_m / 1000.0) for km in range(1, int(manifest.width_m / 1000.0) + 1): draw_line(img, 1, int(km * px_per_km_x), (110, 110, 120)) for km in range(1, int(manifest.height_m / 1000.0) + 1): draw_line(img, 0, int(km * px_per_km_y), (110, 110, 120)) for i in range(manifest.tiles_x + 1): draw_line(img, 1, min(wide - 1, i * per_tile), (250, 214, 84), 2) for j in range(manifest.tiles_y + 1): draw_line(img, 0, min(high - 1, j * per_tile), (250, 214, 84), 2) draw_line(img, 1, wide // 2, (255, 86, 86), 3) draw_line(img, 0, high // 2, (255, 86, 86), 3) def read_metres(manifest, tx, ty): path = manifest.height_path(tx, ty) if not os.path.isfile(path): return None v = heightmap_io.read_png(path).astype(np.float32) return manifest.elevation_min_m + v / 65535.0 * manifest.elevation_span_m def colour_height(manifest, metres, land, top_m): img = ramp_colour(np.clip(metres / max(top_m, 1e-6), 0.0, 1.0), HEIGHT_RAMP) img[~land] = (46, 62, 88) return np.clip(img * hillshade(metres, manifest.width_m / img.shape[1]), 0, 255) def paint_map(manifest, per_tile): """The three weightmaps composited by weight: what the landscape is painted with, whatever the material ends up rendering.""" layers = {} for name in (layer.name for layer in manifest.enabled_layers): def read(tx, ty, name=name): path = manifest.weight_path(tx, ty, name) if not os.path.isfile(path): return None return heightmap_io.read_png(path).astype(np.float32) / 255.0 layers[name] = mosaic(manifest, per_tile, read) total = np.zeros_like(next(iter(layers.values()))) img = np.zeros(total.shape + (3,), dtype=np.float32) for name, weight in layers.items(): img += weight[..., None] * np.array(PAINT_COLOURS[name], dtype=np.float32) total += weight img /= np.maximum(total, 1e-6)[..., None] return np.clip(img, 0, 255), {k: float(v.mean()) for k, v in layers.items()} def main(argv=None): parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) parser.add_argument("--manifest", default=MANIFEST_PATH) parser.add_argument("--pixels", type=int, default=2048) parser.add_argument("--mode", choices=("height", "paint", "both"), default="height") parser.add_argument("--top-m", type=float, help="ceiling of the height ramp in metres; default is the " "99th percentile of the land, printed either way") args = parser.parse_args(argv) manifest = load_manifest(args.manifest) folder = os.path.dirname(manifest.path) per_tile = max(1, args.pixels // max(manifest.tiles_x, manifest.tiles_y)) wide, high = per_tile * manifest.tiles_x, per_tile * manifest.tiles_y hx, hy = manifest.width_m / 2.0, manifest.height_m / 2.0 print(f"{wide}x{high} px, {manifest.width_m / 1000:.2f} x {manifest.height_m / 1000:.2f} km, " f"{wide / manifest.width_m * 1000:.1f} px/km") print(f" columns are world X {-hx:+.0f}..{+hx:+.0f} m, rows are world Y {-hy:+.0f}..{+hy:+.0f} m") print(f" world X cm = col * {manifest.width_m * 100 / wide:.2f} - {hx * 100:.0f}, " f"Y cm = row * {manifest.height_m * 100 / high:.2f} - {hy * 100:.0f}") print(" yellow lines are tile edges, grey is a kilometre, red is the origin") if args.mode in ("height", "both"): metres = mosaic(manifest, per_tile, lambda tx, ty: read_metres(manifest, tx, ty)) land = metres > manifest.sea_level_m top = args.top_m if args.top_m else float(np.percentile(metres[land], 99.0)) img = colour_height(manifest, metres, land, top) decorate(img, manifest, per_tile) out = os.path.join(folder, "Region_Overview.png") write_rgb_png(out, np.ascontiguousarray(np.rint(img).astype(np.uint8))) print(f" height -> {out}") print(f" ramp ceiling {top:.0f} m; land {land.mean() * 100:.1f}%, " f"height {metres[land].min():.0f}..{metres.max():.0f} m, median {np.median(metres[land]):.0f} m") if args.mode in ("paint", "both"): img, means = paint_map(manifest, per_tile) decorate(img, manifest, per_tile) out = os.path.join(folder, "Region_Paint.png") write_rgb_png(out, np.ascontiguousarray(np.rint(img).astype(np.uint8))) print(f" paint -> {out}") for name, mean in means.items(): print(f" {name:11s} {mean * 100:5.1f}% drawn as {PAINT_COLOURS[name]}") if __name__ == "__main__": main()