From 13bce7aa42655910984e0e3d62196f2470f6edfb Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Wed, 2 Sep 2026 00:05:10 -0400 Subject: [PATCH] Rejection details on surface course diagnostics Valley solver rejections carry the failing measure (required cut, exposed stations, missing head) through the planner diagnostics; the transect probe prints them. --- .../hydrology/HydrologyCaveCourseFilter.java | 4 +- .../HydrologyDiagnosticCandidate.java | 3 +- .../engine/hydrology/HydrologyPlanner.java | 50 +++++++++++++------ .../surface/SurfaceCourseBuilder.java | 7 +-- .../surface/SurfaceCourseResult.java | 7 +-- .../hydrology/surface/ValleyProfile.java | 10 ++-- .../surface/ValleyProfileSolver.java | 21 +++++--- .../render/IrisRendererRiverTest.java | 2 +- .../arcane/iris/probe/RiverTransectProbe.java | 12 ++++- 9 files changed, 77 insertions(+), 39 deletions(-) diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCaveCourseFilter.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCaveCourseFilter.java index 132b447c7..0e2802747 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCaveCourseFilter.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCaveCourseFilter.java @@ -926,7 +926,7 @@ final class HydrologyCaveCourseFilter { kind, type, representative.start(), - rejection + rejection, 0 )); } @@ -1035,7 +1035,7 @@ final class HydrologyCaveCourseFilter { kind, type, representative.start(), - HydrologyCandidateRejection.CAVE_CONTAINMENT + HydrologyCandidateRejection.CAVE_CONTAINMENT, 0 ); } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyDiagnosticCandidate.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyDiagnosticCandidate.java index 67681d976..e6ff475e8 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyDiagnosticCandidate.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyDiagnosticCandidate.java @@ -7,7 +7,8 @@ public record HydrologyDiagnosticCandidate( HydrologyCandidateKind kind, HydrologyFeatureType projectedType, HydrologyPoint point, - HydrologyCandidateRejection rejection + HydrologyCandidateRejection rejection, + int detail ) { public HydrologyDiagnosticCandidate { Objects.requireNonNull(kind, "kind"); 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 5e4196be3..0475d7c48 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 @@ -1925,7 +1925,7 @@ public final class HydrologyPlanner { HydrologyCandidateKind.OUTLET, candidate.outlet().type(), candidate.outlet().landwardPoint(), - HydrologyCandidateRejection.OUTLET_LIMIT + HydrologyCandidateRejection.OUTLET_LIMIT, 0 )); } } @@ -2007,7 +2007,10 @@ public final class HydrologyPlanner { ); } - /** Largest rise a surface drainage edge may take: the cut the valley solver will still accept. */ + /** + * Largest rise a surface drainage edge may take. Heads only fall, so a rise along a route becomes a cut + * of at least that rise; the lattice may climb no more than the cut the valley solver still accepts. + */ private int maximumSurfaceEdgeRise() { int permitted = settings.surface().maximumIncision() - settings.surface().banks().inset() - settings.surface().minimumDepth(); return Math.max(1, permitted); @@ -2522,7 +2525,7 @@ public final class HydrologyPlanner { HydrologyCandidateKind.SOURCE, surface ? HydrologyFeatureType.SURFACE_POOL : HydrologyFeatureType.UNDERGROUND_POOL, new HydrologyPoint(node.x(), y, node.z()), - rejection + rejection, 0 )); } @@ -4134,7 +4137,7 @@ public final class HydrologyPlanner { .reversed() .thenComparingLong(SurfaceCourseDraft::courseId)); RiverCourse mainCourse = null; - HashMap mainRejections = new HashMap<>(); + HashMap mainRejections = new HashMap<>(); for (SurfaceCourseDraft draft : outletDrafts) { SurfaceCourseBuild candidate = buildSurfaceCourse( draft.courseId(), @@ -4143,7 +4146,7 @@ public final class HydrologyPlanner { draft.path() ); if (candidate.course() == null) { - mainRejections.put(draft.courseId(), candidate.rejection()); + mainRejections.put(draft.courseId(), candidate); continue; } mainCourse = candidate.course(); @@ -4152,11 +4155,13 @@ public final class HydrologyPlanner { } if (mainCourse == null) { for (SurfaceCourseDraft draft : outletDrafts) { + SurfaceCourseBuild rejected = mainRejections.get(draft.courseId()); addCompiledSourceDiagnostic( draft.source(), true, draft.courseId(), - mainRejections.get(draft.courseId()), + rejected.rejection(), + rejected.rejectionDetail(), diagnostics ); } @@ -4180,7 +4185,7 @@ public final class HydrologyPlanner { CoursePath path ) { if (path.points().size() < 2) { - return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.COURSE_TOO_SHORT); + return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.COURSE_TOO_SHORT, 0); } RiverOutlet outlet = path.outlet(); boolean surfaceSinkhole = path.reachesOutlet() && surfaceSinkhole(outlet); @@ -4203,7 +4208,7 @@ public final class HydrologyPlanner { terminalHead ); if (!result.accepted()) { - return SurfaceCourseBuild.rejected(result.rejection()); + return SurfaceCourseBuild.rejected(result.rejection(), result.rejectionDetail()); } ArrayList segments = new ArrayList<>(result.segments()); if (path.reachesOutlet()) { @@ -4226,7 +4231,7 @@ public final class HydrologyPlanner { ); } if (path.organicSurfaceRequired() && !surfaceShapeAccepted(path.points())) { - return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.SURFACE_SHAPE_UNSUPPORTED); + return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.SURFACE_SHAPE_UNSUPPORTED, 0); } int discharge = maximumSurfaceDischarge(path.edges()); return SurfaceCourseBuild.accepted(new RiverCourse( @@ -4458,6 +4463,17 @@ public final class HydrologyPlanner { long courseId, HydrologyCandidateRejection rejection, List diagnostics + ) { + addCompiledSourceDiagnostic(source, surface, courseId, rejection, 0, diagnostics); + } + + private void addCompiledSourceDiagnostic( + GridNode source, + boolean surface, + long courseId, + HydrologyCandidateRejection rejection, + int detail, + List diagnostics ) { int y = surface ? source.terrain().naturalHeight() : source.terrain().caveFluidY(); diagnostics.add(new HydrologyDiagnosticCandidate( @@ -4465,7 +4481,8 @@ public final class HydrologyPlanner { HydrologyCandidateKind.SOURCE, surface ? HydrologyFeatureType.SURFACE_POOL : HydrologyFeatureType.UNDERGROUND_POOL, new HydrologyPoint(source.x(), y, source.z()), - rejection + rejection, + detail )); } @@ -6244,7 +6261,7 @@ public final class HydrologyPlanner { HydrologyCandidateKind.DEEP_FLUID, deepFluid.containedPools() ? HydrologyFeatureType.DEEP_POOL : HydrologyFeatureType.DEEP_CHANNEL, new HydrologyPoint(site.x(), site.head(), site.z()), - rejection + rejection, 0 )); } @@ -7259,7 +7276,7 @@ public final class HydrologyPlanner { HydrologyCandidateKind.SOURCE, HydrologyFeatureType.SURFACE_POOL, representative.start(), - HydrologyCandidateRejection.SOURCE_SPACING + HydrologyCandidateRejection.SOURCE_SPACING, 0 ); } @@ -7385,7 +7402,8 @@ public final class HydrologyPlanner { private record SurfaceCourseBuild( RiverCourse course, - HydrologyCandidateRejection rejection + HydrologyCandidateRejection rejection, + int rejectionDetail ) { private SurfaceCourseBuild { if ((course == null) == (rejection == null)) { @@ -7394,11 +7412,11 @@ public final class HydrologyPlanner { } private static SurfaceCourseBuild accepted(RiverCourse course) { - return new SurfaceCourseBuild(Objects.requireNonNull(course, "course"), null); + return new SurfaceCourseBuild(Objects.requireNonNull(course, "course"), null, 0); } - private static SurfaceCourseBuild rejected(HydrologyCandidateRejection rejection) { - return new SurfaceCourseBuild(null, Objects.requireNonNull(rejection, "rejection")); + private static SurfaceCourseBuild rejected(HydrologyCandidateRejection rejection, int detail) { + return new SurfaceCourseBuild(null, Objects.requireNonNull(rejection, "rejection"), detail); } } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseBuilder.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseBuilder.java index e8c334a30..0df14d9af 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseBuilder.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseBuilder.java @@ -46,7 +46,7 @@ public final class SurfaceCourseBuilder { ValleyProfile valley = new ValleyProfileSolver(surface, sampler, seaLevel, minimumCourseLength) .solve(centerline, channel, terminal, terminalHead); if (!valley.accepted()) { - return SurfaceCourseResult.rejected(valley.rejection()); + return SurfaceCourseResult.rejected(valley.rejection(), valley.rejectionDetail()); } int exposed = valley.exposedStations(); boolean coastalDrop = directOcean && valley.head()[exposed - 1] > seaLevel; @@ -82,7 +82,7 @@ public final class SurfaceCourseBuilder { List segments = SurfaceSegmentLabeler.label( worldSeed, courseId, x, z, head, width, depth, surface.banks()); if (segments.isEmpty()) { - return SurfaceCourseResult.rejected(HydrologyCandidateRejection.COURSE_TOO_SHORT); + return SurfaceCourseResult.rejected(HydrologyCandidateRejection.COURSE_TOO_SHORT, stations); } HydraulicSegment last = segments.getLast(); int lastStation = stations - 1; @@ -92,7 +92,8 @@ public final class SurfaceCourseBuilder { last.width(), last.depth(), new HydrologyPoint(x[lastStation], head[lastStation], z[lastStation]), - null + null, + 0 ); } } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseResult.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseResult.java index 2d92b9803..e7b08acf4 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseResult.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseResult.java @@ -13,7 +13,8 @@ public record SurfaceCourseResult( int lastWidth, int lastDepth, HydrologyPoint pathEnd, - HydrologyCandidateRejection rejection + HydrologyCandidateRejection rejection, + int rejectionDetail ) { public SurfaceCourseResult { segments = List.copyOf(Objects.requireNonNull(segments, "segments")); @@ -23,7 +24,7 @@ public record SurfaceCourseResult( return rejection == null; } - public static SurfaceCourseResult rejected(HydrologyCandidateRejection rejection) { - return new SurfaceCourseResult(List.of(), 0, 0, 0, null, Objects.requireNonNull(rejection, "rejection")); + public static SurfaceCourseResult rejected(HydrologyCandidateRejection rejection, int detail) { + return new SurfaceCourseResult(List.of(), 0, 0, 0, null, Objects.requireNonNull(rejection, "rejection"), detail); } } 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 index f8b9a7403..d2a54eae7 100644 --- 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 @@ -8,17 +8,19 @@ public record ValleyProfile( int[] crossMax, int[] centerNatural, int exposedStations, - HydrologyCandidateRejection rejection + HydrologyCandidateRejection rejection, + int rejectionDetail ) { public boolean accepted() { return rejection == null; } public static ValleyProfile fromHeads(int[] head, int exposedStations) { - return new ValleyProfile(head, new int[0], new int[0], new int[0], exposedStations, null); + return new ValleyProfile(head, new int[0], new int[0], new int[0], exposedStations, null, 0); } - public static ValleyProfile rejected(HydrologyCandidateRejection rejection) { - return new ValleyProfile(new int[0], new int[0], new int[0], new int[0], 0, rejection); + /** {@code detail} names the measure that failed: exposed stations, required cut, or missing head. */ + public static ValleyProfile rejected(HydrologyCandidateRejection rejection, int detail) { + return new ValleyProfile(new int[0], new int[0], new int[0], new int[0], 0, rejection, detail); } } 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 index bde493a22..c74939e6b 100644 --- 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 @@ -79,10 +79,10 @@ public final class ValleyProfileSolver { crossMax[station] = maximum; } if (exposed < 2 || exposed < minimumCourseLength) { - return ValleyProfile.rejected(HydrologyCandidateRejection.COURSE_TOO_SHORT); + return ValleyProfile.rejected(HydrologyCandidateRejection.COURSE_TOO_SHORT, exposed); } if (exposed < count && terminal != SurfaceTerminal.OCEAN_MOUTH) { - return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_EXPOSURE); + return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_EXPOSURE, exposed); } int inset = surface.banks().inset(); int[] head = new int[count]; @@ -101,19 +101,24 @@ public final class ValleyProfileSolver { } } if (terminal == SurfaceTerminal.SINKHOLE && head[exposed - 1] < terminalHead) { - return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_SINKHOLE_CLEARANCE); + return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_SINKHOLE_CLEARANCE, terminalHead - head[exposed - 1]); } if (terminal == SurfaceTerminal.COASTAL_GROTTO && head[exposed - 1] < terminalHead) { - return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_HEAD_RANGE); + return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_HEAD_RANGE, terminalHead - head[exposed - 1]); } int maximumIncision = surface.maximumIncision(); + int deepestCut = 0; + boolean rejected = false; 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); - } + int cut = centerNatural[station] - bed; + deepestCut = Math.max(deepestCut, cut); + rejected |= cut > permitted; } - return new ValleyProfile(head, crossMin, crossMax, centerNatural, exposed, null); + if (rejected) { + return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, deepestCut); + } + return new ValleyProfile(head, crossMin, crossMax, centerNatural, exposed, null, 0); } } diff --git a/core/src/test/java/art/arcane/iris/engine/framework/render/IrisRendererRiverTest.java b/core/src/test/java/art/arcane/iris/engine/framework/render/IrisRendererRiverTest.java index 15a5d7e54..2b11e4366 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/render/IrisRendererRiverTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/render/IrisRendererRiverTest.java @@ -143,7 +143,7 @@ public class IrisRendererRiverTest { HydrologyCandidateKind.SOURCE, HydrologyFeatureType.SURFACE_POOL, new HydrologyPoint(0, 80, 0), - HydrologyCandidateRejection.NO_LEGAL_OUTLET + HydrologyCandidateRejection.NO_LEGAL_OUTLET, 0 ); when(engine.getComplex()).thenReturn(complex); when(complex.getHydrologyRuntime()).thenReturn(runtime); diff --git a/probe/src/main/java/art/arcane/iris/probe/RiverTransectProbe.java b/probe/src/main/java/art/arcane/iris/probe/RiverTransectProbe.java index a2acfc8a7..d1d44d931 100644 --- a/probe/src/main/java/art/arcane/iris/probe/RiverTransectProbe.java +++ b/probe/src/main/java/art/arcane/iris/probe/RiverTransectProbe.java @@ -219,13 +219,23 @@ public final class RiverTransectProbe { static List rejectionLines(HydrologyTile tile) { TreeMap counts = new TreeMap<>(); + TreeMap> details = new TreeMap<>(); for (HydrologyDiagnosticCandidate candidate : tile.diagnosticCandidates()) { String key = "rejected " + candidate.kind() + " " + candidate.projectedType() + " " + candidate.rejection(); counts.merge(key, 1, Integer::sum); + if (candidate.detail() != 0) { + details.computeIfAbsent(key, (String ignored) -> new ArrayList<>()).add(candidate.detail()); + } } ArrayList lines = new ArrayList<>(counts.size()); for (Map.Entry entry : counts.entrySet()) { - lines.add(entry.getKey() + " x" + entry.getValue()); + ArrayList values = details.get(entry.getKey()); + String suffix = ""; + if (values != null) { + values.sort(null); + suffix = " detail=" + values.subList(0, Math.min(values.size(), 24)); + } + lines.add(entry.getKey() + " x" + entry.getValue() + suffix); } return lines; }