mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-12 03:24:14 +00:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
+115
@@ -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();
|
||||
}
|
||||
}
|
||||
+204
@@ -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);
|
||||
}
|
||||
}
|
||||
+98
@@ -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));
|
||||
}
|
||||
}
|
||||
+186
@@ -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);
|
||||
}
|
||||
}
|
||||
+22
@@ -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));
|
||||
}
|
||||
}
|
||||
+76
@@ -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);
|
||||
}
|
||||
}
|
||||
+175
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user