231 lines
11 KiB
JavaScript
231 lines
11 KiB
JavaScript
// 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 };
|
|
}
|