OverviewFilesCommitsIssuesDocsReleasesPeers

terrain-lookdev commits / 7ebf9c0

explore: terrain-class overview — the world already has the range, it had no way to be found
"could we do a large scale biome calc that smoothly transitions between terrain types — rolling Vermont hills vs Appalachian vs Everest?" It already does. Surveyed 2150 x 2150 km (4.6 M km2) through the coarse map in 16.4 s, classifying land by LOCAL RELIEF over a 23 km window: flat / plains 4.0% of land 110 m 22.6C 887 mm rolling hills (Vermont-ish) 21.9% 375 m 18.5C 859 mm hill country / low ranges 42.1% 844 m 11.8C 783 mm rugged (Appalachian-ish) 28.2% 1221 m 8.0C 751 mm alpine (Everest-ish) 3.8% 1396 m 7.8C 746 mm peaks to 6407 m It is a continuous field, so the transitions are already smooth, and it is climate-correlated the way Earth is — rugged terrain here is colder and drier. We had been seeing mountains because I explicitly scanned for high-relief land when looking for something to test the erosion filter against. So the missing piece was navigation, not generation. The coarse backdrop gains a terrain-class view: relief classes over shaded relief, with a legend. Finding rolling hills is now clicking a green region instead of writing a scan script. RELIEF, not elevation, is the classifier. A high plateau and a mountain range have the same elevation and nothing else in common; relief over 23 km separates them cleanly, and the percentages above are the evidence that the split lands where it should rather than piling everything into one class. Costs nothing: the coarse stage is ~34,000 km2/s against the detail stage's ~50. AUTHORING it, rather than finding it, is the coarse map: synthetic_map.py is "perlin noise with transformations to have the same statistics as real world data" and is documented as modifiable, and tiff-export accepts conditioning GeoTIFFs (including an Azgaar import). Not attempted here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7ebf9c087f49a31a24419ce2cddb1b00e42dffa1
by plankatron <noreply@anthropic.com> · 2026-08-31T19:28:23.000Z

Changed files (2) +70 -3

