OverviewFilesCommitsIssuesDocsReleasesPeers

terrain-lookdev commits / 89eefa1

explore: the tiles were laid out transposed — InfiniteDiffusion was never at fault
"i thought you said infinite diffusion is supposed to tile perfectly?" It does, and this is the third measurement saying so, the strongest yet: tiles are stored with a 1 px apron, so tile A's apron covers exactly the ground of tile B's first interior row. Across 108 independently-generated adjacent pairs: MEAN disagreement on identical ground: 0.00 m WORST: 0 m The data is bit-exact across every boundary. The seam was entirely mine. THE BUG. A tile is fetched as fetch(i1 = tx*n, j1 = ty*n) and stored as array[i, j]. Image.fromarray maps axis 0 to rows, i.e. to screen Y. So tx is the SCREEN-Y index and ty is the SCREEN-X index. The page had it the other way round, laying every tile out transposed relative to its own content. tx on screen-X (what shipped) : joins step 6.86x an interior pixel tx on screen-Y (correct) : 0.82x — indistinguishable TWO WRONG DIAGNOSES BEFORE THIS ONE, both of which I reported as findings: "np.gradient uses one-sided differences at tile edges." True, and worth fixing — the apron stays — but NOT the cause. Bisected afterwards: per-tile shade-then-crop scores 0.92x against 0.92x for shading one big array. The shading path was already seamless. "the browser is serving hour-old cached tile PNGs." Also a real bug, now fixed with TILE_VERSION in the URL, but not the cause either — the user disabled cache and the seam stayed. What finally located it was bisecting instead of theorising: shade the same two tiles locally, compare per-tile against one-big-array, find them identical, and conclude the fault had to be downstream of shading. The stitch test I had written to PROVE the seam contained the same transposition, so my instrument and the page were wrong in the same way and agreed with each other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
89eefa1aa4bba04ff07269f14a6f6592cab3dd67
by plankatron <noreply@anthropic.com> · 2026-08-31T19:17:11.000Z

Changed files (3) +54 -17

