219 lines
9.4 KiB
Python
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()
|