diff --git a/examples/build_town.py b/examples/build_town.py index 6357196..0f4e79c 100644 --- a/examples/build_town.py +++ b/examples/build_town.py @@ -61,6 +61,43 @@ }, } +# Blocks that fall when nothing holds them up, and what to use instead. +# +# This matters only because a crust floats, and it very nearly does not +# announce itself. Blocks are placed with physics off -- deliberately, because +# running block physics for every one of half a million placements is what +# stalled the server and got it killed by the watchdog -- so gravity is never +# evaluated at build time and a crust of regolith sits in mid-air looking +# perfectly solid. It stays that way until anything at all causes a block +# update nearby. Mine one block and the update spreads, every neighbour +# discovers it is unsupported, and the landscape drains away. +# +# The Moon's top three layers were concrete powder and Mars's top two were red +# sand, so both were a landslide waiting for the first pickaxe. The substitutes +# are chosen to keep the colour: the point of the palette is that regolith is +# grey, not that it is dust. +_COLOURS = ("WHITE", "ORANGE", "MAGENTA", "LIGHT_BLUE", "YELLOW", "LIME", + "PINK", "GRAY", "LIGHT_GRAY", "CYAN", "PURPLE", "BLUE", "BROWN", + "GREEN", "RED", "BLACK") +FALLING = {"%s_CONCRETE_POWDER" % c: "%s_CONCRETE" % c for c in _COLOURS} +FALLING.update({ + "SAND": "SANDSTONE", + "SUSPICIOUS_SAND": "SANDSTONE", + "RED_SAND": "RED_SANDSTONE", + "GRAVEL": "COBBLESTONE", + "SUSPICIOUS_GRAVEL": "COBBLESTONE", + "ANVIL": "IRON_BLOCK", + "POINTED_DRIPSTONE": "DRIPSTONE_BLOCK", + "SCAFFOLDING": "OAK_PLANKS", + "DRAGON_EGG": "OBSIDIAN", +}) + + +def anchored(material): + """The nearest block of the same colour that stays where it is put.""" + return FALLING.get(material, material) + + WALL = "WHITE_CONCRETE" ROOF = "GRAY_CONCRETE" @@ -347,17 +384,51 @@ def to_voxels(ground, buildings, base_y, depth, hollow=True, body="earth", # surface either way; the difference is only visible from underneath, and # from underneath there is nothing to see. # + # But a FLAT crust is both too much and too little. Too much because on + # level ground one block is plenty -- nothing can see past it. Too little + # because where the ground drops sharply, a column whose neighbour sits + # more than `crust` blocks below it has daylight under its lip, and you + # see straight through the wall of the valley you came to look at. + # + # So `crust` is a MINIMUM, and each column is thickened to cover the drop + # to its lowest neighbour. On Hadley Rille that turns 1.37M blocks into + # 310k -- four times cheaper -- while closing 671 columns of holes that a + # flat crust of five left open, because the deepest drop there is 81 + # blocks and no sane constant covers it. + # # The label is i + 1, not len(soil) - i. The latter reads the list # backwards and buries the grass: yards came out coarse dirt with the turf # three blocks down, and Mars had terracotta on top instead of red sand. # Anywhere with a land-cover tag was painted over afterwards and looked # right, so only the untagged ground -- which is to say people's gardens -- # showed it. + # How far below each column's top its lowest neighbour sits. Off the edge + # of the map there is no neighbour, so nothing is required there and the + # rim comes out thin, which is what a cut-out of terrain should look like. + thickness = None + if crust is not None: + drop = np.zeros_like(ground_h) + for axis in (0, 1): + for step in (1, -1): + shifted = np.roll(ground_h, step, axis=axis) + if axis == 0: + if step == 1: + shifted[0, :] = ground_h[0, :] + else: + shifted[-1, :] = ground_h[-1, :] + else: + if step == 1: + shifted[:, 0] = ground_h[:, 0] + else: + shifted[:, -1] = ground_h[:, -1] + drop = np.maximum(drop, ground_h - shifted) + thickness = np.maximum(int(crust), np.maximum(drop, 0)) + for x in range(nx): col = ground_h[x] for z in range(nz): top = col[z] - bottom = 0 if crust is None else max(0, top - int(crust)) + bottom = 0 if crust is None else max(0, top - int(thickness[x, z])) world[x, bottom:top, z] = stone for i, _ in enumerate(soil): y = top - 1 - i @@ -537,6 +608,11 @@ def label_for(material): world[px, py, pz] = leaf_label palette = soil + [deep, WALL, ROOF] + extra + if crust is not None: + # Only when there is a crust. A build filled to the floor rests on + # something, so sand on Mars can behave like sand, which is half the + # reason it is sand. + palette = [anchored(m) for m in palette] return world, palette diff --git a/examples/data/moon_hadley_full.npz b/examples/data/moon_hadley_full.npz new file mode 100644 index 0000000..4c7d55e Binary files /dev/null and b/examples/data/moon_hadley_full.npz differ diff --git a/examples/exoplanets.py b/examples/exoplanets.py index 5506677..4c19972 100644 --- a/examples/exoplanets.py +++ b/examples/exoplanets.py @@ -102,7 +102,10 @@ ROCK_BY_TEMP = ( (180.0, ["PACKED_ICE", "BLUE_ICE"]), (320.0, ["GRASS_BLOCK", "STONE"]), - (600.0, ["TERRACOTTA", "RED_SAND"]), + # Red sandstone, not red sand: these hang in the sky with nothing under + # them, and a gravity-affected block up there is one block update away + # from raining the planet onto the ground. + (600.0, ["TERRACOTTA", "RED_SANDSTONE"]), (1200.0, ["BLACKSTONE", "BASALT"]), (float("inf"), ["MAGMA_BLOCK", "NETHERRACK"]), ) diff --git a/examples/fetch_planet.py b/examples/fetch_planet.py index f85637a..3d3ea75 100644 --- a/examples/fetch_planet.py +++ b/examples/fetch_planet.py @@ -45,6 +45,7 @@ import sys import urllib.error import urllib.request +import warnings try: import numpy as np @@ -281,6 +282,32 @@ def at(off, n): return info +def downsample(grid, stride): + """Average stride x stride pixels into one, ignoring gaps. + + Taking every nth pixel instead would be quicker and wrong: it is point + sampling a surface with real detail in it, so a narrow ridge either + survives at full height or vanishes entirely depending on where the grid + happens to fall. Averaging keeps the shape and quietly loses the detail, + which is what a coarser map is supposed to do. + + A window is trimmed to a whole number of blocks first, so the last part + row is dropped rather than averaged against nothing. + """ + stride = int(stride) + if stride <= 1: + return grid + rows = (grid.shape[0] // stride) * stride + cols = (grid.shape[1] // stride) * stride + block = grid[:rows, :cols].reshape(rows // stride, stride, + cols // stride, stride) + with warnings.catch_warnings(): + # A block that is entirely nodata averages to nan, which is correct + # and which numpy would rather warn about every time. + warnings.simplefilter("ignore", RuntimeWarning) + return np.nanmean(block, axis=(1, 3)) + + def read_window(url, info, row0, col0, rows, cols, wrap=True): """A rows x cols window, read as one contiguous range request. @@ -409,7 +436,16 @@ def main(): p.add_argument("--place", help="a named landing site or feature; --list to see them") p.add_argument("--lat", type=float, help="centre latitude, if not using --place") p.add_argument("--lon", type=float, help="centre longitude, degrees east") - p.add_argument("--size", type=int, default=400, help="window across, in pixels (default 400)") + p.add_argument("--size", type=int, default=400, + help="window across, in PIXELS of the source map (default 400)") + p.add_argument("--rows", type=int, + help="window north-south, in pixels (default: same as --size). " + "The interesting axis is often only one of them") + p.add_argument("--stride", type=int, default=1, + help="average this many pixels square into one block. A 2 m " + "DEM at --stride 3 is 6 m to the block, which is how a " + "window covers ground it could not afford at full " + "resolution (default 1)") p.add_argument("--vscale", type=float, default=1.0, help="vertical exaggeration; 1 is true scale (default 1)") p.add_argument("--name", help="output name (default: body_place)") @@ -491,8 +527,10 @@ def main(): if args.check: return + rows_wanted = args.rows or args.size cx, cy = pixel_for(lat, lon, info, proj) half = args.size // 2 + half_rows = rows_wanted // 2 # A global mosaic wraps and is huge, so a window always fits. A site DEM # is a few thousand pixels across and asking for the middle of a 21 km @@ -503,38 +541,57 @@ def main(): sys.exit("%.4f, %.4f is not inside %s (pixel %d,%d of %d x %d).\n" "Use --check to see what it covers." % (lat, lon, args.dem, cx, cy, info["width"], info["height"])) - if args.size > min(info["width"], info["height"]): - print(" note: --size %d is larger than the DEM (%d x %d); trimming" - % (args.size, info["width"], info["height"])) - args.size = min(info["width"], info["height"]) + if args.size > info["width"]: + print(" note: --size %d is wider than the DEM (%d); trimming" + % (args.size, info["width"])) + args.size = info["width"] half = args.size // 2 + if rows_wanted > info["height"]: + rows_wanted = info["height"] + half_rows = rows_wanted // 2 col0 = max(0, min(cx - half, info["width"] - args.size)) else: col0 = cx - half - row0 = max(0, min(cy - half, info["height"] - args.size)) - - span_km = args.size * mpp / 1000.0 - mb = args.size * info["strip_bytes"][0] / 1e6 - print(" window %d x %d pixels = %.0f x %.0f km (about %.0f MB of range requests)" - % (args.size, args.size, span_km, span_km, mb)) - - raw = read_window(url, info, row0, col0, args.size, args.size, wrap=dem is None) - ground = raw / source["units_per_metre"] - - # Gaps. The global mosaics use the most negative 16-bit integer; the site - # DEMs say what they use in the TIFF, and one of them says "nan", which - # never equals itself and so has to be tested for separately. + row0 = max(0, min(cy - half_rows, info["height"] - rows_wanted)) + + span_x = args.size * mpp / 1000.0 + span_z = rows_wanted * mpp / 1000.0 + mb = rows_wanted * info["strip_bytes"][0] / 1e6 + print(" window %d x %d pixels = %.2f x %.2f km (about %.0f MB of range requests)" + % (args.size, rows_wanted, span_x, span_z, mb)) + + raw = read_window(url, info, row0, col0, rows_wanted, args.size, + wrap=dem is None) + + # Gaps become NaN BEFORE anything averages them. The global mosaics mark + # nodata with the most negative 16-bit integer; the site DEMs say what + # they use in the TIFF, and for the NAC DTM that is -3.4e38. + # + # Averaging first is a quiet disaster: one nodata pixel in a 2x2 block + # drags the average to -8.5e37, which is not obviously a gap, is not + # caught by a later "is this the nodata value" test because it no longer + # equals it, and builds as a hole a hundred billion times deeper than the + # crater. NaN spreads harmlessly instead, and nanmean ignores it. nodata = info.get("nodata") if nodata is not None and nodata == nodata: - ground[raw <= nodata + abs(nodata) * 1e-6] = np.nan - ground[~np.isfinite(raw)] = np.nan + raw[raw <= nodata + abs(nodata) * 1e-6] = np.nan + raw[~np.isfinite(raw)] = np.nan + + if args.stride > 1: + before = raw.shape + raw = downsample(raw, args.stride) + mpp = mpp * args.stride + print(" averaged %dx%d pixels per block: %s -> %s at %g m per block" + % (args.stride, args.stride, "x".join(map(str, before)), + "x".join(map(str, raw.shape)), mpp)) + ground = raw / source["units_per_metre"] gaps = int(np.isnan(ground).sum()) if gaps: print(" %d of %d pixels have no data (%.1f%%)" % (gaps, ground.size, 100.0 * gaps / ground.size)) lo, hi = float(np.nanmin(ground)), float(np.nanmax(ground)) - print(" elevation %.0f to %.0f m (relief %.0f m over %.0f km)" - % (lo, hi, hi - lo, span_km)) + print(" elevation %.0f to %.0f m (relief %.0f m over %.2f km)" + % (lo, hi, hi - lo, max(span_x, span_z))) if args.vscale != 1.0: mid = (lo + hi) / 2.0 @@ -556,6 +613,7 @@ def main(): meta=json.dumps({ "body": args.body, "place": args.place, "what": what, "lat": lat, "lon": lon, "size": args.size, + "rows": rows_wanted, "stride": args.stride, "metres_per_pixel": mpp, "vscale": args.vscale, "elevation_min_m": lo, "elevation_max_m": hi, "metres_per_cell": mpp, diff --git a/examples/moon_landings.py b/examples/moon_landings.py index 35129b5..0aa32fb 100644 --- a/examples/moon_landings.py +++ b/examples/moon_landings.py @@ -111,12 +111,9 @@ def build_apollo_lm(scale=0.5): # Four landing legs, out and down, with a round footpad on the end. for sx, sz in ((1, 1), (1, -1), (-1, 1), (-1, -1)): - steps = max(leg_span - desc_r, 1) - for i in range(steps + 1): - t = i / steps - x = int(round(sx * (desc_r + (leg_span - desc_r) * t))) - z = int(round(sz * (desc_r + (leg_span - desc_r) * t))) - y = int(round(desc_h * 0.55 * (1 - t))) + top = (sx * desc_r, int(round(desc_h * 0.55)), sz * desc_r) + foot = (sx * leg_span, 0, sz * leg_span) + for x, y, z in connected(top, foot): out.append((x, y, z, "IRON_BARS")) px, pz = sx * leg_span, sz * leg_span for dx in (-1, 0, 1): @@ -152,6 +149,27 @@ def build_apollo_lm(scale=0.5): return out +def connected(a, b): + """Every point from a to b, each sharing a FACE with the one before it. + + Sweeping a parametric line and rounding gives points that step diagonally, + and two blocks meeting only at an edge are not neighbours as far as + Minecraft is concerned. Iron bars work out what to join onto by looking at + their four side faces, so a landing leg drawn that way came out as a row + of loose rods hanging in the air with daylight between them. + + Moving one axis at a time -- the one with furthest still to go -- costs a + few more blocks and makes the leg look like a leg. + """ + cur = list(a) + pts = [tuple(cur)] + while tuple(cur) != tuple(b): + far = max(range(3), key=lambda i: abs(b[i] - cur[i])) + cur[far] += 1 if b[far] > cur[far] else -1 + pts.append(tuple(cur)) + return pts + + def build_lrv(scale=0.5): """The Lunar Roving Vehicle: two seats, four wire wheels, a dish aerial.""" b = lambda m: max(int(round(m / scale)), 1) diff --git a/pyncraft/minecraft.py b/pyncraft/minecraft.py index 9884fb3..7a29fc3 100644 --- a/pyncraft/minecraft.py +++ b/pyncraft/minecraft.py @@ -194,6 +194,28 @@ def pollChatPosts(self): return [ChatEvent.Post(int(e[:e.find(",")]), e[e.find(",") + 1:]) for e in events] +def reshape_blocks(names, xs, ys, zs): + """The server's flat block list into [y][x][z], which is the order it uses. + + Kept out of the Minecraft class so it can be tested without a server: the + whole bug was in the arithmetic, and the arithmetic needs no socket. + """ + if len(names) != xs * ys * zs: + # `warn` here is this package's logger, not warnings.warn: the + # `from .logger import *` above shadows it. That is worth knowing + # before writing a test that waits for a warning which never arrives. + warn("getBlocks: expected %d blocks, got %d" % (xs * ys * zs, len(names))) + out, k = [], 0 + for _ in range(ys): + slab = [] + for _ in range(xs): + slab.append(names[k:k + zs]) + k += zs + out.append(slab) + return out + + + class Minecraft: """The main class to interact with a running instance of Minecraft Pi.""" def __init__(self, connection, playerId): @@ -259,25 +281,23 @@ def getBlockData(self, x, y=None, z=None, parse: bool = True) -> dict: return data def getBlocks(self, x1:int, y1:int, z1:int, x2:int, y2:int, z2:int) -> list: - """Get a cuboid of blocks (x0,y0,z0,x1,y1,z1) => [id:int]""" + """A cuboid of blocks, as nested lists indexed [y][x][z]. + + The order is the server's, and it is not the obvious one: FruitJuice + walks the cuboid with Y outermost, then X, then Z. So the flat reply is + a stack of horizontal slabs, each slab a set of rows running east, each + row running south. + + This used to reshape into slabs of xSize * ySize, which is the right + SIZE only when the cuboid happens to be as tall as it is deep. Any + other shape silently came back scrambled -- every block was a real + block from somewhere in the box, just not from where you thought, so + nothing looked wrong until the answer was checked against a place + somebody could actually stand. + """ blocks = self.conn.sendReceive(b"world.getBlocks", x1, y1, z1, x2, y2, z2) - arr1d = blocks.split(',') - - xSize = abs(x1 - x2) + 1 - ySize = abs(y1 - y2) + 1 - zSize = abs(z1 - z2) + 1 - totalSize = xSize * ySize * zSize - arr3d = [] - - if len(arr1d) != totalSize: - warn('Get number of blocks is incomplete') - - for i in range(0,totalSize,xSize*ySize): - curArr = [] - for j in range(0,xSize*ySize,xSize): - curArr.append(arr1d[i+j:i+j+xSize]) - arr3d.append(curArr) - return arr3d + return reshape_blocks(blocks.split(","), + abs(x1 - x2) + 1, abs(y1 - y2) + 1, abs(z1 - z2) + 1) # DIRECTION: NORTH SOUTH EAST WEST # FACE: FLOOR, CEILING, WALL diff --git a/tests/test_getblocks.py b/tests/test_getblocks.py new file mode 100644 index 0000000..d5075a9 --- /dev/null +++ b/tests/test_getblocks.py @@ -0,0 +1,62 @@ +"""getBlocks comes back in the server's order, which is not the obvious one. + +FruitJuice walks a cuboid with Y outermost, then X, then Z -- a stack of +horizontal slabs, each slab rows running east, each row running south. The +client reshaped it into slabs of xSize * ySize instead, which is the right +size only when the box happens to be as tall as it is deep. + +That is the worst kind of wrong. Every value returned was a real block from +somewhere inside the box, so nothing was empty, nothing raised, and a scan of +a landing site reported the Lunar Module spread over thirty blocks of height +it did not occupy. It only showed up when the answer was checked against +somewhere a person could stand. +""" +import pytest + +from pyncraft.minecraft import reshape_blocks + + +def _server_order(xs, ys, zs): + """What the plugin actually sends: for y, for x, for z.""" + return ["%d_%d_%d" % (x, y, z) + for y in range(ys) for x in range(xs) for z in range(zs)] + + +@pytest.mark.parametrize("xs,ys,zs", [ + (1, 1, 1), (3, 3, 3), (2, 3, 4), (4, 3, 2), (5, 1, 9), (1, 7, 2), (10, 2, 3), +]) +def test_every_block_lands_where_it_belongs(xs, ys, zs): + grid = reshape_blocks(_server_order(xs, ys, zs), xs, ys, zs) + assert len(grid) == ys + assert all(len(slab) == xs for slab in grid) + assert all(len(row) == zs for slab in grid for row in slab) + for y in range(ys): + for x in range(xs): + for z in range(zs): + assert grid[y][x][z] == "%d_%d_%d" % (x, y, z) + + +def test_the_shape_that_used_to_work_by_accident(): + """A cube was fine, which is why this survived: ySize == zSize hid it.""" + xs = ys = zs = 4 + grid = reshape_blocks(_server_order(xs, ys, zs), xs, ys, zs) + assert grid[2][1][3] == "1_2_3" + + +def test_a_short_reply_says_so_rather_than_lying(capsys): + """It complains through pyncraft's own logger, not warnings.warn. + + `from .logger import *` in minecraft.py shadows the `warn` imported from + warnings a line earlier, so the message is printed rather than raised. + Worth knowing before writing a test that waits for a warning that is never + going to arrive. + """ + reshape_blocks(["STONE"] * 5, 2, 3, 4) + assert "expected 24 blocks, got 5" in capsys.readouterr().out + + +def test_nothing_is_invented_or_dropped(): + names = _server_order(3, 4, 5) + grid = reshape_blocks(names, 3, 4, 5) + flat = [v for slab in grid for row in slab for v in row] + assert sorted(flat) == sorted(names) diff --git a/tests/test_site_dems.py b/tests/test_site_dems.py index 6fe10e3..2540384 100644 --- a/tests/test_site_dems.py +++ b/tests/test_site_dems.py @@ -129,35 +129,57 @@ def test_global_mosaic_still_spans_the_whole_body(): # ── the crust ────────────────────────────────────────────────────────────── -def test_crust_costs_the_same_however_deep_the_valley(): - """The whole reason for the crust: cost stops depending on the relief. - - Not that every crusted build costs the same -- at the very lowest point a - column is only `depth` blocks tall, so a flat map is cheaper than a - cliffed one no matter what. The property that matters is that making the - cliff ten times deeper costs nothing extra, and without a crust it costs - ten times as much. - """ - shallow = np.zeros((40, 40), dtype=float) - shallow[20:, :] = -300.0 - deeper = np.zeros((40, 40), dtype=float) - deeper[20:, :] = -3000.0 # ten times the relief, same shape - - crusted = [] - solid = [] +def test_a_crust_is_far_cheaper_than_filling_to_the_floor(): + """The point of a crust: cost follows area, not the depth of the valley.""" + shallow = np.zeros((40, 40)); shallow[20:, :] = -300.0 + deeper = np.zeros((40, 40)); deeper[20:, :] = -3000.0 for ground in (shallow, deeper): - world, _ = build_town.to_voxels( - ground, np.zeros_like(ground), 0, 3, body="moon", - metres_per_cell=2.0, crust=5) - crusted.append(int((world != 0).sum())) - world, _ = build_town.to_voxels( - ground, np.zeros_like(ground), 0, 3, body="moon", - metres_per_cell=2.0) - solid.append(int((world != 0).sum())) + crusted, _ = build_town.to_voxels(ground, np.zeros_like(ground), 0, 3, + body="moon", metres_per_cell=2.0, crust=1) + solid, _ = build_town.to_voxels(ground, np.zeros_like(ground), 0, 3, + body="moon", metres_per_cell=2.0) + assert int((crusted != 0).sum()) < int((solid != 0).sum()) / 8 - assert crusted[0] == crusted[1], "crust cost must not follow the relief" - assert solid[1] > solid[0] * 9, "without a crust it should, and does" - assert crusted[1] * 20 < solid[1] + +def test_the_crust_thickens_to_cover_a_cliff(): + """A flat crust leaves daylight under the lip of any drop taller than it. + + This is what `crust` being a minimum rather than a thickness buys: on + level ground one block is plenty, and at the top of a cliff the column + reaches down far enough that you cannot see under it. On Hadley Rille the + deepest drop is 81 blocks, so no sane constant would have covered it -- a + flat crust of five left 671 columns open. + """ + ground = np.zeros((20, 20)) + ground[10:, :] = -160.0 # an 80-block cliff at 2 m + world, _ = build_town.to_voxels(ground, np.zeros_like(ground), 0, 3, + body="moon", metres_per_cell=2.0, crust=1) + nx, ny, nz = world.shape + tops, bottoms = {}, {} + for x in range(nx): + for z in range(nz): + filled = np.nonzero(world[x, :, z])[0] + assert len(filled), "column %d,%d is empty" % (x, z) + tops[(x, z)] = int(filled.max()) + bottoms[(x, z)] = int(filled.min()) + # solid from bottom to top, no floating shelf + assert len(filled) == tops[(x, z)] - bottoms[(x, z)] + 1 + + # Nothing may see under a column: every neighbour's top is at or above + # this column's lowest block. + for (x, z), bottom in bottoms.items(): + for dx, dz in ((1, 0), (-1, 0), (0, 1), (0, -1)): + n = (x + dx, z + dz) + if n in tops: + assert tops[n] >= bottom - 1, "gap at %d,%d: neighbour top %d, this bottom %d" % ( + x, z, tops[n], bottom) + + +def test_flat_ground_only_needs_one_block(): + """Where nothing can see past it, one block is the whole crust.""" + world, _ = build_town.to_voxels(np.zeros((12, 12)), np.zeros((12, 12)), 0, 1, + body="moon", metres_per_cell=2.0, crust=1) + assert int((world != 0).sum()) == 12 * 12 def test_crust_keeps_the_surface_where_it_was(): @@ -178,3 +200,105 @@ def test_crust_keeps_the_surface_where_it_was(): assert a.max() == b.max(), "top of column %d,%d moved" % (x, z) # And it is a strict subset: nothing new appeared. assert np.all((crust != 0) <= (solid != 0)) + + +# ── coarsening a window ──────────────────────────────────────────────────── + +def test_downsample_averages_rather_than_samples(): + """Taking every nth pixel would be quicker and wrong. + + Point sampling a surface with real detail in it means a narrow ridge + either survives at full height or vanishes entirely, depending on where + the grid happens to land. Averaging loses the detail, which is what a + coarser map is supposed to do. + """ + grid = np.arange(16, dtype=float).reshape(4, 4) + out = fetch_planet.downsample(grid, 2) + assert out.shape == (2, 2) + assert out[0, 0] == pytest.approx((0 + 1 + 4 + 5) / 4.0) + assert out[1, 1] == pytest.approx((10 + 11 + 14 + 15) / 4.0) + + +def test_stride_of_one_changes_nothing(): + grid = np.random.default_rng(0).random((5, 7)) + assert np.array_equal(fetch_planet.downsample(grid, 1), grid) + + +def test_a_partial_block_is_trimmed_not_averaged_against_nothing(): + assert fetch_planet.downsample(np.ones((5, 7)), 2).shape == (2, 3) + + +def test_downsample_ignores_gaps_but_keeps_a_whole_one(): + grid = np.ones((4, 4)) + grid[0, 0] = np.nan # one gap among three real values + grid[2:4, 2:4] = np.nan # a block that is all gap + out = fetch_planet.downsample(grid, 2) + assert out[0, 0] == pytest.approx(1.0) + assert np.isnan(out[1, 1]) + + +def test_nodata_has_to_be_masked_before_anything_averages_it(): + """The ordering bug, which produced a hole 1e35 times deeper than the crater. + + The NAC DTM marks nodata with -3.4e38. Averaging a 2x2 block containing + one of those gives about -8.5e37: not obviously a gap, no longer equal to + the nodata value so a later test for it fails, and a perfectly ordinary + looking number until you notice the units. Masking first turns it into a + NaN, which nanmean simply ignores. + """ + nodata = -3.4028226e38 + block = np.full((2, 2), -1900.0) + block[0, 0] = nodata + + averaged_first = fetch_planet.downsample(block.copy(), 2)[0, 0] + assert averaged_first < -8e37 + assert averaged_first != nodata, "and so cannot be spotted afterwards" + + masked = block.copy() + masked[masked <= nodata + abs(nodata) * 1e-6] = np.nan + assert fetch_planet.downsample(masked, 2)[0, 0] == pytest.approx(-1900.0) + + +# ── a crust has to stay up ───────────────────────────────────────────────── + +def test_a_crust_contains_nothing_that_falls(): + """The one that actually collapsed a landscape. + + Blocks go in with physics off, deliberately: evaluating gravity for half a + million placements is what stalled the server. So a floating crust of + concrete powder never learns it is unsupported, and looks perfect -- until + any block update reaches it. Break one block and the update spreads + outward, every neighbour finds nothing beneath it, and the map drains + away. + + The Moon's top three soil layers were concrete powder and Mars's top two + were red sand, so both were a landslide waiting for a pickaxe. + """ + for body in ("moon", "mars", "earth"): + _, palette = build_town.to_voxels( + np.zeros((8, 8)), np.zeros((8, 8)), 0, 3, + body=body, metres_per_cell=2.0, crust=1) + for material in palette: + assert material not in build_town.FALLING, "%s: %s" % (body, material) + + +def test_without_a_crust_sand_is_still_sand(): + """A build filled to the floor rests on something, so nothing is swapped. + + Mars is red sand on top because the surface really is dust and behaves + like dust when you dig it, which is worth keeping where it is safe. + """ + _, palette = build_town.to_voxels(np.zeros((8, 8)), np.zeros((8, 8)), 0, 3, + body="mars", metres_per_cell=2.0) + assert "RED_SAND" in palette + + +def test_the_substitute_keeps_the_colour(): + assert build_town.anchored("RED_SAND") == "RED_SANDSTONE" + assert build_town.anchored("GRAY_CONCRETE_POWDER") == "GRAY_CONCRETE" + assert build_town.anchored("LIGHT_GRAY_CONCRETE_POWDER") == "LIGHT_GRAY_CONCRETE" + assert build_town.anchored("STONE") == "STONE" + # every colour of powder has a concrete of the same name + for name, solid in build_town.FALLING.items(): + if name.endswith("_CONCRETE_POWDER"): + assert solid == name.replace("_POWDER", "")