205 lines
8.7 KiB
JavaScript
205 lines
8.7 KiB
JavaScript
// Showing the overlay: the sheet as a texture draped over the globe and laid over the map.
|
|
//
|
|
// A mark is not voted onto the mesh for display, deliberately. The sheet is painted at the template's
|
|
// resolution - a road is eight pixels wide, a village a few hundred - and a region on this mesh covers
|
|
// roughly eight pixels each way, so a per-region colour would turn every road into a chain of blobs and lose
|
|
// the thin strokes entirely. A texture keeps every stroke exactly as painted, which is what an author wants
|
|
// to check: does the road follow the valley the solve made, is the town on the coast it was drawn against.
|
|
//
|
|
// On the globe the texture rides on the planet mesh's own triangles, with longitude and latitude as UVs, so
|
|
// it follows the relief and is never a shell floating over the mountains. On the map it is one flat quad
|
|
// over the map mesh, shifted with the centre-longitude slider through the texture offset rather than by
|
|
// moving the quad, so the seam wraps for free. The class map underneath is dimmed by the Overlay layer
|
|
// (painted-layers.js) for the same reason the Go tool's map_overlay.png halves the class colours: a
|
|
// full-strength mark on top of it cannot be mistaken for the ground.
|
|
|
|
import * as THREE from 'three';
|
|
import { scene } from './scene.js';
|
|
import { state } from './state.js';
|
|
|
|
// The largest sheet uploaded to the GPU. A 7738-wide template is 120 MB as RGBA; halved it is 30 and every
|
|
// stroke survives, because a block keeps the most common mark in it rather than the top-left pixel.
|
|
const SHEET_MAX_W = 4096;
|
|
|
|
const MAP_CLIP_PLANES = [
|
|
new THREE.Plane(new THREE.Vector3(1, 0, 0), 2),
|
|
new THREE.Plane(new THREE.Vector3(-1, 0, 0), 2),
|
|
];
|
|
|
|
/**
|
|
* Draw the mark raster as an RGBA canvas no wider than maxW: each mark in its legend colour, blank
|
|
* transparent. Downsampling is by block vote over the non-blank marks, so a stroke thinner than the block
|
|
* still shows.
|
|
*/
|
|
export function buildSheetCanvas(overlay, maxW = SHEET_MAX_W) {
|
|
const { raster, w, h, legend } = overlay;
|
|
const f = Math.max(1, Math.ceil(w / maxW));
|
|
const ow = Math.ceil(w / f), oh = Math.ceil(h / f);
|
|
const cvs = document.createElement('canvas');
|
|
cvs.width = ow; cvs.height = oh;
|
|
const ctx = cvs.getContext('2d');
|
|
const img = ctx.createImageData(ow, oh);
|
|
const px = img.data;
|
|
const marks = legend.marks;
|
|
const counts = new Int32Array(marks.length + 1);
|
|
for (let oy = 0; oy < oh; oy++) {
|
|
const y0 = oy * f, y1 = Math.min(h, y0 + f);
|
|
for (let ox = 0; ox < ow; ox++) {
|
|
let best = 0;
|
|
if (f === 1) {
|
|
best = raster[y0 * w + ox];
|
|
} else {
|
|
const x0 = ox * f, x1 = Math.min(w, x0 + f);
|
|
counts.fill(0);
|
|
for (let y = y0; y < y1; y++) {
|
|
const row = y * w;
|
|
for (let x = x0; x < x1; x++) { const m = raster[row + x]; if (m) counts[m]++; }
|
|
}
|
|
let bc = 0;
|
|
for (let m = 1; m < counts.length; m++) if (counts[m] > bc) { bc = counts[m]; best = m; }
|
|
}
|
|
if (!best) continue;
|
|
const rgb = marks[best - 1].rgb;
|
|
const o = (oy * ow + ox) * 4;
|
|
px[o] = rgb[0]; px[o + 1] = rgb[1]; px[o + 2] = rgb[2]; px[o + 3] = 255;
|
|
}
|
|
}
|
|
ctx.putImageData(img, 0, 0);
|
|
return cvs;
|
|
}
|
|
|
|
function makeTexture(cvs) {
|
|
const tex = new THREE.CanvasTexture(cvs);
|
|
tex.wrapS = THREE.RepeatWrapping;
|
|
tex.wrapT = THREE.ClampToEdgeWrapping;
|
|
tex.magFilter = THREE.NearestFilter;
|
|
tex.minFilter = THREE.LinearMipmapLinearFilter;
|
|
tex.colorSpace = THREE.SRGBColorSpace;
|
|
return tex;
|
|
}
|
|
|
|
function disposeOverlayMeshes() {
|
|
for (const key of ['overlayGlobeMesh', 'overlayMapMesh']) {
|
|
const m = state[key];
|
|
if (!m) continue;
|
|
scene.remove(m);
|
|
m.geometry.dispose();
|
|
if (m.material.map && m.material.map !== (state.overlay && state.overlay.texture)) m.material.map.dispose();
|
|
m.material.dispose();
|
|
state[key] = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Install a classified overlay ({ legend, raster, w, h, report, name }) as the current sheet, or null to
|
|
* remove it. Builds the texture once; the meshes follow the terrain meshes and are rebuilt with them.
|
|
*/
|
|
export function setOverlaySheet(overlay) {
|
|
disposeOverlayMeshes();
|
|
if (state.overlay && state.overlay.texture) state.overlay.texture.dispose();
|
|
if (!overlay) { state.overlay = null; return; }
|
|
overlay.sheet = buildSheetCanvas(overlay);
|
|
overlay.texture = makeTexture(overlay.sheet);
|
|
state.overlay = overlay;
|
|
updateOverlayMeshes();
|
|
}
|
|
|
|
/** Whether the sheet should be on screen: the toggle, or the Overlay layer, which always shows it. */
|
|
export function overlayWanted() {
|
|
return !!(state.overlay && state.overlay.texture) && (!!state.overlayVisible || state.debugLayer === 'paintOverlay');
|
|
}
|
|
|
|
/** Show or hide the sheet meshes for the current view without rebuilding them. */
|
|
export function setOverlayVisible() {
|
|
const on = overlayWanted();
|
|
if (state.overlayGlobeMesh) state.overlayGlobeMesh.visible = on && !state.mapMode;
|
|
if (state.overlayMapMesh) state.overlayMapMesh.visible = on && state.mapMode;
|
|
}
|
|
|
|
/** Follow the centre-longitude slider while it is being dragged: the map mesh moves, the sheet's UVs shift. */
|
|
export function syncOverlayMapCenter() {
|
|
const m = state.overlayMapMesh;
|
|
if (!m || !m.material.map) return;
|
|
m.material.map.offset.x = (state.mapCenterLon || 0) / (2 * Math.PI);
|
|
}
|
|
|
|
/**
|
|
* Rebuild the sheet meshes over whatever terrain meshes exist now. Called at the end of buildMesh and
|
|
* buildMapMesh in planet-mesh.js, so a rebuilt globe never keeps a stale sheet.
|
|
*/
|
|
export function updateOverlayMeshes() {
|
|
disposeOverlayMeshes();
|
|
const ov = state.overlay;
|
|
if (!ov || !ov.texture) return;
|
|
|
|
if (state.planetMesh) {
|
|
// The planet mesh's own triangles, unindexed, so each vertex is one triangle's and a triangle across
|
|
// the seam can have its U unwrapped past 1 without touching its neighbours.
|
|
const pos = state.planetMesh.geometry.getAttribute('position');
|
|
const n = pos.count;
|
|
const uv = new Float32Array(n * 2);
|
|
for (let i = 0; i < n; i += 3) {
|
|
let umin = 2, umax = -1;
|
|
for (let k = 0; k < 3; k++) {
|
|
const x = pos.getX(i + k), y = pos.getY(i + k), z = pos.getZ(i + k);
|
|
const len = Math.hypot(x, y, z) || 1;
|
|
const lon = Math.atan2(x, z);
|
|
const lat = Math.asin(Math.max(-1, Math.min(1, y / len)));
|
|
const u = (lon / Math.PI + 1) * 0.5;
|
|
uv[(i + k) * 2] = u;
|
|
uv[(i + k) * 2 + 1] = 0.5 + lat / Math.PI;
|
|
if (u < umin) umin = u;
|
|
if (u > umax) umax = u;
|
|
}
|
|
if (umax - umin > 0.5) {
|
|
for (let k = 0; k < 3; k++) { const j = (i + k) * 2; if (uv[j] < 0.5) uv[j] += 1; }
|
|
}
|
|
}
|
|
const geo = new THREE.BufferGeometry();
|
|
geo.setAttribute('position', pos);
|
|
geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
|
|
const mat = new THREE.MeshBasicMaterial({
|
|
map: ov.texture, transparent: true, depthWrite: false,
|
|
polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2,
|
|
});
|
|
const mesh = new THREE.Mesh(geo, mat);
|
|
mesh.renderOrder = 2;
|
|
scene.add(mesh);
|
|
state.overlayGlobeMesh = mesh;
|
|
}
|
|
|
|
if (state.mapMesh) {
|
|
const geo = new THREE.PlaneGeometry(4, 2);
|
|
// Its own texture object over the same canvas, because the offset is per texture and the globe's must
|
|
// stay at zero.
|
|
const tex = makeTexture(ov.sheet);
|
|
const mat = new THREE.MeshBasicMaterial({
|
|
map: tex, transparent: true, depthWrite: false, side: THREE.DoubleSide,
|
|
clippingPlanes: MAP_CLIP_PLANES,
|
|
});
|
|
const mesh = new THREE.Mesh(geo, mat);
|
|
mesh.position.z = 0.0035;
|
|
mesh.renderOrder = 2;
|
|
scene.add(mesh);
|
|
state.overlayMapMesh = mesh;
|
|
syncOverlayMapCenter();
|
|
}
|
|
|
|
setOverlayVisible();
|
|
}
|
|
|
|
/**
|
|
* Composite the sheet over an exported map canvas of width x height, 1:1 where the sheet is at least that
|
|
* wide and by nearest upscaling where it is not, so marks stay crisp.
|
|
*/
|
|
export function compositeOverlaySheet(ctx, width, height) {
|
|
const ov = state.overlay;
|
|
if (!ov || !ov.raster) return;
|
|
const sheet = width >= ov.w ? buildSheetCanvas(ov, ov.w) : buildSheetCanvas(ov, width);
|
|
const prev = ctx.imageSmoothingEnabled;
|
|
ctx.imageSmoothingEnabled = sheet.width === width;
|
|
ctx.drawImage(sheet, 0, 0, width, height);
|
|
ctx.imageSmoothingEnabled = prev;
|
|
sheet.width = 0; sheet.height = 0;
|
|
}
|