Files
UnrealPrototyping/Tools/Orogen/js/painted-overlay-view.js
T
2026-09-25 17:02:24 +03:00

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