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
This commit is contained in:
Brian Neumann-Fopiano committed 2026-09-02 00:54:22 -04:00
1 parent 185e2fa4ae
commit d23d6d5c9b
15 files changed
+360 -37

No files matched your search

@@ -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);
@@ -1099,7 +1099,7 @@ public final class HydrologyPlanner {
graph = mergeGraphs(surfaceGraph, undergroundGraph);
}
}
List<RiverCourse> normalizedTrunkCourses = normalizeSharedTrunks(courses);
List<RiverCourse> normalizedTrunkCourses = normalizeSharedTrunks(courses, diagnostics);
List<RiverCourse> normalizedOutletCourses = normalizeOutletContinuations(normalizedTrunkCourses);
courses.clear();
courses.addAll(normalizedOutletCourses);
@@ -1277,7 +1277,7 @@ public final class HydrologyPlanner {
return false;
}
private List<RiverCourse> normalizeSharedTrunks(List<RiverCourse> courses) {
private List<RiverCourse> normalizeSharedTrunks(List<RiverCourse> courses, List<HydrologyDiagnosticCandidate> diagnostics) {
HashMap<CourseEdgeKey, RiverCourse> 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<HydrologyDiagnosticCandidate> diagnostics
) {
ArrayList<HydrologyPoint> stemStations = new ArrayList<>();
ArrayList<Integer> stemWidths = new ArrayList<>();
for (HydraulicSegment segment : owner.segments()) {
for (HydrologyPoint point : segment.centerline()) {
stemStations.add(point);
stemWidths.add(segment.width());
}
}
ArrayList<HydraulicSegment> retained = new ArrayList<>();
int stations = 0;
HydrologyPoint joinStation = null;
for (HydraulicSegment segment : course.segments()) {
List<HydrologyPoint> 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<HydrologyPoint> 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<HydrologyDiagnosticCandidate> 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<SourceCandidate> 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<Long, SurfaceCourseBuild> 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<HydrologyPoint> routedCenterline) {
/** 0 when the shape is organic; otherwise 1 too short, 2 too sinuous, 3 grid-locked, 4 sharp turns. */
private int surfaceShapeRejection(List<HydrologyPoint> routedCenterline) {
List<HydrologyPoint> 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<HydrologyPoint> 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<Double> 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<HydrologyPoint> points) {
@@ -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.
@@ -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,
@@ -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;
}