Files
Battle-for-Tismo/tests/webgl_geometry_test.js
T

389 lines
16 KiB
JavaScript

import { TestCase } from "./framework/test_case.js";
import { GLMapRenderer } from "../client/js/map_view/webgl.js";
import { CORNERS, EDGE_SEGMENTS, latticeKey } from "../client/js/map_view/distortion.js";
import { createHeightField } from "../client/js/map_view/heightmap.js";
import { TERRAIN_LODS } from "../client/js/map_view/constants.js";
import { MapTopology, key, mapToLocal } from "../shared/hex.js";
// Minimal stand-in for a GLMapRenderer: the prototype supplies the pure
// geometry helpers, and only the two GL touch-points (`_upload`, `this.gl`) are
// stubbed so the builders can run without a context.
function makeRenderer() {
const renderer = Object.create(GLMapRenderer.prototype);
renderer.gl = { DYNAMIC_DRAW: 0 };
renderer.captured = {};
renderer.borders = { name: "borders" };
renderer.roads = { name: "roads" };
renderer.regionOverlay = { name: "region" };
renderer.politicalFill = { name: "political" };
renderer.economicFill = { name: "economic" };
renderer._upload = function (pass, arr) {
this.captured[pass.name] = arr;
pass.count = arr.length / 8;
};
return renderer;
}
function view(renderer, { size = 3, territory, regions, selectedRegion = null, roads = new Set(), terrainAt = null } = {}) {
const topology = new MapTopology({ x: size, y: size }, false);
const tiles = {};
for (let y = topology.originY; y < topology.originY + size; y++) {
for (let x = topology.originX; x < topology.originX + size; x++) {
tiles[key(x, y)] = { col: 0, terrainType: terrainAt ? terrainAt(x, y) : "Land" };
}
}
return {
seed: 4242,
topology,
tiles,
roads,
territory: new Map(territory),
regions: new Map(regions),
civilisations: [{ primaryColour: "#ff0000" }, { primaryColour: "#00ff00" }],
_selectedRegion: selectedRegion,
};
}
export class WebglGeometryTest extends TestCase {
async test_chains_stitch_edges_across_tiles() {
const renderer = makeRenderer();
const edges = [
{ points: [{ x: 0, y: 0 }, { x: 1, y: 0 }], startKey: "0", endKey: "1" },
{ points: [{ x: 1, y: 0 }, { x: 2, y: 0 }], startKey: "1", endKey: "2" },
{ points: [{ x: 9, y: 9 }, { x: 8, y: 9 }], startKey: "8", endKey: "7" },
];
const chains = renderer._chainEdges(edges);
this.assertSize(chains, 2, "two disjoint runs");
const run = chains.find((chain) => chain.points.length === 3);
this.assertNotNull(run, "the joined run has three points");
this.assertFalse(run.closed, "a run with two open ends is not closed");
this.assertEqual(run.points[0], { x: 0, y: 0 }, "the shared corner is only listed once");
}
async test_closed_boundary_is_one_chain() {
const renderer = makeRenderer();
const edges = [
{ points: [{ x: 0, y: 0 }, { x: 1, y: 0 }], startKey: "0", endKey: "1" },
{ points: [{ x: 1, y: 0 }, { x: 1, y: 1 }], startKey: "1", endKey: "2" },
{ points: [{ x: 1, y: 1 }, { x: 0, y: 0 }], startKey: "2", endKey: "0" },
];
const chains = renderer._chainEdges(edges);
this.assertSize(chains, 1, "the loop is a single chain");
this.assertTrue(chains[0].closed, "the loop closes");
this.assertSize(chains[0].points, 3, "the repeated closing point is dropped");
}
async test_border_strokes_are_finite() {
const renderer = makeRenderer();
const territory = [
["-1,-1", 0], ["0,-1", 0], ["-1,0", 0], ["0,0", 0],
["1,0", 1], ["1,1", 1],
];
const state = view(renderer, { territory });
renderer.buildBorders(state);
const arr = renderer.captured.borders;
this.assertGreater(arr.length, 0, "borders are built");
for (const value of arr) {
this.assertTrue(Number.isFinite(value), "every vertex coordinate is finite");
}
}
async test_a_nations_whole_outline_is_one_closed_chain() {
// A 3x3 block owned by a single nation has no open ends, so its border must
// chain into one loop rather than a handful of per-tile strokes.
const renderer = makeRenderer();
const territory = [];
for (let y = -1; y <= 1; y++) {
for (let x = -1; x <= 1; x++) territory.push([key(x, y), 0]);
}
const state = view(renderer, { territory });
const edges = [];
for (const [k, owner] of state.territory) {
const coords = k.split(",").map(Number);
const centre = mapToLocal(coords[0], coords[1]);
const neighbours = renderer._edgeNeighbours(state, coords[0], coords[1]);
for (let e = 0; e < CORNERS.length; e++) {
const neighbour = neighbours[e];
if (!neighbour) continue;
const nk = key(neighbour.x, neighbour.y);
if (state.territory.get(nk) === owner) continue;
const a = { x: centre.x + CORNERS[e].x, y: centre.y + CORNERS[e].y };
const next = CORNERS[(e + 1) % CORNERS.length];
const b = { x: centre.x + next.x, y: centre.y + next.y };
const dx = b.x - a.x;
const dy = b.y - a.y;
const mx = (a.x + b.x) / 2;
const my = (a.y + b.y) / 2;
const ownerIsLeft = (centre.x - mx) * -dy + (centre.y - my) * dx >= 0;
edges.push(ownerIsLeft
? { points: [a, b], startKey: latticeKey(a.x, a.y), endKey: latticeKey(b.x, b.y) }
: { points: [b, a], startKey: latticeKey(b.x, b.y), endKey: latticeKey(a.x, a.y) });
}
}
const chains = renderer._chainEdges(edges);
this.assertSize(chains, 1, "the whole coastline is one run");
this.assertTrue(chains[0].closed, "the block's border wraps all the way round");
}
async test_region_border_is_suppressed_on_a_national_border() {
const selected = 0;
const sharedDifferentOwner = [
["0,0", 0], ["1,0", 1],
];
const sharedSameOwner = [
["0,0", 0], ["1,0", 0],
];
const regions = [["0,0", 0], ["1,0", 1]];
const splitRenderer = makeRenderer();
splitRenderer.buildRegionOverlay(
view(splitRenderer, { territory: sharedDifferentOwner, regions, selectedRegion: selected })
);
const split = splitRenderer.captured.region.length;
const joinedRenderer = makeRenderer();
joinedRenderer.buildRegionOverlay(
view(joinedRenderer, { territory: sharedSameOwner, regions, selectedRegion: selected })
);
const joined = joinedRenderer.captured.region.length;
// Only the selected tile (0,0) contributes; its six-edge fill is 24 tris.
const fill = 24 * 3 * 8;
this.assertEqual(split, fill, "no dotted line is drawn over the national border");
this.assertGreater(joined, fill, "a same-owner region border is still dotted");
}
async test_roads_join_at_a_bend_without_a_notch() {
const renderer = makeRenderer();
const state = view(renderer, { roads: new Set(["-1,0", "0,0", "1,0"]) });
renderer.buildRoads(state);
const arr = renderer.captured.roads;
this.assertGreater(arr.length, 0, "roads are built");
for (const value of arr) {
this.assertTrue(Number.isFinite(value), "every road vertex is finite");
}
}
async test_terrain_vertices_carry_the_relief() {
const renderer = makeRenderer();
let captured = null;
renderer._upload = (pass, arr, usage, floats) => {
captured = arr;
pass.floats = floats;
};
const state = view(renderer, {
terrainAt: (x, y) => (x === 0 && y === 0 ? "Mountain" : "Land"),
});
renderer._buildTerrainPass(state, 0);
this.assertEqual(captured.length % 17, 0, "seventeen floats per terrain vertex");
let max = 0;
for (let i = 0; i < captured.length; i += 17) {
const height = captured[i + 6];
this.assertTrue(Number.isFinite(height), "height is finite");
this.assertTrue(height >= 0 && height <= 1, "height is normalised");
this.assertTrue(Number.isFinite(captured[i + 15]), "slope x is finite");
this.assertTrue(Number.isFinite(captured[i + 16]), "slope y is finite");
max = Math.max(max, height);
}
this.assertGreater(max, 0.4, "the mountain lifts its vertices high");
}
async test_a_coarser_level_of_detail_uses_fewer_vertices() {
const renderer = makeRenderer();
const state = view(renderer);
let counts = [];
renderer._upload = (pass, arr) => { counts.push(arr.length); };
renderer._buildTerrainPass(state, 0);
renderer._buildTerrainPass(state, 2);
this.assertGreater(counts[0], counts[1], "the far level has less geometry");
}
async test_the_closest_level_subdivides_each_fan_triangle() {
const renderer = makeRenderer();
const state = view(renderer);
let verts = 0;
renderer._upload = (pass, arr) => { verts = arr.length / 17; };
renderer._buildTerrainPass(state, 0);
const lod = TERRAIN_LODS[0];
const trianglesPerTile = CORNERS.length * lod.segments * 4 ** (lod.subdivisions || 0);
this.assertEqual(verts, 9 * trianglesPerTile * 3, "each fan triangle is split in four");
}
async test_flat_layers_carry_the_relief_too() {
const renderer = makeRenderer();
const state = view(renderer, { roads: new Set(["0,0"]) });
renderer.buildRoads(state);
const arr = renderer.captured.roads;
let lifted = false;
for (let i = 0; i < arr.length; i += 8) {
if (arr[i + 2] > 0) lifted = true;
}
this.assertTrue(lifted, "road vertices ride the heightmap");
}
async test_sea_tiles_are_left_out_of_the_terrain_mesh() {
// The sea is drawn by the single ocean quad, so no terrain vertex may still
// carry the water flag (the terrain layout puts it at index 10).
const renderer = makeRenderer();
let captured = null;
renderer._upload = (pass, arr, usage, floats) => {
captured = arr;
pass.floats = floats;
};
const state = view(renderer, { terrainAt: (x) => (x === 0 ? "Land" : "Sea") });
renderer._buildTerrainPass(state, 0);
this.assertGreater(captured.length, 0, "the land tiles are still built");
for (let i = 0; i < captured.length; i += 17) {
this.assertEqual(captured[i + 10], 0, "no sea tile is drawn as water");
}
}
async test_the_ocean_is_one_flat_quad() {
const renderer = makeRenderer();
renderer.ocean = { name: "ocean" };
const state = view(renderer, { terrainAt: (x) => (x === 0 ? "Land" : "Sea") });
state._worldMinX = -31.5;
state._worldMinY = -56;
state._worldWidth = 100;
state._worldHeight = 80;
renderer.buildOcean(state);
const arr = renderer.captured.ocean;
this.assertEqual(arr.length, 6 * 17, "one quad is two triangles");
for (let i = 0; i < arr.length; i += 17) {
this.assertEqual(arr[i + 6], 0, "the ocean surface is flat");
this.assertEqual(arr[i + 10], 1, "the water flag is set");
this.assertEqual(arr[i + 15], 0, "the ocean has no slope");
this.assertEqual(arr[i + 16], 0, "the ocean has no slope");
}
}
async test_border_strokes_ride_the_relief() {
const renderer = makeRenderer();
const state = view(renderer, {
territory: [["0,0", 0], ["1,0", 1]],
terrainAt: (x) => (x === 0 ? "Mountain" : "Land"),
});
renderer.buildBorders(state);
const arr = renderer.captured.borders;
let lifted = false;
for (let i = 0; i < arr.length; i += 8) {
if (arr[i + 2] > 0) lifted = true;
}
this.assertTrue(lifted, "border vertices ride the heightmap");
}
async test_the_political_fill_paints_owned_tiles_only() {
const renderer = makeRenderer();
const state = view(renderer, { territory: [["0,0", 0], ["1,0", 1]] });
renderer._warp = renderer._buildWarpSet(state);
renderer.buildPolitical(state);
this.assertEmpty(renderer.captured.political, "the terrain map has no political fill");
state.political = true;
renderer.buildPolitical(state);
const arr = renderer.captured.political;
// One flat hexagon per owned tile: six triangles, 8 floats a vertex.
this.assertEqual(arr.length, 2 * 6 * 3 * 8, "two owned tiles are painted");
for (const value of arr) {
this.assertTrue(Number.isFinite(value), "every political vertex is finite");
}
}
async test_the_economic_fill_paints_every_valued_tile() {
const renderer = makeRenderer();
const state = view(renderer);
renderer._warp = renderer._buildWarpSet(state);
renderer.buildEconomic(state);
this.assertEmpty(renderer.captured.economic, "no figures means no economic fill");
state.economic = true;
state.economicValues = new Map([["0,0", 10], ["1,0", 20], ["0,1", 30]]);
state.economicRange = { min: 0, max: 30 };
renderer.buildEconomic(state);
const arr = renderer.captured.economic;
this.assertEqual(arr.length, 3 * 6 * 3 * 8, "one flat hexagon per valued tile");
for (const value of arr) {
this.assertTrue(Number.isFinite(value), "every economic vertex is finite");
}
}
async test_sea_edges_are_straight_except_against_land() {
const renderer = makeRenderer();
const state = view(renderer, { terrainAt: (x) => (x === 0 ? "Sea" : "Land") });
const wavy = renderer.edgeWaviness(state, 0, 0);
this.assertSize(wavy, 6, "one flag per edge");
this.assertTrue(wavy.some((flag) => flag === false), "open-water edges are straight");
this.assertTrue(wavy.some((flag) => flag === true), "coastal edges stay wavy");
this.assertNull(renderer.edgeWaviness(state, 1, 0), "land tiles keep the all-wavy default");
}
async test_a_ridge_in_front_hides_a_border_point() {
const renderer = makeRenderer();
const flat = { at: () => 0.1 };
this.assertFalse(renderer._isOccluded(flat, 0, 0), "flat ground hides nothing");
const ridge = { at: (x, y) => (y > 0 ? 0.9 : 0.1) };
this.assertTrue(renderer._isOccluded(ridge, 0, 0), "a rise to the south hides the point");
const gentle = { at: (x, y) => (y > 0 ? 0.15 : 0.1) };
this.assertFalse(renderer._isOccluded(gentle, 0, 0), "a gentle rise does not");
}
async test_is_occluded_reads_the_relief_field() {
const renderer = makeRenderer();
this.assertFalse(renderer.isOccluded(0, 0), "no field hides nothing");
renderer.heightField = { at: (x, y) => (y > 0 ? 0.9 : 0.1) };
this.assertTrue(renderer.isOccluded(0, 0), "a ridge to the south hides the point");
}
async test_hidden_border_runs_are_dotted() {
const renderer = makeRenderer();
const line = [{ x: 0, y: 0, h: 0 }, { x: 100, y: 0, h: 0 }];
const solid = [];
renderer._stroke(solid, line, 3, [0, 0, 0, 1], false);
const dotted = [];
renderer._stroke(dotted, line, 3, [0, 0, 0, 1], false, [true, true]);
this.assertGreater(dotted.length, solid.length, "a hidden run breaks into dashes");
this.assertEqual(dotted.length % 8, 0, "flat-colour vertices");
}
async test_point_height_reads_the_relief_field() {
const renderer = makeRenderer();
const state = view(renderer, {
terrainAt: (x, y) => (x === 0 && y === 0 ? "Mountain" : "Land"),
});
this.assertEqual(renderer.pointHeight(0, 0), 0, "no field means flat");
renderer.heightField = createHeightField(state);
const centre = mapToLocal(0, 0);
this.assertGreater(renderer.pointHeight(centre.x, centre.y), 0.3, "the mountain lifts its centre");
}
async test_open_water_hexagons_stay_regular() {
const renderer = makeRenderer();
const state = view(renderer, { terrainAt: () => "Sea" });
renderer._warp = renderer._buildWarpSet(state);
this.assertSize(renderer._warp, 0, "open water has no warped vertices");
const centre = mapToLocal(0, 0);
const boundary = renderer.tileBoundary(state, 0, 0);
for (let i = 0; i < CORNERS.length; i++) {
const point = boundary[i * EDGE_SEGMENTS];
this.assertApprox(point.x, centre.x + CORNERS[i].x, 1e-9, "regular corner x");
this.assertApprox(point.y, centre.y + CORNERS[i].y, 1e-9, "regular corner y");
}
}
async test_land_vertices_are_still_warped() {
const renderer = makeRenderer();
const state = view(renderer, { terrainAt: (x) => (x === 0 ? "Land" : "Sea") });
renderer._warp = renderer._buildWarpSet(state);
this.assertGreater(renderer._warp.size, 0, "land vertices are warped");
const centre = mapToLocal(0, 0);
const boundary = renderer.tileBoundary(state, 0, 0);
let moved = false;
for (let i = 0; i < CORNERS.length; i++) {
const point = boundary[i * EDGE_SEGMENTS];
const ideal = { x: centre.x + CORNERS[i].x, y: centre.y + CORNERS[i].y };
if (Math.hypot(point.x - ideal.x, point.y - ideal.y) > 1e-6) moved = true;
}
this.assertTrue(moved, "a land tile keeps its warped outline");
}
}