From d23d6d5c9b301494663e647e0d306af47212c434 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Wed, 2 Sep 2026 00:54:22 -0400 Subject: [PATCH] Tributaries join the main stem at a confluence - routing.tributaries lets an outlet keep extra surface courses; each is cut where its centerline first meets the stem and joined with a short connector - A tributary must keep half the minimum course length and arrive at or above the stem's water - Surface shape rejections carry the failing rule as their detail --- .../core/pack/PackHydrologyValidator.java | 1 + .../engine/hydrology/HydrologyPlanner.java | 202 ++++++++++++++++-- .../hydrology/HydrologyPlannerSettings.java | 8 +- .../runtime/IrisHydrologyRuntime.java | 3 +- .../engine/object/IrisRiverRoutingConfig.java | 5 + ...CrossTileCaveAdmissionIntegrationTest.java | 2 +- .../HydrologyPlannerGlobalInvariantTest.java | 2 +- .../HydrologyPlannerSettingsTest.java | 2 +- .../HydrologyPlannerSurfacePoolTest.java | 2 +- .../hydrology/HydrologyPlannerTest.java | 6 +- .../HydrologyPlannerTributaryTest.java | 152 +++++++++++++ .../HydrologyStyledGeometryTest.java | 2 +- .../hydrology/HydrologyTileCacheTest.java | 4 +- .../MantleHydrologyComponentTest.java | 2 +- .../iris/probe/HydrologyPlannerProbe.java | 4 +- 15 files changed, 360 insertions(+), 37 deletions(-) create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTributaryTest.java diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackHydrologyValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackHydrologyValidator.java index 3436a7d37..0ae3e4c51 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackHydrologyValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackHydrologyValidator.java @@ -282,6 +282,7 @@ final class PackHydrologyValidator { PackJsonFieldChecks.validateOptionalDoubleRange(path, routing, "slopePenalty", 0D, 16D, errors); PackJsonFieldChecks.validateOptionalDoubleRange(path, routing, "confluenceAttraction", 0D, 1D, errors); PackJsonFieldChecks.validateOptionalDoubleRange(path, routing, "lengthPreference", 0D, 8D, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, routing, "tributaries", 0, 4, errors); int tileSize = integerValue(routing, "tileSize", 2048); int sampleSpacing = integerValue(routing, "sampleSpacing", 64); diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java index 104445833..ea0aa21cd 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java @@ -1099,7 +1099,7 @@ public final class HydrologyPlanner { graph = mergeGraphs(surfaceGraph, undergroundGraph); } } - List normalizedTrunkCourses = normalizeSharedTrunks(courses); + List normalizedTrunkCourses = normalizeSharedTrunks(courses, diagnostics); List normalizedOutletCourses = normalizeOutletContinuations(normalizedTrunkCourses); courses.clear(); courses.addAll(normalizedOutletCourses); @@ -1277,7 +1277,7 @@ public final class HydrologyPlanner { return false; } - private List normalizeSharedTrunks(List courses) { + private List normalizeSharedTrunks(List courses, List diagnostics) { HashMap edgeOwners = new HashMap<>(); for (RiverCourse course : courses) { if (course.sourceNodeId().isEmpty()) { @@ -1317,6 +1317,14 @@ public final class HydrologyPlanner { .get(ownedEdgeCount - 1) .centerline() .getLast(); + if (course.type() == RiverCourseType.SURFACE) { + RiverCourse owner = edgeOwners.get(new CourseEdgeKey(course.type(), course.drainageEdges().get(ownedEdgeCount).id())); + RiverCourse tributary = surfaceTributary(course, owner, boundary, ownedEdgeCount, diagnostics); + if (tributary != null) { + normalized.add(tributary); + } + continue; + } int retainedSegmentCount = retainedSegmentCount(course.segments(), boundary); if (retainedSegmentCount < 1) { normalized.add(course); @@ -1336,6 +1344,137 @@ public final class HydrologyPlanner { return List.copyOf(normalized); } + /** + * Turns a surface course that lost its shared drainage to a longer trunk into a tributary: it is cut + * where its own centerline first comes within a channel width of the stem and joined to the nearest + * stem station with a short connector. The tributary must still be a real reach and must arrive at + * or above the stem's water, so it steps down into the river instead of pooling below it. + */ + private RiverCourse surfaceTributary( + RiverCourse course, + RiverCourse owner, + HydrologyPoint boundary, + int ownedEdgeCount, + List diagnostics + ) { + ArrayList stemStations = new ArrayList<>(); + ArrayList stemWidths = new ArrayList<>(); + for (HydraulicSegment segment : owner.segments()) { + for (HydrologyPoint point : segment.centerline()) { + stemStations.add(point); + stemWidths.add(segment.width()); + } + } + ArrayList retained = new ArrayList<>(); + int stations = 0; + HydrologyPoint joinStation = null; + for (HydraulicSegment segment : course.segments()) { + List centerline = segment.centerline(); + int joinIndex = -1; + for (int index = 0; index < centerline.size() && joinIndex < 0; index++) { + HydrologyPoint point = centerline.get(index); + for (int stemIndex = 0; stemIndex < stemStations.size(); stemIndex++) { + HydrologyPoint station = stemStations.get(stemIndex); + double reach = (segment.width() + stemWidths.get(stemIndex)) / 2D + 1D; + if (point.distanceSquared2D(station) <= reach * reach) { + joinIndex = index; + joinStation = station; + break; + } + } + } + if (joinIndex < 0) { + retained.add(segment); + stations += centerline.size(); + continue; + } + if (joinIndex > 0 || retained.isEmpty()) { + List prefix = centerline.subList(0, joinIndex + 1); + int downstreamHead = prefix.getLast().y(); + int drop = segment.upstreamHeadY() - downstreamHead; + HydrologyFeatureType type = drop == 0 && segment.type().isDrop() + ? HydrologyFeatureType.SURFACE_POOL + : segment.type(); + retained.add(new HydraulicSegment( + segment.id(), + segment.courseId(), + type, + segment.upstreamHeadY(), + downstreamHead, + segment.width(), + segment.depth(), + false, + false, + prefix + )); + stations += prefix.size(); + } + break; + } + if (joinStation == null) { + addTributaryDiagnostic(course, HydrologyCandidateRejection.NO_DRAINAGE_PATH, 0, diagnostics); + return null; + } + if (retained.isEmpty() || stations < settings.routing().minimumSurfaceCourseLength() / 2) { + addTributaryDiagnostic(course, HydrologyCandidateRejection.COURSE_TOO_SHORT, stations, diagnostics); + return null; + } + HydraulicSegment last = retained.getLast(); + HydrologyPoint end = last.end(); + int stemHead = joinStation.y(); + if (last.downstreamHeadY() < stemHead) { + addTributaryDiagnostic(course, HydrologyCandidateRejection.SURFACE_HEAD_RANGE, + stemHead - last.downstreamHeadY(), diagnostics); + return null; + } + if (end.x() != joinStation.x() || end.z() != joinStation.z()) { + int drop = last.downstreamHeadY() - stemHead; + HydrologyFeatureType type = drop == 0 + ? HydrologyFeatureType.SURFACE_POOL + : drop >= settings.hydraulics().waterfallMinimumDrop() + ? HydrologyFeatureType.WATERFALL + : drop == 1 ? HydrologyFeatureType.RIFFLE : HydrologyFeatureType.CASCADE; + retained.add(new HydraulicSegment( + HydrologyHash.mix(course.id(), SEGMENT_SALT, 0x4a4f494eL), + course.id(), + type, + last.downstreamHeadY(), + stemHead, + last.width(), + last.depth(), + false, + false, + List.of(end, new HydrologyPoint(joinStation.x(), stemHead, joinStation.z())) + )); + } + return new RiverCourse( + course.id(), + course.type(), + course.sourceNodeId(), + course.outletId(), + course.profileKey(), + course.discharge(), + course.drainageEdges().subList(0, ownedEdgeCount), + retained + ); + } + + private void addTributaryDiagnostic( + RiverCourse course, + HydrologyCandidateRejection rejection, + int detail, + List diagnostics + ) { + diagnostics.add(new HydrologyDiagnosticCandidate( + HydrologyHash.mix(course.id(), DIAGNOSTIC_SALT, rejection.ordinal(), 0x5452494255544152L), + HydrologyCandidateKind.SOURCE, + HydrologyFeatureType.SURFACE_POOL, + course.segments().getFirst().start(), + rejection, + detail + )); + } + private RiverCourse preferredTrunkCourse(RiverCourse first, RiverCourse second) { double firstLength = edgeLength(first.drainageEdges(), first.drainageEdges().size()); double secondLength = edgeLength(second.drainageEdges(), second.drainageEdges().size()); @@ -2161,7 +2300,8 @@ public final class HydrologyPlanner { ? Math.max(1, sourceSettings.maximumPerTile()) : sourceSettings.maximumPerTile(); target = Math.min(maximum, target); - target = effectiveSourceTarget(surface && !enforceGlobalSpacing, target, routing.outlets().size()); + int coursesPerOutlet = surface ? 1 + settings.routing().tributaries() : Integer.MAX_VALUE; + target = effectiveSourceTarget(surface && !enforceGlobalSpacing, target, routing.outlets().size(), coursesPerOutlet); Comparator candidateOrder = Comparator .comparing(SourceCandidate::required) .reversed(); @@ -2170,7 +2310,7 @@ public final class HydrologyPlanner { .thenComparingLong(SourceCandidate::stableId)); prioritizeInlandSource(candidates, routing); int guaranteed = Math.min(target, requiredMinimum); - int maximumCoursesPerOutlet = surface ? 1 : Integer.MAX_VALUE; + int maximumCoursesPerOutlet = coursesPerOutlet; SourceAdmissionSelection admission = selectSourceAdmissionsByOutlet( candidates, routing, @@ -2209,10 +2349,15 @@ public final class HydrologyPlanner { } static int effectiveSourceTarget(boolean outletBounded, int requestedTarget, int outletCount) { - if (requestedTarget < 0 || outletCount < 0) { + return effectiveSourceTarget(outletBounded, requestedTarget, outletCount, 1); + } + + static int effectiveSourceTarget(boolean outletBounded, int requestedTarget, int outletCount, int coursesPerOutlet) { + if (requestedTarget < 0 || outletCount < 0 || coursesPerOutlet < 1) { throw new IllegalArgumentException("Source target bounds cannot be negative."); } - return outletBounded ? Math.min(requestedTarget, outletCount) : requestedTarget; + long bound = (long) outletCount * coursesPerOutlet; + return outletBounded ? (int) Math.min(requestedTarget, bound) : requestedTarget; } private SourceAdmissionSelection selectSourceAdmissionsByOutlet( @@ -4144,7 +4289,11 @@ public final class HydrologyPlanner { .thenComparingLong(SurfaceCourseDraft::courseId)); RiverCourse mainCourse = null; HashMap mainRejections = new HashMap<>(); + int tributaries = 0; for (SurfaceCourseDraft draft : outletDrafts) { + if (mainCourse != null && tributaries >= settings.routing().tributaries()) { + break; + } SurfaceCourseBuild candidate = buildSurfaceCourse( draft.courseId(), draft.profileKey(), @@ -4152,12 +4301,19 @@ public final class HydrologyPlanner { draft.path() ); if (candidate.course() == null) { - mainRejections.put(draft.courseId(), candidate); + if (mainCourse == null) { + mainRejections.put(draft.courseId(), candidate); + } continue; } - mainCourse = candidate.course(); - courses.add(mainCourse); - break; + if (mainCourse == null) { + mainCourse = candidate.course(); + } else { + // A later draft to the same outlet shares the stem's tail; shared-trunk + // normalisation cuts it at the confluence into a tributary. + tributaries++; + } + courses.add(candidate.course()); } if (mainCourse == null) { for (SurfaceCourseDraft draft : outletDrafts) { @@ -4236,8 +4392,11 @@ public final class HydrologyPlanner { segments ); } - if (path.organicSurfaceRequired() && !surfaceShapeAccepted(path.points())) { - return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.SURFACE_SHAPE_UNSUPPORTED, 0); + if (path.organicSurfaceRequired()) { + int shapeRejection = surfaceShapeRejection(path.points()); + if (shapeRejection != 0) { + return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.SURFACE_SHAPE_UNSUPPORTED, shapeRejection); + } } int discharge = maximumSurfaceDischarge(path.edges()); return SurfaceCourseBuild.accepted(new RiverCourse( @@ -4252,10 +4411,11 @@ public final class HydrologyPlanner { )); } - private boolean surfaceShapeAccepted(List routedCenterline) { + /** 0 when the shape is organic; otherwise 1 too short, 2 too sinuous, 3 grid-locked, 4 sharp turns. */ + private int surfaceShapeRejection(List routedCenterline) { List centerline = withoutDuplicateRoutePoints(routedCenterline); if (centerline.size() < 3) { - return false; + return 1; } double routedLength = centerlineLength(centerline); HydrologyPoint start = centerline.getFirst(); @@ -4263,7 +4423,7 @@ public final class HydrologyPlanner { double directLength = StrictMath.hypot(end.x() - start.x(), end.z() - start.z()); if (directLength <= 0D || routedLength / directLength > SURFACE_MAXIMUM_SINUOSITY) { - return false; + return 2; } List sampled = resampleRouteCenterline( centerline, @@ -4298,7 +4458,7 @@ public final class HydrologyPlanner { if (routedLength >= 96D && (lockedLength / routedLength > SURFACE_MAXIMUM_GRID_LOCKED_FRACTION || longestLockedRun > SURFACE_MAXIMUM_GRID_LOCKED_RUN)) { - return false; + return 3; } ArrayList turnAngles = new ArrayList<>(); for (int pointIndex = 1; pointIndex < sampled.size() - 1; pointIndex++) { @@ -4308,12 +4468,12 @@ public final class HydrologyPlanner { sampled.get(pointIndex + 1) ); if (turn > SURFACE_MAXIMUM_RENDERED_TURN_DEGREES) { - return false; + return 4; } turnAngles.add(turn); } if (turnAngles.isEmpty()) { - return false; + return 1; } for (int turnIndex = 0; turnIndex < turnAngles.size(); turnIndex++) { double turn = turnAngles.get(turnIndex); @@ -4322,7 +4482,7 @@ public final class HydrologyPlanner { if (turn > SURFACE_MAXIMUM_ISOLATED_TURN_DEGREES && previous <= SURFACE_MAXIMUM_ISOLATED_NEIGHBOR_TURN_DEGREES && next <= SURFACE_MAXIMUM_ISOLATED_NEIGHBOR_TURN_DEGREES) { - return false; + return 4; } } turnAngles.sort(Double::compare); @@ -4331,9 +4491,9 @@ public final class HydrologyPlanner { Math.max(0, Math.min(percentileIndex, turnAngles.size() - 1)) ); if (percentile > SURFACE_MAXIMUM_P95_TURN_DEGREES) { - return false; + return 4; } - return true; + return 0; } private double centerlineLength(List points) { diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettings.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettings.java index f35abfabb..3c7612cdc 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettings.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettings.java @@ -37,7 +37,7 @@ public record HydrologyPlannerSettings( Source undergroundSources = new Source(true, 0.25D, Integer.MIN_VALUE, 0, 1, 512); return new HydrologyPlannerSettings( 63, - new Routing(2048, 64, 8192, 8192, 384, 192, 1.5D, 24D, 2D, 0.2D, 1D), + new Routing(2048, 64, 8192, 8192, 384, 192, 1.5D, 24D, 2D, 0.2D, 1D, 0), new Surface(true, surfaceSources, 4, 8, 2, 4, 10, 1.5D, Banks.defaults()), new Hydraulics(8), new Underground(true, undergroundSources, -48, 72, 3, 8, 1, 3, 6, 14, true), @@ -166,7 +166,8 @@ public record HydrologyPlannerSettings( double uphillPenalty, double slopePenalty, double confluenceAttraction, - double lengthPreference + double lengthPreference, + int tributaries ) { public Routing { if (tileSize < 32 || sampleSpacing < 4 || tileSize % sampleSpacing != 0) { @@ -193,6 +194,9 @@ public record HydrologyPlannerSettings( requireFiniteNonNegative(slopePenalty, "slopePenalty"); requireFiniteNonNegative(confluenceAttraction, "confluenceAttraction"); requireFiniteNonNegative(lengthPreference, "lengthPreference"); + if (tributaries < 0) { + throw new IllegalArgumentException("tributaries must not be negative."); + } } // Route refinement is derived from the lattice, never authored. diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java index ea8904301..ca3be7725 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java @@ -677,7 +677,8 @@ public final class IrisHydrologyRuntime implements AutoCloseable { routing.getUphillPenalty(), routing.getSlopePenalty(), routing.getConfluenceAttraction(), - routing.getLengthPreference() + routing.getLengthPreference(), + routing.getTributaries() ); HydrologyPlannerSettings.Source plannerSurfaceSources = new HydrologyPlannerSettings.Source( surfaceEnabled, diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverRoutingConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverRoutingConfig.java index f1456ac47..482a35255 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverRoutingConfig.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverRoutingConfig.java @@ -75,4 +75,9 @@ public class IrisRiverRoutingConfig { @MaxNumber(8) @Desc("How strongly longer source-to-outlet routes are preferred when choosing river sources; 0 ranks by elevation alone.") private double lengthPreference = 1D; + + @MinNumber(0) + @MaxNumber(4) + @Desc("Extra surface courses an outlet may accept as tributaries joining its main river; 0 keeps one river per outlet.") + private int tributaries = 1; } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerCrossTileCaveAdmissionIntegrationTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerCrossTileCaveAdmissionIntegrationTest.java index f31ca3dc1..86fe516f5 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerCrossTileCaveAdmissionIntegrationTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerCrossTileCaveAdmissionIntegrationTest.java @@ -252,7 +252,7 @@ public class HydrologyPlannerCrossTileCaveAdmissionIntegrationTest { ); return new HydrologyPlannerSettings( 63, - new HydrologyPlannerSettings.Routing(256, 16, 1_089, 256, 16, 8, 0.5D, 12D, 0.5D, 0.2D, 1D), + new HydrologyPlannerSettings.Routing(256, 16, 1_089, 256, 16, 8, 0.5D, 12D, 0.5D, 0.2D, 1D, 0), new HydrologyPlannerSettings.Surface( true, surfaceSources, diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerGlobalInvariantTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerGlobalInvariantTest.java index 2889838cd..5e12fe090 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerGlobalInvariantTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerGlobalInvariantTest.java @@ -668,7 +668,7 @@ public class HydrologyPlannerGlobalInvariantTest { 12D, 0.5D, 0.1D - , 1D), + , 1D, 0), new HydrologyPlannerSettings.Surface( surfaceEnabled, surfaceSources, diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettingsTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettingsTest.java index 2d0f8e40c..d4fda0066 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettingsTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettingsTest.java @@ -79,7 +79,7 @@ public class HydrologyPlannerSettingsTest { 0D, 0D, 0D - , 1D); + , 1D, 0); HydrologyPlannerSettings.Source sources = new HydrologyPlannerSettings.Source(true, 1D, 0, 0, 1, 0); HydrologyPlannerSettings.Surface surface = new HydrologyPlannerSettings.Surface( true, diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSurfacePoolTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSurfacePoolTest.java index a952f8346..46f6b0657 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSurfacePoolTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSurfacePoolTest.java @@ -102,7 +102,7 @@ public class HydrologyPlannerSurfacePoolTest { private static HydrologyPlannerSettings settings(double density, int spacing, int minimumRadius, int maximumRadius, int depth) { HydrologyPlannerSettings base = HydrologyPlannerSettings.defaults(); HydrologyPlannerSettings.Routing routing = new HydrologyPlannerSettings.Routing( - 1024, 64, base.routing().maximumRouteNodes(), 4096, 384, 192, 1.5D, 24D, 2D, 0.2D, 1D); + 1024, 64, base.routing().maximumRouteNodes(), 4096, 384, 192, 1.5D, 24D, 2D, 0.2D, 1D, 0); return new HydrologyPlannerSettings( base.seaLevel(), routing, diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTest.java index 1f1a344b2..1ac509b8f 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTest.java @@ -581,7 +581,7 @@ public class HydrologyPlannerTest { routing.valleyPreference(), routing.uphillPenalty(), routing.slopePenalty(), - routing.confluenceAttraction(), 1D + routing.confluenceAttraction(), 1D, 0 ), base.surface(), base.hydraulics(), @@ -1733,7 +1733,7 @@ public class HydrologyPlannerTest { ); return new HydrologyPlannerSettings( 63, - new HydrologyPlannerSettings.Routing(128, 16, 512, 256, 0, 0, 0.5D, 12D, 0.5D, 0.1D, 1D), + new HydrologyPlannerSettings.Routing(128, 16, 512, 256, 0, 0, 0.5D, 12D, 0.5D, 0.1D, 1D, 0), new HydrologyPlannerSettings.Surface( surfaceDensity > 0D || surfaceSources.maximumPerTile() > 0, surfaceSources, @@ -1800,7 +1800,7 @@ public class HydrologyPlannerTest { 24D, 2D, 0.2D - , 1D), + , 1D, 0), new HydrologyPlannerSettings.Surface( true, new HydrologyPlannerSettings.Source(true, 8D, 80, 0, 8, 128), diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTributaryTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTributaryTest.java new file mode 100644 index 000000000..1c3fc554f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTributaryTest.java @@ -0,0 +1,152 @@ +package art.arcane.iris.engine.hydrology; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class HydrologyPlannerTributaryTest { + private static final HydrologyTileKey TILE = new HydrologyTileKey(0, 0); + + @Test + public void aSecondSourceJoinsTheMainStemAsATributaryWhenAllowed() { + HydrologyTerrainSampler terrain = HydrologyPlannerTributaryTest::valley; + + HydrologyTile single = new HydrologyPlanner(19L, settings(0), terrain).plan(TILE); + HydrologyTile split = new HydrologyPlanner(19L, settings(1), terrain).plan(TILE); + + List singleCourses = surface(single); + List splitCourses = surface(split); + assertEquals(single.diagnosticCandidates().toString(), 1, singleCourses.size()); + assertEquals(split.diagnosticCandidates().toString(), 2, splitCourses.size()); + RiverCourse stem = null; + RiverCourse tributary = null; + for (RiverCourse course : splitCourses) { + if (stem == null || stations(course) > stations(stem)) { + tributary = stem; + stem = course; + } else { + tributary = course; + } + } + assertNotNull(stem); + assertNotNull(tributary); + assertEquals(stem.outletId(), tributary.outletId()); + assertTrue(stem.sourceNodeId().getAsLong() != tributary.sourceNodeId().getAsLong()); + + HydrologyPoint confluence = tributary.segments().getLast().end(); + Integer stemHead = null; + for (HydraulicSegment segment : stem.segments()) { + for (HydrologyPoint point : segment.centerline()) { + if (point.x() == confluence.x() && point.z() == confluence.z()) { + stemHead = point.y(); + } + } + } + assertNotNull("tributary ends on the stem", stemHead); + assertTrue(confluence.y() >= stemHead); + assertTrue(stations(tributary) >= 192); + assertTrue(stations(tributary) < stations(stem)); + for (HydraulicSegment segment : tributary.segments()) { + assertTrue(segment.upstreamHeadY() >= segment.downstreamHeadY()); + } + } + + private static int stations(RiverCourse course) { + int stations = 0; + for (HydraulicSegment segment : course.segments()) { + stations += segment.centerline().size(); + } + return stations; + } + + private static List surface(HydrologyTile tile) { + ArrayList courses = new ArrayList<>(); + for (RiverCourse course : tile.courses()) { + if (course.type() == RiverCourseType.SURFACE) { + courses.add(course); + } + } + return courses; + } + + /** A plain sloping east into the sea with a valley along z = 0 that both flanks drain into. */ + private static HydrologyTerrainSample valley(int x, int z) { + if (x >= 900) { + return HydrologyTerrainSample.ocean(50, "sea"); + } + // The valley floor wanders so the stem never runs straight along the lattice, and the eastward + // fall is steeper than the valley flanks so routes drift into the valley at a shallow angle. + int valleyCenter = (int) Math.round(Math.sin(x / 150D) * 40D); + int valley = Math.max(0, 12 - Math.abs(z - valleyCenter) / 20); + int wave = (int) Math.round(Math.sin(x / 29D + z / 43D) * 3D + Math.sin(z / 19D - x / 61D + 1D) * 2D); + int height = 240 - x / 5 - valley + wave; + boolean source = x >= 32 && x <= 96 && (Math.abs(z - 300) <= 40 || Math.abs(z - 150) <= 40); + return new HydrologyTerrainSample( + height, + 0D, + false, + false, + height - 40, + height - 38, + true, + true, + source, + false, + false, + false, + 0D, + source ? 1D : 0D, + 0D, + 1D, + 1D, + 1D, + 1D, + 1D, + "land", + "land", + "land", + "land", + "land", + "land", + List.of("default"), + List.of() + ); + } + + private static HydrologyPlannerSettings settings(int tributaries) { + HydrologyPlannerSettings base = HydrologyPlannerSettings.defaults(); + HydrologyPlannerSettings.Routing routing = new HydrologyPlannerSettings.Routing( + 1024, 64, base.routing().maximumRouteNodes(), 4096, 384, 192, 1.5D, 24D, 2D, 0.2D, 1D, tributaries); + HydrologyPlannerSettings.Source sources = new HydrologyPlannerSettings.Source(true, 4D, 0, 0, 4, 128); + HydrologyPlannerSettings.Surface surface = new HydrologyPlannerSettings.Surface( + true, sources, 4, 8, 2, 4, 40, 1.5D, HydrologyPlannerSettings.Banks.defaults()); + HydrologyPlannerSettings.Underground underground = new HydrologyPlannerSettings.Underground( + false, base.underground().sources(), -48, 72, 3, 8, 1, 3, 6, 14, true); + HydrologyPlannerSettings.Outlets outlets = new HydrologyPlannerSettings.Outlets( + true, + base.outlets().coastalGrotto(), + base.outlets().inlandGrotto(), + false, + base.outlets().coastalCliffMinimumHeight(), + base.outlets().mouthLevelingDistance(), + base.outlets().maximumOceanApron(), + 1 + ); + return new HydrologyPlannerSettings( + base.seaLevel(), + routing, + surface, + base.hydraulics(), + underground, + outlets, + base.geometry(), + List.of(), + List.of() + ); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyStyledGeometryTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyStyledGeometryTest.java index 1e1b52312..51cbdead8 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyStyledGeometryTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyStyledGeometryTest.java @@ -195,7 +195,7 @@ public class HydrologyStyledGeometryTest { ); return new HydrologyPlannerSettings( 63, - new HydrologyPlannerSettings.Routing(128, 16, 512, 256, 16, 8, 0.5D, 12D, 0.5D, 0.1D, 1D), + new HydrologyPlannerSettings.Routing(128, 16, 512, 256, 16, 8, 0.5D, 12D, 0.5D, 0.1D, 1D, 0), new HydrologyPlannerSettings.Surface( surfaceDensity > 0D, surfaceSources, diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java index 9e574a62e..5198814ee 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java @@ -243,7 +243,7 @@ public class HydrologyTileCacheTest { ); return new HydrologyPlannerSettings( 63, - new HydrologyPlannerSettings.Routing(64, 16, 128, 64, 0, 0, 0D, 0D, 0D, 0D, 1D), + new HydrologyPlannerSettings.Routing(64, 16, 128, 64, 0, 0, 0D, 0D, 0D, 0D, 1D, 0), new HydrologyPlannerSettings.Surface(false, disabled, 2, 4, 1, 2, 4, 1D, HydrologyPlannerSettings.Banks.defaults()), new HydrologyPlannerSettings.Hydraulics(3), @@ -282,7 +282,7 @@ public class HydrologyTileCacheTest { ); return new HydrologyPlannerSettings( 63, - new HydrologyPlannerSettings.Routing(64, 16, 128, 96, 16, 8, 0.5D, 12D, 0.5D, 0.1D, 1D), + new HydrologyPlannerSettings.Routing(64, 16, 128, 96, 16, 8, 0.5D, 12D, 0.5D, 0.1D, 1D, 0), new HydrologyPlannerSettings.Surface(true, surfaceSources, 4, 8, 2, 3, 20, 1.5D, tileBoundedBanks()), new HydrologyPlannerSettings.Hydraulics(3), new HydrologyPlannerSettings.Underground(false, disabled, -32, 32, 2, 4, 1, 2, 3, 4, false), diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleHydrologyComponentTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleHydrologyComponentTest.java index 54134bf9e..06aac6374 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleHydrologyComponentTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleHydrologyComponentTest.java @@ -1530,7 +1530,7 @@ public class MantleHydrologyComponentTest { ); return new HydrologyPlannerSettings( 63, - new HydrologyPlannerSettings.Routing(128, 16, 512, 256, 16, 8, 0.5D, 12D, 0.5D, 0.1D, 1D), + new HydrologyPlannerSettings.Routing(128, 16, 512, 256, 16, 8, 0.5D, 12D, 0.5D, 0.1D, 1D, 0), new HydrologyPlannerSettings.Surface( true, surfaceSources, diff --git a/probe/src/main/java/art/arcane/iris/probe/HydrologyPlannerProbe.java b/probe/src/main/java/art/arcane/iris/probe/HydrologyPlannerProbe.java index 2efb12f17..4ba6b8810 100644 --- a/probe/src/main/java/art/arcane/iris/probe/HydrologyPlannerProbe.java +++ b/probe/src/main/java/art/arcane/iris/probe/HydrologyPlannerProbe.java @@ -229,7 +229,7 @@ public final class HydrologyPlannerProbe { 12D, 0.5D, 0.1D - , 1D), + , 1D, 0), new HydrologyPlannerSettings.Surface( true, surfaceSources, @@ -440,7 +440,7 @@ public final class HydrologyPlannerProbe { 12D, 0.5D, 0.2D - , 1D), + , 1D, 0), new HydrologyPlannerSettings.Surface( true, surfaceSources,