// 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; }