在已合入的平面 hexmap 之上增加球面世界,复用其单元记录(HexCellData / HexValues / HexFlags)、几何常量(HexMetrics)、网格容器(HexMeshData)与地形顶点编码 (HexTerrainVertexCode): - HexSphereTopology:二十面体(Goldberg)拓扑。单元是细分后二十面体的顶点, cellCount = 10f^2 + 2,12 个五边形恒为 [0,12)。方向不是算术:五边形只有 5 条边, 跨边查询一律走 directionOf(cell, other),因为不存在处处成立的 opposite。 - HexSphereMap:球面对应物。逐格脏标记(球面无分块网格),高程为径向, 含拾取、距离与笔刷。 - HexSphereMesh:整球地形与海洋网格。平面版用弦 v1->v5 跨越梯田带,这在平面上安全 只因其 5 个采样点共线;球面采样点在弧上,故按扇形同样拆成 4 段以免 T 型接缝。 - HexSphereGenerator:三维值噪声 fBm、按噪声自身分位数定海平面、生物群系。 - HexMapModule:28 个 hexmap.sphere* 脚本绑定。 - examples/hex-planet:轨道相机 + 两个片元着色器。平面着色器把 vWorldPos.y 当海拔、 N.y 当“上”、细节噪声投影到 XZ、远处加距离雾,这四点在球面上均不成立。 - test/hexmap_sphere.cpp:31 个用例;docs/usr/modules/hexmap.md 补充球面章节。 验证:hexmap 套件 66/66 通过(含 31 个球面用例);示例实机跑通并自捕获 hex-planet.png。网格闭合性由焊接后的欧拉特征 V - E + F = 2 断言,朝向由 “无内向三角形”断言。
956 lines
38 KiB
C++
956 lines
38 KiB
C++
#include "zeroerr/assert.h"
|
|
#include "zeroerr/unittest.h"
|
|
|
|
#include "hexmap/HexSphereGenerator.h"
|
|
#include "hexmap/HexSphereMap.h"
|
|
#include "hexmap/HexSphereMesh.h"
|
|
#include "hexmap/HexSphereTopology.h"
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <cmath>
|
|
#include <cstddef>
|
|
#include <cstdint>
|
|
#include <limits>
|
|
#include <map>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
using namespace eve::hexmap;
|
|
|
|
namespace {
|
|
|
|
/** @brief Builds a topology the test depends on, failing the case when it cannot. */
|
|
[[nodiscard]] HexSphereTopology makeTopology(std::int32_t subdivision) {
|
|
auto built = HexSphereTopology::build(subdivision);
|
|
REQUIRE(built.ok());
|
|
return built.value();
|
|
}
|
|
|
|
/** @brief Dot product, so the tests do not depend on module internals. */
|
|
[[nodiscard]] float dot(HexVec3 a, HexVec3 b) { return a.x * b.x + a.y * b.y + a.z * b.z; }
|
|
|
|
/** @brief Length of a vector. */
|
|
[[nodiscard]] float magnitude(HexVec3 a) { return std::sqrt(dot(a, a)); }
|
|
|
|
/** @brief Whether two directions denote the same point within float tolerance. */
|
|
[[nodiscard]] bool sameDirection(HexVec3 a, HexVec3 b) { return magnitude(a - b) < 1e-5f; }
|
|
|
|
/**
|
|
* @brief A deterministic, low-discrepancy sample of the sphere.
|
|
*
|
|
* A golden-angle spiral is used instead of a PRNG so a failure always reproduces
|
|
* and the samples do not clump at the poles, where a naive uniform latitude
|
|
* sampling would over-weight the twelve pentagons.
|
|
*/
|
|
[[nodiscard]] HexVec3 sampleDirection(std::uint32_t index, std::uint32_t count) {
|
|
constexpr float kGoldenAngle = 2.399963229728653f;
|
|
const float fraction = (static_cast<float>(index) + 0.5f) / static_cast<float>(count);
|
|
const float y = 1.f - 2.f * fraction;
|
|
const float radius = std::sqrt(std::max(0.f, 1.f - y * y));
|
|
const float angle = kGoldenAngle * static_cast<float>(index);
|
|
return HexVec3{radius * std::cos(angle), y, radius * std::sin(angle)};
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("hexmap.sphere.countsMatchTheGoldbergFormula") {
|
|
for (std::int32_t subdivision = 0; subdivision <= 4; ++subdivision) {
|
|
const HexSphereTopology topology = makeTopology(subdivision);
|
|
const std::int32_t frequency = std::int32_t{1} << subdivision;
|
|
const std::int32_t cellCount = 10 * frequency * frequency + 2;
|
|
|
|
CHECK_EQ(topology.subdivision(), subdivision);
|
|
CHECK_EQ(topology.frequency(), frequency);
|
|
CHECK_EQ(topology.cellCount(), cellCount);
|
|
CHECK_EQ(topology.pentagonCount(), 12);
|
|
CHECK_EQ(topology.edgeCount(), 30 * frequency * frequency);
|
|
CHECK_EQ(topology.cornerCount(), 20 * frequency * frequency);
|
|
// Euler: a closed surface of genus zero.
|
|
CHECK_EQ(topology.cellCount() - topology.edgeCount() + topology.cornerCount(), 2);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("hexmap.sphere.pentagonsAreTheTwelveIcosahedronVertices") {
|
|
const HexSphereTopology topology = makeTopology(3);
|
|
CHECK_EQ(topology.pentagonCount(), 12);
|
|
|
|
for (HexSphereCell cell = 0; cell < topology.cellCount(); ++cell) {
|
|
const bool pentagon = cell < 12;
|
|
CHECK_EQ(topology.isPentagon(cell), pentagon);
|
|
CHECK_EQ(topology.neighborCount(cell), pentagon ? 5 : 6);
|
|
CHECK_EQ(topology.cornerCountOf(cell), topology.neighborCount(cell));
|
|
}
|
|
|
|
// Ids are stable across levels, so the twelve pentagons are the same cells at
|
|
// every subdivision and their directions are the icosahedron's vertices.
|
|
const HexSphereTopology coarse = makeTopology(0);
|
|
const HexSphereTopology fine = makeTopology(4);
|
|
for (HexSphereCell cell = 0; cell < 12; ++cell) {
|
|
CHECK(sameDirection(coarse.direction(cell), fine.direction(cell)));
|
|
CHECK(fine.isPentagon(cell));
|
|
}
|
|
}
|
|
|
|
TEST_CASE("hexmap.sphere.neighboursAreMutualAndShareBothCorners") {
|
|
const HexSphereTopology topology = makeTopology(3);
|
|
|
|
for (HexSphereCell cell = 0; cell < topology.cellCount(); ++cell) {
|
|
const std::int32_t degree = topology.neighborCount(cell);
|
|
// Kept out of the assertion expression: zeroerr decomposes `a || b` and
|
|
// would collide with the built-in boolean operator.
|
|
const bool degreeIsHexOrPentagon = degree == 5 || degree == 6;
|
|
REQUIRE(degreeIsHexOrPentagon);
|
|
|
|
for (std::int32_t direction = 0; direction < degree; ++direction) {
|
|
const HexSphereCell other = topology.neighbor(cell, direction);
|
|
REQUIRE(other != kNoHexSphereCell);
|
|
CHECK(topology.contains(other));
|
|
|
|
const std::int32_t back = topology.directionOf(other, cell);
|
|
REQUIRE(back >= 0);
|
|
CHECK_EQ(back, topology.oppositeDirection(cell, direction));
|
|
|
|
// The shared edge must expose the same two corners from both sides,
|
|
// in reversed order; that is what lets a mesh builder walk a boundary
|
|
// from either cell.
|
|
const std::int32_t otherDegree = topology.neighborCount(other);
|
|
const HexVec3 here = topology.corner(cell, direction);
|
|
const HexVec3 hereNext = topology.corner(cell, (direction + 1) % degree);
|
|
const HexVec3 there = topology.corner(other, back);
|
|
const HexVec3 thereNext = topology.corner(other, (back + 1) % otherDegree);
|
|
|
|
CHECK(sameDirection(here, thereNext));
|
|
CHECK(sameDirection(hereNext, there));
|
|
CHECK(!sameDirection(here, hereNext));
|
|
|
|
// Adjacency is symmetric in the other direction too: `other` reports
|
|
// `cell` exactly once.
|
|
std::int32_t matches = 0;
|
|
for (std::int32_t candidate = 0; candidate < otherDegree; ++candidate) {
|
|
if (topology.neighbor(other, candidate) == cell) ++matches;
|
|
}
|
|
CHECK_EQ(matches, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST_CASE("hexmap.sphere.directionsAndCornersAreUnitVectors") {
|
|
const HexSphereTopology topology = makeTopology(3);
|
|
|
|
for (HexSphereCell cell = 0; cell < topology.cellCount(); ++cell) {
|
|
CHECK(std::fabs(magnitude(topology.direction(cell)) - 1.f) < 1e-5f);
|
|
const std::int32_t degree = topology.neighborCount(cell);
|
|
for (std::int32_t corner = 0; corner < degree; ++corner) {
|
|
CHECK(std::fabs(magnitude(topology.corner(cell, corner)) - 1.f) < 1e-5f);
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST_CASE("hexmap.sphere.everyCornerIsSharedByExactlyThreeCells") {
|
|
const HexSphereTopology topology = makeTopology(2);
|
|
|
|
std::vector<HexVec3> groups;
|
|
std::vector<std::int32_t> members;
|
|
|
|
for (HexSphereCell cell = 0; cell < topology.cellCount(); ++cell) {
|
|
const std::int32_t degree = topology.neighborCount(cell);
|
|
for (std::int32_t corner = 0; corner < degree; ++corner) {
|
|
const HexVec3 position = topology.corner(cell, corner);
|
|
std::size_t group = groups.size();
|
|
for (std::size_t candidate = 0; candidate < groups.size(); ++candidate) {
|
|
if (sameDirection(groups[candidate], position)) {
|
|
group = candidate;
|
|
break;
|
|
}
|
|
}
|
|
if (group == groups.size()) {
|
|
groups.push_back(position);
|
|
members.push_back(1);
|
|
} else {
|
|
++members[group];
|
|
}
|
|
}
|
|
}
|
|
|
|
CHECK_EQ(static_cast<std::int32_t>(groups.size()), topology.cornerCount());
|
|
for (const std::int32_t count : members) CHECK_EQ(count, 3);
|
|
}
|
|
|
|
TEST_CASE("hexmap.sphere.edgeLengthsAreNearUniform") {
|
|
const HexSphereTopology topology = makeTopology(3);
|
|
|
|
float shortest = 10.f;
|
|
float longest = 0.f;
|
|
for (HexSphereCell cell = 0; cell < topology.cellCount(); ++cell) {
|
|
const std::int32_t degree = topology.neighborCount(cell);
|
|
for (std::int32_t direction = 0; direction < degree; ++direction) {
|
|
const float length = HexSphereTopology::angularDistance(
|
|
topology.direction(cell), topology.direction(topology.neighbor(cell, direction)));
|
|
shortest = std::min(shortest, length);
|
|
longest = std::max(longest, length);
|
|
}
|
|
}
|
|
|
|
CHECK(shortest > 0.f);
|
|
// Hexagon-hexagon edges are slightly longer than the edges around a pentagon;
|
|
// the ratio stays close to one because both are governed by the same level.
|
|
CHECK(longest / shortest < 1.3f);
|
|
}
|
|
|
|
TEST_CASE("hexmap.sphere.cellAtMatchesBruteForceNearest") {
|
|
const HexSphereTopology topology = makeTopology(3);
|
|
|
|
constexpr std::uint32_t kSamples = 400;
|
|
for (std::uint32_t index = 0; index < kSamples; ++index) {
|
|
const HexVec3 sample = sampleDirection(index, kSamples);
|
|
|
|
HexSphereCell best = 0;
|
|
float bestScore = -2.f;
|
|
for (HexSphereCell cell = 0; cell < topology.cellCount(); ++cell) {
|
|
const float score = dot(topology.direction(cell), sample);
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
best = cell;
|
|
}
|
|
}
|
|
|
|
const HexSphereCell found = topology.cellAt(sample);
|
|
REQUIRE(found != kNoHexSphereCell);
|
|
CHECK_EQ(found, best);
|
|
|
|
// The reported cell must actually contain the sample: the sample is closer
|
|
// to its own centre than to every neighbour's.
|
|
for (std::int32_t direction = 0; direction < topology.neighborCount(found); ++direction) {
|
|
CHECK(dot(topology.direction(found), sample) >
|
|
dot(topology.direction(topology.neighbor(found, direction)), sample));
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST_CASE("hexmap.sphere.subdivisionOutOfRangeIsRejected") {
|
|
auto tooLow = HexSphereTopology::build(-1);
|
|
CHECK(!tooLow.ok());
|
|
|
|
auto tooHigh = HexSphereTopology::build(kMaxHexSphereSubdivision + 1);
|
|
CHECK(!tooHigh.ok());
|
|
|
|
auto base = HexSphereTopology::build(0);
|
|
REQUIRE(base.ok());
|
|
CHECK_EQ(base.value().cellCount(), 12);
|
|
CHECK_EQ(base.value().pentagonCount(), 12);
|
|
CHECK_EQ(base.value().cornerCount(), 20);
|
|
CHECK_EQ(base.value().edgeCount(), 30);
|
|
for (HexSphereCell cell = 0; cell < 12; ++cell) {
|
|
CHECK(base.value().isPentagon(cell));
|
|
CHECK_EQ(base.value().neighborCount(cell), 5);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("hexmap.sphere.buildIsDeterministic") {
|
|
const HexSphereTopology first = makeTopology(2);
|
|
const HexSphereTopology second = makeTopology(2);
|
|
|
|
REQUIRE(first.cellCount() == second.cellCount());
|
|
for (HexSphereCell cell = 0; cell < first.cellCount(); ++cell) {
|
|
CHECK(sameDirection(first.direction(cell), second.direction(cell)));
|
|
const std::int32_t degree = first.neighborCount(cell);
|
|
CHECK_EQ(degree, second.neighborCount(cell));
|
|
for (std::int32_t direction = 0; direction < degree; ++direction) {
|
|
CHECK_EQ(first.neighbor(cell, direction), second.neighbor(cell, direction));
|
|
CHECK(sameDirection(first.corner(cell, direction), second.corner(cell, direction)));
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST_CASE("hexmap.sphere.outOfRangeQueriesAreTotal") {
|
|
const HexSphereTopology topology = makeTopology(1);
|
|
|
|
CHECK_EQ(topology.neighborCount(-1), 0);
|
|
CHECK_EQ(topology.neighborCount(topology.cellCount()), 0);
|
|
CHECK_EQ(topology.neighbor(-1, 0), kNoHexSphereCell);
|
|
CHECK_EQ(topology.neighbor(0, -1), kNoHexSphereCell);
|
|
CHECK_EQ(topology.neighbor(0, topology.neighborCount(0)), kNoHexSphereCell);
|
|
CHECK_EQ(topology.directionOf(0, topology.cellCount()), kNoHexSphereCell);
|
|
CHECK_EQ(topology.oppositeDirection(-1, 0), kNoHexSphereCell);
|
|
|
|
// `directionOf` must report exactly the cell's own degree, no more and no less.
|
|
std::int32_t adjacent = 0;
|
|
for (HexSphereCell cell = 0; cell < topology.cellCount(); ++cell) {
|
|
if (topology.directionOf(0, cell) >= 0) ++adjacent;
|
|
}
|
|
CHECK_EQ(adjacent, topology.neighborCount(0));
|
|
|
|
const HexSphereTopology empty;
|
|
CHECK(empty.empty());
|
|
CHECK_EQ(empty.cellCount(), 0);
|
|
CHECK_EQ(empty.pentagonCount(), 0);
|
|
CHECK_EQ(empty.edgeCount(), 0);
|
|
CHECK_EQ(empty.cornerCount(), 0);
|
|
CHECK_EQ(empty.cellAt(HexVec3{0.f, 1.f, 0.f}), kNoHexSphereCell);
|
|
}
|
|
|
|
// --- HexSphereMap -----------------------------------------------------------
|
|
|
|
namespace {
|
|
|
|
/** @brief Builds a map the test depends on, failing the case when it cannot. */
|
|
[[nodiscard]] HexSphereMap makeMap(std::int32_t subdivision, float radius = 100.f, std::uint32_t seed = 7u) {
|
|
HexSphereMap map;
|
|
auto built = map.reset(subdivision, radius, seed);
|
|
REQUIRE(built.ok());
|
|
return map;
|
|
}
|
|
|
|
/** @brief Drains the dirty queue into a vector of cell ids. */
|
|
[[nodiscard]] std::vector<HexSphereCell> drainDirty(HexSphereMap& map) {
|
|
std::vector<HexSphereCell> dirty;
|
|
for (HexSphereCell cell = map.takeDirtyCell(); cell != kNoHexSphereCell; cell = map.takeDirtyCell())
|
|
dirty.push_back(cell);
|
|
return dirty;
|
|
}
|
|
|
|
[[nodiscard]] bool holds(const std::vector<HexSphereCell>& cells, HexSphereCell cell) {
|
|
return std::find(cells.begin(), cells.end(), cell) != cells.end();
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("hexmap.sphereMap.resetBuildsEveryCell") {
|
|
const HexSphereMap map = makeMap(2, 250.f, 11u);
|
|
|
|
CHECK(!map.empty());
|
|
CHECK_EQ(map.subdivision(), 2);
|
|
CHECK_EQ(map.frequency(), 4);
|
|
CHECK_EQ(map.cellCount(), 10 * 4 * 4 + 2);
|
|
CHECK_EQ(map.pentagonCount(), 12);
|
|
CHECK_EQ(map.topology().cellCount(), map.cellCount());
|
|
CHECK_EQ(map.seed(), 11u);
|
|
CHECK_EQ(map.sphereRadius(), 250.f);
|
|
CHECK(map.elevationStep() > 0.f);
|
|
|
|
for (HexSphereCell cell = 0; cell < map.cellCount(); ++cell) {
|
|
CHECK_EQ(map.elevation(cell), 0);
|
|
CHECK_EQ(map.waterLevel(cell), 0);
|
|
CHECK_EQ(map.terrainType(cell), 0);
|
|
CHECK(!map.hasRiver(cell));
|
|
CHECK(!map.hasRoad(cell));
|
|
CHECK(!map.isUnderwater(cell));
|
|
CHECK(!map.isWalled(cell));
|
|
}
|
|
|
|
// A fresh map owes a rebuild of every cell, exactly once.
|
|
CHECK_EQ(map.dirtyCellCount(), map.cellCount());
|
|
}
|
|
|
|
TEST_CASE("hexmap.sphereMap.elevationIsRadial") {
|
|
HexSphereMap map = makeMap(1, 100.f, 3u);
|
|
const float step = map.elevationStep();
|
|
|
|
for (const HexSphereCell cell : std::vector<HexSphereCell>{0, 13, 40}) {
|
|
for (const std::int32_t level : std::vector<std::int32_t>{-4, -1, 0, 3, 8}) {
|
|
CHECK(map.setElevation(cell, level).ok());
|
|
CHECK_EQ(map.elevation(cell), level);
|
|
|
|
const float radius = 100.f + static_cast<float>(level) * step;
|
|
const HexVec3 direction = map.direction(cell);
|
|
CHECK(std::fabs(map.surfaceRadius(cell) - radius) < 1e-3f);
|
|
CHECK(std::fabs(map.cellPosition(cell).x - direction.x * radius) < 1e-3f);
|
|
CHECK(std::fabs(map.cellPosition(cell).y - direction.y * radius) < 1e-3f);
|
|
CHECK(std::fabs(map.cellPosition(cell).z - direction.z * radius) < 1e-3f);
|
|
|
|
// The ground position is the same point at the sphere radius.
|
|
CHECK(std::fabs(map.cellGroundPosition(cell).x - direction.x * 100.f) < 1e-3f);
|
|
CHECK(std::fabs(map.cellGroundPosition(cell).y - direction.y * 100.f) < 1e-3f);
|
|
CHECK(std::fabs(map.cellGroundPosition(cell).z - direction.z * 100.f) < 1e-3f);
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST_CASE("hexmap.sphereMap.authoringClampsAndMarksDirty") {
|
|
HexSphereMap map = makeMap(1);
|
|
// A fresh map owes a rebuild of every cell, so draining it must yield them all.
|
|
CHECK_EQ(static_cast<std::int32_t>(drainDirty(map).size()), map.cellCount());
|
|
CHECK_EQ(map.dirtyCellCount(), 0);
|
|
|
|
const HexSphereCell cell = 20;
|
|
CHECK(map.setElevation(cell, 99).ok());
|
|
CHECK_EQ(map.elevation(cell), HexMetrics::kMaxElevation);
|
|
CHECK(map.setElevation(cell, -99).ok());
|
|
CHECK_EQ(map.elevation(cell), HexMetrics::kMinElevation);
|
|
CHECK(map.setWaterLevel(cell, 99).ok());
|
|
CHECK_EQ(map.waterLevel(cell), HexMetrics::kMaxElevation);
|
|
CHECK(map.setTerrainType(cell, 99).ok());
|
|
CHECK_EQ(map.terrainType(cell), kHexTerrainTypeCount - 1);
|
|
CHECK_EQ(map.terrainType(cell), static_cast<std::int32_t>(HexTerrainType::Snow));
|
|
|
|
// Every edit owed a rebuild of the cell and of all of its neighbours.
|
|
const std::vector<HexSphereCell> dirty = drainDirty(map);
|
|
CHECK(holds(dirty, cell));
|
|
for (std::int32_t d = 0; d < map.neighborCount(cell); ++d) CHECK(holds(dirty, map.neighbor(cell, d)));
|
|
|
|
CHECK_EQ(map.dirtyCellCount(), 0);
|
|
CHECK_EQ(map.takeDirtyCell(), kNoHexSphereCell);
|
|
}
|
|
|
|
TEST_CASE("hexmap.sphereMap.dirtyQueueDrainsEachCellOnce") {
|
|
HexSphereMap map = makeMap(2);
|
|
const auto expected = static_cast<std::size_t>(map.cellCount());
|
|
|
|
std::vector<std::uint8_t> seen(expected, 0u);
|
|
std::size_t count = 0;
|
|
for (std::int32_t cell = map.takeDirtyCell(); cell != kNoHexSphereCell; cell = map.takeDirtyCell()) {
|
|
REQUIRE(cell >= 0);
|
|
REQUIRE(static_cast<std::size_t>(cell) < expected);
|
|
CHECK_EQ(seen[static_cast<std::size_t>(cell)], 0);
|
|
seen[static_cast<std::size_t>(cell)] = 1u;
|
|
++count;
|
|
}
|
|
CHECK_EQ(count, expected);
|
|
CHECK_EQ(map.dirtyCellCount(), 0);
|
|
CHECK_EQ(map.takeDirtyCell(), kNoHexSphereCell);
|
|
}
|
|
|
|
TEST_CASE("hexmap.sphereMap.distanceIsSymmetricAndMatchesNeighbours") {
|
|
const HexSphereMap map = makeMap(3);
|
|
CHECK_EQ(map.distance(13, 13), 0);
|
|
CHECK_EQ(map.distance(-1, 0), kNoHexSphereCell);
|
|
CHECK_EQ(map.distance(0, map.cellCount()), kNoHexSphereCell);
|
|
|
|
for (const HexSphereCell cell : std::vector<HexSphereCell>{0, 13, 100, 641}) {
|
|
for (std::int32_t d = 0; d < map.neighborCount(cell); ++d) {
|
|
const HexSphereCell neighbour = map.neighbor(cell, d);
|
|
CHECK_EQ(map.distance(cell, neighbour), 1);
|
|
CHECK_EQ(map.distance(neighbour, cell), 1);
|
|
}
|
|
}
|
|
|
|
for (const HexSphereCell a : std::vector<HexSphereCell>{0, 13, 100}) {
|
|
for (const HexSphereCell b : std::vector<HexSphereCell>{200, 400, 641}) {
|
|
CHECK_EQ(map.distance(a, b), map.distance(b, a));
|
|
CHECK(map.distance(a, b) > 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST_CASE("hexmap.sphereMap.brushIsExactlyTheBreadthFirstBall") {
|
|
const HexSphereMap map = makeMap(2);
|
|
std::vector<HexSphereCell> cells;
|
|
|
|
map.collectBrush(0, 0, cells);
|
|
CHECK_EQ(static_cast<std::int32_t>(cells.size()), 1);
|
|
CHECK_EQ(cells.front(), 0);
|
|
|
|
// The centre is pentagonal, so its one-step brush is smaller than a hexagon's.
|
|
map.collectBrush(0, 1, cells);
|
|
CHECK_EQ(static_cast<std::int32_t>(cells.size()), 1 + map.neighborCount(0));
|
|
CHECK_EQ(map.neighborCount(0), 5);
|
|
CHECK_EQ(map.neighborCount(13), 6);
|
|
map.collectBrush(13, 1, cells);
|
|
CHECK_EQ(static_cast<std::int32_t>(cells.size()), 7);
|
|
|
|
// The brush must be exactly the set of cells within `radius` steps: nothing
|
|
// farther than the radius, and nothing missing.
|
|
for (std::int32_t radius = 0; radius <= 3; ++radius) {
|
|
map.collectBrush(13, radius, cells);
|
|
std::vector<std::uint8_t> inBrush(static_cast<std::size_t>(map.cellCount()), 0u);
|
|
for (const HexSphereCell cell : cells) inBrush[static_cast<std::size_t>(cell)] = 1u;
|
|
for (HexSphereCell cell = 0; cell < map.cellCount(); ++cell) {
|
|
const bool within = map.distance(13, cell) <= radius;
|
|
CHECK_EQ(static_cast<bool>(inBrush[static_cast<std::size_t>(cell)]), within);
|
|
}
|
|
}
|
|
|
|
// Breadth-first order is deterministic for a given topology.
|
|
std::vector<HexSphereCell> again;
|
|
map.collectBrush(13, 2, cells);
|
|
map.collectBrush(13, 2, again);
|
|
CHECK_EQ(cells.size(), again.size());
|
|
for (std::size_t i = 0; i < cells.size(); ++i) CHECK_EQ(cells[i], again[i]);
|
|
|
|
// An invalid centre or a negative radius yields an empty brush.
|
|
map.collectBrush(-1, 2, cells);
|
|
CHECK(cells.empty());
|
|
map.collectBrush(13, -1, cells);
|
|
CHECK(cells.empty());
|
|
}
|
|
|
|
TEST_CASE("hexmap.sphereMap.riversMirrorAcrossTheSharedEdge") {
|
|
HexSphereMap map = makeMap(2);
|
|
|
|
// A pentagon's edges have no arithmetic opposite, so this is precisely the
|
|
// case a planar `opposite(direction)` would get wrong.
|
|
const HexSphereCell pentagon = 0;
|
|
const HexSphereCell neighbour = map.neighbor(pentagon, 2);
|
|
const std::int32_t back = map.oppositeDirection(pentagon, 2);
|
|
REQUIRE(neighbour != kNoHexSphereCell);
|
|
REQUIRE(back >= 0);
|
|
CHECK_EQ(map.neighbor(neighbour, back), pentagon);
|
|
|
|
CHECK(map.setOutgoingRiver(pentagon, 2).ok());
|
|
CHECK(map.hasRiverThrough(pentagon, 2));
|
|
CHECK(map.hasRiverThrough(neighbour, back));
|
|
|
|
CHECK(map.removeRiver(pentagon).ok());
|
|
CHECK(!map.hasRiver(pentagon));
|
|
CHECK(!map.hasRiver(neighbour));
|
|
|
|
// A river may not be carved into an edge the cell does not have.
|
|
CHECK(!map.setOutgoingRiver(pentagon, map.neighborCount(pentagon)).ok());
|
|
CHECK(!map.setOutgoingRiver(pentagon, -1).ok());
|
|
}
|
|
|
|
TEST_CASE("hexmap.sphereMap.roadsMirrorAndRejectSteepEdges") {
|
|
HexSphereMap map = makeMap(2);
|
|
const HexSphereCell a = 13;
|
|
const std::int32_t dir = 1;
|
|
const HexSphereCell b = map.neighbor(a, dir);
|
|
const std::int32_t back = map.oppositeDirection(a, dir);
|
|
REQUIRE(b != kNoHexSphereCell);
|
|
REQUIRE(back >= 0);
|
|
|
|
CHECK(map.addRoad(a, dir).ok());
|
|
CHECK(map.hasRoad(a));
|
|
CHECK(map.hasRoad(b));
|
|
|
|
// Raising the neighbour by more than one step must drop the road again.
|
|
CHECK(map.setElevation(b, map.elevation(a) + 3).ok());
|
|
CHECK(!map.hasRoad(a));
|
|
CHECK(!map.hasRoad(b));
|
|
|
|
// A road may only be added across an existing edge, and only over a slope.
|
|
CHECK(!map.addRoad(a, map.neighborCount(a)).ok());
|
|
CHECK(map.setElevation(b, map.elevation(a) + 2).ok());
|
|
CHECK(map.addRoad(a, dir).ok());
|
|
CHECK(!map.hasRoad(a));
|
|
}
|
|
|
|
TEST_CASE("hexmap.sphereMap.pickCellFindsTheFacingCell") {
|
|
HexSphereMap map = makeMap(2, 100.f, 5u);
|
|
|
|
for (const HexSphereCell expected : std::vector<HexSphereCell>{0, 5, 11, 13, 100, 161}) {
|
|
const HexVec3 direction = map.direction(expected);
|
|
const HexVec3 origin = direction * 400.f;
|
|
auto picked = map.pickCell(origin, HexVec3{-direction.x, -direction.y, -direction.z});
|
|
REQUIRE(picked.ok());
|
|
CHECK_EQ(picked.value(), expected);
|
|
}
|
|
|
|
// Elevation must not be ignored: a cell raised well above the sphere still
|
|
// answers for the ray aimed at it, and its neighbour does not.
|
|
CHECK(map.setElevation(13, HexMetrics::kMaxElevation).ok());
|
|
{
|
|
const HexVec3 direction = map.direction(13);
|
|
auto picked = map.pickCell(direction * 400.f, HexVec3{-direction.x, -direction.y, -direction.z});
|
|
REQUIRE(picked.ok());
|
|
CHECK_EQ(picked.value(), 13);
|
|
}
|
|
|
|
const HexVec3 away = map.direction(13) * 400.f;
|
|
auto missed = map.pickCell(away, map.direction(13));
|
|
CHECK(!missed.ok());
|
|
CHECK(missed.code() == eve::StatusCode::NotFound);
|
|
|
|
auto degenerate = map.pickCell(HexVec3{0.f, 0.f, 300.f}, HexVec3{});
|
|
CHECK(!degenerate.ok());
|
|
CHECK(degenerate.code() == eve::StatusCode::Rejected);
|
|
|
|
const HexSphereMap empty;
|
|
auto nothing = empty.pickCell(HexVec3{0.f, 0.f, 300.f}, HexVec3{0.f, 0.f, -1.f});
|
|
CHECK(!nothing.ok());
|
|
CHECK(nothing.code() == eve::StatusCode::NotFound);
|
|
}
|
|
|
|
TEST_CASE("hexmap.sphereMap.rejectsBadShapeArguments") {
|
|
HexSphereMap map;
|
|
|
|
CHECK(!map.reset(-1, 100.f, 1u).ok());
|
|
CHECK(!map.reset(kMaxHexSphereSubdivision + 1, 100.f, 1u).ok());
|
|
CHECK(!map.reset(1, 0.f, 1u).ok());
|
|
CHECK(!map.reset(1, -5.f, 1u).ok());
|
|
CHECK(!map.reset(1, std::numeric_limits<float>::quiet_NaN(), 1u).ok());
|
|
CHECK(map.empty());
|
|
CHECK_EQ(map.cellCount(), 0);
|
|
CHECK_EQ(map.takeDirtyCell(), kNoHexSphereCell);
|
|
|
|
CHECK(map.reset(1, 100.f, 1u).ok());
|
|
CHECK(!map.empty());
|
|
CHECK_EQ(map.cellCount(), 42);
|
|
}
|
|
|
|
TEST_CASE("hexmap.sphereMap.isDeterministicForTheSameShape") {
|
|
const HexSphereMap first = makeMap(2, 100.f, 9u);
|
|
const HexSphereMap second = makeMap(2, 100.f, 9u);
|
|
|
|
CHECK_EQ(first.cellCount(), second.cellCount());
|
|
CHECK_EQ(first.elevationStep(), second.elevationStep());
|
|
for (HexSphereCell cell = 0; cell < first.cellCount(); ++cell) {
|
|
CHECK(sameDirection(first.direction(cell), second.direction(cell)));
|
|
CHECK_EQ(first.neighborCount(cell), second.neighborCount(cell));
|
|
for (std::int32_t d = 0; d < first.neighborCount(cell); ++d) {
|
|
CHECK_EQ(first.neighbor(cell, d), second.neighbor(cell, d));
|
|
CHECK(sameDirection(first.cornerDirection(cell, d), second.cornerDirection(cell, d)));
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST_CASE("hexmap.sphereMap.invalidCellQueriesAreTotal") {
|
|
const HexSphereMap map = makeMap(1);
|
|
|
|
CHECK(!map.contains(-1));
|
|
CHECK(!map.contains(map.cellCount()));
|
|
CHECK_EQ(map.cellAt(-1), nullptr);
|
|
CHECK_EQ(map.cellAt(map.cellCount()), nullptr);
|
|
CHECK_EQ(map.neighborCount(-1), 0);
|
|
CHECK_EQ(map.elevation(-1), 0);
|
|
CHECK_EQ(map.waterLevel(-1), 0);
|
|
CHECK(!map.hasRiver(-1));
|
|
CHECK(!map.hasRoad(-1));
|
|
CHECK(!map.hasRiverThrough(-1, 0));
|
|
CHECK(!map.isUnderwater(-1));
|
|
CHECK_EQ(map.edgeTypeTo(-1, 0), HexEdgeType::Flat);
|
|
CHECK_EQ(map.surfaceRadius(-1), map.sphereRadius());
|
|
|
|
const HexSphereMap empty;
|
|
CHECK_EQ(empty.surfaceRadius(0), 0.f);
|
|
}
|
|
|
|
// --- spherical mesh ---------------------------------------------------------
|
|
|
|
namespace {
|
|
|
|
/** @brief Exact vector equality; a shared topological corner must be bit-identical. */
|
|
[[nodiscard]] bool identical(HexVec3 a, HexVec3 b) { return a.x == b.x && a.y == b.y && a.z == b.z; }
|
|
|
|
/** @brief Quantised position key used to weld the deliberately unshared vertex stream. */
|
|
struct WeldKey {
|
|
int x = 0;
|
|
int y = 0;
|
|
int z = 0;
|
|
[[nodiscard]] bool operator<(const WeldKey& other) const {
|
|
if (x != other.x) return x < other.x;
|
|
if (y != other.y) return y < other.y;
|
|
return z < other.z;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* @brief Welds a position to a 1e-3 grid.
|
|
*
|
|
* The mesher never shares a vertex between triangles, so two triangles only "meet" if
|
|
* their positions are equal - and shared corners are emitted as an exactly equal pure
|
|
* function of the topological position, so a coarse quantum is enough to weld them
|
|
* while staying far below the distance between two distinct corners.
|
|
*/
|
|
[[nodiscard]] WeldKey weldKey(HexVec3 p) {
|
|
constexpr double kQuantum = 1000.0;
|
|
return WeldKey{static_cast<int>(std::llround(static_cast<double>(p.x) * kQuantum)),
|
|
static_cast<int>(std::llround(static_cast<double>(p.y) * kQuantum)),
|
|
static_cast<int>(std::llround(static_cast<double>(p.z) * kQuantum))};
|
|
}
|
|
|
|
/** @brief Edge-use census of a welded mesh. */
|
|
struct WeldReport {
|
|
std::size_t vertices = 0;
|
|
std::size_t edges = 0;
|
|
std::size_t boundaryEdges = 0;
|
|
std::size_t nonManifold = 0;
|
|
std::size_t triangles = 0;
|
|
};
|
|
|
|
/**
|
|
* @brief Welds the mesh by position and counts how many triangles meet at every edge.
|
|
*
|
|
* A closed triangulated sphere has no boundary: every welded edge is shared by exactly
|
|
* two triangles. A count of one is a crack and a count above two is a duplicated or
|
|
* overlapping sheet. Both are invisible to a triangle count, and both are exactly what
|
|
* a wrong winding, a wrong edge-span or a wrong corner index produces.
|
|
*/
|
|
[[nodiscard]] WeldReport analyseWeld(const HexMeshData& mesh) {
|
|
WeldReport report;
|
|
report.triangles = mesh.triangleCount();
|
|
|
|
const auto& positions = mesh.positions();
|
|
const std::size_t count = positions.size() / 3u;
|
|
std::map<WeldKey, std::uint32_t> welded;
|
|
std::vector<std::uint32_t> ids;
|
|
ids.reserve(count);
|
|
for (std::size_t i = 0; i < count; ++i) {
|
|
const HexVec3 position{positions[i * 3u], positions[i * 3u + 1u], positions[i * 3u + 2u]};
|
|
const auto inserted = welded.emplace(weldKey(position), static_cast<std::uint32_t>(welded.size()));
|
|
ids.push_back(inserted.first->second);
|
|
}
|
|
|
|
std::map<std::pair<std::uint32_t, std::uint32_t>, std::int32_t> edgeUse;
|
|
const auto& indices = mesh.indices();
|
|
for (std::size_t t = 0; t + 2u < indices.size(); t += 3u) {
|
|
const std::uint32_t triangle[3] = {ids[indices[t]], ids[indices[t + 1u]], ids[indices[t + 2u]]};
|
|
for (std::int32_t e = 0; e < 3; ++e) {
|
|
std::uint32_t a = triangle[e];
|
|
std::uint32_t b = triangle[(e + 1) % 3];
|
|
if (a > b) std::swap(a, b);
|
|
++edgeUse[std::make_pair(a, b)];
|
|
}
|
|
}
|
|
|
|
report.edges = edgeUse.size();
|
|
report.vertices = welded.size();
|
|
for (const auto& entry : edgeUse) {
|
|
if (entry.second == 1) ++report.boundaryEdges;
|
|
else if (entry.second > 2) ++report.nonManifold;
|
|
}
|
|
return report;
|
|
}
|
|
|
|
/**
|
|
* @brief Asserts the welded surface is closed, manifold and of genus zero.
|
|
*
|
|
* `V - E + F = 2` is what turns "no boundary edges were found" into "this really is a
|
|
* closed sphere": a weld that silently merged or missed vertices would still show no
|
|
* boundary edges, but it could not also satisfy the Euler characteristic.
|
|
*/
|
|
void checkClosedSphere(const WeldReport& report) {
|
|
CHECK(report.triangles > 0u);
|
|
CHECK(report.vertices > 0u);
|
|
CHECK(report.edges > 0u);
|
|
CHECK_EQ(report.boundaryEdges, static_cast<std::size_t>(0));
|
|
CHECK_EQ(report.nonManifold, static_cast<std::size_t>(0));
|
|
CHECK_EQ(report.vertices + report.triangles - report.edges, static_cast<std::size_t>(2));
|
|
}
|
|
|
|
/** @brief Number of triangles whose stored normal points back towards the sphere centre. */
|
|
[[nodiscard]] std::size_t countInwardFacingTriangles(const HexMeshData& mesh) {
|
|
const auto& positions = mesh.positions();
|
|
const auto& normals = mesh.normals();
|
|
const auto& indices = mesh.indices();
|
|
|
|
std::size_t inward = 0;
|
|
for (std::size_t t = 0; t + 2u < indices.size(); t += 3u) {
|
|
const std::size_t normalBase = static_cast<std::size_t>(indices[t]) * 3u;
|
|
const HexVec3 normal{normals[normalBase], normals[normalBase + 1u], normals[normalBase + 2u]};
|
|
|
|
HexVec3 centroid{};
|
|
for (std::int32_t k = 0; k < 3; ++k) {
|
|
const std::size_t base = static_cast<std::size_t>(indices[t + static_cast<std::size_t>(k)]) * 3u;
|
|
centroid.x += positions[base];
|
|
centroid.y += positions[base + 1u];
|
|
centroid.z += positions[base + 2u];
|
|
}
|
|
centroid = centroid * (1.f / 3.f);
|
|
|
|
if (normal.x * centroid.x + normal.y * centroid.y + normal.z * centroid.z <= 0.f) ++inward;
|
|
}
|
|
return inward;
|
|
}
|
|
|
|
/** @brief Raises a scattered subset of cells so that slopes and cliffs both occur. */
|
|
void scatterElevation(HexSphereMap& map) {
|
|
for (HexSphereCell cell = 0; cell < map.cellCount(); ++cell) {
|
|
const std::int32_t level = (cell % 5 == 0) ? 3 : ((cell % 7 == 0) ? 1 : 0);
|
|
REQUIRE(map.setElevation(cell, level).ok());
|
|
}
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("hexmap.sphereMesh.isAWatertightSurface") {
|
|
// Flat: only fans and blend strips are exercised.
|
|
{
|
|
HexSphereMap map = makeMap(1, 100.f, 4u);
|
|
HexMeshData mesh;
|
|
buildSphereTerrainMesh(map, mesh);
|
|
REQUIRE(!mesh.empty());
|
|
|
|
const WeldReport report = analyseWeld(mesh);
|
|
checkClosedSphere(report);
|
|
}
|
|
|
|
// Scattered elevations: terraces and radial cliff walls are exercised as well.
|
|
{
|
|
HexSphereMap map = makeMap(1, 100.f, 4u);
|
|
scatterElevation(map);
|
|
|
|
HexMeshData mesh;
|
|
buildSphereTerrainMesh(map, mesh);
|
|
checkClosedSphere(analyseWeld(mesh));
|
|
}
|
|
|
|
// A finer level, to catch a crack that only appears when a pentagon meets hexagons.
|
|
{
|
|
HexSphereMap map = makeMap(2, 100.f, 4u);
|
|
scatterElevation(map);
|
|
|
|
HexMeshData mesh;
|
|
buildSphereTerrainMesh(map, mesh);
|
|
const WeldReport report = analyseWeld(mesh);
|
|
checkClosedSphere(report);
|
|
// Guard against a vacuously small mesh passing the census.
|
|
CHECK(report.triangles > 1000u);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("hexmap.sphereMesh.facesPointAwayFromTheCentre") {
|
|
HexSphereMap map = makeMap(2, 100.f, 6u);
|
|
scatterElevation(map);
|
|
|
|
HexMeshData mesh;
|
|
buildSphereTerrainMesh(map, mesh);
|
|
REQUIRE(!mesh.empty());
|
|
REQUIRE(mesh.hasNormals());
|
|
|
|
// A clockwise fan or a reversed strip would light the planet from inside out.
|
|
CHECK_EQ(countInwardFacingTriangles(mesh), static_cast<std::size_t>(0));
|
|
}
|
|
|
|
TEST_CASE("hexmap.sphereMesh.verticesStayOnTheirSurfaceRadius") {
|
|
HexSphereMap map = makeMap(1, 100.f, 2u);
|
|
CHECK(map.setElevation(0, HexMetrics::kMaxElevation).ok());
|
|
CHECK(map.setElevation(1, HexMetrics::kMinElevation).ok());
|
|
CHECK(map.setElevation(2, HexMetrics::kMaxElevation).ok());
|
|
|
|
HexMeshData mesh;
|
|
buildSphereTerrainMesh(map, mesh);
|
|
REQUIRE(!mesh.empty());
|
|
|
|
const float step = map.elevationStep();
|
|
const float lowest = map.sphereRadius() + static_cast<float>(HexMetrics::kMinElevation) * step;
|
|
const float highest = map.sphereRadius() + static_cast<float>(HexMetrics::kMaxElevation) * step;
|
|
const float deepest = lowest + (HexMetrics::kStreamBedElevationOffset / HexMetrics::kElevationStep) * step;
|
|
|
|
// The perturbation slides *along* the surface, so it must not move a vertex in or
|
|
// out; every radius has to stay inside the relief the elevations can produce.
|
|
const auto& positions = mesh.positions();
|
|
for (std::size_t i = 0; i + 2u < positions.size(); i += 3u) {
|
|
const HexVec3 point{positions[i], positions[i + 1u], positions[i + 2u]};
|
|
const float radius = std::sqrt(point.x * point.x + point.y * point.y + point.z * point.z);
|
|
CHECK(radius >= deepest - 1e-2f);
|
|
CHECK(radius <= highest + 1e-2f);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("hexmap.sphereMesh.isDeterministicAndReusable") {
|
|
const HexSphereMap map = makeMap(1, 100.f, 8u);
|
|
|
|
HexMeshData first;
|
|
HexMeshData second;
|
|
buildSphereTerrainMesh(map, first);
|
|
buildSphereTerrainMesh(map, second);
|
|
|
|
REQUIRE(!first.empty());
|
|
CHECK_EQ(first.vertexCount(), second.vertexCount());
|
|
CHECK_EQ(first.triangleCount(), second.triangleCount());
|
|
CHECK(first.positions() == second.positions());
|
|
CHECK(first.normals() == second.normals());
|
|
CHECK(first.uvs() == second.uvs());
|
|
CHECK(first.indices() == second.indices());
|
|
|
|
// Rebuilding into a mesh that already holds data must replace it, not append.
|
|
HexMeshData reused;
|
|
buildSphereTerrainMesh(map, reused);
|
|
buildSphereTerrainMesh(map, reused);
|
|
CHECK_EQ(reused.vertexCount(), first.vertexCount());
|
|
CHECK_EQ(reused.triangleCount(), first.triangleCount());
|
|
}
|
|
|
|
TEST_CASE("hexmap.sphereMesh.emptyMapProducesAnEmptyMesh") {
|
|
const HexSphereMap map;
|
|
HexMeshData mesh;
|
|
buildSphereTerrainMesh(map, mesh);
|
|
|
|
CHECK(mesh.empty());
|
|
CHECK_EQ(mesh.vertexCount(), static_cast<std::size_t>(0));
|
|
CHECK_EQ(mesh.triangleCount(), static_cast<std::size_t>(0));
|
|
}
|
|
|
|
TEST_CASE("hexmap.sphere.cornerIndicesAgreeAcrossTheThreeCellsAtACorner") {
|
|
// The mesher reaches a shared corner from three different cells by index arithmetic
|
|
// alone; this is that arithmetic, checked against the topology's own corner table.
|
|
for (std::int32_t subdivision = 0; subdivision <= 3; ++subdivision) {
|
|
const HexSphereTopology topology = makeTopology(subdivision);
|
|
for (HexSphereCell cell = 0; cell < topology.cellCount(); ++cell) {
|
|
const std::int32_t edges = topology.neighborCount(cell);
|
|
for (std::int32_t d = 0; d < edges; ++d) {
|
|
const std::int32_t nextD = (d + 1) % edges;
|
|
const HexSphereCell left = topology.neighbor(cell, d);
|
|
const HexSphereCell right = topology.neighbor(cell, nextD);
|
|
if (left == kNoHexSphereCell || right == kNoHexSphereCell) continue;
|
|
|
|
const std::int32_t back = topology.oppositeDirection(cell, d);
|
|
const std::int32_t back2 = topology.oppositeDirection(cell, nextD);
|
|
REQUIRE(back >= 0);
|
|
REQUIRE(back2 >= 0);
|
|
|
|
const HexVec3 shared = topology.corner(cell, nextD);
|
|
CHECK(identical(topology.corner(left, back), shared));
|
|
CHECK(identical(topology.corner(right, (back2 + 1) % topology.neighborCount(right)), shared));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- spherical generator ----------------------------------------------------
|
|
|
|
namespace {
|
|
|
|
/** @brief Generates a planet at `landPercentage` and returns it. */
|
|
[[nodiscard]] HexSphereMap generatePlanet(std::int32_t landPercentage) {
|
|
HexSphereMap map = makeMap(2, 100.f, 12u);
|
|
HexSphereGeneratorSettings settings{};
|
|
settings.seed = 1234u;
|
|
settings.landPercentage = landPercentage;
|
|
settings.waterLevel = 0;
|
|
auto generated = generateSphereMap(map, settings);
|
|
REQUIRE(generated.ok());
|
|
return map;
|
|
}
|
|
|
|
/** @brief Cells the generator left above their own water line. */
|
|
[[nodiscard]] std::int32_t landCells(const HexSphereMap& map) {
|
|
std::int32_t land = 0;
|
|
for (HexSphereCell cell = 0; cell < map.cellCount(); ++cell) {
|
|
if (map.elevation(cell) > map.waterLevel(cell)) ++land;
|
|
}
|
|
return land;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("hexmap.sphereGenerator.matchesItsLandTargetAndIsDeterministic") {
|
|
const HexSphereMap first = generatePlanet(45);
|
|
const HexSphereMap second = generatePlanet(45);
|
|
|
|
for (HexSphereCell cell = 0; cell < first.cellCount(); ++cell) {
|
|
CHECK_EQ(first.elevation(cell), second.elevation(cell));
|
|
CHECK_EQ(first.waterLevel(cell), second.waterLevel(cell));
|
|
CHECK_EQ(first.terrainType(cell), second.terrainType(cell));
|
|
}
|
|
|
|
for (HexSphereCell cell = 0; cell < first.cellCount(); ++cell) {
|
|
CHECK(first.elevation(cell) >= HexMetrics::kMinElevation);
|
|
CHECK(first.elevation(cell) <= HexMetrics::kMaxElevation);
|
|
const std::int32_t terrain = first.terrainType(cell);
|
|
CHECK(terrain >= 0);
|
|
CHECK(terrain < kHexTerrainTypeCount);
|
|
}
|
|
|
|
// The sea level is picked from the noise's own distribution, so the share of land
|
|
// has to follow the requested percentage rather than wherever the field fell.
|
|
const std::int32_t expected = first.cellCount() * 45 / 100;
|
|
const std::int32_t actual = landCells(first);
|
|
CHECK(actual > expected - first.cellCount() / 10);
|
|
CHECK(actual < expected + first.cellCount() / 10);
|
|
}
|
|
|
|
TEST_CASE("hexmap.sphereGenerator.landShareFollowsTheSetting") {
|
|
CHECK(landCells(generatePlanet(25)) < landCells(generatePlanet(70)));
|
|
}
|
|
|
|
TEST_CASE("hexmap.sphereGenerator.rejectsAnEmptyMap") {
|
|
HexSphereMap map;
|
|
HexSphereGeneratorSettings settings{};
|
|
auto generated = generateSphereMap(map, settings);
|
|
|
|
CHECK(!generated.ok());
|
|
CHECK(generated.code() == eve::StatusCode::Rejected);
|
|
}
|