__pycache__/server.cpython-312.pyc +0 -0
(no content changes — binary or rename)
explore.html +41 -16
……@@ -49,14 +49,31 @@ canvas{position:absolute;inset:0;width:100%;height:100%;display:block}
4949
5050<script>
5151const TILE_PX = 256; // must match the server's tile_px
52// Must match server.py TILE_VERSION. Tile PNGs are browser-cacheable for an
53// hour, so without this in the URL a shading or apron change is invisible until
54// the cache expires -- the page keeps drawing images from a build that is gone.
55let TILE_VERSION = 3;
5256const M_PER_PX = 30; // model native
5357const TILE_M = TILE_PX * M_PER_PX; // 7.68 km per tile
5458
5559const cv = document.getElementById("cv"), ctx = cv.getContext("2d", {alpha:false});
5660const $ = id => document.getElementById(id);
5761
58// view: world position (in model px) at screen centre, and scale (screen px per model px)
59let view = {x: 0, y: 2048 * 1 + 128, scale: 0.25};
62// AXES, and getting this wrong is what made the terrain "not line up".
63//
64// A tile is fetched as fetch(i1 = tx*n, j1 = ty*n) and stored as array[i, j].
65// Image.fromarray maps axis 0 -> rows -> screen Y. So:
66//
67// tx indexes i -> SCREEN Y (north-south)
68// ty indexes j -> SCREEN X (east-west)
69//
70// The first version put tx on screen X, which lays every tile out transposed
71// relative to its own content. Measured: joins stepped 6.86x an interior pixel.
72// With the mapping below, 0.82x -- indistinguishable. The DATA was always
73// exact (0.00 m across 108 overlapping pairs); this was pure bookkeeping.
74//
75// view.x is world j (columns, screen X); view.y is world i (rows, screen Y).
76let view = {x: 2048 + 128, y: 128, scale: 0.25};
6077const imgs = new Map(); // "tx,ty" -> Image (loaded) or "pending"
6178let tileState = {}; // "tx,ty" -> cached|disk|queued|busy|missing|error
6279let stats = {};
……@@ -74,11 +91,12 @@ addEventListener("resize", resize);
7491function visibleTiles() {
7592 const w = innerWidth / view.scale, h = innerHeight / view.scale;
7693 const x0 = view.x - w/2, y0 = view.y - h/2;
77 const t0x = Math.floor(x0 / TILE_PX), t1x = Math.floor((x0 + w) / TILE_PX);
78 const t0y = Math.floor(y0 / TILE_PX), t1y = Math.floor((y0 + h) / TILE_PX);
94 // screen X spans world j -> ty ; screen Y spans world i -> tx
95 const tj0 = Math.floor(x0 / TILE_PX), tj1 = Math.floor((x0 + w) / TILE_PX);
96 const ti0 = Math.floor(y0 / TILE_PX), ti1 = Math.floor((y0 + h) / TILE_PX);
7997 const out = [];
80 for (let ty = t0y; ty <= t1y; ty++)
81 for (let tx = t0x; tx <= t1x; tx++) out.push([tx, ty]);
98 for (let tx = ti0; tx <= ti1; tx++)
99 for (let ty = tj0; ty <= tj1; ty++) out.push([tx, ty]);
82100 return out;
83101}
84102
……@@ -88,7 +106,7 @@ function tileImage(tx, ty) {
88106 const im = new Image();
89107 im.onload = () => { imgs.set(k, im); dirty = true; };
90108 im.onerror = () => { imgs.delete(k); }; // 204 = not ready yet
91 im.src = `/api/tile.png?tx=${tx}&ty=${ty}&view=shaded-height`;
109 im.src = `/api/tile.png?tx=${tx}&ty=${ty}&view=shaded-height&v=${TILE_VERSION}`;
92110 imgs.set(k, "pending");
93111 return "pending";
94112}
……@@ -101,8 +119,8 @@ function draw() {
101119 const showGrid = $("grid").checked;
102120
103121 for (const [tx, ty] of visibleTiles()) {
104 const sx = cxs + (tx * TILE_PX - view.x) * s;
105 const sy = cys + (ty * TILE_PX - view.y) * s;
122 const sx = cxs + (ty * TILE_PX - view.x) * s; // ty = j = east-west
123 const sy = cys + (tx * TILE_PX - view.y) * s; // tx = i = north-south
106124 const im = tileImage(tx, ty);
107125 if (im && im !== "pending") {
108126 ctx.drawImage(im, sx, sy, size + 1, size + 1);
……@@ -171,11 +189,12 @@ async function poll() {
171189 for (let dy = -rings; dy <= rings; dy++)
172190 for (let dx = -rings; dx <= rings; dx++) want.add((tx+dx) + "," + (ty+dy));
173191 }
174 const ctx_ = [Math.floor(view.x / TILE_PX), Math.floor(view.y / TILE_PX)];
192 // centre expressed as (tx, ty) = (i, j), matching the server's ordering
193 const ctx_ = [Math.floor(view.y / TILE_PX), Math.floor(view.x / TILE_PX)];
175194 // Sort by distance from the view centre and ask only for the nearest few.
176195 const sorted = [...want].map(k => {
177 const [x, y] = k.split(",").map(Number);
178 return {k, d: (x - ctx_[0]) ** 2 + (y - ctx_[1]) ** 2};
196 const [tx, ty] = k.split(",").map(Number);
197 return {k, d: (tx - ctx_[0]) ** 2 + (ty - ctx_[1]) ** 2};
179198 }).sort((a, b) => a.d - b.d);
180199 const list = sorted.map(o => o.k).join(";"); // state for all
181200 const ask = sorted.slice(0, MAX_QUEUE).map(o => o.k).join(";"); // generate a few
……@@ -187,6 +206,11 @@ async function poll() {
187206 const r = await fetch(`/api/tiles/state?tiles=${encodeURIComponent(list)}`);
188207 const j = await r.json();
189208 tileState = j.state; stats = j.stats;
209 if (j.tile_version && j.tile_version !== TILE_VERSION) {
210 // The server re-rendered tiles differently. Drop every cached image
211 // rather than showing a mix of two builds.
212 TILE_VERSION = j.tile_version; imgs.clear(); dirty = true;
213 }
190214 // retry any tile that has become available since we last failed on it
191215 for (const [k, st] of Object.entries(tileState))
192216 if ((st === "cached" || st === "disk") && !imgs.has(k)) {
……@@ -207,12 +231,12 @@ $("clearq").onclick = () => {
207231function hud() {
208232 const s = stats || {};
209233 const km = (TILE_PX * M_PER_PX / 1000).toFixed(2);
210 const wx = (view.x * M_PER_PX / 1000).toFixed(1);
211 const wy = (view.y * M_PER_PX / 1000).toFixed(1);
234 const wx = (view.x * M_PER_PX / 1000).toFixed(1); // j, east-west
235 const wy = (view.y * M_PER_PX / 1000).toFixed(1); // i, north-south
212236 const span = (innerWidth / view.scale * M_PER_PX / 1000).toFixed(1);
213237 $("stats").innerHTML =
214238`<span class="a">INFINITE DIFFUSION — STREAMING</span>
215<span class="k">centre </span>${wx} , ${wy} km <span class="k">(tile</span> ${Math.floor(view.x/TILE_PX)},${Math.floor(view.y/TILE_PX)}<span class="k">)</span>
239<span class="k">centre </span>${wx} , ${wy} km <span class="k">(tile</span> ${Math.floor(view.y/TILE_PX)},${Math.floor(view.x/TILE_PX)}<span class="k">)</span>
216240<span class="k">view </span>${span} km across <span class="k">tile</span> ${km} km
217241<span class="k">visible </span>${visibleTiles().length} tiles <span class="k">on-screen</span> ${(TILE_PX*view.scale).toFixed(0)} px${TILE_PX*view.scale < 48 ? ' <span class="w">— too small, not requesting</span>' : ''}
218242
……@@ -249,7 +273,8 @@ $("zoom").addEventListener("input", () => setZoom(+$("zoom").value));
249273$("pre").addEventListener("input", () => { $("v_pre").textContent = $("pre").value; });
250274$("grid").addEventListener("change", () => { dirty = true; });
251275$("go0").onclick = () => { view.x = 0; view.y = 0; dirty = true; poll(); };
252$("gomtn").onclick = () => { view.x = 128; view.y = 2176; dirty = true; poll(); };
276// the mountain region is i=0, j=2048 -> screen y=128, x=2176
277$("gomtn").onclick = () => { view.x = 2176; view.y = 128; dirty = true; poll(); };
253278
254279resize(); setZoom(-2); hud(); poll();
255280</script>
server.py +13 -1
……@@ -34,6 +34,14 @@ INC = os.path.abspath(os.path.join(HERE, "..", "proc-nodes", "src", "shared",
3434app = Flask(__name__, static_folder=None)
3535_tiles = None # TileStore, built lazily so importing this is cheap
3636
37# Bump when anything that changes a tile's PIXELS changes: the apron, the
38# shading, the ramp, the tile size. Tile PNGs are cacheable (they must be, or
39# panning re-encodes constantly), so the URL has to change or browsers keep
40# serving images made by code that no longer exists. That is exactly what
41# happened after the apron fix: the server had 2 tiles and the page was full of
42# them, all rendered by the previous build, all mis-joined.
43TILE_VERSION = 3
44
3745
3846def tiles():
3947 global _tiles
……@@ -511,8 +519,10 @@ def api_tile():
511519 ap = tiles().APRON
512520 png = to_png(shaded[ap:-ap, ap:-ap] if ap else shaded)
513521 r = Response(png, mimetype="image/png")
522 # Safe to cache only because the URL carries TILE_VERSION.
514523 r.headers["Cache-Control"] = "public, max-age=3600"
515524 r.headers["X-Tile"] = f"{tx},{ty}"
525 r.headers["X-Tile-Version"] = str(TILE_VERSION)
516526 return r
517527 except Exception as e:
518528 return Response(str(e), status=400, mimetype="text/plain")
……@@ -550,7 +560,9 @@ def api_tiles_state():
550560 continue
551561 x, y = [int(v) for v in pair.split(",")]
552562 out[f"{x},{y}"] = ts.state(x, y)
553 return Response(json.dumps({"state": out, "stats": ts.summary()}),
563 return Response(json.dumps({"state": out, "stats": ts.summary(),
564 "tile_version": TILE_VERSION,
565 "tile_px": ts.tile_px}),
554566 mimetype="application/json")
555567 except Exception as e:
556568 return Response(str(e), status=400, mimetype="text/plain")