Tunabelly

This commit is contained in:
Brian Neumann-Fopiano committed 2026-09-22 17:04:56 -04:00
1 parent 197d2c2461
commit 30c533feaf
114 files changed
+4071 -3994

No files matched your search

@@ -210,7 +210,7 @@ public final class IrisWorldGeneratorResolver {
packDirs = validation.packDirs(); packDirs = validation.packDirs();
contentSnapshot = validation.contentSnapshot(); contentSnapshot = validation.contentSnapshot();
results = validation.results(); results = validation.results();
if (validation.stable()) { if (validation.stable() && contextFingerprint.equals(PackValidationCache.contextFingerprint())) {
try { try {
PackValidationCache.save( PackValidationCache.save(
cacheFile, cacheFile,
@@ -229,7 +229,7 @@ public final class IrisWorldGeneratorResolver {
String packFingerprint = packFingerprints.get(result.getPackName()); String packFingerprint = packFingerprints.get(result.getPackName());
File packDirectory = PackDirectoryResolver.resolveExisting(packsRoot, result.getPackName()); File packDirectory = PackDirectoryResolver.resolveExisting(packsRoot, result.getPackName());
if (packDirectory != null && packFingerprint != null && !packFingerprint.isBlank()) { if (packDirectory != null && packFingerprint != null && !packFingerprint.isBlank()) {
PackValidationRegistry.publish(packDirectory.toPath(), result, packFingerprint); PackValidationRegistry.publish(packDirectory.toPath(), result, packFingerprint, contextFingerprint);
} }
String minecraftVersion = IrisPlatforms.isBound() ? IrisPlatforms.get().minecraftVersion() : null; String minecraftVersion = IrisPlatforms.isBound() ? IrisPlatforms.get().minecraftVersion() : null;
String compatSummary = PackValidator.compatSummary(result, minecraftVersion); String compatSummary = PackValidator.compatSummary(result, minecraftVersion);
@@ -6,6 +6,7 @@ import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.pack.BrokenPackException; import art.arcane.iris.pack.BrokenPackException;
import art.arcane.iris.pack.PackValidationCache;
import art.arcane.iris.pack.PackValidationRegistry; import art.arcane.iris.pack.PackValidationRegistry;
import art.arcane.iris.pack.PackValidationResult; import art.arcane.iris.pack.PackValidationResult;
import art.arcane.iris.pack.PackValidator; import art.arcane.iris.pack.PackValidator;
@@ -184,8 +185,9 @@ final class ModdedGenerationHistoryStorage {
} }
private static void validatePack(Path packRoot, String fingerprint) { private static void validatePack(Path packRoot, String fingerprint) {
String contextFingerprint = PackValidationCache.contextFingerprint();
PackValidationResult validation = PackValidator.validate(packRoot.toFile()); PackValidationResult validation = PackValidator.validate(packRoot.toFile());
PackValidationRegistry.publish(packRoot, validation, fingerprint); PackValidationRegistry.publish(packRoot, validation, fingerprint, contextFingerprint);
if (!validation.isLoadable()) { if (!validation.isLoadable()) {
throw new BrokenPackException(packRoot.toString(), validation.getBlockingErrors()); throw new BrokenPackException(packRoot.toString(), validation.getBlockingErrors());
} }
@@ -266,6 +266,7 @@ public final class ModdedWorldEngines {
TransitionGenerationPlan transitionPlan = activation.isInitial() TransitionGenerationPlan transitionPlan = activation.isInitial()
? null ? null
: history.transitionPlan(activation.activationId()); : history.transitionPlan(activation.activationId());
data.bindGenerationRegistryContract(epoch.registryContract());
IrisEngine engine = new IrisEngine( IrisEngine engine = new IrisEngine(
new EngineTarget(world, dimension, data), new EngineTarget(world, dimension, data),
IrisEngine.InitializationMode.RUNTIME, IrisEngine.InitializationMode.RUNTIME,
@@ -11,7 +11,8 @@ record FootprintLayerShape(
boolean archedChannel, boolean archedChannel,
boolean roundedSurfaceBed, boolean roundedSurfaceBed,
boolean organicBoundary, boolean organicBoundary,
boolean fallingThroat boolean fallingThroat,
boolean inlet
) { ) {
double totalRadius() { double totalRadius() {
return channelRadius + shoreWidth + gradingWidth; return channelRadius + shoreWidth + gradingWidth;
@@ -159,7 +159,7 @@ final class FootprintMutableColumn {
first.connectedFluid() || second.connectedFluid(), first.connectedFluid() || second.connectedFluid(),
first.fallingFluid() || second.fallingFluid(), first.fallingFluid() || second.fallingFluid(),
first.receivingPool() || second.receivingPool(), first.receivingPool() || second.receivingPool(),
(first.terrainOwned() || second.terrainOwned()) && (!drySurfaceBlend || bedY < naturalHeight), (first.terrainOwned() || second.terrainOwned()) && (!drySurfaceBlend || bedY != naturalHeight),
first.fluidOwned() || second.fluidOwned(), first.fluidOwned() || second.fluidOwned(),
first.oceanApron() || second.oceanApron(), first.oceanApron() || second.oceanApron(),
first.profileKey(), first.profileKey(),
@@ -46,7 +46,7 @@ final class HydrologyCacheBudget {
} }
long routingBytes() { long routingBytes() {
return regionalEnabled ? plannerBytes / 8L : plannerBytes * 3L / 16L; return plannerBytes * 3L / 16L;
} }
long ownerBytes() { long ownerBytes() {
@@ -69,7 +69,4 @@ final class HydrologyCacheBudget {
return regionalEnabled ? plannerBytes / 4L : 0L; return regionalEnabled ? plannerBytes / 4L : 0L;
} }
long regionalReachBytes() {
return regionalEnabled ? plannerBytes / 16L : 0L;
}
} }
@@ -69,7 +69,7 @@ final class HydrologyCacheWeights {
static int regionalCoarse(HydrologyRegionalCoarsePlan plan) { static int regionalCoarse(HydrologyRegionalCoarsePlan plan) {
HydrologyRegionalGraph.Tree tree = plan.tree(); HydrologyRegionalGraph.Tree tree = plan.tree();
long treeBytes = tree == null ? 0L : 96L + (long) tree.selected().length * 8L long treeBytes = tree == null ? 0L : 96L + (long) tree.selected().length * 8L
+ (long) tree.accumulation().length * 4L + tree.retainedLabelCount() * 96L; + ((long) tree.accumulation().length + tree.heads().length) * 4L + tree.retainedLabelCount() * 96L;
return saturated(ENTRY_BYTES + grid(plan.grid()) + treeBytes + (long) plan.outlets().size() * 224L return saturated(ENTRY_BYTES + grid(plan.grid()) + treeBytes + (long) plan.outlets().size() * 224L
+ (long) plan.sources().size() * 32L + (long) plan.envelopes().size() * 64L); + (long) plan.sources().size() * 32L + (long) plan.envelopes().size() * 64L);
} }
@@ -79,7 +79,7 @@ final class HydrologyCacheWeights {
} }
private static long grid(HydrologySampledGrid grid) { private static long grid(HydrologySampledGrid grid) {
return grid == null ? 0L : 96L + (long) grid.nodes().size() * (64L + TERRAIN_BYTES); return grid == null ? 0L : 96L + (long) grid.nodes().size() * (64L + TERRAIN_BYTES + 104L);
} }
private static long routingPlan(HydrologyRoutingPlan plan) { private static long routingPlan(HydrologyRoutingPlan plan) {
@@ -88,7 +88,8 @@ final class HydrologyCacheWeights {
} }
return 128L + (long) plan.potential().length * 8L return 128L + (long) plan.potential().length * 8L
+ ((long) plan.parent().length + plan.outletIndex().length + plan.routeLengths().length) * 4L + ((long) plan.parent().length + plan.outletIndex().length + plan.routeLengths().length) * 4L
+ (long) plan.outlets().size() * 224L; + (long) plan.outlets().size() * 224L
+ (plan.surfaceDrainage() == null ? 0L : plan.surfaceDrainage().maximumRetainedBytes());
} }
private static long network(List<DrainageNode> nodes, List<DrainageEdge> edges, List<RiverOutlet> outlets, private static long network(List<DrainageNode> nodes, List<DrainageEdge> edges, List<RiverOutlet> outlets,
@@ -1194,8 +1194,12 @@ final class HydrologyCaveCourseFilter {
HydraulicSegment segment = segments.get(index); HydraulicSegment segment = segments.get(index);
if (segment.type().isSurface() && segment.fallingFluid()) { if (segment.type().isSurface() && segment.fallingFluid()) {
int radius = Math.max(1, segment.width() / 2) + 1; int radius = Math.max(1, segment.width() / 2) + 1;
HydraulicSegment receiving = index + 1 < segments.size() ? segments.get(index + 1) : null;
boolean coastal = receiving != null && receiving.type() == HydrologyFeatureType.MOUTH
&& receiving.upstreamHeadY() == segment.downstreamHeadY()
&& receiving.start().equals(segment.end());
openings.add(CaveSurfaceOpening.create(segment, true, (long) radius * radius, openings.add(CaveSurfaceOpening.create(segment, true, (long) radius * radius,
segment.downstreamHeadY() + 2, true)); segment.downstreamHeadY() + (coastal ? 0 : 2), true));
continue; continue;
} }
if (!segment.type().isUnderground()) { if (!segment.type().isUnderground()) {
@@ -1,9 +1,12 @@
package art.arcane.iris.generation.hydrology; package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.surface.SurfaceNoise; import art.arcane.iris.generation.hydrology.surface.SurfaceNoise;
import art.arcane.iris.generation.hydrology.surface.SurfaceInletRange;
final class HydrologyChannelGeometry { final class HydrologyChannelGeometry {
private final HydrologyFootprintCompiler compiler; private final HydrologyFootprintCompiler compiler;
private RiverCourse inletCourse;
private SurfaceInletRange inletRange;
HydrologyChannelGeometry(HydrologyFootprintCompiler compiler) { HydrologyChannelGeometry(HydrologyFootprintCompiler compiler) {
this.compiler = compiler; this.compiler = compiler;
@@ -141,10 +144,24 @@ final class HydrologyChannelGeometry {
archedChannel, archedChannel,
roundedSurfaceBed, roundedSurfaceBed,
organicBoundary, organicBoundary,
falling falling,
inletStation(course, segment, point)
); );
} }
private boolean inletStation(RiverCourse course, HydraulicSegment segment, HydrologyPoint point) {
if (course.type() != RiverCourseType.SURFACE || !segment.type().isSurface() || !segment.fallingFluid()) {
return false;
}
if (inletCourse != course) {
HydrologyTerrainSampler receiving = HydrologyOceanReceiver.forCourse(
compiler.settings, compiler::sampleTerrainBasis, course);
inletRange = SurfaceInletRange.forCourse(compiler.settings, course, receiving);
inletCourse = course;
}
return inletRange.allowsFallingStation(segment.id(), point.x(), point.z());
}
int organicVerticalVariation( int organicVerticalVariation(
long seed, long seed,
int x, int x,
@@ -0,0 +1,280 @@
package art.arcane.iris.generation.hydrology;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.atomic.AtomicReferenceArray;
final class HydrologyDrainageGeometry {
private final HydrologyPlanner planner;
private final Basin basin;
private final int[] heads;
private final AtomicReferenceArray<HydrologyPoint> anchors;
private final AtomicReferenceArray<CompletableFuture<List<HydrologyPoint>>> edges;
HydrologyDrainageGeometry(HydrologyPlanner planner, Basin basin) {
this.planner = planner;
this.basin = basin;
this.heads = new int[basin.grid().nodes().size()];
this.anchors = new AtomicReferenceArray<>(heads.length);
this.edges = new AtomicReferenceArray<>(heads.length);
Arrays.fill(heads, Integer.MIN_VALUE);
planHeads();
}
int head(int index) {
return heads[index];
}
HydrologyPoint anchor(int index) {
HydrologyPoint existing = anchors.get(index);
if (existing != null) {
return existing;
}
HydrologyGridNode node = basin.grid().node(index);
HydrologyPoint point;
if (basin.parent()[index] < 0 && basin.outletIndex()[index] >= 0) {
HydrologyPoint landward = basin.outlets().get(basin.outletIndex()[index]).outlet().landwardPoint();
point = new HydrologyPoint(landward.x(), heads[index], landward.z());
} else {
point = displacedAnchor(node);
}
anchors.compareAndSet(index, null, point);
return anchors.get(index);
}
List<HydrologyPoint> edge(int index) {
DraftProfile profile = planner.currentDraftProfile();
profile.routeCalls++;
CompletableFuture<List<HydrologyPoint>> future = edges.get(index);
if (future == null) {
CompletableFuture<List<HydrologyPoint>> owned = new CompletableFuture<>();
CompletableFuture<List<HydrologyPoint>> existing = edges.compareAndExchange(index, null, owned);
if (existing == null) {
long started = System.nanoTime();
profile.routeSolves++;
try {
List<HydrologyPoint> points = compile(index);
owned.complete(points);
return points;
} catch (Throwable failure) {
owned.completeExceptionally(failure);
edges.compareAndSet(index, owned, null);
throw HydrologyPlanner.propagateOwnerFailure(failure);
} finally {
profile.routeSolveNanos += System.nanoTime() - started;
}
}
future = existing;
}
try {
return future.join();
} catch (CompletionException failure) {
throw HydrologyPlanner.propagateOwnerFailure(failure.getCause());
}
}
long maximumRetainedBytes() {
int stations = Math.ceilDiv(basin.grid().spacing() * 3, planner.settings.routing().refinementSpacing()) + 2;
return (long) heads.length * (112L + stations * 40L);
}
private void planHeads() {
int[] path = new int[heads.length];
int[] nominal = new int[heads.length];
for (int index = 0; index < heads.length; index++) {
if (heads[index] != Integer.MIN_VALUE || basin.outletIndex()[index] < 0) {
continue;
}
int current = index;
int size = 0;
while (current >= 0 && heads[current] == Integer.MIN_VALUE) {
if (size == path.length) {
throw new IllegalStateException("Surface drainage must be acyclic.");
}
path[size++] = current;
current = basin.parent()[current];
}
int downstream = current < 0 ? Integer.MIN_VALUE : heads[current];
while (size > 0) {
int node = path[--size];
HydrologyTerrainSample terrain = basin.grid().node(node).terrain();
if (basin.parent()[node] < 0) {
HydrologyPoint landward = basin.outlets().get(basin.outletIndex()[node]).outlet().landwardPoint();
HydrologyTerrainSample sampled = planner.sampleLandBasisWithoutSlope(landward.x(), landward.z());
if (sampled != null) {
terrain = sampled;
}
}
int depth = (planner.settings.surface().minimumDepth() + planner.settings.surface().maximumDepth()) / 2;
nominal[node] = terrain.naturalHeight() - planner.settings.surface().banks().sink() - depth;
int required = terrain.naturalHeight() - planner.sourcePlanner.permittedSurfaceIncision(terrain) + depth;
heads[node] = Math.max(Math.max(planner.settings.seaLevel(), required), downstream);
downstream = heads[node];
}
}
int[] required = heads.clone();
int[] ceiling = new int[heads.length];
Arrays.fill(ceiling, Integer.MAX_VALUE);
int[] upstream = new int[heads.length];
for (int index = 0; index < heads.length; index++) {
if (heads[index] == Integer.MIN_VALUE) {
continue;
}
if (planner.sourcePlanner.rawSourceEligible(basin.grid().node(index).terrain(),
planner.settings.surface().sources(), true)) {
ceiling[index] = Math.max(required[index], nominal[index]);
}
int parent = basin.parent()[index];
if (parent >= 0) {
upstream[parent]++;
}
}
int end = 0;
for (int index = 0; index < heads.length; index++) {
if (heads[index] != Integer.MIN_VALUE && upstream[index] == 0) {
path[end++] = index;
}
}
for (int position = 0; position < end; position++) {
int node = path[position];
int parent = basin.parent()[node];
if (parent >= 0) {
ceiling[parent] = Math.min(ceiling[parent], ceiling[node]);
if (--upstream[parent] == 0) {
path[end++] = parent;
}
}
}
for (int position = end - 1; position >= 0; position--) {
int node = path[position];
int parent = basin.parent()[node];
int downstream = parent < 0 ? Integer.MIN_VALUE : heads[parent];
heads[node] = Math.max(Math.max(required[node], downstream), Math.min(nominal[node], ceiling[node]));
}
}
private HydrologyPoint displacedAnchor(HydrologyGridNode node) {
double ratio = Math.min(HydrologyRouteGeometry.ROUTE_ANCHOR_MAXIMUM_OFFSET_RATIO,
planner.settings.geometry().meanders().maximumOffsetRatio() * 0.625D);
double wavelength = planner.settings.geometry().meanders().primaryWavelength() * 5D;
int x = node.x() + (int) StrictMath.round(basin.grid().spacing() * ratio
* planner.routeAnchorX.noiseSigned(node.x() / wavelength, node.z() / wavelength));
int z = node.z() + (int) StrictMath.round(basin.grid().spacing() * ratio
* planner.routeAnchorZ.noiseSigned(node.x() / wavelength, node.z() / wavelength));
if (!permittedAnchor(node, x, z)) {
x = node.x();
z = node.z();
}
if (nearCoast(node)) {
int clearance = (int) StrictMath.ceil(planner.settings.surface().maximumWidth()
* planner.settings.surface().banks().springWidthRatio() / 2D) + 1;
if (!landClearance(x, z, clearance)) {
int radius = Math.max(1, (int) StrictMath.ceil(basin.grid().spacing()
* HydrologyRouteGeometry.ROUTE_ANCHOR_MAXIMUM_OFFSET_RATIO));
long nearest = Long.MAX_VALUE;
int selectedX = x;
int selectedZ = z;
for (HydrologyGridOffset offset : HydrologySourcePlanner.ROUTING_OFFSETS) {
int candidateX = node.x() + offset.x() * radius;
int candidateZ = node.z() + offset.z() * radius;
long dx = (long) candidateX - x;
long dz = (long) candidateZ - z;
long distance = dx * dx + dz * dz;
if (distance < nearest && permittedAnchor(node, candidateX, candidateZ)
&& landClearance(candidateX, candidateZ, clearance)) {
nearest = distance;
selectedX = candidateX;
selectedZ = candidateZ;
}
}
x = selectedX;
z = selectedZ;
}
}
return new HydrologyPoint(x, heads[node.index()], z);
}
private boolean permittedAnchor(HydrologyGridNode node, int x, int z) {
HydrologyTerrainSample terrain = planner.sampleLandBasisWithoutSlope(x, z);
return terrain != null && !terrain.ocean() && terrain.transitAllowed()
&& node.terrain().drainsInto(terrain) && terrain.drainsInto(node.terrain())
&& HydrologySurfaceProfiles.sharesProfile(node.terrain(), terrain)
&& node.terrain().surfacePolicy().areaKey().equals(terrain.surfacePolicy().areaKey());
}
private boolean nearCoast(HydrologyGridNode node) {
for (HydrologyGridOffset offset : HydrologySourcePlanner.ROUTING_OFFSETS) {
HydrologyGridNode neighbor = basin.grid().nodeAt(node.gridX() + offset.x(), node.gridZ() + offset.z());
if (neighbor != null && neighbor.terrain().ocean()) {
return true;
}
}
return false;
}
private boolean landClearance(int x, int z, int radius) {
for (HydrologyGridOffset offset : HydrologySourcePlanner.ROUTING_OFFSETS) {
HydrologyTerrainSample terrain = planner.sampleLandBasisWithoutSlope(x + offset.x() * radius,
z + offset.z() * radius);
if (terrain == null || terrain.ocean()) {
return false;
}
}
return true;
}
private List<HydrologyPoint> compile(int index) {
int downstream = basin.parent()[index];
if (downstream < 0) {
return List.of();
}
HydrologyPoint start = anchor(index);
HydrologyPoint end = anchor(downstream);
int continuation = basin.parent()[downstream];
HydrologyPoint next = continuation < 0 ? basin.outlets().get(basin.outletIndex()[index])
.outlet().connectionPoint() : anchor(continuation);
RouteDirection startTangent = planner.routePaths.direction(start.x(), start.z(), end.x(), end.z());
RouteDirection endTangent = planner.routePaths.direction(end.x(), end.z(), next.x(), next.z());
if (endTangent.x() == 0D && endTangent.z() == 0D) {
endTangent = startTangent;
}
double distance = StrictMath.hypot(end.x() - start.x(), end.z() - start.z());
int steps = Math.max(1, (int) StrictMath.ceil(distance / planner.settings.routing().refinementSpacing()));
ArrayList<HydrologyPoint> points = new ArrayList<>(steps + 1);
for (int step = 0; step <= steps; step++) {
double progress = step / (double) steps;
RoutePosition position = planner.routeGeometry.routePosition(start, end, startTangent, endTangent,
distance * HydrologyRouteGeometry.ROUTE_TANGENT_SCALE, distance, progress, 1D);
HydrologyPoint point = step == 0 ? start : step == steps ? end
: new HydrologyPoint((int) StrictMath.round(position.x()),
(int) StrictMath.round(start.y() + (end.y() - start.y()) * progress),
(int) StrictMath.round(position.z()));
if (points.isEmpty() || points.getLast().distanceSquared2D(point) > 0L) {
points.add(point);
}
}
return permitted(points) ? List.copyOf(points) : List.of();
}
private boolean permitted(List<HydrologyPoint> points) {
HydrologyTerrainSample previous = null;
for (int index = 1; index < points.size(); index++) {
for (HydrologyPoint point : planner.segments.rasterLine(points.get(index - 1), points.get(index))) {
HydrologyTerrainSample terrain = planner.sampleLandBasisWithoutSlope(point.x(), point.z());
if (terrain == null || terrain.ocean() || !terrain.transitAllowed()
|| previous != null && (!previous.drainsInto(terrain)
|| !HydrologySurfaceProfiles.sharesProfile(previous, terrain))) {
return false;
}
previous = terrain;
}
}
return points.size() >= 2;
}
record Basin(HydrologySampledGrid grid, int[] parent, int[] outletIndex, List<OutletCandidate> outlets) {
}
}
@@ -0,0 +1,50 @@
package art.arcane.iris.generation.hydrology;
final class HydrologyDrainageGraph {
private final HydrologyPlannerSettings settings;
private final int[] upstream;
private final double[] costs;
private final byte[] eligibility;
HydrologyDrainageGraph(HydrologySampledGrid grid, HydrologySourcePlanner planner) {
settings = planner.settings();
int directions = HydrologySourcePlanner.ROUTING_OFFSETS.size();
int size = Math.multiplyExact(grid.nodes().size(), directions);
upstream = new int[size];
costs = new double[size];
eligibility = new byte[size];
for (HydrologyGridNode downstream : grid.nodes()) {
int valleyFloor = planner.minimumNeighborHeight(grid, downstream);
for (int direction = 0; direction < directions; direction++) {
int edge = downstream.index() * directions + direction;
HydrologyGridOffset offset = HydrologySourcePlanner.ROUTING_OFFSETS.get(direction);
HydrologyGridNode source = grid.nodeAt(downstream.gridX() + offset.x(), downstream.gridZ() + offset.z());
upstream[edge] = source == null ? -1 : source.index();
if (source == null || source.terrain().ocean() || !source.terrain().transitAllowed()
|| !source.terrain().drainsInto(downstream.terrain())) {
continue;
}
eligibility[edge] = 1;
if (HydrologySurfaceProfiles.sharesProfile(source.terrain(), downstream.terrain())
&& downstream.terrain().naturalHeight() - source.terrain().naturalHeight()
<= planner.maximumSurfaceEdgeRise(downstream.terrain())) {
eligibility[edge] |= 2;
}
costs[edge] = planner.routeCost(source, downstream, offset, valleyFloor);
}
}
}
HydrologyPlannerSettings settings() {
return settings;
}
int upstream(int downstream, int direction, boolean surface) {
int edge = downstream * HydrologySourcePlanner.ROUTING_OFFSETS.size() + direction;
return (eligibility[edge] & (surface ? 2 : 1)) == 0 ? -1 : upstream[edge];
}
double cost(int downstream, int direction) {
return costs[downstream * HydrologySourcePlanner.ROUTING_OFFSETS.size() + direction];
}
}
@@ -565,6 +565,9 @@ final class HydrologyFootprintCompiler {
boolean surfaceDrop = segment.type().isSurface() && segment.fallingFluid(); boolean surfaceDrop = segment.type().isSurface() && segment.fallingFluid();
if (surfaceDrop && channel && !falling) { if (surfaceDrop && channel && !falling) {
int maximumIncision = terrain.surfacePolicy().maximumIncision(settings.surface().maximumIncision()); int maximumIncision = terrain.surfacePolicy().maximumIncision(settings.surface().maximumIncision());
if (shape.inlet()) {
maximumIncision = Math.max(maximumIncision, settings.surface().banks().inlet().maximumIncision());
}
int allowedIncision = Math.min(maximumIncision, int allowedIncision = Math.min(maximumIncision,
(int) StrictMath.floor(maximumIncision * terrain.incisionMultiplier())); (int) StrictMath.floor(maximumIncision * terrain.incisionMultiplier()));
bed = Math.max(bed, Math.min(shape.fluidHead(), terrain.naturalHeight() - allowedIncision)); bed = Math.max(bed, Math.min(shape.fluidHead(), terrain.naturalHeight() - allowedIncision));
@@ -17,7 +17,6 @@ import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -701,7 +700,6 @@ public final class HydrologyPlanner {
final HashMap<UndergroundSegmentCapKey, Integer> undergroundSegmentCaps; final HashMap<UndergroundSegmentCapKey, Integer> undergroundSegmentCaps;
final HashMap<Long, HydrologyPoint> routeAnchors; final HashMap<Long, HydrologyPoint> routeAnchors;
final HashMap<Long, HydrologyPoint> surfaceRouteAnchors; final HashMap<Long, HydrologyPoint> surfaceRouteAnchors;
final IdentityHashMap<HydrologySampledGrid, HashMap<SurfaceRouteKey, List<HydrologyPoint>>> surfaceRoutes;
final HydrologyCaveCourseFilter.CandidateCache caveCandidates; final HydrologyCaveCourseFilter.CandidateCache caveCandidates;
final HydrologyCaveContainmentPlanner.ValidationCache caveValidations; final HydrologyCaveContainmentPlanner.ValidationCache caveValidations;
@@ -723,7 +721,6 @@ public final class HydrologyPlanner {
this.undergroundSegmentCaps = new HashMap<>(); this.undergroundSegmentCaps = new HashMap<>();
this.routeAnchors = new HashMap<>(); this.routeAnchors = new HashMap<>();
this.surfaceRouteAnchors = new HashMap<>(); this.surfaceRouteAnchors = new HashMap<>();
this.surfaceRoutes = new IdentityHashMap<>();
this.caveCandidates = new HydrologyCaveCourseFilter.CandidateCache(); this.caveCandidates = new HydrologyCaveCourseFilter.CandidateCache();
this.caveValidations = new HydrologyCaveContainmentPlanner.ValidationCache(); this.caveValidations = new HydrologyCaveContainmentPlanner.ValidationCache();
} }
@@ -18,7 +18,7 @@ public record HydrologyPlannerSettings(
SeaCaves seaCaves, SeaCaves seaCaves,
SurfacePolicyBounds surfacePolicyBounds SurfacePolicyBounds surfacePolicyBounds
) { ) {
private static final long PLAN_FORMAT_REVISION = 8L; private static final long PLAN_FORMAT_REVISION = 9L;
private static final int MAXIMUM_CROSS_TILE_COLOR_PERIOD = 4; private static final int MAXIMUM_CROSS_TILE_COLOR_PERIOD = 4;
public HydrologyPlannerSettings { public HydrologyPlannerSettings {
@@ -345,8 +345,8 @@ public record HydrologyPlannerSettings(
} }
/** /**
* {@code sink} is how many blocks the water surface sits below the lowest natural ground beside * {@code sink} is how many blocks the water surface sits below the shaped bank top;
* the channel; zero keeps the water flush with the bank and the bank top always meets the water. * zero keeps the water flush with the bank.
* {@code channel} shapes the wet outline and {@code flow} the waterfalls and plunge basins. * {@code channel} shapes the wet outline and {@code flow} the waterfalls and plunge basins.
*/ */
public record Banks( public record Banks(
@@ -1,6 +1,7 @@
package art.arcane.iris.generation.hydrology; package art.arcane.iris.generation.hydrology;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.BitSet; import java.util.BitSet;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
@@ -94,7 +95,42 @@ final class HydrologyRegionalGraph {
accumulation[label.downstream.state.id()] += accumulation[label.state.id()]; accumulation[label.downstream.state.id()] += accumulation[label.state.id()];
} }
} }
return new Tree(selected, accumulation, List.copyOf(outlets)); int[] heads = new int[labels.size()];
int[] required = new int[labels.size()];
int[] nominal = new int[labels.size()];
int[] ceiling = new int[labels.size()];
Arrays.fill(ceiling, Integer.MAX_VALUE);
for (Label label : labels) {
HydrologyTerrainSample terrain = grid.node(label.state.index()).terrain();
if (label.downstream == null) {
HydrologyPoint landward = outlets.get(label.state.root()).outlet().landwardPoint();
terrain = planner.sampleLandBasisWithoutSlope(landward.x(), landward.z());
}
int index = label.state.id();
int depth = (planner.settings.surface().minimumDepth() + planner.settings.surface().maximumDepth()) / 2;
nominal[index] = terrain.naturalHeight() - planner.settings.surface().banks().sink() - depth;
int minimum = terrain.naturalHeight() - planner.sourcePlanner.permittedSurfaceIncision(terrain) + depth;
required[index] = coastal ? planner.settings.seaLevel() : Math.max(planner.settings.seaLevel(),
Math.max(minimum, label.downstream == null ? Integer.MIN_VALUE : required[label.downstream.state.id()]));
if (selected[label.state.index()] == label && planner.sourcePlanner.rawSourceEligible(
grid.node(label.state.index()).terrain(), planner.settings.surface().sources(), true)) {
ceiling[index] = Math.max(nominal[index], required[index]);
}
}
for (int index = labels.size() - 1; index >= 0; index--) {
Label label = labels.get(index);
if (label.downstream != null) {
int parent = label.downstream.state.id();
ceiling[parent] = Math.min(ceiling[parent], ceiling[index]);
}
}
for (Label label : labels) {
int index = label.state.id();
int downstream = label.downstream == null ? Integer.MIN_VALUE : heads[label.downstream.state.id()];
heads[index] = coastal ? planner.settings.seaLevel()
: Math.max(Math.max(required[index], downstream), Math.min(nominal[index], ceiling[index]));
}
return new Tree(selected, accumulation, List.copyOf(outlets), heads);
} }
List<Integer> path(Tree tree, int source) { List<Integer> path(Tree tree, int source) {
@@ -160,7 +196,7 @@ final class HydrologyRegionalGraph {
+ first.routingCost() + second.routingCost()) * policy); + first.routingCost() + second.routingCost()) * policy);
} }
record Tree(Label[] selected, int[] accumulation, List<OutletCandidate> outlets) { record Tree(Label[] selected, int[] accumulation, List<OutletCandidate> outlets, int[] heads) {
int retainedLabelCount() { int retainedLabelCount() {
BitSet retained = new BitSet(accumulation.length); BitSet retained = new BitSet(accumulation.length);
for (Label label : selected) { for (Label label : selected) {
@@ -187,6 +223,15 @@ final class HydrologyRegionalGraph {
return selected[source] == null ? 0D : selected[source].state.length(); return selected[source] == null ? 0D : selected[source].state.length();
} }
List<HydrologyPoint> centerline(HydrologySampledGrid grid, int source) {
ArrayList<HydrologyPoint> points = new ArrayList<>();
for (Label label = selected[source]; label != null; label = label.downstream) {
HydrologyGridNode node = grid.node(label.state.index());
points.add(new HydrologyPoint(node.x(), heads[label.state.id()], node.z()));
}
return List.copyOf(points);
}
int[] contributions(int source) { int[] contributions(int source) {
int count = 0; int count = 0;
for (Label label = selected[source]; label != null; label = label.downstream) { for (Label label = selected[source]; label != null; label = label.downstream) {
@@ -1,31 +1,10 @@
package art.arcane.iris.generation.hydrology; package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.surface.SurfaceBankSupport;
import java.util.Arrays;
final class HydrologyRegionalHydraulics { final class HydrologyRegionalHydraulics {
private static final int MAXIMUM_WIDTH_CANDIDATES = 8;
private static final int MAXIMUM_ADDITIONAL_SAMPLES = 2048;
private static final int MAXIMUM_BANK_RADIUS = 96;
private final HydrologyPlannerSettings settings; private final HydrologyPlannerSettings settings;
private final SurfaceBankSupport bankSupport;
private final double minimumHalfWidth;
private final double maximumProbedHalfWidth;
private final double[] candidateHalfWidths;
HydrologyRegionalHydraulics(HydrologyPlannerSettings settings) { HydrologyRegionalHydraulics(HydrologyPlannerSettings settings) {
this.settings = settings; this.settings = settings;
this.bankSupport = new SurfaceBankSupport(settings.surface(), settings.seaLevel());
HydrologyPlannerSettings.Surface surface = settings.surface();
this.minimumHalfWidth = surface.minimumWidth() / 2D;
double authored = surface.maximumWidth() / 2D;
double expanded = surface.maximumWidth();
this.maximumProbedHalfWidth = Math.min(expanded, Math.max(minimumHalfWidth,
(MAXIMUM_BANK_RADIUS - 2D) / (1D + surface.banks().roughness())));
this.candidateHalfWidths = candidates(authored, midpoint(minimumHalfWidth, authored),
maximumProbedHalfWidth, midpoint(minimumHalfWidth, maximumProbedHalfWidth));
} }
int minimumHead(HydrologyTerrainSample terrain) { int minimumHead(HydrologyTerrainSample terrain) {
@@ -41,90 +20,6 @@ final class HydrologyRegionalHydraulics {
return Math.max(settings.seaLevel(), terrain.naturalHeight() - settings.surface().banks().sink()); return Math.max(settings.seaLevel(), terrain.naturalHeight() - settings.surface().banks().sink());
} }
int supportedHead(HeadStation station, HydrologyTerrainSampler sampler) {
int centerHead = maximumHead(station.terrain());
if (centerHead >= station.incomingHead()) {
return station.incomingHead();
}
int supported = support(station, sampler, minimumHalfWidth, centerHead);
if (supported >= station.incomingHead()) {
return station.incomingHead();
}
SampleBudget budget = new SampleBudget(sampler);
double localMinimum = effectiveHalfWidth(settings.surface().minimumWidth(), station.terrain().widthMultiplier());
double localMaximum = effectiveHalfWidth(settings.surface().maximumWidth(), station.terrain().widthMultiplier());
if (additional(localMaximum)) {
supported = support(station, budget, localMaximum, supported);
}
if (supported < station.incomingHead() && localMinimum != localMaximum && additional(localMinimum)) {
supported = support(station, budget, localMinimum, supported);
}
double localMiddle = midpoint(localMinimum, localMaximum);
if (supported < station.incomingHead() && localMiddle != localMinimum && localMiddle != localMaximum && additional(localMiddle)) {
supported = support(station, budget, localMiddle, supported);
}
for (double halfWidth : candidateHalfWidths) {
if (supported >= station.incomingHead() || budget.remaining == 0) {
break;
}
supported = support(station, budget, halfWidth, supported);
}
return Math.min(station.incomingHead(), supported);
}
private int support(HeadStation station, HydrologyTerrainSampler sampler, double halfWidth, int supportedHead) {
SurfaceBankSupport.Station crossSection = new SurfaceBankSupport.Station(station.x(), station.z(),
station.tangentX(), station.tangentZ(), halfWidth);
SurfaceBankSupport.CrossSection support = bankSupport.crossSection(sampler, crossSection, true);
if (support.blocked() || support.minimum() - settings.surface().banks().sink() <= supportedHead) {
return supportedHead;
}
SurfaceBankSupport.Perimeter perimeter = bankSupport.perimeter(sampler, crossSection, support.minimum());
if (!perimeter.complete()) {
return supportedHead;
}
int supported = perimeter.minimum() - settings.surface().banks().sink();
return Math.max(supportedHead, supported);
}
private double effectiveHalfWidth(int width, double multiplier) {
return Math.max(settings.surface().minimumWidth(), Math.min(settings.surface().maximumWidth() * 2D, width * multiplier)) / 2D;
}
private boolean additional(double halfWidth) {
if (halfWidth <= minimumHalfWidth || halfWidth > maximumProbedHalfWidth) {
return false;
}
for (double candidate : candidateHalfWidths) {
if (candidate == halfWidth) {
return false;
}
}
return true;
}
private double[] candidates(double... widths) {
double[] distinct = new double[MAXIMUM_WIDTH_CANDIDATES - 4];
int size = 0;
for (double width : widths) {
if (!Double.isFinite(width) || width <= minimumHalfWidth || width > maximumProbedHalfWidth) {
continue;
}
boolean duplicate = false;
for (int index = 0; index < size; index++) {
duplicate |= distinct[index] == width;
}
if (!duplicate && size < distinct.length) {
distinct[size++] = width;
}
}
return Arrays.copyOf(distinct, size);
}
private static double midpoint(double first, double second) {
return first + (second - first) / 2D;
}
private int minimumHead(HydrologyTerrainSample terrain, int incision) { private int minimumHead(HydrologyTerrainSample terrain, int incision) {
int permitted = Math.min(incision, (int) StrictMath.floor(incision * terrain.incisionMultiplier())); int permitted = Math.min(incision, (int) StrictMath.floor(incision * terrain.incisionMultiplier()));
int depth = (int) StrictMath.round(Math.max(1D, Math.min(settings.surface().maximumDepth() * 2D, int depth = (int) StrictMath.round(Math.max(1D, Math.min(settings.surface().maximumDepth() * 2D,
@@ -132,24 +27,4 @@ final class HydrologyRegionalHydraulics {
return Math.max(settings.seaLevel(), terrain.naturalHeight() - permitted + depth); return Math.max(settings.seaLevel(), terrain.naturalHeight() - permitted + depth);
} }
record HeadStation(int x, int z, double tangentX, double tangentZ, HydrologyTerrainSample terrain, int incomingHead) {
}
private static final class SampleBudget implements HydrologyTerrainSampler {
private final HydrologyTerrainSampler sampler;
private int remaining = MAXIMUM_ADDITIONAL_SAMPLES;
private SampleBudget(HydrologyTerrainSampler sampler) {
this.sampler = sampler;
}
@Override
public HydrologyTerrainSample sample(int blockX, int blockZ) {
if (remaining == 0) {
return null;
}
remaining--;
return sampler.sample(blockX, blockZ);
}
}
} }
@@ -475,24 +475,25 @@ final class HydrologyRegionalPlanner {
List<Integer> indices = graph.path(tree, source); List<Integer> indices = graph.path(tree, source);
OutletCandidate destination = tree.outlets().get(tree.root(source)); OutletCandidate destination = tree.outlets().get(tree.root(source));
RiverOutlet outlet = destination.outlet(); RiverOutlet outlet = destination.outlet();
ArrayList<HydrologyPoint> guide = new ArrayList<>(indices.size() + 1); ArrayList<HydrologyPoint> guide = new ArrayList<>(tree.centerline(grid, source));
ArrayList<String> profiles = new ArrayList<>(grid.node(source).terrain().preferredProfileKeys()); ArrayList<String> profiles = new ArrayList<>(grid.node(source).terrain().preferredProfileKeys());
for (int index : indices) { for (int index : indices) {
HydrologyGridNode node = grid.node(index); HydrologyGridNode node = grid.node(index);
guide.add(node.naturalPoint());
profiles.retainAll(node.terrain().preferredProfileKeys()); profiles.retainAll(node.terrain().preferredProfileKeys());
} }
profiles.retainAll(sample(outlet.connectionPoint().x(), outlet.connectionPoint().z()).preferredProfileKeys()); profiles.retainAll(sample(outlet.connectionPoint().x(), outlet.connectionPoint().z()).preferredProfileKeys());
if (origin != null) { if (origin != null) {
profiles.retainAll(sample(origin.outlet().connectionPoint().x(), origin.outlet().connectionPoint().z()).preferredProfileKeys()); profiles.retainAll(sample(origin.outlet().connectionPoint().x(), origin.outlet().connectionPoint().z()).preferredProfileKeys());
guide.set(0, origin.outlet().landwardPoint()); HydrologyPoint start = origin.outlet().landwardPoint();
guide.set(0, new HydrologyPoint(start.x(), planner.settings.seaLevel(), start.z()));
} }
if (profiles.isEmpty() || guide.size() < 2) { if (profiles.isEmpty() || guide.size() < 2) {
diagnostic(diagnostics, grid.node(source).id(), grid.node(source).naturalPoint(), origin != null, diagnostic(diagnostics, grid.node(source).id(), grid.node(source).naturalPoint(), origin != null,
HydrologyCandidateRejection.POLICY_EXCLUDED, guide.size()); HydrologyCandidateRejection.POLICY_EXCLUDED, guide.size());
return HydrologyRegionalNetwork.EMPTY; return HydrologyRegionalNetwork.EMPTY;
} }
guide.set(guide.size() - 1, outlet.landwardPoint()); HydrologyPoint end = outlet.landwardPoint();
guide.set(guide.size() - 1, new HydrologyPoint(end.x(), guide.getLast().y(), end.z()));
String profile = profiles.get(HydrologyHash.between(HydrologyHash.mix(basinSeed, source), 0, profiles.size() - 1)); String profile = profiles.get(HydrologyHash.between(HydrologyHash.mix(basinSeed, source), 0, profiles.size() - 1));
BuildContext context = new BuildContext(grid.node(source), origin, basinSeed, outlet, profile, BuildContext context = new BuildContext(grid.node(source), origin, basinSeed, outlet, profile,
new HydrologyRegionalFlow(guide, tree.contributions(source)), new HydrologyRegionalFlow(guide, tree.contributions(source)),
@@ -18,41 +18,32 @@ final class HydrologyRegionalRoute {
private static final int MAXIMUM_CORRIDOR_WORKERS = 8; private static final int MAXIMUM_CORRIDOR_WORKERS = 8;
private final HydrologyPlanner planner; private final HydrologyPlanner planner;
private final HydrologyRegionalTerrainRefiner terrainRefiner; private final HydrologyRegionalTerrain terrain;
private final HydrologyRegionalHydraulics hydraulics;
private final SimplexNoise bends; private final SimplexNoise bends;
private final SimplexNoise details; private final SimplexNoise details;
HydrologyRegionalRoute(HydrologyPlanner planner) { HydrologyRegionalRoute(HydrologyPlanner planner) {
this.planner = planner; this.planner = planner;
this.terrainRefiner = new HydrologyRegionalTerrainRefiner(planner); this.terrain = new HydrologyRegionalTerrain(planner);
this.hydraulics = new HydrologyRegionalHydraulics(planner.settings);
this.bends = new SimplexNoise(HydrologyHash.mix(planner.worldSeed, 0x52454742454e4453L)); this.bends = new SimplexNoise(HydrologyHash.mix(planner.worldSeed, 0x52454742454e4453L));
this.details = new SimplexNoise(HydrologyHash.mix(planner.worldSeed, 0x5245474445544149L)); this.details = new SimplexNoise(HydrologyHash.mix(planner.worldSeed, 0x5245474445544149L));
} }
void clear() { void clear() {
terrainRefiner.clear(); terrain.clear();
} }
Attempt select(List<HydrologyPoint> guide, String profile, boolean coastal, HydrologyTerrainSampler receiver, Attempt select(List<HydrologyPoint> guide, String profile, boolean coastal, HydrologyTerrainSampler receiver,
CandidateAdmission admission) { CandidateAdmission admission) {
HydrologyRegionalMorphology morphology = HydrologyRegionalMorphology.sample(guide, terrainRefiner::sample, planner.settings); HydrologyRegionalMorphology morphology = HydrologyRegionalMorphology.sample(guide, this.terrain::sample, planner.settings);
Refinement topology = validateCorridor(guide, profile, coastal); Refinement topology = validateCorridor(guide, profile, coastal);
OceanEntry resolved = topology.rejection() == null ? topology.oceanEntry() : null; OceanEntry resolved = topology.rejection() == null ? topology.oceanEntry() : null;
if (resolved != null) { if (resolved != null) {
guide = topology.points(); guide = topology.points();
receiver = HydrologyOceanReceiver.forConnection(planner.settings, terrainRefiner::sample, receiver = HydrologyOceanReceiver.forConnection(planner.settings, this.terrain::sample,
resolved.landward(), resolved.receiving()); resolved.landward(), resolved.receiving());
} }
CandidateSelection selection = new CandidateSelection(admission, resolved); CandidateSelection selection = new CandidateSelection(admission, resolved);
List<HydrologyPoint> repaired = terrainRefiner.refine(guide, profile, coastal, receiver);
if (!repaired.isEmpty() && !repaired.equals(guide)) {
Attempt accepted = selectCurve(repaired, profile, coastal, receiver, selection, morphology);
if (accepted != null) {
return accepted;
}
}
Attempt accepted = selectCurve(guide, profile, coastal, receiver, selection, morphology); Attempt accepted = selectCurve(guide, profile, coastal, receiver, selection, morphology);
return accepted == null ? selection.failure : accepted; return accepted == null ? selection.failure : accepted;
} }
@@ -396,7 +387,7 @@ final class HydrologyRegionalRoute {
terrain.naturalHeight() - planner.settings.seaLevel() + planner.settings.surface().minimumDepth(), null); terrain.naturalHeight() - planner.settings.seaLevel() + planner.settings.surface().minimumDepth(), null);
} }
previousTerrain = terrain; previousTerrain = terrain;
previousPoint = failure; previousPoint = new HydrologyPoint(x, (int) StrictMath.round(first.y() + (second.y() - first.y()) * progress), z);
} }
length += StrictMath.sqrt(first.distanceSquared2D(second)); length += StrictMath.sqrt(first.distanceSquared2D(second));
} }
@@ -419,7 +410,7 @@ final class HydrologyRegionalRoute {
if (++samples > MAXIMUM_RECEIVER_SAMPLES) { if (++samples > MAXIMUM_RECEIVER_SAMPLES) {
return new Refinement(List.of(), previousPoint, HydrologyCandidateRejection.ROUTE_LIMIT, samples, null); return new Refinement(List.of(), previousPoint, HydrologyCandidateRejection.ROUTE_LIMIT, samples, null);
} }
HydrologyTerrainSample terrain = terrainRefiner.sample(x, z); HydrologyTerrainSample terrain = this.terrain.sample(x, z);
HydrologyPoint point = new HydrologyPoint(x, terrain == null ? first.y() : terrain.naturalHeight(), z); HydrologyPoint point = new HydrologyPoint(x, terrain == null ? first.y() : terrain.naturalHeight(), z);
if (terrain == null || terrain.naturalHeight() >= planner.settings.seaLevel()) { if (terrain == null || terrain.naturalHeight() >= planner.settings.seaLevel()) {
return new Refinement(List.of(), point, HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, 1, null); return new Refinement(List.of(), point, HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, 1, null);
@@ -453,7 +444,7 @@ final class HydrologyRegionalRoute {
private boolean wetBridge(int x, int z, HydrologyTerrainSample previous, HydrologyTerrainSample next, private boolean wetBridge(int x, int z, HydrologyTerrainSample previous, HydrologyTerrainSample next,
String profile, boolean coastal) { String profile, boolean coastal) {
HydrologyTerrainSample bridge = terrainRefiner.sample(x, z); HydrologyTerrainSample bridge = this.terrain.sample(x, z);
return bridge != null && bridge.naturalHeight() < planner.settings.seaLevel() return bridge != null && bridge.naturalHeight() < planner.settings.seaLevel()
&& bridge.preferredProfileKeys().contains(profile) && previous.drainsInto(bridge) && bridge.drainsInto(next) && bridge.preferredProfileKeys().contains(profile) && previous.drainsInto(bridge) && bridge.drainsInto(next)
&& (!coastal || next.drainsInto(bridge) && bridge.drainsInto(previous)); && (!coastal || next.drainsInto(bridge) && bridge.drainsInto(previous));
@@ -465,7 +456,7 @@ final class HydrologyRegionalRoute {
HydrologyPoint landward = new HydrologyPoint(shore.point().x(), seaLevel, shore.point().z()); HydrologyPoint landward = new HydrologyPoint(shore.point().x(), seaLevel, shore.point().z());
receiving = new HydrologyPoint(receiving.x(), seaLevel, receiving.z()); receiving = new HydrologyPoint(receiving.x(), seaLevel, receiving.z());
HydrologyTerrainSampler proof = HydrologyOceanReceiver.forConnection(planner.settings, HydrologyTerrainSampler proof = HydrologyOceanReceiver.forConnection(planner.settings,
terrainRefiner::sample, landward, receiving); this.terrain::sample, landward, receiving);
if (!shore.terrain().outletAllowed() || !proof.receivingWater(firstWet.x(), firstWet.z(), seaLevel) if (!shore.terrain().outletAllowed() || !proof.receivingWater(firstWet.x(), firstWet.z(), seaLevel)
|| !receivingChordAllowed(shore, receiving, profile, coastal)) { || !receivingChordAllowed(shore, receiving, profile, coastal)) {
return new Refinement(List.of(), firstWet, HydrologyCandidateRejection.SURFACE_MOUTH_DISCONNECTED, 0, null); return new Refinement(List.of(), firstWet, HydrologyCandidateRejection.SURFACE_MOUTH_DISCONNECTED, 0, null);
@@ -489,7 +480,7 @@ final class HydrologyRegionalRoute {
double progress = step / (double) steps; double progress = step / (double) steps;
int x = (int) StrictMath.round(start.x() + (end.x() - start.x()) * progress); int x = (int) StrictMath.round(start.x() + (end.x() - start.x()) * progress);
int z = (int) StrictMath.round(start.z() + (end.z() - start.z()) * progress); int z = (int) StrictMath.round(start.z() + (end.z() - start.z()) * progress);
HydrologyTerrainSample terrain = terrainRefiner.sample(x, z); HydrologyTerrainSample terrain = this.terrain.sample(x, z);
if (terrain == null || terrain.naturalHeight() >= planner.settings.seaLevel() if (terrain == null || terrain.naturalHeight() >= planner.settings.seaLevel()
|| !terrain.preferredProfileKeys().contains(profile) || !previous.drainsInto(terrain) || !terrain.preferredProfileKeys().contains(profile) || !previous.drainsInto(terrain)
|| coastal && !terrain.drainsInto(previous)) { || coastal && !terrain.drainsInto(previous)) {
@@ -517,30 +508,32 @@ final class HydrologyRegionalRoute {
Math.abs(points.get(index).z() - points.get(index - 1).z())); Math.abs(points.get(index).z() - points.get(index - 1).z()));
} }
HydrologyPlannerSettings.Inlet inlet = planner.settings.surface().banks().inlet(); HydrologyPlannerSettings.Inlet inlet = planner.settings.surface().banks().inlet();
boolean oceanMouth = corridor.oceanEntry() != null || terrainRefiner.receivingTerminal(points.getLast(), profile, false, receiver); boolean ocean = corridor.oceanEntry() != null || terrain.receivingTerminal(points.getLast(), profile, false, receiver);
int reach = oceanMouth ? Math.min(inlet.length(), (int) StrictMath.floor(count * inlet.courseFraction())) : 0; int reach = ocean ? Math.min(inlet.length(), (int) StrictMath.floor(count * inlet.courseFraction())) : 0;
int rampStart = count - reach - reach / 2; int rampStart = count - reach - reach / 2;
int availableHead = Integer.MAX_VALUE;
int station = 0; int station = 0;
HydrologyTerrainSampler bankSampler = terrainRefiner::sample;
for (int index = 0; index + 1 < points.size(); index++) { for (int index = 0; index + 1 < points.size(); index++) {
HydrologyPoint first = points.get(index); HydrologyPoint first = points.get(index);
HydrologyPoint second = points.get(index + 1); HydrologyPoint second = points.get(index + 1);
int steps = Math.max(Math.abs(second.x() - first.x()), Math.abs(second.z() - first.z())); if (first.y() < second.y()) {
double distance = StrictMath.sqrt(first.distanceSquared2D(second)); return new Refinement(List.of(), second, HydrologyCandidateRejection.SURFACE_HEAD_RANGE,
double tangentX = distance == 0D ? 1D : (second.x() - first.x()) / distance; second.y() - first.y(), null);
double tangentZ = distance == 0D ? 0D : (second.z() - first.z()) / distance; }
for (int step = index == 0 ? 0 : 1; step <= steps; step++) { List<HydrologyPoint> raster = planner.segments.rasterLine(first, second);
double progress = steps == 0 ? 0D : step / (double) steps; for (int step = index == 0 ? 0 : 1; step < raster.size(); step++) {
int x = (int) StrictMath.round(first.x() + (second.x() - first.x()) * progress); HydrologyPoint sampled = raster.get(step);
int z = (int) StrictMath.round(first.z() + (second.z() - first.z()) * progress); double progress = raster.size() < 2 ? 0D : step / (double) (raster.size() - 1);
HydrologyTerrainSample terrain = terrainRefiner.sample(x, z); HydrologyPoint point = new HydrologyPoint(sampled.x(),
availableHead = hydraulics.supportedHead(new HydrologyRegionalHydraulics.HeadStation( (int) StrictMath.round(first.y() + (second.y() - first.y()) * progress), sampled.z());
x, z, tangentX, tangentZ, terrain, availableHead), bankSampler); HydrologyTerrainSample terrain = this.terrain.sample(point.x(), point.z());
int requiredHead = station >= rampStart ? hydraulics.inletMinimumHead(terrain) : hydraulics.minimumHead(terrain); int incision = terrain.surfacePolicy().maximumIncision(planner.settings.surface().maximumIncision());
if (requiredHead > availableHead) { if (station >= rampStart) {
return new Refinement(List.of(), new HydrologyPoint(x, terrain.naturalHeight(), z), incision = Math.max(incision, inlet.maximumIncision());
HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, requiredHead - availableHead, null); }
int permitted = HydrologySourcePlanner.permittedSurfaceIncision(incision, terrain.incisionMultiplier());
int cut = terrain.naturalHeight() - point.y() + planner.settings.surface().minimumDepth();
if (cut > permitted) {
return new Refinement(List.of(), point, HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, cut, null);
} }
station++; station++;
} }
@@ -596,7 +589,7 @@ final class HydrologyRegionalRoute {
private HydrologyTerrainSample next(int segment, int step, int x, int z) { private HydrologyTerrainSample next(int segment, int step, int x, int z) {
if (workers == 1) { if (workers == 1) {
return terrainRefiner.sample(x, z); return HydrologyRegionalRoute.this.terrain.sample(x, z);
} }
if (cursor == count) { if (cursor == count) {
fill(segment, step); fill(segment, step);
@@ -643,7 +636,7 @@ final class HydrologyRegionalRoute {
private void sampleRange(int start, int end) { private void sampleRange(int start, int end) {
for (int index = start; index < end; index++) { for (int index = start; index < end; index++) {
try { try {
terrain[index] = terrainRefiner.sample(xs[index], zs[index]); terrain[index] = HydrologyRegionalRoute.this.terrain.sample(xs[index], zs[index]);
} catch (RuntimeException failure) { } catch (RuntimeException failure) {
failures[index] = failure; failures[index] = failure;
} }
@@ -0,0 +1,66 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.volmlib.util.cache.CacheKey;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
final class HydrologyRegionalTerrain implements HydrologyTerrainSampler {
private static final int MAXIMUM_CACHED_SAMPLES = 4194304;
private static final int[][] CARDINAL = {{1, 0}, {0, 1}, {-1, 0}, {0, -1}};
private final HydrologyPlanner planner;
private final HydrologyCacheBudget budget;
private volatile Cache<Long, Terrain> terrain;
HydrologyRegionalTerrain(HydrologyPlanner planner) {
this.planner = planner;
this.budget = HydrologyCacheBudget.runtime(planner.settings.routing().regional().enabled());
this.terrain = cache();
}
void clear() {
terrain = cache();
}
@Override
public HydrologyTerrainSample sample(int x, int z) {
Cache<Long, Terrain> active = terrain;
long key = CacheKey.mix(RiverFootprint.pack(x, z));
Terrain cached = active.getIfPresent(key);
if (cached != null) {
return cached.sample();
}
Terrain sampled = new Terrain(planner.naturalSampler == null
? planner.sampler.sample(x, z) : planner.naturalSampler.sampleBasisWithoutSlope(x, z));
Terrain previous = active.asMap().putIfAbsent(key, sampled);
return (previous == null ? sampled : previous).sample();
}
boolean receivingTerminal(HydrologyPoint point, String profile, boolean coastal, HydrologyTerrainSampler receiver) {
HydrologyTerrainSample land = sample(point.x(), point.z());
if (land == null || land.ocean() || land.naturalHeight() < planner.settings.seaLevel() || !land.outletAllowed()) {
return false;
}
for (int index = 0; index < 4; index++) {
int[] offset = CARDINAL[index];
HydrologyTerrainSample ocean = sample(point.x() + offset[0], point.z() + offset[1]);
if (ocean != null && receiver.receivingWater(point.x() + offset[0], point.z() + offset[1], planner.settings.seaLevel())
&& ocean.preferredProfileKeys().contains(profile) && land.drainsInto(ocean)
&& (!coastal || ocean.drainsInto(land))) {
return true;
}
}
return false;
}
private Cache<Long, Terrain> cache() {
return Caffeine.newBuilder()
.maximumWeight(budget.regionalTerrainBytes())
.weigher((Long key, Terrain value) -> HydrologyCacheWeights.bounded(
HydrologyCacheWeights.terrain(value.sample()), budget.regionalTerrainBytes(),
(int) Math.max(65536L, Math.min(MAXIMUM_CACHED_SAMPLES, Runtime.getRuntime().maxMemory() / 8192L))))
.build();
}
private record Terrain(HydrologyTerrainSample sample) {
}
}
@@ -1,514 +0,0 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.volmlib.util.cache.CacheKey;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.PriorityQueue;
import java.util.concurrent.ConcurrentHashMap;
final class HydrologyRegionalTerrainRefiner {
private static final int GRID_SPACING = 16;
private static final int MAXIMUM_EXPANSIONS = 4096;
private static final int MAXIMUM_SAMPLES = 65536;
private static final int MAXIMUM_CACHED_SAMPLES = 4194304;
private static final int MAXIMUM_SKIPPED_WAYPOINTS = 2;
private static final int[][] NEIGHBOURS = {{1, 0}, {0, 1}, {-1, 0}, {0, -1}, {1, 1}, {-1, 1}, {-1, -1}, {1, -1}};
private final HydrologyPlanner planner;
private final HydrologyRegionalHydraulics hydraulics;
private final HydrologyCacheBudget cacheBudget;
private volatile CacheState caches;
HydrologyRegionalTerrainRefiner(HydrologyPlanner planner) {
this.planner = planner;
this.hydraulics = new HydrologyRegionalHydraulics(planner.settings);
this.cacheBudget = HydrologyCacheBudget.runtime(planner.settings.routing().regional().enabled());
this.caches = new CacheState(cacheBudget);
}
void clear() {
caches = new CacheState(cacheBudget);
}
HydrologyTerrainSample sample(int x, int z) {
return sample(caches.terrain, x, z);
}
private HydrologyTerrainSample sample(Cache<Long, Terrain> cache, int x, int z) {
long key = CacheKey.mix(RiverFootprint.pack(x, z));
Terrain cached = cache.getIfPresent(key);
if (cached != null) {
return cached.sample();
}
Terrain sampled = new Terrain(planner.naturalSampler == null
? planner.sampler.sample(x, z) : planner.naturalSampler.sampleBasisWithoutSlope(x, z));
Terrain existing = cache.asMap().putIfAbsent(key, sampled);
return (existing == null ? sampled : existing).sample();
}
List<HydrologyPoint> refine(List<HydrologyPoint> guide, String profile, boolean coastal, HydrologyTerrainSampler receiver) {
if (guide.size() < 2) {
return List.of();
}
double maximumLength = planner.settings.routing().maximumRouteLength();
double remaining = 0D;
for (int index = 1; index < guide.size(); index++) {
remaining += distance(guide.get(index - 1), guide.get(index));
if (remaining > maximumLength) {
return List.of();
}
}
Terminal terminal = terminal(guide.getLast(), profile, coastal, receiver);
int[] requiredHead = requiredHeads(guide, terminal, MAXIMUM_SKIPPED_WAYPOINTS + 1);
if (requiredHead.length == 0) {
return List.of();
}
int availableHead = Integer.MAX_VALUE;
double length = 0D;
ArrayList<HydrologyPoint> refined = new ArrayList<>();
refined.add(guide.getFirst());
Long2IntOpenHashMap stations = new Long2IntOpenHashMap();
stations.defaultReturnValue(-1);
stations.put(RiverFootprint.pack(guide.getFirst().x(), guide.getFirst().z()), 0);
for (int index = 1; index < guide.size(); index++) {
int start = index - 1;
Reach reach = null;
int selected = index;
for (int candidate = index; candidate < guide.size() && candidate <= index + MAXIMUM_SKIPPED_WAYPOINTS; candidate++) {
Edge edge = new Edge(guide.get(start), guide.get(candidate), profile, coastal,
requiredHead[candidate], availableHead, terminal);
reach = edge(edge);
if (!reach.points().isEmpty()) {
selected = candidate;
break;
}
}
if (reach.points().isEmpty()) {
return List.of();
}
List<HydrologyPoint> points = reach.points();
availableHead = reach.availableHead();
for (int skipped = start; skipped < selected; skipped++) {
remaining -= distance(guide.get(skipped), guide.get(skipped + 1));
}
index = selected;
for (int point = 1; point < points.size(); point++) {
HydrologyPoint next = points.get(point);
long key = RiverFootprint.pack(next.x(), next.z());
int previous = stations.get(key);
if (previous >= 0) {
while (refined.size() > previous + 1) {
HydrologyPoint removed = refined.removeLast();
stations.remove(RiverFootprint.pack(removed.x(), removed.z()));
length -= distance(refined.getLast(), removed);
}
} else {
length += distance(refined.getLast(), next);
stations.put(key, refined.size());
refined.add(next);
}
if (length + Math.max(0D, remaining) > maximumLength) {
return List.of();
}
}
}
return simplify(refined, profile, coastal, terminal);
}
boolean receivingTerminal(HydrologyPoint point, String profile, boolean coastal, HydrologyTerrainSampler receiver) {
HydrologyTerrainSample land = sample(point.x(), point.z());
if (land == null || land.ocean() || land.naturalHeight() < planner.settings.seaLevel() || !land.outletAllowed()) {
return false;
}
for (int index = 0; index < 4; index++) {
int[] offset = NEIGHBOURS[index];
HydrologyTerrainSample ocean = sample(point.x() + offset[0], point.z() + offset[1]);
if (ocean != null && receiver.receivingWater(point.x() + offset[0], point.z() + offset[1], planner.settings.seaLevel())
&& ocean.preferredProfileKeys().contains(profile) && land.drainsInto(ocean)
&& (!coastal || ocean.drainsInto(land))) {
return true;
}
}
return false;
}
private Terminal terminal(HydrologyPoint point, String profile, boolean coastal, HydrologyTerrainSampler receiver) {
int inlet = receivingTerminal(point, profile, coastal, receiver) ? planner.settings.surface().banks().inlet().length() : 0;
return new Terminal(point, inlet + inlet / 2);
}
private int minimumHead(HydrologyTerrainSample terrain, int x, int z, Terminal terminal) {
boolean inlet = terminal.possibleStations() > 0
&& Math.max(Math.abs(x - terminal.landward().x()), Math.abs(z - terminal.landward().z())) < terminal.possibleStations();
return inlet ? hydraulics.inletMinimumHead(terrain) : hydraulics.minimumHead(terrain);
}
private int[] requiredHeads(List<HydrologyPoint> points, Terminal terminal, int maximumAdvance) {
int[] heads = new int[points.size()];
for (int index = points.size() - 1; index >= 0; index--) {
HydrologyPoint point = points.get(index);
HydrologyTerrainSample terrain = sample(point.x(), point.z());
if (terrain == null) {
return new int[0];
}
int required = planner.settings.seaLevel();
if (index + 1 < points.size()) {
required = Integer.MAX_VALUE;
for (int downstream = index + 1; downstream < points.size() && downstream <= index + maximumAdvance; downstream++) {
required = Math.min(required, heads[downstream]);
}
}
heads[index] = Math.max(required, minimumHead(terrain, point.x(), point.z(), terminal));
}
return heads;
}
private List<HydrologyPoint> simplify(List<HydrologyPoint> points, String profile, boolean coastal, Terminal terminal) {
double maximumReach = Math.min(512D, planner.settings.routing().regional().sampleSpacing() * 2D);
Samples samples = new Samples();
ArrayList<HydrologyPoint> simplified = new ArrayList<>();
int current = 0;
int[] requiredHead = requiredHeads(points, terminal, 1);
if (requiredHead.length == 0) {
return List.of();
}
int availableHead = Integer.MAX_VALUE;
simplified.add(points.getFirst());
while (current + 1 < points.size()) {
int farthest = current + 1;
double reach = distance(points.get(current), points.get(farthest));
while (farthest + 1 < points.size()) {
double next = distance(points.get(farthest), points.get(farthest + 1));
if (reach + next > maximumReach) {
break;
}
reach += next;
farthest++;
}
int selected = current + 1;
Traversal selectedTraversal = traverse(points.get(current), points.get(selected),
new Edge(points.get(current), points.get(selected), profile, coastal, requiredHead[selected], availableHead, terminal),
samples, availableHead);
for (int candidate = farthest; candidate > current + 1; candidate--) {
Edge shortcut = new Edge(points.get(current), points.get(candidate), profile, coastal,
requiredHead[candidate], availableHead, terminal);
Traversal traversal = traverse(shortcut.start(), shortcut.end(), shortcut, samples, availableHead);
if (traversal.cost() == 0D) {
selected = candidate;
selectedTraversal = traversal;
break;
}
}
if (!Double.isFinite(selectedTraversal.cost())) {
return List.copyOf(points);
}
availableHead = selectedTraversal.availableHead();
current = selected;
simplified.add(points.get(current));
}
return List.copyOf(simplified);
}
private Reach route(Edge edge) {
Samples samples = new Samples();
Traversal direct = traverse(edge.start(), edge.end(), edge, samples, edge.maximumHead());
if (direct.cost() == 0D) {
return new Reach(List.of(edge.start(), edge.end()), direct.availableHead());
}
int margin = planner.settings.routing().regional().sampleSpacing();
int minimumX = Math.min(edge.start().x(), edge.end().x()) - margin;
int maximumX = Math.max(edge.start().x(), edge.end().x()) + margin;
int minimumZ = Math.min(edge.start().z(), edge.end().z()) - margin;
int maximumZ = Math.max(edge.start().z(), edge.end().z()) + margin;
Long2ObjectOpenHashMap<List<Node>> visited = new Long2ObjectOpenHashMap<>();
PriorityQueue<Pending> pending = new PriorityQueue<>(Comparator.comparingDouble(Pending::estimate)
.thenComparingLong(Pending::key)
.thenComparing(Comparator.comparingInt(Pending::availableHead).reversed()));
long firstKey = RiverFootprint.pack(edge.start().x(), edge.start().z());
long lastKey = RiverFootprint.pack(edge.end().x(), edge.end().z());
Search search = new Search(edge, samples, visited, pending);
Node first = new Node(edge.start(), 0D, edge.maximumHead(), null);
ArrayList<Node> firstLabels = new ArrayList<>(1);
firstLabels.add(first);
visited.put(firstKey, firstLabels);
pending.add(new Pending(firstKey, first, distance(edge.start(), edge.end())));
for (int expanded = 0; !pending.isEmpty() && expanded < MAXIMUM_EXPANSIONS; expanded++) {
Pending next = pending.remove();
Node current = next.node();
if (!retained(visited.get(next.key()), current)) {
continue;
}
if (next.key() == lastKey) {
return new Reach(path(current), current.availableHead());
}
if (distance(current.point(), edge.end()) <= GRID_SPACING * StrictMath.sqrt(2D)) {
offer(search, current, edge.end());
}
for (int[] offset : NEIGHBOURS) {
int x = current.point().x() + offset[0] * GRID_SPACING;
int z = current.point().z() + offset[1] * GRID_SPACING;
if (x < minimumX || x > maximumX || z < minimumZ || z > maximumZ) {
continue;
}
offer(search, current, new HydrologyPoint(x, current.point().y(), z));
}
}
return new Reach(List.of(), edge.maximumHead());
}
private Reach edge(Edge edge) {
CacheState state = caches;
Reach cached = state.edges.getIfPresent(edge);
if (cached != null) {
return cached;
}
HydrologyForkJoin.Task<Reach> task = new HydrologyForkJoin.Task<>(() -> {
try {
Reach present = state.edges.getIfPresent(edge);
Reach result = present == null ? route(edge) : present;
state.edges.put(edge, result);
return result;
} finally {
state.loading.remove(edge);
}
});
HydrologyForkJoin.Task<Reach> existing = state.loading.putIfAbsent(edge, task);
return (existing == null ? task : existing).await();
}
private void offer(Search search, Node current, HydrologyPoint target) {
applyOffer(search, current, evaluateOffer(search, current, target));
}
private Offer evaluateOffer(Search search, Node current, HydrologyPoint target) {
Samples samples = search.samples();
HydrologyTerrainSample terrain = samples.sample(target.x(), target.z());
if (!allowed(terrain, search.edge())) {
return null;
}
HydrologyPoint point = new HydrologyPoint(target.x(), terrain.naturalHeight(), target.z());
double cost = current.cost() + distance(current.point(), point)
+ Math.max(0D, point.y() - current.point().y()) * planner.settings.routing().uphillPenalty()
+ Math.max(0D, terrain.routingCost() * terrain.routingMultiplier());
long key = RiverFootprint.pack(point.x(), point.z());
List<Node> labels = search.visited().get(key);
if (dominated(labels, cost, current.availableHead())) {
return null;
}
Traversal traversal = traverse(current.point(), point, search.edge(), samples, current.availableHead());
cost += traversal.cost();
if (!Double.isFinite(cost) || dominated(labels, cost, traversal.availableHead())) {
return null;
}
cost += clearanceCost(point, search.edge(), samples);
if (dominated(labels, cost, traversal.availableHead())) {
return null;
}
return new Offer(point, cost, traversal.availableHead());
}
private void applyOffer(Search search, Node current, Offer offer) {
if (offer == null) {
return;
}
long key = RiverFootprint.pack(offer.point().x(), offer.point().z());
List<Node> labels = search.visited().get(key);
if (labels == null) {
labels = new ArrayList<>(2);
search.visited().put(key, labels);
}
for (int index = labels.size() - 1; index >= 0; index--) {
Node previous = labels.get(index);
if (offer.cost() <= previous.cost() && offer.availableHead() >= previous.availableHead()) {
labels.remove(index);
}
}
Node retained = new Node(offer.point(), offer.cost(), offer.availableHead(), current);
labels.add(retained);
search.pending().add(new Pending(key, retained, offer.cost() + distance(offer.point(), search.edge().end())));
}
private static boolean dominated(List<Node> labels, double cost, int availableHead) {
if (labels == null) {
return false;
}
for (Node previous : labels) {
if (previous.cost() <= cost && previous.availableHead() >= availableHead) {
return true;
}
}
return false;
}
private static boolean retained(List<Node> labels, Node current) {
for (Node retained : labels) {
if (retained == current) {
return true;
}
}
return false;
}
private Traversal traverse(HydrologyPoint start, HydrologyPoint end, Edge edge,
Samples samples, int availableHead) {
int steps = Math.max(Math.abs(end.x() - start.x()), Math.abs(end.z() - start.z()));
HydrologyTerrainSample previous = null;
double distance = distance(start, end);
double tangentX = distance == 0D ? 1D : (end.x() - start.x()) / distance;
double tangentZ = distance == 0D ? 0D : (end.z() - start.z()) / distance;
double cost = 0D;
for (int step = 0; step <= steps; step++) {
double progress = steps == 0 ? 0D : step / (double) steps;
int x = (int) StrictMath.round(start.x() + (end.x() - start.x()) * progress);
int z = (int) StrictMath.round(start.z() + (end.z() - start.z()) * progress);
HydrologyTerrainSample terrain = samples.sample(x, z);
if (!allowed(terrain, edge) || previous != null && (!previous.drainsInto(terrain)
|| edge.coastal() && !terrain.drainsInto(previous))) {
return new Traversal(Double.POSITIVE_INFINITY, availableHead);
}
if (!edge.coastal()) {
availableHead = hydraulics.supportedHead(new HydrologyRegionalHydraulics.HeadStation(
x, z, tangentX, tangentZ, terrain, availableHead), samples);
if (availableHead < Math.max(edge.minimumHead(), minimumHead(terrain, x, z, edge.terminal()))) {
return new Traversal(Double.POSITIVE_INFINITY, availableHead);
}
}
cost += ridgeCost(x, z, terrain, edge);
previous = terrain;
}
return new Traversal(cost, availableHead);
}
private double ridgeCost(int x, int z, HydrologyTerrainSample terrain, Edge edge) {
if (edge.coastal()) {
return 0D;
}
double deltaX = edge.end().x() - edge.start().x();
double deltaZ = edge.end().z() - edge.start().z();
double squared = deltaX * deltaX + deltaZ * deltaZ;
double progress = squared == 0D ? 0D : Math.max(0D, Math.min(1D,
((x - edge.start().x()) * deltaX + (z - edge.start().z()) * deltaZ) / squared));
double envelope = edge.start().y() + (edge.end().y() - edge.start().y()) * progress;
int incision = terrain.surfacePolicy().maximumIncision(planner.settings.surface().maximumIncision());
double permitted = Math.min(incision, StrictMath.floor(incision * terrain.incisionMultiplier()));
double excess = terrain.naturalHeight() - envelope + planner.settings.surface().minimumDepth()
+ planner.settings.surface().banks().sink() - permitted;
return Math.max(0D, excess) * Math.max(1D, planner.settings.routing().uphillPenalty());
}
private double clearanceCost(HydrologyPoint point, Edge edge, Samples samples) {
double radius = planner.settings.surface().maximumWidth() / 2D
* (1D + planner.settings.surface().banks().roughness()) + 1D;
int blocked = 0;
for (int[] offset : NEIGHBOURS) {
double scale = offset[0] != 0 && offset[1] != 0 ? radius / StrictMath.sqrt(2D) : radius;
int x = point.x() + (int) StrictMath.round(offset[0] * scale);
int z = point.z() + (int) StrictMath.round(offset[1] * scale);
if (!allowed(samples.sample(x, z), edge)) {
blocked++;
}
}
return blocked * GRID_SPACING / 2D;
}
private boolean allowed(HydrologyTerrainSample terrain, Edge edge) {
return terrain != null && !terrain.ocean() && terrain.naturalHeight() >= planner.settings.seaLevel() && terrain.transitAllowed()
&& terrain.preferredProfileKeys().contains(edge.profile())
&& (!edge.coastal() || terrain.naturalHeight() - planner.settings.seaLevel()
+ planner.settings.surface().minimumDepth()
<= planner.settings.routing().regional().maximumCoastalIncision());
}
private static List<HydrologyPoint> path(Node current) {
ArrayList<HydrologyPoint> points = new ArrayList<>();
while (current != null) {
points.add(current.point());
current = current.parent();
}
Collections.reverse(points);
return List.copyOf(points);
}
private static double distance(HydrologyPoint first, HydrologyPoint second) {
return StrictMath.sqrt(first.distanceSquared2D(second));
}
final class Samples implements HydrologyTerrainSampler {
private final Long2ObjectOpenHashMap<HydrologyTerrainSample> values = new Long2ObjectOpenHashMap<>();
private final Cache<Long, Terrain> terrainCache = caches.terrain;
@Override
public HydrologyTerrainSample sample(int x, int z) {
long key = RiverFootprint.pack(x, z);
HydrologyTerrainSample sampled = values.get(key);
if (sampled != null || values.containsKey(key)) {
return sampled;
}
if (values.size() >= MAXIMUM_SAMPLES) {
return null;
}
sampled = HydrologyRegionalTerrainRefiner.this.sample(terrainCache, x, z);
values.put(key, sampled);
return sampled;
}
}
private static final class CacheState {
private final Cache<Edge, Reach> edges;
private final ConcurrentHashMap<Edge, HydrologyForkJoin.Task<Reach>> loading = new ConcurrentHashMap<>();
private final Cache<Long, Terrain> terrain;
private CacheState(HydrologyCacheBudget budget) {
edges = Caffeine.newBuilder()
.maximumWeight(budget.regionalReachBytes())
.weigher((Edge edge, Reach reach) -> HydrologyCacheWeights.bounded(
HydrologyCacheWeights.points(reach.points()), budget.regionalReachBytes(), 128))
.build();
terrain = Caffeine.newBuilder()
.maximumWeight(budget.regionalTerrainBytes())
.weigher((Long key, Terrain terrain) -> HydrologyCacheWeights.bounded(
HydrologyCacheWeights.terrain(terrain.sample()), budget.regionalTerrainBytes(),
(int) Math.max(MAXIMUM_SAMPLES, Math.min(MAXIMUM_CACHED_SAMPLES, Runtime.getRuntime().maxMemory() / 8192L))))
.build();
}
}
private record Offer(HydrologyPoint point, double cost, int availableHead) {
}
private record Edge(HydrologyPoint start, HydrologyPoint end, String profile, boolean coastal,
int minimumHead, int maximumHead, Terminal terminal) {
}
private record Terminal(HydrologyPoint landward, int possibleStations) {
}
private record Reach(List<HydrologyPoint> points, int availableHead) {
}
private record Traversal(double cost, int availableHead) {
}
private record Terrain(HydrologyTerrainSample sample) {
}
private record Search(Edge edge, Samples samples, Long2ObjectOpenHashMap<List<Node>> visited,
PriorityQueue<Pending> pending) {
}
private record Node(HydrologyPoint point, double cost, int availableHead, Node parent) {
}
private record Pending(long key, Node node, double estimate) {
private int availableHead() {
return node.availableHead();
}
}
}
@@ -15,7 +15,6 @@ final class HydrologyRouteGeometry {
this.planner = planner; this.planner = planner;
} }
static final double CROSS_DROP_WEIGHT = 3D;
static final long ROUTE_ANCHOR_X_SALT = 0x52414e434858L; static final long ROUTE_ANCHOR_X_SALT = 0x52414e434858L;
static final long ROUTE_ANCHOR_Z_SALT = 0x52414e43485aL; static final long ROUTE_ANCHOR_Z_SALT = 0x52414e43485aL;
static final long ROUTE_WORM_PRIMARY_SALT = 0x52575052494dL; static final long ROUTE_WORM_PRIMARY_SALT = 0x52575052494dL;
@@ -24,22 +23,17 @@ final class HydrologyRouteGeometry {
static final double ROUTE_TANGENT_SCALE = 0.68D; static final double ROUTE_TANGENT_SCALE = 0.68D;
static final int ROUTE_TRANSVERSE_CANDIDATES = 2; static final int ROUTE_TRANSVERSE_CANDIDATES = 2;
static final int ROUTE_FALLBACK_TRANSVERSE_CANDIDATES = 4; static final int ROUTE_FALLBACK_TRANSVERSE_CANDIDATES = 4;
static final int SURFACE_COURSE_TRANSVERSE_CANDIDATES = 2;
static final double SURFACE_COURSE_MAXIMUM_TURN_DEGREES = 50D;
static final double SURFACE_COURSE_TURN_COST = 2.5D;
static final int SURFACE_MINIMUM_NATURAL_BANK_RISE = 1;
static final int MAXIMUM_SURFACE_BORE_STEP = 4; static final int MAXIMUM_SURFACE_BORE_STEP = 4;
HydrologyPoint edgeContinuation( HydrologyPoint edgeContinuation(
HydrologySampledGrid grid, HydrologySampledGrid grid,
HydrologyRoutingPlan routing, HydrologyRoutingPlan routing,
HydrologyGridNode downstream, HydrologyGridNode downstream,
RiverOutlet outlet, RiverOutlet outlet
boolean surface
) { ) {
int continuationIndex = routing.parent()[downstream.index()]; int continuationIndex = routing.parent()[downstream.index()];
if (continuationIndex >= 0) { if (continuationIndex >= 0) {
return routeAnchor(grid.node(continuationIndex), surface); return routeAnchor(grid.node(continuationIndex), false);
} }
HydrologyPoint landward = outlet.landwardPoint(); HydrologyPoint landward = outlet.landwardPoint();
if (landward.x() != downstream.x() || landward.z() != downstream.z()) { if (landward.x() != downstream.x() || landward.z() != downstream.z()) {
@@ -177,17 +171,15 @@ final class HydrologyRouteGeometry {
return score + HydrologyHash.unit(tie) * 1.0E-6D; return score + HydrologyHash.unit(tie) * 1.0E-6D;
} }
List<HydrologyPoint> refineEdge( List<HydrologyPoint> refineUndergroundEdge(
long upstreamId, long upstreamId,
long downstreamId, long downstreamId,
HydrologyPoint upstream, HydrologyPoint upstream,
HydrologyPoint downstream, HydrologyPoint downstream,
HydrologyPoint continuation, HydrologyPoint continuation,
int transverseCandidates, int transverseCandidates
boolean surface
) { ) {
int refinement = planner.settings.routing().refinementSpacing(); int refinement = planner.settings.routing().refinementSpacing();
int effectiveTransverseCandidates = transverseCandidates;
double deltaX = downstream.x() - upstream.x(); double deltaX = downstream.x() - upstream.x();
double deltaZ = downstream.z() - upstream.z(); double deltaZ = downstream.z() - upstream.z();
double distance = StrictMath.hypot(deltaX, deltaZ); double distance = StrictMath.hypot(deltaX, deltaZ);
@@ -240,7 +232,7 @@ final class HydrologyRouteGeometry {
nominal, nominal,
progress, progress,
refinement, refinement,
elevationTransition && !surface ? 0 : effectiveTransverseCandidates, elevationTransition ? 0 : transverseCandidates,
upstreamTerrain == null ? null : upstreamTerrain.confinesKey() upstreamTerrain == null ? null : upstreamTerrain.confinesKey()
); );
if (candidates.isEmpty()) { if (candidates.isEmpty()) {
@@ -531,144 +523,6 @@ final class HydrologyRouteGeometry {
return List.copyOf(candidates.values()); return List.copyOf(candidates.values());
} }
List<RouteCandidate> unresolvedSurfaceRouteCandidates(
HydrologySampledGrid grid,
long upstreamId,
long downstreamId,
RoutePosition nominal,
double progress,
int refinement,
int transverseCandidates
) {
double searchEnvelope = StrictMath.sin(StrictMath.PI * progress);
LinkedHashMap<Long, RouteCandidate> candidates = new LinkedHashMap<>();
for (int offsetIndex = -transverseCandidates;
offsetIndex <= transverseCandidates;
offsetIndex++) {
double offset = offsetIndex * refinement * searchEnvelope;
int x = (int) StrictMath.round(nominal.x() - nominal.tangent().z() * offset);
int z = (int) StrictMath.round(nominal.z() + nominal.tangent().x() * offset);
long packed = RiverFootprint.pack(x, z);
RouteTerrainEstimate estimate = routeTerrainEstimate(grid, x, z);
double localScore = estimate.terrainScore()
+ StrictMath.abs(offset) * 0.18D
+ HydrologyHash.unit(HydrologyHash.mix(
planner.worldSeed,
upstreamId,
downstreamId,
x,
z
)) * 1.0E-6D;
RouteCandidate candidate = new RouteCandidate(
new HydrologyPoint(x, estimate.height(), z),
nominal.x() - nominal.tangent().z() * offset,
nominal.z() + nominal.tangent().x() * offset,
offset,
localScore,
estimate.terrainScore(),
nominal.tangent(),
false
);
RouteCandidate current = candidates.get(packed);
if (current == null || candidate.localScore() < current.localScore()) {
candidates.put(packed, candidate);
}
}
return List.copyOf(candidates.values());
}
RouteTerrainEstimate routeTerrainEstimate(HydrologySampledGrid grid, int x, int z) {
double gridX = (x - grid.minimumX()) / (double) grid.spacing();
double gridZ = (z - grid.minimumZ()) / (double) grid.spacing();
int minimumGridX = Math.max(0, Math.min(grid.width() - 1, (int) StrictMath.floor(gridX)));
int minimumGridZ = Math.max(0, Math.min(grid.width() - 1, (int) StrictMath.floor(gridZ)));
int maximumGridX = Math.min(grid.width() - 1, minimumGridX + 1);
int maximumGridZ = Math.min(grid.width() - 1, minimumGridZ + 1);
double progressX = Math.max(0D, Math.min(1D, gridX - minimumGridX));
double progressZ = Math.max(0D, Math.min(1D, gridZ - minimumGridZ));
HydrologyTerrainSample northwest = grid.nodeAt(minimumGridX, minimumGridZ).terrain();
HydrologyTerrainSample northeast = grid.nodeAt(maximumGridX, minimumGridZ).terrain();
HydrologyTerrainSample southwest = grid.nodeAt(minimumGridX, maximumGridZ).terrain();
HydrologyTerrainSample southeast = grid.nodeAt(maximumGridX, maximumGridZ).terrain();
double height = planner.routePaths.bilinear(
northwest.naturalHeight(),
northeast.naturalHeight(),
southwest.naturalHeight(),
southeast.naturalHeight(),
progressX,
progressZ
);
double terrainScore = planner.routePaths.bilinear(
routeTerrainScore(northwest),
routeTerrainScore(northeast),
routeTerrainScore(southwest),
routeTerrainScore(southeast),
progressX,
progressZ
);
return new RouteTerrainEstimate((int) StrictMath.round(height), terrainScore);
}
RouteCandidate resolveRouteCandidate(RouteCandidate candidate) {
if (candidate.terrainResolved()) {
return candidate;
}
HydrologyPoint point = candidate.point();
HydrologyTerrainSample terrain = planner.sampleLandBasis(point.x(), point.z());
if (terrain == null || !terrain.transitAllowed()) {
return new RouteCandidate(
point,
candidate.continuousX(),
candidate.continuousZ(),
candidate.offset(),
Double.POSITIVE_INFINITY,
Double.POSITIVE_INFINITY,
candidate.tangent(),
true
);
}
double terrainScore = routeTerrainScore(terrain) + crossDropPenalty(point, candidate.tangent(), terrain);
double exactLocalScore = candidate.localScore() - candidate.terrainScore() + terrainScore;
return new RouteCandidate(
new HydrologyPoint(point.x(), terrain.naturalHeight(), point.z()),
candidate.continuousX(),
candidate.continuousZ(),
candidate.offset(),
exactLocalScore,
terrainScore,
candidate.tangent(),
true
);
}
/**
* Ground that falls away beside a candidate forces the valley solver to cut the channel down to the
* lower bank; costing that drop keeps refined routes on valley floors and contour lines instead of
* traversing hillsides that the incision cap would later reject.
*/
double crossDropPenalty(HydrologyPoint point, RouteDirection tangent, HydrologyTerrainSample center) {
double length = StrictMath.hypot(tangent.x(), tangent.z());
if (length <= 0D) {
return 0D;
}
double normalX = -tangent.z() / length;
double normalZ = tangent.x() / length;
double reach = planner.settings.surface().maximumWidth() / 2D + planner.settings.surface().shoreWidth() + 2D;
int lowest = center.naturalHeight();
for (double distance = reach / 2D; distance <= reach; distance += reach / 2D) {
for (double direction = -1D; direction <= 1D; direction += 2D) {
double side = planner.sampleLandHeight(
(int) StrictMath.round(point.x() + normalX * distance * direction),
(int) StrictMath.round(point.z() + normalZ * distance * direction));
if (!Double.isNaN(side)) {
lowest = Math.min(lowest, (int) side);
}
}
}
double drop = center.naturalHeight() - lowest;
return drop * planner.settings.routing().valleyPreference() * CROSS_DROP_WEIGHT;
}
double routeTerrainScore(HydrologyTerrainSample terrain) { double routeTerrainScore(HydrologyTerrainSample terrain) {
return terrain.naturalHeight() * planner.settings.routing().valleyPreference() return terrain.naturalHeight() * planner.settings.routing().valleyPreference()
+ terrain.slope() * planner.settings.routing().slopePenalty() + terrain.slope() * planner.settings.routing().slopePenalty()
@@ -791,414 +645,6 @@ final class HydrologyRouteGeometry {
return null; return null;
} }
RouteCandidate[] selectCurvatureAwareTerrainRoute(
List<List<RouteCandidate>> layers,
double targetStepLength,
double maximumTurn,
double turnCost
) {
int layerCount = layers.size();
if (layerCount < 2) {
return new RouteCandidate[0];
}
double[][] localPenalties = new double[layerCount][];
double[][][] transitionCosts = new double[layerCount][][];
double[][][][] turnCosts = new double[layerCount][][][];
for (int layerIndex = 0; layerIndex < layerCount; layerIndex++) {
int layerSize = layers.get(layerIndex).size();
localPenalties[layerIndex] = new double[layerSize];
if (layerIndex > 0 && layerIndex < layerCount - 1) {
Arrays.fill(localPenalties[layerIndex], Double.NaN);
}
if (layerIndex == 0) {
continue;
}
int previousSize = layers.get(layerIndex - 1).size();
transitionCosts[layerIndex] = new double[previousSize][layerSize];
for (double[] row : transitionCosts[layerIndex]) {
Arrays.fill(row, Double.NaN);
}
}
CurvatureWorkspace workspace = new CurvatureWorkspace(layers);
while (true) {
CurvatureRouteSelection selection = selectCurvatureAwareTerrainRoute(
layers,
targetStepLength,
maximumTurn,
turnCost,
localPenalties,
transitionCosts,
turnCosts,
workspace
);
if (selection.route().length == 0) {
return selection.route();
}
boolean evaluated = evaluateSelectedSurfaceCosts(
selection,
layers,
localPenalties,
transitionCosts,
targetStepLength
);
if (!evaluated) {
return selection.route();
}
}
}
CurvatureRouteSelection selectCurvatureAwareTerrainRoute(
List<List<RouteCandidate>> layers,
double targetStepLength,
double maximumTurn,
double turnCost,
double[][] localPenalties,
double[][][] transitionCosts,
double[][][][] turnCosts,
CurvatureWorkspace workspace
) {
int layerCount = layers.size();
double[][] previousCosts = workspace.firstCosts;
double[][] currentCosts = workspace.secondCosts;
int[][][] predecessors = workspace.predecessors;
List<RouteCandidate> firstLayer = layers.getFirst();
List<RouteCandidate> secondLayer = layers.get(1);
for (int firstIndex = 0; firstIndex < firstLayer.size(); firstIndex++) {
Arrays.fill(previousCosts[firstIndex], Double.POSITIVE_INFINITY);
Arrays.fill(predecessors[1][firstIndex], -1);
for (int secondIndex = 0; secondIndex < secondLayer.size(); secondIndex++) {
RouteCandidate first = firstLayer.get(firstIndex);
RouteCandidate second = secondLayer.get(secondIndex);
double transition = resolvedTransitionCost(
first,
second,
targetStepLength,
transitionCosts[1][firstIndex][secondIndex],
workspace,
1,
firstIndex,
secondIndex
);
if (Double.isFinite(transition)) {
previousCosts[firstIndex][secondIndex] = first.localScore()
+ second.localScore()
+ resolvedLocalPenalty(localPenalties[1][secondIndex])
+ transition;
}
}
}
for (int layerIndex = 2; layerIndex < layerCount; layerIndex++) {
List<RouteCandidate> beforeLayer = layers.get(layerIndex - 2);
List<RouteCandidate> previousLayer = layers.get(layerIndex - 1);
List<RouteCandidate> currentLayer = layers.get(layerIndex);
if (turnCosts[layerIndex] == null) {
turnCosts[layerIndex] = new double[previousLayer.size()][currentLayer.size()][];
}
for (int previousIndex = 0; previousIndex < previousLayer.size(); previousIndex++) {
Arrays.fill(currentCosts[previousIndex], Double.POSITIVE_INFINITY);
Arrays.fill(predecessors[layerIndex][previousIndex], -1);
RouteCandidate previous = previousLayer.get(previousIndex);
for (int currentIndex = 0; currentIndex < currentLayer.size(); currentIndex++) {
RouteCandidate current = currentLayer.get(currentIndex);
double transition = resolvedTransitionCost(
previous,
current,
targetStepLength,
transitionCosts[layerIndex][previousIndex][currentIndex],
workspace,
layerIndex,
previousIndex,
currentIndex
);
if (!Double.isFinite(transition)) {
continue;
}
double localPenalty = resolvedLocalPenalty(localPenalties[layerIndex][currentIndex]);
for (int beforeIndex = 0; beforeIndex < beforeLayer.size(); beforeIndex++) {
double previousCost = previousCosts[beforeIndex][previousIndex];
if (!Double.isFinite(previousCost)) {
continue;
}
double[] candidateTurns = turnCosts[layerIndex][previousIndex][currentIndex];
if (candidateTurns == null) {
candidateTurns = new double[beforeLayer.size()];
Arrays.fill(candidateTurns, Double.NaN);
turnCosts[layerIndex][previousIndex][currentIndex] = candidateTurns;
}
double turnPenalty = candidateTurns[beforeIndex];
if (Double.isNaN(turnPenalty)) {
double turn = continuousTurnDegrees(
beforeLayer.get(beforeIndex),
previous,
current
);
turnPenalty = turn > maximumTurn
? Double.POSITIVE_INFINITY
: curvatureCost(turn, maximumTurn, turnCost);
candidateTurns[beforeIndex] = turnPenalty;
}
if (turnPenalty == Double.POSITIVE_INFINITY) {
continue;
}
double cost = previousCost
+ current.localScore()
+ localPenalty
+ transition
+ turnPenalty;
if (cost < currentCosts[previousIndex][currentIndex]) {
currentCosts[previousIndex][currentIndex] = cost;
predecessors[layerIndex][previousIndex][currentIndex] = beforeIndex;
}
}
}
}
double[][] completedCosts = currentCosts;
currentCosts = previousCosts;
previousCosts = completedCosts;
}
int lastLayerIndex = layerCount - 1;
int selectedPreviousIndex = -1;
int selectedCurrentIndex = -1;
double selectedCost = Double.POSITIVE_INFINITY;
for (int previousIndex = 0; previousIndex < layers.get(lastLayerIndex - 1).size(); previousIndex++) {
for (int currentIndex = 0;
currentIndex < layers.get(lastLayerIndex).size();
currentIndex++) {
if (previousCosts[previousIndex][currentIndex] < selectedCost) {
selectedCost = previousCosts[previousIndex][currentIndex];
selectedPreviousIndex = previousIndex;
selectedCurrentIndex = currentIndex;
}
}
}
if (!Double.isFinite(selectedCost)) {
return CurvatureRouteSelection.empty();
}
RouteCandidate[] route = new RouteCandidate[layerCount];
int[] indices = new int[layerCount];
route[lastLayerIndex] = layers.get(lastLayerIndex).get(selectedCurrentIndex);
route[lastLayerIndex - 1] = layers.get(lastLayerIndex - 1).get(selectedPreviousIndex);
indices[lastLayerIndex] = selectedCurrentIndex;
indices[lastLayerIndex - 1] = selectedPreviousIndex;
for (int layerIndex = lastLayerIndex; layerIndex >= 2; layerIndex--) {
int beforeIndex = predecessors[layerIndex][selectedPreviousIndex][selectedCurrentIndex];
if (beforeIndex < 0) {
return CurvatureRouteSelection.empty();
}
route[layerIndex - 2] = layers.get(layerIndex - 2).get(beforeIndex);
indices[layerIndex - 2] = beforeIndex;
selectedCurrentIndex = selectedPreviousIndex;
selectedPreviousIndex = beforeIndex;
}
return new CurvatureRouteSelection(route, indices);
}
boolean evaluateSelectedSurfaceCosts(
CurvatureRouteSelection selection,
List<List<RouteCandidate>> layers,
double[][] localPenalties,
double[][][] transitionCosts,
double targetStepLength
) {
boolean evaluated = false;
RouteCandidate[] route = selection.route();
int[] indices = selection.indices();
for (int layerIndex = 1; layerIndex < route.length - 1; layerIndex++) {
RouteCandidate candidate = route[layerIndex];
if (candidate.terrainResolved()) {
continue;
}
RouteCandidate resolved = resolveRouteCandidate(candidate);
ArrayList<RouteCandidate> updatedLayer = new ArrayList<>(layers.get(layerIndex));
updatedLayer.set(indices[layerIndex], resolved);
layers.set(layerIndex, List.copyOf(updatedLayer));
route[layerIndex] = resolved;
evaluated = true;
}
for (int layerIndex = 1; layerIndex < route.length - 1; layerIndex++) {
int candidateIndex = indices[layerIndex];
if (!Double.isNaN(localPenalties[layerIndex][candidateIndex])) {
continue;
}
if (!Double.isFinite(route[layerIndex].localScore())) {
localPenalties[layerIndex][candidateIndex] = 0D;
continue;
}
localPenalties[layerIndex][candidateIndex] = planner.surfaceCourses.surfaceRouteCandidateBankPenalty(
route[layerIndex]
);
evaluated = true;
}
for (int layerIndex = 1; layerIndex < route.length; layerIndex++) {
int previousIndex = indices[layerIndex - 1];
int currentIndex = indices[layerIndex];
if (!Double.isNaN(transitionCosts[layerIndex][previousIndex][currentIndex])) {
continue;
}
RouteCandidate previous = route[layerIndex - 1];
RouteCandidate current = route[layerIndex];
if (!Double.isFinite(previous.localScore()) || !Double.isFinite(current.localScore())) {
transitionCosts[layerIndex][previousIndex][currentIndex] = Double.POSITIVE_INFINITY;
continue;
}
double transition = planner.routePaths.routeTransitionCost(previous, current, targetStepLength);
if (Double.isFinite(transition)) {
transition += surfaceRouteTransitionPenalty(previous, current);
}
transitionCosts[layerIndex][previousIndex][currentIndex] = transition;
evaluated = true;
}
return evaluated;
}
double resolvedLocalPenalty(double penalty) {
return Double.isNaN(penalty) ? 0D : penalty;
}
double resolvedTransitionCost(
RouteCandidate previous,
RouteCandidate current,
double targetStepLength,
double exactCost,
CurvatureWorkspace workspace,
int layerIndex,
int previousIndex,
int currentIndex
) {
if (!Double.isNaN(exactCost)) {
return exactCost;
}
long heights = ((long) previous.point().y() << 32) | (current.point().y() & 0xffffffffL);
if (!workspace.geometryKnown[layerIndex][previousIndex][currentIndex]
|| workspace.geometryHeights[layerIndex][previousIndex][currentIndex] != heights) {
workspace.geometryCosts[layerIndex][previousIndex][currentIndex] =
planner.routePaths.routeTransitionGeometryCost(previous, current, targetStepLength);
workspace.geometryHeights[layerIndex][previousIndex][currentIndex] = heights;
workspace.geometryKnown[layerIndex][previousIndex][currentIndex] = true;
}
return workspace.geometryCosts[layerIndex][previousIndex][currentIndex];
}
double curvatureCost(double turn, double maximumTurn, double turnCost) {
double excess = Math.max(0D, turn - 8D);
return excess * excess * turnCost / maximumTurn;
}
double continuousTurnDegrees(RouteCandidate before, RouteCandidate center, RouteCandidate after) {
double incomingX = center.continuousX() - before.continuousX();
double incomingZ = center.continuousZ() - before.continuousZ();
double outgoingX = after.continuousX() - center.continuousX();
double outgoingZ = after.continuousZ() - center.continuousZ();
double length = StrictMath.hypot(incomingX, incomingZ) * StrictMath.hypot(outgoingX, outgoingZ);
if (length <= 0D) {
return 0D;
}
double cosine = (incomingX * outgoingX + incomingZ * outgoingZ) / length;
return StrictMath.toDegrees(StrictMath.acos(Math.max(-1D, Math.min(1D, cosine))));
}
double surfaceRouteTransitionPenalty(
RouteCandidate start,
RouteCandidate end
) {
int maximumBankDistance = planner.surfaceCourses.surfaceBankDistance(planner.settings.surface().maximumWidth());
RouteDirection tangent = planner.routePaths.direction(start.point().x(), start.point().z(), end.point().x(), end.point().z());
int endpointCeiling = Math.max(start.point().y(), end.point().y());
double penalty = 0D;
List<HydrologyPoint> raster = planner.segments.rasterLine(start.point(), end.point());
int sampleStride = Math.max(1, planner.settings.routing().refinementSpacing() / 4);
for (int pointIndex = 0; pointIndex < raster.size(); pointIndex += sampleStride) {
HydrologyPoint point = raster.get(pointIndex);
HydrologyTerrainSample terrain = planner.sampleLandBasis(point.x(), point.z());
if (terrain == null) {
return 1.0E12D;
}
int permittedIncision = planner.sourcePlanner.permittedSurfaceIncision(terrain);
int ridgeExcess = Math.max(
0,
terrain.naturalHeight() - endpointCeiling - permittedIncision
);
penalty += (double) ridgeExcess * ridgeExcess * 65536D;
if (terrain.slope() >= planner.settings.hydraulics().waterfallMinimumDrop()) {
continue;
}
int head = Math.subtractExact(
terrain.naturalHeight(),
planner.settings.surface().banks().sink()
);
penalty += planner.surfaceCourses.surfaceRouteBankBandPenalty(
point,
tangent,
maximumBankDistance,
head,
planner.surfaceCourses.maximumSurfaceBankRise(terrain)
);
}
if ((raster.size() - 1) % sampleStride != 0) {
HydrologyPoint point = raster.getLast();
HydrologyTerrainSample terrain = planner.sampleLandBasis(point.x(), point.z());
if (terrain == null) {
return 1.0E12D;
}
int permittedIncision = planner.sourcePlanner.permittedSurfaceIncision(terrain);
int ridgeExcess = Math.max(
0,
terrain.naturalHeight() - endpointCeiling - permittedIncision
);
penalty += (double) ridgeExcess * ridgeExcess * 65536D;
if (terrain.slope() < planner.settings.hydraulics().waterfallMinimumDrop()) {
int head = Math.subtractExact(
terrain.naturalHeight(),
planner.settings.surface().banks().sink()
);
penalty += planner.surfaceCourses.surfaceRouteBankBandPenalty(
point,
tangent,
maximumBankDistance,
head,
planner.surfaceCourses.maximumSurfaceBankRise(terrain)
);
}
}
return penalty;
}
static final class CurvatureWorkspace {
private final double[][] firstCosts;
private final double[][] secondCosts;
private final int[][][] predecessors;
private final double[][][] geometryCosts;
private final long[][][] geometryHeights;
private final boolean[][][] geometryKnown;
CurvatureWorkspace(List<? extends List<?>> layers) {
int maximumCandidates = 0;
for (List<?> layer : layers) {
maximumCandidates = Math.max(maximumCandidates, layer.size());
}
firstCosts = new double[maximumCandidates][maximumCandidates];
secondCosts = new double[maximumCandidates][maximumCandidates];
predecessors = new int[layers.size()][][];
geometryCosts = new double[layers.size()][][];
geometryHeights = new long[layers.size()][][];
geometryKnown = new boolean[layers.size()][][];
for (int layer = 1; layer < layers.size(); layer++) {
int previousSize = layers.get(layer - 1).size();
int currentSize = layers.get(layer).size();
predecessors[layer] = new int[previousSize][currentSize];
geometryCosts[layer] = new double[previousSize][currentSize];
geometryHeights[layer] = new long[previousSize][currentSize];
geometryKnown[layer] = new boolean[previousSize][currentSize];
}
}
}
record CurvatureRouteSelection(RouteCandidate[] route, int[] indices) {
private static CurvatureRouteSelection empty() {
return new CurvatureRouteSelection(new RouteCandidate[0], new int[0]);
}
}
record TerrainTransitionKey(HydrologyPoint start, HydrologyPoint end) { record TerrainTransitionKey(HydrologyPoint start, HydrologyPoint end) {
} }
} }
@@ -23,19 +23,6 @@ final class HydrologyRoutePath {
return false; return false;
} }
double bilinear(
double northwest,
double northeast,
double southwest,
double southeast,
double progressX,
double progressZ
) {
double north = northwest + (northeast - northwest) * progressX;
double south = southwest + (southeast - southwest) * progressX;
return north + (south - north) * progressZ;
}
int removeTerrainPits(List<List<RouteCandidate>> layers, RouteCandidate[] route) { int removeTerrainPits(List<List<RouteCandidate>> layers, RouteCandidate[] route) {
int removed = 0; int removed = 0;
for (int routeIndex = 1; routeIndex < route.length - 1; routeIndex++) { for (int routeIndex = 1; routeIndex < route.length - 1; routeIndex++) {
@@ -8,6 +8,7 @@ record HydrologyRoutingPlan(
int[] outletIndex, int[] outletIndex,
int[] routeLengths, int[] routeLengths,
List<OutletCandidate> outlets, List<OutletCandidate> outlets,
boolean organicSurfaceRequired boolean organicSurfaceRequired,
HydrologyDrainageGeometry surfaceDrainage
) { ) {
} }
@@ -2,16 +2,77 @@ package art.arcane.iris.generation.hydrology;
import java.util.List; import java.util.List;
record HydrologySampledGrid( final class HydrologySampledGrid {
int minimumX, private final int minimumX;
int minimumZ, private final int minimumZ;
int ownerMinimumX, private final int ownerMinimumX;
int ownerMinimumZ, private final int ownerMinimumZ;
int ownerSize, private final int ownerSize;
int width, private final int width;
int spacing, private final int spacing;
List<HydrologyGridNode> nodes private final List<HydrologyGridNode> nodes;
) { private volatile HydrologyDrainageGraph drainage;
HydrologySampledGrid(int minimumX, int minimumZ, int ownerMinimumX, int ownerMinimumZ,
int ownerSize, int width, int spacing, List<HydrologyGridNode> nodes) {
this.minimumX = minimumX;
this.minimumZ = minimumZ;
this.ownerMinimumX = ownerMinimumX;
this.ownerMinimumZ = ownerMinimumZ;
this.ownerSize = ownerSize;
this.width = width;
this.spacing = spacing;
this.nodes = List.copyOf(nodes);
}
int minimumX() {
return minimumX;
}
int minimumZ() {
return minimumZ;
}
int ownerMinimumX() {
return ownerMinimumX;
}
int ownerMinimumZ() {
return ownerMinimumZ;
}
int ownerSize() {
return ownerSize;
}
int width() {
return width;
}
int spacing() {
return spacing;
}
List<HydrologyGridNode> nodes() {
return nodes;
}
HydrologyDrainageGraph drainage(HydrologySourcePlanner sourcePlanner) {
HydrologyDrainageGraph current = drainage;
if (current != null && current.settings().equals(sourcePlanner.settings())) {
return current;
}
synchronized (this) {
current = drainage;
if (current == null || !current.settings().equals(sourcePlanner.settings())) {
current = new HydrologyDrainageGraph(this, sourcePlanner);
drainage = current;
}
return current;
}
}
HydrologyGridNode node(int index) { HydrologyGridNode node(int index) {
return nodes.get(index); return nodes.get(index);
} }
@@ -21,6 +21,10 @@ final class HydrologySourcePlanner {
this.planner = planner; this.planner = planner;
} }
HydrologyPlannerSettings settings() {
return planner.settings;
}
static final long EDGE_SALT = 0x45444745L; static final long EDGE_SALT = 0x45444745L;
static final long SURFACE_SOURCE_SALT = 0x53555246414345L; static final long SURFACE_SOURCE_SALT = 0x53555246414345L;
static final long UNDERGROUND_SOURCE_SALT = 0x554e444552L; static final long UNDERGROUND_SOURCE_SALT = 0x554e444552L;
@@ -70,10 +74,7 @@ final class HydrologySourcePlanner {
double[] potential = new double[nodeCount]; double[] potential = new double[nodeCount];
int[] parent = new int[nodeCount]; int[] parent = new int[nodeCount];
int[] outletIndex = new int[nodeCount]; int[] outletIndex = new int[nodeCount];
int[] minimumNeighborHeights = new int[nodeCount]; HydrologyDrainageGraph drainage = grid.drainage(this);
for (HydrologyGridNode node : grid.nodes()) {
minimumNeighborHeights[node.index()] = minimumNeighborHeight(grid, node);
}
Arrays.fill(potential, Double.POSITIVE_INFINITY); Arrays.fill(potential, Double.POSITIVE_INFINITY);
Arrays.fill(parent, -1); Arrays.fill(parent, -1);
Arrays.fill(outletIndex, -1); Arrays.fill(outletIndex, -1);
@@ -91,25 +92,13 @@ final class HydrologySourcePlanner {
continue; continue;
} }
HydrologyGridNode downstream = grid.node(currentEntry.nodeIndex()); HydrologyGridNode downstream = grid.node(currentEntry.nodeIndex());
for (HydrologyGridOffset offset : ROUTING_OFFSETS) { for (int direction = 0; direction < ROUTING_OFFSETS.size(); direction++) {
HydrologyGridNode upstream = grid.nodeAt(downstream.gridX() + offset.x(), downstream.gridZ() + offset.z()); int upstreamIndex = drainage.upstream(downstream.index(), direction, surface);
if (upstream == null || upstream.terrain().ocean() || !upstream.terrain().transitAllowed() if (upstreamIndex < 0) {
|| !upstream.terrain().drainsInto(downstream.terrain())
|| surface && !HydrologySurfaceProfiles.sharesProfile(upstream.terrain(), downstream.terrain())) {
// Confined ground only drains into its own area, so every course that starts or
// arrives there keeps to that area up to and including its outlet.
continue; continue;
} }
if (surface && downstream.terrain().naturalHeight() - upstream.terrain().naturalHeight() HydrologyGridNode upstream = grid.node(upstreamIndex);
> maximumSurfaceEdgeRise(downstream.terrain())) { double edgeCost = drainage.cost(downstream.index(), direction);
continue;
}
double edgeCost = routeCost(
upstream,
downstream,
offset,
minimumNeighborHeights[downstream.index()]
);
double candidatePotential = currentEntry.potential() + edgeCost; double candidatePotential = currentEntry.potential() + edgeCost;
int existingParent = parent[upstream.index()]; int existingParent = parent[upstream.index()];
boolean replace = candidatePotential < potential[upstream.index()] - 1.0E-9D; boolean replace = candidatePotential < potential[upstream.index()] - 1.0E-9D;
@@ -135,7 +124,9 @@ final class HydrologySourcePlanner {
outletIndex, outletIndex,
routeLengths(parent, grid.width(), planner.settings.routing()), routeLengths(parent, grid.width(), planner.settings.routing()),
List.copyOf(outlets), List.copyOf(outlets),
false false,
surface ? new HydrologyDrainageGeometry(planner,
new HydrologyDrainageGeometry.Basin(grid, parent, outletIndex, List.copyOf(outlets))) : null
); );
} }
@@ -160,7 +151,8 @@ final class HydrologySourcePlanner {
routing.outletIndex(), routing.outletIndex(),
routing.routeLengths(), routing.routeLengths(),
routing.outlets(), routing.outlets(),
true true,
routing.surfaceDrainage()
); );
} }
@@ -985,75 +977,75 @@ final class HydrologySourcePlanner {
HydrologyGridNode downstream = grid.node(downstreamIndex); HydrologyGridNode downstream = grid.node(downstreamIndex);
RiverOutlet outlet = routing.outlets().get(routing.outletIndex()[upstream.index()]).outlet(); RiverOutlet outlet = routing.outlets().get(routing.outletIndex()[upstream.index()]).outlet();
long edgeId = HydrologyHash.mix(planner.worldSeed, EDGE_SALT, upstream.id(), downstream.id(), outlet.id()); long edgeId = HydrologyHash.mix(planner.worldSeed, EDGE_SALT, upstream.id(), downstream.id(), outlet.id());
long refinementId = surface ? HydrologyHash.mix(edgeId, SURFACE_SOURCE_SALT) : edgeId; List<HydrologyPoint> centerline;
List<HydrologyPoint> centerline = refinedEdges.get(refinementId); if (surface) {
int transverseCandidates = HydrologyRouteGeometry.ROUTE_TRANSVERSE_CANDIDATES; centerline = routing.surfaceDrainage().edge(upstream.index());
if (centerline == null) { } else {
HydrologyPoint upstreamAnchor = planner.routeGeometry.routeAnchor(upstream, surface); centerline = refinedEdges.get(edgeId);
HydrologyPoint downstreamAnchor = planner.routeGeometry.routeAnchor(downstream, surface); int transverseCandidates = HydrologyRouteGeometry.ROUTE_TRANSVERSE_CANDIDATES;
HydrologyPoint continuation = planner.routeGeometry.edgeContinuation(grid, routing, downstream, outlet, surface); if (centerline == null) {
RefinedEdgeKey refinedEdgeKey = new RefinedEdgeKey( HydrologyPoint upstreamAnchor = planner.routeGeometry.routeAnchor(upstream, false);
upstream.id(), HydrologyPoint downstreamAnchor = planner.routeGeometry.routeAnchor(downstream, false);
downstream.id(), HydrologyPoint continuation = planner.routeGeometry.edgeContinuation(grid, routing, downstream, outlet);
upstreamAnchor.x(), RefinedEdgeKey refinedEdgeKey = new RefinedEdgeKey(
upstreamAnchor.z(), upstream.id(),
downstreamAnchor.x(), downstream.id(),
downstreamAnchor.z(), upstreamAnchor.x(),
continuation.x(), upstreamAnchor.z(),
continuation.z(), downstreamAnchor.x(),
transverseCandidates, downstreamAnchor.z(),
surface continuation.x(),
); continuation.z(),
centerline = planner.refinedEdgeCache.get( transverseCandidates
refinedEdgeKey, );
ignored -> planner.routeGeometry.refineEdge( centerline = planner.refinedEdgeCache.get(
upstream.id(), refinedEdgeKey,
downstream.id(), ignored -> planner.routeGeometry.refineUndergroundEdge(
upstreamAnchor, upstream.id(),
downstreamAnchor, downstream.id(),
continuation, upstreamAnchor,
transverseCandidates, downstreamAnchor,
surface continuation,
) transverseCandidates
); )
refinedEdges.put(refinementId, centerline); );
} refinedEdges.put(edgeId, centerline);
if (centerline.isEmpty()) { }
HydrologyPoint upstreamAnchor = planner.routeGeometry.routeAnchor(upstream, surface); if (centerline.isEmpty()) {
HydrologyPoint downstreamAnchor = planner.routeGeometry.routeAnchor(downstream, surface); HydrologyPoint upstreamAnchor = planner.routeGeometry.routeAnchor(upstream, false);
HydrologyPoint continuation = planner.routeGeometry.edgeContinuation(grid, routing, downstream, outlet, surface); HydrologyPoint downstreamAnchor = planner.routeGeometry.routeAnchor(downstream, false);
RefinedEdgeKey refinedEdgeKey = new RefinedEdgeKey( HydrologyPoint continuation = planner.routeGeometry.edgeContinuation(grid, routing, downstream, outlet);
upstream.id(), RefinedEdgeKey refinedEdgeKey = new RefinedEdgeKey(
downstream.id(), upstream.id(),
upstreamAnchor.x(), downstream.id(),
upstreamAnchor.z(), upstreamAnchor.x(),
downstreamAnchor.x(), upstreamAnchor.z(),
downstreamAnchor.z(), downstreamAnchor.x(),
continuation.x(), downstreamAnchor.z(),
continuation.z(), continuation.x(),
HydrologyRouteGeometry.ROUTE_FALLBACK_TRANSVERSE_CANDIDATES, continuation.z(),
surface HydrologyRouteGeometry.ROUTE_FALLBACK_TRANSVERSE_CANDIDATES
); );
centerline = planner.refinedEdgeCache.get( centerline = planner.refinedEdgeCache.get(
refinedEdgeKey, refinedEdgeKey,
ignored -> planner.routeGeometry.refineEdge( ignored -> planner.routeGeometry.refineUndergroundEdge(
upstream.id(), upstream.id(),
downstream.id(), downstream.id(),
upstreamAnchor, upstreamAnchor,
downstreamAnchor, downstreamAnchor,
continuation, continuation,
HydrologyRouteGeometry.ROUTE_FALLBACK_TRANSVERSE_CANDIDATES, HydrologyRouteGeometry.ROUTE_FALLBACK_TRANSVERSE_CANDIDATES
surface )
) );
); refinedEdges.put(edgeId, centerline);
refinedEdges.put(refinementId, centerline); }
} if (centerline.isEmpty()) {
if (centerline.isEmpty()) { centerline = planner.routeGeometry.constrainedTerrainFallbackEdge(
centerline = planner.routeGeometry.constrainedTerrainFallbackEdge( planner.routeGeometry.routeAnchor(upstream, false),
planner.routeGeometry.routeAnchor(upstream, surface), planner.routeGeometry.routeAnchor(downstream, false)
planner.routeGeometry.routeAnchor(downstream, surface) );
); refinedEdges.put(edgeId, centerline);
refinedEdges.put(refinementId, centerline); }
} }
if (centerline.isEmpty()) { if (centerline.isEmpty()) {
continue; continue;
@@ -1081,7 +1073,8 @@ final class HydrologySourcePlanner {
} }
int outletIndex = routing.outletIndex()[node.index()]; int outletIndex = routing.outletIndex()[node.index()];
RiverOutlet outlet = routing.outlets().get(outletIndex).outlet(); RiverOutlet outlet = routing.outlets().get(outletIndex).outlet();
HydrologyPoint anchor = planner.routeGeometry.routeAnchor(node, surface); HydrologyPoint anchor = surface ? routing.surfaceDrainage().anchor(node.index())
: planner.routeGeometry.routeAnchor(node, false);
HydrologyTerrainSample terrain = Objects.requireNonNull( HydrologyTerrainSample terrain = Objects.requireNonNull(
planner.sampleLandBasis(anchor.x(), anchor.z()), planner.sampleLandBasis(anchor.x(), anchor.z()),
"Hydrology route anchor left natural land" "Hydrology route anchor left natural land"
@@ -9,8 +9,8 @@ import java.util.Comparator;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.OptionalLong; import java.util.OptionalLong;
import java.util.Objects;
final class HydrologySurfaceCoursePlanner { final class HydrologySurfaceCoursePlanner {
private final HydrologyPlanner planner; private final HydrologyPlanner planner;
@@ -28,7 +28,6 @@ final class HydrologySurfaceCoursePlanner {
static final double SURFACE_GRID_LOCK_TOLERANCE_DEGREES = 5D; static final double SURFACE_GRID_LOCK_TOLERANCE_DEGREES = 5D;
static final double SURFACE_MAXIMUM_GRID_LOCKED_FRACTION = 0.5D; static final double SURFACE_MAXIMUM_GRID_LOCKED_FRACTION = 0.5D;
static final double SURFACE_MAXIMUM_GRID_LOCKED_RUN = 64D; static final double SURFACE_MAXIMUM_GRID_LOCKED_RUN = 64D;
static final double SURFACE_MAXIMUM_WIDTH_SCALE = 1.18D;
void compileSurfaceCourses( void compileSurfaceCourses(
HydrologyTileKey key, HydrologyTileKey key,
@@ -407,224 +406,8 @@ final class HydrologySurfaceCoursePlanner {
if (guide.size() < 2 || guideEdges.size() != guide.size() - 1) { if (guide.size() < 2 || guideEdges.size() != guide.size() - 1) {
return null; return null;
} }
ArrayList<HydrologyPoint> routedGuide = new ArrayList<>(guide); return new HydrologyCoursePath(List.copyOf(guide), List.copyOf(guideEdges), List.copyOf(edges),
ArrayList<DrainageEdge> routedGuideEdges = new ArrayList<>(guideEdges); outlet, true, routing.organicSurfaceRequired(), null);
HydrologyPoint landward = outlet.landwardPoint();
HydrologyPoint guideEnd = routedGuide.getLast();
boolean terminateAtSinkhole = planner.segments.surfaceSinkhole(outlet)
&& (guideEnd.x() != landward.x() || guideEnd.z() != landward.z());
if (terminateAtSinkhole) {
routedGuide.set(routedGuide.size() - 1, landward);
}
List<HydrologyPoint> surfaceGuide = planner.routePaths.withoutDuplicateRoutePoints(routedGuide);
long geometryId = HydrologyHash.mix(
planner.worldSeed,
COURSE_SALT,
HydrologySourcePlanner.SURFACE_SOURCE_SALT,
edges.getFirst().upstreamNodeId(),
outlet.id()
);
Objects.requireNonNull(grid, "Surface course routing grid is required");
HydrologyPlanner.PlanningSamples samples = planner.planningSamples.get();
DraftProfile profile = planner.currentDraftProfile();
profile.routeCalls++;
List<HydrologyPoint> points;
if (samples == null) {
profile.routeSolves++;
long solveStarted = System.nanoTime();
points = surfaceCourseCenterline(grid, surfaceGuide, geometryId);
profile.routeSolveNanos += System.nanoTime() - solveStarted;
} else {
HashMap<SurfaceRouteKey, List<HydrologyPoint>> routes = samples.surfaceRoutes.computeIfAbsent(
grid, ignored -> new HashMap<>()
);
SurfaceRouteKey routeKey = new SurfaceRouteKey(surfaceGuide, geometryId);
points = routes.computeIfAbsent(routeKey, ignored -> {
profile.routeSolves++;
long solveStarted = System.nanoTime();
List<HydrologyPoint> solved = surfaceCourseCenterline(grid, surfaceGuide, geometryId);
profile.routeSolveNanos += System.nanoTime() - solveStarted;
return solved;
});
}
if (points.size() < 2) {
return null;
}
List<DrainageEdge> pairEdges = mapCoursePairs(points, routedGuide, routedGuideEdges);
if (pairEdges.size() != points.size() - 1) {
return null;
}
return new HydrologyCoursePath(
points,
pairEdges,
List.copyOf(edges),
outlet,
true,
routing.organicSurfaceRequired(),
null
);
}
List<HydrologyPoint> surfaceCourseCenterline(
HydrologySampledGrid grid,
List<HydrologyPoint> guide,
long geometryId
) {
if (guide.size() < 2) {
return List.of();
}
int refinement = planner.settings.routing().refinementSpacing();
List<SurfaceCurvePoint> base = smoothSurfaceGuide(resampleSurfaceGuide(guide, refinement));
List<SurfaceCurvePoint> nominal = meanderSurfaceGuide(base, geometryId, refinement);
if (nominal.size() < 2) {
return List.of();
}
ArrayList<List<RouteCandidate>> layers = new ArrayList<>(nominal.size());
int transverseCandidates = HydrologyRouteGeometry.SURFACE_COURSE_TRANSVERSE_CANDIDATES;
for (int pointIndex = 0; pointIndex < nominal.size(); pointIndex++) {
SurfaceCurvePoint point = nominal.get(pointIndex);
RouteDirection tangent = surfaceCurveTangent(nominal, pointIndex);
if (pointIndex == 0 || pointIndex == nominal.size() - 1) {
HydrologyPoint endpoint = pointIndex == 0 ? guide.getFirst() : guide.getLast();
layers.add(List.of(planner.routeGeometry.routeEndpoint(endpoint, tangent)));
continue;
}
double progress = pointIndex / (double) (nominal.size() - 1);
SurfaceCurvePoint basePoint = base.get(pointIndex);
RoutePosition position = new RoutePosition(
point.x(),
point.z(),
basePoint.x(),
basePoint.z(),
tangent
);
long candidateId = HydrologyHash.mix(geometryId, pointIndex);
List<RouteCandidate> candidates = planner.routeGeometry.unresolvedSurfaceRouteCandidates(
grid,
geometryId,
candidateId,
position,
progress,
refinement,
transverseCandidates
);
if (candidates.isEmpty()) {
return List.of();
}
layers.add(candidates);
}
ArrayList<List<RouteCandidate>> availableLayers = new ArrayList<>(layers);
double maximumTurn = Math.min(
HydrologyRouteGeometry.SURFACE_COURSE_MAXIMUM_TURN_DEGREES,
planner.settings.geometry().meanders().maximumTurnDegrees()
);
while (true) {
RouteCandidate[] route = planner.routeGeometry.selectCurvatureAwareTerrainRoute(
availableLayers,
refinement,
maximumTurn,
HydrologyRouteGeometry.SURFACE_COURSE_TURN_COST
);
if (route.length == 0) {
if (addExhaustedSurfaceRouteFallbacks(
grid,
availableLayers,
nominal,
base,
geometryId
)) {
continue;
}
return List.of();
}
int pitRemoval = planner.routePaths.removeTerrainPits(availableLayers, route);
if (pitRemoval < 0) {
return List.of();
}
if (pitRemoval > 0) {
continue;
}
ArrayList<HydrologyPoint> points = new ArrayList<>(route.length);
for (RouteCandidate candidate : route) {
HydrologyPoint point = candidate.point();
if (points.isEmpty()
|| point.x() != points.getLast().x()
|| point.z() != points.getLast().z()) {
points.add(point);
}
}
if (points.size() < 2 || !planner.routePaths.traversableRoute(points)) {
return List.of();
}
return planner.routePaths.smoothTerrainRoute(points);
}
}
boolean addExhaustedSurfaceRouteFallbacks(
HydrologySampledGrid grid,
List<List<RouteCandidate>> layers,
List<SurfaceCurvePoint> nominal,
List<SurfaceCurvePoint> base,
long geometryId
) {
boolean added = false;
for (int layerIndex = 1; layerIndex < layers.size() - 1; layerIndex++) {
List<RouteCandidate> layer = layers.get(layerIndex);
if (!exhaustedSurfaceRouteLayer(layer)) {
continue;
}
SurfaceCurvePoint nominalPoint = nominal.get(layerIndex);
SurfaceCurvePoint basePoint = base.get(layerIndex);
RouteDirection tangent = surfaceCurveTangent(nominal, layerIndex);
int fallbackX = (int) StrictMath.round(basePoint.x());
int fallbackZ = (int) StrictMath.round(basePoint.z());
long packed = RiverFootprint.pack(fallbackX, fallbackZ);
boolean present = false;
for (RouteCandidate candidate : layer) {
if (RiverFootprint.pack(candidate.point().x(), candidate.point().z()) == packed) {
present = true;
break;
}
}
if (present) {
continue;
}
RouteTerrainEstimate estimate = planner.routeGeometry.routeTerrainEstimate(grid, fallbackX, fallbackZ);
double offset = (basePoint.x() - nominalPoint.x()) * -tangent.z()
+ (basePoint.z() - nominalPoint.z()) * tangent.x();
double localScore = estimate.terrainScore()
+ StrictMath.abs(offset) * 0.18D
+ HydrologyHash.unit(HydrologyHash.mix(
planner.worldSeed,
geometryId,
HydrologyHash.mix(geometryId, layerIndex),
fallbackX,
fallbackZ
)) * 1.0E-6D;
ArrayList<RouteCandidate> expanded = new ArrayList<>(layer);
expanded.add(new RouteCandidate(
new HydrologyPoint(fallbackX, estimate.height(), fallbackZ),
basePoint.x(),
basePoint.z(),
offset,
localScore,
estimate.terrainScore(),
tangent,
false
));
layers.set(layerIndex, List.copyOf(expanded));
added = true;
}
return added;
}
boolean exhaustedSurfaceRouteLayer(List<RouteCandidate> layer) {
for (RouteCandidate candidate : layer) {
if (!candidate.terrainResolved() || Double.isFinite(candidate.localScore())) {
return false;
}
}
return true;
} }
double routeTurnScore(List<HydrologyPoint> route) { double routeTurnScore(List<HydrologyPoint> route) {
@@ -652,289 +435,6 @@ final class HydrologySurfaceCoursePlanner {
return percentile + maximum * 0.05D; return percentile + maximum * 0.05D;
} }
double surfaceRouteCandidateBankPenalty(RouteCandidate candidate) {
int maximumBankDistance = surfaceBankDistance(planner.settings.surface().maximumWidth());
HydrologyPoint point = candidate.point();
HydrologyTerrainSample terrain = planner.sampleLandBasis(point.x(), point.z());
if (terrain == null) {
return Double.POSITIVE_INFINITY;
}
if (terrain.slope() >= planner.settings.hydraulics().waterfallMinimumDrop()) {
return 0D;
}
int head = Math.subtractExact(
terrain.naturalHeight(),
planner.settings.surface().banks().sink()
);
return surfaceRouteBankBandPenalty(
point,
candidate.tangent(),
maximumBankDistance,
head,
maximumSurfaceBankRise(terrain)
);
}
int maximumSurfaceBankRise(HydrologyTerrainSample terrain) {
return Math.addExact(
terrain.surfacePolicy().maximumIncision(planner.settings.surface().maximumIncision()),
(int) StrictMath.ceil(planner.settings.surface().banks().minimumBlendWidth() * 0.5D)
);
}
double surfaceRouteBankBandPenalty(
HydrologyPoint point,
RouteDirection tangent,
int maximumDistance,
int head,
int maximumBankRise
) {
int innerDistance = surfaceBankInnerDistance();
double penalty = 0D;
for (int distance = innerDistance; distance <= maximumDistance; distance++) {
penalty += surfaceRouteBankPenalty(point, tangent, distance, head, maximumBankRise);
penalty += surfaceRouteBankPenalty(point, tangent, -distance, head, maximumBankRise);
}
return penalty;
}
double surfaceRouteBankPenalty(
HydrologyPoint point,
RouteDirection tangent,
int signedDistance,
int head,
int maximumBankRise
) {
int bankX = (int) StrictMath.round(point.x() - tangent.z() * signedDistance);
int bankZ = (int) StrictMath.round(point.z() + tangent.x() * signedDistance);
double bankHeight = planner.sampleLandHeight(bankX, bankZ);
if (Double.isNaN(bankHeight)) {
return 1.0E12D;
}
int rise = (int) bankHeight - head;
int shortfall = HydrologyRouteGeometry.SURFACE_MINIMUM_NATURAL_BANK_RISE - rise;
if (shortfall > 0) {
return (double) shortfall * shortfall * 4096D;
}
int excess = Math.max(0, rise - maximumBankRise);
return (double) excess * excess * 128D;
}
/**
* Innermost probed ring, in blocks out from the centerline. It reads {@code surface.maximumWidth()} and
* nothing else - no {@code shoreWidth}, no candidate, no terrain - so raising the shore knob widens the
* probe band instead of sliding it. That is the whole guarantee.
*
* <p>The ring sits one block outside half the nominal maximum width, which is the widest channel the
* width sampler will draw, not the widest channel that gets cut: {@code ChannelProfileBuilder} clamps a
* station to {@code maximumWidth * 2} after the terrain width multiplier, then scales it again by
* {@code banks().springWidthRatio()} over the spring reach and {@code banks().mouthFlareRatio()} through
* the inlet flare. Routing runs before any profile exists, so no ring can promise to clear the channel.
*/
int surfaceBankInnerDistance() {
return (int) StrictMath.ceil(planner.settings.surface().maximumWidth() / 2D) + 1;
}
/**
* Outermost probed ring: where the bank of the widest channel is expected once the shore is cut. It
* gains a ring per block of {@code shoreWidth}, which is what widens the band walked by the penalty.
*/
int surfaceBankDistance(int width) {
int channelRadius = (int) StrictMath.ceil(width / 2D);
return Math.max(
channelRadius + 1,
(int) StrictMath.ceil(
channelRadius * SURFACE_MAXIMUM_WIDTH_SCALE + planner.settings.surface().shoreWidth()
) + 1
);
}
List<SurfaceCurvePoint> resampleSurfaceGuide(List<HydrologyPoint> guide, int spacing) {
double[] cumulative = new double[guide.size()];
for (int pointIndex = 1; pointIndex < guide.size(); pointIndex++) {
HydrologyPoint previous = guide.get(pointIndex - 1);
HydrologyPoint point = guide.get(pointIndex);
cumulative[pointIndex] = cumulative[pointIndex - 1]
+ StrictMath.hypot(point.x() - previous.x(), point.z() - previous.z());
}
double totalLength = cumulative[cumulative.length - 1];
if (totalLength <= 0D) {
return List.of();
}
int capacity = Math.addExact(2, (int) StrictMath.ceil(totalLength / spacing));
ArrayList<SurfaceCurvePoint> sampled = new ArrayList<>(capacity);
int segmentIndex = 0;
for (double distance = 0D; distance < totalLength; distance += spacing) {
while (segmentIndex < guide.size() - 2
&& cumulative[segmentIndex + 1] < distance) {
segmentIndex++;
}
HydrologyPoint start = guide.get(segmentIndex);
HydrologyPoint end = guide.get(segmentIndex + 1);
double segmentLength = cumulative[segmentIndex + 1] - cumulative[segmentIndex];
double progress = segmentLength <= 0D
? 0D
: (distance - cumulative[segmentIndex]) / segmentLength;
sampled.add(new SurfaceCurvePoint(
start.x() + (end.x() - start.x()) * progress,
start.z() + (end.z() - start.z()) * progress,
distance
));
}
HydrologyPoint end = guide.getLast();
sampled.add(new SurfaceCurvePoint(end.x(), end.z(), totalLength));
return List.copyOf(sampled);
}
List<SurfaceCurvePoint> smoothSurfaceGuide(List<SurfaceCurvePoint> guide) {
if (guide.size() < 3) {
return guide;
}
List<SurfaceCurvePoint> current = guide;
int passes = Math.max(2, planner.settings.geometry().meanders().smoothingPasses() + 1);
for (int pass = 0; pass < passes; pass++) {
ArrayList<SurfaceCurvePoint> smoothed = new ArrayList<>(current.size());
smoothed.add(current.getFirst());
for (int pointIndex = 1; pointIndex < current.size() - 1; pointIndex++) {
SurfaceCurvePoint previous = current.get(pointIndex - 1);
SurfaceCurvePoint point = current.get(pointIndex);
SurfaceCurvePoint next = current.get(pointIndex + 1);
smoothed.add(new SurfaceCurvePoint(
previous.x() * 0.25D + point.x() * 0.5D + next.x() * 0.25D,
previous.z() * 0.25D + point.z() * 0.5D + next.z() * 0.25D,
point.distance()
));
}
smoothed.add(current.getLast());
current = List.copyOf(smoothed);
}
return current;
}
List<SurfaceCurvePoint> meanderSurfaceGuide(
List<SurfaceCurvePoint> guide,
long geometryId,
int refinement
) {
HydrologyPlannerSettings.Meanders meanders = planner.settings.geometry().meanders();
double detailWeight = meanders.detailStrength() * 0.08D;
double totalWeight = meanders.primaryStrength() + detailWeight;
if (guide.size() < 3 || totalWeight <= 0D || meanders.maximumOffsetRatio() <= 0D) {
return guide;
}
double maximumOffset = planner.settings.routing().sampleSpacing() * Math.min(
0.16D,
meanders.maximumOffsetRatio() * 0.32D
);
double primaryPhase = HydrologyHash.unit(HydrologyHash.mix(geometryId, HydrologyRouteGeometry.ROUTE_WORM_PRIMARY_SALT))
* StrictMath.PI * 2D;
double detailPhase = HydrologyHash.unit(HydrologyHash.mix(geometryId, HydrologyRouteGeometry.ROUTE_WORM_DETAIL_SALT))
* StrictMath.PI * 2D;
double totalLength = guide.getLast().distance();
double taperLength = Math.max(
refinement * 8D,
Math.min(planner.settings.routing().sampleSpacing() * 0.75D, meanders.primaryWavelength() * 0.35D)
);
ArrayList<SurfaceCurvePoint> meandered = new ArrayList<>(guide.size());
for (int pointIndex = 0; pointIndex < guide.size(); pointIndex++) {
SurfaceCurvePoint point = guide.get(pointIndex);
RouteDirection tangent = surfaceCurveTangent(guide, pointIndex);
double endpointDistance = Math.min(point.distance(), totalLength - point.distance());
double taperProgress = Math.min(1D, endpointDistance / taperLength);
double taper = StrictMath.pow(StrictMath.sin(taperProgress * StrictMath.PI * 0.5D), 2D);
double primary = StrictMath.sin(
point.distance() * StrictMath.PI * 2D / meanders.primaryWavelength() + primaryPhase
) * meanders.primaryStrength();
double detail = StrictMath.sin(
point.distance() * StrictMath.PI * 2D / meanders.detailWavelength() + detailPhase
) * detailWeight;
double offset = maximumOffset * taper * (primary + detail) / totalWeight;
meandered.add(new SurfaceCurvePoint(
point.x() - tangent.z() * offset,
point.z() + tangent.x() * offset,
point.distance()
));
}
return List.copyOf(meandered);
}
RouteDirection surfaceCurveTangent(List<SurfaceCurvePoint> points, int pointIndex) {
SurfaceCurvePoint start = points.get(Math.max(0, pointIndex - 2));
SurfaceCurvePoint end = points.get(Math.min(points.size() - 1, pointIndex + 2));
double deltaX = end.x() - start.x();
double deltaZ = end.z() - start.z();
double length = StrictMath.hypot(deltaX, deltaZ);
return length <= 0D
? new RouteDirection(1D, 0D)
: new RouteDirection(deltaX / length, deltaZ / length);
}
List<DrainageEdge> mapCoursePairs(
List<HydrologyPoint> points,
List<HydrologyPoint> guide,
List<DrainageEdge> guideEdges
) {
ArrayList<DrainageEdge> mapped = new ArrayList<>(points.size() - 1);
int minimumGuideIndex = 0;
for (int pointIndex = 0; pointIndex < points.size() - 1; pointIndex++) {
HydrologyPoint first = points.get(pointIndex);
HydrologyPoint second = points.get(pointIndex + 1);
double x = (first.x() + second.x()) * 0.5D;
double z = (first.z() + second.z()) * 0.5D;
int selectedIndex = minimumGuideIndex;
double selectedDistance = Double.POSITIVE_INFINITY;
for (int guideIndex = minimumGuideIndex; guideIndex < guideEdges.size(); guideIndex++) {
double distance = pointToSegmentDistanceSquared(
x,
z,
guide.get(guideIndex),
guide.get(guideIndex + 1)
);
if (distance < selectedDistance) {
selectedIndex = guideIndex;
selectedDistance = distance;
}
}
minimumGuideIndex = selectedIndex;
mapped.add(guideEdges.get(selectedIndex));
}
return List.copyOf(mapped);
}
double pointToSegmentDistanceSquared(
double x,
double z,
HydrologyPoint start,
HydrologyPoint end
) {
double deltaX = end.x() - start.x();
double deltaZ = end.z() - start.z();
double lengthSquared = deltaX * deltaX + deltaZ * deltaZ;
if (lengthSquared == 0D) {
double pointX = x - start.x();
double pointZ = z - start.z();
return pointX * pointX + pointZ * pointZ;
}
double progress = ((x - start.x()) * deltaX + (z - start.z()) * deltaZ) / lengthSquared;
progress = Math.max(0D, Math.min(1D, progress));
double nearestX = start.x() + deltaX * progress;
double nearestZ = start.z() + deltaZ * progress;
double pointX = x - nearestX;
double pointZ = z - nearestZ;
return pointX * pointX + pointZ * pointZ;
}
HydrologyPoint surfaceBankPoint(HydrologyPoint point, RouteDirection tangent, int signedDistance) {
return new HydrologyPoint(
(int) StrictMath.round(point.x() - tangent.z() * signedDistance),
point.y(),
(int) StrictMath.round(point.z() + tangent.x() * signedDistance)
);
}
record SurfaceCurvePoint(double x, double z, double distance) {
}
record SurfaceCourseBuild( record SurfaceCourseBuild(
RiverCourse course, RiverCourse course,
HydrologyCandidateRejection rejection, HydrologyCandidateRejection rejection,
@@ -3,8 +3,8 @@ package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.surface.SurfaceBounds; import art.arcane.iris.generation.hydrology.surface.SurfaceBounds;
import art.arcane.iris.generation.hydrology.surface.SurfaceCenterline; import art.arcane.iris.generation.hydrology.surface.SurfaceCenterline;
import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap; import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap;
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
@@ -14,16 +14,15 @@ public final class HydrologySurfaceDropRaster {
private static final HydrologySurfaceDropRaster EMPTY = new HydrologySurfaceDropRaster(List.of(), Map.of()); private static final HydrologySurfaceDropRaster EMPTY = new HydrologySurfaceDropRaster(List.of(), Map.of());
private final List<HydrologyColumnSample> columns; private final List<HydrologyColumnSample> columns;
private final Map<Long, HydrologyColumnSample> indexed; private final Long2ObjectOpenHashMap<HydrologyColumnSample> indexed;
private final Map<Long, OceanReceiver> oceanReceivers; private final Map<Long, OceanReceiver> oceanReceivers;
private HydrologySurfaceDropRaster(List<HydrologyColumnSample> columns, Map<Long, OceanReceiver> oceanReceivers) { private HydrologySurfaceDropRaster(List<HydrologyColumnSample> columns, Map<Long, OceanReceiver> oceanReceivers) {
this.columns = List.copyOf(columns); this.columns = List.copyOf(columns);
Map<Long, HydrologyColumnSample> indexed = new HashMap<>(columns.size()); this.indexed = new Long2ObjectOpenHashMap<>(columns.size());
for (HydrologyColumnSample sample : columns) { for (HydrologyColumnSample sample : columns) {
indexed.put(RiverFootprint.pack(sample.x(), sample.z()), sample); indexed.put(RiverFootprint.pack(sample.x(), sample.z()), sample);
} }
this.indexed = Map.copyOf(indexed);
this.oceanReceivers = Map.copyOf(oceanReceivers); this.oceanReceivers = Map.copyOf(oceanReceivers);
} }
@@ -65,9 +65,10 @@ public final class HydrologyTileCache implements AutoCloseable {
private final Object publicationLock = new Object(); private final Object publicationLock = new Object();
private final BooleanSupplier waitingForbidden; private final BooleanSupplier waitingForbidden;
private volatile SharedCacheScope sharedCacheScope; private volatile SharedCacheScope sharedCacheScope;
private volatile StudioHydrologyTileStore persistentStore; private volatile PreparedHydrologyTileStore persistentStore;
private volatile boolean neighbourPrefetchEnabled; private volatile boolean neighbourPrefetchEnabled;
private volatile Runnable terrainPreparation; private volatile Runnable terrainPreparation;
private PregenerationScope pregenerationScope;
private int demandBatches; private int demandBatches;
public HydrologyTileCache(HydrologyPlanner planner) { public HydrologyTileCache(HydrologyPlanner planner) {
@@ -338,27 +339,31 @@ public final class HydrologyTileCache implements AutoCloseable {
SharedTileKey sharedKey = sharedCacheScope == null ? null : new SharedTileKey(sharedCacheScope, key); SharedTileKey sharedKey = sharedCacheScope == null ? null : new SharedTileKey(sharedCacheScope, key);
HydrologyTile shared = sharedKey == null ? null : SHARED_TILES.getIfPresent(sharedKey); HydrologyTile shared = sharedKey == null ? null : SHARED_TILES.getIfPresent(sharedKey);
if (shared != null && validSharedTile(shared, sharedKey)) { if (shared != null && validSharedTile(shared, sharedKey)) {
PreparedHydrologyTileStore store = persistentStore;
if (store != null && store.load(key).isEmpty()) {
persistTile(store, shared);
}
planner.reuseResolvedTile(shared); planner.reuseResolvedTile(shared);
IrisLogging.debug("Reused shared Studio hydrology tile %d,%d", key.tileX(), key.tileZ()); IrisLogging.debug("Reused shared prepared hydrology tile %d,%d", key.tileX(), key.tileZ());
return shared; return shared;
} }
if (shared != null) { if (shared != null) {
SHARED_TILES.invalidate(sharedKey); SHARED_TILES.invalidate(sharedKey);
} }
StudioHydrologyTileStore store = persistentStore; PreparedHydrologyTileStore store = persistentStore;
try (HydrologyPlanningAdmission.Permit ignored = HydrologyPlanningAdmission.acquireRoot(closed::get)) { try (HydrologyPlanningAdmission.Permit ignored = HydrologyPlanningAdmission.acquireRoot(closed::get)) {
return planAdmitted(key, planningEpoch, sharedKey, store); return planAdmitted(key, planningEpoch, sharedKey, store);
} }
} }
private HydrologyTile planAdmitted(HydrologyTileKey key, long planningEpoch, private HydrologyTile planAdmitted(HydrologyTileKey key, long planningEpoch,
SharedTileKey sharedKey, StudioHydrologyTileStore store) { SharedTileKey sharedKey, PreparedHydrologyTileStore store) {
if (store != null && persistentStudioKey(key)) { if (store != null) {
HydrologyTile persisted = store.load(key).orElse(null); HydrologyTile persisted = store.load(key).orElse(null);
if (persisted != null) { if (persisted != null) {
SHARED_TILES.put(sharedKey, persisted); SHARED_TILES.put(sharedKey, persisted);
planner.reuseResolvedTile(persisted); planner.reuseResolvedTile(persisted);
IrisLogging.debug("Loaded persisted Studio hydrology tile %d,%d", key.tileX(), key.tileZ()); IrisLogging.debug("Loaded persisted prepared hydrology tile %d,%d", key.tileX(), key.tileZ());
return persisted; return persisted;
} }
} }
@@ -369,18 +374,12 @@ public final class HydrologyTileCache implements AutoCloseable {
if (sharedKey != null && !closed.get() && planningEpoch == cacheEpoch.get()) { if (sharedKey != null && !closed.get() && planningEpoch == cacheEpoch.get()) {
HydrologyTile existing = SHARED_TILES.asMap().putIfAbsent(sharedKey, planned); HydrologyTile existing = SHARED_TILES.asMap().putIfAbsent(sharedKey, planned);
if (existing == null) { if (existing == null) {
IrisLogging.debug("Published shared Studio hydrology tile %d,%d", key.tileX(), key.tileZ()); IrisLogging.debug("Published shared prepared hydrology tile %d,%d", key.tileX(), key.tileZ());
} }
} }
} }
if (store != null && persistentStudioKey(key) && !closed.get() && planningEpoch == cacheEpoch.get()) { if (store != null && !closed.get() && planningEpoch == cacheEpoch.get()) {
try { persistTile(store, planned);
store.save(planned);
IrisLogging.debug("Persisted Studio hydrology tile %d,%d", key.tileX(), key.tileZ());
} catch (IOException failure) {
IrisLogging.reportError("Failed to persist Studio hydrology tile "
+ key.tileX() + "," + key.tileZ() + ".", failure);
}
} }
return planned; return planned;
} catch (RuntimeException failure) { } catch (RuntimeException failure) {
@@ -394,6 +393,15 @@ public final class HydrologyTileCache implements AutoCloseable {
} }
} }
private void persistTile(PreparedHydrologyTileStore store, HydrologyTile tile) {
try {
store.save(tile);
} catch (IOException failure) {
IrisLogging.reportError("Failed to persist prepared hydrology tile "
+ tile.key().tileX() + "," + tile.key().tileZ() + ".", failure);
}
}
private static boolean interrupted(Throwable failure) { private static boolean interrupted(Throwable failure) {
if (Thread.currentThread().isInterrupted()) { if (Thread.currentThread().isInterrupted()) {
return true; return true;
@@ -510,6 +518,9 @@ public final class HydrologyTileCache implements AutoCloseable {
public void clear() { public void clear() {
ArrayList<CompletableFuture<ChunkColumns>> cancelledQueries = new ArrayList<>(); ArrayList<CompletableFuture<ChunkColumns>> cancelledQueries = new ArrayList<>();
synchronized (prefetchQueue) { synchronized (prefetchQueue) {
if (pregenerationScope != null) {
pregenerationScope.close();
}
synchronized (publicationLock) { synchronized (publicationLock) {
cacheEpoch.incrementAndGet(); cacheEpoch.incrementAndGet();
planner.clearOwnerDrafts(); planner.clearOwnerDrafts();
@@ -547,20 +558,29 @@ public final class HydrologyTileCache implements AutoCloseable {
neighbourPrefetchEnabled = enabled; neighbourPrefetchEnabled = enabled;
} }
public void preparePregeneration(int centerBlockX, int centerBlockZ) { public PregenerationScope preparePregeneration(PregenerationArea area) {
Objects.requireNonNull(area, "area");
int tileSize = planner.settings().routing().tileSize(); int tileSize = planner.settings().routing().tileSize();
int minimumBlockX = Math.subtractExact(centerBlockX, tileSize); int publicationRadius = planner.settings().publicationRadius();
int minimumBlockZ = Math.subtractExact(centerBlockZ, tileSize); TileBounds bounds = new TileBounds(
int maximumBlockX = Math.addExact(centerBlockX, tileSize); tileCoordinate(Math.subtractExact(area.minimumBlockX(), publicationRadius), tileSize),
int maximumBlockZ = Math.addExact(centerBlockZ, tileSize); tileCoordinate(Math.subtractExact(area.minimumBlockZ(), publicationRadius), tileSize),
enqueuePrefetchArea(prefetchAreaKeys( tileCoordinate(Math.addExact(area.maximumBlockX(), publicationRadius), tileSize),
minimumBlockX, tileCoordinate(Math.addExact(area.maximumBlockZ(), publicationRadius), tileSize));
minimumBlockZ, List<HydrologyTileKey> keys = prefetchAreaKeys(
maximumBlockX, Math.max(area.minimumBlockX(), (long) area.centerBlockX() - tileSize),
maximumBlockZ, Math.max(area.minimumBlockZ(), (long) area.centerBlockZ() - tileSize),
centerBlockX, Math.min(area.maximumBlockX(), (long) area.centerBlockX() + tileSize),
centerBlockZ Math.min(area.maximumBlockZ(), (long) area.centerBlockZ() + tileSize),
), true); area.centerBlockX(), area.centerBlockZ());
PregenerationScope scope = new PregenerationScope(bounds);
try {
enqueuePrefetchArea(keys, scope);
return scope;
} catch (RuntimeException | Error failure) {
scope.close();
throw failure;
}
} }
public void enableSharedCache(SharedCacheScope scope, Path persistentRoot) { public void enableSharedCache(SharedCacheScope scope, Path persistentRoot) {
@@ -570,16 +590,7 @@ public final class HydrologyTileCache implements AutoCloseable {
sharedCacheScope = Objects.requireNonNull(scope, "scope"); sharedCacheScope = Objects.requireNonNull(scope, "scope");
persistentStore = persistentRoot == null persistentStore = persistentRoot == null
? null ? null
: new StudioHydrologyTileStore(persistentRoot, scope, planner.settings().routing().tileSize()); : new PreparedHydrologyTileStore(persistentRoot, scope, planner.settings().routing().tileSize());
}
private boolean persistentStudioKey(HydrologyTileKey key) {
int tileSize = planner.settings().routing().tileSize();
int publicationRadius = planner.settings().publicationRadius();
int minimumTile = Math.subtractExact(tileCoordinate(-(long) publicationRadius, tileSize), 1);
int maximumTile = Math.addExact(tileCoordinate(CHUNK_SIZE - 1L + publicationRadius, tileSize), 1);
return key.tileX() >= minimumTile && key.tileX() <= maximumTile
&& key.tileZ() >= minimumTile && key.tileZ() <= maximumTile;
} }
@Override @Override
@@ -622,6 +633,11 @@ public final class HydrologyTileCache implements AutoCloseable {
.join(); .join();
planning.clear(); planning.clear();
diagnosing.clear(); diagnosing.clear();
synchronized (prefetchQueue) {
if (pregenerationScope != null) {
pregenerationScope.close();
}
}
clear(); clear();
} }
@@ -833,11 +849,11 @@ public final class HydrologyTileCache implements AutoCloseable {
return; return;
} }
enqueuePrefetchArea(prefetchAreaKeys(minimumBlockX, minimumBlockZ, maximumBlockX, maximumBlockZ, enqueuePrefetchArea(prefetchAreaKeys(minimumBlockX, minimumBlockZ, maximumBlockX, maximumBlockZ,
centreBlockX, centreBlockZ), false); centreBlockX, centreBlockZ), null);
} }
private List<HydrologyTileKey> prefetchAreaKeys(int minimumBlockX, int minimumBlockZ, int maximumBlockX, private List<HydrologyTileKey> prefetchAreaKeys(long minimumBlockX, long minimumBlockZ, long maximumBlockX,
int maximumBlockZ, int centreBlockX, int centreBlockZ) { long maximumBlockZ, int centreBlockX, int centreBlockZ) {
int tileSize = planner.settings().routing().tileSize(); int tileSize = planner.settings().routing().tileSize();
int publicationRadius = planner.settings().publicationRadius(); int publicationRadius = planner.settings().publicationRadius();
int minimumTileX = tileCoordinate((long) minimumBlockX - publicationRadius, tileSize); int minimumTileX = tileCoordinate((long) minimumBlockX - publicationRadius, tileSize);
@@ -858,11 +874,20 @@ public final class HydrologyTileCache implements AutoCloseable {
return keys; return keys;
} }
private void enqueuePrefetchArea(List<HydrologyTileKey> keys, boolean discardOutsideArea) { private void enqueuePrefetchArea(List<HydrologyTileKey> keys, PregenerationScope scope) {
Set<HydrologyTileKey> area = discardOutsideArea ? new HashSet<>(keys) : Set.of(); Set<HydrologyTileKey> area = scope != null ? new HashSet<>(keys) : Set.of();
tiles.cleanUp(); tiles.cleanUp();
synchronized (prefetchQueue) { synchronized (prefetchQueue) {
if (discardOutsideArea) { if (closed.get()) {
if (scope != null) {
requireOpen();
}
return;
}
if (scope != null) {
scope.previousNeighbourPrefetch = pregenerationScope == null
? neighbourPrefetchEnabled : pregenerationScope.previousNeighbourPrefetch;
pregenerationScope = scope;
prefetchQueue.removeIf(key -> { prefetchQueue.removeIf(key -> {
if (!area.contains(key)) { if (!area.contains(key)) {
queuedPrefetches.remove(key); queuedPrefetches.remove(key);
@@ -903,6 +928,7 @@ public final class HydrologyTileCache implements AutoCloseable {
private boolean enqueuePrefetch(HydrologyTileKey key) { private boolean enqueuePrefetch(HydrologyTileKey key) {
if (closed.get() if (closed.get()
|| pregenerationScope != null && !pregenerationScope.bounds.contains(key)
|| tiles.getIfPresent(key) != null || tiles.getIfPresent(key) != null
|| planning.containsKey(new CacheLoadKey<>(cacheEpoch.get(), key)) || planning.containsKey(new CacheLoadKey<>(cacheEpoch.get(), key))
|| queuedPrefetches.contains(key) || queuedPrefetches.contains(key)
@@ -1056,7 +1082,8 @@ public final class HydrologyTileCache implements AutoCloseable {
if (!queuedPrefetches.remove(key)) { if (!queuedPrefetches.remove(key)) {
continue; continue;
} }
if (tiles.getIfPresent(key) == null) { if ((pregenerationScope == null || pregenerationScope.bounds.contains(key))
&& tiles.getIfPresent(key) == null) {
return key; return key;
} }
} }
@@ -1322,6 +1349,48 @@ public final class HydrologyTileCache implements AutoCloseable {
} }
} }
public record PregenerationArea(
int centerBlockX, int centerBlockZ,
long minimumBlockX, long minimumBlockZ,
long maximumBlockX, long maximumBlockZ
) {
public PregenerationArea {
if (minimumBlockX > centerBlockX || maximumBlockX < centerBlockX
|| minimumBlockZ > centerBlockZ || maximumBlockZ < centerBlockZ) {
throw new IllegalArgumentException("Pregeneration bounds must contain their center.");
}
}
}
public final class PregenerationScope implements AutoCloseable {
private final TileBounds bounds;
private boolean previousNeighbourPrefetch;
private PregenerationScope(TileBounds bounds) {
this.bounds = bounds;
}
@Override
public void close() {
synchronized (prefetchQueue) {
if (pregenerationScope != this) {
return;
}
pregenerationScope = null;
neighbourPrefetchEnabled = previousNeighbourPrefetch;
prefetchQueue.clear();
queuedPrefetches.clear();
}
}
}
private record TileBounds(int minimumX, int minimumZ, int maximumX, int maximumZ) {
private boolean contains(HydrologyTileKey key) {
return key.tileX() >= minimumX && key.tileX() <= maximumX
&& key.tileZ() >= minimumZ && key.tileZ() <= maximumZ;
}
}
private record ChunkColumns(int chunkX, int chunkZ, HydrologyColumnSample[] columns) { private record ChunkColumns(int chunkX, int chunkZ, HydrologyColumnSample[] columns) {
private long estimatedBytes() { private long estimatedBytes() {
long bytes = 64L + 8L * columns.length; long bytes = 64L + 8L * columns.length;
@@ -492,14 +492,6 @@ final class HydrologyTributaryPlanner {
} }
} }
ArrayList<HydraulicSegment> segments = new ArrayList<>(result.segments()); ArrayList<HydraulicSegment> segments = new ArrayList<>(result.segments());
// Where the two channels touch, the tributary must sit at the lowest stem water it reaches.
double touch = stemWidths.get(stemIndex) + lastWidth(result) + 1D;
for (int index = 0; index < stemStations.size(); index++) {
HydrologyPoint station = stemStations.get(index);
if (station.distanceSquared2D(joinStation) <= touch * touch) {
stemHead = Math.min(stemHead, station.y());
}
}
if (segments.getLast().downstreamHeadY() < stemHead) { if (segments.getLast().downstreamHeadY() < stemHead) {
int shortfall = stemHead - segments.getLast().downstreamHeadY(); int shortfall = stemHead - segments.getLast().downstreamHeadY();
if (shortfall > TRIBUTARY_BACKWATER_LIMIT) { if (shortfall > TRIBUTARY_BACKWATER_LIMIT) {
@@ -10,16 +10,16 @@ import lombok.experimental.Accessors;
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@Accessors(chain = true) @Accessors(chain = true)
@Description("Bounds terrain removal outside the wet river channel.") @Description("Bounds terrain shaping outside the wet river channel.")
public class IrisRiverExcavationConfig { public class IrisRiverExcavationConfig {
@MinNumber(0) @MinNumber(0)
@MaxNumber(64) @MaxNumber(64)
@Description("Maximum blocks removed vertically from a dry bank column. Zero preserves dry bank heights.") @Description("Maximum blocks cut or filled vertically in a dry bank column. Fill is also limited by the local channel incision policy. Zero preserves dry bank heights.")
private int maximumDepth = 8; private int maximumDepth = 8;
@MinNumber(1) @MinNumber(1)
@MaxNumber(64) @MaxNumber(64)
@Description("Maximum horizontal distance from the channel outline affected by shore and bank excavation.") @Description("Maximum horizontal distance from the channel outline affected by shore and bank shaping.")
private int maximumWidth = 16; private int maximumWidth = 16;
@MinNumber(0) @MinNumber(0)
@@ -20,7 +20,7 @@ public class IrisSurfaceRiverBankConfig {
@MinNumber(0.5) @MinNumber(0.5)
@MaxNumber(12) @MaxNumber(12)
@Description("Horizontal run in blocks of eroded bank per block of cut depth.") @Description("Horizontal bank blend in blocks per block of terrain cut or fill.")
private double blendSlope = 3D; private double blendSlope = 3D;
@MinNumber(1) @MinNumber(1)
@@ -33,7 +33,7 @@ public class IrisSurfaceRiverBankConfig {
@Description("Widest eroded band outside the shore, in blocks.") @Description("Widest eroded band outside the shore, in blocks.")
private int maximumBlendWidth = 32; private int maximumBlendWidth = 32;
@Description("Limits dry bank cut depth, width, and excavated volume independently of the wet channel.") @Description("Limits dry bank cut depth, fill height, width, and excavated volume independently of the wet channel. Fill also respects channel.maximumIncision and local river policy.")
private IrisRiverExcavationConfig excavation = new IrisRiverExcavationConfig(); private IrisRiverExcavationConfig excavation = new IrisRiverExcavationConfig();
@Description("Show the biome's deeper layers on eroded banks instead of the surface layer.") @Description("Show the biome's deeper layers on eroded banks instead of the surface layer.")
@@ -24,12 +24,12 @@ public class IrisSurfaceRiverChannelConfig {
@MinNumber(0) @MinNumber(0)
@MaxNumber(3) @MaxNumber(3)
@Description("Blocks the water surface sinks below the lowest natural ground beside the channel; 0 keeps the water flush with the bank, and the bank always meets the water at its own height.") @Description("Blocks the water surface sinks below the shaped bank top; 0 keeps the water flush with its banks.")
private int sink = 0; private int sink = 0;
@MinNumber(1) @MinNumber(1)
@MaxNumber(32) @MaxNumber(32)
@Description("Maximum cut below natural terrain at the channel center before a course is rejected.") @Description("Maximum channel cut below natural terrain. Also caps bank fill, together with banks.excavation.maximumDepth; courses exceeding containment or terrain limits are rejected.")
private int maximumIncision = 10; private int maximumIncision = 10;
@MinNumber(0) @MinNumber(0)
@@ -41,8 +41,8 @@ import java.util.Set;
import java.util.zip.GZIPInputStream; import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream; import java.util.zip.GZIPOutputStream;
final class StudioHydrologyTileStore { final class PreparedHydrologyTileStore {
private static final int SCHEMA_VERSION = 5; private static final int SCHEMA_VERSION = 6;
private static final long MAXIMUM_COMPRESSED_BYTES = 128L * 1024L * 1024L; private static final long MAXIMUM_COMPRESSED_BYTES = 128L * 1024L * 1024L;
private static final long MAXIMUM_DECOMPRESSED_BYTES = 512L * 1024L * 1024L; private static final long MAXIMUM_DECOMPRESSED_BYTES = 512L * 1024L * 1024L;
private static final Gson GSON = new GsonBuilder() private static final Gson GSON = new GsonBuilder()
@@ -51,19 +51,21 @@ final class StudioHydrologyTileStore {
.create(); .create();
private final Path directory; private final Path directory;
private final String scopeIdentity;
private final HydrologyTileCache.SharedCacheScope scope; private final HydrologyTileCache.SharedCacheScope scope;
private final int expectedTileSize; private final int expectedTileSize;
StudioHydrologyTileStore(Path root, HydrologyTileCache.SharedCacheScope scope, int expectedTileSize) { PreparedHydrologyTileStore(Path root, HydrologyTileCache.SharedCacheScope scope, int expectedTileSize) {
this.scope = Objects.requireNonNull(scope, "scope"); this.scope = Objects.requireNonNull(scope, "scope");
if (expectedTileSize < 1) { if (expectedTileSize < 1) {
throw new IllegalArgumentException("Hydrology tile size must be positive."); throw new IllegalArgumentException("Hydrology tile size must be positive.");
} }
this.expectedTileSize = expectedTileSize; this.expectedTileSize = expectedTileSize;
this.scopeIdentity = scopeFingerprint(scope);
this.directory = Objects.requireNonNull(root, "root") this.directory = Objects.requireNonNull(root, "root")
.toAbsolutePath() .toAbsolutePath()
.normalize() .normalize()
.resolve(scopeFingerprint(scope)); .resolve(scopeIdentity);
} }
Optional<HydrologyTile> load(HydrologyTileKey key) { Optional<HydrologyTile> load(HydrologyTileKey key) {
@@ -85,6 +87,7 @@ final class StudioHydrologyTileStore {
PersistedTile persisted = GSON.fromJson(reader, PersistedTile.class); PersistedTile persisted = GSON.fromJson(reader, PersistedTile.class);
if (persisted == null if (persisted == null
|| persisted.schemaVersion() != SCHEMA_VERSION || persisted.schemaVersion() != SCHEMA_VERSION
|| !scopeIdentity.equals(persisted.scopeIdentity())
|| !key.equals(persisted.key()) || !key.equals(persisted.key())
|| persisted.worldSeed() != scope.worldSeed() || persisted.worldSeed() != scope.worldSeed()
|| persisted.settingsFingerprint() != scope.settingsFingerprint()) { || persisted.settingsFingerprint() != scope.settingsFingerprint()) {
@@ -99,7 +102,7 @@ final class StudioHydrologyTileStore {
void save(HydrologyTile tile) throws IOException { void save(HydrologyTile tile) throws IOException {
if (!valid(tile, tile.key())) { if (!valid(tile, tile.key())) {
throw new IOException("Refused to persist a hydrology tile outside the active Studio cache scope."); throw new IOException("Refused to persist a hydrology tile outside the active prepared-plan scope.");
} }
Files.createDirectories(directory); Files.createDirectories(directory);
Path target = file(tile.key()); Path target = file(tile.key());
@@ -109,10 +112,10 @@ final class StudioHydrologyTileStore {
OutputStream buffered = new BufferedOutputStream(raw); OutputStream buffered = new BufferedOutputStream(raw);
OutputStream compressed = new GZIPOutputStream(buffered); OutputStream compressed = new GZIPOutputStream(buffered);
Writer writer = new OutputStreamWriter(compressed, StandardCharsets.UTF_8)) { Writer writer = new OutputStreamWriter(compressed, StandardCharsets.UTF_8)) {
GSON.toJson(PersistedTile.from(tile), writer); GSON.toJson(PersistedTile.from(tile, scopeIdentity), writer);
} }
if (Files.size(staged) > MAXIMUM_COMPRESSED_BYTES) { if (Files.size(staged) > MAXIMUM_COMPRESSED_BYTES) {
throw new IOException("Studio hydrology tile cache entry exceeds the size limit."); throw new IOException("Prepared hydrology tile cache entry exceeds the size limit.");
} }
try { try {
Files.move(staged, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); Files.move(staged, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
@@ -161,6 +164,7 @@ final class StudioHydrologyTileStore {
private record PersistedTile( private record PersistedTile(
int schemaVersion, int schemaVersion,
String scopeIdentity,
HydrologyTileKey key, HydrologyTileKey key,
long worldSeed, long worldSeed,
long settingsFingerprint, long settingsFingerprint,
@@ -174,7 +178,7 @@ final class StudioHydrologyTileStore {
List<HydrologyDiagnosticCandidate> localDiagnosticCandidates, List<HydrologyDiagnosticCandidate> localDiagnosticCandidates,
List<HydrologyColumnSample> columns List<HydrologyColumnSample> columns
) { ) {
private static PersistedTile from(HydrologyTile tile) { private static PersistedTile from(HydrologyTile tile, String scopeIdentity) {
ArrayList<PersistedCourse> courses = new ArrayList<>(tile.courses().size()); ArrayList<PersistedCourse> courses = new ArrayList<>(tile.courses().size());
for (RiverCourse course : tile.courses()) { for (RiverCourse course : tile.courses()) {
courses.add(PersistedCourse.from(course)); courses.add(PersistedCourse.from(course));
@@ -187,6 +191,7 @@ final class StudioHydrologyTileStore {
} }
return new PersistedTile( return new PersistedTile(
SCHEMA_VERSION, SCHEMA_VERSION,
scopeIdentity,
tile.key(), tile.key(),
tile.worldSeed(), tile.worldSeed(),
tile.settingsFingerprint(), tile.settingsFingerprint(),
@@ -375,7 +380,7 @@ final class StudioHydrologyTileStore {
private void advance(long amount) throws IOException { private void advance(long amount) throws IOException {
count += amount; count += amount;
if (count > limit) { if (count > limit) {
throw new IOException("Studio hydrology tile cache entry exceeds the decompressed size limit."); throw new IOException("Prepared hydrology tile cache entry exceeds the decompressed size limit.");
} }
} }
} }
@@ -9,7 +9,6 @@ record RefinedEdgeKey(
int downstreamZ, int downstreamZ,
int continuationX, int continuationX,
int continuationZ, int continuationZ,
int transverseCandidates, int transverseCandidates
boolean surface
) { ) {
} }
@@ -1,4 +0,0 @@
package art.arcane.iris.generation.hydrology;
record RouteTerrainEstimate(int height, double terrainScore) {
}
@@ -1,6 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.List;
record SurfaceRouteKey(List<HydrologyPoint> guide, long geometryId) {
}
@@ -170,8 +170,8 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
cache.prefetchArea(minimumBlockX, minimumBlockZ, maximumBlockX, maximumBlockZ, centreBlockX, centreBlockZ); cache.prefetchArea(minimumBlockX, minimumBlockZ, maximumBlockX, maximumBlockZ, centreBlockX, centreBlockZ);
} }
public void preparePregeneration(int centerBlockX, int centerBlockZ) { public HydrologyTileCache.PregenerationScope preparePregeneration(HydrologyTileCache.PregenerationArea area) {
cache.preparePregeneration(centerBlockX, centerBlockZ); return cache.preparePregeneration(area);
} }
public void setNeighbourPrefetchEnabled(boolean enabled) { public void setNeighbourPrefetchEnabled(boolean enabled) {
@@ -31,15 +31,17 @@ public final class ErosionFieldCompiler {
private final HydrologyPlannerSettings.Surface surface; private final HydrologyPlannerSettings.Surface surface;
private final HydrologyTerrainSampler sampler; private final HydrologyTerrainSampler sampler;
private final int seaLevel; private final int seaLevel;
private final double maximumShoreWidth;
public ErosionFieldCompiler( public ErosionFieldCompiler(
HydrologyPlannerSettings.Surface surface, HydrologyPlannerSettings settings,
HydrologyTerrainSampler sampler, HydrologyTerrainSampler sampler
int seaLevel
) { ) {
this.surface = Objects.requireNonNull(surface, "surface"); Objects.requireNonNull(settings, "settings");
this.surface = settings.surface();
this.sampler = Objects.requireNonNull(sampler, "sampler"); this.sampler = Objects.requireNonNull(sampler, "sampler");
this.seaLevel = seaLevel; this.seaLevel = settings.seaLevel();
this.maximumShoreWidth = Math.max(surface.shoreWidth(), settings.widestShoreBiomeWidth());
} }
public ErosionField compile( public ErosionField compile(
@@ -80,11 +82,12 @@ public final class ErosionFieldCompiler {
SurfaceRasterContext context SurfaceRasterContext context
) { ) {
return compile(courseSeed, centerline, channel, valley, terminal, apronLimit, ponds, context, return compile(courseSeed, centerline, channel, valley, terminal, apronLimit, ponds, context,
prepare(centerline, channel, valley, terminal)); prepare(centerline, channel, valley, terminal, terminal == SurfaceTerminal.OCEAN_MOUTH
? inletStart(valley.exposedStations()) : centerline.size()));
} }
PreparedField prepare(SurfaceCenterline centerline, ChannelProfile channel, ValleyProfile valley, PreparedField prepare(SurfaceCenterline centerline, ChannelProfile channel, ValleyProfile valley,
SurfaceTerminal terminal) { SurfaceTerminal terminal, int inletStart) {
int count = terminal == SurfaceTerminal.OCEAN_MOUTH ? centerline.size() : valley.exposedStations(); int count = terminal == SurfaceTerminal.OCEAN_MOUTH ? centerline.size() : valley.exposedStations();
boolean[] basin = basins(valley, channel, count); boolean[] basin = basins(valley, channel, count);
double[] bendOffsets = SurfaceBendProfile.offsets(centerline, channel); double[] bendOffsets = SurfaceBendProfile.offsets(centerline, channel);
@@ -99,7 +102,7 @@ public final class ErosionFieldCompiler {
radii[station] = (int) StrictMath.ceil(channel.halfWidth()[station] * (1D + surface.banks().roughness()) radii[station] = (int) StrictMath.ceil(channel.halfWidth()[station] * (1D + surface.banks().roughness())
+ Math.max(stationShore + widest, stationBand)) + 1; + Math.max(stationShore + widest, stationBand)) + 1;
} }
return new PreparedField(count, basin, bendOffsets, blendWidth, radii); return new PreparedField(count, basin, bendOffsets, blendWidth, radii, inletStart);
} }
ErosionField compile(long courseSeed, SurfaceCenterline centerline, ChannelProfile channel, ValleyProfile valley, ErosionField compile(long courseSeed, SurfaceCenterline centerline, ChannelProfile channel, ValleyProfile valley,
@@ -157,6 +160,7 @@ public final class ErosionFieldCompiler {
} }
} }
int oceanStart = oceanStart(centerline, valley, terminal); int oceanStart = oceanStart(centerline, valley, terminal);
int inletStart = prepared.inletStart();
Long2ObjectOpenHashMap<SurfaceColumn> columns = new Long2ObjectOpenHashMap<>(nearest.size()); Long2ObjectOpenHashMap<SurfaceColumn> columns = new Long2ObjectOpenHashMap<>(nearest.size());
LongArrayList wetKeys = new LongArrayList(); LongArrayList wetKeys = new LongArrayList();
for (long key : nearest.keySet()) { for (long key : nearest.keySet()) {
@@ -168,7 +172,6 @@ public final class ErosionFieldCompiler {
if (!context.boundary().allows(cellX, cellZ) || context.drops().connects(cellX, cellZ, head)) { if (!context.boundary().allows(cellX, cellZ) || context.drops().connects(cellX, cellZ, head)) {
continue; continue;
} }
HydrologyTerrainSample terrain = sampler.sample(cellX, cellZ);
double halfWidth = channel.halfWidth()[station]; double halfWidth = channel.halfWidth()[station];
double outline = halfWidth * (1D + roughness * SurfaceNoise.signed( double outline = halfWidth * (1D + roughness * SurfaceNoise.signed(
courseSeed ^ OUTLINE_SALT, cellX, cellZ, banks.roughnessWavelength())); courseSeed ^ OUTLINE_SALT, cellX, cellZ, banks.roughnessWavelength()));
@@ -176,6 +179,13 @@ public final class ErosionFieldCompiler {
Math.max(0.5D, channelSettings.outlineMinimumRatio() * halfWidth), Math.max(0.5D, channelSettings.outlineMinimumRatio() * halfWidth),
Math.min(channelSettings.outlineMaximumRatio() * halfWidth, outline)); Math.min(channelSettings.outlineMaximumRatio() * halfWidth, outline));
boolean wet = cellDistance <= outline + 0.25D; boolean wet = cellDistance <= outline + 0.25D;
double bankDistance = cellDistance - outline;
if (!wet && cellDistance > outline + maximumShoreWidth + 0.25D
&& (bankDistance > erosion.excavation().maximumWidth() || !erosion.enabled()
|| bankDistance - maximumShoreWidth >= blendWidth[station][side(centerline, station, cellX, cellZ) < 0D ? 0 : 1])) {
continue;
}
HydrologyTerrainSample terrain = sampler.sample(cellX, cellZ);
boolean mouthLand = terminal == SurfaceTerminal.OCEAN_MOUTH && head == seaLevel boolean mouthLand = terminal == SurfaceTerminal.OCEAN_MOUTH && head == seaLevel
&& SurfaceCellAdmission.mouthLand(terrain, seaLevel); && SurfaceCellAdmission.mouthLand(terrain, seaLevel);
if (!SurfaceCellAdmission.writable(terrain, seaLevel) && !mouthLand) { if (!SurfaceCellAdmission.writable(terrain, seaLevel) && !mouthLand) {
@@ -198,6 +208,10 @@ public final class ErosionFieldCompiler {
+ erosion.bedNoise() * roughness * SurfaceNoise.signed(courseSeed ^ BED_SALT, cellX, cellZ, banks.roughnessWavelength()) + erosion.bedNoise() * roughness * SurfaceNoise.signed(courseSeed ^ BED_SALT, cellX, cellZ, banks.roughnessWavelength())
+ (basin[station] ? flow.plungeBasinDepth() : 0D); + (basin[station] ? flow.plungeBasinDepth() : 0D);
int bed = Math.min(natural, head - Math.max(1, (int) StrictMath.round(local))); int bed = Math.min(natural, head - Math.max(1, (int) StrictMath.round(local)));
int minimumBed = natural - maximumIncision(terrain, station >= inletStart);
if (minimumBed < head) {
bed = Math.max(bed, minimumBed);
}
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.CHANNEL, bed, head, false)); columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.CHANNEL, bed, head, false));
wetKeys.add(key); wetKeys.add(key);
continue; continue;
@@ -212,7 +226,6 @@ public final class ErosionFieldCompiler {
// geometric shore with the bank biome. // geometric shore with the bank biome.
boolean shoreBiome = cellDistance <= outline + terrain.shoreBiomeWidth(shoreWidth) + 0.25D; boolean shoreBiome = cellDistance <= outline + terrain.shoreBiomeWidth(shoreWidth) + 0.25D;
SurfaceRole dryRole = shoreBiome ? SurfaceRole.SHORE : SurfaceRole.BANK; SurfaceRole dryRole = shoreBiome ? SurfaceRole.SHORE : SurfaceRole.BANK;
double bankDistance = cellDistance - outline;
if (bankDistance > erosion.excavation().maximumWidth()) { if (bankDistance > erosion.excavation().maximumWidth()) {
if (shoreBiome) { if (shoreBiome) {
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, natural, natural, false)); columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, natural, natural, false));
@@ -220,14 +233,14 @@ public final class ErosionFieldCompiler {
continue; continue;
} }
if (cellDistance <= outline + shore + 0.25D) { if (cellDistance <= outline + shore + 0.25D) {
int height = Math.min(natural, bankTop + (int) StrictMath.round(shoreRise * benchProgress(cellDistance, outline, shore))); int height = bankTop + (int) StrictMath.round(shoreRise * benchProgress(cellDistance, outline, shore));
height = boundedBankHeight(natural, height, erosion.excavation()); height = boundedBankHeight(terrain, height, erosion.excavation());
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, dryRole, height, height, false)); columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, dryRole, height, height, false));
continue; continue;
} }
int shoreTop = bankTop + (int) StrictMath.round(shoreRise); int shoreTop = bankTop + (int) StrictMath.round(shoreRise);
int cut = natural - shoreTop; int cut = natural - shoreTop;
if (cut <= 0 || !erosion.enabled() || !terrain.erosion()) { if (cut == 0 || !erosion.enabled() || !terrain.erosion()) {
if (shoreBiome) { if (shoreBiome) {
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, natural, natural, false)); columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, natural, natural, false));
} }
@@ -243,8 +256,8 @@ public final class ErosionFieldCompiler {
} }
continue; continue;
} }
int height = Math.min(natural, (int) StrictMath.round(shoreTop + cut * blend(progress, erosion))); int height = (int) StrictMath.round(shoreTop + cut * blend(progress, erosion));
height = boundedBankHeight(natural, height, erosion.excavation()); height = boundedBankHeight(terrain, height, erosion.excavation());
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, dryRole, height, height, false)); columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, dryRole, height, height, false));
} }
if (count > 0) { if (count > 0) {
@@ -255,7 +268,7 @@ public final class ErosionFieldCompiler {
} }
int uncontained = contain(columns, wetKeys, sink, bounds, context.drops()); int uncontained = contain(columns, wetKeys, sink, bounds, context.drops());
connectSteps(columns, wetKeys); connectSteps(columns, wetKeys);
return validate(columns, wetKeys, centerline, terminal, bounds, uncontained); return validate(columns, wetKeys, centerline, terminal, bounds, uncontained, inletStart);
} }
/** /**
@@ -340,6 +353,10 @@ public final class ErosionFieldCompiler {
if (wasWet) { if (wasWet) {
bed = Math.min(bed, existing.height()); bed = Math.min(bed, existing.height());
} }
int minimumBed = natural - maximumIncision(terrain, false);
if (minimumBed < head) {
bed = Math.max(bed, minimumBed);
}
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.CHANNEL, bed, head, false)); columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.CHANNEL, bed, head, false));
if (!wasWet) { if (!wasWet) {
wetKeys.add(key); wetKeys.add(key);
@@ -353,24 +370,24 @@ public final class ErosionFieldCompiler {
continue; continue;
} }
if (distance <= outline + shore + 0.25D) { if (distance <= outline + shore + 0.25D) {
int height = Math.min(natural, bankTop + (int) StrictMath.round(shoreRise * benchProgress(distance, outline, shore))); int height = bankTop + (int) StrictMath.round(shoreRise * benchProgress(distance, outline, shore));
height = boundedBankHeight(natural, height, erosion.excavation()); height = boundedBankHeight(terrain, height, erosion.excavation());
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, height, height, false)); columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, height, height, false));
continue; continue;
} }
int cut = natural - shoreTop; int cut = natural - shoreTop;
if (cut <= 0 || !erosion.enabled() || !erode) { if (cut == 0 || !erosion.enabled() || !erode) {
continue; continue;
} }
double width = Math.max(banks.minimumBlendWidth(), double width = Math.max(banks.minimumBlendWidth(),
Math.min(banks.maximumBlendWidth(), cut * banks.blendSlope() + erosion.blendBaseWidth())); Math.min(banks.maximumBlendWidth(), Math.abs(cut) * banks.blendSlope() + erosion.blendBaseWidth()));
width = Math.min(width, Math.max(0.25D, erosion.excavation().maximumWidth() - shore)); width = Math.min(width, Math.max(0.25D, erosion.excavation().maximumWidth() - shore));
double progress = (distance - outline - shore) / width; double progress = (distance - outline - shore) / width;
if (progress >= 1D) { if (progress >= 1D) {
continue; continue;
} }
int height = Math.min(natural, (int) StrictMath.round(shoreTop + cut * blend(progress, erosion))); int height = (int) StrictMath.round(shoreTop + cut * blend(progress, erosion));
height = boundedBankHeight(natural, height, erosion.excavation()); height = boundedBankHeight(terrain, height, erosion.excavation());
if (existing != null && existing.height() <= height) { if (existing != null && existing.height() <= height) {
continue; continue;
} }
@@ -401,7 +418,8 @@ public final class ErosionFieldCompiler {
int cellX = (int) StrictMath.round(centreX + Math.cos(angle) * ring); int cellX = (int) StrictMath.round(centreX + Math.cos(angle) * ring);
int cellZ = (int) StrictMath.round(centreZ + Math.sin(angle) * ring); int cellZ = (int) StrictMath.round(centreZ + Math.sin(angle) * ring);
HydrologyTerrainSample terrain = sampler.sample(cellX, cellZ); HydrologyTerrainSample terrain = sampler.sample(cellX, cellZ);
if (!SurfaceCellAdmission.writable(terrain, seaLevel) || terrain.naturalHeight() < bankTop) { if (!SurfaceCellAdmission.writable(terrain, seaLevel)
|| (long) terrain.naturalHeight() + maximumFill(terrain) < bankTop) {
return false; return false;
} }
} }
@@ -512,20 +530,17 @@ public final class ErosionFieldCompiler {
int uncontained = 0; int uncontained = 0;
for (int index = 0; index < wetKeys.size(); index++) { for (int index = 0; index < wetKeys.size(); index++) {
SurfaceColumn wet = columns.get(wetKeys.getLong(index)); SurfaceColumn wet = columns.get(wetKeys.getLong(index));
if (bounds != null && !bounds.contains(wet.x(), wet.z())) { boolean owned = bounds == null || bounds.contains(wet.x(), wet.z());
continue; uncontained += lip(columns, wet, CARDINALS, sink, owned, drops);
}
uncontained += lip(columns, wet, CARDINALS, sink, true, drops);
lip(columns, wet, DIAGONALS, sink, false, drops); lip(columns, wet, DIAGONALS, sink, false, drops);
} }
return uncontained; return uncontained;
} }
/** /**
* The bank beside water must reach the natural ground the head was taken from: the water's own * The bank beside water reaches the planned water level plus the sink. Low neighbours may be
* block level plus the sink. A neighbour lower than that is raised to it. The solver takes the head * filled within the local channel and bank terrain limits. Any remaining shortfall below the
* from the banks it samples at each station. A missed low cell can retain its natural ground, * water head stays uncontained and is counted.
* but cannot be raised above it. A shortfall below the water head stays uncontained and is counted.
*/ */
private int lip( private int lip(
Long2ObjectOpenHashMap<SurfaceColumn> columns, Long2ObjectOpenHashMap<SurfaceColumn> columns,
@@ -558,8 +573,7 @@ public final class ErosionFieldCompiler {
} }
int required = wet.headY() + sink; int required = wet.headY() + sink;
if (neighbour.height() < required) { if (neighbour.height() < required) {
int ceiling = neighbour.terrain().naturalHeight(); int raised = boundedBankHeight(neighbour.terrain(), required, surface.banks().erosion().excavation());
int raised = Math.min(ceiling, required);
neighbour = neighbour.withHeight(raised); neighbour = neighbour.withHeight(raised);
columns.put(key, neighbour); columns.put(key, neighbour);
} }
@@ -584,18 +598,51 @@ public final class ErosionFieldCompiler {
return count; return count;
} }
private static int boundedBankHeight(int natural, int target, HydrologyPlannerSettings.Excavation limits) { private int boundedBankHeight(HydrologyTerrainSample terrain, int target, HydrologyPlannerSettings.Excavation limits) {
return Math.max(natural - limits.maximumDepth(), Math.min(natural, target)); int natural = terrain.naturalHeight();
return (int) Math.clamp((long) target, (long) natural - limits.maximumDepth(),
(long) natural + maximumFill(terrain));
}
private int maximumFill(HydrologyTerrainSample terrain) {
int incision = terrain.surfacePolicy().maximumIncision(surface.maximumIncision());
return Math.min(surface.banks().erosion().excavation().maximumDepth(),
Math.min(incision, (int) StrictMath.floor(incision * terrain.incisionMultiplier())));
}
int inletStart(int exposedStations) {
HydrologyPlannerSettings.Inlet inlet = surface.banks().inlet();
int reach = Math.min(inlet.length(), (int) StrictMath.floor(exposedStations * inlet.courseFraction()));
return Math.max(0, exposedStations - reach - reach / 2);
}
private int maximumIncision(HydrologyTerrainSample terrain, boolean inlet) {
int incision = terrain.surfacePolicy().maximumIncision(surface.maximumIncision());
if (inlet) {
incision = Math.max(incision, surface.banks().inlet().maximumIncision());
}
return Math.min(incision, (int) StrictMath.floor(incision * terrain.incisionMultiplier()));
} }
private ErosionField validate(Long2ObjectOpenHashMap<SurfaceColumn> columns, LongArrayList wetKeys, private ErosionField validate(Long2ObjectOpenHashMap<SurfaceColumn> columns, LongArrayList wetKeys,
SurfaceCenterline centerline, SurfaceTerminal terminal, SurfaceBounds bounds, int uncontained) { SurfaceCenterline centerline, SurfaceTerminal terminal,
SurfaceBounds bounds, int uncontained, int inletStart) {
long excavation = 0L; long excavation = 0L;
int[] stationVolumes = new int[centerline.size()]; int[] stationVolumes = new int[centerline.size()];
int deepest = 0; int deepest = 0;
for (SurfaceColumn column : columns.values()) { for (SurfaceColumn column : columns.values()) {
if (column.apron() || column.role() == SurfaceRole.CHANNEL if (column.apron() || bounds != null && !bounds.contains(column.x(), column.z())) {
|| bounds != null && !bounds.contains(column.x(), column.z())) { continue;
}
if (column.role() == SurfaceRole.CHANNEL) {
HydrologyTerrainSample terrain = column.terrain();
int incision = maximumIncision(terrain, column.station() >= inletStart);
int cut = terrain.naturalHeight() - column.height();
if (cut > incision) {
return new ErosionField(columns, uncontained,
HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED,
cut, excavation);
}
continue; continue;
} }
int cut = Math.max(0, column.terrain().naturalHeight() - column.height()); int cut = Math.max(0, column.terrain().naturalHeight() - column.height());
@@ -613,14 +660,20 @@ public final class ErosionFieldCompiler {
return new ErosionField(columns, uncontained, HydrologyCandidateRejection.SURFACE_WATER_CONTAINMENT, return new ErosionField(columns, uncontained, HydrologyCandidateRejection.SURFACE_WATER_CONTAINMENT,
uncontained, excavation); uncontained, excavation);
} }
if (bounds == null && terminal == SurfaceTerminal.OCEAN_MOUTH && !connectedToOcean(columns, wetKeys)) { if (bounds == null && terminal == SurfaceTerminal.OCEAN_MOUTH && !connectedToOcean(columns, wetKeys, centerline)) {
return new ErosionField(columns, 0, HydrologyCandidateRejection.SURFACE_MOUTH_DISCONNECTED, 0, excavation); return new ErosionField(columns, 0, HydrologyCandidateRejection.SURFACE_MOUTH_DISCONNECTED, 0, excavation);
} }
return new ErosionField(columns, uncontained, null, 0, excavation); return new ErosionField(columns, uncontained, null, 0, excavation);
} }
private boolean connectedToOcean(Long2ObjectOpenHashMap<SurfaceColumn> columns, LongArrayList wetKeys) { private boolean connectedToOcean(Long2ObjectOpenHashMap<SurfaceColumn> columns, LongArrayList wetKeys,
SurfaceCenterline centerline) {
if (wetKeys.isEmpty()) { if (wetKeys.isEmpty()) {
for (int station = 0; station < centerline.size(); station++) {
if (sampler.receivingWater(centerline.x()[station], centerline.z()[station], seaLevel)) {
return true;
}
}
return false; return false;
} }
long firstKey = wetKeys.getLong(0); long firstKey = wetKeys.getLong(0);
@@ -700,7 +753,7 @@ public final class ErosionFieldCompiler {
int probeX = (int) StrictMath.round(centerline.x()[station] + centerline.normalX(station) * probe * direction); int probeX = (int) StrictMath.round(centerline.x()[station] + centerline.normalX(station) * probe * direction);
int probeZ = (int) StrictMath.round(centerline.z()[station] + centerline.normalZ(station) * probe * direction); int probeZ = (int) StrictMath.round(centerline.z()[station] + centerline.normalZ(station) * probe * direction);
HydrologyTerrainSample terrain = sampler.sample(probeX, probeZ); HydrologyTerrainSample terrain = sampler.sample(probeX, probeZ);
double cut = terrain == null ? 0D : Math.max(0D, terrain.naturalHeight() - bankTop); double cut = terrain == null ? 0D : Math.abs(terrain.naturalHeight() - (double) bankTop);
double width = cut * banks.blendSlope() * channel.bankMultiplier()[station] + erosion.blendBaseWidth(); double width = cut * banks.blendSlope() * channel.bankMultiplier()[station] + erosion.blendBaseWidth();
widths[station][side] = Math.max(banks.minimumBlendWidth(), Math.min(banks.maximumBlendWidth(), width)); widths[station][side] = Math.max(banks.minimumBlendWidth(), Math.min(banks.maximumBlendWidth(), width));
} }
@@ -765,6 +818,7 @@ public final class ErosionFieldCompiler {
return deltaX * centerline.normalX(station) + deltaZ * centerline.normalZ(station); return deltaX * centerline.normalX(station) + deltaZ * centerline.normalZ(station);
} }
record PreparedField(int count, boolean[] basin, double[] bendOffsets, double[][] blendWidth, int[] radii) { record PreparedField(int count, boolean[] basin, double[] bendOffsets, double[][] blendWidth, int[] radii,
int inletStart) {
} }
} }
@@ -0,0 +1,169 @@
package art.arcane.iris.generation.hydrology.surface;
import art.arcane.iris.generation.hydrology.HydrologyCandidateRejection;
import art.arcane.iris.generation.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.generation.hydrology.HydrologyPoint;
import art.arcane.iris.generation.hydrology.HydrologyTerrainSample;
import art.arcane.iris.generation.hydrology.HydrologyTerrainSampler;
import java.util.List;
import java.util.Objects;
final class RiverProfileSolver {
private final HydrologyPlannerSettings.Surface surface;
private final HydrologyTerrainSampler sampler;
private final int seaLevel;
RiverProfileSolver(Options options, HydrologyTerrainSampler sampler) {
this.surface = Objects.requireNonNull(options.surface());
this.sampler = Objects.requireNonNull(sampler);
this.seaLevel = options.seaLevel();
}
ValleyProfile solve(List<HydrologyPoint> path, SurfaceCenterline centerline, ChannelProfile channel,
SurfaceTerminal terminal, int terminalHead, int minimumLength) {
int count = centerline.size();
int[] heads = interpolate(path, centerline);
int[] natural = new int[count];
int[] incision = new int[count];
int[] inletIncision = new int[count];
int exposed = count;
double length = 0D;
for (int station = 0; station < count; station++) {
HydrologyTerrainSample terrain = sampler.sample(centerline.x()[station], centerline.z()[station]);
boolean writable = terminal == SurfaceTerminal.OCEAN_MOUTH
? SurfaceCellAdmission.mouthLand(terrain, seaLevel)
: SurfaceCellAdmission.writable(terrain, seaLevel);
if (!writable) {
exposed = station;
break;
}
if (!terrain.transitAllowed()) {
return ValleyProfile.rejected(HydrologyCandidateRejection.POLICY_EXCLUDED, station);
}
natural[station] = terrain.naturalHeight();
int limit = terrain.surfacePolicy().maximumIncision(surface.maximumIncision());
incision[station] = Math.min(limit, (int) StrictMath.floor(limit * terrain.incisionMultiplier()));
int inletLimit = Math.max(limit, surface.banks().inlet().maximumIncision());
inletIncision[station] = Math.min(inletLimit,
(int) StrictMath.floor(inletLimit * terrain.incisionMultiplier()));
if (station > 0) {
length += StrictMath.hypot((double) centerline.x()[station] - centerline.x()[station - 1],
(double) centerline.z()[station] - centerline.z()[station - 1]);
}
}
if (exposed < 2 || length < minimumLength) {
return ValleyProfile.rejected(HydrologyCandidateRejection.COURSE_TOO_SHORT, (int) StrictMath.floor(length));
}
if (exposed < count && terminal != SurfaceTerminal.OCEAN_MOUTH
&& terminal != SurfaceTerminal.COASTAL_GROTTO) {
return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_EXPOSURE, exposed);
}
int floor = terminal == SurfaceTerminal.OCEAN_MOUTH || terminal == SurfaceTerminal.COASTAL_GROTTO
? Math.max(seaLevel, terminalHead) : terminalHead;
for (int station = 0; station < exposed; station++) {
heads[station] = Math.max(heads[station], floor);
}
if (terminal == SurfaceTerminal.TRIBUTARY) {
gradeTerminal(heads, exposed, terminalHead, surface.banks().cascadeRun());
}
int inletStart = exposed;
if (terminal == SurfaceTerminal.OCEAN_MOUTH) {
inletStart = gradeInlet(heads, natural, inletIncision, channel, exposed);
for (int station = exposed; station < count; station++) {
heads[station] = seaLevel;
}
}
int sourceHead = heads[0];
int downstream = floor;
for (int station = exposed - 1; station >= 0; station--) {
int depth = (int) StrictMath.round(channel.depth()[station]);
int limit = incision[station];
if (station >= inletStart) {
limit = inletIncision[station];
}
int required = Math.max(Math.max(heads[station], downstream), natural[station] - limit + depth);
if (required > sourceHead || terminal == SurfaceTerminal.TRIBUTARY
&& station == exposed - 1 && required > terminalHead) {
return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED,
natural[station] - heads[station] + depth);
}
heads[station] = required;
downstream = required;
}
for (int station = 1; station < exposed; station++) {
int drop = heads[station - 1] - heads[station];
if (drop >= surface.banks().waterfallMinimumDrop()) {
continue;
}
int limit = station - 1 >= inletStart ? inletIncision[station - 1] : incision[station - 1];
int connectedHead = Math.max(heads[station - 1] - 1, natural[station - 1] - limit + 1);
if (terminal == SurfaceTerminal.TRIBUTARY && station == exposed - 1 && connectedHead > terminalHead) {
return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED,
natural[station - 1] - terminalHead + 1);
}
heads[station] = Math.max(heads[station], Math.min(heads[station - 1], connectedHead));
}
return new ValleyProfile(heads, natural, natural, natural, exposed, null, 0);
}
private static int[] interpolate(List<HydrologyPoint> path, SurfaceCenterline centerline) {
int[] heads = new int[centerline.size()];
for (int station = 0; station < heads.length; station++) {
int index = Math.min(centerline.pathIndex()[station], path.size() - 1);
HydrologyPoint start = path.get(index);
HydrologyPoint end = path.get(Math.min(index + 1, path.size() - 1));
double dx = (double) end.x() - start.x();
double dz = (double) end.z() - start.z();
double squared = dx * dx + dz * dz;
double progress = squared == 0D ? 0D : Math.clamp(
((centerline.x()[station] - (double) start.x()) * dx
+ (centerline.z()[station] - (double) start.z()) * dz) / squared, 0D, 1D);
int head = (int) StrictMath.round(start.y() + ((double) end.y() - start.y()) * progress);
heads[station] = station == 0 ? head : Math.min(heads[station - 1], head);
}
return heads;
}
private static void gradeTerminal(int[] heads, int exposed, int terminalHead, int run) {
for (int station = exposed - 1; station >= 0; station--) {
long target = (long) terminalHead + Math.floorDiv(exposed - 1L - station, Math.max(1, run));
if (heads[station] <= target) {
break;
}
heads[station] = (int) Math.min(Integer.MAX_VALUE, target);
}
}
private int gradeInlet(int[] heads, int[] natural, int[] incision, ChannelProfile channel, int exposed) {
HydrologyPlannerSettings.Inlet inlet = surface.banks().inlet();
int reach = Math.min(inlet.length(), (int) StrictMath.floor(exposed * inlet.courseFraction()));
int start = exposed;
while (start > 0 && exposed - start < reach) {
int index = start - 1;
int cut = natural[index] - seaLevel + (int) StrictMath.round(channel.depth()[index]);
if (cut > incision[index]) {
break;
}
heads[index] = seaLevel;
start--;
}
int rampStart = start;
for (int station = start - 1; station >= Math.max(0, start - reach / 2); station--) {
int target = seaLevel + (int) StrictMath.round((start - station) * inlet.rampSlope());
if (heads[station] <= target) {
break;
}
int cut = natural[station] - target + (int) StrictMath.round(channel.depth()[station]);
if (cut > incision[station]) {
break;
}
heads[station] = target;
rampStart = station;
}
return rampStart;
}
record Options(HydrologyPlannerSettings.Surface surface, int seaLevel) {
}
}
@@ -5,6 +5,7 @@ import art.arcane.iris.generation.hydrology.HydraulicSegment;
import art.arcane.iris.generation.hydrology.HydrologyCandidateRejection; import art.arcane.iris.generation.hydrology.HydrologyCandidateRejection;
import art.arcane.iris.generation.hydrology.HydrologyFeatureType; import art.arcane.iris.generation.hydrology.HydrologyFeatureType;
import art.arcane.iris.generation.hydrology.HydrologyGeometrySampler; import art.arcane.iris.generation.hydrology.HydrologyGeometrySampler;
import art.arcane.iris.generation.hydrology.HydrologyHash;
import art.arcane.iris.generation.hydrology.HydrologyPlannerSettings; import art.arcane.iris.generation.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.generation.hydrology.HydrologyPoint; import art.arcane.iris.generation.hydrology.HydrologyPoint;
import art.arcane.iris.generation.hydrology.HydrologyTerrainSample; import art.arcane.iris.generation.hydrology.HydrologyTerrainSample;
@@ -15,6 +16,8 @@ import java.util.List;
import java.util.Objects; import java.util.Objects;
public final class SurfaceCourseBuilder { public final class SurfaceCourseBuilder {
private static final long COASTAL_DROP_SALT = 0x434f41535446414cL;
private final HydrologyPlannerSettings.Surface surface; private final HydrologyPlannerSettings.Surface surface;
private final HydrologyTerrainSampler sampler; private final HydrologyTerrainSampler sampler;
private final HydrologyGeometrySampler geometry; private final HydrologyGeometrySampler geometry;
@@ -45,14 +48,14 @@ public final class SurfaceCourseBuilder {
boolean directOcean = terminal == SurfaceTerminal.OCEAN_MOUTH; boolean directOcean = terminal == SurfaceTerminal.OCEAN_MOUTH;
ChannelProfile channel = new ChannelProfileBuilder(surface, sampler, geometry) ChannelProfile channel = new ChannelProfileBuilder(surface, sampler, geometry)
.build(centerline, profileKey, directOcean); .build(centerline, profileKey, directOcean);
ValleyProfile valley = new ValleyProfileSolver(surface, sampler, seaLevel, minimumCourseLength) ValleyProfile valley = new RiverProfileSolver(new RiverProfileSolver.Options(surface, seaLevel), sampler)
.solve(centerline, channel, terminal, terminalHead); .solve(path, centerline, channel, terminal, terminalHead, minimumCourseLength);
if (!valley.accepted()) { if (!valley.accepted()) {
return SurfaceCourseResult.rejected(valley.rejection(), valley.rejectionDetail()); return SurfaceCourseResult.rejected(valley.rejection(), valley.rejectionDetail());
} }
int exposed = valley.exposedStations(); int exposed = valley.exposedStations();
boolean coastalDrop = directOcean && valley.head()[exposed - 1] > seaLevel; boolean coastalDrop = directOcean && valley.head()[exposed - 1] > seaLevel;
int stations = coastalDrop ? exposed + 1 : exposed; int stations = exposed;
int[] x = new int[stations]; int[] x = new int[stations];
int[] z = new int[stations]; int[] z = new int[stations];
int[] head = new int[stations]; int[] head = new int[stations];
@@ -65,40 +68,52 @@ public final class SurfaceCourseBuilder {
width[station] = channel.halfWidth()[station] * 2D; width[station] = channel.halfWidth()[station] * 2D;
depth[station] = channel.depth()[station]; depth[station] = channel.depth()[station];
} }
if (coastalDrop) { ArrayList<HydraulicSegment> segments = fallingTransitions(SurfaceSegmentLabeler.label(
int last = exposed - 1; worldSeed, courseId, x, z, head, width, depth, surface.banks()), directOcean);
if (exposed < centerline.size()) {
x[exposed] = centerline.x()[exposed];
z[exposed] = centerline.z()[exposed];
} else {
x[exposed] = x[last] + (int) StrictMath.round(centerline.tangentX()[last]);
z[exposed] = z[last] + (int) StrictMath.round(centerline.tangentZ()[last]);
if (x[exposed] == x[last] && z[exposed] == z[last]) {
x[exposed] = x[last] + 1;
}
}
head[exposed] = seaLevel;
width[exposed] = width[last];
depth[exposed] = depth[last];
}
List<HydraulicSegment> segments = fallingTransitions(SurfaceSegmentLabeler.label(
worldSeed, courseId, x, z, head, width, depth, surface.banks()));
if (segments.isEmpty()) { if (segments.isEmpty()) {
return SurfaceCourseResult.rejected(HydrologyCandidateRejection.COURSE_TOO_SHORT, stations); return SurfaceCourseResult.rejected(HydrologyCandidateRejection.COURSE_TOO_SHORT, stations);
} }
if (coastalDrop) {
int last = exposed - 1;
int receiverX;
int receiverZ;
if (exposed < centerline.size()) {
receiverX = centerline.x()[exposed];
receiverZ = centerline.z()[exposed];
} else {
receiverX = x[last] + (int) StrictMath.round(centerline.tangentX()[last]);
receiverZ = z[last] + (int) StrictMath.round(centerline.tangentZ()[last]);
if (receiverX == x[last] && receiverZ == z[last]) {
receiverX = x[last] + 1;
}
}
int drop = head[last] - seaLevel;
HydrologyFeatureType type = drop == 1 ? HydrologyFeatureType.RIFFLE
: drop >= surface.banks().waterfallMinimumDrop()
? HydrologyFeatureType.WATERFALL : HydrologyFeatureType.CASCADE;
boolean receiving = sampler.receivingWater(receiverX, receiverZ, seaLevel);
segments.add(new HydraulicSegment(HydrologyHash.mix(worldSeed, COASTAL_DROP_SALT, courseId),
courseId, type, head[last], seaLevel,
Math.max(1, (int) StrictMath.ceil(width[last])),
Math.max(1, (int) StrictMath.ceil(depth[last])), receiving, receiving,
List.of(new HydrologyPoint(x[last], head[last], z[last]),
new HydrologyPoint(receiverX, seaLevel, receiverZ)),
new HydraulicChannelProfile(new double[]{width[last], width[last]},
new double[]{depth[last], depth[last]})));
}
HydraulicSegment last = segments.getLast(); HydraulicSegment last = segments.getLast();
int lastStation = stations - 1;
return new SurfaceCourseResult( return new SurfaceCourseResult(
segments, segments,
head[lastStation], last.downstreamHeadY(),
Math.max(1, (int) StrictMath.ceil(last.channelProfile().widthAt(last.centerline().size() - 1))), Math.max(1, (int) StrictMath.ceil(last.channelProfile().widthAt(last.centerline().size() - 1))),
Math.max(1, (int) StrictMath.ceil(last.channelProfile().depthAt(last.centerline().size() - 1))), Math.max(1, (int) StrictMath.ceil(last.channelProfile().depthAt(last.centerline().size() - 1))),
new HydrologyPoint(x[lastStation], head[lastStation], z[lastStation]), last.end(),
null, null,
0 0
); );
} }
private List<HydraulicSegment> fallingTransitions(List<HydraulicSegment> labelled) {
private ArrayList<HydraulicSegment> fallingTransitions(List<HydraulicSegment> labelled, boolean directOcean) {
ArrayList<HydraulicSegment> segments = new ArrayList<>(labelled); ArrayList<HydraulicSegment> segments = new ArrayList<>(labelled);
for (int index = 0; index + 1 < segments.size(); index++) { for (int index = 0; index + 1 < segments.size(); index++) {
HydraulicSegment fall = segments.get(index); HydraulicSegment fall = segments.get(index);
@@ -112,6 +127,9 @@ public final class SurfaceCourseBuilder {
continue; continue;
} }
int incision = terrain.surfacePolicy().maximumIncision(surface.maximumIncision()); int incision = terrain.surfacePolicy().maximumIncision(surface.maximumIncision());
if (directOcean && fall.downstreamHeadY() == seaLevel) {
incision = Math.max(incision, surface.banks().inlet().maximumIncision());
}
incision = Math.min(incision, (int) StrictMath.floor(incision * terrain.incisionMultiplier())); incision = Math.min(incision, (int) StrictMath.floor(incision * terrain.incisionMultiplier()));
if (terrain.naturalHeight() - fall.downstreamHeadY() + fall.depth() > incision) { if (terrain.naturalHeight() - fall.downstreamHeadY() + fall.depth() > incision) {
continue; continue;
@@ -142,7 +160,7 @@ public final class SurfaceCourseBuilder {
receiver.fallingFluid(), receiver.receivingPool(), receiver.fallingFluid(), receiver.receivingPool(),
receiver.centerline().subList(1, receiver.centerline().size()), receiverProfile)); receiver.centerline().subList(1, receiver.centerline().size()), receiverProfile));
} }
return List.copyOf(segments); return segments;
} }
} }
@@ -69,12 +69,11 @@ public final class SurfaceFootprintCompiler {
} }
if (prepared.receivingSampler == null) { if (prepared.receivingSampler == null) {
prepared.receivingSampler = HydrologyOceanReceiver.forCourse(settings, sampler, course); prepared.receivingSampler = HydrologyOceanReceiver.forCourse(settings, sampler, course);
prepared.offsets = stationOffsets(course.segments()); prepared.inlet = SurfaceInletRange.forCourse(settings, course, prepared.receivingSampler);
} }
HydrologyTerrainSampler receivingSampler = prepared.receivingSampler; HydrologyTerrainSampler receivingSampler = prepared.receivingSampler;
HydrologySurfaceDropRaster drops = HydrologySurfaceDropRaster.compile(settings, receivingSampler, geometry, course, HydrologySurfaceDropRaster drops = HydrologySurfaceDropRaster.compile(settings, receivingSampler, geometry, course,
bounds == null ? null : bounds.expand(2)); bounds == null ? null : bounds.expand(2));
int[] offsets = prepared.offsets;
ArrayList<SurfaceLayerColumn> columns = new ArrayList<>(); ArrayList<SurfaceLayerColumn> columns = new ArrayList<>();
int uncontained = 0; int uncontained = 0;
long excavation = 0L; long excavation = 0L;
@@ -90,7 +89,7 @@ public final class SurfaceFootprintCompiler {
while (after < course.segments().size() && exposedSegment(course.segments().get(after))) { while (after < course.segments().size() && exposedSegment(course.segments().get(after))) {
after++; after++;
} }
SurfaceFootprint run = compileRun(prepared, first, after, offsets[first], bounds, drops, pool, receivingSampler); SurfaceFootprint run = compileRun(prepared, first, after, prepared.inlet.offset(first), bounds, drops, pool, receivingSampler);
columns.addAll(run.columns()); columns.addAll(run.columns());
uncontained += run.uncontainedWetCells(); uncontained += run.uncontainedWetCells();
excavation += run.bankExcavation(); excavation += run.bankExcavation();
@@ -123,7 +122,7 @@ public final class SurfaceFootprintCompiler {
} }
private PreparedRun prepareRun(RiverCourse course, int first, int after, boolean pool, private PreparedRun prepareRun(RiverCourse course, int first, int after, boolean pool,
HydrologyTerrainSampler receivingSampler) { HydrologyTerrainSampler receivingSampler, SurfaceInletRange inlet) {
List<HydraulicSegment> exposed = course.segments().subList(first, after); List<HydraulicSegment> exposed = course.segments().subList(first, after);
Stations stations = stations(exposed); Stations stations = stations(exposed);
boolean fallingEnd = after < course.segments().size() && course.segments().get(after).fallingFluid(); boolean fallingEnd = after < course.segments().size() && course.segments().get(after).fallingFluid();
@@ -141,9 +140,9 @@ public final class SurfaceFootprintCompiler {
: new HydrologyPlannerSettings.Ponds(first == 0 ? settings.surface().banks().ponds().source() : disabled.source(), : new HydrologyPlannerSettings.Ponds(first == 0 ? settings.surface().banks().ponds().source() : disabled.source(),
fallingEnd ? disabled.terminal() : settings.surface().banks().ponds().terminal()); fallingEnd ? disabled.terminal() : settings.surface().banks().ponds().terminal());
SurfaceRunBoundary boundary = runBoundary(course, first, after, centerline, channel); SurfaceRunBoundary boundary = runBoundary(course, first, after, centerline, channel);
ErosionFieldCompiler erosion = new ErosionFieldCompiler(settings.surface(), receivingSampler, settings.seaLevel()); ErosionFieldCompiler erosion = new ErosionFieldCompiler(settings, receivingSampler);
return new PreparedRun(exposed, stations, coastalChannel, centerline, channel, poolBiome, valley, return new PreparedRun(exposed, stations, coastalChannel, centerline, channel, poolBiome, valley,
terminal, ponds, boundary, erosion, erosion.prepare(centerline, channel, valley, terminal)); terminal, ponds, boundary, erosion, erosion.prepare(centerline, channel, valley, terminal, inlet.firstStation(first)));
} }
private SurfaceFootprint compileRun(PreparedCourse prepared, int first, int after, int stationOffset, private SurfaceFootprint compileRun(PreparedCourse prepared, int first, int after, int stationOffset,
@@ -152,7 +151,7 @@ public final class SurfaceFootprintCompiler {
RiverCourse course = prepared.course; RiverCourse course = prepared.course;
PreparedRun run = prepared.runs[first]; PreparedRun run = prepared.runs[first];
if (run == null) { if (run == null) {
run = prepareRun(course, first, after, pool, receivingSampler); run = prepareRun(course, first, after, pool, receivingSampler, prepared.inlet);
prepared.runs[first] = run; prepared.runs[first] = run;
} }
List<HydraulicSegment> exposed = run.exposed(); List<HydraulicSegment> exposed = run.exposed();
@@ -210,26 +209,6 @@ public final class SurfaceFootprintCompiler {
+ settings.surface().banks().maximumBlendWidth() + settings.surface().shoreWidth() + 2D; + settings.surface().banks().maximumBlendWidth() + settings.surface().shoreWidth() + 2D;
} }
private static int[] stationOffsets(List<HydraulicSegment> segments) {
int[] offsets = new int[segments.size()];
int station = 0;
HydrologyPoint previous = null;
for (int segmentIndex = 0; segmentIndex < segments.size(); segmentIndex++) {
List<HydrologyPoint> points = segments.get(segmentIndex).centerline();
for (int pointIndex = 0; pointIndex < points.size(); pointIndex++) {
HydrologyPoint point = points.get(pointIndex);
if (previous != null) {
station += Math.max(Math.abs(point.x() - previous.x()), Math.abs(point.z() - previous.z()));
}
if (pointIndex == 0) {
offsets[segmentIndex] = station;
}
previous = point;
}
}
return offsets;
}
private ChannelProfile acceptedProfile(Stations stations, SurfaceCenterline centerline) { private ChannelProfile acceptedProfile(Stations stations, SurfaceCenterline centerline) {
int count = centerline.size(); int count = centerline.size();
double[] halfWidth = new double[count]; double[] halfWidth = new double[count];
@@ -406,7 +385,7 @@ public final class SurfaceFootprintCompiler {
private final RiverCourse course; private final RiverCourse course;
private final PreparedRun[] runs; private final PreparedRun[] runs;
private HydrologyTerrainSampler receivingSampler; private HydrologyTerrainSampler receivingSampler;
private int[] offsets; private SurfaceInletRange inlet;
private PreparedCourse(SurfaceFootprintCompiler compiler, RiverCourse course) { private PreparedCourse(SurfaceFootprintCompiler compiler, RiverCourse course) {
this.compiler = compiler; this.compiler = compiler;
@@ -0,0 +1,104 @@
package art.arcane.iris.generation.hydrology.surface;
import art.arcane.iris.generation.hydrology.HydraulicSegment;
import art.arcane.iris.generation.hydrology.HydrologyFeatureType;
import art.arcane.iris.generation.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.generation.hydrology.HydrologyPoint;
import art.arcane.iris.generation.hydrology.HydrologyTerrainSampler;
import art.arcane.iris.generation.hydrology.RiverCourse;
import art.arcane.iris.generation.hydrology.RiverCourseType;
import art.arcane.iris.generation.hydrology.RiverFootprint;
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
import it.unimi.dsi.fastutil.longs.LongOpenHashSet;
import java.util.List;
public final class SurfaceInletRange {
private final int[] offsets;
private final int firstStation;
private final Long2ObjectOpenHashMap<LongOpenHashSet> fallingStations;
private SurfaceInletRange(int[] offsets, int firstStation,
Long2ObjectOpenHashMap<LongOpenHashSet> fallingStations) {
this.offsets = offsets;
this.firstStation = firstStation;
this.fallingStations = fallingStations;
}
public static SurfaceInletRange forCourse(HydrologyPlannerSettings settings, RiverCourse course,
HydrologyTerrainSampler receivingSampler) {
List<HydraulicSegment> segments = course.segments();
int[] offsets = offsets(segments);
Long2ObjectOpenHashMap<LongOpenHashSet> fallingStations = new Long2ObjectOpenHashMap<>();
if (course.type() != RiverCourseType.SURFACE || segments.isEmpty()
|| segments.getLast().type() != HydrologyFeatureType.MOUTH) {
return new SurfaceInletRange(offsets, Integer.MAX_VALUE, fallingStations);
}
int exposed = exposedStations(settings.seaLevel(), segments, offsets, receivingSampler);
HydrologyPlannerSettings.Inlet inlet = settings.surface().banks().inlet();
int reach = Math.min(inlet.length(), (int) StrictMath.floor(exposed * inlet.courseFraction()));
int firstStation = Math.max(0, exposed - reach - reach / 2);
for (int index = 0; index < segments.size(); index++) {
HydraulicSegment segment = segments.get(index);
if (!segment.type().isSurface() || !segment.fallingFluid()) {
continue;
}
SurfaceCenterline centerline = SurfaceCenterline.densify(segment.centerline());
int first = Math.max(0, firstStation - offsets[index]);
if (first >= centerline.size()) {
continue;
}
LongOpenHashSet positions = new LongOpenHashSet(centerline.size() - first);
for (int station = first; station < centerline.size(); station++) {
positions.add(RiverFootprint.pack(centerline.x()[station], centerline.z()[station]));
}
fallingStations.put(segment.id(), positions);
}
return new SurfaceInletRange(offsets, firstStation, fallingStations);
}
public int offset(int segmentIndex) {
return offsets[segmentIndex];
}
public int firstStation(int segmentIndex) {
return Math.max(0, firstStation - offsets[segmentIndex]);
}
public boolean allowsFallingStation(long segmentId, int x, int z) {
LongOpenHashSet positions = fallingStations.get(segmentId);
return positions != null && positions.contains(RiverFootprint.pack(x, z));
}
private static int[] offsets(List<HydraulicSegment> segments) {
int[] offsets = new int[segments.size()];
int station = 0;
HydrologyPoint previous = null;
for (int index = 0; index < segments.size(); index++) {
List<HydrologyPoint> points = segments.get(index).centerline();
for (int pointIndex = 0; pointIndex < points.size(); pointIndex++) {
HydrologyPoint point = points.get(pointIndex);
if (previous != null) {
station += Math.max(Math.abs(point.x() - previous.x()), Math.abs(point.z() - previous.z()));
}
if (pointIndex == 0) {
offsets[index] = station;
}
previous = point;
}
}
return offsets;
}
private static int exposedStations(int seaLevel, List<HydraulicSegment> segments, int[] offsets,
HydrologyTerrainSampler receivingSampler) {
int mouth = segments.size() - 1;
SurfaceCenterline centerline = SurfaceCenterline.densify(segments.get(mouth).centerline());
for (int station = centerline.size() - 1; station >= 0; station--) {
if (!receivingSampler.receivingWater(centerline.x()[station], centerline.z()[station], seaLevel)) {
return offsets[mouth] + station + 1;
}
}
return offsets[mouth];
}
}
@@ -121,12 +121,12 @@ public class MantleObjectComponent extends IrisMantleComponent {
@Override @Override
public int getOutputRadius() { public int getOutputRadius() {
return hasCollisionRules() ? 0 : getRadius(); return 0;
} }
@Override @Override
public int getInputRadius() { public int getInputRadius() {
return hasCollisionRules() ? calculateInputRadius(getRadius(), true) : 0; return calculateInputRadius(getRadius(), hasCollisionRules());
} }
@Override @Override
@@ -140,10 +140,6 @@ public class MantleObjectComponent extends IrisMantleComponent {
@Override @Override
public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) { public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) {
if (!hasCollisionRules()) {
generateOrigin(writer, x, z, context);
return;
}
ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, x, z); ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, x, z);
replaySourceChunks( replaySourceChunks(
x, x,
@@ -169,12 +165,14 @@ public class MantleObjectComponent extends IrisMantleComponent {
sourceChunkX, sourceChunkX,
sourceChunkZ sourceChunkZ
); );
replaySourcePredecessors( if (hasCollisionRules()) {
sourceChunkX, replaySourcePredecessors(
sourceChunkZ, sourceChunkX,
getRadius(), sourceChunkZ,
(predecessorX, predecessorZ) -> generateOrigin(scratch, predecessorX, predecessorZ, context) getRadius(),
); (predecessorX, predecessorZ) -> generateOrigin(scratch, predecessorX, predecessorZ, context)
);
}
int checkpoint = scratch.mutationCheckpoint(); int checkpoint = scratch.mutationCheckpoint();
generateOrigin(scratch, sourceChunkX, sourceChunkZ, context); generateOrigin(scratch, sourceChunkX, sourceChunkZ, context);
return scratch.sourcePlanSince(checkpoint); return scratch.sourcePlanSince(checkpoint);
@@ -54,7 +54,9 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
void apply(ObjectSourcePlan plan) { void apply(ObjectSourcePlan plan) {
for (Mutation mutation : plan.mutations()) { for (Mutation mutation : plan.mutations()) {
mutation.apply(this); if (isDestination(mutation.x(), mutation.z())) {
mutation.apply(this);
}
} }
} }
@@ -38,7 +38,6 @@ import art.arcane.iris.generation.mantle.EngineMantle;
import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.pack.value.IrisColor; import art.arcane.iris.pack.value.IrisColor;
import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.terrain.IrisDimensionCarvingEntry;
import art.arcane.iris.generation.terrain.IrisDimensionCarvingResolver; import art.arcane.iris.generation.terrain.IrisDimensionCarvingResolver;
import art.arcane.iris.structure.object.IrisObject; import art.arcane.iris.structure.object.IrisObject;
import art.arcane.iris.structure.object.IrisObjectPlacement; import art.arcane.iris.structure.object.IrisObjectPlacement;
@@ -424,15 +423,7 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer,
IrisDimensionCarvingResolver.State state IrisDimensionCarvingResolver.State state
) { ) {
int worldY = y + getWorld().minHeight(); int worldY = y + getWorld().minHeight();
IrisDimensionCarvingEntry rootCarvingEntry = IrisDimensionCarvingResolver.resolveRootEntry(this, worldY, state); return IrisDimensionCarvingResolver.resolveBiome(this, x, worldY, z, state);
if (rootCarvingEntry != null) {
IrisDimensionCarvingEntry resolvedCarvingEntry = IrisDimensionCarvingResolver.resolveFromRoot(this, rootCarvingEntry, x, z, state);
IrisBiome resolvedCarvingBiome = IrisDimensionCarvingResolver.resolveEntryBiome(this, resolvedCarvingEntry, state);
if (resolvedCarvingBiome != null) {
return resolvedCarvingBiome;
}
}
return null;
} }
private IrisBiome resolveDepthCaveBiome( private IrisBiome resolveDepthCaveBiome(
@@ -79,6 +79,8 @@ final class EngineHotloader {
previousGeneration.mantleStorageDirectory(), previousGeneration.mantleStorageDirectory(),
previousGeneration.runtimeKernel(), previousGeneration.runtimeKernel(),
previousGeneration.transitionPlan()); previousGeneration.transitionPlan());
PreparedHydrologyCacheIdentity cacheIdentity = PreparedHydrologyCacheIdentity.capture(
assembly.target, assembly.runtimeKernel, assembly.transitionPlan, engine.isStudio());
engine.runtimeAssembly.set(assembly); engine.runtimeAssembly.set(assembly);
EngineRuntime next; EngineRuntime next;
try (IrisContext.Scope ignored = IrisContext.open(engine, engine.getGenerationSessions().currentSessionId(), null)) { try (IrisContext.Scope ignored = IrisContext.open(engine, engine.getGenerationSessions().currentSessionId(), null)) {
@@ -86,6 +88,7 @@ final class EngineHotloader {
assembly.dimensionStackContext = assembly.runtimeKernel.createDimensionStackContext(engine); assembly.dimensionStackContext = assembly.runtimeKernel.createDimensionStackContext(engine);
assembly.upperContext = assembly.runtimeKernel.createUpperContext(engine); assembly.upperContext = assembly.runtimeKernel.createUpperContext(engine);
BiomeMaxes biomeMaxes = engine.runtimeBuilder.computeBiomeMaxes(); BiomeMaxes biomeMaxes = engine.runtimeBuilder.computeBiomeMaxes();
EngineRuntimeBuilder.enablePreparedCache(assembly, cacheIdentity, engine.isStudio());
GenerationRuntime nextGeneration = previousGeneration.withComplex( GenerationRuntime nextGeneration = previousGeneration.withComplex(
assembly.cacheId, assembly.cacheId,
assembly.complex, assembly.complex,
@@ -57,13 +57,14 @@ final class EngineRuntimeBuilder {
this.engine = engine; this.engine = engine;
} }
EngineRuntime buildRuntime() { EngineRuntime buildRuntime(PreparedHydrologyCacheIdentity cacheIdentity) {
return buildRuntime( return buildRuntime(
engine.getTarget(), engine.getTarget(),
engine.getInitialMantleStorageDirectory(), engine.getInitialMantleStorageDirectory(),
engine.getInitialKernelVersion(), engine.getInitialKernelVersion(),
engine.getInitialTransitionPlan(), engine.getInitialTransitionPlan(),
null); null,
cacheIdentity);
} }
EngineRuntime buildRuntime(EngineTarget runtimeTarget) { EngineRuntime buildRuntime(EngineTarget runtimeTarget) {
@@ -73,7 +74,9 @@ final class EngineRuntimeBuilder {
active.mantleStorageDirectory(), active.mantleStorageDirectory(),
active.kernelVersion(), active.kernelVersion(),
active.transitionPlan(), active.transitionPlan(),
active.mantle()); active.mantle(),
PreparedHydrologyCacheIdentity.capture(runtimeTarget, selectRuntimeKernel(active.kernelVersion()),
active.transitionPlan(), engine.isStudio()));
} }
private EngineRuntime buildRuntime( private EngineRuntime buildRuntime(
@@ -81,7 +84,8 @@ final class EngineRuntimeBuilder {
Path mantleStorageDirectory, Path mantleStorageDirectory,
GenerationKernelRegistry.Version kernelVersion, GenerationKernelRegistry.Version kernelVersion,
TransitionGenerationPlan transitionPlan, TransitionGenerationPlan transitionPlan,
EngineMantle transferredMantle EngineMantle transferredMantle,
PreparedHydrologyCacheIdentity cacheIdentity
) { ) {
RuntimeAssembly assembly = new RuntimeAssembly( RuntimeAssembly assembly = new RuntimeAssembly(
RuntimeAssembly.nextRuntimeId(), RuntimeAssembly.nextRuntimeId(),
@@ -135,6 +139,7 @@ final class EngineRuntimeBuilder {
} }
IrisLogging.debug("[IrisEngine timing] IrisWorldManager=" + (M.ms() - started) + "ms"); IrisLogging.debug("[IrisEngine timing] IrisWorldManager=" + (M.ms() - started) + "ms");
BiomeMaxes biomeMaxes = computeBiomeMaxes(); BiomeMaxes biomeMaxes = computeBiomeMaxes();
enablePreparedCache(assembly, cacheIdentity, engine.isStudio());
return assembly.freezeRuntime(biomeMaxes); return assembly.freezeRuntime(biomeMaxes);
} catch (Throwable e) { } catch (Throwable e) {
Throwable cleanupFailure = engine.shutdownSequence.closeAssembly(assembly, e); Throwable cleanupFailure = engine.shutdownSequence.closeAssembly(assembly, e);
@@ -167,6 +172,8 @@ final class EngineRuntimeBuilder {
mantleStorageDirectory, mantleStorageDirectory,
selectRuntimeKernel(kernelVersion), selectRuntimeKernel(kernelVersion),
transitionPlan); transitionPlan);
PreparedHydrologyCacheIdentity cacheIdentity = PreparedHydrologyCacheIdentity.capture(
runtimeTarget, assembly.runtimeKernel, transitionPlan, engine.isStudio());
engine.runtimeAssembly.set(assembly); engine.runtimeAssembly.set(assembly);
try (IrisContext.Scope ignored = IrisContext.open(engine, engine.getGenerationSessions().currentSessionId(), null)) { try (IrisContext.Scope ignored = IrisContext.open(engine, engine.getGenerationSessions().currentSessionId(), null)) {
IrisLogging.debug("Setup Detached Generation Runtime " + assembly.cacheId); IrisLogging.debug("Setup Detached Generation Runtime " + assembly.cacheId);
@@ -184,7 +191,9 @@ final class EngineRuntimeBuilder {
assembly.mode, assembly.mode,
assembly.dimensionStackContext assembly.dimensionStackContext
); );
return assembly.freezeGeneration(computeBiomeMaxes()); BiomeMaxes biomeMaxes = computeBiomeMaxes();
enablePreparedCache(assembly, cacheIdentity, engine.isStudio());
return assembly.freezeGeneration(biomeMaxes);
} catch (Throwable e) { } catch (Throwable e) {
Throwable cleanupFailure = engine.shutdownSequence.closeAssembly(assembly, e); Throwable cleanupFailure = engine.shutdownSequence.closeAssembly(assembly, e);
if (cleanupFailure != e) { if (cleanupFailure != e) {
@@ -196,6 +205,12 @@ final class EngineRuntimeBuilder {
} }
} }
static void enablePreparedCache(RuntimeAssembly assembly, PreparedHydrologyCacheIdentity identity, boolean studio) {
if (identity != null) {
identity.enableIfUnchanged(assembly.target, assembly.runtimeKernel, assembly.transitionPlan, assembly.complex, studio);
}
}
void publishRuntime(EngineRuntime next, EngineRuntime previous) { void publishRuntime(EngineRuntime next, EngineRuntime previous) {
Throwable retirementFailure = engine.shutdownSequence.closeRuntime( Throwable retirementFailure = engine.shutdownSequence.closeRuntime(
previous, previous,
@@ -560,7 +560,7 @@ public class IrisComplex implements DataProvider {
hydrologyNoiseCaches = List.of(); hydrologyNoiseCaches = List.of();
} }
void enableStudioHydrologyCache(String runtimeIdentity, Path persistentRoot) { void enablePreparedHydrologyCache(String runtimeIdentity, Path persistentRoot) {
if (hydrologyRuntime != null) { if (hydrologyRuntime != null) {
hydrologyRuntime.enableSharedCache(runtimeIdentity, persistentRoot); hydrologyRuntime.enableSharedCache(runtimeIdentity, persistentRoot);
} }
@@ -1934,6 +1934,7 @@ public class IrisComplex implements DataProvider {
private Engine engine; private Engine engine;
private IrisGenerator[] generators; private IrisGenerator[] generators;
private IdentityHashMap<IrisBiome, GeneratorBounds> cachedBounds; private IdentityHashMap<IrisBiome, GeneratorBounds> cachedBounds;
private boolean integerCoordinates;
private boolean inUse; private boolean inUse;
private boolean isInUse() { private boolean isInUse() {
@@ -1945,6 +1946,7 @@ public class IrisComplex implements DataProvider {
this.engine = engine; this.engine = engine;
this.generators = generators; this.generators = generators;
this.cachedBounds = cachedBounds; this.cachedBounds = cachedBounds;
this.integerCoordinates = complex.baseBiomeStream instanceof CachedStream2D<?>;
this.inUse = true; this.inUse = true;
if (sampleSource.get() != complex.baseBiomeStream) { if (sampleSource.get() != complex.baseBiomeStream) {
sampleCache.clear(); sampleCache.clear();
@@ -1967,10 +1969,12 @@ public class IrisComplex implements DataProvider {
@Override @Override
public NoiseBounds noise(double xx, double zz) { public NoiseBounds noise(double xx, double zz) {
try { try {
IrisBiome bx = sampleCache.get(xx, zz); double sampleX = integerCoordinates ? (int) xx : xx;
double sampleZ = integerCoordinates ? (int) zz : zz;
IrisBiome bx = sampleCache.get(sampleX, sampleZ);
if (bx == null) { if (bx == null) {
bx = complex.baseBiomeStream.get(xx, zz); bx = complex.baseBiomeStream.get(xx, zz);
sampleCache.put(xx, zz, bx); sampleCache.put(sampleX, sampleZ, bx);
} }
GeneratorBounds bounds = complex.resolveGeneratorBounds(engine, generators, bx, cachedBounds, localBounds); GeneratorBounds bounds = complex.resolveGeneratorBounds(engine, generators, bx, cachedBounds, localBounds);
@@ -35,7 +35,6 @@ import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.terrain.IrisDimensionCarvingResolver; import art.arcane.iris.generation.terrain.IrisDimensionCarvingResolver;
import art.arcane.iris.generation.terrain.IrisRegion; import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.iris.pack.PackValidationCache;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import com.google.common.util.concurrent.AtomicDouble; import com.google.common.util.concurrent.AtomicDouble;
import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisPlatforms;
@@ -303,7 +302,8 @@ public class IrisEngine implements Engine {
getData().registerEngine(this); getData().registerEngine(this);
_t0 = M.ms(); _t0 = M.ms();
long phaseStartedAt = System.nanoTime(); long phaseStartedAt = System.nanoTime();
String studioCacheIdentity = currentStudioCacheIdentity(); PreparedHydrologyCacheIdentity cacheIdentity = PreparedHydrologyCacheIdentity.capture(
getTarget(), EngineRuntimeBuilder.selectRuntimeKernel(initialKernelVersion), initialTransitionPlan, studio);
getData().loadPrefetch(this); getData().loadPrefetch(this);
IrisLogging.debug("[IrisEngine timing] loadPrefetch=" + (M.ms() - _t0) + "ms"); IrisLogging.debug("[IrisEngine timing] loadPrefetch=" + (M.ms() - _t0) + "ms");
diagnostics.logStudioInitializationPhase("load_prefetch", phaseStartedAt, false); diagnostics.logStudioInitializationPhase("load_prefetch", phaseStartedAt, false);
@@ -315,8 +315,7 @@ public class IrisEngine implements Engine {
IrisLogging.notice("Engine init: " + requiredTarget.getWorld().name() + "/" + requiredTarget.getDimension().getLoadKey() + " seed=" + getSeedManager().getSeed()); IrisLogging.notice("Engine init: " + requiredTarget.getWorld().name() + "/" + requiredTarget.getDimension().getLoadKey() + " seed=" + getSeedManager().getSeed());
_t0 = M.ms(); _t0 = M.ms();
phaseStartedAt = System.nanoTime(); phaseStartedAt = System.nanoTime();
EngineRuntime initialRuntime = runtimeBuilder.buildRuntime(); EngineRuntime initialRuntime = runtimeBuilder.buildRuntime(cacheIdentity);
enableStableStudioCache(initialRuntime, studioCacheIdentity);
runtimeBuilder.publishRuntime(initialRuntime, null); runtimeBuilder.publishRuntime(initialRuntime, null);
IrisLogging.debug("[IrisEngine timing] setupEngine total=" + (M.ms() - _t0) + "ms"); IrisLogging.debug("[IrisEngine timing] setupEngine total=" + (M.ms() - _t0) + "ms");
diagnostics.logPackCompatSummary(); diagnostics.logPackCompatSummary();
@@ -359,47 +358,6 @@ public class IrisEngine implements Engine {
} }
} }
private String currentStudioCacheIdentity() {
if (initializationMode != InitializationMode.STUDIO || !IrisPlatforms.isBound()) {
return "";
}
try {
String contentFingerprint = PackValidationCache.contentFingerprint(
IrisPlatforms.get().packsFolderNoCreate());
String contextFingerprint = PackValidationCache.contextFingerprint();
if (contentFingerprint.isBlank() || contextFingerprint.isBlank()) {
return "";
}
return contentFingerprint + contextFingerprint;
} catch (RuntimeException failure) {
IrisLogging.warn("Studio runtime cache fingerprint failed: " + failure.getMessage());
return "";
}
}
private void enableStableStudioCache(EngineRuntime runtime, String initialIdentity) {
if (initialIdentity.isBlank()) {
return;
}
String finalIdentity = currentStudioCacheIdentity();
if (!initialIdentity.equals(finalIdentity)) {
IrisLogging.warn("Studio packs changed during runtime compilation; shared generation caches are disabled.");
return;
}
runtime.generation().complex().enableStudioHydrologyCache(initialIdentity, studioHydrologyCacheRoot());
IrisLogging.debug("Enabled shared Studio hydrology cache identity="
+ initialIdentity.substring(0, Math.min(12, initialIdentity.length())));
}
private Path studioHydrologyCacheRoot() {
Path packsRoot = IrisPlatforms.get().packsFolderNoCreate().toPath().toAbsolutePath().normalize();
Path packRoot = packsRoot.resolve(getDimension().getLoadKey()).normalize();
if (!packRoot.startsWith(packsRoot)) {
throw new IllegalStateException("Studio dimension key resolves outside the Iris packs folder.");
}
return packRoot.resolve(".iris").resolve("studio-hydrology");
}
public void startEntryHydrology(int blockX, int blockZ) { public void startEntryHydrology(int blockX, int blockZ) {
GenerationRuntimeBinding binding = getActiveGenerationRuntimeBinding(); GenerationRuntimeBinding binding = getActiveGenerationRuntimeBinding();
if (!backgroundTasks.scheduleTrackedTask(() -> prepareEntryHydrology(binding, blockX, blockZ))) { if (!backgroundTasks.scheduleTrackedTask(() -> prepareEntryHydrology(binding, blockX, blockZ))) {
@@ -0,0 +1,118 @@
package art.arcane.iris.generation.runtime;
import art.arcane.iris.pack.PackValidationCache;
import art.arcane.iris.pack.PackDirectoryResolver;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.world.history.GenerationKernelRegistry;
import art.arcane.iris.world.history.GenerationPackFingerprint;
import art.arcane.iris.world.history.TransitionGenerationPlan;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
record PreparedHydrologyCacheIdentity(String fingerprint) {
static PreparedHydrologyCacheIdentity capture(
EngineTarget target,
GenerationKernelRegistry.RuntimeKernel kernel,
TransitionGenerationPlan transition,
boolean studio
) {
if (!IrisPlatforms.isBound()) {
return null;
}
try {
String context = PackValidationCache.contextFingerprint();
if (context.isBlank()) {
return null;
}
String content = GenerationPackFingerprint.compute(
target.getData().getDataFolder().toPath(), GenerationPackFingerprint.CURRENT_VERSION);
String dependencies = target.getData().hasGenerationRegistryContract() ? "closed"
: dependencyFingerprint(IrisPlatforms.get().packsFolderNoCreate());
if (dependencies.isBlank()) {
return null;
}
int buffetSize = studio && "bukkit".equals(IrisPlatforms.get().platformName())
? target.getDimension().getStudioMode().biomeSizeChunks() : 0;
return fromInputs(content, dependencies, context, kernel, transition == null ? null : transition.specification(),
target.getWorld().minHeight(), target.getWorld().maxHeight(), buffetSize);
} catch (IOException | RuntimeException failure) {
IrisLogging.reportError("Prepared hydrology cache identity could not be captured.", failure);
return null;
}
}
static PreparedHydrologyCacheIdentity fromInputs(
String packContent,
String dependencies,
String platformContext,
GenerationKernelRegistry.RuntimeKernel kernel,
TransitionGenerationPlan.Specification transition,
int minimumHeight,
int maximumHeight,
int biomeBuffetSize
) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
update(digest, packContent);
update(digest, dependencies);
update(digest, platformContext);
update(digest, kernel.version().toString());
update(digest, kernel.implementationFingerprint());
update(digest, Integer.toString(minimumHeight));
update(digest, Integer.toString(maximumHeight));
update(digest, Integer.toString(biomeBuffetSize));
update(digest, Boolean.toString(transition != null));
if (transition != null) {
update(digest, Long.toString(transition.activationId()));
update(digest, transition.oldEpochId());
update(digest, transition.newEpochId());
update(digest, Integer.toString(transition.algorithmVersion()));
update(digest, Integer.toString(transition.widthBlocks()));
update(digest, transition.boundaryIdentity());
update(digest, transition.terrainSignatureIdentity());
}
return new PreparedHydrologyCacheIdentity(HexFormat.of().formatHex(digest.digest()));
} catch (NoSuchAlgorithmException failure) {
throw new IllegalStateException("SHA-256 is unavailable.", failure);
}
}
void enableIfUnchanged(EngineTarget target, GenerationKernelRegistry.RuntimeKernel kernel,
TransitionGenerationPlan transition, IrisComplex complex, boolean studio) {
if (!equals(capture(target, kernel, transition, studio))) {
IrisLogging.warn("Generation inputs changed during compilation; prepared hydrology caching is disabled.");
return;
}
complex.enablePreparedHydrologyCache(fingerprint,
IrisPlatforms.get().dataFolderNoCreate("cache", "hydrology-plans").toPath());
}
private static void update(MessageDigest digest, String value) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
digest.update((byte) (bytes.length >>> 24));
digest.update((byte) (bytes.length >>> 16));
digest.update((byte) (bytes.length >>> 8));
digest.update((byte) bytes.length);
digest.update(bytes);
}
private static String dependencyFingerprint(File packsRoot) throws IOException {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
for (File pack : PackDirectoryResolver.listVisiblePackDirectoriesOrThrow(packsRoot)) {
update(digest, pack.getName());
update(digest, GenerationPackFingerprint.compute(
pack.toPath().toRealPath(), GenerationPackFingerprint.CURRENT_VERSION));
}
return HexFormat.of().formatHex(digest.digest());
} catch (NoSuchAlgorithmException failure) {
throw new IllegalStateException("SHA-256 is unavailable.", failure);
}
}
}
@@ -151,7 +151,8 @@ public final class HydrologyBankTerrainRuntime {
return new BankColumn(flat, flat); return new BankColumn(flat, flat);
} }
if (base >= natural.topY()) { if (base >= natural.topY()) {
return new BankColumn(natural, natural); Terrain3DColumn filled = Terrain3DColumn.unshaped(base, options.height());
return new BankColumn(filled, filled);
} }
double rise = Math.min(amplitude, natural.topY() - base); double rise = Math.min(amplitude, natural.topY() - base);
IntArrayList boundaries = new IntArrayList(natural.spanCount() * 2 + 8); IntArrayList boundaries = new IntArrayList(natural.spanCount() * 2 + 8);
@@ -28,6 +28,17 @@ public final class IrisDimensionCarvingResolver {
} }
public static IrisBiome resolveBiome(Engine engine, int worldX, int worldY, int worldZ, State state) {
State resolvedState = state == null ? threadState() : state;
IrisData data = resolvedState.bind(engine);
IrisDimensionCarvingEntry root = resolveBoundRootEntry(engine, worldY, data, resolvedState);
if (root == null) {
return null;
}
IrisDimensionCarvingEntry resolved = resolveBoundFromRoot(engine, root, worldX, worldZ, data, resolvedState);
return resolved == null ? null : resolveBoundEntryBiome(data, resolved, resolvedState);
}
public static IrisDimensionCarvingEntry resolveRootEntry(Engine engine, int worldY) { public static IrisDimensionCarvingEntry resolveRootEntry(Engine engine, int worldY) {
return resolveRootEntry(engine, worldY, threadState()); return resolveRootEntry(engine, worldY, threadState());
} }
@@ -35,6 +46,36 @@ public final class IrisDimensionCarvingResolver {
public static IrisDimensionCarvingEntry resolveRootEntry(Engine engine, int worldY, State state) { public static IrisDimensionCarvingEntry resolveRootEntry(Engine engine, int worldY, State state) {
State resolvedState = state == null ? threadState() : state; State resolvedState = state == null ? threadState() : state;
IrisData data = resolvedState.bind(engine); IrisData data = resolvedState.bind(engine);
return resolveBoundRootEntry(engine, worldY, data, resolvedState);
}
public static IrisDimensionCarvingEntry resolveFromRoot(Engine engine, IrisDimensionCarvingEntry rootEntry, int worldX, int worldZ) {
return resolveFromRoot(engine, rootEntry, worldX, worldZ, threadState());
}
public static IrisDimensionCarvingEntry resolveFromRoot(Engine engine, IrisDimensionCarvingEntry rootEntry, int worldX, int worldZ, State state) {
State resolvedState = state == null ? threadState() : state;
IrisData data = resolvedState.bind(engine);
return resolveBoundFromRoot(engine, rootEntry, worldX, worldZ, data, resolvedState);
}
public static IrisBiome resolveEntryBiome(Engine engine, IrisDimensionCarvingEntry entry) {
return resolveEntryBiome(engine, entry, null);
}
public static IrisBiome resolveEntryBiome(Engine engine, IrisDimensionCarvingEntry entry, State state) {
if (entry == null) {
return null;
}
if (state == null) {
return entry.getRealBiome(engine.getData());
}
return resolveBoundEntryBiome(state.bind(engine), entry, state);
}
private static IrisDimensionCarvingEntry resolveBoundRootEntry(Engine engine, int worldY, IrisData data, State resolvedState) {
if (resolvedState.rootEntriesByWorldY.containsKey(worldY)) { if (resolvedState.rootEntriesByWorldY.containsKey(worldY)) {
return resolvedState.rootEntriesByWorldY.get(worldY); return resolvedState.rootEntriesByWorldY.get(worldY);
} }
@@ -59,13 +100,7 @@ public final class IrisDimensionCarvingResolver {
return resolved; return resolved;
} }
public static IrisDimensionCarvingEntry resolveFromRoot(Engine engine, IrisDimensionCarvingEntry rootEntry, int worldX, int worldZ) { private static IrisDimensionCarvingEntry resolveBoundFromRoot(Engine engine, IrisDimensionCarvingEntry rootEntry, int worldX, int worldZ, IrisData data, State resolvedState) {
return resolveFromRoot(engine, rootEntry, worldX, worldZ, threadState());
}
public static IrisDimensionCarvingEntry resolveFromRoot(Engine engine, IrisDimensionCarvingEntry rootEntry, int worldX, int worldZ, State state) {
State resolvedState = state == null ? threadState() : state;
IrisData data = resolvedState.bind(engine);
if (rootEntry == null) { if (rootEntry == null) {
return null; return null;
} }
@@ -100,22 +135,6 @@ public final class IrisDimensionCarvingResolver {
return current; return current;
} }
public static IrisBiome resolveEntryBiome(Engine engine, IrisDimensionCarvingEntry entry) {
return resolveEntryBiome(engine, entry, null);
}
public static IrisBiome resolveEntryBiome(Engine engine, IrisDimensionCarvingEntry entry, State state) {
if (entry == null) {
return null;
}
if (state == null) {
return entry.getRealBiome(engine.getData());
}
return resolveBoundEntryBiome(state.bind(engine), entry, state);
}
private static IrisBiome resolveBoundEntryBiome(IrisData data, IrisDimensionCarvingEntry entry, State state) { private static IrisBiome resolveBoundEntryBiome(IrisData data, IrisDimensionCarvingEntry entry, State state) {
if (state.biomeCache.containsKey(entry)) { if (state.biomeCache.containsKey(entry)) {
return state.biomeCache.get(entry); return state.biomeCache.get(entry);
@@ -92,9 +92,10 @@ public final class Terrain3DRuntime {
int gridX = Math.floorDiv(x, STEP) * STEP; int gridX = Math.floorDiv(x, STEP) * STEP;
int gridZ = Math.floorDiv(z, STEP) * STEP; int gridZ = Math.floorDiv(z, STEP) * STEP;
Anchor northWest = anchor(gridX, gridZ); Anchor northWest = anchor(gridX, gridZ);
Anchor northEast = anchor(gridX + STEP, gridZ); Anchor northEast = x == gridX ? northWest : anchor(gridX + STEP, gridZ);
Anchor southWest = anchor(gridX, gridZ + STEP); Anchor southWest = z == gridZ ? northWest : anchor(gridX, gridZ + STEP);
Anchor southEast = anchor(gridX + STEP, gridZ + STEP); Anchor southEast = x == gridX ? southWest
: z == gridZ ? northEast : anchor(gridX + STEP, gridZ + STEP);
double dx = (x - gridX) / (double) STEP; double dx = (x - gridX) / (double) STEP;
double dz = (z - gridZ) / (double) STEP; double dz = (z - gridZ) / (double) STEP;
double amplitude = interpolate(northWest.amplitude(), northEast.amplitude(), double amplitude = interpolate(northWest.amplitude(), northEast.amplitude(),
@@ -49,14 +49,25 @@ public final class PackValidationRegistry {
if (packRoot == null || result == null) { if (packRoot == null || result == null) {
return; return;
} }
publish(normalize(packRoot), new RootValidation(result, "")); publish(normalize(packRoot), new RootValidation(result, "", ""));
} }
public static void publish(Path packRoot, PackValidationResult result, String contentFingerprint) { public static void publish(
Path packRoot,
PackValidationResult result,
String contentFingerprint,
String validatedContextFingerprint
) {
if (packRoot == null || result == null || contentFingerprint == null || contentFingerprint.isBlank()) { if (packRoot == null || result == null || contentFingerprint == null || contentFingerprint.isBlank()) {
return; return;
} }
publish(normalize(packRoot), new RootValidation(result, contentFingerprint)); if (validatedContextFingerprint == null
|| !validatedContextFingerprint.equals(PackValidationCache.contextFingerprint())) {
publish(packRoot, result);
return;
}
publish(normalize(packRoot), new RootValidation(
result, contentFingerprint, validatedContextFingerprint));
} }
public static PackValidationResult publishMatchingCopy( public static PackValidationResult publishMatchingCopy(
@@ -118,7 +129,7 @@ public final class PackValidationRegistry {
return new RootState( return new RootState(
current.generation(), current.generation(),
false, false,
new RootValidation(result, "")); new RootValidation(result, "", ""));
}); });
return published.get(); return published.get();
} }
@@ -140,6 +151,14 @@ public final class PackValidationRegistry {
: state.validation().result(); : state.validation().result();
} }
public static PackValidationResult getMatching(Path packRoot, String contentFingerprint) {
if (packRoot == null || contentFingerprint == null || contentFingerprint.isBlank()) {
return null;
}
RootValidation validation = matchingValidation(packRoot, contentFingerprint);
return validation == null ? null : validation.result();
}
public static PackValidationResult requireLoadable(String packName) { public static PackValidationResult requireLoadable(String packName) {
if (packName == null || packName.isBlank()) { if (packName == null || packName.isBlank()) {
throw new IllegalArgumentException("Pack name is required for validation"); throw new IllegalArgumentException("Pack name is required for validation");
@@ -250,10 +269,11 @@ public final class PackValidationRegistry {
if (sourceState == null if (sourceState == null
|| sourceState.mutating() || sourceState.mutating()
|| sourceState.validation() == null || sourceState.validation() == null
|| !copiedContentFingerprint.equals(sourceState.validation().contentFingerprint())) { || !copiedContentFingerprint.equals(sourceState.validation().contentFingerprint())
|| !sourceState.validation().contextFingerprint().equals(PackValidationCache.contextFingerprint())) {
return null; return null;
} }
return sourceState.validation(); return ROOT_STATES.get(normalizedSource) == sourceState ? sourceState.validation() : null;
} }
private static long nextGeneration(RootState current) { private static long nextGeneration(RootState current) {
@@ -297,6 +317,7 @@ public final class PackValidationRegistry {
requireOpen(); requireOpen();
pendingValidation = new RootValidation( pendingValidation = new RootValidation(
Objects.requireNonNull(result, "Pack validation result"), Objects.requireNonNull(result, "Pack validation result"),
"",
""); "");
} }
@@ -345,7 +366,11 @@ public final class PackValidationRegistry {
} }
} }
private record RootValidation(PackValidationResult result, String contentFingerprint) { private record RootValidation(
PackValidationResult result,
String contentFingerprint,
String contextFingerprint
) {
} }
private record RootState(long generation, boolean mutating, RootValidation validation) { private record RootState(long generation, boolean mutating, RootValidation validation) {
@@ -300,6 +300,10 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
} }
} }
public boolean hasGenerationRegistryContract() {
return generationRegistryContract != null;
}
public synchronized void bindGenerationRegistryContract(GenerationRegistryContract contract) { public synchronized void bindGenerationRegistryContract(GenerationRegistryContract contract) {
GenerationRegistryContract required = Objects.requireNonNull(contract, "contract"); GenerationRegistryContract required = Objects.requireNonNull(contract, "contract");
if (generationRegistryContract != null && !generationRegistryContract.equals(required)) { if (generationRegistryContract != null && !generationRegistryContract.equals(required)) {
@@ -716,6 +716,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
} catch (IOException failure) { } catch (IOException failure) {
throw new IllegalStateException("Unable to load the active Iris generation transition.", failure); throw new IllegalStateException("Unable to load the active Iris generation transition.", failure);
} }
engineTarget.getData().bindGenerationRegistryContract(epoch.registryContract());
IrisEngine createdEngine = new IrisEngine( IrisEngine createdEngine = new IrisEngine(
engineTarget, engineTarget,
mode, mode,
@@ -1319,7 +1320,8 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
openGenerationHistoryCoordinateScope(engine, x << 4, z << 4); openGenerationHistoryCoordinateScope(engine, x << 4, z << 4);
GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_terrain_stage"); GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_terrain_stage");
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
engine.generate(x << 4, z << 4, blocks, biomes, engine.shouldGenerateMulticore()); engine.generate(x << 4, z << 4, blocks, biomes,
initialEntryPending || studioEntryBootstrapActive.get() || engine.shouldGenerateMulticore());
blocks.apply(); blocks.apply();
if (historyScope != null) { if (historyScope != null) {
historyScope.claimGeneratedSemantics(); historyScope.claimGeneratedSemantics();
@@ -362,7 +362,7 @@ public class StudioSVC implements IrisService {
packRoot, packRoot,
GenerationPackFingerprint.CURRENT_VERSION GenerationPackFingerprint.CURRENT_VERSION
); );
validatePublishedPack(packRoot); validateGenerationCandidate(packRoot, fingerprint);
IrisData data = IrisData.openDatapackCompiler(packRoot.toFile()); IrisData data = IrisData.openDatapackCompiler(packRoot.toFile());
try { try {
IrisDimension dimension = data.getDimensionLoader().load(dimensionKey, false); IrisDimension dimension = data.getDimensionLoader().load(dimensionKey, false);
@@ -566,6 +566,17 @@ public class StudioSVC implements IrisService {
} }
} }
static PackValidationResult validateGenerationCandidate(Path packRoot, String contentFingerprint) {
PackValidationResult validation = PackValidationRegistry.getMatching(packRoot, contentFingerprint);
if (validation == null) {
return validatePublishedPack(packRoot);
}
if (!validation.isLoadable()) {
throw new BrokenPackException(packRoot.toString(), validation.getBlockingErrors());
}
return validation;
}
static PackValidationResult validatePublishedPack( static PackValidationResult validatePublishedPack(
Path packRoot, Path packRoot,
Path validatedSource, Path validatedSource,
@@ -29,6 +29,7 @@ import art.arcane.iris.world.IrisToolbelt;
import art.arcane.iris.platform.bukkit.nms.INMS; import art.arcane.iris.platform.bukkit.nms.INMS;
import art.arcane.iris.platform.bukkit.nms.INMSBinding; import art.arcane.iris.platform.bukkit.nms.INMSBinding;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.hydrology.HydrologyTileCache;
import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.volmlib.util.collection.KSet; import art.arcane.volmlib.util.collection.KSet;
import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.mantle.runtime.Mantle;
@@ -69,6 +70,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
private static final long MANTLE_CLEANUP_DRAIN_SECONDS = 60L; private static final long MANTLE_CLEANUP_DRAIN_SECONDS = 60L;
private static final long CHUNK_FLUSH_DRAIN_SECONDS = 30L; private static final long CHUNK_FLUSH_DRAIN_SECONDS = 30L;
private static final long ADMISSION_WAIT_BOUND_MS = 500L; private static final long ADMISSION_WAIT_BOUND_MS = 500L;
private static final int HYDROLOGY_PREFETCH_HALO_BLOCKS = 512;
private final World world; private final World world;
private final IrisRuntimeSchedulerMode runtimeSchedulerMode; private final IrisRuntimeSchedulerMode runtimeSchedulerMode;
private final IrisPaperLikeBackendMode paperLikeBackendMode; private final IrisPaperLikeBackendMode paperLikeBackendMode;
@@ -117,6 +119,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
private final AtomicBoolean holdsWorkerBoost = new AtomicBoolean(); private final AtomicBoolean holdsWorkerBoost = new AtomicBoolean();
private volatile Engine metricsEngine; private volatile Engine metricsEngine;
private volatile Mantle cachedMantle; private volatile Mantle cachedMantle;
private volatile HydrologyTileCache.PregenerationScope hydrologyPrefetchScope;
private final PregenMantleBackpressure backpressure; private final PregenMantleBackpressure backpressure;
public AsyncPregenMethod(World world, int unusedThreads) { public AsyncPregenMethod(World world, int unusedThreads) {
@@ -298,7 +301,18 @@ public class AsyncPregenMethod implements PregeneratorMethod {
if (engine == null || engine.getComplex() == null || engine.getComplex().getHydrologyRuntime() == null) { if (engine == null || engine.getComplex() == null || engine.getComplex().getHydrologyRuntime() == null) {
return; return;
} }
engine.getComplex().getHydrologyRuntime().preparePregeneration(centerBlockX, centerBlockZ); HydrologyTileCache.PregenerationArea area = new HydrologyTileCache.PregenerationArea(
centerBlockX, centerBlockZ,
(long) boundsMinRegionX * 512L - HYDROLOGY_PREFETCH_HALO_BLOCKS,
(long) boundsMinRegionZ * 512L - HYDROLOGY_PREFETCH_HALO_BLOCKS,
((long) boundsMaxRegionX + 1L) * 512L - 1L + HYDROLOGY_PREFETCH_HALO_BLOCKS,
((long) boundsMaxRegionZ + 1L) * 512L - 1L + HYDROLOGY_PREFETCH_HALO_BLOCKS);
HydrologyTileCache.PregenerationScope scope = engine.getComplex().getHydrologyRuntime().preparePregeneration(area);
hydrologyPrefetchScope = scope;
if (closing.get()) {
scope.close();
hydrologyPrefetchScope = null;
}
} }
private boolean inBounds(int rx, int rz) { private boolean inBounds(int rx, int rz) {
@@ -827,6 +841,11 @@ public class AsyncPregenMethod implements PregeneratorMethod {
releaseWorkerThreadBoost(); releaseWorkerThreadBoost();
} }
} finally { } finally {
HydrologyTileCache.PregenerationScope scope = hydrologyPrefetchScope;
if (scope != null) {
scope.close();
hydrologyPrefetchScope = null;
}
if (interrupted) { if (interrupted) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
} }
@@ -196,9 +196,15 @@ public class PregenTask {
public void iterateRegions(Spiraled s) { public void iterateRegions(Spiraled s) {
Bound bound = bounds.region(); Bound bound = bounds.region();
new Spiraler(bound.sizeX, bound.sizeZ, ((x, z) -> { int centerRegionX = PowerOfTwoCoordinates.blockToRegionFloor(center.getX());
if (bound.check(x, z)) s.on(x, z); int centerRegionZ = PowerOfTwoCoordinates.blockToRegionFloor(center.getZ());
})).setOffset(PowerOfTwoCoordinates.blockToRegionFloor(center.getX()), PowerOfTwoCoordinates.blockToRegionFloor(center.getZ())).drain(); int width = Math.max(centerRegionX - bound.minX(), bound.maxX() - centerRegionX) * 2 + 1;
int depth = Math.max(centerRegionZ - bound.minZ(), bound.maxZ() - centerRegionZ) * 2 + 1;
new Spiraler(width, depth, ((x, z) -> {
if (bound.check(x, z)) {
s.on(x, z);
}
})).setOffset(centerRegionX, centerRegionZ).drain();
} }
public void iterateChunks(int rX, int rZ, Spiraled s) { public void iterateChunks(int rX, int rZ, Spiraled s) {
@@ -291,10 +297,10 @@ public class PregenTask {
PowerOfTwoCoordinates.ceilDivPow2(maxZ, PowerOfTwoCoordinates.CHUNK_BITS) PowerOfTwoCoordinates.ceilDivPow2(maxZ, PowerOfTwoCoordinates.CHUNK_BITS)
); );
region = new Bound( region = new Bound(
PowerOfTwoCoordinates.blockToRegionFloor(minX), PowerOfTwoCoordinates.chunkToRegion(chunk.minX()),
PowerOfTwoCoordinates.blockToRegionFloor(minZ), PowerOfTwoCoordinates.chunkToRegion(chunk.minZ()),
PowerOfTwoCoordinates.ceilDivPow2(maxX, PowerOfTwoCoordinates.REGION_BITS), PowerOfTwoCoordinates.chunkToRegion(chunk.maxX()),
PowerOfTwoCoordinates.ceilDivPow2(maxZ, PowerOfTwoCoordinates.REGION_BITS) PowerOfTwoCoordinates.chunkToRegion(chunk.maxZ())
); );
requireSaneSpan(region); requireSaneSpan(region);
} }
@@ -24,7 +24,7 @@ public class HydrologyCacheBudgetTest {
HydrologyCacheBudget budget = HydrologyCacheBudget.forHeap(heap, regionalEnabled); HydrologyCacheBudget budget = HydrologyCacheBudget.forHeap(heap, regionalEnabled);
long total = budget.tileBytes() + budget.columnBytes() + budget.diagnosticBytes() long total = budget.tileBytes() + budget.columnBytes() + budget.diagnosticBytes()
+ budget.routingBytes() + budget.ownerBytes() + budget.edgeBytes() + budget.regionalDraftBytes() + budget.routingBytes() + budget.ownerBytes() + budget.edgeBytes() + budget.regionalDraftBytes()
+ budget.regionalCoarseBytes() + budget.regionalTerrainBytes() + budget.regionalReachBytes(); + budget.regionalCoarseBytes() + budget.regionalTerrainBytes();
assertTrue(total <= Math.clamp(heap / 8L, 8L * MEBIBYTE, 256L * MEBIBYTE)); assertTrue(total <= Math.clamp(heap / 8L, 8L * MEBIBYTE, 256L * MEBIBYTE));
assertTrue(total >= 8L * MEBIBYTE); assertTrue(total >= 8L * MEBIBYTE);
assertEquals(budget.tileBytes() / 6L, budget.columnBytes()); assertEquals(budget.tileBytes() / 6L, budget.columnBytes());
@@ -40,7 +40,7 @@ public class HydrologyCacheBudgetTest {
assertEquals(24L * MEBIBYTE, localOnly.routingBytes()); assertEquals(24L * MEBIBYTE, localOnly.routingBytes());
assertEquals(8L * MEBIBYTE, localOnly.edgeBytes()); assertEquals(8L * MEBIBYTE, localOnly.edgeBytes());
assertEquals(0L, localOnly.regionalDraftBytes() + localOnly.regionalCoarseBytes() assertEquals(0L, localOnly.regionalDraftBytes() + localOnly.regionalCoarseBytes()
+ localOnly.regionalTerrainBytes() + localOnly.regionalReachBytes()); + localOnly.regionalTerrainBytes());
assertThrows(IllegalArgumentException.class, () -> HydrologyCacheBudget.forHeap(0L, false)); assertThrows(IllegalArgumentException.class, () -> HydrologyCacheBudget.forHeap(0L, false));
} }
@@ -112,7 +112,7 @@ public class HydrologyCacheBudgetTest {
HydrologyRegionalGraph.Label second = new HydrologyRegionalGraph.Label( HydrologyRegionalGraph.Label second = new HydrologyRegionalGraph.Label(
new HydrologyRegionalGraph.LabelState(3, 2, 63, 1D, 1D, 0), downstream); new HydrologyRegionalGraph.LabelState(3, 2, 63, 1D, 1D, 0), downstream);
HydrologyRegionalGraph.Tree tree = new HydrologyRegionalGraph.Tree( HydrologyRegionalGraph.Tree tree = new HydrologyRegionalGraph.Tree(
new HydrologyRegionalGraph.Label[]{first, second, null}, new int[4], List.of()); new HydrologyRegionalGraph.Label[]{first, second, null}, new int[4], List.of(), new int[4]);
assertEquals(3, tree.retainedLabelCount()); assertEquals(3, tree.retainedLabelCount());
} }
} }
@@ -0,0 +1,66 @@
package art.arcane.iris.generation.hydrology;
import org.junit.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.OptionalLong;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class HydrologyCoastalFallAdmissionTest {
private static final HydrologyPlannerSettings SETTINGS = HydrologyPlannerSettings.defaults();
private static final HydrologyTerrainSampler TERRAIN = (x, z) -> x >= 80
? HydrologyTerrainSample.ocean(54, "ocean") : HydrologyTerrainSample.openLand(95, 0D, "land");
@Test
public void aConnectedMouthAdmitsTheCoastalFallAtTheNaturalSeaSurface() {
RiverCourse course = course(HydrologyFeatureType.MOUTH, 80);
HydrologyFootprintCompiler compiler = new HydrologyFootprintCompiler(SETTINGS, TERRAIN);
ArrayList<HydrologyDiagnosticCandidate> diagnostics = new ArrayList<>();
assertTrue(compiler.surfaceFootprint(course).toString(), compiler.surfaceFootprint(course).accepted());
HydrologyCaveCourseFilter.Result result = filter().filter(List.of(), List.of(), List.of(), List.of(course),
compiler.compileValidation(List.of(course)), diagnostics);
assertEquals(diagnostics.toString(), List.of(course), result.courses());
assertEquals(1, result.cavePlans().size());
assertTrue(result.cavePlans().getFirst().accepted());
assertTrue(diagnostics.isEmpty());
}
@Test
public void anInlandTerminalOrDisconnectedMouthCannotOpenTheFallAtSeaLevel() {
for (RiverCourse course : List.of(course(HydrologyFeatureType.SURFACE_POOL, 80),
course(HydrologyFeatureType.MOUTH, 81))) {
HydrologyFootprintCompiler compiler = new HydrologyFootprintCompiler(SETTINGS, TERRAIN);
ArrayList<HydrologyDiagnosticCandidate> diagnostics = new ArrayList<>();
HydrologyCaveCourseFilter.Result result = filter().filter(List.of(), List.of(), List.of(), List.of(course),
compiler.compileValidation(List.of(course)), diagnostics);
assertTrue(result.courses().isEmpty());
assertFalse(diagnostics.isEmpty());
assertEquals(HydrologyCandidateRejection.CAVE_CONTAINMENT, diagnostics.getFirst().rejection());
}
}
private static HydrologyCaveCourseFilter filter() {
return new HydrologyCaveCourseFilter(new HydrologyTerrainCaveVoxelView(TERRAIN, SETTINGS.seaLevel(), -64, 256),
new HydrologyCaveCourseFilter.Options(false, 8192, 8192));
}
private static RiverCourse course(HydrologyFeatureType terminal, int receiverX) {
int sea = SETTINGS.seaLevel();
HydraulicSegment approach = new HydraulicSegment(1L, 4L, HydrologyFeatureType.SURFACE_POOL,
93, 93, 4, 2, false, false, List.of(new HydrologyPoint(0, 93, 0), new HydrologyPoint(79, 93, 0)),
HydraulicChannelProfile.uniform(4, 2));
HydraulicSegment fall = new HydraulicSegment(2L, 4L, HydrologyFeatureType.WATERFALL,
93, sea, 4, 2, true, true, List.of(new HydrologyPoint(79, 93, 0), new HydrologyPoint(80, sea, 0)),
HydraulicChannelProfile.uniform(4, 2));
HydraulicSegment receiving = new HydraulicSegment(3L, 4L, terminal, sea, sea, 4, 2, false, false,
List.of(new HydrologyPoint(receiverX, sea, 0)), HydraulicChannelProfile.uniform(4, 2));
return new RiverCourse(4L, RiverCourseType.SURFACE, OptionalLong.of(5L), OptionalLong.of(6L),
"default", 1, List.of(), List.of(approach, fall, receiving));
}
}
@@ -0,0 +1,191 @@
package art.arcane.iris.generation.hydrology;
import org.junit.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.doReturn;
public class HydrologyDrainageGeometryTest {
@Test
public void sourceBranchesShareExactlyTheSameDescendingTrunk() {
HydrologyTerrainSampler terrain = (x, z) -> land(120 - x / 8);
Fixture fixture = fixture(terrain);
HydrologyDrainageGeometry geometry = fixture.geometry();
List<HydrologyPoint> first = geometry.edge(0);
List<HydrologyPoint> tributary = geometry.edge(3);
List<HydrologyPoint> trunk = geometry.edge(1);
assertFalse(first.isEmpty());
assertFalse(tributary.isEmpty());
assertFalse(trunk.isEmpty());
assertEquals(first.getLast(), trunk.getFirst());
assertEquals(tributary.getLast(), trunk.getFirst());
for (List<HydrologyPoint> edge : List.of(first, tributary, trunk)) {
for (int index = 1; index < edge.size(); index++) {
assertTrue(edge.get(index - 1).y() >= edge.get(index).y());
}
}
assertEquals(geometry.head(1), trunk.getFirst().y());
}
@Test
public void repeatedAndConcurrentSourcesReuseOneGeometryComputation() {
AtomicInteger sampled = new AtomicInteger();
Fixture fixture = fixture((x, z) -> {
sampled.incrementAndGet();
return land(120 - x / 8);
});
ArrayList<CompletableFuture<List<HydrologyPoint>>> tasks = new ArrayList<>();
for (int index = 0; index < 16; index++) {
tasks.add(CompletableFuture.supplyAsync(() -> fixture.geometry().edge(0)));
}
List<HydrologyPoint> expected = tasks.getFirst().join();
assertFalse(expected.isEmpty());
for (CompletableFuture<List<HydrologyPoint>> task : tasks) {
assertSame(expected, task.join());
}
int samples = sampled.get();
assertSame(expected, fixture.geometry().edge(0));
assertEquals(samples, sampled.get());
}
@Test
public void aFailedRequiredSampleCanBeRetriedWithoutCachingItsFailure() {
AtomicInteger attempts = new AtomicInteger();
IllegalStateException unavailable = new IllegalStateException("Terrain unavailable");
Fixture fixture = fixture((x, z) -> {
if (x == 31 && attempts.incrementAndGet() == 1) {
throw unavailable;
}
return land(120 - x / 8);
});
assertSame(unavailable, assertThrows(IllegalStateException.class, () -> fixture.geometry().edge(0)));
assertFalse(fixture.geometry().edge(0).isEmpty());
}
@Test
public void detailedReliefDoesNotSendTheSharedRiverSearchingForAnotherRoute() {
Fixture flat = fixture((x, z) -> land(120 - x / 8));
Fixture broken = fixture((x, z) -> land(x > 24 && x < 40 ? 90 : 120 - x / 8));
assertEquals(flat.geometry().edge(0), broken.geometry().edge(0));
assertFalse(broken.geometry().edge(0).isEmpty());
}
@Test
public void aThinExcludedPolicyBandStillBlocksAnEdge() {
HydrologyTerrainSample blocked = spy(land(116));
doReturn(false).when(blocked).transitAllowed();
Fixture fixture = fixture((x, z) -> x == 31 ? blocked : land(120 - x / 8));
assertTrue(fixture.geometry().edge(0).isEmpty());
}
@Test
public void coastalRootKeepsItsLandwardPointAndDescendingHeadAboveTheSea() {
Fixture fixture = fixture((x, z) -> land(120 - x / 8));
HydrologyPoint root = fixture.geometry().anchor(2);
assertEquals(128, root.x());
assertEquals(0, root.z());
assertTrue(root.y() >= fixture.planner().settings.seaLevel());
assertEquals(root, fixture.geometry().edge(1).getLast());
}
@Test
public void everyOutletTrialReusesTheSameGridCostsAndPolicyEdges() {
Fixture fixture = fixture((x, z) -> land(120 - x / 8));
HydrologySourcePlanner sources = fixture.planner().sourcePlanner;
HydrologyDrainageGraph graph = fixture.grid().drainage(sources);
sources.buildRouting(fixture.grid(), List.of(fixture.outlet()), true);
sources.buildRouting(fixture.grid(), List.of(fixture.outlet()), false);
assertSame(graph, fixture.grid().drainage(sources));
for (HydrologyGridNode downstream : fixture.grid().nodes()) {
for (int direction = 0; direction < HydrologySourcePlanner.ROUTING_OFFSETS.size(); direction++) {
HydrologyGridOffset offset = HydrologySourcePlanner.ROUTING_OFFSETS.get(direction);
HydrologyGridNode upstream = fixture.grid().nodeAt(downstream.gridX() + offset.x(), downstream.gridZ() + offset.z());
if (upstream == null) {
assertEquals(-1, graph.upstream(downstream.index(), direction, false));
} else {
assertEquals(upstream.index(), graph.upstream(downstream.index(), direction, false));
assertEquals(sources.routeCost(upstream, downstream, offset,
sources.minimumNeighborHeight(fixture.grid(), downstream)), graph.cost(downstream.index(), direction), 0D);
}
}
}
}
@Test
public void aCuttableRidgeDoesNotLiftTheSharedUpstreamRiverAboveItsSpring() {
HydrologyPlannerSettings base = HydrologyPlannerSettings.defaults();
HydrologyPlannerSettings.Surface surface = base.surface();
HydrologyPlannerSettings settings = new HydrologyPlannerSettings(base.seaLevel(), base.routing(),
new HydrologyPlannerSettings.Surface(surface.enabled(), surface.sources(), surface.minimumWidth(),
surface.maximumWidth(), surface.minimumDepth(), surface.maximumDepth(), 64,
surface.shoreWidth(), surface.banks()), base.hydraulics(), base.underground(),
base.outlets(), base.geometry(), base.deepFluids(), base.surfacePools(),
base.widestShoreBiomeWidth(), base.seaCaves(), base.surfacePolicyBounds());
HydrologyTerrainSampler terrain = (x, z) -> land(x == 64 ? 154 : 120 - x / 8);
Fixture fixture = fixture(terrain, settings, terrain);
HydrologyDrainageGeometry geometry = fixture.geometry();
int depth = (surface.minimumDepth() + surface.maximumDepth()) / 2;
assertEquals(120 - surface.banks().sink() - depth, geometry.head(0));
assertTrue(geometry.head(0) >= geometry.head(1));
assertTrue(geometry.head(1) >= geometry.head(2));
assertTrue(154 - geometry.head(1) + depth <= 64);
assertEquals(geometry.edge(0).getLast(), geometry.edge(1).getFirst());
}
@Test
public void aSourceExcludedDepressionDoesNotLowerTheSharedOutletHead() {
HydrologyTerrainSample excluded = spy(land(90));
doReturn(false).when(excluded).surfaceSourceAllowed();
HydrologyTerrainSampler terrain = (x, z) -> z == 0 ? land(120 - x / 8) : excluded;
HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults();
Fixture fixture = fixture(terrain, settings, terrain);
HydrologyPlannerSettings.Surface surface = settings.surface();
int depth = (surface.minimumDepth() + surface.maximumDepth()) / 2;
assertEquals(104 - surface.banks().sink() - depth, fixture.geometry().head(2));
assertTrue(fixture.geometry().head(0) >= fixture.geometry().head(1));
assertTrue(fixture.geometry().head(1) >= fixture.geometry().head(2));
assertEquals(fixture.geometry().edge(0).getLast(), fixture.geometry().edge(1).getFirst());
}
private Fixture fixture(HydrologyTerrainSampler terrain) {
return fixture(terrain, HydrologyPlannerSettings.defaults(), (x, z) -> land(120 - x / 8));
}
private Fixture fixture(HydrologyTerrainSampler terrain, HydrologyPlannerSettings settings,
HydrologyTerrainSampler coarseTerrain) {
HydrologyPlanner planner = new HydrologyPlanner(91L, settings, terrain);
ArrayList<HydrologyGridNode> nodes = new ArrayList<>();
for (int index = 0; index < 9; index++) {
int x = index % 3 * 64;
int z = index / 3 * 64;
nodes.add(new HydrologyGridNode(index, index % 3, index / 3, x, z, index + 1L, coarseTerrain.sample(x, z)));
}
HydrologySampledGrid grid = new HydrologySampledGrid(0, 0, 0, 0, 192, 3, 64, nodes);
OutletCandidate outlet = new OutletCandidate(2, -1, new RiverOutlet(10L, HydrologyFeatureType.MOUTH,
3L, new HydrologyPoint(128, 104, 0), new HydrologyPoint(129, 63, 0), 63, true));
int[] parent = {1, 2, -1, 1, 1, 2, 3, 4, 5};
HydrologyDrainageGeometry geometry = new HydrologyDrainageGeometry(planner,
new HydrologyDrainageGeometry.Basin(grid, parent, new int[9], List.of(outlet)));
return new Fixture(planner, grid, outlet, geometry);
}
private static HydrologyTerrainSample land(int height) {
return HydrologyTerrainSample.openLand(height, 0D, "land");
}
private record Fixture(HydrologyPlanner planner, HydrologySampledGrid grid,
OutletCandidate outlet, HydrologyDrainageGeometry geometry) {
}
}
@@ -154,7 +154,7 @@ public class HydrologyFallbackDiagnosticsTest {
} }
private static HydrologyRoutingPlan routing(List<OutletCandidate> outlets) { private static HydrologyRoutingPlan routing(List<OutletCandidate> outlets) {
return new HydrologyRoutingPlan(new double[]{0D}, new int[]{-1}, new int[]{0}, new int[]{0}, outlets, true); return new HydrologyRoutingPlan(new double[]{0D}, new int[]{-1}, new int[]{0}, new int[]{0}, outlets, true, null);
} }
private static OutletCandidate outlet(int index) { private static OutletCandidate outlet(int index) {
@@ -247,7 +247,7 @@ public class HydrologyFallbackParallelismTest {
} }
private static HydrologyRoutingPlan routing(List<OutletCandidate> outlets) { private static HydrologyRoutingPlan routing(List<OutletCandidate> outlets) {
return new HydrologyRoutingPlan(new double[]{0D}, new int[]{-1}, new int[]{0}, new int[]{0}, outlets, true); return new HydrologyRoutingPlan(new double[]{0D}, new int[]{-1}, new int[]{0}, new int[]{0}, outlets, true, null);
} }
private static OutletCandidate outlet(int index) { private static OutletCandidate outlet(int index) {
@@ -1,5 +1,6 @@
package art.arcane.iris.generation.hydrology; package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.policy.SurfaceRiverPolicy;
import org.junit.Test; import org.junit.Test;
import java.util.ArrayDeque; import java.util.ArrayDeque;
@@ -1508,7 +1509,7 @@ public class HydrologyFootprintCompilerTest {
} }
@Test @Test
public void lowBanksKeepTheirNaturalHeightAndReportUncontainedWater() { public void lowBanksFillWithinTheirPolicyAndReportRemainingUncontainedWater() {
HydraulicSegment channel = new HydraulicSegment( HydraulicSegment channel = new HydraulicSegment(
529L, 529L,
528L, 528L,
@@ -1539,25 +1540,25 @@ public class HydrologyFootprintCompilerTest {
HydrologyFootprintCompiler lowBankCompiler = compiler(lowBankTerrain); HydrologyFootprintCompiler lowBankCompiler = compiler(lowBankTerrain);
RiverFootprint lowBankFootprint = lowBankCompiler.compile(List.of(course)); RiverFootprint lowBankFootprint = lowBankCompiler.compile(List.of(course));
HydrologyColumnSample lowBank = lowBankFootprint.sample(12, lowBankZ).orElseThrow(); HydrologyColumnSample lowBank = lowBankFootprint.sample(12, lowBankZ).orElseThrow();
HydrologyTerrainSampler deepBankTerrain = (int x, int z) -> HydrologyTerrainSample.openLand( SurfaceRiverPolicy oneBlockFill = new SurfaceRiverPolicy("restricted-bank",
x == 12 && z == lowBankZ ? 68 : 82, null, null, null, null, null, null, 1);
0D, HydrologyTerrainSampler deepBankTerrain = (int x, int z) -> x == 12 && z == lowBankZ
"parent" ? HydrologyTerrainSample.openLand(68, 0D, "parent").withSurfacePolicy(oneBlockFill)
); : HydrologyTerrainSample.openLand(82, 0D, "parent");
RiverFootprint deepBankFootprint = compiler(deepBankTerrain).compile(List.of(course)); RiverFootprint deepBankFootprint = compiler(deepBankTerrain).compile(List.of(course));
HydrologyColumnSample deepBank = deepBankFootprint.sample(12, lowBankZ).orElseThrow(); HydrologyColumnSample deepBank = deepBankFootprint.sample(12, lowBankZ).orElseThrow();
assertEquals(0, unsupportedBankCells(highFootprint, 528L)); assertEquals(0, unsupportedBankCells(highFootprint, 528L));
// A low bank remains below the water and is reported without raising terrain.
assertEquals(69, lowBank.naturalHeight()); assertEquals(69, lowBank.naturalHeight());
assertEquals(69, lowBank.terrainHeight()); assertEquals(70, lowBank.terrainHeight());
assertTrue(unsupportedBankCells(lowBankFootprint, 528L) > 0); assertEquals(0, unsupportedBankCells(lowBankFootprint, 528L));
for (HydrologyColumnSample sample : lowBankFootprint.columns().values()) { for (HydrologyColumnSample sample : lowBankFootprint.columns().values()) {
assertTrue(sample.terrainHeight() <= sample.naturalHeight()); HydrologyColumnLayer layer = sample.primarySurfaceLayerOrNull();
int fillLimit = layer != null && layer.channel() ? 0 : 8;
assertTrue(sample.terrainHeight() - sample.naturalHeight() <= fillLimit);
} }
// Two blocks short: the bank keeps its natural height and the edge is reported.
assertEquals(68, deepBank.naturalHeight()); assertEquals(68, deepBank.naturalHeight());
assertEquals(68, deepBank.terrainHeight()); assertEquals(69, deepBank.terrainHeight());
assertTrue(unsupportedBankCells(deepBankFootprint, 528L) > 0); assertTrue(unsupportedBankCells(deepBankFootprint, 528L) > 0);
} }
@@ -394,6 +394,30 @@ public class HydrologyModelTest {
assertEquals(72, sample.terrainHeight()); assertEquals(72, sample.terrainHeight());
} }
@Test
public void overlappingRaisedBanksRetainOwnershipWithoutAccumulatingFill() {
for (boolean sameFeature : List.of(true, false)) {
for (boolean reverse : List.of(true, false)) {
FootprintMutableColumn column = new FootprintMutableColumn(0, 0,
HydrologyTerrainSample.openLand(77, 0D, "parent"), 63);
int[] beds = reverse ? new int[]{81, 80, 77, 75} : new int[]{75, 77, 80, 81};
for (int index = 0; index < beds.length; index++) {
HydrologyFeatureRef feature = feature(HydrologyFeatureType.SURFACE_POOL,
sameFeature ? 30L : 30L + index, 80);
column.add(surfaceLayer(feature, beds[index], beds[index],
false, false, true, beds[index] != 77, false));
}
HydrologyColumnSample merged = column.build();
assertEquals(77, merged.naturalHeight());
assertEquals(81, merged.terrainHeight());
assertEquals(4, merged.terrainHeight() - merged.naturalHeight());
assertTrue(merged.primarySurfaceLayerOrNull().terrainOwned());
assertEquals(sameFeature ? 1 : 4, merged.layers().size());
}
}
}
@Test @Test
public void uncutShoreRetainsItsBiomeWithoutOverridingAnOwnedBank() { public void uncutShoreRetainsItsBiomeWithoutOverridingAnOwnedBank() {
HydrologyColumnLayer shore = surfaceLayer(feature(HydrologyFeatureType.SURFACE_POOL, 30L, 75), HydrologyColumnLayer shore = surfaceLayer(feature(HydrologyFeatureType.SURFACE_POOL, 30L, 75),
@@ -17,7 +17,7 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public class HydrologyPlanHashTest { public class HydrologyPlanHashTest {
private static final String EXPECTED_PLAN_DIGEST = "9ec4c9cf7dbdad16976041a70853b909816655170cd694058bf359092c2d5a10"; private static final String EXPECTED_PLAN_DIGEST = "e54461c339854842f7fc7b2e85bd700b57942a2c691f5a249d8e498729378296";
private static final List<HydrologyTileKey> TILES = List.of( private static final List<HydrologyTileKey> TILES = List.of(
new HydrologyTileKey(0, 0), new HydrologyTileKey(0, 0),
@@ -17,7 +17,6 @@ import java.lang.reflect.InvocationTargetException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -35,7 +34,6 @@ import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeoutException;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.function.Consumer; import java.util.function.Consumer;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
@@ -669,25 +667,18 @@ public class HydrologyPlannerTest {
} }
@Test @Test
public void surfaceRouteMemoPreservesCompleteOwnerOutputAcrossPublicationPasses() throws Exception { public void sharedDrainagePreservesCompleteOwnerOutputAcrossPublicationPasses() {
for (boolean organic : new boolean[]{false, true}) { for (boolean organic : new boolean[]{false, true}) {
HydrologyPlannerSettings settings = organic ? organicShapeSettings() HydrologyPlannerSettings settings = organic ? organicShapeSettings()
: standardSettings(4D, 2D, true, false, List.of()); : standardSettings(4D, 2D, true, false, List.of());
HydrologyTerrainSampler terrain = organic ? organicShapeTerrain() : rollingCoast(112); HydrologyTerrainSampler terrain = organic ? organicShapeTerrain() : rollingCoast(112);
SurfaceRouteMemos uncached = new SurfaceRouteMemos(false); HydrologyPlanner planner = new HydrologyPlanner(642L, settings, terrain);
SurfaceRouteMemos cached = new SurfaceRouteMemos(true); HydrologyTile expected = planner.plan(TILE);
HydrologyTile expected = planWithSurfaceRouteMemos(new HydrologyPlanner(642L, settings, terrain), uncached); planner.clearOwnerDrafts();
HydrologyTile actual = planWithSurfaceRouteMemos(new HydrologyPlanner(642L, settings, terrain), cached); HydrologyTile actual = planner.plan(TILE);
assertEquals(expected, actual); assertEquals(expected, actual);
assertEquals(expected.courses(), actual.courses()); assertEquals(expected.courses(), actual.courses());
assertEquals(expected.localDiagnosticCandidates(), actual.localDiagnosticCandidates()); assertEquals(expected.localDiagnosticCandidates(), actual.localDiagnosticCandidates());
assertTrue(cached.computations <= uncached.computations);
if (organic) {
assertTrue("hits=" + cached.hits, cached.hits > 0);
assertTrue("uncached=" + uncached.computations + " cached=" + cached.computations,
cached.computations < uncached.computations);
}
} }
} }
@@ -717,7 +708,10 @@ public class HydrologyPlannerTest {
for (HydraulicSegment segment : course.segments()) { for (HydraulicSegment segment : course.segments()) {
if (segment.drop() > 0) { if (segment.drop() > 0) {
assertTrue(segment.type().isDrop()); assertTrue(segment.type().isDrop());
assertFalse(segment.fallingFluid()); if (segment.fallingFluid()) {
assertEquals(HydrologyFeatureType.WATERFALL, segment.type());
assertTrue(tile.cavePlan(course.id()).orElseThrow().accepted());
}
} }
} }
} }
@@ -1124,7 +1118,9 @@ public class HydrologyPlannerTest {
); );
for (HydraulicSegment segment : segments(cliff, HydrologyFeatureType.WATERFALL)) { for (HydraulicSegment segment : segments(cliff, HydrologyFeatureType.WATERFALL)) {
assertTrue(segment.drop() > 0); assertTrue(segment.drop() > 0);
assertFalse(segment.fallingFluid()); assertTrue(segment.fallingFluid());
assertTrue(segment.receivingPool());
assertTrue(cliff.cavePlan(segment.courseId()).orElseThrow().accepted());
assertTrue(segment.centerline().size() >= 2); assertTrue(segment.centerline().size() >= 2);
assertTrue(segment.width() >= 1); assertTrue(segment.width() >= 1);
} }
@@ -1139,7 +1135,8 @@ public class HydrologyPlannerTest {
HydraulicSegment receiver = course.segments().get(index + 1); HydraulicSegment receiver = course.segments().get(index + 1);
assertTrue(approach.type().isSurface()); assertTrue(approach.type().isSurface());
assertTrue(receiver.type().isSurface()); assertTrue(receiver.type().isSurface());
assertEquals(approach.end(), waterfall.start()); assertEquals(approach.downstreamHeadY(), waterfall.upstreamHeadY());
assertTrue(approach.end().distanceSquared2D(waterfall.start()) <= 2L);
assertEquals(waterfall.end(), receiver.start()); assertEquals(waterfall.end(), receiver.start());
sawBlendedWaterfall = true; sawBlendedWaterfall = true;
} }
@@ -1206,6 +1203,13 @@ public class HydrologyPlannerTest {
} }
} }
assertTrue("courses=" + surfaceCourses(tile), aggregateTransition); assertTrue("courses=" + surfaceCourses(tile), aggregateTransition);
for (HydrologyColumnSample column : tile.footprint().columns().values()) {
for (HydrologyColumnLayer layer : column.layers()) {
if (layer.feature().type().isSurface() && layer.channel() && layer.terrainOwned()) {
assertTrue(column.naturalHeight() - layer.bedY() <= settings.surface().maximumIncision());
}
}
}
} }
@Test @Test
@@ -1879,7 +1883,10 @@ public class HydrologyPlannerTest {
} }
assertTrue(bed); assertTrue(bed);
assertEquals(drop.fallingFluid(), falling); assertEquals(drop.fallingFluid(), falling);
assertFalse(drop.fallingFluid()); if (drop.fallingFluid()) {
assertEquals(HydrologyFeatureType.WATERFALL, drop.type());
assertTrue(tile.cavePlan(drop.courseId()).orElseThrow().accepted());
}
if (!receiving) { if (!receiving) {
HydrologyColumnSample endColumn = tile.columnAt(drop.end().x(), drop.end().z()).orElseThrow(); HydrologyColumnSample endColumn = tile.columnAt(drop.end().x(), drop.end().z()).orElseThrow();
receiving = endColumn.layers().stream().anyMatch((HydrologyColumnLayer layer) -> receiving = endColumn.layers().stream().anyMatch((HydrologyColumnLayer layer) ->
@@ -2210,6 +2217,15 @@ public class HydrologyPlannerTest {
).orElseThrow(); ).orElseThrow();
assertEquals(course.id(), located.courseId()); assertEquals(course.id(), located.courseId());
assertEquals(sinkhole.id(), located.segmentId()); assertEquals(sinkhole.id(), located.segmentId());
for (HydrologyColumnSample column : first.footprint().columns().values()) {
for (HydrologyColumnLayer layer : column.layers()) {
if (layer.feature().courseId() == course.id() && layer.feature().type().isSurface()
&& layer.channel()) {
assertTrue("Surface pond exceeded the channel incision limit at " + column.x() + "," + column.z(),
column.naturalHeight() - layer.bedY() <= settings.surface().maximumIncision());
}
}
}
} }
@Test @Test
@@ -3446,57 +3462,6 @@ public class HydrologyPlannerTest {
} }
return false; return false;
} }
@SuppressWarnings("unchecked")
private HydrologyTile planWithSurfaceRouteMemos(HydrologyPlanner planner, SurfaceRouteMemos memos) throws Exception {
Class<?> samplesClass = Class.forName(HydrologyPlanner.class.getName() + "$PlanningSamples");
Constructor<?> constructor = samplesClass.getDeclaredConstructor();
constructor.setAccessible(true);
Object samples = constructor.newInstance();
Field routes = samplesClass.getDeclaredField("surfaceRoutes");
routes.setAccessible(true);
routes.set(samples, memos);
Field scope = HydrologyPlanner.class.getDeclaredField("planningSamples");
scope.setAccessible(true);
ThreadLocal<Object> local = (ThreadLocal<Object>) scope.get(planner);
local.set(samples);
try {
return planner.plan(TILE);
} finally {
local.remove();
}
}
private static final class SurfaceRouteMemos extends IdentityHashMap<Object, HashMap<Object, List<HydrologyPoint>>> {
private final boolean retain;
private int hits;
private int computations;
private SurfaceRouteMemos(boolean retain) {
this.retain = retain;
}
@Override
public HashMap<Object, List<HydrologyPoint>> computeIfAbsent(Object key,
Function<? super Object, ? extends HashMap<Object, List<HydrologyPoint>>> ignored) {
return super.computeIfAbsent(key, unused -> new HashMap<>() {
@Override
public List<HydrologyPoint> computeIfAbsent(Object route,
Function<? super Object, ? extends List<HydrologyPoint>> computation) {
if (!retain) {
clear();
}
if (containsKey(route)) {
hits++;
}
return super.computeIfAbsent(route, missing -> {
computations++;
return computation.apply(missing);
});
}
});
}
}
private HydrologyPlanner countedPlanner(CountingNaturalSampler sampler) { private HydrologyPlanner countedPlanner(CountingNaturalSampler sampler) {
return new HydrologyPlanner(91L, standardSettings(4D, 0D, true, false, List.of()), return new HydrologyPlanner(91L, standardSettings(4D, 0D, true, false, List.of()),
sampler.delegate, sampler, HydrologyGeometrySampler.deterministic(sampler.delegate), sampler.delegate, sampler, HydrologyGeometrySampler.deterministic(sampler.delegate),
@@ -31,9 +31,7 @@ public class HydrologyRegionalBankSupportTest {
HydrologyTerrainSampler terrain = depression(false); HydrologyTerrainSampler terrain = depression(false);
List<HydrologyPoint> path = path(); List<HydrologyPoint> path = path();
HydrologyPlanner planner = new HydrologyPlanner(71L, SETTINGS, terrain); HydrologyPlanner planner = new HydrologyPlanner(71L, SETTINGS, terrain);
List<HydrologyPoint> refined = new HydrologyRegionalTerrainRefiner(planner).refine(path, "default", false, terrain);
assertEquals(path, refined);
assertNull(validate(planner, path, terrain).rejection()); assertNull(validate(planner, path, terrain).rejection());
SurfaceCourseResult built = build(path, terrain); SurfaceCourseResult built = build(path, terrain);
assertTrue(built.accepted()); assertTrue(built.accepted());
@@ -44,7 +42,7 @@ public class HydrologyRegionalBankSupportTest {
assertEquals(0, footprint.uncontainedWetCells()); assertEquals(0, footprint.uncontainedWetCells());
assertTrue(course.hydraulicallyNonRising()); assertTrue(course.hydraulicallyNonRising());
for (SurfaceLayerColumn column : footprint.columns()) { for (SurfaceLayerColumn column : footprint.columns()) {
assertTrue(column.layer().bedY() <= column.terrain().naturalHeight()); assertTrue(column.layer().bedY() - column.terrain().naturalHeight() <= SETTINGS.surface().maximumIncision());
} }
SurfaceLayerColumn center = footprint.columns().stream() SurfaceLayerColumn center = footprint.columns().stream()
.filter(column -> column.x() == 132 && column.z() == 0).findFirst().orElseThrow(); .filter(column -> column.x() == 132 && column.z() == 0).findFirst().orElseThrow();
@@ -58,67 +56,16 @@ public class HydrologyRegionalBankSupportTest {
HydrologyTerrainSampler terrain = depression(true); HydrologyTerrainSampler terrain = depression(true);
HydrologyPlanner planner = new HydrologyPlanner(71L, SETTINGS, terrain); HydrologyPlanner planner = new HydrologyPlanner(71L, SETTINGS, terrain);
assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, assertNull(validate(planner, path(), terrain).rejection());
validate(planner, path(), terrain).rejection());
List<HydrologyPoint> refined = new HydrologyRegionalTerrainRefiner(planner).refine(path(), "default", false, terrain);
if (!refined.isEmpty()) {
assertFalse(build(refined, terrain).accepted());
}
SurfaceCourseResult built = build(path(), terrain); SurfaceCourseResult built = build(path(), terrain);
assertFalse(built.accepted()); if (built.accepted()) {
assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, built.rejection()); RiverCourse course = new RiverCourse(91L, RiverCourseType.SURFACE, OptionalLong.of(1L), OptionalLong.of(2L),
assertEquals(17, built.rejectionDetail()); "default", 1, List.of(), built.segments());
} SurfaceFootprint footprint = new SurfaceFootprintCompiler(SETTINGS, terrain, GEOMETRY).compile(course);
assertFalse(footprint.accepted());
@Test } else {
public void levelCentersNeedNoBankSamplesAndSupportedDipsCannotRaiseIncomingHead() { assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, built.rejection());
AtomicInteger samples = new AtomicInteger();
HydrologyTerrainSampler terrain = (x, z) -> {
assertTrue(Math.abs(x - 132) <= 5 && Math.abs(z) <= 5);
samples.incrementAndGet();
return depression(false).sample(x, z);
};
HydrologyRegionalHydraulics hydraulics = new HydrologyRegionalHydraulics(SETTINGS);
assertEquals(79, hydraulics.supportedHead(new HydrologyRegionalHydraulics.HeadStation(
132, 0, 1D, 0D, HydrologyTerrainSample.openLand(80, 0D, "land"), 79), terrain));
assertEquals(0, samples.get());
assertEquals(78, hydraulics.supportedHead(station(78), terrain));
assertTrue(samples.get() > 0 && samples.get() < 128);
}
@Test
public void unavailableCrossSectionOrPerimeterCannotCertifyAContainedDip() {
HydrologyRegionalHydraulics hydraulics = new HydrologyRegionalHydraulics(SETTINGS);
HydrologyTerrainSampler crossMissing = (x, z) -> x == 132 && z == 2 ? null : depression(false).sample(x, z);
HydrologyTerrainSampler perimeterMissing = (x, z) -> x == 133 ? null : depression(false).sample(x, z);
assertEquals(65, hydraulics.supportedHead(station(80), crossMissing));
assertEquals(65, hydraulics.supportedHead(station(80), perimeterMissing));
}
@Test
public void bankSupportCannotSamplePastTheExistingSearchBudget() throws Exception {
AtomicInteger requests = new AtomicInteger();
HydrologyTerrainSample ground = HydrologyTerrainSample.openLand(80, 0D, "land");
HydrologyTerrainSampler terrain = (x, z) -> {
requests.incrementAndGet();
return ground;
};
HydrologyRegionalTerrainRefiner refiner = new HydrologyRegionalTerrainRefiner(new HydrologyPlanner(71L, SETTINGS, terrain));
HydrologyRegionalTerrainRefiner.Samples sampled = refiner.new Samples();
for (int index = 0; index < 65536; index++) {
sampled.sample(index, 100000);
} }
requests.set(0);
assertEquals(65, new HydrologyRegionalHydraulics(SETTINGS).supportedHead(station(80), sampled));
assertEquals(0, requests.get());
}
private static HydrologyRegionalHydraulics.HeadStation station(int incomingHead) {
return new HydrologyRegionalHydraulics.HeadStation(132, 0, 1D, 0D,
HydrologyTerrainSample.openLand(65, 0D, "land"), incomingHead);
} }
private static HydrologyTerrainSampler depression(boolean acrossBanks) { private static HydrologyTerrainSampler depression(boolean acrossBanks) {
@@ -14,34 +14,6 @@ import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public class HydrologyRegionalCacheTest { public class HydrologyRegionalCacheTest {
@Test
public void searchBudgetDoesNotLimitSubsequentSearchesOrTerrainResults() {
AtomicInteger reads = new AtomicInteger();
HydrologyTerrainSampler terrain = (x, z) -> {
reads.incrementAndGet();
return HydrologyTerrainSample.openLand(70, 0D, "land");
};
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
HydrologyRegionalTerrainRefiner refiner = new HydrologyRegionalTerrainRefiner(planner);
HydrologyRegionalTerrainRefiner.Samples first = refiner.new Samples();
for (int x = 0; x < 65536; x++) {
assertNotNull(first.sample(x, 0));
}
assertNull(first.sample(65536, 0));
HydrologyRegionalTerrainRefiner.Samples second = refiner.new Samples();
for (int x = 65536; x < 70000; x++) {
assertNotNull(second.sample(x, 0));
}
assertEquals(70000, reads.get());
for (int x = 0; x < 70000; x++) {
assertEquals(70, refiner.sample(x, 0).naturalHeight());
}
int readsBeforeClear = reads.get();
refiner.clear();
assertEquals(70, refiner.sample(0, 0).naturalHeight());
assertEquals(readsBeforeClear + 1, reads.get());
}
@Test @Test
public void clearingTilesRefreshesChangedRegionalTerrain() throws Exception { public void clearingTilesRefreshesChangedRegionalTerrain() throws Exception {
AtomicInteger height = new AtomicInteger(70); AtomicInteger height = new AtomicInteger(70);
@@ -51,7 +23,7 @@ public class HydrologyRegionalCacheTest {
return HydrologyTerrainSample.openLand(height.get(), 0D, "land"); return HydrologyTerrainSample.openLand(height.get(), 0D, "land");
}; };
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain); HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
HydrologyRegionalTerrainRefiner refiner = refiner(planner); HydrologyRegionalTerrain refiner = refiner(planner);
try (HydrologyTileCache cache = new HydrologyTileCache(planner)) { try (HydrologyTileCache cache = new HydrologyTileCache(planner)) {
assertEquals(70, refiner.sample(0, 0).naturalHeight()); assertEquals(70, refiner.sample(0, 0).naturalHeight());
height.set(85); height.set(85);
@@ -71,7 +43,7 @@ public class HydrologyRegionalCacheTest {
return available.get() ? HydrologyTerrainSample.openLand(70, 0D, "land") : null; return available.get() ? HydrologyTerrainSample.openLand(70, 0D, "land") : null;
}; };
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain); HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
HydrologyRegionalTerrainRefiner refiner = refiner(planner); HydrologyRegionalTerrain refiner = refiner(planner);
try (HydrologyTileCache cache = new HydrologyTileCache(planner)) { try (HydrologyTileCache cache = new HydrologyTileCache(planner)) {
assertNull(refiner.sample(0, 0)); assertNull(refiner.sample(0, 0));
available.set(true); available.set(true);
@@ -82,32 +54,11 @@ public class HydrologyRegionalCacheTest {
} }
} }
@Test private static HydrologyRegionalTerrain refiner(HydrologyPlanner planner) throws Exception {
public void clearingTilesReplansRegionalReachesAgainstCurrentTerrain() throws Exception {
AtomicBoolean flooded = new AtomicBoolean();
HydrologyTerrainSampler terrain = (x, z) -> flooded.get() && x >= 96 && x <= 160 && Math.abs(z) < 32
? HydrologyTerrainSample.ocean(60, "ocean") : HydrologyTerrainSample.openLand(70, 0D, "land");
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
HydrologyRegionalTerrainRefiner refiner = refiner(planner);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0));
try (HydrologyTileCache cache = new HydrologyTileCache(planner)) {
assertEquals(guide, refiner.refine(guide, "default", false, terrain));
flooded.set(true);
cache.clear();
List<HydrologyPoint> actual = refiner.refine(guide, "default", false, terrain);
List<HydrologyPoint> fresh = new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, terrain);
assertFalse(fresh.isEmpty());
assertTrue(fresh.stream().anyMatch(point -> Math.abs(point.z()) >= 32));
assertEquals(fresh, actual);
}
}
private static HydrologyRegionalTerrainRefiner refiner(HydrologyPlanner planner) throws Exception {
Field routes = HydrologyRegionalPlanner.class.getDeclaredField("routes"); Field routes = HydrologyRegionalPlanner.class.getDeclaredField("routes");
routes.setAccessible(true); routes.setAccessible(true);
Field refiner = HydrologyRegionalRoute.class.getDeclaredField("terrainRefiner"); Field refiner = HydrologyRegionalRoute.class.getDeclaredField("terrain");
refiner.setAccessible(true); refiner.setAccessible(true);
return (HydrologyRegionalTerrainRefiner) refiner.get(routes.get(planner.regional)); return (HydrologyRegionalTerrain) refiner.get(routes.get(planner.regional));
} }
} }
@@ -19,7 +19,7 @@ import static org.junit.Assert.assertTrue;
public class HydrologyRegionalCandidateSelectionTest { public class HydrologyRegionalCandidateSelectionTest {
@Test @Test
public void lowerMeanderCandidatePassesTheFullPlannerAfterTheFirstBankCutFails() throws Exception { public void plannedHeadCrossesAChannelDepressionWithoutLoweringTheWholeCourse() throws Exception {
HydrologyTerrainSampler terrain = coast(true); HydrologyTerrainSampler terrain = coast(true);
HydrologyPlanner planner = planner(terrain, 16, 2); HydrologyPlanner planner = planner(terrain, 16, 2);
List<HydrologyPoint> guide = guide(terrain); List<HydrologyPoint> guide = guide(terrain);
@@ -33,9 +33,8 @@ public class HydrologyRegionalCandidateSelectionTest {
}); });
assertTrue(selected.toString(), selected.accepted()); assertTrue(selected.toString(), selected.accepted());
assertEquals(2, attempted.size()); assertEquals(1, attempted.size());
assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, attempted.getFirst().rejection().rejection()); assertNull(attempted.getFirst().rejection());
assertEquals(18, attempted.getFirst().rejection().detail());
assertEquals(1, selected.network().courses().size()); assertEquals(1, selected.network().courses().size());
assertEquals(selected, attempted.getLast()); assertEquals(selected, attempted.getLast());
RiverCourse course = selected.network().courses().getFirst(); RiverCourse course = selected.network().courses().getFirst();
@@ -124,8 +123,10 @@ public class HydrologyRegionalCandidateSelectionTest {
} }
int[] contributions = new int[9]; int[] contributions = new int[9];
Arrays.fill(contributions, 1); Arrays.fill(contributions, 1);
int[] heads = new int[9];
Arrays.fill(heads, 80);
HydrologyRegionalGraph.Tree tree = new HydrologyRegionalGraph.Tree(selected, contributions, HydrologyRegionalGraph.Tree tree = new HydrologyRegionalGraph.Tree(selected, contributions,
List.of(new OutletCandidate(8, 8, outlet()))); List.of(new OutletCandidate(8, 8, outlet())), heads);
ArrayList<HydrologyDiagnosticCandidate> diagnostics = new ArrayList<>(); ArrayList<HydrologyDiagnosticCandidate> diagnostics = new ArrayList<>();
Method build = HydrologyRegionalPlanner.class.getDeclaredMethod("build", HydrologySampledGrid.class, Method build = HydrologyRegionalPlanner.class.getDeclaredMethod("build", HydrologySampledGrid.class,
HydrologyRegionalGraph.Tree.class, int.class, OutletCandidate.class, long.class, List.class); HydrologyRegionalGraph.Tree.class, int.class, OutletCandidate.class, long.class, List.class);
@@ -51,7 +51,7 @@ public final class HydrologyRegionalFallsTest {
HydrologyTile original = new HydrologyTile(new HydrologyTileKey(0, 0), 1L, SETTINGS.fingerprint(), HydrologyTile original = new HydrologyTile(new HydrologyTileKey(0, 0), 1L, SETTINGS.fingerprint(),
SETTINGS.routing().tileSize(), network.nodes(), network.edges(), network.outlets(), SETTINGS.routing().tileSize(), network.nodes(), network.edges(), network.outlets(),
List.of(regional, local), Set.of(regional.id()), clipped.cavePlans(), List.of(), materialized.footprint()); List.of(regional, local), Set.of(regional.id()), clipped.cavePlans(), List.of(), materialized.footprint());
StudioHydrologyTileStore store = new StudioHydrologyTileStore(temporaryFolder.newFolder().toPath(), PreparedHydrologyTileStore store = new PreparedHydrologyTileStore(temporaryFolder.newFolder().toPath(),
new HydrologyTileCache.SharedCacheScope("regional-ownership", 1L, 128, "overworld", SETTINGS.fingerprint()), new HydrologyTileCache.SharedCacheScope("regional-ownership", 1L, 128, "overworld", SETTINGS.fingerprint()),
SETTINGS.routing().tileSize()); SETTINGS.routing().tileSize());
store.save(original); store.save(original);
@@ -28,8 +28,8 @@ public class HydrologyRegionalMouthRefinementTest {
HydrologyTerrainSampler receiver = HydrologyOceanReceiver.forOutlet(settings, terrain, outlet); HydrologyTerrainSampler receiver = HydrologyOceanReceiver.forOutlet(settings, terrain, outlet);
HydrologyPlanner planner = new HydrologyPlanner(1L, settings, terrain); HydrologyPlanner planner = new HydrologyPlanner(1L, settings, terrain);
HydrologyRegionalRoute route = new HydrologyRegionalRoute(planner); HydrologyRegionalRoute route = new HydrologyRegionalRoute(planner);
List<HydrologyPoint> path = List.of(new HydrologyPoint(0, 65, 0), new HydrologyPoint(767, 65, 0), List<HydrologyPoint> path = List.of(new HydrologyPoint(0, 63, 0), new HydrologyPoint(767, 63, 0),
new HydrologyPoint(768, 80, 0), landward); new HydrologyPoint(768, 63, 0), landward);
Method validate = HydrologyRegionalRoute.class.getDeclaredMethod("validate", List.class, String.class, Method validate = HydrologyRegionalRoute.class.getDeclaredMethod("validate", List.class, String.class,
boolean.class, HydrologyTerrainSampler.class); boolean.class, HydrologyTerrainSampler.class);
validate.setAccessible(true); validate.setAccessible(true);
@@ -40,7 +40,7 @@ public class HydrologyRegionalMouthRefinementTest {
assertNull(accepted.toString(), accepted.rejection()); assertNull(accepted.toString(), accepted.rejection());
assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, unproved.rejection()); assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, unproved.rejection());
assertTrue(new HydrologyRegionalTerrainRefiner(planner).receivingTerminal(landward, "default", false, receiver)); assertTrue(new HydrologyRegionalTerrain(planner).receivingTerminal(landward, "default", false, receiver));
assertFalse(terrain.sample(800, 0).ocean()); assertFalse(terrain.sample(800, 0).ocean());
assertTrue(receiver.receivingWater(800, 0, 63)); assertTrue(receiver.receivingWater(800, 0, 63));
} }
@@ -171,8 +171,8 @@ public class HydrologyRegionalMouthRefinementTest {
HydrologyPlannerSettings settings = HydrologyRegionalPlannerTest.settings(false, 1); HydrologyPlannerSettings settings = HydrologyRegionalPlannerTest.settings(false, 1);
HydrologyTerrainSampler terrain = (x, z) -> x >= 800 ? HydrologyTerrainSample.ocean(60, "ocean") HydrologyTerrainSampler terrain = (x, z) -> x >= 800 ? HydrologyTerrainSample.ocean(60, "ocean")
: HydrologyTerrainSample.openLand(x >= 768 ? 80 : 65, 0D, "land"); : HydrologyTerrainSample.openLand(x >= 768 ? 80 : 65, 0D, "land");
List<HydrologyPoint> path = List.of(new HydrologyPoint(0, 65, 0), new HydrologyPoint(767, 65, 0), List<HydrologyPoint> path = List.of(new HydrologyPoint(0, 63, 0), new HydrologyPoint(767, 63, 0),
new HydrologyPoint(768, 80, 0), new HydrologyPoint(799, 80, 0), new HydrologyPoint(800, 63, 0)); new HydrologyPoint(768, 63, 0), new HydrologyPoint(799, 63, 0), new HydrologyPoint(800, 63, 0));
HydrologyGeometrySampler geometry = request -> switch (request.field()) { HydrologyGeometrySampler geometry = request -> switch (request.field()) {
case SURFACE_WIDTH -> 6; case SURFACE_WIDTH -> 6;
case SURFACE_DEPTH -> 2; case SURFACE_DEPTH -> 2;
@@ -38,7 +38,7 @@ public class HydrologyRegionalSamplingTest {
RecordingSampler serial = new RecordingSampler(terrain); RecordingSampler serial = new RecordingSampler(terrain);
SurfaceBankSupport.CrossSection cross = support.crossSection(serial, station, true); SurfaceBankSupport.CrossSection cross = support.crossSection(serial, station, true);
SurfaceBankSupport.Perimeter perimeter = support.perimeter(serial, station, 80); SurfaceBankSupport.Perimeter perimeter = support.perimeter(serial, station, 80);
RecordingSampler parallel = new RecordingSampler(refiner(terrain).new Samples()); RecordingSampler parallel = new RecordingSampler(refiner(terrain));
pool.submit(() -> { pool.submit(() -> {
assertEquals(cross, support.crossSection(parallel, station, true)); assertEquals(cross, support.crossSection(parallel, station, true));
assertEquals(perimeter, support.perimeter(parallel, station, 80)); assertEquals(perimeter, support.perimeter(parallel, station, 80));
@@ -49,48 +49,6 @@ public class HydrologyRegionalSamplingTest {
} }
} }
@Test
public void searchLimitCountsNullsAndRetainsAlreadyAdmittedCoordinates() {
AtomicInteger reads = new AtomicInteger();
HydrologyTerrainSampler terrain = (x, z) -> {
reads.incrementAndGet();
return x == 0 ? null : HydrologyTerrainSample.openLand(70, 0D, "land");
};
HydrologyRegionalTerrainRefiner refiner = refiner(terrain);
refiner.sample(100000, 0);
HydrologyRegionalTerrainRefiner.Samples samples = refiner.new Samples();
assertNull(samples.sample(0, 0));
for (int x = 1; x < 65536; x++) {
assertEquals(70, samples.sample(x, 0).naturalHeight());
}
assertNull(samples.sample(0, 0));
assertEquals(70, samples.sample(1, 0).naturalHeight());
assertNull(samples.sample(65536, 0));
assertNull(samples.sample(100000, 0));
assertEquals(65537, reads.get());
}
@Test
public void failedRequiredSampleRetainsItsExceptionAndDoesNotConsumeAdmission() {
AtomicInteger failedReads = new AtomicInteger();
IllegalStateException failure = new IllegalStateException("Required terrain unavailable");
HydrologyTerrainSampler terrain = (x, z) -> {
if (x == -1 && failedReads.incrementAndGet() == 1) {
throw failure;
}
return HydrologyTerrainSample.openLand(70, 0D, "land");
};
HydrologyRegionalTerrainRefiner.Samples samples = refiner(terrain).new Samples();
for (int x = 0; x < 65535; x++) {
samples.sample(x, 0);
}
assertSame(failure, assertThrows(IllegalStateException.class, () -> samples.sample(-1, 0)));
assertEquals(70, samples.sample(-1, 0).naturalHeight());
assertEquals(70, samples.sample(0, 0).naturalHeight());
assertNull(samples.sample(-2, 0));
assertEquals(2, failedReads.get());
}
@Test @Test
public void unavailableBankStopsBeforeALaterProviderFailure() throws Exception { public void unavailableBankStopsBeforeALaterProviderFailure() throws Exception {
IllegalStateException failure = new IllegalStateException("Later bank sample"); IllegalStateException failure = new IllegalStateException("Later bank sample");
@@ -103,7 +61,7 @@ public class HydrologyRegionalSamplingTest {
SurfaceBankSupport support = new SurfaceBankSupport(SETTINGS.surface(), SETTINGS.seaLevel()); SurfaceBankSupport support = new SurfaceBankSupport(SETTINGS.surface(), SETTINGS.seaLevel());
SurfaceBankSupport.Station station = new SurfaceBankSupport.Station(0, 0, 1D, 0D, 4D); SurfaceBankSupport.Station station = new SurfaceBankSupport.Station(0, 0, 1D, 0D, 4D);
SurfaceBankSupport.CrossSection expected = support.crossSection(terrain, station, true); SurfaceBankSupport.CrossSection expected = support.crossSection(terrain, station, true);
HydrologyRegionalTerrainRefiner.Samples samples = refiner(terrain).new Samples(); HydrologyRegionalTerrain samples = refiner(terrain);
try (ForkJoinPool pool = new ForkJoinPool(4)) { try (ForkJoinPool pool = new ForkJoinPool(4)) {
assertEquals(expected, pool.submit(() -> support.crossSection(samples, station, true)).get(10, TimeUnit.SECONDS)); assertEquals(expected, pool.submit(() -> support.crossSection(samples, station, true)).get(10, TimeUnit.SECONDS));
} }
@@ -111,137 +69,8 @@ public class HydrologyRegionalSamplingTest {
assertSame(failure, assertThrows(IllegalStateException.class, () -> samples.sample(0, 0))); assertSame(failure, assertThrows(IllegalStateException.class, () -> samples.sample(0, 0)));
} }
@Test private static HydrologyRegionalTerrain refiner(HydrologyTerrainSampler terrain) {
public void hydraulicBudgetAndLazyWidthsPreserveConsumptionOrder() throws Exception { return new HydrologyRegionalTerrain(new HydrologyPlanner(71L, SETTINGS, terrain));
HydrologyPlannerSettings.Surface surface = SETTINGS.surface();
HydrologyPlannerSettings settings = new HydrologyPlannerSettings(SETTINGS.seaLevel(), SETTINGS.routing(),
new HydrologyPlannerSettings.Surface(surface.enabled(), surface.sources(), surface.minimumWidth(), 256,
surface.minimumDepth(), surface.maximumDepth(), surface.maximumIncision(), surface.shoreWidth(), surface.banks()),
SETTINGS.hydraulics(), SETTINGS.underground(), SETTINGS.outlets(), SETTINGS.geometry(), SETTINGS.deepFluids(),
SETTINGS.surfacePools(), SETTINGS.widestShoreBiomeWidth(), SETTINGS.seaCaves(), SETTINGS.surfacePolicyBounds());
HydrologyTerrainSample ground = new HydrologyTerrainSample(65, 0D, false, false, 0, 0,
true, true, true, false, true, false, 0D, 1D, 1D, 0.75D, 1D, 1D, 1D, 1D,
"land", "land", "land", "land", "land", "land", List.of("default"), List.of(),
Double.NaN, null, Double.NaN, true, SurfaceRiverPolicy.INHERIT);
HydrologyTerrainSampler terrain = (x, z) -> x == 133 ? null : x == 132 && z == 0 ? ground
: HydrologyTerrainSample.openLand(80, 0D, "land");
HydrologyRegionalHydraulics hydraulics = new HydrologyRegionalHydraulics(settings);
HydrologyRegionalHydraulics.HeadStation station = new HydrologyRegionalHydraulics.HeadStation(
132, 0, 1D, 0D, terrain.sample(132, 0), 80);
RecordingSampler serial = new RecordingSampler(terrain);
int expected = hydraulics.supportedHead(station, serial);
RecordingSampler parallel = new RecordingSampler(refiner(terrain).new Samples());
try (ForkJoinPool pool = new ForkJoinPool(4)) {
assertEquals(expected, pool.submit(() -> hydraulics.supportedHead(station, parallel)).get(10, TimeUnit.SECONDS).intValue());
}
assertEquals(serial.requests, parallel.requests);
assertTrue(serial.requests.size() > 2048);
}
@Test
public void routeRefinementMatchesSerialTerrainAndHydraulics() throws Exception {
HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(
x >= 96 && x <= 160 && Math.abs(z) < 32 ? 95 : 70, 0D, "land");
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0));
List<HydrologyPoint> expected = refiner(terrain).refine(guide, "default", false, terrain);
try (ForkJoinPool pool = new ForkJoinPool(4)) {
List<HydrologyPoint> actual = pool.submit(() -> refiner(terrain).refine(guide, "default", false, terrain))
.get(15, TimeUnit.SECONDS);
assertFalse(expected.isEmpty());
assertEquals(expected, actual);
}
}
@Test
public void coastalCenterlineRetainsNegativeAndFractionalTraversalOrder() throws Exception {
HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(64, 0D, "land");
List<HydrologyPoint> guide = List.of(new HydrologyPoint(-173, 64, -117), new HydrologyPoint(174, 64, 138));
List<HydrologyPoint> expected = refiner(terrain).refine(guide, "default", true, terrain);
try (ForkJoinPool pool = new ForkJoinPool(4)) {
assertFalse(expected.isEmpty());
assertEquals(expected, pool.submit(() -> refiner(terrain).refine(guide, "default", true, terrain))
.get(15, TimeUnit.SECONDS));
}
}
@Test
public void rejectedTraversalDoesNotReadALaterProviderFailure() throws Exception {
IllegalStateException failure = new IllegalStateException("Later centerline sample");
AtomicInteger failures = new AtomicInteger();
HydrologyTerrainSampler terrain = (x, z) -> {
if (x == 32 && z == 0) {
failures.incrementAndGet();
throw failure;
}
return z == 0 && (x == 0 || x == 128)
? HydrologyTerrainSample.openLand(70, 0D, "land") : null;
};
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(128, 70, 0));
assertTrue(refiner(terrain).refine(guide, "default", false, terrain).isEmpty());
assertEquals(0, failures.get());
try (ForkJoinPool pool = new ForkJoinPool(4)) {
assertTrue(pool.submit(() -> refiner(terrain).refine(guide, "default", false, terrain))
.get(15, TimeUnit.SECONDS).isEmpty());
assertEquals(0, failures.get());
}
}
@Test
public void clearingAnActiveReachCannotPublishIntoTheNewCache() throws Exception {
AtomicBoolean changed = new AtomicBoolean();
AtomicBoolean blocked = new AtomicBoolean();
CountDownLatch started = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
HydrologyTerrainSampler terrain = (x, z) -> {
boolean elevated = changed.get();
if (x == 64 && z == 0 && blocked.compareAndSet(false, true)) {
started.countDown();
await(release);
}
return HydrologyTerrainSample.openLand(elevated && x == 64 && z == 0 ? 100 : 70, 0D, "land");
};
HydrologyRegionalTerrainRefiner refiner = refiner(terrain);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0));
try (ExecutorService executor = Executors.newSingleThreadExecutor()) {
Future<List<HydrologyPoint>> previous = executor.submit(() -> refiner.refine(guide, "default", false, terrain));
try {
assertTrue(started.await(5, TimeUnit.SECONDS));
changed.set(true);
refiner.clear();
assertEquals(100, refiner.sample(64, 0).naturalHeight());
} finally {
release.countDown();
}
previous.get(15, TimeUnit.SECONDS);
assertEquals(refiner(terrain).refine(guide, "default", false, terrain),
refiner.refine(guide, "default", false, terrain));
assertEquals(100, refiner.sample(64, 0).naturalHeight());
}
}
@Test
public void signedCoordinateTiesRemainStableInEveryDirection() throws Exception {
try (ForkJoinPool pool = new ForkJoinPool(8)) {
for (int[] direction : new int[][]{{1, 0}, {0, 1}, {-1, 0}, {0, -1}}) {
HydrologyTerrainSampler terrain = (x, z) -> {
int along = x * direction[0] + z * direction[1];
int across = -x * direction[1] + z * direction[0];
return HydrologyTerrainSample.openLand(
along >= 96 && along <= 160 && Math.abs(across) < 32 ? 95 : 70, 0D, "land");
};
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0),
new HydrologyPoint(256 * direction[0], 70, 256 * direction[1]));
List<HydrologyPoint> expected = refiner(terrain).refine(guide, "default", false, terrain);
List<HydrologyPoint> actual = pool.submit(() -> refiner(terrain).refine(guide, "default", false, terrain))
.get(15, TimeUnit.SECONDS);
assertFalse(expected.isEmpty());
assertEquals(expected, actual);
}
}
}
private static HydrologyRegionalTerrainRefiner refiner(HydrologyTerrainSampler terrain) {
return new HydrologyRegionalTerrainRefiner(new HydrologyPlanner(71L, SETTINGS, terrain));
} }
private static void await(CountDownLatch latch) { private static void await(CountDownLatch latch) {
@@ -1,169 +0,0 @@
package art.arcane.iris.generation.hydrology;
import org.junit.Test;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class HydrologyRegionalSkippedWaypointTest {
@Test
public void aSkippableHillDoesNotBlockAnEarlierValleyAcceptedByTheFullPlanner() throws Exception {
Set<Integer> hills = Set.of(4);
HydrologyTerrainSampler terrain = terrain(hills, true);
HydrologyPlanner planner = planner(terrain);
List<HydrologyPoint> guide = guide(terrain, hills);
assertCoarseFeasible(planner, terrain, guide);
List<HydrologyPoint> refined = new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, terrain);
assertFalse(refined.isEmpty());
assertFalse(refined.contains(guide.get(4)));
Object context = context(guide, terrain);
HydrologyRegionalRoute.Attempt selected = new HydrologyRegionalRoute(planner).select(guide, "default", false, terrain,
candidate -> attempt(planner, context, candidate));
assertTrue(selected.toString(), selected.accepted());
assertEquals(1, selected.network().courses().size());
assertTrue(selected.network().courses().getFirst().hydraulicallyNonRising());
assertEquals(16, planner.settings.surface().maximumIncision());
}
@Test
public void theTerminalHeadRemainsMandatory() {
HydrologyTerrainSampler highTerminal = terrain(Set.of(8), false);
HydrologyPlanner planner = planner(highTerminal);
List<HydrologyPoint> guide = guide(highTerminal, Set.of(8));
assertCoarseFeasible(planner, highTerminal, guide);
assertTrue(new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, highTerminal).isEmpty());
HydrologyTerrainSampler lowTerminal = terrain(Set.of(), false);
assertFalse(new HydrologyRegionalTerrainRefiner(planner(lowTerminal))
.refine(guide(lowTerminal, Set.of()), "default", false, lowTerminal).isEmpty());
}
@Test
public void threeConsecutiveHighWaypointsCannotBeSkipped() {
Set<Integer> hills = Set.of(4, 5, 6);
HydrologyTerrainSampler terrain = terrain(hills, true);
HydrologyPlanner planner = planner(terrain);
List<HydrologyPoint> guide = guide(terrain, hills);
assertCoarseFeasible(planner, terrain, guide);
assertTrue(new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, terrain).isEmpty());
Set<Integer> twoHills = Set.of(4, 5);
HydrologyTerrainSampler skippable = terrain(twoHills, true);
assertFalse(new HydrologyRegionalTerrainRefiner(planner(skippable))
.refine(guide(skippable, twoHills), "default", false, skippable).isEmpty());
}
@Test
public void aSampledRidgeCannotRaiseTheHeadAfterAnEarlierValley() {
HydrologyTerrainSampler base = terrain(Set.of(), true);
HydrologyTerrainSampler ridge = (x, z) -> x >= 200 && x <= 224
? HydrologyTerrainSample.openLand(95, 0D, "land") : base.sample(x, z);
HydrologyPlanner planner = planner(ridge);
List<HydrologyPoint> guide = guide(ridge, Set.of());
assertCoarseFeasible(planner, ridge, guide);
assertTrue(new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, ridge).isEmpty());
AtomicInteger admitted = new AtomicInteger();
HydrologyRegionalRoute.Attempt selected = new HydrologyRegionalRoute(planner).select(guide, "default", false, ridge,
candidate -> {
admitted.incrementAndGet();
return new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null);
});
assertFalse(selected.accepted());
assertEquals(0, admitted.get());
assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, selected.refinement().rejection());
}
private static void assertCoarseFeasible(HydrologyPlanner planner, HydrologyTerrainSampler terrain, List<HydrologyPoint> guide) {
HydrologyRegionalHydraulics hydraulics = new HydrologyRegionalHydraulics(planner.settings);
int available = Integer.MAX_VALUE;
for (HydrologyPoint point : guide) {
HydrologyTerrainSample sample = terrain.sample(point.x(), point.z());
available = Math.min(available, hydraulics.maximumHead(sample));
assertTrue(point.toString(), available >= hydraulics.minimumHead(sample));
}
}
private static HydrologyRegionalRoute.Attempt attempt(HydrologyPlanner planner, Object context,
HydrologyRegionalRoute.Refinement candidate) {
try {
Method attempt = HydrologyRegionalPlanner.class.getDeclaredMethod("attempt", context.getClass(), HydrologyRegionalRoute.Refinement.class);
attempt.setAccessible(true);
return (HydrologyRegionalRoute.Attempt) attempt.invoke(planner.regional, context, candidate);
} catch (ReflectiveOperationException failure) {
throw new AssertionError(failure);
}
}
private static Object context(List<HydrologyPoint> guide, HydrologyTerrainSampler terrain) throws Exception {
Class<?> type = Class.forName(HydrologyRegionalPlanner.class.getName() + "$BuildContext");
Constructor<?> constructor = type.getDeclaredConstructors()[0];
constructor.setAccessible(true);
int[] contributions = new int[guide.size()];
Arrays.fill(contributions, 1);
RiverOutlet outlet = new RiverOutlet(2L, HydrologyFeatureType.MOUTH, 8L, new HydrologyPoint(1024, 63, 0),
new HydrologyPoint(1025, 63, 0), 63, true);
return constructor.newInstance(new HydrologyGridNode(0, 0, 0, 0, 0, 100L, terrain.sample(0, 0)),
null, 123L, outlet, "default", new HydrologyRegionalFlow(guide, contributions),
HydrologyRegionalMorphology.sample(guide, terrain, HydrologyRegionalPlannerTest.settings(false, 1)));
}
private static List<HydrologyPoint> guide(HydrologyTerrainSampler terrain, Set<Integer> hills) {
ArrayList<HydrologyPoint> guide = new ArrayList<>();
for (int index = 0; index < 9; index++) {
int x = index * 128;
int z = index != 8 && hills.contains(index) ? 128 : 0;
guide.add(new HydrologyPoint(x, terrain.sample(x, z).naturalHeight(), z));
}
return List.copyOf(guide);
}
private static HydrologyTerrainSampler terrain(Set<Integer> hills, boolean coast) {
return (x, z) -> {
if (coast && x >= 1025) {
return HydrologyTerrainSample.ocean(60, "ocean");
}
int height = coast ? 80 - Math.max(0, x - 752) / 16 : 80;
if (x >= 32 && x <= 96) {
height = 75;
}
for (int hill : hills) {
if (Math.abs(x - hill * 128) <= 24 && Math.abs(z - (hill == 8 ? 0 : 128)) <= 24) {
height = 93;
}
}
return HydrologyTerrainSample.openLand(height, 0D, "land");
};
}
private static HydrologyPlanner planner(HydrologyTerrainSampler terrain) {
HydrologyPlannerSettings base = HydrologyRegionalPlannerTest.settings(false, 1);
HydrologyPlannerSettings.Surface surface = base.surface();
HydrologyPlannerSettings settings = new HydrologyPlannerSettings(base.seaLevel(), base.routing(),
new HydrologyPlannerSettings.Surface(surface.enabled(), surface.sources(), surface.minimumWidth(), surface.maximumWidth(),
surface.minimumDepth(), surface.maximumDepth(), 16, surface.shoreWidth(), surface.banks()),
base.hydraulics(), base.underground(), base.outlets(), base.geometry(), base.deepFluids(), base.surfacePools(),
base.widestShoreBiomeWidth(), base.seaCaves(), base.surfacePolicyBounds());
HydrologyGeometrySampler geometry = request -> switch (request.field()) {
case SURFACE_WIDTH -> 8;
case SURFACE_DEPTH -> 2;
default -> request.minimum();
};
return new HydrologyPlanner(71L, settings, terrain, geometry, -4096,
footprint -> new HydrologyTerrainCaveVoxelView(terrain, 63, -4096, 4096));
}
}
@@ -209,7 +209,7 @@ public class HydrologyRegionalSourceParallelismTest {
sources.put(RiverFootprint.pack(node.x(), node.z()), ordinal++); sources.put(RiverFootprint.pack(node.x(), node.z()), ordinal++);
} }
assertEquals(secondCatchmentSize + 4, ordinal); assertEquals(secondCatchmentSize + 4, ordinal);
return new HydrologyRegionalGraph.Tree(labels, contributions, outlets); return new HydrologyRegionalGraph.Tree(labels, contributions, outlets, new int[contributions.length]);
}); });
when(graph.path(any(), anyInt())).thenAnswer(invocation -> List.of(invocation.<Integer>getArgument(1), 0)); when(graph.path(any(), anyInt())).thenAnswer(invocation -> List.of(invocation.<Integer>getArgument(1), 0));
replace(planner.regional, "graph", graph); replace(planner.regional, "graph", graph);
@@ -92,7 +92,7 @@ public class HydrologyRegionalSourceTrialsTest {
new RiverOutlet(100L + root, HydrologyFeatureType.MOUTH, root, point, point, 63, true))); new RiverOutlet(100L + root, HydrologyFeatureType.MOUTH, root, point, point, 63, true)));
} }
HydrologySampledGrid grid = new HydrologySampledGrid(0, 0, 0, 0, 16384, sources.size(), 1, nodes); HydrologySampledGrid grid = new HydrologySampledGrid(0, 0, 0, 0, 16384, sources.size(), 1, nodes);
HydrologyRegionalGraph.Tree tree = new HydrologyRegionalGraph.Tree(selected, new int[sources.size()], outlets); HydrologyRegionalGraph.Tree tree = new HydrologyRegionalGraph.Tree(selected, new int[sources.size()], outlets, new int[sources.size()]);
return new Fixture(grid, tree, indices); return new Fixture(grid, tree, indices);
} }
@@ -249,7 +249,7 @@ public class HydrologyRegionalSurfacePolicyTest {
candidates.add(new OutletCandidate(node.index(), -1, new RiverOutlet(node.index() + 1000L, candidates.add(new OutletCandidate(node.index(), -1, new RiverOutlet(node.index() + 1000L,
HydrologyFeatureType.MOUTH, node.id(), point, point, 63, true))); HydrologyFeatureType.MOUTH, node.id(), point, point, 63, true)));
} }
return new HydrologyRoutingPlan(potential, parent, outlets, lengths, List.copyOf(candidates), false); return new HydrologyRoutingPlan(potential, parent, outlets, lengths, List.copyOf(candidates), false, null);
} }
private static HydrologyPlannerSettings withSources(HydrologyPlannerSettings.Source sources) { private static HydrologyPlannerSettings withSources(HydrologyPlannerSettings.Source sources) {
@@ -1,541 +0,0 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.policy.SurfaceRiverPolicy;
import art.arcane.iris.generation.hydrology.surface.SurfaceCenterline;
import org.junit.Test;
import java.util.List;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.lang.reflect.Method;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class HydrologyRegionalTerrainRefinerTest {
@Test(timeout = 10000)
public void clearingDuringSamplingCannotPublishOldTerrain() throws Exception {
CountDownLatch started = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
AtomicInteger calls = new AtomicInteger();
HydrologyTerrainSampler terrain = (x, z) -> {
int call = calls.incrementAndGet();
if (call == 1) {
started.countDown();
try {
assertTrue(release.await(5, TimeUnit.SECONDS));
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new IllegalStateException(interrupted);
}
}
return HydrologyTerrainSample.openLand(call == 1 ? 70 : 80, 0D, "land");
};
HydrologyRegionalTerrainRefiner refiner = new HydrologyRegionalTerrainRefiner(
new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain));
try (ExecutorService workers = Executors.newSingleThreadExecutor()) {
Future<HydrologyTerrainSample> first = workers.submit(() -> refiner.sample(0, 0));
try {
assertTrue(started.await(3, TimeUnit.SECONDS));
refiner.clear();
assertEquals(80, refiner.sample(0, 0).naturalHeight());
} finally {
release.countDown();
}
assertEquals(70, first.get(5, TimeUnit.SECONDS).naturalHeight());
assertEquals(80, refiner.sample(0, 0).naturalHeight());
assertEquals(2, calls.get());
}
}
@Test(timeout = 10000)
public void independentDiagonalSamplesDoNotWaitForEachOthersTerrain() throws Exception {
CountDownLatch sampling = new CountDownLatch(2);
AtomicInteger calls = new AtomicInteger();
HydrologyTerrainSampler terrain = (x, z) -> {
calls.incrementAndGet();
sampling.countDown();
try {
assertTrue("Independent terrain samples were serialized", sampling.await(3, TimeUnit.SECONDS));
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new IllegalStateException(interrupted);
}
return HydrologyTerrainSample.openLand(70 + x, 0D, "land");
};
HydrologyRegionalTerrainRefiner refiner = new HydrologyRegionalTerrainRefiner(
new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain));
try (ExecutorService workers = Executors.newFixedThreadPool(2)) {
Future<HydrologyTerrainSample> first = workers.submit(() -> refiner.sample(0, 0));
Future<HydrologyTerrainSample> second = workers.submit(() -> refiner.sample(1, 1));
assertEquals(70, first.get(5, TimeUnit.SECONDS).naturalHeight());
assertEquals(71, second.get(5, TimeUnit.SECONDS).naturalHeight());
assertEquals(first.get(), refiner.sample(0, 0));
assertEquals(second.get(), refiner.sample(1, 1));
assertEquals(2, calls.get());
}
}
@Test
public void aHigherHeadDetourSurvivesACheaperInfeasibleArrival() {
int[][] heights = {
{86, 94, 77, 91, 75, 85, 82, 88, 74},
{84, 94, 83, 85, 72, 76, 73, 80, 72},
{73, 85, 78, 77, 73, 82, 84, 81, 79},
{83, 77, 92, 81, 84, 80, 93, 94, 96},
{90, 73, 76, 84, 74, 73, 91, 80, 85},
{91, 96, 90, 75, 91, 84, 94, 96, 95},
{84, 76, 80, 92, 72, 73, 96, 75, 86},
{94, 93, 86, 81, 80, 89, 75, 93, 77},
{85, 95, 85, 73, 88, 82, 81, 94, 92}
};
HydrologyTerrainSampler terrain = (x, z) -> {
int gridX = Math.floorDiv(x, 16);
int gridZ = Math.floorDiv(z, 16) + 4;
return gridX < 0 || gridX >= 9 || gridZ < 0 || gridZ >= 9
? HydrologyTerrainSample.ocean(60, "outside")
: HydrologyTerrainSample.openLand(heights[gridZ][gridX], 0D, "land");
};
HydrologyPlanner planner = new HydrologyPlanner(1L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 90, 0), new HydrologyPoint(128, 85, 0));
HydrologyRegionalTerrainRefiner refiner = new HydrologyRegionalTerrainRefiner(planner);
List<HydrologyPoint> path = refiner.refine(guide, "default", false, terrain);
assertFalse(path.isEmpty());
assertEquals(guide.getFirst(), path.getFirst());
assertEquals(guide.getLast(), path.getLast());
HydrologyRegionalHydraulics hydraulics = new HydrologyRegionalHydraulics(planner.settings);
SurfaceCenterline centerline = SurfaceCenterline.densify(path);
int available = 90;
for (int station = 0; station < centerline.size(); station++) {
HydrologyTerrainSample sample = terrain.sample(centerline.x()[station], centerline.z()[station]);
assertFalse(sample.ocean());
int supported = hydraulics.supportedHead(new HydrologyRegionalHydraulics.HeadStation(
centerline.x()[station], centerline.z()[station], centerline.tangentX()[station],
centerline.tangentZ()[station], sample, available), terrain);
assertTrue(supported <= available);
available = supported;
assertTrue("station=" + station, available >= hydraulics.minimumHead(sample));
}
assertEquals(path, refiner.refine(guide, "default", false, terrain));
assertEquals(path, new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, terrain));
}
@Test
public void shortRepairedPrefixDoesNotHideALongEnoughGuide() {
HydrologyTerrainSampler terrain = (x, z) -> x >= 406 ? HydrologyTerrainSample.ocean(60, "ocean")
: HydrologyTerrainSample.openLand(x >= 400 ? 60 : 70, 0D, "land");
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(0, 70, 96),
new HydrologyPoint(128, 70, 96), new HydrologyPoint(256, 70, 96), new HydrologyPoint(256, 70, 0),
new HydrologyPoint(512, 70, 0));
HydrologyRegionalRoute.Refinement selected = new HydrologyRegionalRoute(planner).select(guide, "default", false, terrain,
candidate -> {
double length = 0D;
for (int index = 1; index < candidate.points().size(); index++) {
length += StrictMath.sqrt(candidate.points().get(index - 1).distanceSquared2D(candidate.points().get(index)));
}
assertTrue(length >= planner.settings.routing().regional().minimumLength());
return new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null);
}).refinement();
assertNull(selected.toString(), selected.rejection());
assertEquals(new HydrologyPoint(399, 63, 0), selected.oceanEntry().landward());
assertEquals(new HydrologyPoint(406, 63, 0), selected.oceanEntry().receiving());
}
@Test
public void onlyTheRetainedDryPrefixNeedsTerrainRepair() {
HydrologyTerrainSampler terrain = (x, z) -> x >= 806 ? HydrologyTerrainSample.ocean(60, "ocean")
: HydrologyTerrainSample.openLand(x >= 800 ? 60 : x >= 96 && x <= 160 && Math.abs(z) < 32 ? 86 : 70,
0D, "land");
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0),
new HydrologyPoint(512, 70, 0), new HydrologyPoint(1024, 70, 0));
assertTrue(new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, terrain).isEmpty());
HydrologyRegionalRoute.Refinement repaired = new HydrologyRegionalRoute(planner).select(guide, "default", false, terrain, candidate -> new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null)).refinement();
assertNull(repaired.toString(), repaired.rejection());
assertEquals(new HydrologyPoint(799, 63, 0), repaired.oceanEntry().landward());
assertEquals(new HydrologyPoint(806, 63, 0), repaired.oceanEntry().receiving());
assertTrue(repaired.points().stream().anyMatch(point -> Math.abs(point.z()) >= 32));
assertTrue(repaired.points().stream().allMatch(point -> point.x() < 800));
}
@Test
public void aCoarseOceanHitBeforeMinimumLengthStillAllowsADryTerrainDetour() {
HydrologyTerrainSampler terrain = (x, z) -> x >= 800 || x >= 96 && x <= 160 && Math.abs(z) < 32
? HydrologyTerrainSample.ocean(60, "ocean") : HydrologyTerrainSample.openLand(70, 0D, "land");
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0),
new HydrologyPoint(512, 70, 0), new HydrologyPoint(799, 70, 0));
HydrologyRegionalRoute.Refinement repaired = new HydrologyRegionalRoute(planner).select(guide, "default", false, terrain, candidate -> new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null)).refinement();
assertNull(repaired.toString(), repaired.rejection());
assertTrue(repaired.points().stream().anyMatch(point -> Math.abs(point.z()) >= 32));
assertEquals(guide.getLast(), repaired.points().getLast());
}
@Test
public void naturallyFloodedLandCannotBeUsedAsAnOwnedDryReach() {
HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(
x >= 96 && x <= 160 && Math.abs(z) < 32 ? 60 : 70, 0D, "land");
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0));
List<HydrologyPoint> repaired = new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, terrain);
assertFalse(repaired.isEmpty());
SurfaceCenterline centerline = SurfaceCenterline.densify(repaired);
for (int station = 0; station < centerline.size(); station++) {
HydrologyTerrainSample sampled = terrain.sample(centerline.x()[station], centerline.z()[station]);
assertFalse(sampled.ocean());
assertTrue(sampled.naturalHeight() >= planner.settings.seaLevel());
}
}
@Test
public void repeatedCoarseAnchorsCannotCreateRetracedWaterways() {
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1),
(x, z) -> HydrologyTerrainSample.openLand(70, 0D, "land"));
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(128, 70, 0),
new HydrologyPoint(128, 70, 128), new HydrologyPoint(128, 70, 0), new HydrologyPoint(256, 70, 0));
List<HydrologyPoint> refined = new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, planner.sampler);
assertEquals(List.of(guide.getFirst(), guide.getLast()), refined);
}
@Test
public void redundantCoarseAnchorHairpinsUseTheClearTerrainShortcut() {
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1),
(x, z) -> HydrologyTerrainSample.openLand(70, 0D, "land"));
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(128, 70, 0),
new HydrologyPoint(160, 70, 32), new HydrologyPoint(144, 70, 48), new HydrologyPoint(160, 70, 48),
new HydrologyPoint(192, 70, 16), new HydrologyPoint(256, 70, 0));
List<HydrologyPoint> refined = new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, planner.sampler);
assertEquals(guide.getFirst(), refined.getFirst());
assertEquals(guide.getLast(), refined.getLast());
assertTrue(refined.size() <= 3);
for (int index = 1; index < refined.size(); index++) {
assertTrue(refined.get(index).x() > refined.get(index - 1).x());
}
}
@Test
public void repairsUnsampledOceanPocketsAtPositiveAndNegativeCoordinates() {
for (int origin : new int[]{0, -512}) {
HydrologyTerrainSampler terrain = (x, z) -> x >= origin + 96 && x <= origin + 160 && Math.abs(z) < 32
? HydrologyTerrainSample.ocean(60, "ocean") : HydrologyTerrainSample.openLand(70, 0D, "land");
List<HydrologyPoint> guide = List.of(new HydrologyPoint(origin, 70, 0), new HydrologyPoint(origin + 256, 70, 0));
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
HydrologyRegionalTerrainRefiner refiner = new HydrologyRegionalTerrainRefiner(planner);
List<HydrologyPoint> first = refiner.refine(guide, "default", false, planner.sampler);
assertFalse(first.isEmpty());
assertEquals(guide.getFirst(), first.getFirst());
assertEquals(guide.getLast(), first.getLast());
SurfaceCenterline centerline = SurfaceCenterline.densify(first);
for (int station = 0; station < centerline.size(); station++) {
assertFalse(terrain.sample(centerline.x()[station], centerline.z()[station]).ocean());
}
assertEquals(first, refiner.refine(guide, "default", false, planner.sampler));
assertEquals(first, new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, planner.sampler));
}
}
@Test
public void aWaterCourseDetoursAroundALavaOnlyBiome() {
HydrologyTerrainSampler terrain = (x, z) -> land(x >= 96 && x <= 160 && Math.abs(z) < 32 ? "lava" : "water");
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0));
List<HydrologyPoint> refined = new HydrologyRegionalTerrainRefiner(planner).refine(guide, "water", false, planner.sampler);
assertFalse(refined.isEmpty());
SurfaceCenterline centerline = SurfaceCenterline.densify(refined);
for (int station = 0; station < centerline.size(); station++) {
assertEquals(List.of("water"), terrain.sample(centerline.x()[station], centerline.z()[station]).preferredProfileKeys());
}
}
@Test
public void repairedTerrainRouteStillPassesOrganicShapeValidation() {
HydrologyTerrainSampler terrain = (x, z) -> x >= 96 && x <= 160 && Math.abs(z) < 32
? HydrologyTerrainSample.ocean(60, "ocean") : HydrologyTerrainSample.openLand(70, 0D, "land");
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
HydrologyRegionalRoute.Refinement refined = new HydrologyRegionalRoute(planner).select(
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0)), "default", false, planner.sampler, candidate -> new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null)).refinement();
assertNull(refined.toString(), refined.rejection());
assertTrue(refined.points().stream().anyMatch(point -> Math.abs(point.z()) >= 32));
}
@Test
public void anUnavoidableOceanBarrierRejectsWithinTheTerrainSampleBudget() {
AtomicInteger calls = new AtomicInteger();
HydrologyTerrainSampler terrain = (x, z) -> {
calls.incrementAndGet();
return x >= 96 && x <= 160 ? HydrologyTerrainSample.ocean(60, "ocean")
: HydrologyTerrainSample.openLand(70, 0D, "land");
};
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
List<HydrologyPoint> refined = new HydrologyRegionalTerrainRefiner(planner).refine(
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0)), "default", false, planner.sampler);
assertTrue(refined.isEmpty());
assertTrue("terrain samples=" + calls.get(), calls.get() <= 65536);
}
@Test
public void unavailableTerrainIsSampledOnceAndCountsTowardTheBudget() {
Map<Long, Integer> unavailable = new HashMap<>();
AtomicInteger calls = new AtomicInteger();
HydrologyTerrainSampler terrain = (x, z) -> {
calls.incrementAndGet();
if (x >= 96 && x <= 160 && Math.abs(z) < 32) {
unavailable.merge(RiverFootprint.pack(x, z), 1, Integer::sum);
return null;
}
return HydrologyTerrainSample.openLand(70, 0D, "land");
};
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
List<HydrologyPoint> repaired = new HydrologyRegionalTerrainRefiner(planner).refine(
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0)), "default", false, planner.sampler);
assertFalse(repaired.isEmpty());
assertFalse(unavailable.isEmpty());
assertTrue(unavailable.values().stream().allMatch(count -> count == 1));
assertTrue(calls.get() <= 65536);
}
@Test
public void anUnsampledRidgeUsesTheLowerValleyWithoutIncreasingIncision() {
HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(
x >= 96 && x <= 160 && Math.abs(z) < 32 ? 94 : 70, 0D, "land");
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0));
List<HydrologyPoint> repaired = new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, planner.sampler);
assertFalse(repaired.isEmpty());
SurfaceCenterline centerline = SurfaceCenterline.densify(repaired);
for (int station = 0; station < centerline.size(); station++) {
assertEquals(70, terrain.sample(centerline.x()[station], centerline.z()[station]).naturalHeight());
}
}
@Test
public void anImpossibleIntermediateAnchorCanBeSkippedWithinTheSameValley() {
HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(
x < 48 || x >= 120 && x <= 136 && Math.abs(z) < 8 ? 90 : 65, 0D, "land");
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 90, 0), new HydrologyPoint(128, 90, 0),
new HydrologyPoint(256, 65, 0));
HydrologyRegionalTerrainRefiner refiner = new HydrologyRegionalTerrainRefiner(planner);
List<HydrologyPoint> repaired = refiner.refine(guide, "default", false, planner.sampler);
assertFalse(repaired.isEmpty());
assertEquals(guide.getFirst(), repaired.getFirst());
assertEquals(guide.getLast(), repaired.getLast());
assertFalse(repaired.contains(guide.get(1)));
SurfaceCenterline centerline = SurfaceCenterline.densify(repaired);
HydrologyRegionalHydraulics hydraulics = new HydrologyRegionalHydraulics(planner.settings);
int head = Integer.MAX_VALUE;
for (int station = 0; station < centerline.size(); station++) {
HydrologyTerrainSample sample = terrain.sample(centerline.x()[station], centerline.z()[station]);
head = Math.min(head, hydraulics.maximumHead(sample));
assertTrue(hydraulics.minimumHead(sample) <= head);
}
assertEquals(repaired, refiner.refine(guide, "default", false, planner.sampler));
}
@Test
public void aLowFineDetourCannotResetTheHeadBeforeTheNextRidge() {
HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(
x >= 96 && x <= 160 && Math.abs(z) < 32 ? 65 : x >= 384 ? 92 : 80, 0D, "land");
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 80, 0), new HydrologyPoint(256, 80, 0),
new HydrologyPoint(512, 92, 0));
List<HydrologyPoint> repaired = new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, planner.sampler);
assertFalse(repaired.isEmpty());
assertTrue(repaired.stream().anyMatch(point -> Math.abs(point.z()) >= 32));
SurfaceCenterline centerline = SurfaceCenterline.densify(repaired);
HydrologyRegionalHydraulics hydraulics = new HydrologyRegionalHydraulics(planner.settings);
int head = Integer.MAX_VALUE;
for (int station = 0; station < centerline.size(); station++) {
HydrologyTerrainSample sample = terrain.sample(centerline.x()[station], centerline.z()[station]);
head = Math.min(head, hydraulics.maximumHead(sample));
assertTrue(hydraulics.minimumHead(sample) <= head);
}
}
@Test
public void ridgeRepairRespectsTheLocalIncisionPolicy() {
SurfaceRiverPolicy limited = new SurfaceRiverPolicy("valley", null, null, null, null, null, null, 4);
HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(
x >= 96 && x <= 160 && Math.abs(z) < 32 ? 76 : 70, 0D, "land").withSurfacePolicy(limited);
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
List<HydrologyPoint> repaired = new HydrologyRegionalTerrainRefiner(planner).refine(
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0)), "default", false, planner.sampler);
assertFalse(repaired.isEmpty());
assertTrue(repaired.stream().anyMatch(point -> Math.abs(point.z()) >= 32));
}
@Test
public void detourLengthRejectsBeforeDetailedSamplingOfTheRemainingCourse() {
AtomicInteger maximumX = new AtomicInteger(Integer.MIN_VALUE);
HydrologyTerrainSampler terrain = (x, z) -> {
boolean coarseAnchor = Math.floorMod(x + 1, 256) <= 2 && Math.abs(z) <= 1;
if (!coarseAnchor) {
maximumX.accumulateAndGet(x, Math::max);
}
return x >= 96 && x <= 160 && Math.abs(z) < 32 ? HydrologyTerrainSample.ocean(60, "ocean")
: HydrologyTerrainSample.openLand(70, 0D, "land");
};
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
ArrayList<HydrologyPoint> guide = new ArrayList<>();
for (int x = 0; x <= planner.settings.routing().maximumRouteLength(); x += 256) {
guide.add(new HydrologyPoint(x, 70, 0));
}
assertTrue(new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, planner.sampler).isEmpty());
assertTrue("A rejected first detour must not sample later reaches", maximumX.get() < 512);
}
@Test
public void anAlreadyOverlongGuideDoesNotSampleTerrain() {
AtomicInteger calls = new AtomicInteger();
HydrologyTerrainSampler terrain = (x, z) -> {
calls.incrementAndGet();
return HydrologyTerrainSample.openLand(70, 0D, "land");
};
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
int before = calls.get();
assertTrue(new HydrologyRegionalTerrainRefiner(planner).refine(
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(4096, 70, 0)), "default", false, planner.sampler).isEmpty());
assertEquals(before, calls.get());
}
@Test
public void clearancePreferenceDoesNotExcludeTheOnlyNarrowCorridor() {
HydrologyTerrainSampler terrain = (x, z) -> {
boolean horizontal = Math.abs(z) <= 3 && (x <= 80 || x >= 176);
boolean detour = Math.abs(z - 16) <= 3 && x >= 61 && x <= 195;
boolean vertical = (Math.abs(x - 64) <= 3 || Math.abs(x - 192) <= 3) && z >= -3 && z <= 19;
return land(horizontal || detour || vertical ? "water" : "lava");
};
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
List<HydrologyPoint> repaired = new HydrologyRegionalTerrainRefiner(planner).refine(
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0)), "water", false, planner.sampler);
assertFalse(repaired.isEmpty());
SurfaceCenterline centerline = SurfaceCenterline.densify(repaired);
for (int station = 0; station < centerline.size(); station++) {
assertEquals(List.of("water"), terrain.sample(centerline.x()[station], centerline.z()[station]).preferredProfileKeys());
}
}
@Test
public void oceanDetourPreservesTheShippingTwentyDegreeTurnLimit() {
HydrologyTerrainSampler terrain = (x, z) -> x >= 96 && x <= 160 && Math.abs(z) < 32
? HydrologyTerrainSample.ocean(60, "ocean") : HydrologyTerrainSample.openLand(70, 0D, "land");
HydrologyPlanner planner = new HydrologyPlanner(15L, withMeanders(
new HydrologyPlannerSettings.Meanders(64, 12, 0.34D, 0.42D, 0.48D, 1, 20D)), terrain);
HydrologyRegionalRoute.Refinement repaired = new HydrologyRegionalRoute(planner).select(
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0)), "default", false, planner.sampler, candidate -> new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null)).refinement();
assertNull(repaired.toString(), repaired.rejection());
assertTrue(repaired.points().stream().anyMatch(point -> Math.abs(point.z()) >= 32));
}
@Test
public void shortRepairReversalsReceiveEnoughReachToMeetTwentyDegrees() {
HydrologyPlanner planner = new HydrologyPlanner(15L, withMeanders(
new HydrologyPlannerSettings.Meanders(64, 12, 0D, 0D, 0D, 1, 20D)),
(x, z) -> HydrologyTerrainSample.openLand(70, 0D, "land"));
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(128, 70, 0),
new HydrologyPoint(160, 70, 32), new HydrologyPoint(160, 70, 16),
new HydrologyPoint(192, 70, 16), new HydrologyPoint(192, 70, 128), new HydrologyPoint(512, 70, 128));
HydrologyRegionalRoute.Refinement repaired = new HydrologyRegionalRoute(planner).select(guide, "default", false, planner.sampler, candidate -> new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null)).refinement();
assertNull(repaired.toString(), repaired.rejection());
assertEquals(guide.getFirst(), repaired.points().getFirst());
assertEquals(guide.getLast(), repaired.points().getLast());
}
@Test
public void roundedCurveTurnLimitIsMeasuredBeforeBlockQuantization() throws Exception {
HydrologyPlanner planner = new HydrologyPlanner(15L, withMeanders(
new HydrologyPlannerSettings.Meanders(64, 12, 0D, 0D, 0D, 0, 20D)),
(x, z) -> HydrologyTerrainSample.openLand(70, 0D, "land"));
HydrologyRegionalRoute route = new HydrologyRegionalRoute(planner);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(128, 70, 0),
new HydrologyPoint(128, 70, 128), new HydrologyPoint(256, 70, 128));
Method interpolate = HydrologyRegionalRoute.class.getDeclaredMethod("interpolateLinear", List.class);
Method round = HydrologyRegionalRoute.class.getDeclaredMethod("roundBends", List.class, double.class);
Method resample = HydrologyRegionalRoute.class.getDeclaredMethod("resample", List.class);
Method validate = HydrologyRegionalRoute.class.getDeclaredMethod("validateRounded", List.class, String.class, boolean.class, HydrologyTerrainSampler.class);
interpolate.setAccessible(true);
round.setAccessible(true);
resample.setAccessible(true);
validate.setAccessible(true);
Object linear = interpolate.invoke(route, guide);
Object rounded = round.invoke(route, linear, 128D);
Object curve = resample.invoke(route, rounded);
HydrologyRegionalRoute.Refinement accepted = (HydrologyRegionalRoute.Refinement) validate.invoke(route, curve, "default", false, planner.sampler);
assertNull(accepted.toString(), accepted.rejection());
assertEquals(guide.getFirst(), accepted.points().getFirst());
assertEquals(guide.getLast(), accepted.points().getLast());
Object sharper = resample.invoke(route, round.invoke(route, linear, 64D));
HydrologyRegionalRoute.Refinement rejected = (HydrologyRegionalRoute.Refinement) validate.invoke(route, sharper, "default", false, planner.sampler);
assertEquals(HydrologyCandidateRejection.SURFACE_SHAPE_UNSUPPORTED, rejected.rejection());
}
@Test
public void splineOvershootCannotDiscardAValidNarrowDetour() {
HydrologyTerrainSampler terrain = (x, z) -> {
boolean first = Math.abs(z) <= 8 && x >= -8 && x <= 136;
boolean middle = Math.abs(x - 128) <= 8 && z >= -8 && z <= 136;
boolean last = Math.abs(z - 128) <= 8 && x >= 120 && x <= 264;
return land(first || middle || last ? "water" : "lava");
};
HydrologyPlanner planner = new HydrologyPlanner(15L, withMeanders(
new HydrologyPlannerSettings.Meanders(64, 12, 0D, 0D, 0D, 0, 150D)), terrain);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(128, 70, 0),
new HydrologyPoint(128, 70, 128), new HydrologyPoint(256, 70, 128));
assertEquals(guide, new HydrologyRegionalTerrainRefiner(planner).refine(guide, "water", false, planner.sampler));
HydrologyRegionalRoute.Refinement repaired = new HydrologyRegionalRoute(planner).select(guide, "water", false, planner.sampler, candidate -> new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null)).refinement();
assertNull(repaired.toString(), repaired.rejection());
SurfaceCenterline centerline = SurfaceCenterline.densify(repaired.points());
for (int station = 0; station < centerline.size(); station++) {
assertEquals(List.of("water"), terrain.sample(centerline.x()[station], centerline.z()[station]).preferredProfileKeys());
}
}
private static HydrologyPlannerSettings withMeanders(HydrologyPlannerSettings.Meanders meanders) {
HydrologyPlannerSettings settings = HydrologyRegionalPlannerTest.settings(false, 1);
HydrologyPlannerSettings.Geometry geometry = settings.geometry();
return new HydrologyPlannerSettings(settings.seaLevel(), settings.routing(), settings.surface(), settings.hydraulics(),
settings.underground(), settings.outlets(), new HydrologyPlannerSettings.Geometry(meanders,
geometry.surface(), geometry.underground(), geometry.grottos(), geometry.drops()), settings.deepFluids(),
settings.surfacePools(), settings.widestShoreBiomeWidth(), settings.seaCaves(), settings.surfacePolicyBounds());
}
private static HydrologyTerrainSample land(String profile) {
return new HydrologyTerrainSample(70, 0D, false, false, 38, 40,
true, true, true, false, false, false,
0D, 1D, 1D, 1D, 1D, 1D, 1D, 1D,
"land", "land", "land", "land", "land", "land", List.of(profile), List.of(),
Double.NaN, null, Double.NaN, true, SurfaceRiverPolicy.INHERIT);
}
}
@@ -0,0 +1,192 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.policy.SurfaceRiverPolicy;
import art.arcane.iris.generation.hydrology.surface.SurfaceCenterline;
import org.junit.Test;
import java.util.List;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.lang.reflect.Method;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class HydrologyRegionalTerrainTest {
@Test(timeout = 10000)
public void clearingDuringSamplingCannotPublishOldTerrain() throws Exception {
CountDownLatch started = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
AtomicInteger calls = new AtomicInteger();
HydrologyTerrainSampler terrain = (x, z) -> {
int call = calls.incrementAndGet();
if (call == 1) {
started.countDown();
try {
assertTrue(release.await(5, TimeUnit.SECONDS));
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new IllegalStateException(interrupted);
}
}
return HydrologyTerrainSample.openLand(call == 1 ? 70 : 80, 0D, "land");
};
HydrologyRegionalTerrain refiner = new HydrologyRegionalTerrain(
new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain));
try (ExecutorService workers = Executors.newSingleThreadExecutor()) {
Future<HydrologyTerrainSample> first = workers.submit(() -> refiner.sample(0, 0));
try {
assertTrue(started.await(3, TimeUnit.SECONDS));
refiner.clear();
assertEquals(80, refiner.sample(0, 0).naturalHeight());
} finally {
release.countDown();
}
assertEquals(70, first.get(5, TimeUnit.SECONDS).naturalHeight());
assertEquals(80, refiner.sample(0, 0).naturalHeight());
assertEquals(2, calls.get());
}
}
@Test(timeout = 10000)
public void independentDiagonalSamplesDoNotWaitForEachOthersTerrain() throws Exception {
CountDownLatch sampling = new CountDownLatch(2);
AtomicInteger calls = new AtomicInteger();
HydrologyTerrainSampler terrain = (x, z) -> {
calls.incrementAndGet();
sampling.countDown();
try {
assertTrue("Independent terrain samples were serialized", sampling.await(3, TimeUnit.SECONDS));
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new IllegalStateException(interrupted);
}
return HydrologyTerrainSample.openLand(70 + x, 0D, "land");
};
HydrologyRegionalTerrain refiner = new HydrologyRegionalTerrain(
new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain));
try (ExecutorService workers = Executors.newFixedThreadPool(2)) {
Future<HydrologyTerrainSample> first = workers.submit(() -> refiner.sample(0, 0));
Future<HydrologyTerrainSample> second = workers.submit(() -> refiner.sample(1, 1));
assertEquals(70, first.get(5, TimeUnit.SECONDS).naturalHeight());
assertEquals(71, second.get(5, TimeUnit.SECONDS).naturalHeight());
assertEquals(first.get(), refiner.sample(0, 0));
assertEquals(second.get(), refiner.sample(1, 1));
assertEquals(2, calls.get());
}
}
@Test
public void shortRepairedPrefixDoesNotHideALongEnoughGuide() {
HydrologyTerrainSampler terrain = (x, z) -> x >= 406 ? HydrologyTerrainSample.ocean(60, "ocean")
: HydrologyTerrainSample.openLand(x >= 400 ? 60 : 70, 0D, "land");
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(0, 70, 96),
new HydrologyPoint(128, 70, 96), new HydrologyPoint(256, 70, 96), new HydrologyPoint(256, 70, 0),
new HydrologyPoint(512, 70, 0));
HydrologyRegionalRoute.Refinement selected = new HydrologyRegionalRoute(planner).select(guide, "default", false, terrain,
candidate -> {
double length = 0D;
for (int index = 1; index < candidate.points().size(); index++) {
length += StrictMath.sqrt(candidate.points().get(index - 1).distanceSquared2D(candidate.points().get(index)));
}
assertTrue(length >= planner.settings.routing().regional().minimumLength());
return new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null);
}).refinement();
assertNull(selected.toString(), selected.rejection());
assertEquals(new HydrologyPoint(399, 63, 0), selected.oceanEntry().landward());
assertEquals(new HydrologyPoint(406, 63, 0), selected.oceanEntry().receiving());
}
@Test
public void shortRepairReversalsReceiveEnoughReachToMeetTwentyDegrees() {
HydrologyPlanner planner = new HydrologyPlanner(15L, withMeanders(
new HydrologyPlannerSettings.Meanders(64, 12, 0D, 0D, 0D, 1, 20D)),
(x, z) -> HydrologyTerrainSample.openLand(70, 0D, "land"));
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(128, 70, 0),
new HydrologyPoint(160, 70, 32), new HydrologyPoint(160, 70, 16),
new HydrologyPoint(192, 70, 16), new HydrologyPoint(192, 70, 128), new HydrologyPoint(512, 70, 128));
HydrologyRegionalRoute.Refinement repaired = new HydrologyRegionalRoute(planner).select(guide, "default", false, planner.sampler, candidate -> new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null)).refinement();
assertNull(repaired.toString(), repaired.rejection());
assertEquals(guide.getFirst(), repaired.points().getFirst());
assertEquals(guide.getLast(), repaired.points().getLast());
}
@Test
public void roundedCurveTurnLimitIsMeasuredBeforeBlockQuantization() throws Exception {
HydrologyPlanner planner = new HydrologyPlanner(15L, withMeanders(
new HydrologyPlannerSettings.Meanders(64, 12, 0D, 0D, 0D, 0, 20D)),
(x, z) -> HydrologyTerrainSample.openLand(70, 0D, "land"));
HydrologyRegionalRoute route = new HydrologyRegionalRoute(planner);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(128, 70, 0),
new HydrologyPoint(128, 70, 128), new HydrologyPoint(256, 70, 128));
Method interpolate = HydrologyRegionalRoute.class.getDeclaredMethod("interpolateLinear", List.class);
Method round = HydrologyRegionalRoute.class.getDeclaredMethod("roundBends", List.class, double.class);
Method resample = HydrologyRegionalRoute.class.getDeclaredMethod("resample", List.class);
Method validate = HydrologyRegionalRoute.class.getDeclaredMethod("validateRounded", List.class, String.class, boolean.class, HydrologyTerrainSampler.class);
interpolate.setAccessible(true);
round.setAccessible(true);
resample.setAccessible(true);
validate.setAccessible(true);
Object linear = interpolate.invoke(route, guide);
Object rounded = round.invoke(route, linear, 128D);
Object curve = resample.invoke(route, rounded);
HydrologyRegionalRoute.Refinement accepted = (HydrologyRegionalRoute.Refinement) validate.invoke(route, curve, "default", false, planner.sampler);
assertNull(accepted.toString(), accepted.rejection());
assertEquals(guide.getFirst(), accepted.points().getFirst());
assertEquals(guide.getLast(), accepted.points().getLast());
Object sharper = resample.invoke(route, round.invoke(route, linear, 64D));
HydrologyRegionalRoute.Refinement rejected = (HydrologyRegionalRoute.Refinement) validate.invoke(route, sharper, "default", false, planner.sampler);
assertEquals(HydrologyCandidateRejection.SURFACE_SHAPE_UNSUPPORTED, rejected.rejection());
}
@Test
public void splineOvershootCannotDiscardAValidNarrowDetour() {
HydrologyTerrainSampler terrain = (x, z) -> {
boolean first = Math.abs(z) <= 8 && x >= -8 && x <= 136;
boolean middle = Math.abs(x - 128) <= 8 && z >= -8 && z <= 136;
boolean last = Math.abs(z - 128) <= 8 && x >= 120 && x <= 264;
return land(first || middle || last ? "water" : "lava");
};
HydrologyPlanner planner = new HydrologyPlanner(15L, withMeanders(
new HydrologyPlannerSettings.Meanders(64, 12, 0D, 0D, 0D, 0, 150D)), terrain);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(128, 70, 0),
new HydrologyPoint(128, 70, 128), new HydrologyPoint(256, 70, 128));
HydrologyRegionalRoute.Refinement repaired = new HydrologyRegionalRoute(planner).select(guide, "water", false, planner.sampler, candidate -> new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null)).refinement();
assertNull(repaired.toString(), repaired.rejection());
SurfaceCenterline centerline = SurfaceCenterline.densify(repaired.points());
for (int station = 0; station < centerline.size(); station++) {
assertEquals(List.of("water"), terrain.sample(centerline.x()[station], centerline.z()[station]).preferredProfileKeys());
}
}
private static HydrologyPlannerSettings withMeanders(HydrologyPlannerSettings.Meanders meanders) {
HydrologyPlannerSettings settings = HydrologyRegionalPlannerTest.settings(false, 1);
HydrologyPlannerSettings.Geometry geometry = settings.geometry();
return new HydrologyPlannerSettings(settings.seaLevel(), settings.routing(), settings.surface(), settings.hydraulics(),
settings.underground(), settings.outlets(), new HydrologyPlannerSettings.Geometry(meanders,
geometry.surface(), geometry.underground(), geometry.grottos(), geometry.drops()), settings.deepFluids(),
settings.surfacePools(), settings.widestShoreBiomeWidth(), settings.seaCaves(), settings.surfacePolicyBounds());
}
private static HydrologyTerrainSample land(String profile) {
return new HydrologyTerrainSample(70, 0D, false, false, 38, 40,
true, true, true, false, false, false,
0D, 1D, 1D, 1D, 1D, 1D, 1D, 1D,
"land", "land", "land", "land", "land", "land", List.of(profile), List.of(),
Double.NaN, null, Double.NaN, true, SurfaceRiverPolicy.INHERIT);
}
}
@@ -27,8 +27,6 @@ public class HydrologyRegionalWidthSupportTest {
public void aWiderChannelContainsTheCenterDepressionWithoutRaisingLand() throws Exception { public void aWiderChannelContainsTheCenterDepressionWithoutRaisingLand() throws Exception {
HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand( HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(
x >= 128 && x <= 136 && Math.abs(z) <= 1 ? 65 : x >= 256 && x <= 288 ? 86 : 80, 0D, "land"); x >= 128 && x <= 136 && Math.abs(z) <= 1 ? 65 : x >= 256 && x <= 288 ? 86 : 80, 0D, "land");
HydrologyRegionalHydraulics hydraulics = new HydrologyRegionalHydraulics(SETTINGS);
assertEquals(80, hydraulics.supportedHead(station(terrain, 80), terrain));
HydrologyPlanner planner = new HydrologyPlanner(71L, SETTINGS, terrain); HydrologyPlanner planner = new HydrologyPlanner(71L, SETTINGS, terrain);
Method validate = HydrologyRegionalRoute.class.getDeclaredMethod("validateHydraulics", Method validate = HydrologyRegionalRoute.class.getDeclaredMethod("validateHydraulics",
HydrologyRegionalRoute.Refinement.class, String.class, boolean.class, HydrologyTerrainSampler.class); HydrologyRegionalRoute.Refinement.class, String.class, boolean.class, HydrologyTerrainSampler.class);
@@ -48,7 +46,7 @@ public class HydrologyRegionalWidthSupportTest {
assertTrue(course.hydraulicallyNonRising()); assertTrue(course.hydraulicallyNonRising());
SurfaceLayerColumn center = null; SurfaceLayerColumn center = null;
for (SurfaceLayerColumn column : footprint.columns()) { for (SurfaceLayerColumn column : footprint.columns()) {
assertTrue(column.layer().bedY() <= column.terrain().naturalHeight()); assertTrue(column.layer().bedY() - column.terrain().naturalHeight() <= SETTINGS.surface().maximumIncision());
assertTrue(column.terrain().naturalHeight() - column.layer().bedY() <= SETTINGS.surface().maximumIncision()); assertTrue(column.terrain().naturalHeight() - column.layer().bedY() <= SETTINGS.surface().maximumIncision());
if (column.x() == 132 && column.z() == 0) { if (column.x() == 132 && column.z() == 0) {
center = column; center = column;
@@ -59,112 +57,8 @@ public class HydrologyRegionalWidthSupportTest {
assertEquals(80, center.layer().fluidHeadY()); assertEquals(80, center.layer().fluidHeadY());
SurfaceCourseResult narrow = build(terrain, geometry(4)); SurfaceCourseResult narrow = build(terrain, geometry(4));
assertFalse(narrow.accepted()); assertTrue(narrow.accepted());
assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, narrow.rejection()); assertTrue(narrow.segments().stream().allMatch(segment -> segment.upstreamHeadY() >= segment.downstreamHeadY()));
assertEquals(23, narrow.rejectionDetail());
}
@Test
public void anInteriorWidthCanHoldHeadWhenBothAuthoredExtremaFail() {
HydrologyTerrainSampler terrain = narrowBanks();
assertEquals(65, bankHead(terrain, 4));
assertEquals(80, bankHead(terrain, 6));
assertEquals(65, bankHead(terrain, 8));
AtomicInteger samples = new AtomicInteger();
HydrologyTerrainSampler bounded = (x, z) -> {
assertTrue("A supported interior width must stop wider probes", Math.abs(z) <= 7);
samples.incrementAndGet();
return terrain.sample(x, z);
};
assertEquals(80, new HydrologyRegionalHydraulics(SETTINGS).supportedHead(station(terrain, 80), bounded));
assertTrue(samples.get() > 0);
}
@Test
public void fractionalLocalWidthIsCheckedBeforeUnneededWiderCandidates() {
HydrologyTerrainSampler source = narrowBanks();
HydrologyTerrainSampler terrain = (x, z) -> withWidthMultiplier(source.sample(x, z), 0.8125D);
assertEquals(80, bankHead(terrain, 6.5D));
HydrologyTerrainSampler bounded = (x, z) -> {
assertTrue("The local width6.5 already supports the incoming head", Math.abs(z) <= 6);
return terrain.sample(x, z);
};
assertEquals(80, new HydrologyRegionalHydraulics(SETTINGS).supportedHead(station(terrain, 80), bounded));
}
@Test
public void minimumWidthSupportStaysLazyAndCannotRaiseIncomingHead() {
HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(
x >= 128 && x <= 136 && z == 0 ? 65 : 80, 0D, "land");
AtomicInteger expectedSamples = new AtomicInteger();
bankHead((x, z) -> {
expectedSamples.incrementAndGet();
return terrain.sample(x, z);
}, 4);
AtomicInteger actualSamples = new AtomicInteger();
HydrologyTerrainSampler counted = (x, z) -> {
actualSamples.incrementAndGet();
return terrain.sample(x, z);
};
assertEquals(78, new HydrologyRegionalHydraulics(SETTINGS).supportedHead(station(terrain, 78), counted));
assertEquals(expectedSamples.get(), actualSamples.get());
}
@Test
public void incompleteWiderPerimetersCannotCertifyAHighHead() {
HydrologyTerrainSampler terrain = narrowBanks();
HydrologyTerrainSampler incomplete = (x, z) -> x == 133 ? null : terrain.sample(x, z);
assertEquals(65, new HydrologyRegionalHydraulics(SETTINGS).supportedHead(station(terrain, 80), incomplete));
}
@Test
public void additionalSamplingAndRadiusStayBoundedWhenWiderPerimetersAreIncomplete() {
HydrologyPlannerSettings settings = wideSettings();
HydrologyTerrainSample ground = withWidthMultiplier(HydrologyTerrainSample.openLand(65, 0D, "land"), 0.75D);
HydrologyTerrainSample bank = HydrologyTerrainSample.openLand(80, 0D, "land");
HydrologyTerrainSampler incomplete = (x, z) -> x == 133 ? null : x == 132 && z == 0 ? ground : bank;
AtomicInteger minimumSamples = new AtomicInteger();
HydrologyTerrainSampler minimum = (x, z) -> {
minimumSamples.incrementAndGet();
return incomplete.sample(x, z);
};
SurfaceBankSupport support = new SurfaceBankSupport(settings.surface(), settings.seaLevel());
SurfaceBankSupport.Station station = new SurfaceBankSupport.Station(132, 0, 1D, 0D, 2D);
SurfaceBankSupport.CrossSection cross = support.crossSection(minimum, station, true);
assertFalse(support.perimeter(minimum, station, cross.minimum()).complete());
AtomicInteger samples = new AtomicInteger();
HydrologyTerrainSampler terrain = (x, z) -> {
assertTrue(Math.abs(x - 132) <= 96 && Math.abs(z) <= 96);
samples.incrementAndGet();
return incomplete.sample(x, z);
};
assertEquals(65, new HydrologyRegionalHydraulics(settings).supportedHead(
new HydrologyRegionalHydraulics.HeadStation(132, 0, 1D, 0D, ground, 80), terrain));
assertEquals(minimumSamples.get() + 2048, samples.get());
}
private static HydrologyTerrainSampler narrowBanks() {
return (x, z) -> HydrologyTerrainSample.openLand(
x >= 128 && x <= 136 && Math.abs(z) <= 1 || Math.abs(z) >= 7 ? 65 : 80, 0D, "land");
}
private static int bankHead(HydrologyTerrainSampler terrain, double width) {
SurfaceBankSupport support = new SurfaceBankSupport(SETTINGS.surface(), SETTINGS.seaLevel());
SurfaceBankSupport.Station station = new SurfaceBankSupport.Station(132, 0, 1D, 0D, width / 2D);
SurfaceBankSupport.CrossSection cross = support.crossSection(terrain, station, true);
assertFalse(cross.blocked());
SurfaceBankSupport.Perimeter perimeter = support.perimeter(terrain, station, cross.minimum());
assertTrue(perimeter.complete());
return perimeter.minimum();
}
private static HydrologyRegionalHydraulics.HeadStation station(HydrologyTerrainSampler terrain, int incoming) {
return new HydrologyRegionalHydraulics.HeadStation(132, 0, 1D, 0D, terrain.sample(132, 0), incoming);
} }
private static HydrologyGeometrySampler geometry(int width) { private static HydrologyGeometrySampler geometry(int width) {
@@ -180,22 +74,4 @@ public class HydrologyRegionalWidthSupportTest {
.build(71L, 91L, "default", PATH, SurfaceTerminal.SINKHOLE, 40, 256); .build(71L, 91L, "default", PATH, SurfaceTerminal.SINKHOLE, 40, 256);
} }
private static HydrologyTerrainSample withWidthMultiplier(HydrologyTerrainSample source, double multiplier) {
return new HydrologyTerrainSample(source.naturalHeight(), source.slope(), source.ocean(), source.caveAvailable(),
source.caveFloorY(), source.caveFluidY(), source.transitAllowed(), source.outletAllowed(), source.surfaceSourceAllowed(),
source.surfaceSourceRequired(), source.undergroundSourceAllowed(), source.undergroundSourceRequired(), source.routingCost(),
source.surfaceSourceWeight(), source.undergroundSourceWeight(), multiplier, source.depthMultiplier(), source.incisionMultiplier(),
source.routingMultiplier(), source.bankMultiplier(), source.parentBiomeKey(), source.surfaceBiomeKey(), source.mouthBiomeKey(),
source.shoreBiomeKey(), source.bankBiomeKey(), source.floodedCaveBiomeKey(), source.preferredProfileKeys(), source.surfacePoolKeys(),
source.shoreBiomeWidth(), source.confinesKey(), source.shoreWidth(), source.erosion(), source.surfacePolicy());
}
private static HydrologyPlannerSettings wideSettings() {
HydrologyPlannerSettings.Surface surface = SETTINGS.surface();
return new HydrologyPlannerSettings(SETTINGS.seaLevel(), SETTINGS.routing(),
new HydrologyPlannerSettings.Surface(surface.enabled(), surface.sources(), surface.minimumWidth(), 256,
surface.minimumDepth(), surface.maximumDepth(), surface.maximumIncision(), surface.shoreWidth(), surface.banks()),
SETTINGS.hydraulics(), SETTINGS.underground(), SETTINGS.outlets(), SETTINGS.geometry(), SETTINGS.deepFluids(), SETTINGS.surfacePools(),
SETTINGS.widestShoreBiomeWidth(), SETTINGS.seaCaves(), SETTINGS.surfacePolicyBounds());
}
} }
@@ -10,28 +10,6 @@ import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.spy; import static org.mockito.Mockito.spy;
public class HydrologyRouteHeightProbeTest { public class HydrologyRouteHeightProbeTest {
@Test
public void crossDropUsesScalarHeightsIncludingMissingAndExtremeValues() {
HydrologyTerrainSample center = land(87);
HydrologyTerrainSample[] sides = {null, HydrologyTerrainSample.ocean(20, "ocean"),
land(20), land(100), land(Integer.MIN_VALUE), land(Integer.MAX_VALUE)};
for (HydrologyTerrainSample side : sides) {
Fixture fixture = fixture((x, z) -> side);
int lowest = side == null || side.ocean() ? 87 : Math.min(87, side.naturalHeight());
double expected = (double) (87 - lowest) * fixture.planner().settings.routing().valleyPreference()
* HydrologyRouteGeometry.CROSS_DROP_WEIGHT;
double actual = fixture.planner().routeGeometry.crossDropPenalty(
new HydrologyPoint(21, 87, -37), new RouteDirection(3D, 4D), center);
assertEquals(Double.doubleToRawLongBits(expected), Double.doubleToRawLongBits(actual));
assertEquals(4, fixture.scalarCalls().get());
assertEquals(0, fixture.basisCalls().get());
}
Fixture untouched = fixture((x, z) -> land(20));
assertEquals(0D, untouched.planner().routeGeometry.crossDropPenalty(
new HydrologyPoint(0, 87, 0), new RouteDirection(0D, 0D), center), 0D);
assertEquals(0, untouched.scalarCalls().get());
}
@Test @Test
public void pitChecksRetainMissingAndIntegerDifferenceSemantics() { public void pitChecksRetainMissingAndIntegerDifferenceSemantics() {
int threshold = HydrologyPlannerSettings.defaults().hydraulics().waterfallMinimumDrop(); int threshold = HydrologyPlannerSettings.defaults().hydraulics().waterfallMinimumDrop();
@@ -1,254 +0,0 @@
package art.arcane.iris.generation.hydrology;
import org.junit.Test;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Random;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock;
public class HydrologyRouteTurnCacheTest {
@Test
public void repeatedRefinementMatchesExhaustiveUncachedRoutes() throws Exception {
Random random = new Random(712319L);
for (int scenario = 0; scenario < 40; scenario++) {
Solver solver = new Solver();
int count = 4 + random.nextInt(4);
HydrologyPoint[][] points = new HydrologyPoint[count][];
for (int layer = 0; layer < count; layer++) {
points[layer] = new HydrologyPoint[layer == 0 || layer == count - 1 ? 1 : 1 + random.nextInt(4)];
for (int candidate = 0; candidate < points[layer].length; candidate++) {
points[layer][candidate] = new HydrologyPoint(layer * 12 + random.nextInt(5), 80,
candidate * 9 + random.nextInt(7));
}
}
double maximumTurn = scenario % 2 == 0 ? 35D : 115D;
double[][][][] cache = new double[count][][][];
for (int refinement = 0; refinement < 8; refinement++) {
Fixture fixture = fixture(points, random);
assertArrayEquals("scenario=" + scenario + " refinement=" + refinement,
exhaustive(fixture, maximumTurn), solver.solve(fixture, maximumTurn, cache));
assertCacheGeometry(fixture, maximumTurn, cache);
}
}
}
@Test
public void twoLayerRefinementResetsReusedCostsAndKeepsFiniteRoutes() throws Exception {
Random random = new Random(173L);
HydrologyPoint[][] points = {
{new HydrologyPoint(0, 80, 0), new HydrologyPoint(0, 80, 4)},
{new HydrologyPoint(12, 80, 0), new HydrologyPoint(12, 80, 4)}
};
Solver solver = new Solver();
double[][][][] cache = new double[2][][][];
for (int refinement = 0; refinement < 32; refinement++) {
Fixture fixture = fixture(points, random);
assertArrayEquals(exhaustive(fixture, 75D), solver.solve(fixture, 75D, cache));
}
}
@Test
public void straightTiesAndDegenerateSegmentsKeepOriginalFirstChoice() throws Exception {
HydrologyPoint[][] points = {
{new HydrologyPoint(0, 80, 0)},
{new HydrologyPoint(0, 90, 0), new HydrologyPoint(0, 70, 0)},
{new HydrologyPoint(12, 80, 0), new HydrologyPoint(12, 80, 0)},
{new HydrologyPoint(24, 80, 0)}
};
Fixture fixture = fixture(points, new Random(2L));
for (double[] scores : fixture.scores()) {
Arrays.fill(scores, 0D);
}
for (double[] penalties : fixture.penalties()) {
Arrays.fill(penalties, 0D);
}
for (double[][] transitions : fixture.transitions()) {
if (transitions != null) {
for (double[] row : transitions) {
Arrays.fill(row, 0D);
}
}
}
assertArrayEquals(new int[]{0, 0, 0, 0}, new Solver().solve(fixture, 75D, new double[4][][][]));
}
private static Fixture fixture(HydrologyPoint[][] coordinates, Random random) {
int count = coordinates.length;
HydrologyPoint[][] points = new HydrologyPoint[count][];
double[][] scores = new double[count][];
double[][] penalties = new double[count][];
double[][][] transitions = new double[count][][];
for (int layer = 0; layer < count; layer++) {
int size = coordinates[layer].length;
points[layer] = new HydrologyPoint[size];
scores[layer] = new double[size];
penalties[layer] = new double[size];
for (int candidate = 0; candidate < size; candidate++) {
HydrologyPoint point = coordinates[layer][candidate];
points[layer][candidate] = new HydrologyPoint(point.x(), random.nextInt(200), point.z());
scores[layer][candidate] = random.nextDouble() * 20D;
penalties[layer][candidate] = random.nextDouble() * 10D;
}
if (layer > 0) {
transitions[layer] = new double[coordinates[layer - 1].length][size];
for (double[] row : transitions[layer]) {
for (int current = 0; current < size; current++) {
row[current] = random.nextInt(8) == 0 ? Double.POSITIVE_INFINITY : random.nextDouble() * 30D;
}
}
}
}
return new Fixture(points, scores, penalties, transitions);
}
private static int[] exhaustive(Fixture fixture, double maximumTurn) {
ArrayList<int[]> routes = new ArrayList<>();
enumerate(fixture.points(), new int[fixture.points().length], 0, routes);
int[] selected = new int[0];
double selectedCost = Double.POSITIVE_INFINITY;
for (int[] route : routes) {
double cost = fixture.scores()[0][route[0]] + fixture.scores()[1][route[1]]
+ fixture.penalties()[1][route[1]] + fixture.transitions()[1][route[0]][route[1]];
for (int layer = 2; layer < route.length && Double.isFinite(cost); layer++) {
double turn = turn(fixture.points()[layer - 2][route[layer - 2]],
fixture.points()[layer - 1][route[layer - 1]], fixture.points()[layer][route[layer]]);
if (turn > maximumTurn) {
cost = Double.POSITIVE_INFINITY;
break;
}
double excess = Math.max(0D, turn - 8D);
cost = cost + fixture.scores()[layer][route[layer]] + fixture.penalties()[layer][route[layer]]
+ fixture.transitions()[layer][route[layer - 1]][route[layer]]
+ excess * excess * 12D / maximumTurn;
}
if (cost < selectedCost || Double.isFinite(cost) && cost == selectedCost && precedes(route, selected)) {
selectedCost = cost;
selected = route;
}
}
return selected;
}
private static boolean precedes(int[] candidate, int[] selected) {
int last = candidate.length - 1;
if (candidate[last - 1] != selected[last - 1]) {
return candidate[last - 1] < selected[last - 1];
}
if (candidate[last] != selected[last]) {
return candidate[last] < selected[last];
}
for (int index = last - 2; index >= 0; index--) {
if (candidate[index] != selected[index]) {
return candidate[index] < selected[index];
}
}
return false;
}
private static void enumerate(HydrologyPoint[][] points, int[] route, int layer, List<int[]> routes) {
if (layer == route.length) {
routes.add(route.clone());
return;
}
for (int candidate = 0; candidate < points[layer].length; candidate++) {
route[layer] = candidate;
enumerate(points, route, layer + 1, routes);
}
}
private static void assertCacheGeometry(Fixture fixture, double maximumTurn, double[][][][] cache) {
int populated = 0;
for (int layer = 2; layer < cache.length; layer++) {
for (int previous = 0; previous < cache[layer].length; previous++) {
for (int current = 0; current < cache[layer][previous].length; current++) {
double[] costs = cache[layer][previous][current];
if (costs == null) {
continue;
}
for (int before = 0; before < costs.length; before++) {
if (Double.isNaN(costs[before])) {
continue;
}
double turn = turn(fixture.points()[layer - 2][before],
fixture.points()[layer - 1][previous], fixture.points()[layer][current]);
double excess = Math.max(0D, turn - 8D);
double expected = turn > maximumTurn ? Double.POSITIVE_INFINITY : excess * excess * 12D / maximumTurn;
assertEquals(Double.doubleToLongBits(expected), Double.doubleToLongBits(costs[before]));
populated++;
}
}
}
}
assertTrue(populated > 0);
}
private static double turn(HydrologyPoint before, HydrologyPoint previous, HydrologyPoint current) {
double incomingX = previous.x() - before.x();
double incomingZ = previous.z() - before.z();
double outgoingX = current.x() - previous.x();
double outgoingZ = current.z() - previous.z();
double incomingLength = StrictMath.hypot(incomingX, incomingZ);
double outgoingLength = StrictMath.hypot(outgoingX, outgoingZ);
if (incomingLength == 0D || outgoingLength == 0D) {
return 0D;
}
return StrictMath.toDegrees(StrictMath.acos(Math.max(-1D, Math.min(1D,
(incomingX * outgoingX + incomingZ * outgoingZ) / (incomingLength * outgoingLength)))));
}
private record Fixture(HydrologyPoint[][] points, double[][] scores, double[][] penalties, double[][][] transitions) {
}
private static final class Solver {
private final HydrologyRouteGeometry geometry = mock(HydrologyRouteGeometry.class, CALLS_REAL_METHODS);
private final Constructor<?> candidateConstructor;
private final Object tangent;
private final Method solve;
private final Method indices;
private HydrologyRouteGeometry.CurvatureWorkspace workspace;
private Solver() throws Exception {
Class<?> direction = RouteDirection.class;
Constructor<?> directionConstructor = direction.getDeclaredConstructor(double.class, double.class);
directionConstructor.setAccessible(true);
tangent = directionConstructor.newInstance(1D, 0D);
Class<?> candidate = RouteCandidate.class;
candidateConstructor = candidate.getDeclaredConstructor(HydrologyPoint.class, double.class, double.class,
double.class, double.class, double.class, direction, boolean.class);
candidateConstructor.setAccessible(true);
solve = HydrologyRouteGeometry.class.getDeclaredMethod("selectCurvatureAwareTerrainRoute", List.class,
double.class, double.class, double.class, double[][].class, double[][][].class, double[][][][].class,
HydrologyRouteGeometry.CurvatureWorkspace.class);
solve.setAccessible(true);
indices = solve.getReturnType().getDeclaredMethod("indices");
indices.setAccessible(true);
}
private int[] solve(Fixture fixture, double maximumTurn, double[][][][] cache) throws Exception {
ArrayList<List<Object>> layers = new ArrayList<>();
for (int layer = 0; layer < fixture.points().length; layer++) {
ArrayList<Object> candidates = new ArrayList<>();
for (int candidate = 0; candidate < fixture.points()[layer].length; candidate++) {
HydrologyPoint point = fixture.points()[layer][candidate];
candidates.add(candidateConstructor.newInstance(point, point.x(), point.z(), 0D,
fixture.scores()[layer][candidate], 0D, tangent, true));
}
layers.add(candidates);
}
if (workspace == null) {
workspace = new HydrologyRouteGeometry.CurvatureWorkspace(layers);
}
return (int[]) indices.invoke(solve.invoke(geometry, layers, 12D, maximumTurn, 12D,
fixture.penalties(), fixture.transitions(), cache, workspace));
}
}
}
@@ -259,7 +259,7 @@ public class HydrologySourceSpacingTest {
candidates.add(new OutletCandidate(node.index(), -1, new RiverOutlet(node.index() + 1000L, candidates.add(new OutletCandidate(node.index(), -1, new RiverOutlet(node.index() + 1000L,
HydrologyFeatureType.MOUTH, node.id(), point, point, 63, true))); HydrologyFeatureType.MOUTH, node.id(), point, point, 63, true)));
} }
return new HydrologyRoutingPlan(potential, parent, outlets, lengths, List.copyOf(candidates), false); return new HydrologyRoutingPlan(potential, parent, outlets, lengths, List.copyOf(candidates), false, null);
} }
private static HydrologyTerrainSample land(int naturalHeight, double weight) { private static HydrologyTerrainSample land(int naturalHeight, double weight) {
@@ -64,6 +64,7 @@ public class HydrologyStyledGeometryTest {
Set.of( Set.of(
HydrologyGeometrySampler.Field.SURFACE_WIDTH, HydrologyGeometrySampler.Field.SURFACE_WIDTH,
HydrologyGeometrySampler.Field.SURFACE_DEPTH, HydrologyGeometrySampler.Field.SURFACE_DEPTH,
HydrologyGeometrySampler.Field.SURFACE_BLEND_WIDTH,
HydrologyGeometrySampler.Field.UNDERGROUND_FLUID_LEVEL, HydrologyGeometrySampler.Field.UNDERGROUND_FLUID_LEVEL,
HydrologyGeometrySampler.Field.UNDERGROUND_WIDTH, HydrologyGeometrySampler.Field.UNDERGROUND_WIDTH,
HydrologyGeometrySampler.Field.UNDERGROUND_DEPTH, HydrologyGeometrySampler.Field.UNDERGROUND_DEPTH,
@@ -1,145 +0,0 @@
package art.arcane.iris.generation.hydrology;
import org.junit.Test;
import java.util.NavigableSet;
import java.util.TreeSet;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class HydrologySurfaceBankProbeTest {
@Test
public void raisingShoreWidthWidensTheBankProbeBandInsteadOfSlidingIt() {
NavigableSet<Integer> narrow = probedBankOffsets(1.5D);
NavigableSet<Integer> wide = probedBankOffsets(3D);
int innerRing = innerRing();
assertTrue("narrow band " + narrow, narrow.contains(innerRing));
assertTrue("wide band " + wide, wide.contains(innerRing));
assertTrue("band " + wide + " must still cover " + narrow, wide.containsAll(narrow));
assertTrue("band " + wide + " must reach past " + narrow, wide.size() > narrow.size());
}
@Test
public void theInnerProbeRingDoesNotMoveWithShoreWidth() {
int innerRing = innerRing();
for (double shoreWidth : new double[]{0D, 1.5D, 3D, 16D}) {
NavigableSet<Integer> band = probedBankOffsets(shoreWidth);
assertEquals("band " + band + " at shoreWidth " + shoreWidth,
innerRing, band.first().intValue());
}
}
@Test
public void bankHeightQueriesPreserveExactPenaltiesWithoutBankPolicySamples() {
HydrologyTerrainSampler terrain = (x, z) -> x + z == 7 ? null
: x < -3 ? HydrologyTerrainSample.ocean(60, "ocean")
: HydrologyTerrainSample.openLand(80 + Math.floorMod(x * 3 + z * 7, 31), 0D, "land");
AtomicInteger basisCalls = new AtomicInteger();
AtomicInteger heightCalls = new AtomicInteger();
HydrologyNaturalTerrainSampler natural = new HydrologyNaturalTerrainSampler() {
@Override
public HydrologyTerrainSample sampleBasis(int x, int z) {
basisCalls.incrementAndGet();
return terrain.sample(x, z);
}
@Override
public double sampleLandHeight(int x, int z) {
heightCalls.incrementAndGet();
HydrologyTerrainSample sampled = terrain.sample(x, z);
return sampled == null || sampled.ocean() ? Double.NaN : sampled.naturalHeight();
}
@Override
public HydrologyTerrainSample[] sampleGrid(GridRequest request) {
throw new AssertionError("Bank probes do not require a routing grid");
}
@Override
public NaturalClassification classifyNatural(int x, int z) {
throw new AssertionError("Bank probes only require land heights");
}
};
for (double angle : new double[]{0D, 0.37D, 0.7853981633974483D}) {
HydrologyPlannerSettings settings = settings(3D);
HydrologyPlanner reference = new HydrologyPlanner(91L, settings, terrain);
HydrologyPlanner optimized = new HydrologyPlanner(91L, settings, terrain, natural,
HydrologyGeometrySampler.deterministic(terrain), -4096,
footprint -> new HydrologyTerrainCaveVoxelView(terrain, settings.seaLevel(), -4096, 4096));
HydrologyPoint point = new HydrologyPoint(0, 80, 0);
RouteCandidate candidate = new RouteCandidate(point, 0D, 0D, 0D, 0D, 0D,
new RouteDirection(StrictMath.cos(angle), StrictMath.sin(angle)), true);
basisCalls.set(0);
heightCalls.set(0);
double expected = reference.surfaceCourses.surfaceRouteCandidateBankPenalty(candidate);
double actual = optimized.surfaceCourses.surfaceRouteCandidateBankPenalty(candidate);
assertEquals(Double.doubleToLongBits(expected), Double.doubleToLongBits(actual));
assertEquals(1, basisCalls.get());
assertTrue(heightCalls.get() > 0);
optimized.planningSamples.set(new HydrologyPlanner.PlanningSamples());
try {
assertEquals(expected, optimized.surfaceCourses.surfaceRouteCandidateBankPenalty(candidate), 0D);
int firstPassHeightCalls = heightCalls.get();
assertEquals(expected, optimized.surfaceCourses.surfaceRouteCandidateBankPenalty(candidate), 0D);
assertEquals(firstPassHeightCalls, heightCalls.get());
} finally {
optimized.planningSamples.remove();
}
}
}
/** One block outside half the nominal maximum width - see HydrologySurfaceCoursePlanner. */
private int innerRing() {
return (int) StrictMath.ceil(
HydrologyPlannerSettings.defaults().surface().maximumWidth() / 2D) + 1;
}
private NavigableSet<Integer> probedBankOffsets(double shoreWidth) {
HydrologyPoint center = new HydrologyPoint(0, 84, 0);
NavigableSet<Integer> probed = new TreeSet<>();
HydrologyTerrainSampler sampler = (x, z) -> {
if (x == center.x()) {
probed.add(Math.abs(z - center.z()));
}
return HydrologyTerrainSample.openLand(84, 0D, "land");
};
HydrologyPlanner planner = new HydrologyPlanner(91L, settings(shoreWidth), sampler);
planner.planningSamples.set(new HydrologyPlanner.PlanningSamples());
planner.surfaceCourses.surfaceRouteCandidateBankPenalty(new RouteCandidate(
center, center.x(), center.z(), 0D, 0D, 0D, new RouteDirection(1D, 0D), true));
probed.remove(0);
return probed;
}
private HydrologyPlannerSettings settings(double shoreWidth) {
HydrologyPlannerSettings base = HydrologyPlannerSettings.defaults();
HydrologyPlannerSettings.Surface surface = base.surface();
return new HydrologyPlannerSettings(
base.seaLevel(),
base.routing(),
new HydrologyPlannerSettings.Surface(
surface.enabled(),
surface.sources(),
surface.minimumWidth(),
surface.maximumWidth(),
surface.minimumDepth(),
surface.maximumDepth(),
surface.maximumIncision(),
shoreWidth,
surface.banks()),
base.hydraulics(),
base.underground(),
base.outlets(),
base.geometry(),
base.deepFluids(),
base.surfacePools(),
base.widestShoreBiomeWidth(),
base.seaCaves(),
base.surfacePolicyBounds());
}
}
@@ -51,6 +51,75 @@ public final class HydrologySurfaceDropRasterTest {
assertThrows(UnsupportedOperationException.class, () -> raster.columns().clear()); assertThrows(UnsupportedOperationException.class, () -> raster.columns().clear());
} }
@Test
public void diagonalDropCoordinatesResolveExactlyInBothDirectionsFromTheOrigin() {
for (int origin : new int[]{-128, 128}) {
HydraulicSegment fall = new HydraulicSegment(2L, 1L, HydrologyFeatureType.WATERFALL,
90, 80, 4, 3, true, true,
List.of(new HydrologyPoint(origin, 90, origin), new HydrologyPoint(origin + 12, 80, origin + 12)),
HydraulicChannelProfile.uniform(4, 3));
RiverCourse course = new RiverCourse(1L, RiverCourseType.SURFACE, OptionalLong.of(3L), OptionalLong.of(4L),
"water", 1, List.of(), List.of(fall));
HydrologySurfaceDropRaster raster = HydrologySurfaceDropRaster.compile(SETTINGS, TERRAIN, GEOMETRY, course);
assertFalse(raster.columns().isEmpty());
for (HydrologyColumnSample column : raster.columns()) {
assertSame(column, raster.sample(column.x(), column.z()).orElseThrow());
}
assertTrue(raster.connects(origin, origin, 90));
assertTrue(raster.connects(origin + 12, origin + 12, 80));
assertTrue(raster.sample(origin + 512, origin + 512).isEmpty());
assertFalse(raster.connects(origin + 512, origin + 512, 80));
}
}
@Test
public void receivingDropBedsUseInletIncisionOnlyAtAuthorizedCourseStations() {
HydrologyTerrainSampler terrain = (x, z) -> x >= 200
? HydrologyTerrainSample.ocean(50, "ocean")
: HydrologyTerrainSample.openLand(107, 0D, "parent");
for (int start : new int[]{50, 100, 150}) {
int end = start == 100 ? 110 : start + 1;
HydraulicSegment approach = new HydraulicSegment(1L, 1L, HydrologyFeatureType.SURFACE_POOL,
122, 122, 4, 3, false, false,
List.of(new HydrologyPoint(0, 122, 0), new HydrologyPoint(start, 122, 0)),
HydraulicChannelProfile.uniform(4, 3));
HydraulicSegment fall = new HydraulicSegment(2L, 1L, HydrologyFeatureType.WATERFALL,
122, 78, 4, 3, true, true,
List.of(new HydrologyPoint(start, 122, 0), new HydrologyPoint(end, 78, 0)),
HydraulicChannelProfile.uniform(4, 3));
HydraulicSegment downstream = new HydraulicSegment(3L, 1L, HydrologyFeatureType.CASCADE,
78, SETTINGS.seaLevel(), 4, 3, false, false,
List.of(new HydrologyPoint(end, 78, 0), new HydrologyPoint(199, SETTINGS.seaLevel(), 0)),
HydraulicChannelProfile.uniform(4, 3));
HydraulicSegment mouth = new HydraulicSegment(4L, 1L, HydrologyFeatureType.MOUTH,
SETTINGS.seaLevel(), SETTINGS.seaLevel(), 4, 3, false, false,
List.of(new HydrologyPoint(199, SETTINGS.seaLevel(), 0), new HydrologyPoint(200, SETTINGS.seaLevel(), 0)),
HydraulicChannelProfile.uniform(4, 3));
RiverCourse course = new RiverCourse(1L, RiverCourseType.SURFACE, OptionalLong.of(3L), OptionalLong.of(4L),
"water", 1, List.of(), List.of(approach, fall, downstream, mouth));
HydrologySurfaceDropRaster raster = HydrologySurfaceDropRaster.compile(SETTINGS, terrain, GEOMETRY, course);
assertEquals(start != 50, raster.connects(end, 0, 78));
if (start != 50) {
HydrologyColumnSample receiver = raster.sample(end, 0).orElseThrow();
HydrologyColumnLayer water = receiver.layers().stream()
.filter(layer -> layer.channel() && layer.fluidOwned()).findFirst().orElseThrow();
int cut = receiver.naturalHeight() - water.bedY();
assertTrue(cut > SETTINGS.surface().maximumIncision());
assertTrue(cut <= SETTINGS.surface().banks().inlet().maximumIncision());
}
if (start == 100) {
assertFalse(raster.connects(102, 0, 78));
assertTrue(raster.connects(108, 0, 78));
}
SurfaceBounds bounds = new SurfaceBounds(start - 2, -8, end + 2, 8);
HydrologySurfaceDropRaster bounded = HydrologySurfaceDropRaster.compile(SETTINGS, terrain, GEOMETRY, course, bounds);
assertEquals(raster.columns().stream().filter(column -> bounds.contains(column.x(), column.z())).toList(),
bounded.columns());
}
}
@Test @Test
public void surfaceDropsDoNotOwnSubmergedGroundOrRaiseWaterOnSeaLevelLand() { public void surfaceDropsDoNotOwnSubmergedGroundOrRaiseWaterOnSeaLevelLand() {
RiverCourse course = course(true); RiverCourse course = course(true);
@@ -97,7 +166,7 @@ public final class HydrologySurfaceDropRasterTest {
HydrologyFootprintCompiler compiler = new HydrologyFootprintCompiler(SETTINGS, TERRAIN, GEOMETRY); HydrologyFootprintCompiler compiler = new HydrologyFootprintCompiler(SETTINGS, TERRAIN, GEOMETRY);
HydrologyPlannerSettings.Excavation limits = SETTINGS.surface().banks().erosion().excavation(); HydrologyPlannerSettings.Excavation limits = SETTINGS.surface().banks().erosion().excavation();
FootprintLayerShape shape = new FootprintLayerShape(2, 2D, 40D, 79, 90, 90, FootprintLayerShape shape = new FootprintLayerShape(2, 2D, 40D, 79, 90, 90,
false, false, false, false, true); false, false, false, false, true, false);
HydrologyFeatureRef feature = new HydrologyFeatureRef(5L, segment.type(), course.id(), segment.id(), HydrologyFeatureRef feature = new HydrologyFeatureRef(5L, segment.type(), course.id(), segment.id(),
0, 90, 0, 1, 0, false); 0, 90, 0, 1, 0, false);
HydrologyTerrainSample highTerrain = HydrologyTerrainSample.openLand(160, 0D, "parent"); HydrologyTerrainSample highTerrain = HydrologyTerrainSample.openLand(160, 0D, "parent");
@@ -5,54 +5,9 @@ import org.junit.Test;
import java.util.List; import java.util.List;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
public class HydrologySurfaceRouteGeometryTest { public class HydrologySurfaceRouteGeometryTest {
@Test
public void lazyTransitionGeometryIsReusedUntilResolvedHeightsChange() {
HydrologyPlanner planner = new HydrologyPlanner(91L, HydrologyPlannerSettings.defaults(),
(x, z) -> {
throw new AssertionError("Geometry lookup must not sample terrain.");
});
RouteCandidate previous = candidate(0D, 0D);
RouteCandidate current = spy(candidate(4D, 0D));
HydrologyRouteGeometry.CurvatureWorkspace workspace = new HydrologyRouteGeometry.CurvatureWorkspace(
List.of(List.of(previous), List.of(current)));
double expected = planner.routePaths.routeTransitionGeometryCost(previous, current, 4D);
clearInvocations(current);
for (int iteration = 0; iteration < 8; iteration++) {
double actual = planner.routeGeometry.resolvedTransitionCost(
previous, current, 4D, Double.NaN, workspace, 1, 0, 0);
assertEquals(Double.doubleToRawLongBits(expected), Double.doubleToRawLongBits(actual));
}
verify(current, times(1)).offset();
RouteCandidate raised = spy(new RouteCandidate(new HydrologyPoint(4, 91, 0),
current.continuousX(), current.continuousZ(), current.offset(), current.localScore(),
current.terrainScore(), current.tangent(), true));
double raisedExpected = planner.routePaths.routeTransitionGeometryCost(previous, raised, 4D);
assertTrue(raisedExpected > expected);
clearInvocations(raised);
for (int iteration = 0; iteration < 8; iteration++) {
double actual = planner.routeGeometry.resolvedTransitionCost(
previous, raised, 4D, Double.NaN, workspace, 1, 0, 0);
assertEquals(Double.doubleToRawLongBits(raisedExpected), Double.doubleToRawLongBits(actual));
}
verify(raised, times(1)).offset();
assertEquals(Double.doubleToRawLongBits(-0D), Double.doubleToRawLongBits(
planner.routeGeometry.resolvedTransitionCost(previous, raised, 4D, -0D,
workspace, 1, 0, 0)));
assertEquals(Double.POSITIVE_INFINITY,
planner.routeGeometry.resolvedTransitionCost(previous, raised, 4D,
Double.POSITIVE_INFINITY, workspace, 1, 0, 0), 0D);
}
@Test @Test
public void surfaceAnchorKeepsTheCoarseValleyWhenSmoothGroundIsMuchHigher() { public void surfaceAnchorKeepsTheCoarseValleyWhenSmoothGroundIsMuchHigher() {
HydrologyTerrainSampler sampler = (x, z) -> HydrologyTerrainSample.openLand( HydrologyTerrainSampler sampler = (x, z) -> HydrologyTerrainSample.openLand(
@@ -85,7 +40,7 @@ public class HydrologySurfaceRouteGeometryTest {
} }
@Test @Test
public void steepSurfaceEdgesCanDetourAroundAContainedTerrainPit() { public void undergroundEdgesRejectAContainedTerrainPit() {
HydrologyTerrainSampler sampler = (x, z) -> HydrologyTerrainSample.openLand( HydrologyTerrainSampler sampler = (x, z) -> HydrologyTerrainSample.openLand(
128 - Math.floorDiv(x, 2) - (x >= 30 && x <= 34 && Math.abs(z) <= 1 ? 14 : 0), 128 - Math.floorDiv(x, 2) - (x >= 30 && x <= 34 && Math.abs(z) <= 1 ? 14 : 0),
0D, "land"); 0D, "land");
@@ -94,47 +49,7 @@ public class HydrologySurfaceRouteGeometryTest {
HydrologyPoint end = new HydrologyPoint(64, 96, 0); HydrologyPoint end = new HydrologyPoint(64, 96, 0);
HydrologyPoint continuation = new HydrologyPoint(96, 80, 0); HydrologyPoint continuation = new HydrologyPoint(96, 80, 0);
List<HydrologyPoint> cave = planner.routeGeometry.refineEdge(1L, 2L, start, end, continuation, 4, false); List<HydrologyPoint> cave = planner.routeGeometry.refineUndergroundEdge(1L, 2L, start, end, continuation, 4);
List<HydrologyPoint> surface = planner.routeGeometry.refineEdge(1L, 2L, start, end, continuation, 4, true);
assertTrue(cave.isEmpty()); assertTrue(cave.isEmpty());
assertFalse(surface.isEmpty());
assertTrue(surface.stream().anyMatch(point -> Math.abs(point.z()) > 1));
assertTrue(surface.stream().allMatch(point -> Math.abs(point.z()) <= 16));
assertTrue(planner.routePaths.traversableRoute(surface));
assertFalse(planner.routePaths.containsTerrainPit(surface));
}
@Test
public void curvatureUsesTheContinuousCurveBeforeBlockRounding() {
HydrologyPlanner planner = new HydrologyPlanner(91L, HydrologyPlannerSettings.defaults(),
(x, z) -> HydrologyTerrainSample.openLand(84, 0D, "land"));
RouteCandidate first = candidate(0D, 0D);
RouteCandidate middle = candidate(3.6D, 0.49D);
RouteCandidate last = candidate(7D, 2.1D);
assertTrue(HydrologyRoutePath.routeTurnDegrees(first.point(), middle.point(), last.point()) > 20D);
assertTrue(planner.routeGeometry.continuousTurnDegrees(first, middle, last) < 20D);
RouteCandidate[] route = planner.routeGeometry.selectCurvatureAwareTerrainRoute(
List.of(List.of(first), List.of(middle), List.of(last)), 4D, 20D, 2.5D);
assertEquals(3, route.length);
}
@Test
public void continuousCurvatureStillRejectsASharpTurn() {
HydrologyPlanner planner = new HydrologyPlanner(91L, HydrologyPlannerSettings.defaults(),
(x, z) -> HydrologyTerrainSample.openLand(84, 0D, "land"));
RouteCandidate[] route = planner.routeGeometry.selectCurvatureAwareTerrainRoute(
List.of(List.of(candidate(0D, 0D)), List.of(candidate(4D, 0D)), List.of(candidate(4D, 4D))),
4D, 20D, 2.5D);
assertEquals(0, route.length);
}
private RouteCandidate candidate(double x, double z) {
return new RouteCandidate(
new HydrologyPoint((int) StrictMath.round(x), 84, (int) StrictMath.round(z)),
x, z, 0D, 0D, 0D, new RouteDirection(1D, 0D), true);
} }
} }
@@ -6,6 +6,9 @@ import art.arcane.iris.testsupport.Await;
import art.arcane.iris.generation.concurrent.MultiBurst; import art.arcane.iris.generation.concurrent.MultiBurst;
import art.arcane.volmlib.util.cache.CacheKey; import art.arcane.volmlib.util.cache.CacheKey;
import org.junit.Test; import org.junit.Test;
import org.junit.Rule;
import org.junit.rules.TemporaryFolder;
import java.nio.file.Path;
import java.lang.reflect.Field; import java.lang.reflect.Field;
import java.time.Duration; import java.time.Duration;
@@ -50,6 +53,55 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
public class HydrologyTileCacheTest { public class HydrologyTileCacheTest {
@Rule
public TemporaryFolder preparedFolder = new TemporaryFolder();
@Test
public void preparedPlansRestoreFarFromOriginWithoutTerrainPlanning() throws Exception {
HydrologyPlanner planner = mock(HydrologyPlanner.class);
when(planner.settings()).thenReturn(emptySettings());
HydrologyTileCache.SharedCacheScope scope = sharedScope();
HydrologyTileKey key = new HydrologyTileKey(501, -703);
HydrologyTile tile = preparedTile(key, scope);
Path root = preparedFolder.newFolder().toPath();
new PreparedHydrologyTileStore(root, scope, 64).save(tile);
try (HydrologyTileCache cache = new HydrologyTileCache(planner, 4)) {
cache.enableSharedCache(scope, root);
cache.setTerrainPreparation(() -> { throw new AssertionError("Restoration must not sample terrain"); });
HydrologyTile restored = cache.get(key);
assertEquals(tile.key(), restored.key());
assertEquals(tile.footprint(), restored.footprint());
verify(planner, never()).plan(any(HydrologyTileKey.class));
verify(planner).reuseResolvedTile(restored);
}
}
@Test
public void tileDemandAutomaticallyPersistsTheCompletedPlan() throws Exception {
HydrologyPlanner planner = mock(HydrologyPlanner.class);
when(planner.settings()).thenReturn(emptySettings());
HydrologyTileCache.SharedCacheScope scope = sharedScope();
HydrologyTileKey key = new HydrologyTileKey(125, -141);
HydrologyTile tile = preparedTile(key, scope);
when(planner.plan(key)).thenReturn(tile);
Path root = preparedFolder.newFolder().toPath();
try (HydrologyTileCache cache = new HydrologyTileCache(planner, 64)) {
cache.enableSharedCache(scope, root);
assertSame(tile, cache.get(key));
verify(planner).plan(key);
}
HydrologyTile persisted = new PreparedHydrologyTileStore(root, scope, 64).load(key).orElseThrow();
assertEquals(tile.key(), persisted.key());
assertEquals(tile.worldSeed(), persisted.worldSeed());
assertEquals(tile.settingsFingerprint(), persisted.settingsFingerprint());
assertEquals(tile.footprint(), persisted.footprint());
}
private static HydrologyTile preparedTile(HydrologyTileKey key, HydrologyTileCache.SharedCacheScope scope) {
return new HydrologyTile(key, scope.worldSeed(), scope.settingsFingerprint(), 64,
List.of(), List.of(), List.of(), List.of(), Set.of(), List.of(), List.of(), RiverFootprint.empty());
}
@Test @Test
public void weightedTileCapacityStopsSpeculationWithoutBlockingDemand() { public void weightedTileCapacityStopsSpeculationWithoutBlockingDemand() {
HydrologyPlanner planner = mock(HydrologyPlanner.class); HydrologyPlanner planner = mock(HydrologyPlanner.class);
@@ -400,7 +452,7 @@ public class HydrologyTileCacheTest {
monitor.set(queue.get(cache)); monitor.set(queue.get(cache));
if (pregeneration) { if (pregeneration) {
cache.preparePregeneration(262144, 262144); cache.preparePregeneration(pregenArea(262144, 262144));
} else { } else {
cache.prefetchArea(261120, 261120, 263168, 263168, 262144, 262144); cache.prefetchArea(261120, 261120, 263168, 263168, 262144, 262144);
} }
@@ -409,8 +461,12 @@ public class HydrologyTileCacheTest {
assertEquals(prefetchRectangle(254, 257, 254, 257), new HashSet<>(planned.subList(0, 16))); assertEquals(prefetchRectangle(254, 257, 254, 257), new HashSet<>(planned.subList(0, 16)));
assertEquals(List.of(new HydrologyTileKey(255, 255), new HydrologyTileKey(256, 255), assertEquals(List.of(new HydrologyTileKey(255, 255), new HydrologyTileKey(256, 255),
new HydrologyTileKey(257, 255)), planned.subList(1, 4)); new HydrologyTileKey(257, 255)), planned.subList(1, 4));
assertEquals(prefetchRectangle(-1, 1, -1, 0), new HashSet<>(planned.subList(16, planned.size()))); if (pregeneration) {
assertEquals(22, planned.size()); assertEquals(16, planned.size());
} else {
assertEquals(prefetchRectangle(-1, 1, -1, 0), new HashSet<>(planned.subList(16, planned.size())));
assertEquals(22, planned.size());
}
} }
} }
@@ -447,7 +503,7 @@ public class HydrologyTileCacheTest {
Runnable demand = queued.poll(5, TimeUnit.SECONDS); Runnable demand = queued.poll(5, TimeUnit.SECONDS);
assertNotNull(demand); assertNotNull(demand);
cache.preparePregeneration(262144, 262144); cache.preparePregeneration(pregenArea(262144, 262144));
assertFalse(active.isDone()); assertFalse(active.isDone());
assertFalse(demanded.isDone()); assertFalse(demanded.isDone());
@@ -485,9 +541,9 @@ public class HydrologyTileCacheTest {
LinkedBlockingQueue<Runnable> queued = new LinkedBlockingQueue<>(); LinkedBlockingQueue<Runnable> queued = new LinkedBlockingQueue<>();
HydrologyTileCache cache = new HydrologyTileCache(planner, 128, queued::add); HydrologyTileCache cache = new HydrologyTileCache(planner, 128, queued::add);
cache.prefetchArea(0, 0, 1023, 0, 0, 0); cache.prefetchArea(0, 0, 1023, 0, 0, 0);
cache.preparePregeneration(262144, 262144); cache.preparePregeneration(pregenArea(262144, 262144));
cache.preparePregeneration(263168, 262144); cache.preparePregeneration(pregenArea(263168, 262144));
cache.preparePregeneration(263168, 262144); cache.preparePregeneration(pregenArea(263168, 262144));
drainPrefetchTasks(queued); drainPrefetchTasks(queued);
Set<HydrologyTileKey> expected = new HashSet<>(prefetchRectangle(255, 258, 254, 257)); Set<HydrologyTileKey> expected = new HashSet<>(prefetchRectangle(255, 258, 254, 257));
@@ -496,7 +552,7 @@ public class HydrologyTileCacheTest {
assertEquals(17, planned.size()); assertEquals(17, planned.size());
cache.clear(); cache.clear();
planned.clear(); planned.clear();
cache.preparePregeneration(263168, 262144); cache.preparePregeneration(pregenArea(263168, 262144));
drainPrefetchTasks(queued); drainPrefetchTasks(queued);
assertEquals(prefetchRectangle(255, 258, 254, 257), new HashSet<>(planned)); assertEquals(prefetchRectangle(255, 258, 254, 257), new HashSet<>(planned));
assertEquals(16, planned.size()); assertEquals(16, planned.size());
@@ -516,13 +572,113 @@ public class HydrologyTileCacheTest {
LinkedBlockingQueue<Runnable> queued = new LinkedBlockingQueue<>(); LinkedBlockingQueue<Runnable> queued = new LinkedBlockingQueue<>();
HydrologyTileCache cache = new HydrologyTileCache(planner, 128, queued::add); HydrologyTileCache cache = new HydrologyTileCache(planner, 128, queued::add);
cache.prefetchArea(0, 0, 1023, 0, 0, 0); cache.prefetchArea(0, 0, 1023, 0, 0, 0);
assertThrows(ArithmeticException.class, () -> cache.preparePregeneration(Integer.MAX_VALUE, 0)); assertThrows(IllegalArgumentException.class, () -> cache.preparePregeneration(
assertThrows(ArithmeticException.class, () -> cache.preparePregeneration(0, Integer.MIN_VALUE)); new HydrologyTileCache.PregenerationArea(0, 0, 1, -1, 2, 1)));
assertThrows(ArithmeticException.class, () -> cache.preparePregeneration(
new HydrologyTileCache.PregenerationArea(0, 0, Long.MIN_VALUE, -1, 1, 1)));
drainPrefetchTasks(queued); drainPrefetchTasks(queued);
assertEquals(prefetchRectangle(-1, 1, -1, 0), new HashSet<>(planned)); assertEquals(prefetchRectangle(-1, 1, -1, 0), new HashSet<>(planned));
assertEquals(6, planned.size()); assertEquals(6, planned.size());
} }
@Test
public void pregenBoundsLimitSpeculationWithoutLimitingRequestedChunks() {
HydrologyPlanner planner = mock(HydrologyPlanner.class);
HydrologyTile tile = mock(HydrologyTile.class);
HydrologyPlannerSettings settings = stalePrefetchSettings();
when(planner.settings()).thenReturn(settings);
when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile);
ArrayList<HydrologyTileKey> planned = new ArrayList<>();
doAnswer(invocation -> {
planned.add(invocation.getArgument(0));
return tile;
}).when(planner).plan(any(HydrologyTileKey.class));
HydrologyTileCache cache = new HydrologyTileCache(planner, 128, Runnable::run);
try (HydrologyTileCache.PregenerationScope ignored = cache.preparePregeneration(
new HydrologyTileCache.PregenerationArea(0, 0, -1536, -1536, 2047, 2047))) {
cache.prefetchArea(-4096, -4096, 4095, 4095, 0, 0);
assertEquals(prefetchRectangle(-2, 2, -2, 2), new HashSet<>(planned));
planned.clear();
HydrologyTileKey demanded = new HydrologyTileKey(8, 8);
assertSame(tile, cache.get(demanded));
assertEquals(List.of(demanded), planned);
planned.clear();
assertFalse(cache.isPlanned(10_240, 10_240));
assertTrue(cache.isPlanned(10_240, 10_240));
assertEquals(prefetchRectangle(9, 10, 9, 10), new HashSet<>(planned));
planned.clear();
cache.prepareChunkColumns(-10_240, -10_240);
assertEquals(prefetchRectangle(-11, -10, -11, -10), new HashSet<>(planned));
}
planned.clear();
cache.prepareChunkColumns(20_480, 20_480);
assertEquals(prefetchRectangle(18, 21, 18, 21), new HashSet<>(planned));
cache.close();
}
@Test
public void pregenScopesRestoreStudioPrefetchAndIgnoreSupersededClose() {
HydrologyPlanner planner = mock(HydrologyPlanner.class);
HydrologyTile tile = mock(HydrologyTile.class);
HydrologyPlannerSettings settings = stalePrefetchSettings();
when(planner.settings()).thenReturn(settings);
when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile);
ArrayList<HydrologyTileKey> planned = new ArrayList<>();
doAnswer(invocation -> {
planned.add(invocation.getArgument(0));
return tile;
}).when(planner).plan(any(HydrologyTileKey.class));
HydrologyTileCache cache = new HydrologyTileCache(planner, 128, Runnable::run);
cache.setNeighbourPrefetchEnabled(false);
HydrologyTileCache.PregenerationScope first = cache.preparePregeneration(pregenArea(0, 0));
HydrologyTileCache.PregenerationScope second = cache.preparePregeneration(pregenArea(262144, 262144));
first.close();
planned.clear();
cache.prefetchArea(16384, 16384, 16384, 16384, 16384, 16384);
assertTrue(planned.isEmpty());
second.close();
cache.prepareChunkColumns(16384, 16384);
assertEquals(prefetchRectangle(15, 16, 15, 16), new HashSet<>(planned));
HydrologyTileCache.PregenerationScope cleared = cache.preparePregeneration(pregenArea(0, 0));
cache.clear();
cleared.close();
planned.clear();
cache.prepareChunkColumns(32768, 32768);
assertEquals(prefetchRectangle(31, 32, 31, 32), new HashSet<>(planned));
HydrologyTileCache.PregenerationScope closed = cache.preparePregeneration(pregenArea(0, 0));
cache.close();
closed.close();
cache.prefetchArea(0, 0, 0, 0, 0, 0);
assertThrows(CancellationException.class, () -> cache.preparePregeneration(pregenArea(0, 0)));
}
@Test
public void largePregenBoundsKeepInitialWarmupLocal() {
HydrologyPlanner planner = mock(HydrologyPlanner.class);
HydrologyTile tile = mock(HydrologyTile.class);
HydrologyPlannerSettings settings = stalePrefetchSettings();
when(planner.settings()).thenReturn(settings);
when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile);
ArrayList<HydrologyTileKey> planned = new ArrayList<>();
doAnswer(invocation -> {
planned.add(invocation.getArgument(0));
return tile;
}).when(planner).plan(any(HydrologyTileKey.class));
HydrologyTileCache cache = new HydrologyTileCache(planner, 128, Runnable::run);
try (HydrologyTileCache.PregenerationScope ignored = cache.preparePregeneration(
new HydrologyTileCache.PregenerationArea(0, 0,
Integer.MIN_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE))) {
assertEquals(prefetchRectangle(-2, 1, -2, 1), new HashSet<>(planned));
assertEquals(16, planned.size());
}
cache.close();
}
private static HydrologyTileCache.PregenerationArea pregenArea(int x, int z) {
return new HydrologyTileCache.PregenerationArea(x, z, (long) x - 1024, (long) z - 1024,
(long) x + 1024, (long) z + 1024);
}
private static HydrologyPlannerSettings stalePrefetchSettings() { private static HydrologyPlannerSettings stalePrefetchSettings() {
HydrologyPlannerSettings settings = mock(HydrologyPlannerSettings.class); HydrologyPlannerSettings settings = mock(HydrologyPlannerSettings.class);
when(settings.routing()).thenReturn(new HydrologyPlannerSettings.Routing( when(settings.routing()).thenReturn(new HydrologyPlannerSettings.Routing(
@@ -1100,7 +1256,7 @@ public class HydrologyTileCacheTest {
order.clear(); order.clear();
cache.setNeighbourPrefetchEnabled(false); cache.setNeighbourPrefetchEnabled(false);
cache.preparePregeneration(0, 0); cache.preparePregeneration(pregenArea(0, 0));
assertEquals(new HydrologyTileKey(1, -1), order.getFirst()); assertEquals(new HydrologyTileKey(1, -1), order.getFirst());
assertTrue(order.contains(new HydrologyTileKey(1, 0))); assertTrue(order.contains(new HydrologyTileKey(1, 0)));
@@ -35,14 +35,14 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public class StudioHydrologyTileStoreTest { public class PreparedHydrologyTileStoreTest {
@Rule @Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder(); public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test @Test
public void roundTripsValidatedEntryTile() throws Exception { public void roundTripsValidatedEntryTile() throws Exception {
HydrologyTile original = tile(); HydrologyTile original = tile();
StudioHydrologyTileStore store = store(); PreparedHydrologyTileStore store = store();
store.save(original); store.save(original);
HydrologyTile restored = store.load(original.key()).orElseThrow(); HydrologyTile restored = store.load(original.key()).orElseThrow();
@@ -93,7 +93,7 @@ public class StudioHydrologyTileStoreTest {
HydrologyTile original = new HydrologyTile(new HydrologyTileKey(0, 0), 91L, 17L, 64, HydrologyTile original = new HydrologyTile(new HydrologyTileKey(0, 0), 91L, 17L, 64,
List.of(), List.of(), List.of(), List.of(course), Set.of(), List.of(plan), List.of(), List.of(), List.of(), List.of(), List.of(course), Set.of(), List.of(plan), List.of(),
new RiverFootprint(Map.of(RiverFootprint.pack(0, 0), column))); new RiverFootprint(Map.of(RiverFootprint.pack(0, 0), column)));
StudioHydrologyTileStore store = store(); PreparedHydrologyTileStore store = store();
store.save(original); store.save(original);
HydrologyTile restored = store.load(original.key()).orElseThrow(); HydrologyTile restored = store.load(original.key()).orElseThrow();
assertEquals(original, restored); assertEquals(original, restored);
@@ -118,7 +118,7 @@ public class StudioHydrologyTileStoreTest {
@Test @Test
public void entryRequiresRegionalOwnershipMetadata() throws Exception { public void entryRequiresRegionalOwnershipMetadata() throws Exception {
HydrologyTile original = tile(); HydrologyTile original = tile();
StudioHydrologyTileStore store = store(); PreparedHydrologyTileStore store = store();
store.save(original); store.save(original);
Path file = store.file(original.key()); Path file = store.file(original.key());
JsonObject persisted; JsonObject persisted;
@@ -136,7 +136,7 @@ public class StudioHydrologyTileStoreTest {
@Test @Test
public void corruptEntryFallsBackToCacheMiss() throws Exception { public void corruptEntryFallsBackToCacheMiss() throws Exception {
HydrologyTile original = tile(); HydrologyTile original = tile();
StudioHydrologyTileStore store = store(); PreparedHydrologyTileStore store = store();
store.save(original); store.save(original);
Path file = store.file(original.key()); Path file = store.file(original.key());
Files.writeString(file, "invalid", StandardCharsets.UTF_8); Files.writeString(file, "invalid", StandardCharsets.UTF_8);
@@ -149,15 +149,28 @@ public class StudioHydrologyTileStoreTest {
Path root = temporaryFolder.newFolder().toPath(); Path root = temporaryFolder.newFolder().toPath();
HydrologyTileCache.SharedCacheScope scope = scope(); HydrologyTileCache.SharedCacheScope scope = scope();
HydrologyTile original = tile(); HydrologyTile original = tile();
StudioHydrologyTileStore matching = new StudioHydrologyTileStore(root, scope, 64); PreparedHydrologyTileStore matching = new PreparedHydrologyTileStore(root, scope, 64);
matching.save(original); matching.save(original);
StudioHydrologyTileStore mismatched = new StudioHydrologyTileStore(root, scope, 128); PreparedHydrologyTileStore mismatched = new PreparedHydrologyTileStore(root, scope, 128);
assertTrue(mismatched.load(original.key()).isEmpty()); assertTrue(mismatched.load(original.key()).isEmpty());
} }
private StudioHydrologyTileStore store() throws Exception { @Test
return new StudioHydrologyTileStore( public void copiedTileCannotCrossRuntimeIdentityScopes() throws Exception {
Path root = temporaryFolder.newFolder().toPath();
PreparedHydrologyTileStore source = new PreparedHydrologyTileStore(root, scope(), 64);
PreparedHydrologyTileStore target = new PreparedHydrologyTileStore(root,
new HydrologyTileCache.SharedCacheScope("another-kernel", 91L, 384, "overworld", 17L), 64);
HydrologyTile tile = tile();
source.save(tile);
Files.createDirectories(target.file(tile.key()).getParent());
Files.copy(source.file(tile.key()), target.file(tile.key()));
assertTrue(target.load(tile.key()).isEmpty());
}
private PreparedHydrologyTileStore store() throws Exception {
return new PreparedHydrologyTileStore(
temporaryFolder.newFolder().toPath(), temporaryFolder.newFolder().toPath(),
scope(), scope(),
64 64
@@ -13,9 +13,11 @@ import art.arcane.iris.generation.hydrology.IrisRiverBedProfile;
import art.arcane.iris.generation.hydrology.IrisRiverBlendStyle; import art.arcane.iris.generation.hydrology.IrisRiverBlendStyle;
import org.junit.Test; import org.junit.Test;
import java.util.Arrays;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.IntBinaryOperator; import java.util.function.IntBinaryOperator;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
@@ -31,6 +33,89 @@ public class ErosionFieldCompilerTest {
default -> request.minimum(); default -> request.minimum();
}; };
@Test
public void geometricExclusionAvoidsTerrainSamplesWithoutChangingAsymmetricBanks() {
HydrologyPlannerSettings.Surface surface = zeroRoughnessSurface();
AtomicInteger referenceSamples = new AtomicInteger();
AtomicInteger boundedSamples = new AtomicInteger();
HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(z > 4 ? 100 : 80, 0D, "land");
ErosionField reference = rasterWithShoreBound(surface, (x, z) -> {
referenceSamples.incrementAndGet();
return terrain.sample(x, z);
}, 128D, null);
ErosionField bounded = rasterWithShoreBound(surface, (x, z) -> {
boundedSamples.incrementAndGet();
return terrain.sample(x, z);
}, surface.shoreWidth(), null);
assertEqualFields(reference, bounded);
assertNull(bounded.rejection());
assertTrue("sample counts " + boundedSamples.get() + "/" + referenceSamples.get(),
boundedSamples.get() < referenceSamples.get() * 0.8D);
assertNotNull(bounded.column(0, 18));
assertNull(bounded.column(0, -18));
}
@Test
public void geometricExclusionRetainsPolicyBandsAtExactAndAdjacentThresholds() {
HydrologyPlannerSettings.Surface surface = zeroRoughnessSurface();
for (double width : new double[]{Math.nextDown(1.75D), 1.75D, Math.nextUp(1.75D), 12D}) {
HydrologyTerrainSampler terrain = (x, z) -> land(80, z >= 0 ? width : 0D)
.withShoreWidth(z < 0 ? width : surface.shoreWidth());
double bound = Math.max(surface.shoreWidth(), width);
ErosionField reference = rasterWithShoreBound(surface, terrain, 128D, null);
ErosionField complete = rasterWithShoreBound(surface, terrain, bound, null);
assertEqualFields(reference, complete);
for (SurfaceBounds bounds : List.of(new SurfaceBounds(-16, -16, -1, 15),
new SurfaceBounds(0, -16, 15, 15))) {
ErosionField bounded = rasterWithShoreBound(surface, terrain, bound, bounds);
for (int z = bounds.minimumZ(); z <= bounds.maximumZ(); z++) {
for (int x = bounds.minimumX(); x <= bounds.maximumX(); x++) {
assertEquals("policy width " + width + " at " + x + "," + z,
complete.column(x, z), bounded.column(x, z));
}
}
}
if (width >= 1.75D) {
assertEquals(SurfaceRole.SHORE, complete.column(0, 5).role());
}
if (width == 12D) {
assertEquals(SurfaceRole.SHORE, complete.column(0, 15).role());
assertEquals(SurfaceRole.BANK, complete.column(0, -15).role());
}
}
}
private static ErosionField rasterWithShoreBound(HydrologyPlannerSettings.Surface surface,
HydrologyTerrainSampler terrain, double bound,
SurfaceBounds bounds) {
HydrologyPlannerSettings defaults = HydrologyPlannerSettings.defaults();
HydrologyPlannerSettings settings = new HydrologyPlannerSettings(SEA_LEVEL, defaults.routing(), surface,
defaults.hydraulics(), defaults.underground(), defaults.outlets(), defaults.geometry(),
defaults.deepFluids(), defaults.surfacePools(), bound, defaults.seaCaves(), defaults.surfacePolicyBounds());
SurfaceCenterline centerline = SurfaceCenterline.densify(List.of(
new HydrologyPoint(-48, 80, 0), new HydrologyPoint(48, 80, 0)));
double[] widths = new double[centerline.size()];
double[] depths = new double[centerline.size()];
double[] multipliers = new double[centerline.size()];
int[] heads = new int[centerline.size()];
Arrays.fill(widths, 3D);
Arrays.fill(depths, 2D);
Arrays.fill(multipliers, 1D);
Arrays.fill(heads, 80);
return new ErosionFieldCompiler(settings, terrain).compile(42L, centerline,
new ChannelProfile(widths, depths, multipliers), ValleyProfile.fromHeads(heads, heads.length),
SurfaceTerminal.SINKHOLE, 0, noPonds(), SurfaceRasterContext.bounded(bounds));
}
private static void assertEqualFields(ErosionField expected, ErosionField actual) {
assertEquals(expected.columns(), actual.columns());
assertEquals(expected.rejection(), actual.rejection());
assertEquals(expected.rejectionDetail(), actual.rejectionDetail());
assertEquals(expected.uncontainedWetCells(), actual.uncontainedWetCells());
assertEquals(expected.bankExcavation(), actual.bankExcavation());
}
@Test @Test
public void mouthCutsDrySeaLevelSillBeforeReachingOceanWater() { public void mouthCutsDrySeaLevelSillBeforeReachingOceanWater() {
HydrologyTerrainSampler coast = (int x, int z) -> x >= 100 HydrologyTerrainSampler coast = (int x, int z) -> x >= 100
@@ -81,7 +166,7 @@ public class ErosionFieldCompilerTest {
SurfaceCenterline centerline = SurfaceCenterline.densify(List.of(new HydrologyPoint(-150, 0, 0), new HydrologyPoint(150, 0, 0))); SurfaceCenterline centerline = SurfaceCenterline.densify(List.of(new HydrologyPoint(-150, 0, 0), new HydrologyPoint(150, 0, 0)));
ChannelProfile channel = new ChannelProfileBuilder(surface, hillside, CONSTANT_GEOMETRY).build(centerline, "water", false); ChannelProfile channel = new ChannelProfileBuilder(surface, hillside, CONSTANT_GEOMETRY).build(centerline, "water", false);
ValleyProfile valley = new ValleyProfileSolver(surface, hillside, SEA_LEVEL, 64).solve(centerline, channel, SurfaceTerminal.SINKHOLE, 40); ValleyProfile valley = new ValleyProfileSolver(surface, hillside, SEA_LEVEL, 64).solve(centerline, channel, SurfaceTerminal.SINKHOLE, 40);
ErosionFieldCompiler compiler = new ErosionFieldCompiler(surface, hillside, SEA_LEVEL); ErosionFieldCompiler compiler = new ErosionFieldCompiler(rasterSettings(surface), hillside);
ErosionField complete = compiler.compile(42L, centerline, channel, valley, SurfaceTerminal.SINKHOLE, 8); ErosionField complete = compiler.compile(42L, centerline, channel, valley, SurfaceTerminal.SINKHOLE, 8);
SurfaceBounds bounds = new SurfaceBounds(-16, -16, 15, 15); SurfaceBounds bounds = new SurfaceBounds(-16, -16, 15, 15);
ErosionField bounded = compiler.compile(42L, centerline, channel, valley, SurfaceTerminal.SINKHOLE, 8, surface.banks().ponds(), SurfaceRasterContext.bounded(bounds)); ErosionField bounded = compiler.compile(42L, centerline, channel, valley, SurfaceTerminal.SINKHOLE, 8, surface.banks().ponds(), SurfaceRasterContext.bounded(bounds));
@@ -93,6 +178,44 @@ public class ErosionFieldCompilerTest {
} }
} }
@Test
public void boundedRasterRaisesBanksFromHigherWaterAcrossTheChunkEdge() {
HydrologyPlannerSettings.Surface surface = zeroRoughnessSurface();
HydrologyTerrainSampler lowGround = (x, z) -> HydrologyTerrainSample.openLand(77, 0D, "land");
SurfaceCenterline centerline = SurfaceCenterline.densify(
List.of(new HydrologyPoint(-48, 80, 0), new HydrologyPoint(48, 78, 0)));
int count = centerline.size();
double[] widths = new double[count];
double[] depths = new double[count];
double[] multipliers = new double[count];
int[] heads = new int[count];
Arrays.fill(widths, 3D);
Arrays.fill(depths, 2D);
Arrays.fill(multipliers, 1D);
for (int station = 0; station < count; station++) {
heads[station] = centerline.x()[station] < 0 ? 80 : 78;
}
ChannelProfile channel = new ChannelProfile(widths, depths, multipliers);
ValleyProfile valley = ValleyProfile.fromHeads(heads, count);
ErosionFieldCompiler compiler = new ErosionFieldCompiler(rasterSettings(surface), lowGround);
ErosionField complete = compiler.compile(42L, centerline, channel, valley, SurfaceTerminal.SINKHOLE, 0);
assertNull(complete.rejection());
assertEquals(80, complete.column(0, 4).height());
for (SurfaceBounds bounds : List.of(new SurfaceBounds(-16, -16, -1, 15),
new SurfaceBounds(0, -16, 15, 15), new SurfaceBounds(0, 4, 15, 19))) {
ErosionField bounded = compiler.compile(42L, centerline, channel, valley,
SurfaceTerminal.SINKHOLE, 0, noPonds(), SurfaceRasterContext.bounded(bounds));
assertNull(bounded.rejection());
assertEquals(0, bounded.uncontainedWetCells());
for (int z = bounds.minimumZ(); z <= bounds.maximumZ(); z++) {
for (int x = bounds.minimumX(); x <= bounds.maximumX(); x++) {
assertEquals("bounded column " + x + "," + z, complete.column(x, z), bounded.column(x, z));
}
}
}
}
@Test @Test
public void flatTerrainHoldsTheWaterFlushWithTheBankAndContainedByDefault() { public void flatTerrainHoldsTheWaterFlushWithTheBankAndContainedByDefault() {
Compiled compiled = compile(300, (x, z) -> 80, SurfaceTerminal.SINKHOLE, 40); Compiled compiled = compile(300, (x, z) -> 80, SurfaceTerminal.SINKHOLE, 40);
@@ -320,6 +443,12 @@ public class ErosionFieldCompilerTest {
surface.minimumDepth(), surface.maximumDepth(), surface.maximumIncision(), surface.shoreWidth(), banks); surface.minimumDepth(), surface.maximumDepth(), surface.maximumIncision(), surface.shoreWidth(), banks);
} }
private static HydrologyPlannerSettings.Surface withMaximumIncision(HydrologyPlannerSettings.Surface surface, int maximumIncision) {
return new HydrologyPlannerSettings.Surface(
surface.enabled(), surface.sources(), surface.minimumWidth(), surface.maximumWidth(),
surface.minimumDepth(), surface.maximumDepth(), maximumIncision, surface.shoreWidth(), surface.banks());
}
@Test @Test
public void bankColumnsBeyondTheBlendAreNotWritten() { public void bankColumnsBeyondTheBlendAreNotWritten() {
Compiled compiled = compile(300, (x, z) -> 80, SurfaceTerminal.SINKHOLE, 40); Compiled compiled = compile(300, (x, z) -> 80, SurfaceTerminal.SINKHOLE, 40);
@@ -333,6 +462,14 @@ public class ErosionFieldCompilerTest {
} }
} }
private static void assertBoundedBankFill(Compiled compiled, int maximumFill) {
for (SurfaceColumn column : compiled.field().columns().values()) {
int fill = column.height() - column.terrain().naturalHeight();
assertTrue(column.x() + "," + column.z() + " fill " + fill,
fill <= (column.role() == SurfaceRole.CHANNEL ? 0 : maximumFill));
}
}
private static void assertChannelContained(Compiled compiled) { private static void assertChannelContained(Compiled compiled) {
for (SurfaceColumn column : compiled.field().columns().values()) { for (SurfaceColumn column : compiled.field().columns().values()) {
if (column.role() != SurfaceRole.CHANNEL || column.apron()) { if (column.role() != SurfaceRole.CHANNEL || column.apron()) {
@@ -376,7 +513,7 @@ public class ErosionFieldCompilerTest {
ValleyProfile valley = new ValleyProfileSolver(surface, sampler, SEA_LEVEL, 64) ValleyProfile valley = new ValleyProfileSolver(surface, sampler, SEA_LEVEL, 64)
.solve(centerline, channel, terminal, terminalHead); .solve(centerline, channel, terminal, terminalHead);
assertNull(valley.rejection()); assertNull(valley.rejection());
ErosionField field = new ErosionFieldCompiler(surface, sampler, SEA_LEVEL) ErosionField field = new ErosionFieldCompiler(rasterSettings(surface), sampler)
.compile(1234L, centerline, channel, valley, terminal, 8); .compile(1234L, centerline, channel, valley, terminal, 8);
return new Compiled(field, valley); return new Compiled(field, valley);
} }
@@ -624,7 +761,7 @@ public class ErosionFieldCompilerTest {
.solve(centerline, channel, SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL); .solve(centerline, channel, SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL);
assertNull(valley.rejection()); assertNull(valley.rejection());
ErosionField field = new ErosionFieldCompiler(surface, sampler, SEA_LEVEL) ErosionField field = new ErosionFieldCompiler(rasterSettings(surface), sampler)
.compile(99L, centerline, channel, valley, SurfaceTerminal.OCEAN_MOUTH, 3); .compile(99L, centerline, channel, valley, SurfaceTerminal.OCEAN_MOUTH, 3);
int firstApron = Integer.MAX_VALUE; int firstApron = Integer.MAX_VALUE;
@@ -754,10 +891,12 @@ public class ErosionFieldCompilerTest {
assertEquals(0, compiled.field().uncontainedWetCells()); assertEquals(0, compiled.field().uncontainedWetCells());
assertChannelContained(compiled); assertChannelContained(compiled);
assertNoWriteAboveNatural(compiled); assertBoundedBankFill(compiled, 8);
assertEquals(80, compiled.field().column(0, 0).headY()); assertEquals(80, compiled.field().column(0, 0).headY());
SurfaceColumn nearRim = compiled.field().column(0, 4); SurfaceColumn nearRim = compiled.field().column(0, 4);
assertEquals(SurfaceRole.CHANNEL, nearRim.role()); assertEquals(SurfaceRole.CHANNEL, nearRim.role());
assertTrue(compiled.field().columns().values().stream()
.anyMatch(column -> column.height() > column.terrain().naturalHeight()));
for (SurfaceColumn column : compiled.field().columns().values()) { for (SurfaceColumn column : compiled.field().columns().values()) {
if (column.role() == SurfaceRole.CHANNEL) { if (column.role() == SurfaceRole.CHANNEL) {
assertTrue(Math.abs(column.z()) <= 7); assertTrue(Math.abs(column.z()) <= 7);
@@ -805,19 +944,21 @@ public class ErosionFieldCompilerTest {
HydrologyTerrainSampler shelf = (int x, int z) -> HydrologyTerrainSampler shelf = (int x, int z) ->
HydrologyTerrainSample.openLand(80 + Math.min(16, Math.max(0, Math.abs(z) - 4) * 4), 0D, "land"); HydrologyTerrainSample.openLand(80 + Math.min(16, Math.max(0, Math.abs(z) - 4) * 4), 0D, "land");
Compiled terraced = compile( Compiled terraced = compile(
zeroRoughnessSurface(0, new HydrologyPlannerSettings.Erosion(true, 12, 0.45D, 1D, 0.5D, withMaximumIncision(zeroRoughnessSurface(0, new HydrologyPlannerSettings.Erosion(true, 12, 0.45D, 1D, 0.5D,
IrisRiverBlendStyle.TERRACED, 4, 0.5D, IrisRiverBedProfile.BOWL, 0D, 0D, IrisRiverBlendStyle.TERRACED, 4, 0.5D, IrisRiverBedProfile.BOWL, 0D, 0D,
new HydrologyPlannerSettings.Excavation(64, 64, 8192))), new HydrologyPlannerSettings.Excavation(64, 64, 8192))), 32),
shelf, 300, SurfaceTerminal.SINKHOLE, 40); shelf, 300, SurfaceTerminal.SINKHOLE, 40);
assertNull(terraced.field().rejection());
Set<Integer> steps = bankHeights(terraced, 150, 9, 40); Set<Integer> steps = bankHeights(terraced, 150, 9, 40);
assertTrue(steps.toString(), steps.size() >= 2 && steps.size() <= 4); assertTrue(steps.toString(), steps.size() >= 2 && steps.size() <= 4);
assertHolds(terraced); assertHolds(terraced);
Compiled cliff = compile( Compiled cliff = compile(
zeroRoughnessSurface(0, new HydrologyPlannerSettings.Erosion(true, 12, 0.45D, 1D, 0.5D, withMaximumIncision(zeroRoughnessSurface(0, new HydrologyPlannerSettings.Erosion(true, 12, 0.45D, 1D, 0.5D,
IrisRiverBlendStyle.CLIFF, 4, 0.5D, IrisRiverBedProfile.BOWL, 0D, 0D, IrisRiverBlendStyle.CLIFF, 4, 0.5D, IrisRiverBedProfile.BOWL, 0D, 0D,
new HydrologyPlannerSettings.Excavation(64, 64, 8192))), new HydrologyPlannerSettings.Excavation(64, 64, 8192))), 32),
shelf, 300, SurfaceTerminal.SINKHOLE, 40); shelf, 300, SurfaceTerminal.SINKHOLE, 40);
assertNull(cliff.field().rejection());
Set<Integer> bench = bankHeights(cliff, 150, 9, 40); Set<Integer> bench = bankHeights(cliff, 150, 9, 40);
assertEquals(bench.toString(), 2, bench.size()); assertEquals(bench.toString(), 2, bench.size());
assertTrue(bench.contains(80)); assertTrue(bench.contains(80));
@@ -873,7 +1014,43 @@ public class ErosionFieldCompilerTest {
assertTrue(column.height() <= hillside.sample(150, z).naturalHeight()); assertTrue(column.height() <= hillside.sample(150, z).naturalHeight());
previous = column.height(); previous = column.height();
} }
assertHolds(rising); assertEquals(0, rising.field().uncontainedWetCells());
assertChannelContained(rising);
assertBoundedBankFill(rising, 8);
assertEquals(92, rising.field().column(150, -7).height());
assertEquals(90, rising.field().column(150, -7).terrain().naturalHeight());
}
@Test
public void zeroBankDepthBudgetLeavesLowBanksUnfilledAndUncontained() {
HydrologyPlannerSettings.Erosion erosion = new HydrologyPlannerSettings.Erosion(
true, 12, 0.45D, 1D, 0.5D, IrisRiverBlendStyle.SMOOTH, 4, 0.5D,
IrisRiverBedProfile.BOWL, 0D, 0D, new HydrologyPlannerSettings.Excavation(0, 16, 256));
HydrologyPlannerSettings.Surface surface = zeroRoughnessSurface(0, erosion);
HydrologyTerrainSampler lowGround = (x, z) -> HydrologyTerrainSample.openLand(77, 0D, "land");
SurfaceCenterline centerline = SurfaceCenterline.densify(
List.of(new HydrologyPoint(0, 80, 0), new HydrologyPoint(16, 80, 0)));
int count = centerline.size();
double[] widths = new double[count];
double[] depths = new double[count];
double[] multipliers = new double[count];
int[] heads = new int[count];
Arrays.fill(widths, 3D);
Arrays.fill(depths, 2D);
Arrays.fill(multipliers, 1D);
Arrays.fill(heads, 80);
ErosionField field = new ErosionFieldCompiler(rasterSettings(surface), lowGround).compile(42L,
centerline, new ChannelProfile(widths, depths, multipliers), ValleyProfile.fromHeads(heads, count),
SurfaceTerminal.SINKHOLE, 0);
assertEquals(HydrologyCandidateRejection.SURFACE_WATER_CONTAINMENT, field.rejection());
assertTrue(field.uncontainedWetCells() > 0);
assertEquals(77, field.column(8, 4).height());
for (SurfaceColumn column : field.columns().values()) {
if (column.role() != SurfaceRole.CHANNEL) {
assertEquals(column.terrain().naturalHeight(), column.height());
}
}
} }
@Test @Test
@@ -987,4 +1164,11 @@ public class ErosionFieldCompilerTest {
assertEquals(SurfaceRole.SHORE, bare.field().column(150, 4).role()); assertEquals(SurfaceRole.SHORE, bare.field().column(150, 4).role());
assertHolds(bare); assertHolds(bare);
} }
private static HydrologyPlannerSettings rasterSettings(HydrologyPlannerSettings.Surface surface) {
HydrologyPlannerSettings defaults = HydrologyPlannerSettings.defaults();
return new HydrologyPlannerSettings(60, defaults.routing(), surface, defaults.hydraulics(),
defaults.underground(), defaults.outlets(), defaults.geometry(), defaults.deepFluids(),
defaults.surfacePools(), 12D, defaults.seaCaves(), defaults.surfacePolicyBounds());
}
} }
@@ -0,0 +1,120 @@
package art.arcane.iris.generation.hydrology.surface;
import art.arcane.iris.generation.hydrology.HydrologyCandidateRejection;
import art.arcane.iris.generation.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.generation.hydrology.HydrologyPoint;
import art.arcane.iris.generation.hydrology.HydrologyTerrainSample;
import art.arcane.iris.generation.hydrology.HydrologyTerrainSampler;
import org.junit.Test;
import java.util.Arrays;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class RiverFirstTerrainTest {
@Test
public void riverBuildsContainedBanksAroundALowNaturalPatch() {
HydrologyTerrainSampler sampler = (x, z) -> HydrologyTerrainSample.openLand(77, 0D, "land");
ErosionField field = compile(sampler, null);
assertNull(field.rejection());
assertEquals(0, field.uncontainedWetCells());
SurfaceColumn bed = field.column(0, 0);
assertNotNull(bed);
assertEquals(bed.terrain().naturalHeight(), bed.height());
assertTrue(bed.height() < bed.headY());
int raisedBanks = 0;
for (SurfaceColumn column : field.columns().values()) {
if (column.role() != SurfaceRole.CHANNEL && column.height() > column.terrain().naturalHeight()) {
raisedBanks++;
assertTrue(column.height() - column.terrain().naturalHeight() <= 8);
}
}
assertTrue(raisedBanks > 0);
}
@Test
public void riverRejectsRequiredFillBeyondTheTerrainBudget() {
ErosionField field = compile((x, z) -> HydrologyTerrainSample.openLand(69, 0D, "land"), null);
assertEquals(HydrologyCandidateRejection.SURFACE_WATER_CONTAINMENT, field.rejection());
}
@Test
public void unavailableBankCannotCertifyTheWetPerimeter() {
HydrologyTerrainSample ground = HydrologyTerrainSample.openLand(77, 0D, "land");
ErosionField complete = compile((x, z) -> ground, null);
assertNull(complete.rejection());
SurfaceColumn bank = complete.columns().values().stream()
.filter(column -> column.x() == 0 && column.role() != SurfaceRole.CHANNEL
&& complete.column(0, column.z() - 1) != null
&& complete.column(0, column.z() - 1).role() == SurfaceRole.CHANNEL)
.findFirst().orElseThrow();
ErosionField unavailable = compile((x, z) -> x == bank.x() && z == bank.z() ? null : ground, null);
assertEquals(HydrologyCandidateRejection.SURFACE_WATER_CONTAINMENT, unavailable.rejection());
assertTrue(unavailable.uncontainedWetCells() > 0);
}
@Test
public void fineChannelIncisionLimitIsCheckedBeforePublication() {
ErosionField field = compile((x, z) -> HydrologyTerrainSample.openLand(x == 0 && z == 1 ? 110 : 82, 0D, "land"), null);
assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, field.rejection());
}
@Test
public void channelEdgesRetainWaterWithinTheLocalIncisionLimit() {
ErosionField field = compile((x, z) -> HydrologyTerrainSample.openLand(x == 0 && z == 1 ? 89 : 82, 0D, "land"), null);
assertNull(field.rejection());
SurfaceColumn column = field.column(0, 1);
assertEquals(SurfaceRole.CHANNEL, column.role());
assertEquals(79, column.height());
assertEquals(80, column.headY());
}
@Test
public void adjacentChunkRastersRetainIdenticalRaisedTerrain() {
HydrologyTerrainSampler sampler = (x, z) -> HydrologyTerrainSample.openLand(77 + Math.floorMod(x + z, 3), 0D, "land");
ErosionField complete = compile(sampler, null);
assertNull(complete.rejection());
for (SurfaceBounds bounds : List.of(new SurfaceBounds(-16, -16, -1, 15),
new SurfaceBounds(0, -16, 15, 15))) {
ErosionField bounded = compile(sampler, bounds);
assertNull(bounded.rejection());
for (SurfaceColumn column : complete.columns().values()) {
if (bounds.contains(column.x(), column.z())) {
assertEquals(column, bounded.column(column.x(), column.z()));
}
}
}
}
private static ErosionField compile(HydrologyTerrainSampler sampler, SurfaceBounds bounds) {
HydrologyPlannerSettings.Surface settings = HydrologyPlannerSettings.defaults().surface();
SurfaceCenterline centerline = SurfaceCenterline.densify(
List.of(new HydrologyPoint(-48, 80, 0), new HydrologyPoint(48, 80, 0)));
int count = centerline.size();
double[] widths = new double[count];
double[] depths = new double[count];
double[] multipliers = new double[count];
int[] heads = new int[count];
Arrays.fill(widths, 3D);
Arrays.fill(depths, 2D);
Arrays.fill(multipliers, 1D);
Arrays.fill(heads, 80);
return new ErosionFieldCompiler(rasterSettings(settings), sampler).compile(17L, centerline,
new ChannelProfile(widths, depths, multipliers), ValleyProfile.fromHeads(heads, count),
SurfaceTerminal.SINKHOLE, 0, HydrologyPlannerSettings.Ponds.none(), SurfaceRasterContext.bounded(bounds));
}
private static HydrologyPlannerSettings rasterSettings(HydrologyPlannerSettings.Surface surface) {
HydrologyPlannerSettings defaults = HydrologyPlannerSettings.defaults();
return new HydrologyPlannerSettings(60, defaults.routing(), surface, defaults.hydraulics(),
defaults.underground(), defaults.outlets(), defaults.geometry(), defaults.deepFluids(),
defaults.surfacePools(), surface.shoreWidth(), defaults.seaCaves(), defaults.surfacePolicyBounds());
}
}
@@ -0,0 +1,112 @@
package art.arcane.iris.generation.hydrology.surface;
import art.arcane.iris.generation.hydrology.HydraulicSegment;
import art.arcane.iris.generation.hydrology.HydrologyCandidateRejection;
import art.arcane.iris.generation.hydrology.HydrologyGeometrySampler;
import art.arcane.iris.generation.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.generation.hydrology.HydrologyPoint;
import art.arcane.iris.generation.hydrology.HydrologyTerrainSample;
import art.arcane.iris.generation.hydrology.HydrologyTerrainSampler;
import org.junit.Test;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class RiverProfileSolverTest {
private static final HydrologyGeometrySampler GEOMETRY = request -> switch (request.field()) {
case SURFACE_WIDTH -> 6;
case SURFACE_DEPTH -> 2;
default -> request.minimum();
};
@Test
public void detailedTerrainDoesNotReplacePlannedRiverElevations() {
List<HydrologyPoint> path = List.of(new HydrologyPoint(0, 80, 0), new HydrologyPoint(96, 80, 0));
for (int natural : List.of(78, 83, 84)) {
SurfaceCourseResult result = build(path, (x, z) -> HydrologyTerrainSample.openLand(natural, 0D, "land"));
assertNull(result.rejection());
assertEquals(80, result.lastHead());
for (HydraulicSegment segment : result.segments()) {
assertEquals(80, segment.upstreamHeadY());
assertEquals(80, segment.downstreamHeadY());
}
}
}
@Test
public void interpolatedSharedHeadsRemainNonRisingAcrossStations() {
List<HydrologyPoint> path = List.of(new HydrologyPoint(-96, 88, -16),
new HydrologyPoint(0, 80, 0), new HydrologyPoint(96, 72, 16));
SurfaceCourseResult result = build(path,
(x, z) -> HydrologyTerrainSample.openLand(82 - Math.floorDiv(x, 12), 0D, "land"));
assertNull(result.rejection());
int previous = Integer.MAX_VALUE;
for (HydraulicSegment segment : result.segments()) {
for (HydrologyPoint point : segment.centerline()) {
assertTrue(point.y() <= previous);
previous = point.y();
}
}
assertEquals(72, result.lastHead());
}
@Test
public void plannedProfileChecksTheCenterWithoutSearchingBankRings() {
List<HydrologyPoint> path = List.of(new HydrologyPoint(0, 80, 0), new HydrologyPoint(96, 80, 0));
SurfaceCenterline centerline = SurfaceCenterline.densify(path);
double[] widths = new double[centerline.size()];
double[] depths = new double[centerline.size()];
double[] banks = new double[centerline.size()];
Arrays.fill(widths, 3D);
Arrays.fill(depths, 2D);
Arrays.fill(banks, 1D);
AtomicInteger samples = new AtomicInteger();
HydrologyTerrainSampler sampler = (x, z) -> {
assertEquals(0, z);
samples.incrementAndGet();
return HydrologyTerrainSample.openLand(82, 0D, "land");
};
ValleyProfile profile = new RiverProfileSolver(
new RiverProfileSolver.Options(HydrologyPlannerSettings.defaults().surface(), 60), sampler)
.solve(path, centerline, new ChannelProfile(widths, depths, banks), SurfaceTerminal.SINKHOLE, 40, 32);
assertNull(profile.rejection());
assertEquals(centerline.size(), samples.get());
}
@Test
public void aFineCliffKeepsTheSourceHeadAndRaisesOnlyTheRequiredApproach() {
HydrologyTerrainSampler sampler = (x, z) -> HydrologyTerrainSample.openLand(x < 32 ? 102 : 86, 0D, "land");
SurfaceCourseResult result = build(List.of(new HydrologyPoint(0, 100, 0),
new HydrologyPoint(64, 84, 0)), sampler);
assertNull(result.rejection());
assertEquals(100, result.segments().getFirst().upstreamHeadY());
assertEquals(84, result.lastHead());
int previous = 100;
for (HydraulicSegment segment : result.segments()) {
for (HydrologyPoint point : segment.centerline()) {
assertTrue(point.y() <= previous);
assertTrue(sampler.sample(point.x(), point.z()).naturalHeight() - point.y() + 2
<= HydrologyPlannerSettings.defaults().surface().maximumIncision());
previous = point.y();
}
}
}
@Test
public void anUncuttableFineTerrainRidgeStillRejectsTheCourse() {
SurfaceCourseResult result = build(List.of(new HydrologyPoint(0, 80, 0), new HydrologyPoint(96, 80, 0)),
(x, z) -> HydrologyTerrainSample.openLand(x == 48 ? 110 : 82, 0D, "land"));
assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, result.rejection());
}
private static SurfaceCourseResult build(List<HydrologyPoint> path, HydrologyTerrainSampler sampler) {
return new SurfaceCourseBuilder(HydrologyPlannerSettings.defaults().surface(), sampler, GEOMETRY, 60)
.build(7L, 19L, "default", path, SurfaceTerminal.SINKHOLE, 40, 32);
}
}
@@ -180,7 +180,7 @@ public class SurfaceBendProfileTest {
ValleyProfile valley = new ValleyProfileSolver(surface, FLAT, 60, 32) ValleyProfile valley = new ValleyProfileSolver(surface, FLAT, 60, 32)
.solve(centerline, channel, SurfaceTerminal.SINKHOLE, 40); .solve(centerline, channel, SurfaceTerminal.SINKHOLE, 40);
assertTrue(valley.accepted()); assertTrue(valley.accepted());
return new ErosionFieldCompiler(surface, FLAT, 60).compile(42L, centerline, channel, valley, return new ErosionFieldCompiler(rasterSettings(surface), FLAT).compile(42L, centerline, channel, valley,
SurfaceTerminal.SINKHOLE, 8, HydrologyPlannerSettings.Ponds.none(), context); SurfaceTerminal.SINKHOLE, 8, HydrologyPlannerSettings.Ponds.none(), context);
} }
@@ -191,4 +191,11 @@ public class SurfaceBendProfileTest {
HydrologyPlannerSettings.Inlet.none(), 2.5D, 24, true, HydrologyPlannerSettings.Inlet.none(), 2.5D, 24, true,
HydrologyPlannerSettings.Erosion.defaults(), HydrologyPlannerSettings.Ponds.none())); HydrologyPlannerSettings.Erosion.defaults(), HydrologyPlannerSettings.Ponds.none()));
} }
private static HydrologyPlannerSettings rasterSettings(HydrologyPlannerSettings.Surface surface) {
HydrologyPlannerSettings defaults = HydrologyPlannerSettings.defaults();
return new HydrologyPlannerSettings(60, defaults.routing(), surface, defaults.hydraulics(),
defaults.underground(), defaults.outlets(), defaults.geometry(), defaults.deepFluids(),
defaults.surfacePools(), surface.shoreWidth(), defaults.seaCaves(), defaults.surfacePolicyBounds());
}
} }
@@ -13,6 +13,7 @@ import org.junit.Test;
import java.util.List; import java.util.List;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull; import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
@@ -34,7 +35,8 @@ public class SurfaceCourseBuilderTest {
7L, 7L,
COURSE_ID, COURSE_ID,
"water", "water",
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(140, 0, 0), new HydrologyPoint(280, 0, 0)), List.of(new HydrologyPoint(0, 110, 0), new HydrologyPoint(140, 87, 0),
new HydrologyPoint(260, 67, 0), new HydrologyPoint(280, SEA_LEVEL, 0)),
SurfaceTerminal.OCEAN_MOUTH, SurfaceTerminal.OCEAN_MOUTH,
SEA_LEVEL, SEA_LEVEL,
64 64
@@ -72,7 +74,7 @@ public class SurfaceCourseBuilderTest {
7L, 7L,
COURSE_ID, COURSE_ID,
"water", "water",
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), List.of(new HydrologyPoint(0, 80, 0), new HydrologyPoint(200, 80, 0)),
SurfaceTerminal.SINKHOLE, SurfaceTerminal.SINKHOLE,
40, 40,
64 64
@@ -85,6 +87,41 @@ public class SurfaceCourseBuilderTest {
assertEquals(201, result.segments().getFirst().centerline().size()); assertEquals(201, result.segments().getFirst().centerline().size());
} }
@Test
public void coastalDropKeepsTheUpstreamGradedReachExposed() {
HydrologyPlannerSettings.Surface defaults = HydrologyPlannerSettings.defaults().surface();
HydrologyPlannerSettings.Surface surface = new HydrologyPlannerSettings.Surface(
defaults.enabled(), defaults.sources(), defaults.minimumWidth(), defaults.maximumWidth(),
defaults.minimumDepth(), defaults.maximumDepth(), defaults.maximumIncision(), defaults.shoreWidth(),
defaults.banks().withInlet(HydrologyPlannerSettings.Inlet.none()));
HydrologyTerrainSampler sampler = (x, z) -> x >= 40
? HydrologyTerrainSample.ocean(50, "ocean")
: HydrologyTerrainSample.openLand(100 - x, 0D, "land");
SurfaceCourseBuilder builder = new SurfaceCourseBuilder(surface, sampler, CONSTANT_GEOMETRY, SEA_LEVEL);
List<HydrologyPoint> path = List.of(new HydrologyPoint(0, 100, 0), new HydrologyPoint(40, SEA_LEVEL, 0));
SurfaceCourseResult result = builder.build(7L, COURSE_ID, "water", path,
SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL, 16);
assertNull(result.rejection());
assertEquals(2, result.segments().size());
HydraulicSegment approach = result.segments().getFirst();
HydraulicSegment drop = result.segments().getLast();
assertEquals(HydrologyFeatureType.CASCADE, approach.type());
assertFalse(approach.fallingFluid());
assertEquals(100, approach.upstreamHeadY());
assertEquals(61, approach.downstreamHeadY());
assertEquals(new HydrologyPoint(20, 80, 0), approach.centerline().get(20));
assertEquals(HydrologyFeatureType.RIFFLE, drop.type());
assertTrue(drop.fallingFluid());
assertTrue(drop.receivingPool());
assertEquals(List.of(new HydrologyPoint(39, 61, 0), new HydrologyPoint(40, 60, 0)), drop.centerline());
assertEquals(approach.channelProfile().widthAt(39), drop.channelProfile().widthAt(0), 0D);
assertEquals(approach.channelProfile().depthAt(39), drop.channelProfile().depthAt(0), 0D);
assertTrue(approach.id() != drop.id());
assertEquals(result, builder.build(7L, COURSE_ID, "water", path,
SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL, 16));
}
@Test @Test
public void cliffProducesAWaterfallSegment() { public void cliffProducesAWaterfallSegment() {
HydrologyTerrainSampler sampler = (int x, int z) -> HydrologyTerrainSample.openLand(x < 100 ? 100 : 90, 0D, "land"); HydrologyTerrainSampler sampler = (int x, int z) -> HydrologyTerrainSample.openLand(x < 100 ? 100 : 90, 0D, "land");
@@ -92,7 +129,8 @@ public class SurfaceCourseBuilderTest {
7L, 7L,
COURSE_ID, COURSE_ID,
"water", "water",
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), List.of(new HydrologyPoint(0, 100, 0), new HydrologyPoint(99, 100, 0),
new HydrologyPoint(100, 90, 0), new HydrologyPoint(200, 90, 0)),
SurfaceTerminal.SINKHOLE, SurfaceTerminal.SINKHOLE,
40, 40,
64 64
@@ -106,14 +144,32 @@ public class SurfaceCourseBuilderTest {
assertEquals(HydrologyFeatureType.SURFACE_POOL, result.segments().get(2).type()); assertEquals(HydrologyFeatureType.SURFACE_POOL, result.segments().get(2).type());
} }
@Test
public void cliffIntoTheOceanUsesFallingFluidInsteadOfAnInlandSeaLevelChannel() {
HydrologyTerrainSampler sampler = (x, z) -> x >= 100
? HydrologyTerrainSample.ocean(50, "ocean")
: HydrologyTerrainSample.openLand(100, 0D, "land");
SurfaceCourseResult result = builder(sampler).build(7L, COURSE_ID, "water",
List.of(new HydrologyPoint(0, 97, 0), new HydrologyPoint(99, 97, 0),
new HydrologyPoint(100, SEA_LEVEL, 0)), SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL, 64);
assertNull(result.rejection());
HydraulicSegment fall = result.segments().getLast();
assertEquals(HydrologyFeatureType.WATERFALL, fall.type());
assertTrue(fall.fallingFluid());
assertTrue(fall.receivingPool());
assertEquals(SEA_LEVEL, fall.downstreamHeadY());
assertEquals(100, fall.end().x());
}
@Test @Test
public void gentleSlopeProducesRifflesAndSteepSlopeProducesCascades() { public void gentleSlopeProducesRifflesAndSteepSlopeProducesCascades() {
HydrologyTerrainSampler gentle = (int x, int z) -> HydrologyTerrainSample.openLand(120 - x / 10, 0D, "land"); HydrologyTerrainSampler gentle = (int x, int z) -> HydrologyTerrainSample.openLand(120 - x / 10, 0D, "land");
SurfaceCourseResult gentleResult = builder(gentle).build(7L, COURSE_ID, "water", SurfaceCourseResult gentleResult = builder(gentle).build(7L, COURSE_ID, "water",
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), SurfaceTerminal.SINKHOLE, 40, 64); List.of(new HydrologyPoint(0, 120, 0), new HydrologyPoint(200, 100, 0)), SurfaceTerminal.SINKHOLE, 40, 64);
HydrologyTerrainSampler steep = (int x, int z) -> HydrologyTerrainSample.openLand(300 - x, 0D, "land"); HydrologyTerrainSampler steep = (int x, int z) -> HydrologyTerrainSample.openLand(300 - x, 0D, "land");
SurfaceCourseResult steepResult = builder(steep).build(7L, COURSE_ID, "water", SurfaceCourseResult steepResult = builder(steep).build(7L, COURSE_ID, "water",
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), SurfaceTerminal.SINKHOLE, 40, 64); List.of(new HydrologyPoint(0, 300, 0), new HydrologyPoint(200, 100, 0)), SurfaceTerminal.SINKHOLE, 40, 64);
assertNull(gentleResult.rejection()); assertNull(gentleResult.rejection());
assertNull("detail=" + steepResult.rejectionDetail(), steepResult.rejection()); assertNull("detail=" + steepResult.rejectionDetail(), steepResult.rejection());
@@ -138,7 +194,7 @@ public class SurfaceCourseBuilderTest {
7L, 7L,
COURSE_ID, COURSE_ID,
"water", "water",
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), List.of(new HydrologyPoint(0, 50, 0), new HydrologyPoint(200, 50, 0)),
SurfaceTerminal.OCEAN_MOUTH, SurfaceTerminal.OCEAN_MOUTH,
SEA_LEVEL, SEA_LEVEL,
64 64
@@ -153,7 +209,8 @@ public class SurfaceCourseBuilderTest {
HydrologyTerrainSampler sampler = (int x, int z) -> x >= 240 HydrologyTerrainSampler sampler = (int x, int z) -> x >= 240
? HydrologyTerrainSample.ocean(50, "ocean") ? HydrologyTerrainSample.ocean(50, "ocean")
: HydrologyTerrainSample.openLand(100 - x / 6, 0D, "land"); : HydrologyTerrainSample.openLand(100 - x / 6, 0D, "land");
List<HydrologyPoint> path = List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(241, 0, 0)); List<HydrologyPoint> path = List.of(new HydrologyPoint(0, 100, 0), new HydrologyPoint(239, 61, 0),
new HydrologyPoint(240, SEA_LEVEL, 0), new HydrologyPoint(241, SEA_LEVEL, 0));
HydrologyPlannerSettings.Surface defaults = HydrologyPlannerSettings.defaults().surface(); HydrologyPlannerSettings.Surface defaults = HydrologyPlannerSettings.defaults().surface();
HydrologyPlannerSettings.Inlet inlet = defaults.banks().inlet(); HydrologyPlannerSettings.Inlet inlet = defaults.banks().inlet();
SurfaceCourseResult result = builder(sampler).build(7L, COURSE_ID, "water", path, SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL, 64); SurfaceCourseResult result = builder(sampler).build(7L, COURSE_ID, "water", path, SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL, 64);
@@ -2,6 +2,7 @@ package art.arcane.iris.generation.hydrology.surface;
import art.arcane.iris.generation.hydrology.HydraulicChannelProfile; import art.arcane.iris.generation.hydrology.HydraulicChannelProfile;
import art.arcane.iris.generation.hydrology.HydraulicSegment; import art.arcane.iris.generation.hydrology.HydraulicSegment;
import art.arcane.iris.generation.hydrology.HydrologyCandidateRejection;
import art.arcane.iris.generation.hydrology.HydrologyColumnLayer; import art.arcane.iris.generation.hydrology.HydrologyColumnLayer;
import art.arcane.iris.generation.hydrology.HydrologyColumnSample; import art.arcane.iris.generation.hydrology.HydrologyColumnSample;
import art.arcane.iris.generation.hydrology.HydrologyFeatureType; import art.arcane.iris.generation.hydrology.HydrologyFeatureType;
@@ -131,6 +132,33 @@ public class SurfaceFootprintCompilerTest {
assertNull(column(footprint, 160, 12)); assertNull(column(footprint, 160, 12));
} }
@Test
public void plannedCoastalWaterfallRetainsAConnectedOceanReceiverWithoutCuttingTheCliffToSeaLevel() {
int seaLevel = HydrologyPlannerSettings.defaults().seaLevel();
HydrologyTerrainSampler sampler = (x, z) -> x >= 100
? HydrologyTerrainSample.ocean(50, "ocean")
: HydrologyTerrainSample.openLand(100, 0D, "land");
SurfaceCourseResult result = new SurfaceCourseBuilder(HydrologyPlannerSettings.defaults().surface(),
sampler, CONSTANT_GEOMETRY, seaLevel).build(7L, 1L, "water",
List.of(new HydrologyPoint(0, 97, 0), new HydrologyPoint(99, 97, 0),
new HydrologyPoint(100, seaLevel, 0)), SurfaceTerminal.OCEAN_MOUTH, seaLevel, 64);
assertNull(result.rejection());
ArrayList<HydraulicSegment> segments = new ArrayList<>(result.segments());
segments.add(segment(77L, HydrologyFeatureType.MOUTH, seaLevel, seaLevel,
List.of(result.pathEnd())));
SurfaceFootprint footprint = compiler(sampler).compile(course(segments));
assertNull(footprint.rejection());
assertEquals(0, footprint.uncontainedWetCells());
assertTrue(footprint.columns().stream().anyMatch(column -> column.layer().channel()
&& column.layer().fluidOwned() && column.layer().fluidHeadY() == 97));
for (SurfaceLayerColumn column : footprint.columns()) {
if (!column.apron() && column.role() == SurfaceRole.CHANNEL) {
assertTrue(column.layer().bedY() >= 90);
}
}
}
@Test @Test
public void mouthOverTheOceanPublishesOnlyANonOwningApron() { public void mouthOverTheOceanPublishesOnlyANonOwningApron() {
HydrologyTerrainSampler sampler = (int x, int z) -> x >= 100 HydrologyTerrainSampler sampler = (int x, int z) -> x >= 100
@@ -212,13 +240,101 @@ public class SurfaceFootprintCompilerTest {
} }
} }
@Test
public void inletAllowanceSurvivesAFallWithoutReachingOrdinaryUpstreamTerrain() {
HydrologyPlannerSettings defaults = HydrologyPlannerSettings.defaults();
HydrologyPlannerSettings.Banks banks = defaults.surface().banks();
HydrologyPlannerSettings.Erosion erosion = banks.erosion();
HydrologyPlannerSettings.Erosion expandedVolume = new HydrologyPlannerSettings.Erosion(
erosion.enabled(), erosion.smoothingRadius(), erosion.thalwegFraction(), erosion.blendCurve(),
erosion.bedNoise(), erosion.style(), erosion.terraceSteps(), erosion.cliffFraction(), erosion.bedProfile(),
erosion.shoreRise(), erosion.blendBaseWidth(), new HydrologyPlannerSettings.Excavation(8, 16, 512));
HydrologyPlannerSettings.Banks expandedBanks = new HydrologyPlannerSettings.Banks(
banks.sink(), banks.blendSlope(), banks.minimumBlendWidth(), banks.maximumBlendWidth(), banks.roughness(),
banks.roughnessWavelength(), banks.cascadeRun(), banks.waterfallMinimumDrop(), banks.mouthFlareRatio(),
banks.inlet(), banks.springWidthRatio(), banks.springLength(), banks.exposeCutStrata(), expandedVolume,
banks.ponds(), banks.channel(), banks.flow());
HydrologyPlannerSettings.Surface originalSurface = defaults.surface();
HydrologyPlannerSettings.Surface surface = new HydrologyPlannerSettings.Surface(
originalSurface.enabled(), originalSurface.sources(), originalSurface.minimumWidth(), originalSurface.maximumWidth(),
originalSurface.minimumDepth(), originalSurface.maximumDepth(), originalSurface.maximumIncision(),
originalSurface.shoreWidth(), expandedBanks);
HydrologyPlannerSettings settings = new HydrologyPlannerSettings(defaults.seaLevel(), defaults.routing(), surface,
defaults.hydraulics(), defaults.underground(), defaults.outlets(), defaults.geometry(), defaults.deepFluids(),
defaults.surfacePools(), defaults.widestShoreBiomeWidth(), defaults.seaCaves(), defaults.surfacePolicyBounds());
int seaLevel = settings.seaLevel();
int upstreamHead = seaLevel + 22;
int downstreamHead = seaLevel + 12;
HydrologyTerrainSampler sampler = (x, z) -> x >= 200
? HydrologyTerrainSample.ocean(50, "ocean")
: HydrologyTerrainSample.openLand(x >= 120 && x <= 135 ? 100 : x < 151 ? 90 : 80, 0D, "land");
HydraulicSegment fall = new HydraulicSegment(2L, 1L, HydrologyFeatureType.WATERFALL,
upstreamHead, downstreamHead, 6, 3, true, true,
List.of(new HydrologyPoint(150, upstreamHead, 0), new HydrologyPoint(151, downstreamHead, 0)),
HydraulicChannelProfile.uniform(6, 3));
ArrayList<HydrologyPoint> ramp = new ArrayList<>();
for (int x = 180; x <= 192; x++) {
ramp.add(new HydrologyPoint(x, downstreamHead - (x - 180), 0));
}
RiverCourse course = course(List.of(
segment(1L, HydrologyFeatureType.SURFACE_POOL, upstreamHead, upstreamHead, points(0, 149, upstreamHead)),
fall,
segment(3L, HydrologyFeatureType.SURFACE_POOL, downstreamHead, downstreamHead, points(151, 180, downstreamHead)),
segment(4L, HydrologyFeatureType.CASCADE, downstreamHead, seaLevel, ramp),
segment(5L, HydrologyFeatureType.SURFACE_POOL, seaLevel, seaLevel, points(192, 199, seaLevel)),
segment(6L, HydrologyFeatureType.MOUTH, seaLevel, seaLevel, points(199, 201, seaLevel))));
SurfaceFootprintCompiler compiler = new SurfaceFootprintCompiler(settings, sampler, CONSTANT_GEOMETRY);
SurfaceFootprint full = compiler.compile(course);
assertNull("detail=" + full.rejectionDetail(), full.rejection());
assertEquals(0, full.uncontainedWetCells());
for (int x : new int[]{130, 195}) {
SurfaceLayerColumn channel = column(full, x, 0);
assertNotNull(channel);
assertTrue(channel.layer().channel());
assertEquals(x == 130 ? upstreamHead : seaLevel, channel.layer().fluidHeadY());
int cut = channel.terrain().naturalHeight() - channel.layer().bedY();
assertTrue(cut > settings.surface().maximumIncision());
assertTrue(cut <= settings.surface().banks().inlet().maximumIncision());
}
for (SurfaceBounds bounds : List.of(new SurfaceBounds(112, -8, 159, 8), new SurfaceBounds(144, -8, 199, 8))) {
SurfaceFootprint bounded = compiler.compile(course, bounds);
assertNull(bounded.rejection());
assertEquals(full.columns().stream().filter(value -> bounds.contains(value.x(), value.z())).toList(),
bounded.columns());
}
HydrologyTerrainSampler upstreamRidge = (x, z) -> x == 60
? HydrologyTerrainSample.openLand(100, 0D, "land") : sampler.sample(x, z);
SurfaceFootprint rejected = new SurfaceFootprintCompiler(settings, upstreamRidge, CONSTANT_GEOMETRY).compile(course);
assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, rejected.rejection());
}
@Test
public void standaloneMouthKeepsItsInletBudgetWithinTheDryCourseFraction() {
int seaLevel = HydrologyPlannerSettings.defaults().seaLevel();
for (int coast : new int[]{8, 100}) {
int ridgeX = Math.max(1, coast / 10);
HydrologyTerrainSampler sampler = (x, z) -> x >= coast
? HydrologyTerrainSample.ocean(50, "ocean")
: HydrologyTerrainSample.openLand(x == ridgeX ? 85 : 70, 0D, "land");
RiverCourse mouth = course(List.of(segment(1L, HydrologyFeatureType.MOUTH,
seaLevel, seaLevel, points(0, coast + 1, seaLevel))));
SurfaceFootprint rejected = compiler(sampler).compile(mouth);
assertEquals("coast=" + coast, HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED,
rejected.rejection());
}
}
@Test @Test
public void aContainedFallRetainsBothExposedRunsAndWholeCourseStationIndices() { public void aContainedFallRetainsBothExposedRunsAndWholeCourseStationIndices() {
HydrologyTerrainSampler sampler = (int x, int z) -> HydrologyTerrainSample.openLand(x < 100 ? 100 : 90, 0D, "land"); HydrologyTerrainSampler sampler = (int x, int z) -> HydrologyTerrainSample.openLand(x < 100 ? 100 : 90, 0D, "land");
HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults(); HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults();
SurfaceCourseResult built = new SurfaceCourseBuilder(settings.surface(), sampler, CONSTANT_GEOMETRY, SEA_LEVEL) SurfaceCourseResult built = new SurfaceCourseBuilder(settings.surface(), sampler, CONSTANT_GEOMETRY, SEA_LEVEL)
.build(7L, 1L, "water", List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), .build(7L, 1L, "water", List.of(new HydrologyPoint(0, 100, 0), new HydrologyPoint(99, 100, 0),
SurfaceTerminal.TRIBUTARY, 40, 64); new HydrologyPoint(100, 90, 0), new HydrologyPoint(200, 90, 0)),
SurfaceTerminal.TRIBUTARY, 90, 64);
assertNull(built.rejection()); assertNull(built.rejection());
RiverCourse course = course(built.segments()); RiverCourse course = course(built.segments());
HydraulicSegment fall = course.segments().stream().filter(HydraulicSegment::fallingFluid).findFirst().orElseThrow(); HydraulicSegment fall = course.segments().stream().filter(HydraulicSegment::fallingFluid).findFirst().orElseThrow();
@@ -246,7 +362,13 @@ public class SurfaceFootprintCompilerTest {
assertEquals(full.columns().stream().filter(value -> bounds.contains(value.x(), value.z())).toList(), bounded.columns()); assertEquals(full.columns().stream().filter(value -> bounds.contains(value.x(), value.z())).toList(), bounded.columns());
for (SurfaceLayerColumn value : full.columns()) { for (SurfaceLayerColumn value : full.columns()) {
if (value.layer().terrainOwned()) { if (value.layer().terrainOwned()) {
assertTrue(value.layer().bedY() <= value.terrain().naturalHeight()); int fill = value.layer().bedY() - value.terrain().naturalHeight();
if (value.layer().channel()) {
assertTrue(fill <= 0);
} else {
assertTrue(fill <= settings.surface().maximumIncision());
assertTrue(fill <= settings.surface().banks().erosion().excavation().maximumDepth());
}
} }
} }
} }
@@ -256,8 +378,8 @@ public class SurfaceFootprintCompilerTest {
HydrologyTerrainSampler sampler = (int x, int z) -> HydrologyTerrainSample.openLand(300 - x, 0D, "land"); HydrologyTerrainSampler sampler = (int x, int z) -> HydrologyTerrainSample.openLand(300 - x, 0D, "land");
HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults(); HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults();
SurfaceCourseResult built = new SurfaceCourseBuilder(settings.surface(), sampler, CONSTANT_GEOMETRY, SEA_LEVEL) SurfaceCourseResult built = new SurfaceCourseBuilder(settings.surface(), sampler, CONSTANT_GEOMETRY, SEA_LEVEL)
.build(7L, 1L, "water", List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), .build(7L, 1L, "water", List.of(new HydrologyPoint(0, 300, 0), new HydrologyPoint(200, 100, 0)),
SurfaceTerminal.TRIBUTARY, 40, 64); SurfaceTerminal.TRIBUTARY, 100, 64);
assertNull("detail=" + built.rejectionDetail(), built.rejection()); assertNull("detail=" + built.rejectionDetail(), built.rejection());
SurfaceFootprint footprint = compiler(sampler).compile(course(built.segments())); SurfaceFootprint footprint = compiler(sampler).compile(course(built.segments()));
assertNull("detail=" + footprint.rejectionDetail(), footprint.rejection()); assertNull("detail=" + footprint.rejectionDetail(), footprint.rejection());
@@ -276,8 +398,9 @@ public class SurfaceFootprintCompilerTest {
x + z < 200 ? 100 : 86, 0D, "land"); x + z < 200 ? 100 : 86, 0D, "land");
HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults(); HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults();
SurfaceCourseResult built = new SurfaceCourseBuilder(settings.surface(), sampler, CONSTANT_GEOMETRY, SEA_LEVEL) SurfaceCourseResult built = new SurfaceCourseBuilder(settings.surface(), sampler, CONSTANT_GEOMETRY, SEA_LEVEL)
.build(7L, 1L, "water", List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 200)), .build(7L, 1L, "water", List.of(new HydrologyPoint(0, 100, 0), new HydrologyPoint(99, 100, 99),
SurfaceTerminal.TRIBUTARY, 40, 64); new HydrologyPoint(100, 86, 100), new HydrologyPoint(200, 86, 200)),
SurfaceTerminal.TRIBUTARY, 86, 64);
assertNull(built.rejection()); assertNull(built.rejection());
RiverCourse course = course(built.segments()); RiverCourse course = course(built.segments());
assertTrue(course.segments().stream().anyMatch(segment -> segment.fallingFluid() && segment.drop() == 14)); assertTrue(course.segments().stream().anyMatch(segment -> segment.fallingFluid() && segment.drop() == 14));
@@ -311,8 +434,9 @@ public class SurfaceFootprintCompilerTest {
: HydrologyTerrainSample.openLand((x < 100 ? 100 : 90) + Math.abs(z) / 3, 0D, "land"); : HydrologyTerrainSample.openLand((x < 100 ? 100 : 90) + Math.abs(z) / 3, 0D, "land");
SurfaceCourseResult built = new SurfaceCourseBuilder(HydrologyPlannerSettings.defaults().surface(), sampler, SurfaceCourseResult built = new SurfaceCourseBuilder(HydrologyPlannerSettings.defaults().surface(), sampler,
CONSTANT_GEOMETRY, SEA_LEVEL).build(7L, 1L, "water", CONSTANT_GEOMETRY, SEA_LEVEL).build(7L, 1L, "water",
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(180, 0, 0)), List.of(new HydrologyPoint(0, 100, 0), new HydrologyPoint(99, 100, 0),
SurfaceTerminal.TRIBUTARY, 40, 64); new HydrologyPoint(100, 90, 0), new HydrologyPoint(180, 90, 0)),
SurfaceTerminal.TRIBUTARY, 90, 64);
assertNull(built.rejection()); assertNull(built.rejection());
RiverCourse falling = course(built.segments()); RiverCourse falling = course(built.segments());
RiverCourse mouth = course(List.of(segment(1L, HydrologyFeatureType.MOUTH, SEA_LEVEL, SEA_LEVEL, RiverCourse mouth = course(List.of(segment(1L, HydrologyFeatureType.MOUTH, SEA_LEVEL, SEA_LEVEL,
@@ -186,7 +186,7 @@ public class ValleyProfileSolverTest {
assertNull(valley.rejection()); assertNull(valley.rejection());
assertEquals(100, valley.head()[130]); assertEquals(100, valley.head()[130]);
assertEquals(98, valley.head()[149]); assertEquals(98, valley.head()[149]);
ErosionField field = new ErosionFieldCompiler(HydrologyPlannerSettings.defaults().surface(), terrain, SEA_LEVEL) ErosionField field = new ErosionFieldCompiler(rasterSettings(HydrologyPlannerSettings.defaults().surface()), terrain)
.compile(77L, centerline, channel, valley, SurfaceTerminal.TRIBUTARY, 0, .compile(77L, centerline, channel, valley, SurfaceTerminal.TRIBUTARY, 0,
HydrologyPlannerSettings.Ponds.none()); HydrologyPlannerSettings.Ponds.none());
assertEquals(0, field.uncontainedWetCells()); assertEquals(0, field.uncontainedWetCells());
@@ -214,7 +214,7 @@ public class ValleyProfileSolverTest {
assertEquals(120, valley.head()[110]); assertEquals(120, valley.head()[110]);
assertEquals(118, valley.head()[128]); assertEquals(118, valley.head()[128]);
assertContained(valley); assertContained(valley);
ErosionField field = new ErosionFieldCompiler(surface, terrain, SEA_LEVEL) ErosionField field = new ErosionFieldCompiler(rasterSettings(surface), terrain)
.compile(77L, centerline, channel, valley, SurfaceTerminal.TRIBUTARY, 0, .compile(77L, centerline, channel, valley, SurfaceTerminal.TRIBUTARY, 0,
HydrologyPlannerSettings.Ponds.none()); HydrologyPlannerSettings.Ponds.none());
assertEquals(0, field.uncontainedWetCells()); assertEquals(0, field.uncontainedWetCells());
@@ -231,7 +231,7 @@ public class ValleyProfileSolverTest {
assertNull(valley.rejection()); assertNull(valley.rejection());
assertTrue(valley.exposedStations() < 240); assertTrue(valley.exposedStations() < 240);
assertEquals(92, valley.head()[valley.exposedStations() - 1]); assertEquals(92, valley.head()[valley.exposedStations() - 1]);
ErosionField field = new ErosionFieldCompiler(HydrologyPlannerSettings.defaults().surface(), terrain, SEA_LEVEL) ErosionField field = new ErosionFieldCompiler(rasterSettings(HydrologyPlannerSettings.defaults().surface()), terrain)
.compile(77L, centerline, channel, valley, SurfaceTerminal.COASTAL_GROTTO, 0, .compile(77L, centerline, channel, valley, SurfaceTerminal.COASTAL_GROTTO, 0,
HydrologyPlannerSettings.Ponds.none()); HydrologyPlannerSettings.Ponds.none());
assertEquals(0, field.uncontainedWetCells()); assertEquals(0, field.uncontainedWetCells());
@@ -492,4 +492,11 @@ public class ValleyProfileSolverTest {
private static List<HydrologyPoint> path(int stations) { private static List<HydrologyPoint> path(int stations) {
return List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(stations - 1, 0, 0)); return List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(stations - 1, 0, 0));
} }
private static HydrologyPlannerSettings rasterSettings(HydrologyPlannerSettings.Surface surface) {
HydrologyPlannerSettings defaults = HydrologyPlannerSettings.defaults();
return new HydrologyPlannerSettings(60, defaults.routing(), surface, defaults.hydraulics(),
defaults.underground(), defaults.outlets(), defaults.geometry(), defaults.deepFluids(),
defaults.surfacePools(), surface.shoreWidth(), defaults.seaCaves(), defaults.surfacePolicyBounds());
}
} }
@@ -261,7 +261,7 @@ public class MantleObjectComponentBoundaryRadiusTest {
@Test @Test
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public void translatedTreeSchedulesDistantOwnerChunks() throws Exception { public void translatedTreeCachesDistantSourcesAndSchedulesOnlyItsDestination() throws Exception {
File objectFile = temporaryFolder.newFile("tree.iob"); File objectFile = temporaryFolder.newFile("tree.iob");
try (DataOutputStream output = new DataOutputStream(new FileOutputStream(objectFile))) { try (DataOutputStream output = new DataOutputStream(new FileOutputStream(objectFile))) {
output.writeInt(1); output.writeInt(1);
@@ -293,8 +293,8 @@ public class MantleObjectComponentBoundaryRadiusTest {
MantleObjectComponent component = spy(new MantleObjectComponent(engineMantle)); MantleObjectComponent component = spy(new MantleObjectComponent(engineMantle));
assertEquals(33, component.getRadius()); assertEquals(33, component.getRadius());
assertEquals(33, component.getOutputRadius()); assertEquals(0, component.getOutputRadius());
assertEquals(0, component.getInputRadius()); assertEquals(82, component.getInputRadius());
Mantle<Matter> mantle = mock(Mantle.class); Mantle<Matter> mantle = mock(Mantle.class);
MantleChunk<Matter> chunk = mock(MantleChunk.class); MantleChunk<Matter> chunk = mock(MantleChunk.class);
@@ -322,9 +322,9 @@ public class MantleObjectComponentBoundaryRadiusTest {
component.generateLayer(directWriter, 0, 0, mock(ChunkContext.class)); component.generateLayer(directWriter, 0, 0, mock(ChunkContext.class));
assertEquals(1, directOrigins.get()); assertEquals(49, directOrigins.get());
component.generateLayer(directWriter, 0, 0, mock(ChunkContext.class)); component.generateLayer(directWriter, 0, 0, mock(ChunkContext.class));
assertEquals(2, directOrigins.get()); assertEquals(49, directOrigins.get());
List<String> generatedChunks = new ArrayList<>(); List<String> generatedChunks = new ArrayList<>();
doAnswer(invocation -> { doAnswer(invocation -> {
@@ -342,7 +342,7 @@ public class MantleObjectComponentBoundaryRadiusTest {
)); ));
generator.generateMatter(0, 0, false, mock(ChunkContext.class)); generator.generateMatter(0, 0, false, mock(ChunkContext.class));
assertEquals(49, generatedChunks.size()); assertEquals(List.of("0,0"), generatedChunks);
List<String> replayedSources = new ArrayList<>(); List<String> replayedSources = new ArrayList<>();
MantleObjectComponent.replaySourceChunks( MantleObjectComponent.replaySourceChunks(
@@ -0,0 +1,207 @@
package art.arcane.iris.generation.mantle;
import art.arcane.iris.generation.context.ChunkContext;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.matter.Matter;
import org.junit.Test;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class MantleObjectComponentPlacementDeterminismTest {
private static final Position LEFT = new Position(15, 10, 0);
private static final Position RIGHT = new Position(16, 10, 0);
private static final Position SUPPORT = new Position(15, 11, 0);
@Test
public void ordinaryObjectsKeepPrerequisitesAndSameSourceReadsAcrossDestinationOrders() throws Exception {
Fixture forward = new Fixture();
forward.generate(List.of(0, 1));
Fixture reverse = new Fixture();
reverse.generate(List.of(1, 0));
Fixture parallel = new Fixture();
parallel.generateParallel();
Map<Position, String> expected = Map.of(
LEFT, "minecraft:mangrove_wood",
RIGHT, "minecraft:mangrove_wood",
SUPPORT, "minecraft:mangrove_wood"
);
assertEquals(expected, forward.snapshot());
assertEquals(expected, reverse.snapshot());
assertEquals(expected, parallel.snapshot());
assertEquals(12, parallel.sourceCalls.size());
for (Integer calls : parallel.sourceCalls.values()) {
assertEquals(1, calls.intValue());
}
}
@Test
public void ordinaryObjectSourcePlansRemainReusableAfterNeighborPublicationAndHotload() {
Fixture fixture = new Fixture();
fixture.generate(List.of(1, 0));
Map<Position, String> expected = fixture.snapshot();
Map<Chunk, Integer> calls = Map.copyOf(fixture.sourceCalls);
fixture.generate(List.of(0, 1));
assertEquals(expected, fixture.snapshot());
assertEquals(calls, fixture.sourceCalls);
fixture.component.hotload();
fixture.generate(List.of(0, 1));
assertEquals(expected, fixture.snapshot());
for (Map.Entry<Chunk, Integer> entry : fixture.sourceCalls.entrySet()) {
assertEquals(2, entry.getValue().intValue());
}
}
private static final class Fixture {
private final NativeBlockState stone = state("minecraft:stone");
private final NativeBlockState oak = state("minecraft:oak_wood");
private final NativeBlockState mangrove = state("minecraft:mangrove_wood");
private final NativeBlockState wrongRead = state("minecraft:dirt");
private final Map<Position, NativeBlockState> published = new ConcurrentHashMap<>();
private final Map<Chunk, Integer> sourceCalls = new ConcurrentHashMap<>();
private final ThreadLocal<Chunk> destination = new ThreadLocal<>();
private final MantleWriter writer;
private final MantleObjectComponent component;
private final ChunkContext context = mock(ChunkContext.class);
@SuppressWarnings("unchecked")
private Fixture() {
IrisDimension dimension = mock(IrisDimension.class);
when(dimension.getAllRegions(any())).thenReturn(new KList<>());
when(dimension.getReachableBiomes(any())).thenReturn(new KList<>());
Engine engine = mock(Engine.class);
when(engine.getDimension()).thenReturn(dimension);
EngineMantle engineMantle = mock(EngineMantle.class);
when(engineMantle.getEngine()).thenReturn(engine);
Mantle<Matter> mantle = mock(Mantle.class);
when(mantle.getWorldHeight()).thenReturn(64);
writer = mock(MantleWriter.class);
when(writer.getMantle()).thenReturn(mantle);
when(writer.getEngine()).thenReturn(engine);
when(writer.getPrerequisiteBlock(anyInt(), anyInt(), anyInt())).thenReturn(stone);
when(writer.get(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> published.getOrDefault(
new Position(invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)),
stone));
doAnswer(invocation -> {
destination.set(new Chunk(invocation.getArgument(0), invocation.getArgument(1)));
try {
Runnable task = invocation.getArgument(2);
task.run();
} finally {
destination.remove();
}
return null;
}).when(writer).withChunkFence(anyInt(), anyInt(), any(Runnable.class));
doAnswer(invocation -> {
Position position = new Position(
invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2));
NativeBlockState value = invocation.getArgument(3);
assertEquals(new Chunk(position.x() >> 4, position.z() >> 4), destination.get());
published.put(position, value);
return null;
}).when(writer).setData(anyInt(), anyInt(), anyInt(), any(NativeBlockState.class));
component = new OrdinaryObjects(engineMantle, this);
}
private void generate(List<Integer> destinations) {
for (Integer chunkX : destinations) {
component.generateLayer(writer, chunkX, 0, context);
}
}
private void generateParallel() throws Exception {
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
Future<?> left = executor.submit(() -> generateAfterBarrier(0, ready, start));
Future<?> right = executor.submit(() -> generateAfterBarrier(1, ready, start));
assertTrue(ready.await(5L, TimeUnit.SECONDS));
start.countDown();
left.get(5L, TimeUnit.SECONDS);
right.get(5L, TimeUnit.SECONDS);
}
}
private void generateAfterBarrier(int chunkX, CountDownLatch ready, CountDownLatch start) {
ready.countDown();
try {
assertTrue(start.await(5L, TimeUnit.SECONDS));
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw new IllegalStateException(failure);
}
component.generateLayer(writer, chunkX, 0, context);
}
private Map<Position, String> snapshot() {
Map<Position, String> result = new HashMap<>();
for (Map.Entry<Position, NativeBlockState> entry : published.entrySet()) {
result.put(entry.getKey(), entry.getValue().key());
}
return result;
}
private static NativeBlockState state(String key) {
NativeBlockState state = mock(NativeBlockState.class);
when(state.key()).thenReturn(key);
return state;
}
}
private static final class OrdinaryObjects extends MantleObjectComponent {
private final Fixture fixture;
private OrdinaryObjects(EngineMantle engineMantle, Fixture fixture) {
super(engineMantle);
this.fixture = fixture;
}
@Override
protected int computeRadius() {
return 16;
}
@Override
void generateOrigin(ObjectPassPlacer placer, int chunkX, int chunkZ, ChunkContext context) {
fixture.sourceCalls.merge(new Chunk(chunkX, chunkZ), 1, Integer::sum);
if (chunkZ != 0 || chunkX < 0 || chunkX > 1) {
return;
}
NativeBlockState prerequisite = placer.get(LEFT.x(), LEFT.y(), LEFT.z());
NativeBlockState wood = chunkX == 0 ? fixture.oak : fixture.mangrove;
placer.set(SUPPORT.x(), SUPPORT.y(), SUPPORT.z(), wood);
boolean sameSourceSupport = placer.get(SUPPORT.x(), SUPPORT.y(), SUPPORT.z()) == wood;
NativeBlockState placed = prerequisite == fixture.stone && sameSourceSupport ? wood : fixture.wrongRead;
placer.set(LEFT.x(), LEFT.y(), LEFT.z(), placed);
placer.set(RIGHT.x(), RIGHT.y(), RIGHT.z(), placed);
}
}
private record Position(int x, int y, int z) {
}
private record Chunk(int x, int z) {
}
}
@@ -93,6 +93,35 @@ public class ObjectDestinationTransactionTest {
verify(writer).setData(1, 4, 0, source); verify(writer).setData(1, 4, 0, source);
} }
@Test
public void applyingSourcePlanSkipsForeignChunksBeforeOverlayingAndPreservesLocalOrder() {
MantleWriter writer = writer();
Marker foreign = new Marker("foreign");
Marker prerequisite = new Marker("prerequisite");
Marker first = new Marker("first");
Marker second = new Marker("second");
when(writer.getPrerequisiteDataIfPresent(0, 4, 0, Marker.class)).thenReturn(prerequisite);
ObjectDestinationTransaction source = new ObjectDestinationTransaction(writer, 0, 0);
source.setData(-1, 4, 0, first);
source.setData(0, 4, 0, foreign);
source.setData(-1, 4, 0, second);
source.setData(-1, 4, 16, foreign);
ObjectDestinationTransaction destination = new ObjectDestinationTransaction(writer, -1, 0);
destination.apply(source.sourcePlanSince(0));
assertEquals(2, destination.mutationCheckpoint());
assertSame(second, destination.getDataIfPresent(-1, 4, 0, Marker.class));
assertSame(prerequisite, destination.getDataIfPresent(0, 4, 0, Marker.class));
assertNull(destination.getDataIfPresent(-1, 4, 16, Marker.class));
destination.commit();
InOrder order = inOrder(writer);
order.verify(writer).setData(-1, 4, 0, first);
order.verify(writer).setData(-1, 4, 0, second);
verify(writer, never()).setData(0, 4, 0, foreign);
verify(writer, never()).setData(-1, 4, 16, foreign);
}
@Test @Test
public void sourcePlanRejectsAnInvalidCheckpoint() { public void sourcePlanRejectsAnInvalidCheckpoint() {
ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer(), 0, 0); ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer(), 0, 0);
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more