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

219 lines
9.4 KiB
Python

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