explore.html +20 -1
……@@ -41,6 +41,18 @@ canvas{position:absolute;inset:0;width:100%;height:100%;display:block}
4141 <input type="range" id="pre" min="0" max="3" step="1" value="1"></label>
4242 <label><input type="checkbox" id="grid" checked style="width:auto"> tile grid + state</label>
4343 <hr>
44 <label style="display:block"><input type="checkbox" id="biome" style="width:auto">
45 backdrop: terrain class</label>
46 <div id="legend" hidden style="font-family:'IBM Plex Mono',monospace;font-size:10px;
47 line-height:1.7;color:var(--dim);margin:6px 0 2px">
48 <span style="color:#6b8c54">■</span> plains &lt;60 m<br>
49 <span style="color:#8ca852">■</span> rolling 60–180 m<br>
50 <span style="color:#b8a859">■</span> hill country 180–400 m<br>
51 <span style="color:#bf8052">■</span> rugged 400–800 m<br>
52 <span style="color:#e0dbe6">■</span> alpine &gt;800 m<br>
53 <span style="color:#5b6b8c">■</span> sea<br>
54 <span style="color:#6b7a80">relief over a 23 km window</span>
55 </div>
4456 <button id="go0">origin (0,0)</button>
4557 <button id="gomtn">the mountain region</button>
4658 <button id="clearq">stop prefetching</button>
……@@ -200,7 +212,9 @@ async function ensureCoarse() {
200212 const im = new Image();
201213 im.onload = () => { coarseImg = im; coarseBox = box; coarsePending = false; dirty = true; };
202214 im.onerror = () => { coarsePending = false; };
203 im.src = `/api/coarse.png?ci1=${ci1}&cj1=${cj1}&ci2=${ci2}&cj2=${cj2}&v=${TILE_VERSION}`;
215 const cview = $("biome").checked ? "terrain-class" : "shaded-height";
216 im.src = `/api/coarse.png?ci1=${ci1}&cj1=${cj1}&ci2=${ci2}&cj2=${cj2}` +
217 `&view=${cview}&v=${TILE_VERSION}`;
204218}
205219
206220async function poll() {
……@@ -324,6 +338,11 @@ function setZoom(z) {
324338$("zoom").addEventListener("input", () => setZoom(+$("zoom").value));
325339$("pre").addEventListener("input", () => { $("v_pre").textContent = $("pre").value; });
326340$("grid").addEventListener("change", () => { dirty = true; });
341$("biome").addEventListener("change", () => {
342 $("legend").hidden = !$("biome").checked;
343 coarseImg = null; coarseBox = null; coarsePending = false; // force a refetch
344 ensureCoarse(); dirty = true;
345});
327346$("go0").onclick = () => { view.x = 0; view.y = 0; dirty = true; poll(); };
328347// the mountain region is i=0, j=2048 -> screen y=128, x=2176
329348$("gomtn").onclick = () => { view.x = 2176; view.y = 128; dirty = true; poll(); };
server.py +50 -2
……@@ -58,6 +58,51 @@ def coarse_to_metres(c0):
5858 return np.sign(c0) * c0 * c0
5959
6060
61# Terrain classes by LOCAL RELIEF over a 23 km window (3 coarse px), which is
62# what separates rolling hills from ranges far better than elevation does.
63# Measured over 4.6 M km2 of this seed: 4.0% plains, 21.9% rolling, 42.1% hill
64# country, 28.2% rugged, 3.8% alpine, with peaks to 6407 m. The world already
65# contains the whole range; it just had no way to be found.
66TERRAIN_CLASSES = [
67 ("plains", 0, 60, (0.42, 0.55, 0.33)),
68 ("rolling", 60, 180, (0.55, 0.66, 0.32)),
69 ("hill country",180, 400, (0.72, 0.66, 0.35)),
70 ("rugged", 400, 800, (0.75, 0.50, 0.32)),
71 ("alpine", 800, 1e18, (0.88, 0.86, 0.90)),
72]
73
74
75def local_relief(elev, win=3):
76 """Std of coarse elevation in a win x win window, edge-padded."""
77 r = win // 2
78 p = np.pad(elev, r, mode="edge")
79 acc = np.zeros_like(elev); acc2 = np.zeros_like(elev)
80 for dy in range(win):
81 for dx in range(win):
82 b = p[dy:dy + elev.shape[0], dx:dx + elev.shape[1]]
83 acc += b; acc2 += b * b
84 n = win * win
85 return np.sqrt(np.maximum(acc2 / n - (acc / n) ** 2, 0.0))
86
87
88def colourise_terrain_class(elev):
89 """Colour by terrain class so a region can be FOUND, not just rendered."""
90 rel = local_relief(elev)
91 rgb = np.zeros(elev.shape + (3,))
92 for _nm, lo, hi, col in TERRAIN_CLASSES:
93 m = (rel >= lo) & (rel < hi)
94 for k in range(3):
95 rgb[..., k] = np.where(m, col[k], rgb[..., k])
96 # shade it so the classes read as terrain rather than a choropleth
97 sh = hillshade(elev, 7680.0)
98 rgb *= (0.45 + 0.55 * sh[..., None])
99 sea = elev <= 0
100 d = np.clip(-elev / 6000.0, 0, 1)
101 for k, c in enumerate((0.10, 0.16, 0.34)):
102 rgb[..., k] = np.where(sea, c * (1 - 0.45 * d), rgb[..., k])
103 return (np.clip(rgb, 0, 1) * 255).astype(np.uint8)
104
105
61106def tiles():
62107 global _tiles
63108 if _tiles is None:
……@@ -564,8 +609,11 @@ def api_coarse():
564609 elev = coarse_to_metres(a[..., 0].astype(np.float64))
565610 vmin = float(request.args.get("vmin", -4000))
566611 vmax = float(request.args.get("vmax", 4000))
567 rgb = colourise_array(elev, 7680.0, request.args.get("view", "shaded-height"),
568 vmin, vmax)
612 view = request.args.get("view", "shaded-height")
613 if view == "terrain-class":
614 rgb = colourise_terrain_class(elev)
615 else:
616 rgb = colourise_array(elev, 7680.0, view, vmin, vmax)
569617 resp = Response(to_png(rgb), mimetype="image/png")
570618 resp.headers["Cache-Control"] = "public, max-age=3600"
571619 resp.headers["X-Height"] = str(h); resp.headers["X-Width"] = str(w)