Surface valley model

- Add the surface package with centerline densification, channel profile, valley head solver and erosion field
- Derive the head from the bank ring outside the channel so every wet cell is contained by construction
- Stamp one continuous cross-section with a cut-scaled blend and a freeboard lip, filtered at the ocean
This commit is contained in:
Brian Neumann-Fopiano
2026-09-01 18:56:14 -04:00
parent 915cc1a2f0
commit 2c6792977f
19 changed files with 1296 additions and 5 deletions
@@ -1,10 +1,10 @@
package art.arcane.iris.engine.hydrology;
final class HydrologyHash {
public final class HydrologyHash {
private HydrologyHash() {
}
static long mix(long seed, long... values) {
public static long mix(long seed, long... values) {
long mixed = avalanche(seed ^ 0x9e3779b97f4a7c15L);
for (long value : values) {
mixed = avalanche(mixed ^ avalanche(value + 0x9e3779b97f4a7c15L));
@@ -12,7 +12,7 @@ final class HydrologyHash {
return mixed;
}
static long text(String value) {
public static long text(String value) {
long hash = 0xcbf29ce484222325L;
for (int index = 0; index < value.length(); index++) {
hash ^= value.charAt(index);
@@ -21,11 +21,11 @@ final class HydrologyHash {
return avalanche(hash);
}
static double unit(long value) {
public static double unit(long value) {
return (avalanche(value) >>> 11) * 0x1.0p-53;
}
static int between(long value, int minimum, int maximum) {
public static int between(long value, int minimum, int maximum) {
if (minimum > maximum) {
throw new IllegalArgumentException("minimum cannot exceed maximum.");
}
@@ -0,0 +1,11 @@
package art.arcane.iris.engine.hydrology.surface;
public record ChannelProfile(double[] halfWidth, double[] depth, double[] bankMultiplier) {
public int size() {
return halfWidth.length;
}
public double collar(int station, double roughness) {
return halfWidth[station] * (1D + roughness) + 2D;
}
}
@@ -0,0 +1,115 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydrologyGeometrySampler;
import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.engine.hydrology.HydrologyPoint;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSample;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler;
import java.util.List;
import java.util.Objects;
public final class ChannelProfileBuilder {
private static final int SMOOTHING_RADIUS = 16;
private static final int HEADWATER_TAPER = 48;
private static final int MOUTH_FLARE = 32;
private final HydrologyPlannerSettings.Surface surface;
private final HydrologyTerrainSampler sampler;
private final HydrologyGeometrySampler geometry;
public ChannelProfileBuilder(
HydrologyPlannerSettings.Surface surface,
HydrologyTerrainSampler sampler,
HydrologyGeometrySampler geometry
) {
this.surface = Objects.requireNonNull(surface, "surface");
this.sampler = Objects.requireNonNull(sampler, "sampler");
this.geometry = Objects.requireNonNull(geometry, "geometry");
}
public ChannelProfile build(
SurfaceCenterline centerline,
List<HydrologyPoint> path,
String profileKey,
boolean directOcean
) {
int count = centerline.size();
double[] width = new double[count];
double[] depth = new double[count];
double[] bank = new double[count];
for (int station = 0; station < count; station++) {
HydrologyPoint point = path.get(Math.min(path.size() - 1, centerline.pathIndex()[station]));
int sampledWidth = geometry.sample(
HydrologyGeometrySampler.Field.SURFACE_WIDTH,
profileKey,
point.x(),
point.z(),
0L,
surface.minimumWidth(),
surface.maximumWidth()
);
int sampledDepth = geometry.sample(
HydrologyGeometrySampler.Field.SURFACE_DEPTH,
profileKey,
point.x(),
point.z(),
0L,
surface.minimumDepth(),
surface.maximumDepth()
);
HydrologyTerrainSample terrain = sampler.sample(centerline.x()[station], centerline.z()[station]);
double widthMultiplier = terrain == null ? 1D : terrain.widthMultiplier();
double depthMultiplier = terrain == null ? 1D : terrain.depthMultiplier();
bank[station] = terrain == null ? 1D : terrain.bankMultiplier();
width[station] = clamp(sampledWidth * widthMultiplier, surface.minimumWidth(), surface.maximumWidth() * 2D);
depth[station] = clamp(sampledDepth * depthMultiplier, 1D, surface.maximumDepth() * 2D);
}
double[] smoothWidth = smooth(width);
double[] smoothDepth = smooth(depth);
int taper = Math.min(HEADWATER_TAPER, count / 2);
for (int station = 0; station < taper; station++) {
double progress = SurfaceNoise.smoothStep(station / (double) taper);
smoothWidth[station] = 1D + (smoothWidth[station] - 1D) * progress;
smoothDepth[station] = 1D + (smoothDepth[station] - 1D) * progress;
}
if (directOcean) {
int flare = Math.min(MOUTH_FLARE, count / 3);
int flareStart = count - flare;
for (int station = Math.max(0, flareStart); station < count; station++) {
double progress = SurfaceNoise.smoothStep((station - flareStart) / (double) Math.max(1, flare - 1));
smoothWidth[station] *= 1D + (surface.banks().mouthFlareRatio() - 1D) * progress;
}
}
double[] halfWidth = new double[count];
for (int station = 0; station < count; station++) {
halfWidth[station] = Math.max(0.5D, smoothWidth[station] / 2D);
smoothDepth[station] = Math.max(1D, smoothDepth[station]);
}
return new ChannelProfile(halfWidth, smoothDepth, bank);
}
static double[] smooth(double[] values) {
int count = values.length;
double[] smoothed = new double[count];
for (int station = 0; station < count; station++) {
double total = 0D;
double weight = 0D;
for (int offset = -SMOOTHING_RADIUS; offset <= SMOOTHING_RADIUS; offset++) {
int index = station + offset;
if (index < 0 || index >= count) {
continue;
}
double factor = SMOOTHING_RADIUS + 1 - Math.abs(offset);
total += values[index] * factor;
weight += factor;
}
smoothed[station] = total / weight;
}
return smoothed;
}
private static double clamp(double value, double minimum, double maximum) {
return Math.max(minimum, Math.min(maximum, value));
}
}
@@ -0,0 +1,32 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.RiverFootprint;
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
import java.util.Objects;
public final class ErosionField {
private final Long2ObjectOpenHashMap<SurfaceColumn> columns;
private final int uncontainedWetCells;
ErosionField(Long2ObjectOpenHashMap<SurfaceColumn> columns, int uncontainedWetCells) {
this.columns = Objects.requireNonNull(columns, "columns");
this.uncontainedWetCells = uncontainedWetCells;
}
public Long2ObjectOpenHashMap<SurfaceColumn> columns() {
return columns;
}
public SurfaceColumn column(int x, int z) {
return columns.get(RiverFootprint.pack(x, z));
}
public int uncontainedWetCells() {
return uncontainedWetCells;
}
public int size() {
return columns.size();
}
}
@@ -0,0 +1,204 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSample;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler;
import art.arcane.iris.engine.hydrology.RiverFootprint;
import it.unimi.dsi.fastutil.longs.Long2DoubleOpenHashMap;
import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap;
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
import it.unimi.dsi.fastutil.longs.LongArrayList;
import java.util.Objects;
public final class ErosionFieldCompiler {
private static final int[][] CARDINALS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
private static final long OUTLINE_SALT = 0x4f55544c494e45L;
private static final long BED_SALT = 0x424544L;
private static final double EPSILON = 1.0E-9D;
private final HydrologyPlannerSettings.Surface surface;
private final HydrologyTerrainSampler sampler;
private final int seaLevel;
public ErosionFieldCompiler(
HydrologyPlannerSettings.Surface surface,
HydrologyTerrainSampler sampler,
int seaLevel
) {
this.surface = Objects.requireNonNull(surface, "surface");
this.sampler = Objects.requireNonNull(sampler, "sampler");
this.seaLevel = seaLevel;
}
public ErosionField compile(
long courseSeed,
SurfaceCenterline centerline,
ChannelProfile channel,
ValleyProfile valley,
SurfaceTerminal terminal,
int apronLimit
) {
HydrologyPlannerSettings.Banks banks = surface.banks();
int count = terminal == SurfaceTerminal.OCEAN_MOUTH ? centerline.size() : valley.exposedStations();
double roughness = banks.roughness();
double shoreWidth = surface.shoreWidth();
int freeboard = banks.freeboard();
boolean[] basin = basins(valley, channel, count);
double[][] blendWidth = blendWidths(centerline, channel, valley, count);
Long2IntOpenHashMap nearest = new Long2IntOpenHashMap();
nearest.defaultReturnValue(-1);
Long2DoubleOpenHashMap distance = new Long2DoubleOpenHashMap();
for (int station = 0; station < count; station++) {
double widest = Math.max(blendWidth[station][0], blendWidth[station][1]);
int radius = (int) StrictMath.ceil(channel.halfWidth()[station] * (1D + roughness) + shoreWidth + widest) + 1;
int stationX = centerline.x()[station];
int stationZ = centerline.z()[station];
for (int deltaZ = -radius; deltaZ <= radius; deltaZ++) {
for (int deltaX = -radius; deltaX <= radius; deltaX++) {
int cellX = stationX + deltaX;
int cellZ = stationZ + deltaZ;
double cellDistance = centerline.distanceToSegment(station, cellX, cellZ);
if (cellDistance > radius) {
continue;
}
long key = RiverFootprint.pack(cellX, cellZ);
int existing = nearest.get(key);
if (existing < 0
|| cellDistance < distance.get(key) - EPSILON
|| Math.abs(cellDistance - distance.get(key)) <= EPSILON && station < existing) {
nearest.put(key, station);
distance.put(key, cellDistance);
}
}
}
}
Long2ObjectOpenHashMap<SurfaceColumn> columns = new Long2ObjectOpenHashMap<>(nearest.size());
LongArrayList wetKeys = new LongArrayList();
for (long key : nearest.keySet()) {
int station = nearest.get(key);
double cellDistance = distance.get(key);
int cellX = RiverFootprint.unpackX(key);
int cellZ = RiverFootprint.unpackZ(key);
HydrologyTerrainSample terrain = sampler.sample(cellX, cellZ);
int head = valley.head()[station];
double halfWidth = channel.halfWidth()[station];
double outline = halfWidth * (1D + roughness * SurfaceNoise.signed(
courseSeed ^ OUTLINE_SALT, cellX, cellZ, banks.roughnessWavelength()));
outline = Math.max(Math.max(0.5D, 0.6D * halfWidth), Math.min(1.4D * halfWidth, outline));
boolean wet = cellDistance <= outline + 0.25D;
if (!SurfaceCellAdmission.writable(terrain, seaLevel)) {
if (terrain != null && wet && terminal == SurfaceTerminal.OCEAN_MOUTH
&& station >= valley.exposedStations() - 1
&& station - valley.exposedStations() < apronLimit) {
columns.put(key, new SurfaceColumn(
cellX, cellZ, terrain, station, SurfaceRole.CHANNEL, terrain.naturalHeight(), seaLevel, true));
}
continue;
}
int natural = terrain.naturalHeight();
if (wet) {
double normalized = Math.min(1D, cellDistance / Math.max(0.5D, outline));
double depth = channel.depth()[station];
double local = 1D + (depth - 1D) * (1D - normalized * normalized)
+ 0.5D * roughness * SurfaceNoise.signed(courseSeed ^ BED_SALT, cellX, cellZ, banks.roughnessWavelength())
+ (basin[station] ? 1D : 0D);
int bed = Math.min(natural, head - Math.max(1, (int) StrictMath.round(local)));
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.CHANNEL, bed, head, false));
wetKeys.add(key);
continue;
}
int bankTop = head + freeboard;
if (cellDistance <= outline + shoreWidth + 0.25D) {
int height = Math.min(natural, bankTop);
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, height, height, false));
continue;
}
int cut = natural - bankTop;
if (cut <= 0) {
continue;
}
double side = side(centerline, station, cellX, cellZ);
double width = blendWidth[station][side < 0D ? 0 : 1];
double progress = (cellDistance - outline - shoreWidth) / width;
if (progress >= 1D) {
continue;
}
int height = Math.min(natural, (int) StrictMath.round(bankTop + cut * SurfaceNoise.smoothStep(progress)));
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.BANK, height, height, false));
}
int uncontained = contain(columns, wetKeys, freeboard);
return new ErosionField(columns, uncontained);
}
private int contain(Long2ObjectOpenHashMap<SurfaceColumn> columns, LongArrayList wetKeys, int freeboard) {
int uncontained = 0;
for (int index = 0; index < wetKeys.size(); index++) {
SurfaceColumn wet = columns.get(wetKeys.getLong(index));
for (int[] offset : CARDINALS) {
long key = RiverFootprint.pack(wet.x() + offset[0], wet.z() + offset[1]);
SurfaceColumn neighbour = columns.get(key);
if (neighbour == null) {
if (wet.headY() != seaLevel) {
uncontained++;
}
continue;
}
if (neighbour.role() == SurfaceRole.CHANNEL) {
continue;
}
int required = wet.headY() + freeboard;
if (neighbour.height() < required) {
int raised = Math.min(neighbour.terrain().naturalHeight(), required);
neighbour = neighbour.withHeight(raised);
columns.put(key, neighbour);
}
if (neighbour.height() < wet.headY()) {
uncontained++;
}
}
}
return uncontained;
}
private boolean[] basins(ValleyProfile valley, ChannelProfile channel, int count) {
boolean[] basin = new boolean[count];
for (int station = 1; station < count; station++) {
int drop = valley.head()[station - 1] - valley.head()[station];
if (drop < 2) {
continue;
}
int length = Math.max(2, (int) StrictMath.round(2D * channel.halfWidth()[station]));
for (int following = station; following < Math.min(count, station + length); following++) {
basin[following] = true;
}
}
return basin;
}
private double[][] blendWidths(SurfaceCenterline centerline, ChannelProfile channel, ValleyProfile valley, int count) {
HydrologyPlannerSettings.Banks banks = surface.banks();
double[][] widths = new double[count][2];
for (int station = 0; station < count; station++) {
double halfWidth = channel.halfWidth()[station];
double probe = halfWidth + surface.shoreWidth() + Math.max(2D, halfWidth);
int bankTop = valley.head()[station] + banks.freeboard();
for (int side = 0; side < 2; side++) {
double direction = side == 0 ? -1D : 1D;
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);
HydrologyTerrainSample terrain = sampler.sample(probeX, probeZ);
double cut = terrain == null ? 0D : Math.max(0D, terrain.naturalHeight() - bankTop);
double width = cut * banks.blendSlope() * channel.bankMultiplier()[station];
widths[station][side] = Math.max(banks.minimumBlendWidth(), Math.min(banks.maximumBlendWidth(), width));
}
}
return widths;
}
private static double side(SurfaceCenterline centerline, int station, int cellX, int cellZ) {
double deltaX = cellX - centerline.x()[station];
double deltaZ = cellZ - centerline.z()[station];
return deltaX * centerline.normalX(station) + deltaZ * centerline.normalZ(station);
}
}
@@ -0,0 +1,12 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSample;
public final class SurfaceCellAdmission {
private SurfaceCellAdmission() {
}
public static boolean writable(HydrologyTerrainSample terrain, int seaLevel) {
return terrain != null && !terrain.ocean() && terrain.naturalHeight() > seaLevel;
}
}
@@ -0,0 +1,109 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydrologyPoint;
import it.unimi.dsi.fastutil.ints.IntArrayList;
import java.util.List;
import java.util.Objects;
public record SurfaceCenterline(int[] x, int[] z, double[] tangentX, double[] tangentZ, int[] pathIndex) {
private static final int TANGENT_WINDOW = 6;
public static SurfaceCenterline densify(List<HydrologyPoint> path) {
Objects.requireNonNull(path, "path");
if (path.isEmpty()) {
throw new IllegalArgumentException("A surface centerline requires at least one path point.");
}
IntArrayList xs = new IntArrayList();
IntArrayList zs = new IntArrayList();
IntArrayList indices = new IntArrayList();
HydrologyPoint first = path.getFirst();
xs.add(first.x());
zs.add(first.z());
indices.add(0);
for (int pair = 0; pair + 1 < path.size(); pair++) {
HydrologyPoint start = path.get(pair);
HydrologyPoint end = path.get(pair + 1);
int steps = Math.max(Math.abs(end.x() - start.x()), Math.abs(end.z() - start.z()));
for (int step = 1; step <= steps; step++) {
double progress = step / (double) steps;
int cellX = (int) StrictMath.round(start.x() + (end.x() - start.x()) * progress);
int cellZ = (int) StrictMath.round(start.z() + (end.z() - start.z()) * progress);
int last = xs.size() - 1;
if (xs.getInt(last) == cellX && zs.getInt(last) == cellZ) {
continue;
}
xs.add(cellX);
zs.add(cellZ);
indices.add(pair);
}
}
return fromStations(xs.toIntArray(), zs.toIntArray(), indices.toIntArray());
}
private static SurfaceCenterline fromStations(int[] xs, int[] zs, int[] indices) {
int count = xs.length;
double[] tangentX = new double[count];
double[] tangentZ = new double[count];
for (int station = 0; station < count; station++) {
int from = Math.max(0, station - TANGENT_WINDOW);
int to = Math.min(count - 1, station + TANGENT_WINDOW);
double vectorX = xs[to] - xs[from];
double vectorZ = zs[to] - zs[from];
double length = StrictMath.hypot(vectorX, vectorZ);
if (length <= 0D) {
tangentX[station] = 1D;
tangentZ[station] = 0D;
} else {
tangentX[station] = vectorX / length;
tangentZ[station] = vectorZ / length;
}
}
return new SurfaceCenterline(xs, zs, tangentX, tangentZ, indices);
}
public int size() {
return x.length;
}
public double normalX(int station) {
return -tangentZ[station];
}
public double normalZ(int station) {
return tangentX[station];
}
public double distanceToSegment(int station, double pointX, double pointZ) {
double startX = x[station];
double startZ = z[station];
if (station + 1 >= x.length) {
return StrictMath.hypot(pointX - startX, pointZ - startZ);
}
double endX = x[station + 1];
double endZ = z[station + 1];
double segmentX = endX - startX;
double segmentZ = endZ - startZ;
double lengthSquared = segmentX * segmentX + segmentZ * segmentZ;
double progress = lengthSquared <= 0D
? 0D
: ((pointX - startX) * segmentX + (pointZ - startZ) * segmentZ) / lengthSquared;
progress = Math.max(0D, Math.min(1D, progress));
double closestX = startX + segmentX * progress;
double closestZ = startZ + segmentZ * progress;
return StrictMath.hypot(pointX - closestX, pointZ - closestZ);
}
public SurfaceCenterline truncate(int stations) {
if (stations < 1 || stations > x.length) {
throw new IllegalArgumentException("Cannot truncate a centerline of " + x.length + " stations to " + stations + ".");
}
int[] xs = new int[stations];
int[] zs = new int[stations];
int[] indices = new int[stations];
System.arraycopy(x, 0, xs, 0, stations);
System.arraycopy(z, 0, zs, 0, stations);
System.arraycopy(pathIndex, 0, indices, 0, stations);
return fromStations(xs, zs, indices);
}
}
@@ -0,0 +1,25 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSample;
import java.util.Objects;
public record SurfaceColumn(
int x,
int z,
HydrologyTerrainSample terrain,
int station,
SurfaceRole role,
int height,
int headY,
boolean apron
) {
public SurfaceColumn {
Objects.requireNonNull(terrain, "terrain");
Objects.requireNonNull(role, "role");
}
public SurfaceColumn withHeight(int replacementHeight) {
return new SurfaceColumn(x, z, terrain, station, role, replacementHeight, replacementHeight, apron);
}
}
@@ -0,0 +1,36 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydrologyHash;
public final class SurfaceNoise {
private SurfaceNoise() {
}
public static double value(long seed, int x, int z, int wavelength) {
int span = Math.max(1, wavelength);
int cellX = Math.floorDiv(x, span);
int cellZ = Math.floorDiv(z, span);
double localX = smoothStep(Math.floorMod(x, span) / (double) span);
double localZ = smoothStep(Math.floorMod(z, span) / (double) span);
double top = lerp(corner(seed, cellX, cellZ), corner(seed, cellX + 1, cellZ), localX);
double bottom = lerp(corner(seed, cellX, cellZ + 1), corner(seed, cellX + 1, cellZ + 1), localX);
return lerp(top, bottom, localZ);
}
public static double signed(long seed, int x, int z, int wavelength) {
return value(seed, x, z, wavelength) * 2D - 1D;
}
public static double smoothStep(double progress) {
double clamped = Math.max(0D, Math.min(1D, progress));
return clamped * clamped * (3D - 2D * clamped);
}
private static double corner(long seed, int cellX, int cellZ) {
return HydrologyHash.unit(HydrologyHash.mix(seed, cellX, cellZ));
}
private static double lerp(double first, double second, double progress) {
return first + (second - first) * progress;
}
}
@@ -0,0 +1,7 @@
package art.arcane.iris.engine.hydrology.surface;
public enum SurfaceRole {
CHANNEL,
SHORE,
BANK
}
@@ -0,0 +1,7 @@
package art.arcane.iris.engine.hydrology.surface;
public enum SurfaceTerminal {
OCEAN_MOUTH,
SINKHOLE,
COASTAL_GROTTO
}
@@ -0,0 +1,20 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydrologyCandidateRejection;
public record ValleyProfile(
int[] head,
int[] crossMin,
int[] crossMax,
int[] centerNatural,
int exposedStations,
HydrologyCandidateRejection rejection
) {
public boolean accepted() {
return rejection == null;
}
public static ValleyProfile rejected(HydrologyCandidateRejection rejection) {
return new ValleyProfile(new int[0], new int[0], new int[0], new int[0], 0, rejection);
}
}
@@ -0,0 +1,117 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydrologyCandidateRejection;
import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSample;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler;
import java.util.Objects;
public final class ValleyProfileSolver {
private final HydrologyPlannerSettings.Surface surface;
private final HydrologyTerrainSampler sampler;
private final int seaLevel;
private final int minimumCourseLength;
public ValleyProfileSolver(
HydrologyPlannerSettings.Surface surface,
HydrologyTerrainSampler sampler,
int seaLevel,
int minimumCourseLength
) {
this.surface = Objects.requireNonNull(surface, "surface");
this.sampler = Objects.requireNonNull(sampler, "sampler");
this.seaLevel = seaLevel;
this.minimumCourseLength = minimumCourseLength;
}
public ValleyProfile solve(
SurfaceCenterline centerline,
ChannelProfile channel,
SurfaceTerminal terminal,
int terminalHead
) {
int count = centerline.size();
int[] crossMin = new int[count];
int[] crossMax = new int[count];
int[] centerNatural = new int[count];
double[] incisionMultiplier = new double[count];
double roughness = surface.banks().roughness();
int exposed = count;
for (int station = 0; station < count; station++) {
double outline = channel.halfWidth()[station] * (1D + roughness);
double reach = outline + 2D;
int stationX = centerline.x()[station];
int stationZ = centerline.z()[station];
HydrologyTerrainSample center = sampler.sample(stationX, stationZ);
if (!SurfaceCellAdmission.writable(center, seaLevel)) {
exposed = station;
break;
}
centerNatural[station] = center.naturalHeight();
incisionMultiplier[station] = center.incisionMultiplier();
double normalX = centerline.normalX(station);
double normalZ = centerline.normalZ(station);
int minimum = Integer.MAX_VALUE;
int maximum = Integer.MIN_VALUE;
boolean blocked = false;
for (double offset = -reach; offset <= reach && !blocked; offset += 0.5D) {
int cellX = (int) StrictMath.round(stationX + normalX * offset);
int cellZ = (int) StrictMath.round(stationZ + normalZ * offset);
HydrologyTerrainSample terrain = sampler.sample(cellX, cellZ);
if (!SurfaceCellAdmission.writable(terrain, seaLevel)) {
blocked = true;
break;
}
if (Math.abs(offset) <= outline + 0.25D) {
continue;
}
minimum = Math.min(minimum, terrain.naturalHeight());
maximum = Math.max(maximum, terrain.naturalHeight());
}
if (blocked) {
exposed = station;
break;
}
crossMin[station] = minimum;
crossMax[station] = maximum;
}
if (exposed < 2 || exposed < minimumCourseLength) {
return ValleyProfile.rejected(HydrologyCandidateRejection.COURSE_TOO_SHORT);
}
if (exposed < count && terminal != SurfaceTerminal.OCEAN_MOUTH) {
return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_EXPOSURE);
}
int inset = surface.banks().inset();
int[] head = new int[count];
for (int station = 0; station < exposed; station++) {
int ceiling = crossMin[station] - inset;
head[station] = station == 0 ? ceiling : Math.min(head[station - 1], ceiling);
}
if (terminal == SurfaceTerminal.OCEAN_MOUTH || terminal == SurfaceTerminal.COASTAL_GROTTO) {
for (int station = 0; station < exposed; station++) {
head[station] = Math.max(head[station], seaLevel);
}
}
if (terminal == SurfaceTerminal.OCEAN_MOUTH) {
for (int station = exposed; station < count; station++) {
head[station] = seaLevel;
}
}
if (terminal == SurfaceTerminal.SINKHOLE && head[exposed - 1] < terminalHead) {
return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_SINKHOLE_CLEARANCE);
}
if (terminal == SurfaceTerminal.COASTAL_GROTTO && head[exposed - 1] < terminalHead) {
return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_HEAD_RANGE);
}
int maximumIncision = surface.maximumIncision();
for (int station = 0; station < exposed; station++) {
int permitted = Math.min(maximumIncision, (int) StrictMath.floor(maximumIncision * incisionMultiplier[station]));
int bed = head[station] - (int) StrictMath.round(channel.depth()[station]);
if (centerNatural[station] - bed > permitted) {
return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED);
}
}
return new ValleyProfile(head, crossMin, crossMax, centerNatural, exposed, null);
}
}
@@ -0,0 +1,98 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydrologyGeometrySampler;
import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.engine.hydrology.HydrologyPoint;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSample;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler;
import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class ChannelProfileBuilderTest {
private static final HydrologyGeometrySampler CONSTANT_GEOMETRY = request -> switch (request.field()) {
case SURFACE_WIDTH -> 6;
case SURFACE_DEPTH -> 3;
default -> request.minimum();
};
@Test
public void cruiseWidthIsConstantAndTapersFromTheHeadwater() {
SurfaceCenterline centerline = straight(400);
ChannelProfile profile = builder(1D, 1D).build(centerline, path(400), "water", false);
assertEquals(400, profile.size());
assertEquals(3D, profile.halfWidth()[200], 1.0E-9D);
assertEquals(3D, profile.halfWidth()[399], 1.0E-9D);
assertEquals(3D, profile.depth()[200], 1.0E-9D);
assertTrue(profile.halfWidth()[0] < 1D);
for (int station = 1; station < 48; station++) {
assertTrue(profile.halfWidth()[station] >= profile.halfWidth()[station - 1]);
}
assertTrue(profile.depth()[0] >= 1D);
}
@Test
public void directOceanCoursesFlareAtTheMouth() {
SurfaceCenterline centerline = straight(400);
ChannelProfile profile = builder(1D, 1D).build(centerline, path(400), "water", true);
assertEquals(3D * 1.6D, profile.halfWidth()[399], 0.05D);
assertEquals(3D, profile.halfWidth()[300], 1.0E-9D);
}
@Test
public void policyMultipliersScaleWidthDepthAndBank() {
SurfaceCenterline centerline = straight(400);
ChannelProfile profile = builder(2D, 0.5D).build(centerline, path(400), "water", false);
assertEquals(6D, profile.halfWidth()[200], 1.0E-9D);
assertEquals(1.5D, profile.depth()[200], 1.0E-9D);
assertEquals(1.25D, profile.bankMultiplier()[200], 1.0E-9D);
assertEquals(6D * 1.25D + 2D, profile.collar(200, 0.25D), 1.0E-9D);
}
private static ChannelProfileBuilder builder(double widthMultiplier, double depthMultiplier) {
HydrologyTerrainSampler sampler = (int x, int z) -> new HydrologyTerrainSample(
80,
0D,
false,
false,
48,
50,
true,
true,
true,
false,
false,
false,
0D,
1D,
1D,
widthMultiplier,
depthMultiplier,
1D,
1D,
1.25D,
"parent",
"parent",
"parent",
"parent",
"parent",
"parent",
List.of("water")
);
return new ChannelProfileBuilder(HydrologyPlannerSettings.defaults().surface(), sampler, CONSTANT_GEOMETRY);
}
private static SurfaceCenterline straight(int stations) {
return SurfaceCenterline.densify(path(stations));
}
private static List<HydrologyPoint> path(int stations) {
return List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(stations - 1, 0, 0));
}
}
@@ -0,0 +1,186 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydrologyGeometrySampler;
import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.engine.hydrology.HydrologyPoint;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSample;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler;
import art.arcane.iris.engine.hydrology.RiverFootprint;
import org.junit.Test;
import java.util.List;
import java.util.function.IntBinaryOperator;
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 ErosionFieldCompilerTest {
private static final int SEA_LEVEL = 60;
private static final HydrologyGeometrySampler CONSTANT_GEOMETRY = request -> switch (request.field()) {
case SURFACE_WIDTH -> 6;
case SURFACE_DEPTH -> 3;
default -> request.minimum();
};
@Test
public void flatTerrainProducesAContainedChannelWithLipAndNoWriteAboveNatural() {
Compiled compiled = compile(300, (x, z) -> 80, SurfaceTerminal.SINKHOLE, 40);
assertEquals(0, compiled.field().uncontainedWetCells());
SurfaceColumn center = compiled.field().column(150, 0);
assertNotNull(center);
assertEquals(SurfaceRole.CHANNEL, center.role());
assertEquals(79, center.headY());
assertTrue(center.height() <= 76);
SurfaceColumn edge = compiled.field().column(150, 3);
assertNotNull(edge);
assertEquals(SurfaceRole.CHANNEL, edge.role());
assertTrue(edge.height() > center.height());
assertTrue(edge.height() <= 78);
SurfaceColumn shore = compiled.field().column(150, 4);
assertNotNull(shore);
assertEquals(SurfaceRole.SHORE, shore.role());
assertEquals(80, shore.height());
assertNoWriteAboveNatural(compiled);
assertChannelContained(compiled);
}
@Test
public void hillsideCutGetsABankWhoseWidthGrowsWithTheCut() {
Compiled compiled = compile(300, (x, z) -> 90 + Math.max(0, z) / 2, SurfaceTerminal.SINKHOLE, 40);
assertEquals(0, compiled.field().uncontainedWetCells());
assertNoWriteAboveNatural(compiled);
assertChannelContained(compiled);
int lastBank = -1;
int previous = Integer.MIN_VALUE;
for (int z = 4; z < 60; z++) {
SurfaceColumn column = compiled.field().column(150, z);
if (column == null) {
break;
}
if (column.role() == SurfaceRole.BANK) {
lastBank = z;
assertTrue(column.height() >= previous);
previous = column.height();
}
}
assertTrue(lastBank > 12);
int stepOutsideChannel = 0;
for (int z = 6; z <= lastBank; z++) {
stepOutsideChannel = Math.max(
stepOutsideChannel,
Math.abs(compiled.field().column(150, z).height() - compiled.field().column(150, z - 1).height())
);
}
assertTrue(stepOutsideChannel <= 1);
}
@Test
public void slopingTerrainKeepsBanksSmoothAndContained() {
Compiled compiled = compile(400, (x, z) -> 120 - x / 8, SurfaceTerminal.SINKHOLE, 40);
assertEquals(0, compiled.field().uncontainedWetCells());
assertNoWriteAboveNatural(compiled);
assertChannelContained(compiled);
for (int x = 20; x < 380; x++) {
for (int z = 5; z < 12; z++) {
SurfaceColumn column = compiled.field().column(x, z);
SurfaceColumn next = compiled.field().column(x + 1, z);
if (column == null || next == null || column.role() == SurfaceRole.CHANNEL || next.role() == SurfaceRole.CHANNEL) {
continue;
}
assertTrue(Math.abs(column.height() - next.height()) <= 1);
}
}
}
@Test
public void oceanAndSubmergedCellsReceiveNoWrites() {
HydrologyTerrainSampler sampler = (int x, int z) -> x >= 240 || z > 20
? HydrologyTerrainSample.ocean(50, "ocean")
: HydrologyTerrainSample.openLand(z <= -20 ? SEA_LEVEL : 100 - x / 6, 0D, "land");
Compiled compiled = compile(sampler, 300, SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL);
assertEquals(0, compiled.field().uncontainedWetCells());
for (SurfaceColumn column : compiled.field().columns().values()) {
HydrologyTerrainSample terrain = sampler.sample(column.x(), column.z());
if (terrain.ocean() || terrain.naturalHeight() <= SEA_LEVEL) {
assertTrue(column.apron());
assertEquals(terrain.naturalHeight(), column.height());
}
}
assertNoWriteAboveNatural(compiled);
}
@Test
public void bankColumnsBeyondTheBlendAreNotWritten() {
Compiled compiled = compile(300, (x, z) -> 80, SurfaceTerminal.SINKHOLE, 40);
assertNull(compiled.field().column(150, 40));
}
private static void assertNoWriteAboveNatural(Compiled compiled) {
for (SurfaceColumn column : compiled.field().columns().values()) {
assertTrue(column.height() <= column.terrain().naturalHeight());
}
}
private static void assertChannelContained(Compiled compiled) {
for (SurfaceColumn column : compiled.field().columns().values()) {
if (column.role() != SurfaceRole.CHANNEL || column.apron()) {
continue;
}
int[][] offsets = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
for (int[] offset : offsets) {
SurfaceColumn neighbour = compiled.field().column(column.x() + offset[0], column.z() + offset[1]);
if (neighbour == null) {
assertEquals(SEA_LEVEL, column.headY());
continue;
}
if (neighbour.role() == SurfaceRole.CHANNEL) {
continue;
}
assertTrue(neighbour.height() >= column.headY());
}
}
}
private static Compiled compile(int stations, IntBinaryOperator height, SurfaceTerminal terminal, int terminalHead) {
HydrologyTerrainSampler sampler = (int x, int z) -> HydrologyTerrainSample.openLand(height.applyAsInt(x, z), 0D, "land");
return compile(sampler, stations, terminal, terminalHead);
}
private static Compiled compile(HydrologyTerrainSampler sampler, int stations, SurfaceTerminal terminal, int terminalHead) {
HydrologyPlannerSettings.Surface surface = zeroRoughnessSurface();
List<HydrologyPoint> path = List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(stations - 1, 0, 0));
SurfaceCenterline centerline = SurfaceCenterline.densify(path);
ChannelProfile channel = new ChannelProfileBuilder(surface, sampler, CONSTANT_GEOMETRY)
.build(centerline, path, "water", terminal == SurfaceTerminal.OCEAN_MOUTH);
ValleyProfile valley = new ValleyProfileSolver(surface, sampler, SEA_LEVEL, 64)
.solve(centerline, channel, terminal, terminalHead);
assertNull(valley.rejection());
ErosionField field = new ErosionFieldCompiler(surface, sampler, SEA_LEVEL)
.compile(1234L, centerline, channel, valley, terminal, 8);
return new Compiled(field, valley);
}
private static HydrologyPlannerSettings.Surface zeroRoughnessSurface() {
HydrologyPlannerSettings.Surface defaults = HydrologyPlannerSettings.defaults().surface();
return new HydrologyPlannerSettings.Surface(
true,
defaults.sources(),
4, 8, 2, 4, 1, 1, 10, 1.5D, 4, 32, false, 1, 1,
new HydrologyPlannerSettings.Banks(1, 1, 3D, 4, 32, 0D, 16, 2, 6, 1.6D, true)
);
}
private record Compiled(ErosionField field, ValleyProfile valley) {
}
static long key(int x, int z) {
return RiverFootprint.pack(x, z);
}
}
@@ -0,0 +1,22 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSample;
import org.junit.Test;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class SurfaceCellAdmissionTest {
@Test
public void landAboveSeaLevelIsWritable() {
assertTrue(SurfaceCellAdmission.writable(HydrologyTerrainSample.openLand(61, 0D, "land"), 60));
}
@Test
public void oceanSubmergedAndMissingSamplesAreNotWritable() {
assertFalse(SurfaceCellAdmission.writable(HydrologyTerrainSample.ocean(90, "ocean"), 60));
assertFalse(SurfaceCellAdmission.writable(HydrologyTerrainSample.openLand(60, 0D, "land"), 60));
assertFalse(SurfaceCellAdmission.writable(HydrologyTerrainSample.openLand(40, 0D, "land"), 60));
assertFalse(SurfaceCellAdmission.writable(null, 60));
}
}
@@ -0,0 +1,76 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydrologyPoint;
import org.junit.Test;
import java.util.HashSet;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class SurfaceCenterlineTest {
@Test
public void straightPathDensifiesToOneBlockStationsWithAxisTangent() {
SurfaceCenterline centerline = SurfaceCenterline.densify(List.of(
new HydrologyPoint(0, 0, 0),
new HydrologyPoint(8, 0, 0)
));
assertEquals(9, centerline.size());
for (int station = 0; station < centerline.size(); station++) {
assertEquals(station, centerline.x()[station]);
assertEquals(0, centerline.z()[station]);
assertEquals(1D, centerline.tangentX()[station], 1.0E-9D);
assertEquals(0D, centerline.tangentZ()[station], 1.0E-9D);
assertEquals(0, centerline.pathIndex()[station]);
}
assertEquals(0D, centerline.normalX(4), 1.0E-9D);
assertEquals(1D, centerline.normalZ(4), 1.0E-9D);
}
@Test
public void diagonalAndRepeatedPointsProduceNoDuplicateStations() {
SurfaceCenterline centerline = SurfaceCenterline.densify(List.of(
new HydrologyPoint(0, 0, 0),
new HydrologyPoint(4, 0, 4),
new HydrologyPoint(4, 0, 4),
new HydrologyPoint(4, 0, 9)
));
HashSet<Long> seen = new HashSet<>();
for (int station = 0; station < centerline.size(); station++) {
long key = ((long) centerline.x()[station] << 32) ^ (centerline.z()[station] & 0xffffffffL);
assertTrue(seen.add(key));
}
assertEquals(10, centerline.size());
assertEquals(0, centerline.pathIndex()[0]);
assertEquals(2, centerline.pathIndex()[9]);
}
@Test
public void distanceToSegmentMeasuresPerpendicularOffsetBesideAStraightRun() {
SurfaceCenterline centerline = SurfaceCenterline.densify(List.of(
new HydrologyPoint(0, 0, 0),
new HydrologyPoint(20, 0, 0)
));
assertEquals(3D, centerline.distanceToSegment(7, 7.5D, 3D), 1.0E-9D);
assertEquals(0D, centerline.distanceToSegment(7, 7.5D, 0D), 1.0E-9D);
assertEquals(2D, centerline.distanceToSegment(20, 22D, 0D), 1.0E-9D);
}
@Test
public void truncateKeepsTheLeadingStations() {
SurfaceCenterline centerline = SurfaceCenterline.densify(List.of(
new HydrologyPoint(0, 0, 0),
new HydrologyPoint(20, 0, 0)
));
SurfaceCenterline truncated = centerline.truncate(5);
assertEquals(5, truncated.size());
assertEquals(4, truncated.x()[4]);
assertEquals(1D, truncated.tangentX()[4], 1.0E-9D);
}
}
@@ -0,0 +1,39 @@
package art.arcane.iris.engine.hydrology.surface;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class SurfaceNoiseTest {
@Test
public void valuesStayInRangeAndAreDeterministic() {
for (int x = -40; x < 40; x++) {
for (int z = -40; z < 40; z++) {
double value = SurfaceNoise.value(77L, x, z, 16);
assertTrue(value >= 0D && value <= 1D);
assertEquals(value, SurfaceNoise.value(77L, x, z, 16), 0D);
double signed = SurfaceNoise.signed(77L, x, z, 16);
assertTrue(signed >= -1D && signed <= 1D);
}
}
}
@Test
public void neighbouringCellsAreCoherent() {
double maximumStep = 0D;
for (int x = 0; x < 200; x++) {
maximumStep = Math.max(maximumStep, Math.abs(SurfaceNoise.value(5L, x, 3, 16) - SurfaceNoise.value(5L, x + 1, 3, 16)));
}
assertTrue(maximumStep < 0.2D);
}
@Test
public void smoothStepEasesBetweenZeroAndOne() {
assertEquals(0D, SurfaceNoise.smoothStep(0D), 0D);
assertEquals(1D, SurfaceNoise.smoothStep(1D), 0D);
assertEquals(0.5D, SurfaceNoise.smoothStep(0.5D), 1.0E-9D);
assertTrue(SurfaceNoise.smoothStep(0.25D) < 0.25D);
assertTrue(SurfaceNoise.smoothStep(0.75D) > 0.75D);
}
}
@@ -0,0 +1,175 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydrologyCandidateRejection;
import art.arcane.iris.engine.hydrology.HydrologyGeometrySampler;
import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.engine.hydrology.HydrologyPoint;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSample;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler;
import org.junit.Test;
import java.util.List;
import java.util.function.IntBinaryOperator;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class ValleyProfileSolverTest {
private static final int SEA_LEVEL = 60;
private static final HydrologyGeometrySampler CONSTANT_GEOMETRY = request -> switch (request.field()) {
case SURFACE_WIDTH -> 6;
case SURFACE_DEPTH -> 2;
default -> request.minimum();
};
@Test
public void flatTerrainHoldsTheHeadOneInsetBelowTheBanks() {
ValleyProfile valley = solve(300, (x, z) -> 80, SurfaceTerminal.SINKHOLE, 40);
assertNull(valley.rejection());
assertEquals(300, valley.exposedStations());
for (int station = 0; station < valley.exposedStations(); station++) {
assertEquals(79, valley.head()[station]);
assertEquals(80, valley.crossMin()[station]);
assertEquals(80, valley.centerNatural()[station]);
}
}
@Test
public void gentleSlopeProducesOneBlockNonRisingSteps() {
ValleyProfile valley = solve(400, (x, z) -> 120 - x / 8, SurfaceTerminal.SINKHOLE, 40);
assertNull(valley.rejection());
int steps = 0;
for (int station = 1; station < valley.exposedStations(); station++) {
int drop = valley.head()[station - 1] - valley.head()[station];
assertTrue(drop >= 0);
assertTrue(drop <= 1);
steps += drop;
}
assertTrue(steps >= 45 && steps <= 51);
assertContained(valley);
}
@Test
public void hillsideTraverseKeepsTheHeadBelowTheLowestBank() {
ValleyProfile valley = solve(300, (x, z) -> 90 + z, SurfaceTerminal.SINKHOLE, 40);
assertNull(valley.rejection());
assertContained(valley);
assertTrue(valley.head()[150] < valley.centerNatural()[150] - 1);
}
@Test
public void aDipBesideTheChannelLowersEveryDownstreamHead() {
ValleyProfile valley = solve(300, (x, z) -> x == 150 && z == 6 ? 74 : 80, SurfaceTerminal.SINKHOLE, 40);
assertNull(valley.rejection());
assertEquals(79, valley.head()[100]);
assertTrue(valley.head()[150] <= 73);
assertTrue(valley.head()[299] <= 73);
assertContained(valley);
}
@Test
public void oceanTruncatesTheCourseAndPinsTheMouthToSeaLevel() {
HydrologyTerrainSampler sampler = (int x, int z) -> x >= 240
? HydrologyTerrainSample.ocean(50, "ocean")
: HydrologyTerrainSample.openLand(100 - x / 6, 0D, "land");
ValleyProfile valley = solver(sampler).solve(
straight(300),
channel(300, sampler),
SurfaceTerminal.OCEAN_MOUTH,
SEA_LEVEL
);
assertNull(valley.rejection());
assertTrue(valley.exposedStations() > 200);
assertTrue(valley.exposedStations() <= 240);
assertEquals(SEA_LEVEL, valley.head()[valley.exposedStations() - 1]);
assertEquals(SEA_LEVEL, valley.head()[valley.exposedStations()]);
assertEquals(SEA_LEVEL, valley.head()[299]);
for (int station = 0; station < valley.exposedStations(); station++) {
assertTrue(valley.head()[station] >= SEA_LEVEL);
}
assertContained(valley);
}
@Test
public void landAtSeaLevelAlsoEndsTheExposedCourse() {
ValleyProfile valley = solve(300, (x, z) -> x >= 200 ? SEA_LEVEL : 90, SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL);
assertNull(valley.rejection());
assertTrue(valley.exposedStations() <= 200);
assertTrue(valley.exposedStations() >= 190);
assertEquals(89, valley.head()[valley.exposedStations() - 1]);
assertEquals(SEA_LEVEL, valley.head()[valley.exposedStations()]);
}
@Test
public void aCliffBecomesASingleLargeDrop() {
ValleyProfile valley = solve(300, (x, z) -> x < 150 ? 100 : 90, SurfaceTerminal.SINKHOLE, 40);
assertNull(valley.rejection());
int largest = 0;
for (int station = 1; station < valley.exposedStations(); station++) {
largest = Math.max(largest, valley.head()[station - 1] - valley.head()[station]);
}
assertEquals(10, largest);
assertEquals(99, valley.head()[100]);
assertEquals(89, valley.head()[200]);
}
@Test
public void submergedCourseIsRejectedAsTooShort() {
ValleyProfile valley = solve(300, (x, z) -> 50, SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL);
assertEquals(HydrologyCandidateRejection.COURSE_TOO_SHORT, valley.rejection());
}
@Test
public void sinkholeClearanceIsRequiredAtTheTerminal() {
ValleyProfile valley = solve(300, (x, z) -> 80, SurfaceTerminal.SINKHOLE, 90);
assertEquals(HydrologyCandidateRejection.SURFACE_SINKHOLE_CLEARANCE, valley.rejection());
}
@Test
public void excessiveIncisionIsRejected() {
ValleyProfile valley = solve(300, (x, z) -> 90 - Math.min(20, Math.abs(z) * 8), SurfaceTerminal.SINKHOLE, 40);
assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, valley.rejection());
}
private static void assertContained(ValleyProfile valley) {
for (int station = 0; station < valley.exposedStations(); station++) {
assertTrue(valley.head()[station] <= valley.crossMin()[station] - 1);
if (station > 0) {
assertTrue(valley.head()[station] <= valley.head()[station - 1]);
}
}
}
private static ValleyProfile solve(int stations, IntBinaryOperator height, SurfaceTerminal terminal, int terminalHead) {
HydrologyTerrainSampler sampler = (int x, int z) -> HydrologyTerrainSample.openLand(height.applyAsInt(x, z), 0D, "land");
return solver(sampler).solve(straight(stations), channel(stations, sampler), terminal, terminalHead);
}
private static ValleyProfileSolver solver(HydrologyTerrainSampler sampler) {
return new ValleyProfileSolver(HydrologyPlannerSettings.defaults().surface(), sampler, SEA_LEVEL, 64);
}
private static ChannelProfile channel(int stations, HydrologyTerrainSampler sampler) {
return new ChannelProfileBuilder(HydrologyPlannerSettings.defaults().surface(), sampler, CONSTANT_GEOMETRY)
.build(straight(stations), path(stations), "water", false);
}
private static SurfaceCenterline straight(int stations) {
return SurfaceCenterline.densify(path(stations));
}
private static List<HydrologyPoint> path(int stations) {
return List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(stations - 1, 0, 0));
}
}