#include "CaveFixtures.h" TEST_CASE("procgen.mesh.cave.biogenicCorrosionErasesFluvialScallopsButFastFilmsProtectThem") { CaveBiogenicCorrosionInput dry; dry.along = 0.37f; dry.angle = 1.2f; dry.distance = 0.17f; dry.radius = 0.16f; dry.hydraulicExposure = 0.45f; dry.strength = 0.9f; dry.seed = 20260830u; const auto first = sampleCaveBiogenicCorrosion(dry); const auto repeat = sampleCaveBiogenicCorrosion(dry); CHECK_EQ(first.erosion, repeat.erosion); CHECK_EQ(first.fluvialScallopRetention, repeat.fluvialScallopRetention); CHECK(first.erosion > 0.f); CHECK(first.fluvialScallopRetention < 0.35f); CaveBiogenicCorrosionInput wet = dry; wet.hydraulicExposure = 1.45f; const auto protectedSurface = sampleCaveBiogenicCorrosion(wet); CHECK(protectedSurface.erosion < first.erosion); CHECK(protectedSurface.fluvialScallopRetention > first.fluvialScallopRetention); dry.strength = 0.f; const auto disabled = sampleCaveBiogenicCorrosion(dry); CHECK_EQ(disabled.erosion, 0.f); CHECK_EQ(disabled.fluvialScallopRetention, 1.f); } TEST_CASE("procgen.mesh.cave.biogenicCorrosionIsOptInDistinctAndObservable") { MeshRecipeRegistry::instance().registerBuiltins(); Params params; params.setSeed(20260830); params.setInt("resolution", 24); params.setInt("chambers", 4); params.setInt("branches", 2); params.setFloat("erosion", 0.f); params.setFloat("scallopErosion", 0.f); MeshBuild baseline, first, repeat; std::string error; REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, baseline, error)); params.setFloat("biogenicCorrosion", 0.9f); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, first, error)); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, repeat, error)); CHECK(first.positions() == repeat.positions()); CHECK(first.indices() == repeat.indices()); CHECK(first.positions() != baseline.positions()); CHECK_EQ(first.getMeta("biogenicCorrosionModel", ""), std::string("ammonia-nitrification-aero-speleogen-v1")); CHECK(std::stoi(first.getMeta("biogenicAffectedVoxels", "0")) > 0); CHECK(std::stof(first.getMeta("minimumFluvialScallopRetention", "1")) < 1.f); CHECK(std::stof(first.getMeta("maximumBiogenicErosion", "0")) > 0.f); params.setFloat("biogenicCorrosion", 1.1f); MeshBuild invalid; CHECK(!MeshRecipeRegistry::instance().generate("mesh.cave", params, invalid, error)); } TEST_CASE("procgen.mesh.cave.condensationCorrosionIsShallowCeilingBiasedAndDeterministic") { constexpr int n = 17; auto index = [](int x, int y, int z) { return size_t(x + y * n + z * n * n); }; std::vector density(size_t(n * n * n)); std::vector exposure(density.size(), 0.35f); for (int z = 0; z < n; ++z) for (int y = 0; y < n; ++y) for (int x = 0; x < n; ++x) { const float px = float(x) / float(n - 1) * 2.f - 1.f; const float py = float(y) / float(n - 1) * 2.f - 1.f; const float pz = float(z) / float(n - 1) * 2.f - 1.f; density[index(x, y, z)] = std::sqrt(px * px + py * py + pz * pz) - 0.62f; } const std::vector original = density; std::vector repeated = density; std::vector disabled = density; const auto disabledResult = erodeCaveByCondensation(disabled, exposure, n, n, n, 0.f, 20260830u); CHECK(disabled == original); CHECK_EQ(disabledResult.affectedVoxels, 0); const auto result = erodeCaveByCondensation(density, exposure, n, n, n, 1.f, 20260830u); const auto repeatedResult = erodeCaveByCondensation(repeated, exposure, n, n, n, 1.f, 20260830u); CHECK(density == repeated); CHECK_EQ(result.affectedVoxels, repeatedResult.affectedVoxels); CHECK(result.affectedVoxels > 0); CHECK(result.maximumRetreat > 0.f); CHECK(result.maximumRetreat <= 0.032001f); int upperChanged = 0, lowerChanged = 0; for (int z = 1; z < n - 1; ++z) for (int y = 1; y < n - 1; ++y) for (int x = 1; x < n - 1; ++x) if (density[index(x, y, z)] != original[index(x, y, z)]) { if (y > n / 2) ++upperChanged; if (y < n / 2) ++lowerChanged; } CHECK(upperChanged > lowerChanged * 4); } TEST_CASE("procgen.mesh.cave.condensationCorrosionIsObservableAndOptIn") { MeshRecipeRegistry::instance().registerBuiltins(); Params params; params.setSeed(20260830); params.setInt("resolution", 24); params.setInt("chambers", 4); params.setInt("branches", 2); params.setFloat("condensationCorrosion", 0.9f); MeshBuild first, second; std::string error; REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, first, error)); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, second, error)); CHECK(first.positions() == second.positions()); CHECK_EQ(first.getMeta("condensationCorrosionModel", ""), std::string("cool-wall-co2-film-pitting-v1")); CHECK(std::stoi(first.getMeta("condensationAffectedVoxels", "0")) > 0); CHECK(std::stof(first.getMeta("maximumCondensationRetreat", "0")) > 0.f); params.setFloat("condensationCorrosion", 1.1f); MeshBuild invalid; CHECK(!MeshRecipeRegistry::instance().generate("mesh.cave", params, invalid, error)); } TEST_CASE("procgen.mesh.cave.stylesAreDeterministicAndDistinct") { MeshRecipeRegistry::instance().registerBuiltins(); REQUIRE(MeshRecipeRegistry::instance().has("mesh.cave")); const char* styles[] = {"cavern", "tunnels", "vertical", "labyrinth", "mixed"}; int previousVertices = -1; for (const char* style : styles) { Params params; params.setSeed(20260830); params.setString("style", style); params.setInt("resolution", 28); params.setInt("chambers", 5); params.setInt("branches", 3); MeshBuild first, second; std::string error; REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, first, error)); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, second, error)); CHECK(first.getVertexCount() > 100); CHECK_EQ(first.getIndexCount() % 3, 0); CHECK(first.positions() == second.positions()); CHECK(first.indices() == second.indices()); CHECK_EQ(first.getMeta("style", ""), std::string(style)); CHECK_EQ(first.getMeta("determinism", ""), std::string("bit-exact-cpu")); if (previousVertices >= 0) CHECK(first.getVertexCount() != previousVertices); previousVertices = first.getVertexCount(); } } TEST_CASE("procgen.mesh.cave.parametersControlBoundsAndTopology") { MeshRecipeRegistry::instance().registerBuiltins(); Params params; params.setSeed(77); params.setInt("resolution", 24); params.setFloat("width", 42.f); params.setFloat("height", 14.f); params.setFloat("depth", 26.f); params.setInt("chambers", 4); params.setInt("branches", 1); MeshBuild sparse, branched; std::string error; REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, sparse, error)); params.setInt("branches", 8); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, branched, error)); REQUIRE(sparse.positions() != branched.positions()); REQUIRE_EQ(branched.getMeta("branches", ""), std::string("8")); float minX = std::numeric_limits::max(); float maxX = std::numeric_limits::lowest(); float minY = std::numeric_limits::max(); float maxY = std::numeric_limits::lowest(); for (int vertex = 0; vertex < branched.getVertexCount(); ++vertex) { minX = std::min(minX, branched.getPositionX(vertex)); maxX = std::max(maxX, branched.getPositionX(vertex)); minY = std::min(minY, branched.getPositionY(vertex)); maxY = std::max(maxY, branched.getPositionY(vertex)); REQUIRE(std::fabs(branched.getPositionX(vertex)) <= 21.01f); REQUIRE(std::fabs(branched.getPositionY(vertex)) <= 7.01f); REQUIRE(std::fabs(branched.getPositionZ(vertex)) <= 13.01f); const float nx = branched.getNormalX(vertex); const float ny = branched.getNormalY(vertex); const float nz = branched.getNormalZ(vertex); REQUIRE(std::fabs(std::sqrt(nx * nx + ny * ny + nz * nz) - 1.f) < 1e-3f); } REQUIRE(minX < -8.f); REQUIRE(maxX > 8.f); REQUIRE(maxX - minX > 24.f); REQUIRE(minY < -1.f); REQUIRE(maxY > 1.f); } TEST_CASE("procgen.mesh.cave.rejectsInvalidInputs") { MeshRecipeRegistry::instance().registerBuiltins(); Params params; params.setString("style", "lava-tube-ish"); MeshBuild mesh; std::string error; CHECK(!MeshRecipeRegistry::instance().generate("mesh.cave", params, mesh, error)); CHECK(error.find("unknown style") != std::string::npos); params.setString("style", "mixed"); params.setInt("resolution", 4); CHECK(!MeshRecipeRegistry::instance().generate("mesh.cave", params, mesh, error)); CHECK(error.find("resolution") != std::string::npos); params.setInt("resolution", 24); params.setInt("surfaceRefinement", 3); CHECK(!MeshRecipeRegistry::instance().generate("mesh.cave", params, mesh, error)); CHECK(error.find("invalid") != std::string::npos); params.setInt("surfaceRefinement", 0); params.setInt("isosurfaceSampling", 3); CHECK(!MeshRecipeRegistry::instance().generate("mesh.cave", params, mesh, error)); CHECK(error.find("invalid") != std::string::npos); params.setInt("isosurfaceSampling", 1); params.setString("surfaceNormalMode", "invented"); CHECK(!MeshRecipeRegistry::instance().generate("mesh.cave", params, mesh, error)); CHECK(error.find("surfaceNormalMode") != std::string::npos); params.setString("surfaceNormalMode", "faceAverage"); params.setInt("wetnessRefinement", 2); CHECK(!MeshRecipeRegistry::instance().generate("mesh.cave", params, mesh, error)); CHECK(error.find("invalid") != std::string::npos); params.setInt("wetnessRefinement", 0); params.setFloat("boundaryClosure", 1.1f); CHECK(!MeshRecipeRegistry::instance().generate("mesh.cave", params, mesh, error)); CHECK(error.find("invalid") != std::string::npos); params.setFloat("boundaryClosure", 0.f); params.setFloat("permeabilityContrast", 1.1f); CHECK(!MeshRecipeRegistry::instance().generate("mesh.cave", params, mesh, error)); CHECK(error.find("invalid") != std::string::npos); params.setFloat("permeabilityContrast", 0.65f); params.setFloat("bendUndercut", 1.1f); CHECK(!MeshRecipeRegistry::instance().generate("mesh.cave", params, mesh, error)); CHECK(error.find("invalid") != std::string::npos); params.setFloat("bendUndercut", 0.f); params.setFloat("scallopMaturity", 1.1f); CHECK(!MeshRecipeRegistry::instance().generate("mesh.cave", params, mesh, error)); CHECK(error.find("invalid") != std::string::npos); params.setFloat("scallopMaturity", 0.f); params.setFloat("fragmentDetachment", 1.1f); CHECK(!MeshRecipeRegistry::instance().generate("mesh.cave", params, mesh, error)); CHECK(error.find("invalid") != std::string::npos); params.setFloat("fragmentDetachment", 0.f); params.setFloat("curvatureDissolution", 1.1f); CHECK(!MeshRecipeRegistry::instance().generate("mesh.cave", params, mesh, error)); CHECK(error.find("invalid") != std::string::npos); params.setFloat("curvatureDissolution", 0.f); params.setFloat("reactiveSurfaceCoupling", 1.1f); CHECK(!MeshRecipeRegistry::instance().generate("mesh.cave", params, mesh, error)); CHECK(error.find("invalid") != std::string::npos); params.setFloat("reactiveSurfaceCoupling", 0.f); params.setString("genesis", "volcanic"); CHECK(!MeshRecipeRegistry::instance().generate("mesh.cave", params, mesh, error)); CHECK(error.find("unknown genesis") != std::string::npos); } TEST_CASE("procgen.mesh.cave.microstructureControlsDistributedAndLocalizedDissolution") { MeshRecipeRegistry::instance().registerBuiltins(); Params params; params.setSeed(20260319); params.setString("style", "tunnels"); params.setInt("resolution", 24); params.setInt("dripstones", 0); params.setInt("flowstones", 0); params.setInt("curtains", 0); params.setFloat("erosion", 0.95f); params.setFloat("hydraulicErosion", 0.9f); params.setFloat("microstructure", 0.f); MeshBuild baseline, disabledWithDifferentRock, distributed, localized, repeated; std::string error; REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, baseline, error)); params.setFloat("microporosityAccess", 0.f); params.setFloat("permeabilityContrast", 1.f); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, disabledWithDifferentRock, error)); REQUIRE(baseline.positions() == disabledWithDifferentRock.positions()); REQUIRE(baseline.indices() == disabledWithDifferentRock.indices()); params.setFloat("microstructure", 1.f); params.setFloat("microporosityAccess", 1.f); params.setFloat("permeabilityContrast", 0.2f); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, distributed, error)); params.setFloat("microporosityAccess", 0.f); params.setFloat("permeabilityContrast", 0.95f); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, localized, error)); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, repeated, error)); REQUIRE(distributed.positions() != baseline.positions()); REQUIRE(localized.positions() != baseline.positions()); REQUIRE(distributed.positions() != localized.positions()); REQUIRE(localized.positions() == repeated.positions()); REQUIRE(localized.indices() == repeated.indices()); REQUIRE_EQ(localized.getMeta("erosionModel", ""), std::string("karst-reactive-microstructure-v6")); REQUIRE_EQ(localized.getMeta("microstructureModel", ""), std::string("dual-scale-accessibility-permeability-v1")); REQUIRE_EQ(distributed.getMeta("microporosityAccess", ""), std::string("1.000000")); REQUIRE_EQ(localized.getMeta("permeabilityContrast", ""), std::string("0.950000")); } TEST_CASE("procgen.mesh.cave.hypogeneRisingFlowIsDistinctConnectedAndDeterministic") { MeshRecipeRegistry::instance().registerBuiltins(); Params params; params.setSeed(5322505); params.setString("style", "labyrinth"); params.setInt("resolution", 24); params.setInt("dripstones", 0); params.setInt("flowstones", 0); params.setInt("curtains", 0); params.setString("genesis", "epigene"); MeshBuild epigene, hypogene, repeated, hypogeneWithVadose; std::string error; REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, epigene, error)); params.setString("genesis", "hypogene"); params.setInt("cupolas", 9); params.setInt("feeders", 5); params.setFloat("vadoseIncision", 0.f); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, hypogene, error)); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, repeated, error)); params.setFloat("vadoseIncision", 1.f); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, hypogeneWithVadose, error)); REQUIRE(epigene.positions() != hypogene.positions()); REQUIRE(hypogene.positions() == repeated.positions()); REQUIRE(hypogene.indices() == repeated.indices()); REQUIRE(hypogene.positions() == hypogeneWithVadose.positions()); REQUIRE_EQ(epigene.getMeta("cupolas", ""), std::string("0")); REQUIRE_EQ(hypogene.getMeta("cupolas", ""), std::string("9")); REQUIRE_EQ(hypogene.getMeta("feeders", ""), std::string("5")); REQUIRE_EQ(hypogene.getMeta("risingFlowModel", ""), std::string("feeder-half-tube-cupola-v1")); } TEST_CASE("procgen.mesh.cave.karstErosionIsDeterministicAndObservable") { MeshRecipeRegistry::instance().registerBuiltins(); Params params; params.setSeed(41029); params.setInt("resolution", 24); params.setInt("chambers", 5); params.setInt("branches", 4); params.setFloat("erosion", 0.f); MeshBuild uneroded, eroded, repeated; std::string error; REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, uneroded, error)); params.setFloat("erosion", 0.85f); params.setFloat("bedding", 0.9f); params.setFloat("fractureDissolution", 0.8f); params.setFloat("vadoseIncision", 0.7f); params.setInt("fractureCount", 8); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, eroded, error)); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, repeated, error)); REQUIRE(uneroded.positions() != eroded.positions()); REQUIRE(eroded.positions() == repeated.positions()); REQUIRE(eroded.indices() == repeated.indices()); REQUIRE_EQ(eroded.getMeta("erosionModel", ""), std::string("karst-fracture-bedding-vadose-scallop-v2")); REQUIRE_EQ(eroded.getMeta("determinism", ""), std::string("bit-exact-cpu")); } TEST_CASE("procgen.mesh.cave.hydraulicFlowFocusesReactiveErosion") { MeshRecipeRegistry::instance().registerBuiltins(); Params params; params.setSeed(20250831); params.setString("style", "tunnels"); params.setInt("resolution", 24); params.setInt("dripstones", 0); params.setInt("flowstones", 0); params.setInt("curtains", 0); params.setFloat("erosion", 0.9f); params.setFloat("hydraulicErosion", 0.f); MeshBuild legacy, legacyDifferentHydrology, lowFlow, highFlow, repeated, unfocused; MeshBuild uniformDissolution, wormholeDissolution; std::string error; REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, legacy, error)); params.setFloat("hydraulicGradient", 1.8f); params.setFloat("recharge", 0.1f); params.setFloat("damkohler", 0.04f); params.setFloat("transportG", 4.5f); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, legacyDifferentHydrology, error)); REQUIRE(legacy.positions() == legacyDifferentHydrology.positions()); REQUIRE(legacy.indices() == legacyDifferentHydrology.indices()); params.setFloat("hydraulicErosion", 1.f); params.setFloat("hydraulicGradient", 0.08f); params.setFloat("recharge", 0.2f); params.setFloat("flowFocusing", 0.85f); params.setFloat("damkohler", 0.002f); params.setFloat("transportG", 1.f); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, lowFlow, error)); params.setFloat("hydraulicGradient", 1.2f); params.setFloat("recharge", 0.9f); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, highFlow, error)); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, repeated, error)); params.setFloat("flowFocusing", 0.f); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, unfocused, error)); params.setFloat("flowFocusing", 0.85f); params.setFloat("damkohler", 0.0002f); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, uniformDissolution, error)); params.setFloat("damkohler", 0.02f); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, wormholeDissolution, error)); REQUIRE(lowFlow.positions() != highFlow.positions()); REQUIRE(unfocused.positions() != highFlow.positions()); REQUIRE(uniformDissolution.positions() != highFlow.positions()); REQUIRE(wormholeDissolution.positions() != highFlow.positions()); REQUIRE(uniformDissolution.positions() != wormholeDissolution.positions()); REQUIRE(highFlow.positions() == repeated.positions()); REQUIRE(highFlow.indices() == repeated.indices()); REQUIRE_EQ(highFlow.getMeta("erosionModel", ""), std::string("karst-reactive-network-da-v5")); REQUIRE_EQ(highFlow.getMeta("hydraulicNetwork", ""), std::string("tributary-confluence-feedback-v1")); REQUIRE_EQ(highFlow.getMeta("hydraulicConfluences", ""), std::string("4")); REQUIRE(std::stof(highFlow.getMeta("minimumFlowWeight", "1")) < std::stof(highFlow.getMeta("maximumFlowWeight", "1"))); REQUIRE_EQ(highFlow.getMeta("hydraulicGradient", ""), std::string("1.200000")); REQUIRE_EQ(highFlow.getMeta("recharge", ""), std::string("0.900000")); REQUIRE_EQ(highFlow.getMeta("dissolutionRegime", ""), std::string("channeling")); REQUIRE_EQ(uniformDissolution.getMeta("dissolutionRegime", ""), std::string("uniform")); REQUIRE_EQ(wormholeDissolution.getMeta("dissolutionRegime", ""), std::string("wormholing")); REQUIRE(std::stof(uniformDissolution.getMeta("reactantPenetration", "0")) > std::stof(highFlow.getMeta("reactantPenetration", "0"))); REQUIRE(std::stof(highFlow.getMeta("reactantPenetration", "0")) > std::stof(wormholeDissolution.getMeta("reactantPenetration", "0"))); } TEST_CASE("procgen.mesh.cave.flowScallopsAreDirectionalDeterministicErosion") { MeshRecipeRegistry::instance().registerBuiltins(); Params params; params.setSeed(20250033); params.setString("style", "tunnels"); params.setInt("resolution", 24); params.setInt("dripstones", 0); params.setInt("flowstones", 0); params.setInt("curtains", 0); params.setFloat("roughness", 0.f); params.setFloat("bedding", 0.f); params.setFloat("fractureDissolution", 0.f); params.setFloat("erosion", 0.9f); params.setFloat("scallopErosion", 0.f); MeshBuild smoothWall, scalloped, repeated, scalingWithoutFlow, fixedHydraulic, scaledHydraulic, scaledRepeated; std::string error; REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, smoothWall, error)); params.setFloat("scallopErosion", 1.f); params.setFloat("scallopScale", 0.10f); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, scalloped, error)); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, repeated, error)); params.setFloat("scallopHydraulicScaling", 1.f); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, scalingWithoutFlow, error)); params.setFloat("hydraulicErosion", 1.f); params.setFloat("hydraulicGradient", 1.1f); params.setFloat("recharge", 0.9f); params.setFloat("flowFocusing", 0.9f); params.setFloat("scallopHydraulicScaling", 0.f); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, fixedHydraulic, error)); params.setFloat("scallopHydraulicScaling", 1.f); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, scaledHydraulic, error)); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, scaledRepeated, error)); REQUIRE(smoothWall.positions() != scalloped.positions()); REQUIRE(scalloped.positions() == repeated.positions()); REQUIRE(scalloped.indices() == repeated.indices()); REQUIRE(scalloped.positions() == scalingWithoutFlow.positions()); REQUIRE(fixedHydraulic.positions() != scaledHydraulic.positions()); REQUIRE(scaledHydraulic.positions() == scaledRepeated.positions()); REQUIRE(scaledHydraulic.indices() == scaledRepeated.indices()); REQUIRE_EQ(scalloped.getMeta("scallopScale", ""), std::string("0.100000")); REQUIRE_EQ(scalloped.getMeta("erosionModel", ""), std::string("karst-fracture-bedding-vadose-scallop-v2")); REQUIRE_EQ(scaledHydraulic.getMeta("erosionModel", ""), std::string("karst-hydraulic-scallop-v7")); REQUIRE(std::stof(scaledHydraulic.getMeta("minimumScallopScale", "1")) < std::stof(scaledHydraulic.getMeta("maximumScallopScale", "0"))); } TEST_CASE("procgen.mesh.cave.scallopScalePatchesAndFlowSeparationAreDeterministicOptIns") { CaveScallopInput input; input.along = 0.037f; input.angle = 0.42f; input.distance = 0.17f; input.radius = 0.16f; input.hydraulicIntensity = 0.9f; input.baseScale = 0.1f; input.maturity = 0.65f; input.seed = 20250033u; const CaveScallopSample legacy = sampleCaveScallops(input); CHECK_EQ(legacy.scaleMultiplier, 1.f); input.scaleVariability = 0.8f; const CaveScallopSample varied = sampleCaveScallops(input); CHECK_EQ(varied.scaleMultiplier, sampleCaveScallops(input).scaleMultiplier); CHECK(varied.scaleMultiplier != 1.f); CHECK(varied.scale != legacy.scale); input.flowSeparation = 0.85f; const CaveScallopSample separated = sampleCaveScallops(input); CHECK_EQ(separated.erosion, sampleCaveScallops(input).erosion); CHECK(separated.erosion != varied.erosion); } TEST_CASE("procgen.mesh.cave.scallopVariabilityAndSeparationAreObservableAndDefaultCompatible") { MeshRecipeRegistry::instance().registerBuiltins(); Params params; params.setSeed(20250033); params.setString("style", "tunnels"); params.setInt("resolution", 24); params.setInt("dripstones", 0); params.setInt("flowstones", 0); params.setInt("curtains", 0); params.setFloat("roughness", 0.f); params.setFloat("erosion", 1.f); params.setFloat("scallopErosion", 1.f); MeshBuild legacy, explicitLegacy, varied, repeated, separated; std::string error; REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, legacy, error)); params.setFloat("scallopScaleVariability", 0.f); params.setFloat("scallopFlowSeparation", 0.f); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, explicitLegacy, error)); REQUIRE(legacy.positions() == explicitLegacy.positions()); params.setFloat("scallopScaleVariability", 0.8f); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, varied, error)); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, repeated, error)); REQUIRE(varied.positions() != legacy.positions()); REQUIRE(varied.positions() == repeated.positions()); REQUIRE_EQ(varied.getMeta("scallopScaleDistribution", ""), std::string("correlated-lognormal-proxy-v1")); REQUIRE(std::stof(varied.getMeta("scallopGeometricStdDev", "1")) > 1.f); params.setFloat("scallopFlowSeparation", 0.8f); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, separated, error)); REQUIRE(separated.positions() != varied.positions()); REQUIRE_EQ(separated.getMeta("scallopFlowProfile", ""), std::string("slope-separated-travelling-wave-v2")); params.setFloat("scallopScaleVariability", 1.1f); MeshBuild invalid; REQUIRE(!MeshRecipeRegistry::instance().generate("mesh.cave", params, invalid, error)); } TEST_CASE("procgen.mesh.cave.curvedPassagesUndercutOuterBanksDeterministically") { MeshRecipeRegistry::instance().registerBuiltins(); Params params; params.setSeed(20250481); params.setString("style", "labyrinth"); params.setInt("resolution", 24); params.setInt("branches", 0); params.setInt("dripstones", 0); params.setInt("flowstones", 0); params.setInt("curtains", 0); params.setFloat("roughness", 0.f); params.setFloat("bedding", 0.f); params.setFloat("fractureDissolution", 0.f); params.setFloat("erosion", 1.f); params.setFloat("scallopErosion", 1.f); params.setFloat("bendUndercut", 0.f); MeshBuild symmetric, undercut, repeated; std::string error; REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, symmetric, error)); params.setFloat("bendUndercut", 1.f); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, undercut, error)); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, repeated, error)); REQUIRE(symmetric.positions() != undercut.positions()); REQUIRE(undercut.positions() == repeated.positions()); REQUIRE(undercut.indices() == repeated.indices()); REQUIRE_EQ(undercut.getMeta("erosionModel", ""), std::string("karst-reactive-curvature-v8")); REQUIRE_EQ(undercut.getMeta("bendErosionModel", ""), std::string("curvature-outer-bank-v1")); REQUIRE_EQ(undercut.getMeta("bendUndercut", ""), std::string("1.000000")); } TEST_CASE("procgen.mesh.cave.matureScallopsCoarsenIntoSharpCellularRidges") { MeshRecipeRegistry::instance().registerBuiltins(); Params params; params.setSeed(37988469); params.setString("style", "tunnels"); params.setInt("resolution", 24); params.setInt("dripstones", 0); params.setInt("flowstones", 0); params.setInt("curtains", 0); params.setFloat("roughness", 0.f); params.setFloat("bedding", 0.f); params.setFloat("fractureDissolution", 0.f); params.setFloat("erosion", 1.f); params.setFloat("scallopErosion", 1.f); params.setFloat("scallopScale", 0.09f); params.setFloat("scallopMaturity", 0.f); MeshBuild young, mature, repeated; std::string error; REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, young, error)); params.setFloat("scallopMaturity", 0.8f); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, mature, error)); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, repeated, error)); REQUIRE(young.positions() != mature.positions()); REQUIRE(mature.positions() == repeated.positions()); REQUIRE(mature.indices() == repeated.indices()); REQUIRE_EQ(mature.getMeta("erosionModel", ""), std::string("karst-reactive-coarsening-v9")); REQUIRE_EQ(mature.getMeta("scallopEvolutionModel", ""), std::string("normal-ablation-coarsening-v1")); REQUIRE_EQ(mature.getMeta("scallopMaturity", ""), std::string("0.800000")); } TEST_CASE("procgen.mesh.cave.fragmentDetachmentRemovesOnlyHostDisconnectedRock") { constexpr int size = 7; std::vector density(size_t(size * size * size), -1.f); auto index = [](int x, int y, int z) { return size_t(x + y * size + z * size * size); }; density[index(0, 2, 2)] = 1.f; density[index(1, 2, 2)] = 1.f; density[index(2, 2, 2)] = 1.f; density[index(3, 3, 3)] = 1.f; density[index(5, 5, 5)] = 1.f; const std::vector original = density; const CaveDetachmentResult disabled = detachUnsupportedCaveFragments(density, size, size, size, 0.f); REQUIRE(density == original); REQUIRE_EQ(disabled.unsupportedVoxels, 0); const CaveDetachmentResult detached = detachUnsupportedCaveFragments(density, size, size, size, 1.f); REQUIRE(density[index(0, 2, 2)] > 0.f); REQUIRE(density[index(1, 2, 2)] > 0.f); REQUIRE(density[index(2, 2, 2)] > 0.f); REQUIRE(density[index(3, 3, 3)] > 0.f); // Diagonal neighbors remain host-connected. REQUIRE(density[index(5, 5, 5)] < 0.f); REQUIRE_EQ(detached.unsupportedVoxels, 1); REQUIRE_EQ(detached.detachedVoxels, 1); } TEST_CASE("procgen.mesh.cave.curvatureEvolutionRetreatsConvexWallsDeterministically") { constexpr int size = 17; auto index = [](int x, int y, int z) { return size_t(x + y * size + z * size * size); }; std::vector density(size_t(size * size * size)); for (int z = 0; z < size; ++z) { for (int y = 0; y < size; ++y) { for (int x = 0; x < size; ++x) { const float px = float(x - size / 2) * 2.f / float(size - 1); const float py = float(y - size / 2) * 2.f / float(size - 1); const float pz = float(z - size / 2) * 2.f / float(size - 1); density[index(x, y, z)] = std::sqrt(px * px + py * py + pz * pz) - 0.45f; } } } const std::vector original = density; std::vector repeated = density; const auto disabled = evolveCaveSurfaceByCurvature(density, size, size, size, 0.f); CHECK(density == original); CHECK_EQ(disabled.affectedVoxels, 0); const auto evolved = evolveCaveSurfaceByCurvature(density, size, size, size, 0.8f); const auto repeatedResult = evolveCaveSurfaceByCurvature(repeated, size, size, size, 0.8f); CHECK(density == repeated); CHECK_EQ(evolved.affectedVoxels, repeatedResult.affectedVoxels); CHECK(evolved.affectedVoxels > 0); CHECK(evolved.maximumRetreat > 0.f); CHECK(density[index(size / 2 + 4, size / 2, size / 2)] < original[index(size / 2 + 4, size / 2, size / 2)]); CHECK(density[index(0, 0, 0)] == original[index(0, 0, 0)]); } TEST_CASE("procgen.mesh.cave.depositsAnchorToFinalDensitySurface") { constexpr int size = 33; std::vector density(size_t(size * size * size)); for (int z = 0; z < size; ++z) { for (int y = 0; y < size; ++y) { for (int x = 0; x < size; ++x) { const float px = float(x) / float(size - 1) * 2.f - 1.f; const float py = float(y) / float(size - 1) * 2.f - 1.f; const float pz = float(z) / float(size - 1) * 2.f - 1.f; density[size_t(x + y * size + z * size * size)] = std::sqrt(px * px + py * py + pz * pz) - 0.65f; } } } const auto span = findCaveVerticalSpan(density, size, size, size, 0.f, 0.f, 0.1f); REQUIRE(span.has_value()); CHECK(std::abs(span->floor.position.y + 0.65f) < 0.01f); CHECK(std::abs(span->ceiling.position.y - 0.65f) < 0.01f); CHECK(span->floor.rockNormal.y < -0.9f); CHECK(span->ceiling.rockNormal.y > 0.9f); const auto wall = projectToFinalCaveSurface(density, size, size, size, {0.58f, 0.1f, 0.f}, 0.2f); REQUIRE(wall.has_value()); CHECK(std::abs(sampleCaveDensity(density, size, size, size, wall->position)) < 0.01f); CHECK(wall->rockNormal.x > 0.9f); CHECK(!findCaveVerticalSpan(density, size, size, size, 0.9f, 0.9f, 0.f).has_value()); CHECK(!projectToFinalCaveSurface(density, size, size, size, {0.f, 0.f, 0.f}, 0.2f).has_value()); } TEST_CASE("procgen.mesh.cave.curvatureDissolutionIsObservableBeforeDeposition") { MeshRecipeRegistry::instance().registerBuiltins(); Params params; params.setSeed(20260830); params.setString("style", "mixed"); params.setInt("resolution", 24); params.setInt("dripstones", 12); params.setInt("flowstones", 7); params.setInt("curtains", 5); params.setFloat("curvatureDissolution", 0.f); MeshBuild baseline, evolved, repeated; std::string error; REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, baseline, error)); params.setFloat("curvatureDissolution", 0.8f); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, evolved, error)); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, repeated, error)); REQUIRE(evolved.positions() == repeated.positions()); REQUIRE(evolved.indices() == repeated.indices()); REQUIRE(baseline.positions() != evolved.positions()); REQUIRE_EQ(evolved.getMeta("surfaceEvolutionModel", ""), std::string("convex-normal-retreat-v1")); REQUIRE(std::stoi(evolved.getMeta("curvatureAffectedVoxels", "0")) > 0); REQUIRE(std::stof(evolved.getMeta("maximumCurvatureRetreat", "0")) > 0.f); } TEST_CASE("procgen.mesh.cave.reactiveAccessibilityModulatesCurvatureRetreat") { constexpr int size = 17; auto index = [](int x, int y, int z) { return size_t(x + y * size + z * size * size); }; std::vector density(size_t(size * size * size)); std::vector rates(density.size(), 0.35f); for (int z = 0; z < size; ++z) { for (int y = 0; y < size; ++y) { for (int x = 0; x < size; ++x) { const float px = float(x - size / 2) * 2.f / float(size - 1); const float py = float(y - size / 2) * 2.f / float(size - 1); const float pz = float(z - size / 2) * 2.f / float(size - 1); density[index(x, y, z)] = std::sqrt(px * px + py * py + pz * pz) - 0.45f; if (x > size / 2) rates[index(x, y, z)] = 2.1f; } } } const std::vector original = density; const auto result = evolveCaveSurfaceByCurvature(density, rates, size, size, size, 0.8f); const float fastRetreat = original[index(size / 2 + 4, size / 2, size / 2)] - density[index(size / 2 + 4, size / 2, size / 2)]; const float slowRetreat = original[index(size / 2 - 4, size / 2, size / 2)] - density[index(size / 2 - 4, size / 2, size / 2)]; CHECK(fastRetreat > slowRetreat * 3.f); CHECK(result.minimumRateMultiplier < 0.5f); CHECK(result.maximumRateMultiplier > 2.f); CHECK(result.totalRetreat > result.maximumRetreat); } TEST_CASE("procgen.mesh.cave.surfaceRateCouplingChangesSameHydrologyDeterministically") { MeshRecipeRegistry::instance().registerBuiltins(); Params params; params.setSeed(20251120); params.setString("style", "tunnels"); params.setInt("resolution", 24); params.setInt("dripstones", 0); params.setInt("flowstones", 0); params.setInt("curtains", 0); params.setFloat("curvatureDissolution", 0.85f); params.setFloat("microstructure", 0.9f); params.setFloat("hydraulicErosion", 0.85f); params.setFloat("reactiveSurfaceCoupling", 0.f); MeshBuild uniform, coupled, repeated; std::string error; REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, uniform, error)); params.setFloat("reactiveSurfaceCoupling", 1.f); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, coupled, error)); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, repeated, error)); REQUIRE(uniform.positions() != coupled.positions()); REQUIRE(coupled.positions() == repeated.positions()); REQUIRE(coupled.indices() == repeated.indices()); REQUIRE_EQ(coupled.getMeta("surfaceRateModel", ""), std::string("microstructure-hydraulic-access-v1")); REQUIRE(std::stof(coupled.getMeta("minimumSurfaceRate", "1")) < 1.f); REQUIRE(std::stof(coupled.getMeta("maximumSurfaceRate", "1")) > 1.f); } TEST_CASE("procgen.mesh.cave.detachmentIsObservableAndDeterministic") { MeshRecipeRegistry::instance().registerBuiltins(); Params params; params.setSeed(7); // This fixture produces a mesh-visible effect. params.setString("style", "mixed"); params.setInt("resolution", 36); params.setInt("dripstones", 18); params.setInt("flowstones", 8); params.setInt("curtains", 6); params.setFloat("erosion", 0.9f); params.setFloat("fragmentDetachment", 0.f); MeshBuild retained, detached, repeated; std::string error; REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, retained, error)); params.setFloat("fragmentDetachment", 1.f); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, detached, error)); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, repeated, error)); REQUIRE(detached.positions() == repeated.positions()); REQUIRE(detached.indices() == repeated.indices()); REQUIRE_EQ(detached.getMeta("detachmentModel", ""), std::string("host-rock-connectivity-v1")); REQUIRE(std::stoi(detached.getMeta("detachedVoxels", "0")) > 0); REQUIRE_EQ(std::stoi(detached.getMeta("unsupportedVoxels", "0")), std::stoi(detached.getMeta("detachedVoxels", "0"))); REQUIRE(retained.positions() != detached.positions()); } TEST_CASE("procgen.mesh.cave.surfaceRefinementProjectsDeterministicDetail") { MeshRecipeRegistry::instance().registerBuiltins(); Params params; params.setSeed(7741397); params.setString("style", "tunnels"); params.setInt("resolution", 24); params.setInt("dripstones", 0); params.setInt("flowstones", 0); params.setInt("curtains", 0); params.setFloat("scallopErosion", 0.9f); params.setFloat("scallopScale", 0.10f); params.setInt("surfaceRefinement", 0); MeshBuild coarse, refined, adaptive, repeated; std::string error; REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, coarse, error)); params.setInt("surfaceRefinement", 1); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, refined, error)); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, repeated, error)); REQUIRE_EQ(refined.getIndexCount(), coarse.getIndexCount() * 4); REQUIRE_EQ(refined.getVertexCount(), coarse.getVertexCount() * 4); REQUIRE(refined.positions() == repeated.positions()); REQUIRE(refined.indices() == repeated.indices()); REQUIRE(refined.triangleGroups() == repeated.triangleGroups()); REQUIRE_EQ(refined.getMeta("surfaceProjection", ""), std::string("trilinear-newton-v1")); REQUIRE_EQ(refined.getMeta("surfaceRefinement", ""), std::string("1")); params.setInt("surfaceRefinement", 2); params.setFloat("refinementThreshold", 0.0015f); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, adaptive, error)); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.cave", params, repeated, error)); REQUIRE(adaptive.getIndexCount() > coarse.getIndexCount()); REQUIRE(adaptive.getIndexCount() < refined.getIndexCount()); REQUIRE(adaptive.positions() == repeated.positions()); REQUIRE(adaptive.triangleGroups() == repeated.triangleGroups()); REQUIRE(std::stoi(adaptive.getMeta("adaptiveSplitEdges", "0")) > 0); REQUIRE(std::stoi(adaptive.getMeta("refinedSourceTriangles", "0")) > 0); REQUIRE_EQ(adaptive.getMeta("refinementTriangulation", ""), std::string("conforming-edge-mask-v2")); } TEST_CASE("procgen.mesh.cave.trilinearSupersamplingPreservesSourceSamples") { constexpr int size = 5; std::vector density(size_t(size * size * size)); auto index = [](int x, int y, int z, int width, int height) { return size_t(x) + size_t(y) * size_t(width) + size_t(z) * size_t(width) * size_t(height); }; for (int z = 0; z < size; ++z) for (int y = 0; y < size; ++y) for (int x = 0; x < size; ++x) density[index(x, y, z, size, size)] = float(x * x + 3 * y - 2 * z) - 8.5f; const CaveResampledField doubled = resampleCaveDensity(density, size, size, size, 2); CHECK_EQ(doubled.nx, 9); CHECK_EQ(doubled.ny, 9); CHECK_EQ(doubled.nz, 9); for (int z = 0; z < size; ++z) for (int y = 0; y < size; ++y) for (int x = 0; x < size; ++x) CHECK_EQ(doubled.density[index(x * 2, y * 2, z * 2, doubled.nx, doubled.ny)], density[index(x, y, z, size, size)]); CHECK_EQ(doubled.density[index(1, 0, 0, doubled.nx, doubled.ny)], 0.5f * (density[index(0, 0, 0, size, size)] + density[index(1, 0, 0, size, size)])); }