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 01b062809..5e4196be3 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 @@ -428,7 +428,7 @@ public final class HydrologyPlanner { boolean fallbackSelected = false; while (fallbackOutletIndex < fallbackOutlets.size()) { OutletCandidate fallbackOutlet = fallbackOutlets.get(fallbackOutletIndex++); - RoutingPlan fallbackRouting = requireOrganicSurface(buildRouting(grid, List.of(fallbackOutlet))); + RoutingPlan fallbackRouting = requireOrganicSurface(buildRouting(grid, List.of(fallbackOutlet), true)); HashMap fallbackContexts = new HashMap<>(); fallbackContexts.put(key, new SourceRoutingContext( grid, @@ -1471,7 +1471,7 @@ public final class HydrologyPlanner { addRejectedOutlets(oceanCandidates, selected, diagnostics); return selected; } - boolean[] oceanReachable = outletReachable(grid, oceanCandidates); + boolean[] oceanReachable = outletReachable(grid, oceanCandidates, surface); ArrayList inlandCandidates = inlandOutletCandidates(grid, oceanReachable); sortInlandOutletCandidates(grid, inlandCandidates); int inlandMaximum = oceanCandidates.isEmpty() @@ -1645,7 +1645,8 @@ public final class HydrologyPlanner { return true; } - private boolean[] outletReachable(SampledGrid grid, List outlets) { + private boolean[] outletReachable(SampledGrid grid, List outlets, boolean surface) { + int maximumRise = surface ? maximumSurfaceEdgeRise() : Integer.MAX_VALUE; boolean[] reachable = new boolean[grid.nodes().size()]; int[] queue = new int[reachable.length]; int readIndex = 0; @@ -1662,7 +1663,8 @@ public final class HydrologyPlanner { for (GridOffset offset : ROUTING_OFFSETS) { GridNode neighbor = grid.nodeAt(node.gridX() + offset.x(), node.gridZ() + offset.z()); if (neighbor == null || reachable[neighbor.index()] || neighbor.terrain().ocean() - || !neighbor.terrain().transitAllowed()) { + || !neighbor.terrain().transitAllowed() + || node.terrain().naturalHeight() - neighbor.terrain().naturalHeight() > maximumRise) { continue; } reachable[neighbor.index()] = true; @@ -1931,8 +1933,12 @@ public final class HydrologyPlanner { private RoutingPlan buildRouting( SampledGrid grid, - List outlets + List outlets, + boolean surface ) { + // A surface river never climbs: any rise along its route becomes a cut of at least that rise, + // so lattice edges that rise more than a small saddle are not drainage at all. + int maximumRise = surface ? maximumSurfaceEdgeRise() : Integer.MAX_VALUE; int nodeCount = grid.nodes().size(); double[] potential = new double[nodeCount]; int[] parent = new int[nodeCount]; @@ -1963,6 +1969,9 @@ public final class HydrologyPlanner { if (upstream == null || upstream.terrain().ocean() || !upstream.terrain().transitAllowed()) { continue; } + if (downstream.terrain().naturalHeight() - upstream.terrain().naturalHeight() > maximumRise) { + continue; + } double edgeCost = routeCost( upstream, downstream, @@ -1998,6 +2007,12 @@ public final class HydrologyPlanner { ); } + /** Largest rise a surface drainage edge may take: the cut the valley solver will still accept. */ + private int maximumSurfaceEdgeRise() { + int permitted = settings.surface().maximumIncision() - settings.surface().banks().inset() - settings.surface().minimumDepth(); + return Math.max(1, permitted); + } + private RoutingPlan requireOrganicSurface(RoutingPlan routing) { return new RoutingPlan( routing.potential(), @@ -2393,12 +2408,12 @@ public final class HydrologyPlanner { SampledGrid grid = sample(key); ArrayList diagnostics = new ArrayList<>(); List surfaceOutlets = resolveOutlets(key, grid, true, diagnostics); - RoutingPlan surfaceRouting = buildRouting(grid, surfaceOutlets); + RoutingPlan surfaceRouting = buildRouting(grid, surfaceOutlets, true); List undergroundOutlets = settings.underground().enabled() ? resolveOutlets(key, grid, false, diagnostics) : surfaceOutlets; RoutingPlan undergroundRouting = settings.underground().enabled() - ? buildRouting(grid, undergroundOutlets) + ? buildRouting(grid, undergroundOutlets, false) : surfaceRouting; return new SourceRoutingContext( grid, 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 9313c1e0a..a2acfc8a7 100644 --- a/probe/src/main/java/art/arcane/iris/probe/RiverTransectProbe.java +++ b/probe/src/main/java/art/arcane/iris/probe/RiverTransectProbe.java @@ -2,7 +2,10 @@ package art.arcane.iris.probe; import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.hydrology.DrainageEdge; +import art.arcane.iris.engine.hydrology.DrainageNode; import art.arcane.iris.engine.hydrology.HydraulicSegment; +import art.arcane.iris.engine.hydrology.HydrologyCandidateKind; import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; import art.arcane.iris.engine.hydrology.HydrologyColumnSample; import art.arcane.iris.engine.hydrology.HydrologyDiagnosticCandidate; @@ -180,6 +183,7 @@ public final class RiverTransectProbe { for (String line : rejectionLines(tile)) { System.out.println(PREFIX + " " + line); } + writeTileDiagnostics(new File(configuration.output(), "tile-diagnostics.png"), tile, complex, runtime, seaLevel); List summaries = new ArrayList<>(); for (RiverCourse course : tile.courses()) { if (course.type() != RiverCourseType.SURFACE) { @@ -387,6 +391,108 @@ public final class RiverTransectProbe { return layer.terrainOwned() ? Role.BANK : Role.NONE; } + /** + * One pixel per four blocks over the tile: natural height in gray, sea in blue, courses in white, + * diagnostic candidates as dots coloured by their rejection. + */ + private static void writeTileDiagnostics( + File file, + HydrologyTile tile, + IrisComplex complex, + IrisHydrologyRuntime runtime, + int seaLevel + ) throws IOException { + int scale = 4; + int tileSize = tile.tileSize(); + int size = tileSize / scale; + int minimumX = tile.key().minimumBlockX(tileSize); + int minimumZ = tile.key().minimumBlockZ(tileSize); + int[][] natural = new int[size][size]; + int lowest = Integer.MAX_VALUE; + int highest = Integer.MIN_VALUE; + for (int pz = 0; pz < size; pz++) { + for (int px = 0; px < size; px++) { + int height = (int) Math.round(complex.getNaturalHeightStream().getDouble(minimumX + px * scale, minimumZ + pz * scale)); + natural[px][pz] = height; + lowest = Math.min(lowest, height); + highest = Math.max(highest, height); + } + } + double range = Math.max(1, highest - lowest); + BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_RGB); + for (int pz = 0; pz < size; pz++) { + for (int px = 0; px < size; px++) { + int height = natural[px][pz]; + int rgb; + if (height <= seaLevel) { + rgb = 0x1E3F8A; + } else { + int gray = 40 + (int) Math.round(190D * (height - lowest) / range); + rgb = (gray << 16) | (gray << 8) | gray; + } + image.setRGB(px, pz, rgb); + } + } + Map nodes = new HashMap<>(); + for (DrainageNode node : tile.nodes()) { + nodes.put(node.id(), node); + plot(image, (node.naturalPoint().x() - minimumX) / scale, (node.naturalPoint().z() - minimumZ) / scale, 0x00E0E0, 0); + } + for (DrainageEdge edge : tile.edges()) { + DrainageNode from = nodes.get(edge.upstreamNodeId()); + DrainageNode to = nodes.get(edge.downstreamNodeId()); + if (from == null || to == null) { + continue; + } + line(image, (from.naturalPoint().x() - minimumX) / scale, (from.naturalPoint().z() - minimumZ) / scale, + (to.naturalPoint().x() - minimumX) / scale, (to.naturalPoint().z() - minimumZ) / scale, 0x20C020); + } + for (RiverCourse course : tile.courses()) { + int rgb = course.type() == RiverCourseType.SURFACE ? 0xFFFFFF : 0xFF00FF; + for (HydraulicSegment segment : course.segments()) { + for (HydrologyPoint point : segment.centerline()) { + plot(image, (point.x() - minimumX) / scale, (point.z() - minimumZ) / scale, rgb, 1); + } + } + } + for (HydrologyDiagnosticCandidate candidate : tile.diagnosticCandidates()) { + int rgb = switch (candidate.rejection()) { + case NO_DRAINAGE_PATH -> 0xFF2020; + case COURSE_TOO_SHORT -> 0xFFB000; + case SOURCE_SPACING -> 0xFFFF40; + case SURFACE_CORRIDOR_UNSUPPORTED -> 0xFF60FF; + case OUTLET_LIMIT -> 0x20FF20; + default -> 0x00FFFF; + }; + int radius = candidate.kind() == HydrologyCandidateKind.OUTLET ? 1 : 2; + plot(image, (candidate.point().x() - minimumX) / scale, (candidate.point().z() - minimumZ) / scale, rgb, radius); + } + ImageIO.write(image, "png", file); + } + + private static void line(BufferedImage image, int x0, int z0, int x1, int z1, int rgb) { + int steps = Math.max(1, Math.max(Math.abs(x1 - x0), Math.abs(z1 - z0))); + for (int step = 0; step <= steps; step++) { + int x = x0 + (x1 - x0) * step / steps; + int z = z0 + (z1 - z0) * step / steps; + if (x >= 0 && z >= 0 && x < image.getWidth() && z < image.getHeight()) { + image.setRGB(x, z, rgb); + } + } + } + + private static void plot(BufferedImage image, int x, int z, int rgb, int radius) { + for (int dz = -radius; dz <= radius; dz++) { + for (int dx = -radius; dx <= radius; dx++) { + int px = x + dx; + int pz = z + dz; + if (px >= 0 && pz >= 0 && px < image.getWidth() && pz < image.getHeight()) { + image.setRGB(px, pz, rgb); + } + } + } + } + private static void writePlan(File file, Bounds bounds, Map columns) throws IOException { int minimumNatural = Integer.MAX_VALUE; int maximumNatural = Integer.MIN_VALUE;