// The overlay: the second painting beside a class template, ported from Tools/Terrain internal/overlay. // // The class legend answers "what is the rock doing here" and every colour on it changes the terrain. That is // the wrong place to say "a forest grows here", "this is the village", "a road follows this valley" or "leave // this stretch of coast exactly as I drew it". So there is a second image, the same size as the template and // registered to it, whose colours are *marks* rather than classes, with a legend of its own. Two rules are // the whole design, and both are kept here exactly as the Go tool has them: // // - **A mark that no pass reads still travels.** Forests, settlements and roads change no height anywhere; // they are shown over the terrain and reported, and that is all. // - **A mark that a pass does read changes one number.** `coast_jitter` scales how far the waterline // roughening may move the shore inside the mark: 0 pins a hand-drawn coastline exactly as painted, above // 1 chews it harder than the rest of the world. It is the only mark property any pass reads. // // Blank is decided by alpha, never by a colour: an unpainted pixel is transparent, so no colour is spent on // emptiness and an export with a white matte behind it does not turn the world into whatever mark white is // nearest. An opaque pixel further than `match_distance` from every mark is dropped and counted - the rule // the class legend has inverted, because there every pixel must become something and here most of the sheet // is nothing. // // Pure computation, no DOM: the classifier runs on the main thread so the report is on screen before a // solve, and the region sampling runs inside planet-worker.js. export const OVERLAY_DEFAULT_MATCH_DISTANCE = 40; export const OVERLAY_DEFAULT_MIN_AREA_PX = 24; export const KIND_AREA = 'area'; export const KIND_PATH = 'path'; /** Parse an overlay legend JSON object (the same schema Tools/Terrain reads). Throws with a readable message. */ export function parseOverlayLegend(obj) { if (!obj || typeof obj !== 'object') throw new Error('The overlay legend is not a JSON object'); if (!Array.isArray(obj.marks)) throw new Error('The overlay legend has no "marks" array'); if (obj.marks.length > 254) throw new Error(`The overlay has ${obj.marks.length} marks; the raster holds 254 plus blank`); const seenName = new Map(), seenRGB = new Map(); const marks = obj.marks.map((m, i) => { if (!m || typeof m !== 'object') throw new Error(`Mark ${i} is not an object`); if (typeof m.name !== 'string' || !m.name) throw new Error(`Mark ${i} has no name`); if (seenName.has(m.name)) throw new Error(`Marks ${seenName.get(m.name)} and ${i} are both named "${m.name}"`); seenName.set(m.name, i); if (!Array.isArray(m.rgb) || m.rgb.length !== 3) throw new Error(`Mark "${m.name}" has no rgb`); const rgb = m.rgb.map(v => { const n = Math.round(+v); if (!(n >= 0 && n <= 255)) throw new Error(`Mark "${m.name}": rgb ${v} is outside 0..255`); return n; }); const key = rgb.join(','); if (seenRGB.has(key)) throw new Error(`Marks "${seenRGB.get(key)}" and "${m.name}" share the colour ${key}; nothing could tell them apart`); seenRGB.set(key, m.name); let kind = m.kind || KIND_AREA; if (kind !== KIND_AREA && kind !== KIND_PATH) throw new Error(`Mark "${m.name}": kind "${kind}" is neither "area" nor "path"`); let coastJitter = null; if (m.coast_jitter !== undefined && m.coast_jitter !== null) { coastJitter = +m.coast_jitter; if (!(coastJitter >= 0)) throw new Error(`Mark "${m.name}": coast_jitter is ${m.coast_jitter}; it is a multiplier on how far the waterline may move, so it is never negative`); } const widthM = +m.width_m || 0; if (widthM < 0) throw new Error(`Mark "${m.name}": width_m is ${m.width_m}`); return { index: i + 1, // raster index; 0 is blank name: m.name, rgb, kind, coastJitter, widthM, minAreaPx: +m.min_area_px > 0 ? +m.min_area_px : 0, note: typeof m.note === 'string' ? m.note : '', }; }); return { image: typeof obj.image === 'string' ? obj.image : '', matchDistance: +obj.match_distance > 0 ? +obj.match_distance : OVERLAY_DEFAULT_MATCH_DISTANCE, minAreaPx: +obj.min_area_px > 0 ? +obj.min_area_px : OVERLAY_DEFAULT_MIN_AREA_PX, marks, source: obj, }; } /** Whether any mark asks anything of the coast, which is the only reason a solve has to know about the sheet. */ export function overlayTouchesCoast(legend) { return legend.marks.some(m => m.coastJitter !== null); } /** * Assign every pixel of an RGBA sheet to a mark, or to blank (0). Same rule as the Go classifier: alpha below * half is unpainted; otherwise the nearest mark wins if it is within the tolerance, and a colour further than * that from everything is dropped and counted as `far`. */ export function classifyOverlay(rgba, w, h, legend) { const marks = legend.marks; const n = marks.length; const pr = new Int32Array(n), pg = new Int32Array(n), pb = new Int32Array(n); for (let k = 0; k < n; k++) { pr[k] = marks[k].rgb[0]; pg[k] = marks[k].rgb[1]; pb[k] = marks[k].rgb[2]; } const tol2 = legend.matchDistance * legend.matchDistance; const total = w * h; const out = new Uint8Array(total); const counts = new Int32Array(n + 1); let blank = 0, far = 0, maxD2 = -1, maxAt = [-1, -1]; // A flat stroke repeats its colour millions of times, so the last answer is cached: one compare before // any distance is computed. Exact, because the cache is keyed on the full 24-bit colour. let lastKey = -1, lastBest = 0, lastD2 = 0; for (let p = 0, o = 0; p < total; p++, o += 4) { if (rgba[o + 3] < 128) { blank++; counts[0]++; continue; } const r = rgba[o], g = rgba[o + 1], b = rgba[o + 2]; const key = (r << 16) | (g << 8) | b; if (key !== lastKey) { let best = -1, bestD = 1 << 30; for (let k = 0; k < n; k++) { const dr = r - pr[k], dg = g - pg[k], db = b - pb[k]; const d = dr * dr + dg * dg + db * db; if (d < bestD) { bestD = d; best = k; } } lastKey = key; lastBest = best; lastD2 = bestD; } if (lastBest < 0 || lastD2 > tol2) { blank++; counts[0]++; far++; if (lastD2 > maxD2) { maxD2 = lastD2; maxAt = [p % w, (p / w) | 0]; } continue; } out[p] = lastBest + 1; counts[lastBest + 1]++; } return { marks: out, w, h, counts, total, blank, far, maxDist: maxD2 >= 0 ? Math.sqrt(maxD2) : 0, maxAt, }; } /** The classifier's report as one line, the way `terrain plan` prints it. */ export function overlayReportText(rep) { if (!rep || rep.total === 0) return 'no overlay'; const painted = rep.total - rep.blank; let s = `${painted.toLocaleString()} px painted of ${(rep.total / 1e6).toFixed(1)} MP (${(100 * painted / rep.total).toFixed(1)} %)`; if (rep.far > 0) s += `; ${rep.far.toLocaleString()} px match no mark and were dropped (worst ${rep.maxDist.toFixed(0)} at ${rep.maxAt[0]}, ${rep.maxAt[1]})`; return s + '.'; } const clamp1 = v => Math.max(-1, Math.min(1, v)); /** * Vote the mark raster onto the mesh. Unlike a class, a mark is sparse - a stroke along a coast is a few * pixels wide - so a plain majority would hand almost every region to blank. A region takes the most common * non-blank mark under it when marks cover at least a third of its footprint, else 0. */ export function sampleMarksToMesh(mesh, r_xyz, markRaster, w, h, numMarks) { const N = mesh.numRegions; const r_mark = new Uint8Array(N); const spacing = Math.sqrt(4 * Math.PI / Math.max(1, N - 1)); const pxPerRadX = w / (2 * Math.PI); const pxPerRadY = h / Math.PI; const counts = new Int32Array(numMarks + 1); const MAXS = 7; for (let r = 0; r < N; r++) { const x = r_xyz[3 * r], y = r_xyz[3 * r + 1], z = r_xyz[3 * r + 2]; const lat = Math.asin(clamp1(y)); const lon = Math.atan2(x, z); const cx = (lon / Math.PI + 1) * 0.5 * w; const cy = (0.5 - lat / Math.PI) * h; const cosLat = Math.max(Math.cos(lat), 1e-3); let hx = 0.5 * spacing * pxPerRadX / cosLat; if (hx > w / 2) hx = w / 2; const hy = 0.5 * spacing * pxPerRadY; const nx = Math.min(MAXS, Math.max(1, Math.round(2 * hx))); const ny = Math.min(MAXS, Math.max(1, Math.round(2 * hy))); counts.fill(0); let marked = 0; for (let j = 0; j < ny; j++) { const sy = ny === 1 ? cy : cy - hy + (2 * hy) * (j + 0.5) / ny; let py = Math.floor(sy); if (py < 0) py = 0; else if (py >= h) py = h - 1; const row = py * w; for (let i = 0; i < nx; i++) { const sx = nx === 1 ? cx : cx - hx + (2 * hx) * (i + 0.5) / nx; let px = Math.floor(sx); px = ((px % w) + w) % w; const m = markRaster[row + px]; if (m) { counts[m]++; marked++; } } } if (marked * 3 < nx * ny) continue; let best = 0; for (let m = 1; m <= numMarks; m++) if (counts[m] > counts[best]) best = m; r_mark[r] = best; } return r_mark; } /** * The coast-jitter multiplier per region, from the marks under it. `markJitter[i]` is the multiplier mark i * asks for, or null when it says nothing; regions with no such mark get 1. * * Painting either side of the waterline is enough: the Go pass reads the mark on the far side of the shore * too, so here a set factor spreads two hops over the mesh, and where two spread factors meet the smaller * wins, because pinning is the deliberate act. Returns the factors and how many regions each way. */ export function jitterPerRegion(mesh, r_mark, markJitter, hops = 2) { const N = mesh.numRegions; const { adjOffset, adjList } = mesh; const f = new Float32Array(N).fill(-1); // -1 is "unset" let queue = []; for (let r = 0; r < N; r++) { const m = r_mark[r]; if (!m) continue; const j = markJitter[m]; if (j === null || j === undefined) continue; f[r] = j; queue.push(r); } for (let hop = 0; hop < hops && queue.length; hop++) { const next = []; for (const c of queue) { const fc = f[c]; for (let k = adjOffset[c], kEnd = adjOffset[c + 1]; k < kEnd; k++) { const nb = adjList[k]; if (f[nb] < 0) { f[nb] = fc; next.push(nb); } else if (fc < f[nb] && r_mark[nb] === 0) f[nb] = fc; } } queue = next; } let pinned = 0, marked = 0; for (let r = 0; r < N; r++) { if (f[r] < 0) { f[r] = 1; continue; } marked++; if (f[r] === 0) pinned++; } return { r_jitter: f, pinned, marked }; }