Surface course integration

- Build surface courses through the valley solver and segment labeler
- Compile surface footprints through the erosion field and index them for cave containment
- Drop the contained-surface fallback and the post-hoc surface rejection passes
- Choose the river profile at the source
This commit is contained in:
Brian Neumann-Fopiano committed 2026-09-01 19:05:43 -04:00
1 parent 2c6792977f
commit 8a256e3226
17 files changed
+1002 -507

No files matched your search

@@ -22,7 +22,7 @@ public class ChannelProfileBuilderTest {
@Test
public void cruiseWidthIsConstantAndTapersFromTheHeadwater() {
SurfaceCenterline centerline = straight(400);
ChannelProfile profile = builder(1D, 1D).build(centerline, path(400), "water", false);
ChannelProfile profile = builder(1D, 1D).build(centerline, "water", false);
assertEquals(400, profile.size());
assertEquals(3D, profile.halfWidth()[200], 1.0E-9D);
@@ -38,7 +38,7 @@ public class ChannelProfileBuilderTest {
@Test
public void directOceanCoursesFlareAtTheMouth() {
SurfaceCenterline centerline = straight(400);
ChannelProfile profile = builder(1D, 1D).build(centerline, path(400), "water", true);
ChannelProfile profile = builder(1D, 1D).build(centerline, "water", true);
assertEquals(3D * 1.6D, profile.halfWidth()[399], 0.05D);
assertEquals(3D, profile.halfWidth()[300], 1.0E-9D);
@@ -47,7 +47,7 @@ public class ChannelProfileBuilderTest {
@Test
public void policyMultipliersScaleWidthDepthAndBank() {
SurfaceCenterline centerline = straight(400);
ChannelProfile profile = builder(2D, 0.5D).build(centerline, path(400), "water", false);
ChannelProfile profile = builder(2D, 0.5D).build(centerline, "water", false);
assertEquals(6D, profile.halfWidth()[200], 1.0E-9D);
assertEquals(1.5D, profile.depth()[200], 1.0E-9D);
@@ -158,7 +158,7 @@ public class ErosionFieldCompilerTest {
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);
.build(centerline, "water", terminal == SurfaceTerminal.OCEAN_MOUTH);
ValleyProfile valley = new ValleyProfileSolver(surface, sampler, SEA_LEVEL, 64)
.solve(centerline, channel, terminal, terminalHead);
assertNull(valley.rejection());
@@ -0,0 +1,141 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydraulicSegment;
import art.arcane.iris.engine.hydrology.HydrologyCandidateRejection;
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
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.assertNull;
import static org.junit.Assert.assertTrue;
public class SurfaceCourseBuilderTest {
private static final int SEA_LEVEL = 60;
private static final long COURSE_ID = 4242L;
private static final HydrologyGeometrySampler CONSTANT_GEOMETRY = request -> switch (request.field()) {
case SURFACE_WIDTH -> 6;
case SURFACE_DEPTH -> 3;
default -> request.minimum();
};
@Test
public void slopedCourseToTheSeaProducesNonRisingLabelledSegmentsEndingAtSeaLevel() {
HydrologyTerrainSampler sampler = (int x, int z) -> x >= 260
? HydrologyTerrainSample.ocean(50, "ocean")
: HydrologyTerrainSample.openLand(110 - x / 6, 0D, "land");
SurfaceCourseResult result = builder(sampler).build(
7L,
COURSE_ID,
"water",
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(140, 0, 0), new HydrologyPoint(280, 0, 0)),
SurfaceTerminal.OCEAN_MOUTH,
SEA_LEVEL
);
assertNull(result.rejection());
List<HydraulicSegment> segments = result.segments();
assertTrue(segments.size() > 3);
assertEquals(0, segments.getFirst().start().x());
assertEquals(SEA_LEVEL, result.lastHead());
assertEquals(SEA_LEVEL, segments.getLast().downstreamHeadY());
int previous = Integer.MAX_VALUE;
for (HydraulicSegment segment : segments) {
assertEquals(COURSE_ID, segment.courseId());
assertTrue(segment.upstreamHeadY() <= previous);
assertTrue(segment.downstreamHeadY() <= segment.upstreamHeadY());
assertTrue(segment.type().isSurface());
previous = segment.downstreamHeadY();
}
for (int index = 1; index < segments.size(); index++) {
HydraulicSegment before = segments.get(index - 1);
HydraulicSegment after = segments.get(index);
assertEquals(before.end().x(), after.start().x());
assertEquals(before.end().z(), after.start().z());
assertEquals(before.downstreamHeadY(), after.upstreamHeadY());
}
assertTrue(result.pathEnd().x() >= 250);
assertTrue(result.lastWidth() >= 6);
}
@Test
public void flatCourseIsOneSurfacePool() {
HydrologyTerrainSampler sampler = (int x, int z) -> HydrologyTerrainSample.openLand(80, 0D, "land");
SurfaceCourseResult result = builder(sampler).build(
7L,
COURSE_ID,
"water",
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)),
SurfaceTerminal.SINKHOLE,
40
);
assertNull(result.rejection());
assertEquals(1, result.segments().size());
assertEquals(HydrologyFeatureType.SURFACE_POOL, result.segments().getFirst().type());
assertEquals(79, result.lastHead());
assertEquals(201, result.segments().getFirst().centerline().size());
}
@Test
public void cliffProducesAWaterfallSegment() {
HydrologyTerrainSampler sampler = (int x, int z) -> HydrologyTerrainSample.openLand(x < 100 ? 100 : 90, 0D, "land");
SurfaceCourseResult result = builder(sampler).build(
7L,
COURSE_ID,
"water",
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)),
SurfaceTerminal.SINKHOLE,
40
);
assertNull(result.rejection());
assertEquals(3, result.segments().size());
assertEquals(HydrologyFeatureType.SURFACE_POOL, result.segments().get(0).type());
assertEquals(HydrologyFeatureType.WATERFALL, result.segments().get(1).type());
assertEquals(10, result.segments().get(1).drop());
assertEquals(HydrologyFeatureType.SURFACE_POOL, result.segments().get(2).type());
}
@Test
public void gentleSlopeProducesRifflesAndSteepSlopeProducesCascades() {
HydrologyTerrainSampler gentle = (int x, int z) -> HydrologyTerrainSample.openLand(120 - x / 10, 0D, "land");
SurfaceCourseResult gentleResult = builder(gentle).build(7L, COURSE_ID, "water",
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), SurfaceTerminal.SINKHOLE, 40);
HydrologyTerrainSampler steep = (int x, int z) -> HydrologyTerrainSample.openLand(300 - x, 0D, "land");
SurfaceCourseResult steepResult = builder(steep).build(7L, COURSE_ID, "water",
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), SurfaceTerminal.SINKHOLE, 40);
assertNull(gentleResult.rejection());
assertNull(steepResult.rejection());
assertTrue(gentleResult.segments().stream().anyMatch(segment -> segment.type() == HydrologyFeatureType.RIFFLE));
assertTrue(gentleResult.segments().stream().noneMatch(segment -> segment.type() == HydrologyFeatureType.CASCADE));
assertTrue(steepResult.segments().stream().anyMatch(segment -> segment.type() == HydrologyFeatureType.CASCADE));
}
@Test
public void submergedCourseIsRejected() {
HydrologyTerrainSampler sampler = (int x, int z) -> HydrologyTerrainSample.openLand(50, 0D, "land");
SurfaceCourseResult result = builder(sampler).build(
7L,
COURSE_ID,
"water",
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)),
SurfaceTerminal.OCEAN_MOUTH,
SEA_LEVEL
);
assertEquals(HydrologyCandidateRejection.COURSE_TOO_SHORT, result.rejection());
assertTrue(result.segments().isEmpty());
}
private static SurfaceCourseBuilder builder(HydrologyTerrainSampler sampler) {
return new SurfaceCourseBuilder(HydrologyPlannerSettings.defaults().surface(), sampler, CONSTANT_GEOMETRY, SEA_LEVEL, 64);
}
}
@@ -0,0 +1,145 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydraulicSegment;
import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
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.RiverCourse;
import art.arcane.iris.engine.hydrology.RiverCourseType;
import art.arcane.iris.engine.hydrology.RiverFootprint;
import org.junit.Test;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.OptionalLong;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class SurfaceFootprintCompilerTest {
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 compiledLayersDescribeAContainedCarveOnlyChannelWithShoreAndBank() {
HydrologyTerrainSampler sampler = (int x, int z) -> x >= 150 && z > 8
? HydrologyTerrainSample.ocean(50, "ocean")
: HydrologyTerrainSample.openLand(80 + Math.max(0, -z) / 2, 0D, "land");
RiverCourse course = course(List.of(
segment(1L, HydrologyFeatureType.SURFACE_POOL, 79, 79, points(0, 60, 79)),
segment(2L, HydrologyFeatureType.RIFFLE, 79, 78, List.of(new HydrologyPoint(60, 79, 0), new HydrologyPoint(61, 78, 0))),
segment(3L, HydrologyFeatureType.SURFACE_POOL, 78, 78, points(61, 140, 78))
));
SurfaceFootprint footprint = compiler(sampler).compile(course);
assertEquals(0, footprint.uncontainedWetCells());
assertFalse(footprint.columns().isEmpty());
HashSet<Long> keys = new HashSet<>();
for (SurfaceLayerColumn column : footprint.columns()) {
assertTrue(keys.add(RiverFootprint.pack(column.x(), column.z())));
HydrologyColumnLayer layer = column.layer();
assertTrue(layer.bedY() <= column.terrain().naturalHeight());
assertEquals(layer.fluidHeadY(), layer.ceilingY());
assertEquals(1L, layer.feature().courseId());
assertTrue(layer.feature().type().isSurface());
if (layer.channel()) {
assertTrue(layer.fluidOwned());
assertTrue(layer.connectedFluid());
assertTrue(layer.terrainOwned());
assertTrue(layer.fluidHeadY() < column.terrain().naturalHeight());
assertTrue(layer.bedY() < layer.fluidHeadY());
} else {
assertTrue(layer.grading());
assertTrue(layer.terrainOwned());
assertFalse(layer.fluidOwned());
assertEquals(layer.bedY(), layer.fluidHeadY());
}
}
SurfaceLayerColumn center = column(footprint, 30, 0);
assertNotNull(center);
assertTrue(center.layer().channel());
assertEquals(79, center.layer().fluidHeadY());
assertTrue(center.layer().feature().source() == false);
SurfaceLayerColumn source = column(footprint, 0, 0);
assertNotNull(source);
assertTrue(source.layer().feature().source());
SurfaceLayerColumn shore = column(footprint, 30, 4);
assertNotNull(shore);
assertTrue(shore.layer().shore());
assertEquals("land", shore.layer().shoreBiomeKey());
SurfaceLayerColumn bank = column(footprint, 30, -8);
assertNotNull(bank);
assertTrue(bank.layer().grading());
assertFalse(bank.layer().shore());
assertNull(column(footprint, 160, 12));
}
@Test
public void mouthOverTheOceanPublishesOnlyANonOwningApron() {
HydrologyTerrainSampler sampler = (int x, int z) -> x >= 100
? HydrologyTerrainSample.ocean(50, "ocean")
: HydrologyTerrainSample.openLand(70, 0D, "land");
RiverCourse course = course(List.of(
segment(1L, HydrologyFeatureType.SURFACE_POOL, 69, 69, points(0, 96, 69)),
segment(2L, HydrologyFeatureType.WATERFALL, 69, SEA_LEVEL, List.of(new HydrologyPoint(96, 69, 0), new HydrologyPoint(97, SEA_LEVEL, 0))),
segment(3L, HydrologyFeatureType.MOUTH, SEA_LEVEL, SEA_LEVEL, points(97, 112, SEA_LEVEL))
));
SurfaceFootprint footprint = compiler(sampler).compile(course);
int aprons = 0;
for (SurfaceLayerColumn column : footprint.columns()) {
if (column.terrain().ocean()) {
assertTrue(column.layer().oceanApron());
assertFalse(column.layer().terrainOwned());
assertFalse(column.layer().fluidOwned());
assertEquals(HydrologyFeatureType.MOUTH, column.layer().feature().type());
aprons++;
} else {
assertFalse(column.layer().oceanApron());
}
}
assertTrue(aprons > 0);
assertNull(column(footprint, 111, 0));
}
private static SurfaceLayerColumn column(SurfaceFootprint footprint, int x, int z) {
for (SurfaceLayerColumn column : footprint.columns()) {
if (column.x() == x && column.z() == z) {
return column;
}
}
return null;
}
private static SurfaceFootprintCompiler compiler(HydrologyTerrainSampler sampler) {
return new SurfaceFootprintCompiler(HydrologyPlannerSettings.defaults(), sampler, CONSTANT_GEOMETRY);
}
private static RiverCourse course(List<HydraulicSegment> segments) {
return new RiverCourse(1L, RiverCourseType.SURFACE, OptionalLong.of(9L), OptionalLong.of(8L), "water", 1, List.of(), segments);
}
private static HydraulicSegment segment(long id, HydrologyFeatureType type, int upstream, int downstream, List<HydrologyPoint> centerline) {
return new HydraulicSegment(id, 1L, type, upstream, downstream, 6, 3, false, false, centerline);
}
private static List<HydrologyPoint> points(int fromX, int toX, int y) {
ArrayList<HydrologyPoint> points = new ArrayList<>();
for (int x = fromX; x <= toX; x++) {
points.add(new HydrologyPoint(x, y, 0));
}
return points;
}
}
@@ -162,7 +162,7 @@ public class ValleyProfileSolverTest {
private static ChannelProfile channel(int stations, HydrologyTerrainSampler sampler) {
return new ChannelProfileBuilder(HydrologyPlannerSettings.defaults().surface(), sampler, CONSTANT_GEOMETRY)
.build(straight(stations), path(stations), "water", false);
.build(straight(stations), "water", false);
}
private static SurfaceCenterline straight(int stations) {