454 lines
25 KiB
Python
454 lines
25 KiB
Python
"""Cuts the window that RawContent/World/Region.json describes out of a cylindrical planet heightmap and writes
|
|
it as a grid of Unreal-ready tiles in RawContent/World/RegionTiles/: a 16-bit height and one 8-bit weightmap
|
|
per enabled paint layer, sized for one Landscape actor each.
|
|
|
|
Pure numpy, no engine: run it with the engine's Python (numpy lives in Scripts/Authoring/.pylib, see
|
|
bootstrap-pylib.sh), or let create_region_world.py call it when the tiles are missing.
|
|
|
|
D:/UE_5.8/Engine/Binaries/ThirdParty/Python3/Win64/python.exe Scripts/Authoring/generate_region_tiles.py
|
|
... --scout # measure the window and print what it holds, write nothing
|
|
... --tiles 2,2 3,2 # just these tiles, for a look before committing to all ninety-eight
|
|
... --all-layers # build every paint layer, including the biomes nothing can import yet
|
|
|
|
Seams. Neighbouring tiles share their edge vertices, and every vertex is sampled from the source by its
|
|
*global* position in the window, so the shared column is computed from the same source coordinates twice and
|
|
comes out bit-identical. The paint-layer break-up noise is sampled the same way, through fbm_at at global
|
|
coordinates, for the same reason. Nothing here is per-tile except which slice of the window it covers, which is
|
|
what makes a tile's interior what it would have been had the whole window been done in one piece.
|
|
|
|
Resampling. The window is the whole 8192-pixel export and the grid is 35701 vertices, so this is a fourfold
|
|
*upsample* and the filter shows. It is Catmull-Rom, clamped to the two central taps: a plain cubic overshoots
|
|
wherever the source has a step, and the source's steps are its coastlines, where it falls from land to abyss in
|
|
a single pixel. Unclamped, every shore would get a raised lip on the land side and a trench on the sea side.
|
|
Bilinear would not ring but would crease, leaving a visible facet edge along every source pixel boundary.
|
|
|
|
Paint layers. Three read the height alone - rock by slope, high rock by altitude, and one layer that is the
|
|
remainder - and the rest read a *biome*: a whole-planet mask that `mapart biomes` rendered from the painting's
|
|
classes and the Koppen climate, sampled here at the same global coordinates as the height. Which layers exist
|
|
is Region.json's `layers.paint`, and a layer that is not enabled is not built (D-74), so the three-layer world
|
|
this began as is exactly what an unchanged manifest still produces.
|
|
|
|
What this does not do. It does not erode, and there is no wear, flow or deposit map here to paint from, which
|
|
is why the shore layer is an approximation from height and slope rather than a reading of where the coast pass
|
|
actually laid a beach. The source's own detail is about 200 m (Orogen solves on a 204 K-region sphere mesh), so
|
|
below that scale the ground is smooth, and it will stay smooth until the tiles come from `terrain tiles`
|
|
instead. See Docs/World-Pipeline.md for the routes and Docs/Terrain-Next.md for where the real ground comes from.
|
|
"""
|
|
import argparse
|
|
import os
|
|
import sys
|
|
import time
|
|
|
|
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
|
|
import heightmap_noise # noqa: E402
|
|
from region_manifest import MANIFEST_PATH, TILE_DIR, load_manifest # noqa: E402
|
|
|
|
# Vertices sampled beyond a tile on every side before the paint layers are derived, and thrown away after.
|
|
# The layers read slope, np.gradient takes a one-sided difference at an array edge, and a one-sided difference
|
|
# is not what the neighbouring tile computes for the same vertex: without this every tile boundary came out as
|
|
# a one-vertex line of different paint. One vertex is all a central difference needs.
|
|
LAYER_MARGIN = 1
|
|
|
|
LAYER_DEFAULTS = {
|
|
"rock_slope_start": 0.55, # rise over run where rock starts to show through the meadow (about 29 degrees)
|
|
"rock_slope_full": 1.05, # and where it is all rock (about 46 degrees)
|
|
"high_altitude_start_m": 1400, # where the high rock layer starts
|
|
"high_altitude_full_m": 2000, # and where it has taken over
|
|
"breakup_m": 18, # noise added to the altitude before the rules, so boundaries are not contour lines
|
|
"breakup_cells": 24, # the break-up noise's coarsest lattice across the whole window
|
|
"breakup_seed": 7,
|
|
}
|
|
|
|
# The shore rule. Height alone would run sand up every cliff that meets the sea, so `max_slope` is what makes
|
|
# it a beach and not a contour band.
|
|
BEACH_DEFAULTS = {
|
|
"above_sea_m": 12.0, # full strength up to here
|
|
"fade_m": 25.0, # and gone this much above it
|
|
"max_slope": 0.18, # rise over run, about 10 degrees
|
|
}
|
|
|
|
|
|
def _cubic_weights(t):
|
|
"""Catmull-Rom, for taps at -1, 0, +1, +2."""
|
|
t2 = t * t
|
|
t3 = t2 * t
|
|
return (-0.5 * t3 + t2 - 0.5 * t,
|
|
1.5 * t3 - 2.5 * t2 + 1.0,
|
|
-1.5 * t3 + 2.0 * t2 + 0.5 * t,
|
|
0.5 * t3 - 0.5 * t2)
|
|
|
|
|
|
def resample_axis(src, coords, axis, wrap):
|
|
"""One separable pass of clamped Catmull-Rom along `axis` at float `coords`. `wrap` takes the index modulo
|
|
the axis length, for the source's east-west seam; otherwise it clamps to the edge. The result is held
|
|
between the two central taps, which is what stops the filter ringing at a coastline."""
|
|
i = np.floor(coords).astype(np.int64)
|
|
weights = _cubic_weights((coords - i).astype(np.float32))
|
|
length = src.shape[axis]
|
|
indices = [i - 1, i, i + 1, i + 2]
|
|
indices = [k % length if wrap else np.clip(k, 0, length - 1) for k in indices]
|
|
taps = [np.take(src, k, axis=axis) for k in indices]
|
|
shape = [1, 1]
|
|
shape[axis] = -1
|
|
out = sum(tap * weight.reshape(shape) for tap, weight in zip(taps, weights))
|
|
return np.clip(out, np.minimum(taps[1], taps[2]), np.maximum(taps[1], taps[2]))
|
|
|
|
|
|
def load_source_metres(manifest):
|
|
"""The whole source map in metres, with everything below sea level scaled by `sea_scale`."""
|
|
path = manifest.source_path
|
|
if not os.path.isfile(path):
|
|
raise FileNotFoundError(f"{manifest.path}: source {path} not found")
|
|
values = heightmap_io.read_png(path)
|
|
low, high = manifest.source_elevation
|
|
metres = (low + values.astype(np.float32) / 65535.0 * (high - low)).astype(np.float32)
|
|
scale = manifest.sea_scale
|
|
if scale != 1.0:
|
|
below = metres < 0.0
|
|
metres[below] *= scale
|
|
return metres
|
|
|
|
|
|
def window_coords(manifest, indices, axis):
|
|
"""Global vertex indices along one axis to source-pixel coordinates. The window's first and last pixel
|
|
centres land on the window's first and last vertices, so the whole rectangle is used and no edge is
|
|
extrapolated. Axis 0 is world X (the source's columns), axis 1 world Y (its rows)."""
|
|
_, _, width, height = manifest.source_window
|
|
span = (width if axis == 0 else height) - 1
|
|
return indices.astype(np.float64) * span / manifest.quads_along(axis)
|
|
|
|
|
|
def tile_vertices(manifest, tile, margin):
|
|
"""Global vertex indices along one axis for a tile, with `margin` extra on each side. A margin runs off the
|
|
window at the grid's outside edge, which is well defined: the source coordinate simply lands just outside
|
|
the window, where the source still has pixels."""
|
|
return np.arange(-margin, manifest.vertices_per_tile + margin) + tile * manifest.quads_per_tile
|
|
|
|
|
|
def tile_metres(manifest, source, tx, ty, margin=0):
|
|
"""One tile's height in metres, sampled from the source by global position."""
|
|
x0, y0, _, _ = manifest.source_window
|
|
width = source.shape[1]
|
|
sx = x0 + window_coords(manifest, tile_vertices(manifest, tx, margin), 0)
|
|
sy = y0 + window_coords(manifest, tile_vertices(manifest, ty, margin), 1)
|
|
|
|
# Only the source rows this tile reaches, with all columns kept so the x wrap is a plain modulo.
|
|
row0 = int(np.floor(sy[0])) - 1
|
|
row1 = int(np.floor(sy[-1])) + 3
|
|
rows = np.clip(np.arange(row0, row1), 0, source.shape[0] - 1)
|
|
band = source[rows]
|
|
band = resample_axis(band, sx % width, axis=1, wrap=True)
|
|
return resample_axis(band, sy - row0, axis=0, wrap=False).astype(np.float32)
|
|
|
|
|
|
def apply_spawn_pad(manifest, metres, tx, ty, margin=0):
|
|
"""The flat disc at the centre of the *window* for the player starts, blended over a second radius. It is
|
|
computed from global position, so where it crosses a tile boundary the two tiles agree."""
|
|
if manifest.spawn_pad_m <= 0:
|
|
return metres
|
|
quad_m = manifest.quad_cm / 100.0
|
|
dx = (tile_vertices(manifest, tx, margin) - manifest.quads_x / 2.0) * quad_m
|
|
dy = (tile_vertices(manifest, ty, margin) - manifest.quads_y / 2.0) * quad_m
|
|
dist = np.sqrt(dx[None, :] ** 2 + dy[:, None] ** 2)
|
|
radius = manifest.spawn_pad_m
|
|
weight = heightmap_noise.smoothstep(np.clip(1.0 - (dist - radius) / radius, 0.0, 1.0)).astype(np.float32)
|
|
if not weight.any():
|
|
return metres
|
|
return (metres * (1.0 - weight) + pad_height(manifest) * weight).astype(np.float32)
|
|
|
|
|
|
def pad_height(manifest):
|
|
"""Height of the spawn pad, in metres. Read from the source at the window's exact centre rather than from a
|
|
tile, so every tile the pad touches lifts to the same level."""
|
|
if not hasattr(manifest, "_pad_height"):
|
|
raise RuntimeError("pad height not measured; measure_pad_height first")
|
|
return manifest._pad_height
|
|
|
|
|
|
def measure_pad_height(manifest, source):
|
|
x0, y0, win_w, win_h = manifest.source_window
|
|
sx = np.array([x0 + (win_w - 1) / 2.0], dtype=np.float64) % source.shape[1]
|
|
sy = np.array([y0 + (win_h - 1) / 2.0], dtype=np.float64)
|
|
row0 = int(np.floor(sy[0])) - 1
|
|
rows = np.clip(np.arange(row0, row0 + 4), 0, source.shape[0] - 1)
|
|
band = resample_axis(source[rows], sx, axis=1, wrap=True)
|
|
height = float(resample_axis(band, sy - row0, axis=0, wrap=False)[0, 0])
|
|
manifest._pad_height = max(height, manifest.sea_level_m + 30.0) # never a pad in the sea
|
|
return manifest._pad_height
|
|
|
|
|
|
def load_biome_masks(manifest, layers):
|
|
"""The blurred 0..1 masks `mapart biomes` wrote, one per class- or climate-driven layer.
|
|
|
|
Read whole and kept in memory: the largest is 7738x3761 of uint8, 29 MB, and every tile samples all of it.
|
|
They are 8-bit greyscale, which is the only kind of PNG heightmap_io decodes quickly - the classification
|
|
that produced them had to happen in Go because the painting is RGB (D-74)."""
|
|
masks = {}
|
|
for layer in layers:
|
|
if not layer.reads_mask:
|
|
continue
|
|
path = manifest.mask_path(layer)
|
|
if not os.path.isfile(path):
|
|
raise FileNotFoundError(
|
|
f"paint layer {layer.name!r} reads a {layer.rule} mask and {path} is not there.\n"
|
|
f" Render the masks first: cd Tools/MapArt && go run . biomes")
|
|
masks[layer.name] = heightmap_io.read_png(path)
|
|
return masks
|
|
|
|
|
|
def sample_planet_map(manifest, planet, source_shape, tx, ty, margin=0):
|
|
"""A whole-planet map sampled at one tile's vertices, by the same global coordinates the height uses.
|
|
|
|
`planet` may be any resolution: it is addressed in normalised u,v, which is what lets a 7738-wide painting
|
|
and an 8192-wide heightmap describe the same ground without either being resampled to match the other.
|
|
Same filter as the height, so a mask's edge lands where the slope under it does, and seam-exact for the
|
|
same reason: a shared vertex is computed from the same global coordinates in both tiles."""
|
|
src_h, src_w = source_shape
|
|
ph, pw = planet.shape
|
|
x0, y0, _, _ = manifest.source_window
|
|
sx = (x0 + window_coords(manifest, tile_vertices(manifest, tx, margin), 0)) / src_w * pw
|
|
sy = (y0 + window_coords(manifest, tile_vertices(manifest, ty, margin), 1)) / src_h * ph
|
|
|
|
row0 = int(np.floor(sy[0])) - 1
|
|
row1 = int(np.floor(sy[-1])) + 3
|
|
rows = np.clip(np.arange(row0, row1), 0, ph - 1)
|
|
band = planet[rows].astype(np.float32)
|
|
band = resample_axis(band, sx % pw, axis=1, wrap=True)
|
|
return resample_axis(band, sy - row0, axis=0, wrap=False).astype(np.float32)
|
|
|
|
|
|
def beach_weight(metres, slope, rules):
|
|
"""The shore layer: low ground that is also flat. Height alone would put sand up every cliff that happens
|
|
to meet the sea, which is most of a rocky coast, so the slope term is what makes it a beach rather than a
|
|
contour band. An approximation, and knowingly so - the bake's coast pass knows where beaches actually are
|
|
and this route does not carry it (D-74)."""
|
|
beach = dict(BEACH_DEFAULTS, **rules.get("beach", {}))
|
|
above = float(beach["above_sea_m"])
|
|
fade = max(float(beach["fade_m"]), 1e-6)
|
|
by_height = np.clip(1.0 - (metres - above) / fade, 0.0, 1.0)
|
|
by_height = np.where(metres < 0.0, 0.0, by_height) # underwater is not a beach
|
|
by_slope = np.clip(1.0 - slope / max(float(beach["max_slope"]), 1e-6), 0.0, 1.0)
|
|
return (heightmap_noise.smoothstep(by_height) * heightmap_noise.smoothstep(by_slope)).astype(np.float32)
|
|
|
|
|
|
def derive_layers(manifest, metres, tx, ty, margin=0, masks=None, source_shape=None, layers=None):
|
|
"""Every enabled paint layer's weight for one tile, as uint8 summing to 255.
|
|
|
|
Three kinds of rule. `slope` and `altitude` read the tile's own height, as they always have, with an fBm
|
|
break-up so neither boundary is a contour line. `beach` reads height and slope together. `class` and
|
|
`climate` read a whole-planet mask sampled at the same global coordinates as the height.
|
|
|
|
The composition is a priority, not a blend: rock takes steep ground whatever biome it is in, high rock
|
|
takes altitude, the shore takes what is left near the sea, the biomes divide what remains, and one layer
|
|
is the remainder and absorbs everything nobody claimed. A layer that is not enabled is simply not in the
|
|
competition, so today's three-layer world is exactly what it was before the biomes existed.
|
|
|
|
`metres` carries `margin` vertices of its neighbours on every side; everything is computed over the lot
|
|
and the margin cropped at the end, so the slope at a tile's edge is the central difference its neighbour
|
|
computes there too."""
|
|
rules = {**LAYER_DEFAULTS, **manifest.layers}
|
|
layers = list(layers if layers is not None else manifest.enabled_layers)
|
|
quad_m = manifest.quad_cm / 100.0
|
|
# Both axes are divided by the *longer* one, so the noise stays square on the ground and, because neither
|
|
# coordinate then exceeds 1, it never repeats across the window - fbm_at is periodic with period 1, so
|
|
# dividing by anything smaller (a tile, say) would stamp the same pattern out every few kilometres.
|
|
span = float(max(manifest.quads_x, manifest.quads_y))
|
|
u = (tile_vertices(manifest, tx, margin) / span).astype(np.float32)
|
|
v = (tile_vertices(manifest, ty, margin) / span).astype(np.float32)
|
|
u, v = np.broadcast_arrays(u[None, :], v[:, None])
|
|
|
|
rng = np.random.default_rng(int(rules["breakup_seed"]))
|
|
noise = heightmap_noise.fbm_at(u, v, rng, base_cells=int(rules["breakup_cells"]), octaves=4)
|
|
breakup = (noise - 0.5) * 2.0 * float(rules["breakup_m"])
|
|
|
|
gy, gx = np.gradient(metres, quad_m)
|
|
slope = np.sqrt(gx * gx + gy * gy)
|
|
slope_breakup = breakup / float(rules["breakup_m"]) * 0.12 if rules["breakup_m"] else 0.0
|
|
|
|
rock = heightmap_noise.smoothstep(np.clip(
|
|
(slope + slope_breakup - rules["rock_slope_start"])
|
|
/ (rules["rock_slope_full"] - rules["rock_slope_start"]), 0.0, 1.0))
|
|
high = heightmap_noise.smoothstep(np.clip(
|
|
(metres + breakup - rules["high_altitude_start_m"])
|
|
/ (rules["high_altitude_full_m"] - rules["high_altitude_start_m"]), 0.0, 1.0))
|
|
high = high * (1.0 - rock * 0.5)
|
|
|
|
weights = {}
|
|
for layer in layers:
|
|
if layer.rule == "slope":
|
|
weights[layer.name] = rock
|
|
elif layer.rule == "altitude":
|
|
weights[layer.name] = high
|
|
# What no rule has claimed yet. Everything below takes its share out of this and never out of thin air,
|
|
# which is what keeps the weights summing to one without a final renormalise changing anyone's meaning.
|
|
free = np.ones_like(metres)
|
|
for value in weights.values():
|
|
free = free - value
|
|
free = np.clip(free, 0.0, 1.0)
|
|
|
|
for layer in layers:
|
|
if layer.rule == "beach":
|
|
beach = beach_weight(metres + breakup, slope, rules) * free
|
|
weights[layer.name] = beach
|
|
free = np.clip(free - beach, 0.0, 1.0)
|
|
|
|
# The biome layers divide what is left. They can overlap - a tropical desert is painted desert inside a
|
|
# Koppen savanna band - so where they sum past one they are scaled down together rather than one of them
|
|
# being picked; that keeps a boundary a blend instead of a decision.
|
|
biome_layers = [layer for layer in layers if layer.reads_mask]
|
|
if biome_layers:
|
|
raw = {}
|
|
for layer in biome_layers:
|
|
value = sample_planet_map(manifest, masks[layer.name], source_shape, tx, ty, margin) / 255.0
|
|
raw[layer.name] = np.clip(value, 0.0, 1.0)
|
|
stack = sum(raw.values())
|
|
scale = np.where(stack > 1.0, 1.0 / np.maximum(stack, 1e-6), 1.0).astype(np.float32)
|
|
for name, value in raw.items():
|
|
weights[name] = value * scale * free
|
|
free = np.clip(free - sum(weights[name] for name in raw), 0.0, 1.0)
|
|
|
|
for layer in layers:
|
|
if layer.rule == "remainder":
|
|
weights[layer.name] = free
|
|
|
|
total = np.maximum(sum(weights.values()), 1e-6)
|
|
inside = slice(margin, metres.shape[0] - margin) if margin else slice(None)
|
|
scaled = {name: w[inside, inside] / total[inside, inside] * 255.0 for name, w in weights.items()}
|
|
rounded = {name: np.rint(value).astype(np.int32) for name, value in scaled.items()}
|
|
|
|
# The weights sum to exactly 255 before rounding and to 255 give or take a couple after it, because eight
|
|
# layers round independently. The remainder layer absorbs the difference: it is the one whose meaning is
|
|
# "whatever is left", so a unit of rounding error belongs to it and to nobody else. Where it is already
|
|
# zero there is nothing to take the error out of, which is the one place a tile can still be a unit short.
|
|
remainder = next((layer.name for layer in layers if layer.rule == "remainder"), None)
|
|
if remainder is not None:
|
|
residual = 255 - sum(rounded.values())
|
|
rounded[remainder] = np.clip(rounded[remainder] + residual, 0, 255)
|
|
return {name: value.astype(np.uint8) for name, value in rounded.items()}
|
|
|
|
|
|
def write_tile(manifest, metres, layers, tx, ty, out_dir):
|
|
clipped = float(((metres < manifest.elevation_min_m) | (metres > manifest.elevation_max_m)).mean())
|
|
bounded = np.clip(metres, manifest.elevation_min_m, manifest.elevation_max_m)
|
|
height = np.rint(manifest.metres_to_value(bounded)).clip(0, 65535).astype(np.uint16)
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
heightmap_io.write_png(os.path.join(out_dir, os.path.basename(manifest.height_path(tx, ty))), height)
|
|
for name, data in layers.items():
|
|
heightmap_io.write_png(os.path.join(out_dir, os.path.basename(manifest.weight_path(tx, ty, name))), data)
|
|
return clipped
|
|
|
|
|
|
def scout(manifest, source):
|
|
"""What the window holds, without writing anything: the numbers that decide whether it is the right window."""
|
|
x0, y0, win_w, win_h = manifest.source_window
|
|
height, width = source.shape
|
|
columns = (np.arange(x0, x0 + win_w) % width)
|
|
window = source[y0:y0 + win_h][:, columns]
|
|
land = window > manifest.sea_level_m
|
|
mx, my = manifest.metres_per_pixel(0), manifest.metres_per_pixel(1)
|
|
print(f"window {win_w}x{win_h} px at ({x0}, {y0}) over "
|
|
f"{manifest.width_m / 1000:.2f} x {manifest.height_m / 1000:.2f} km of landscape")
|
|
print(f" {mx:.4f} m a pixel in X, {my:.4f} in Y"
|
|
+ (" -- EQUAL, so the ground is not stretched" if abs(mx - my) < 1e-6 else
|
|
f" -- UNEQUAL: the ground is stretched {abs(mx / my - 1) * 100:.1f}% in X against Y; "
|
|
f"make columns/rows match the window's width/height"))
|
|
if win_w == width and win_h == height:
|
|
print(" this is the whole export, not a crop of it")
|
|
else:
|
|
latitude = (0.5 - (y0 + win_h / 2.0) / height) * 180.0
|
|
stretch = 1.0 / np.cos(np.deg2rad(latitude)) - 1.0
|
|
print(f" centre latitude {latitude:+.2f} on the source, so a flat reading stretches it "
|
|
f"{stretch * 100:.1f}% east-west against the globe it came from")
|
|
print(f" land {land.mean() * 100:.2f}% = {land.mean() * manifest.area_km2:.0f} km2 "
|
|
f"of {manifest.area_km2:.0f} km2")
|
|
print(f" elevation {window.min():.0f}..{window.max():.0f} m "
|
|
f"(land median {np.median(window[land]):.0f} m, 99th {np.percentile(window[land], 99):.0f} m)")
|
|
outside = float(((window < manifest.elevation_min_m) | (window > manifest.elevation_max_m)).mean())
|
|
print(f" {outside * 100:.3f}% of the source window falls outside elevation_m and would clip")
|
|
step = np.abs(np.diff(window, axis=1)).max()
|
|
print(f" steepest single-pixel step {step:.0f} m over {mx:.1f} m: the filter is clamped so it does not ring")
|
|
print(f" {mx:.2f} m a pixel resampled to {manifest.quad_cm / 100:.0f} m quads: "
|
|
f"a {mx / (manifest.quad_cm / 100):.1f}x upsample")
|
|
print(f" {manifest.tile_count} tiles, {manifest.tile_count * (manifest.quads_per_tile // 255) ** 2} "
|
|
f"components, {manifest.vertices_x * manifest.vertices_y / 1e6:.0f} M vertices")
|
|
|
|
|
|
def parse_tiles(spec, manifest):
|
|
if not spec:
|
|
return list(manifest.tiles())
|
|
chosen = []
|
|
for item in spec:
|
|
tx, ty = (int(part) for part in item.split(","))
|
|
if not (0 <= tx < manifest.tiles_x and 0 <= ty < manifest.tiles_y):
|
|
raise SystemExit(f"tile {item} is outside the {manifest.tiles_x}x{manifest.tiles_y} grid")
|
|
chosen.append((tx, ty))
|
|
return chosen
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
|
parser.add_argument("--manifest", default=MANIFEST_PATH)
|
|
parser.add_argument("--out", default=TILE_DIR)
|
|
parser.add_argument("--scout", action="store_true", help="measure the window and print what it holds, write nothing")
|
|
parser.add_argument("--tiles", nargs="*", metavar="TX,TY", help="only these tiles; all of them by default")
|
|
parser.add_argument("--all-layers", action="store_true",
|
|
help="build every paint layer, not only the enabled ones. For looking at a biome "
|
|
"before there is a substance for it; the level cannot import what the material "
|
|
"does not blend, so this is a preview switch and not a build one.")
|
|
args = parser.parse_args(argv)
|
|
|
|
manifest = load_manifest(args.manifest)
|
|
print(manifest.describe())
|
|
started = time.time()
|
|
source = load_source_metres(manifest)
|
|
print(f"source {os.path.basename(manifest.source_path)}: {source.shape[1]}x{source.shape[0]}, "
|
|
f"{source.min():.0f}..{source.max():.0f} m after sea_scale {manifest.sea_scale:g} "
|
|
f"({time.time() - started:.0f} s)")
|
|
|
|
if args.scout:
|
|
scout(manifest, source)
|
|
return
|
|
|
|
measure_pad_height(manifest, source)
|
|
paint = manifest.paint_layers if args.all_layers else manifest.enabled_layers
|
|
masks = load_biome_masks(manifest, paint)
|
|
waiting = [layer.name for layer in manifest.paint_layers if layer not in paint]
|
|
print("layers: " + ", ".join(f"{layer.name}({layer.rule})" for layer in paint)
|
|
+ (f"; not enabled: {', '.join(waiting)}" if waiting else ""))
|
|
if masks:
|
|
print(f"biome masks: {', '.join(sorted(masks))} from {manifest.masks_dir} "
|
|
f"({manifest.biome_blend_m:g} m blend)")
|
|
chosen = parse_tiles(args.tiles, manifest)
|
|
lowest, highest, land_total, clipped_worst = 1e9, -1e9, 0.0, 0.0
|
|
for index, (tx, ty) in enumerate(chosen, 1):
|
|
tile_started = time.time()
|
|
margined = tile_metres(manifest, source, tx, ty, LAYER_MARGIN)
|
|
margined = apply_spawn_pad(manifest, margined, tx, ty, LAYER_MARGIN)
|
|
layers = derive_layers(manifest, margined, tx, ty, LAYER_MARGIN,
|
|
masks=masks, source_shape=source.shape, layers=paint)
|
|
metres = margined[LAYER_MARGIN:-LAYER_MARGIN, LAYER_MARGIN:-LAYER_MARGIN]
|
|
clipped = write_tile(manifest, metres, layers, tx, ty, args.out)
|
|
land = float((metres > manifest.sea_level_m).mean())
|
|
lowest, highest = min(lowest, float(metres.min())), max(highest, float(metres.max()))
|
|
land_total += land
|
|
clipped_worst = max(clipped_worst, clipped)
|
|
print(f" [{index:2d}/{len(chosen)}] {manifest.tile_name(tx, ty)}: "
|
|
f"{metres.min():7.1f}..{metres.max():7.1f} m, {land * 100:5.1f}% land, "
|
|
f"{clipped * 100:.3f}% clipped, {time.time() - tile_started:.1f} s")
|
|
|
|
area = manifest.area_km2 * len(chosen) / manifest.tile_count
|
|
print(f"{len(chosen)} tiles: {lowest:.0f}..{highest:.0f} m, "
|
|
f"{land_total / len(chosen) * 100:.1f}% land = {land_total / len(chosen) * area:.0f} km2 of {area:.0f} km2, "
|
|
f"worst tile {clipped_worst * 100:.3f}% clipped; {time.time() - started:.0f} s; written to {args.out}")
|
|
if clipped_worst > 0.001:
|
|
print(" clipping above 0.1% means elevation_m is too narrow for this window; widen it and rerun")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|