From 2c6792977f09a20f9a71d77cbf3458ad31ab0939 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 1 Sep 2026 18:56:14 -0400 Subject: [PATCH] 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 --- .../iris/engine/hydrology/HydrologyHash.java | 10 +- .../hydrology/surface/ChannelProfile.java | 11 + .../surface/ChannelProfileBuilder.java | 115 ++++++++++ .../hydrology/surface/ErosionField.java | 32 +++ .../surface/ErosionFieldCompiler.java | 204 ++++++++++++++++++ .../surface/SurfaceCellAdmission.java | 12 ++ .../hydrology/surface/SurfaceCenterline.java | 109 ++++++++++ .../hydrology/surface/SurfaceColumn.java | 25 +++ .../hydrology/surface/SurfaceNoise.java | 36 ++++ .../engine/hydrology/surface/SurfaceRole.java | 7 + .../hydrology/surface/SurfaceTerminal.java | 7 + .../hydrology/surface/ValleyProfile.java | 20 ++ .../surface/ValleyProfileSolver.java | 117 ++++++++++ .../surface/ChannelProfileBuilderTest.java | 98 +++++++++ .../surface/ErosionFieldCompilerTest.java | 186 ++++++++++++++++ .../surface/SurfaceCellAdmissionTest.java | 22 ++ .../surface/SurfaceCenterlineTest.java | 76 +++++++ .../hydrology/surface/SurfaceNoiseTest.java | 39 ++++ .../surface/ValleyProfileSolverTest.java | 175 +++++++++++++++ 19 files changed, 1296 insertions(+), 5 deletions(-) create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/surface/ChannelProfile.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilder.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/surface/ErosionField.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompiler.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCellAdmission.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCenterline.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceColumn.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceNoise.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceRole.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceTerminal.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/surface/ValleyProfile.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/surface/ValleyProfileSolver.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilderTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompilerTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceCellAdmissionTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceCenterlineTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceNoiseTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/surface/ValleyProfileSolverTest.java diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyHash.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyHash.java index e86111658..8000a676f 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyHash.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyHash.java @@ -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."); } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ChannelProfile.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ChannelProfile.java new file mode 100644 index 000000000..2f03c1e44 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ChannelProfile.java @@ -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; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilder.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilder.java new file mode 100644 index 000000000..ae0c2cbcf --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilder.java @@ -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 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)); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ErosionField.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ErosionField.java new file mode 100644 index 000000000..ffc450caf --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ErosionField.java @@ -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 columns; + private final int uncontainedWetCells; + + ErosionField(Long2ObjectOpenHashMap columns, int uncontainedWetCells) { + this.columns = Objects.requireNonNull(columns, "columns"); + this.uncontainedWetCells = uncontainedWetCells; + } + + public Long2ObjectOpenHashMap 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(); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompiler.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompiler.java new file mode 100644 index 000000000..6374825ed --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompiler.java @@ -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 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 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); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCellAdmission.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCellAdmission.java new file mode 100644 index 000000000..245863dba --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCellAdmission.java @@ -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; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCenterline.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCenterline.java new file mode 100644 index 000000000..1f1db8b4e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCenterline.java @@ -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 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); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceColumn.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceColumn.java new file mode 100644 index 000000000..fe0f51c1d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceColumn.java @@ -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); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceNoise.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceNoise.java new file mode 100644 index 000000000..8803cd855 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceNoise.java @@ -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; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceRole.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceRole.java new file mode 100644 index 000000000..78f6c2ec9 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceRole.java @@ -0,0 +1,7 @@ +package art.arcane.iris.engine.hydrology.surface; + +public enum SurfaceRole { + CHANNEL, + SHORE, + BANK +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceTerminal.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceTerminal.java new file mode 100644 index 000000000..9ea6c7ed0 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceTerminal.java @@ -0,0 +1,7 @@ +package art.arcane.iris.engine.hydrology.surface; + +public enum SurfaceTerminal { + OCEAN_MOUTH, + SINKHOLE, + COASTAL_GROTTO +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ValleyProfile.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ValleyProfile.java new file mode 100644 index 000000000..b0fd320e0 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ValleyProfile.java @@ -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); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ValleyProfileSolver.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ValleyProfileSolver.java new file mode 100644 index 000000000..64e6b6e9f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ValleyProfileSolver.java @@ -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); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilderTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilderTest.java new file mode 100644 index 000000000..0c461f687 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilderTest.java @@ -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 path(int stations) { + return List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(stations - 1, 0, 0)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompilerTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompilerTest.java new file mode 100644 index 000000000..12a360f0f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompilerTest.java @@ -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 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); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceCellAdmissionTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceCellAdmissionTest.java new file mode 100644 index 000000000..3bfee702c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceCellAdmissionTest.java @@ -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)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceCenterlineTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceCenterlineTest.java new file mode 100644 index 000000000..4e04cdb72 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceCenterlineTest.java @@ -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 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); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceNoiseTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceNoiseTest.java new file mode 100644 index 000000000..d759214a8 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceNoiseTest.java @@ -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); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ValleyProfileSolverTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ValleyProfileSolverTest.java new file mode 100644 index 000000000..b1ff3b144 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ValleyProfileSolverTest.java @@ -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 path(int stations) { + return List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(stations - 1, 0, 0)); + } +}