diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompiler.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompiler.java index 6374825ed..0eafca9e2 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompiler.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompiler.java @@ -12,7 +12,10 @@ import it.unimi.dsi.fastutil.longs.LongArrayList; import java.util.Objects; public final class ErosionFieldCompiler { + private static final int BLEND_SMOOTHING_RADIUS = 12; + private static final double THALWEG_FRACTION = 0.45D; private static final int[][] CARDINALS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + private static final int[][] DIAGONALS = {{1, 1}, {1, -1}, {-1, 1}, {-1, -1}}; private static final long OUTLINE_SALT = 0x4f55544c494e45L; private static final long BED_SALT = 0x424544L; private static final double EPSILON = 1.0E-9D; @@ -64,9 +67,11 @@ public final class ErosionFieldCompiler { } long key = RiverFootprint.pack(cellX, cellZ); int existing = nearest.get(key); + // A cell on a station's cross-section row ties between the two segments meeting + // there; the later station owns it so the row uses the head its own ring bounded. if (existing < 0 || cellDistance < distance.get(key) - EPSILON - || Math.abs(cellDistance - distance.get(key)) <= EPSILON && station < existing) { + || Math.abs(cellDistance - distance.get(key)) <= EPSILON && station > existing) { nearest.put(key, station); distance.put(key, cellDistance); } @@ -100,7 +105,7 @@ public final class ErosionFieldCompiler { if (wet) { double normalized = Math.min(1D, cellDistance / Math.max(0.5D, outline)); double depth = channel.depth()[station]; - double local = 1D + (depth - 1D) * (1D - normalized * normalized) + double local = 1D + (depth - 1D) * bowl(normalized) + 0.5D * roughness * SurfaceNoise.signed(courseSeed ^ BED_SALT, cellX, cellZ, banks.roughnessWavelength()) + (basin[station] ? 1D : 0D); int bed = Math.min(natural, head - Math.max(1, (int) StrictMath.round(local))); @@ -131,31 +136,48 @@ public final class ErosionFieldCompiler { return new ErosionField(columns, uncontained); } + /** + * Every dry cell touching water, diagonals included, keeps the lip; only cardinal neighbours can + * spill, so only they count as uncontained. + */ private int contain(Long2ObjectOpenHashMap columns, LongArrayList wetKeys, int freeboard) { int uncontained = 0; for (int index = 0; index < wetKeys.size(); index++) { SurfaceColumn wet = columns.get(wetKeys.getLong(index)); - for (int[] offset : CARDINALS) { - long key = RiverFootprint.pack(wet.x() + offset[0], wet.z() + offset[1]); - SurfaceColumn neighbour = columns.get(key); - if (neighbour == null) { - if (wet.headY() != seaLevel) { - uncontained++; - } - continue; - } - if (neighbour.role() == SurfaceRole.CHANNEL) { - continue; - } - int required = wet.headY() + freeboard; - if (neighbour.height() < required) { - int raised = Math.min(neighbour.terrain().naturalHeight(), required); - neighbour = neighbour.withHeight(raised); - columns.put(key, neighbour); - } - if (neighbour.height() < wet.headY()) { + uncontained += lip(columns, wet, CARDINALS, freeboard, true); + lip(columns, wet, DIAGONALS, freeboard, false); + } + return uncontained; + } + + private int lip( + Long2ObjectOpenHashMap columns, + SurfaceColumn wet, + int[][] offsets, + int freeboard, + boolean countSpills + ) { + int uncontained = 0; + for (int[] offset : offsets) { + long key = RiverFootprint.pack(wet.x() + offset[0], wet.z() + offset[1]); + SurfaceColumn neighbour = columns.get(key); + if (neighbour == null) { + if (countSpills && wet.headY() != seaLevel) { uncontained++; } + continue; + } + if (neighbour.role() == SurfaceRole.CHANNEL) { + continue; + } + int required = wet.headY() + freeboard; + if (neighbour.height() < required) { + int raised = Math.min(neighbour.terrain().naturalHeight(), required); + neighbour = neighbour.withHeight(raised); + columns.put(key, neighbour); + } + if (countSpills && neighbour.height() < wet.headY()) { + uncontained++; } } return uncontained; @@ -193,7 +215,48 @@ public final class ErosionFieldCompiler { widths[station][side] = Math.max(banks.minimumBlendWidth(), Math.min(banks.maximumBlendWidth(), width)); } } - return widths; + return smoothWidths(widths, BLEND_SMOOTHING_RADIUS); + } + + /** + * Moving average along the course so the valley outline widens and narrows gradually instead of + * stepping between neighbouring stations. + */ + static double[][] smoothWidths(double[][] widths, int radius) { + int count = widths.length; + double[][] smoothed = new double[count][2]; + for (int side = 0; side < 2; side++) { + double sum = 0D; + int window = 0; + int head = -1; + int tail = 0; + for (int station = 0; station < count; station++) { + while (head + 1 < count && head + 1 <= station + radius) { + head++; + sum += widths[head][side]; + window++; + } + while (tail < station - radius) { + sum -= widths[tail][side]; + tail++; + window--; + } + smoothed[station][side] = sum / window; + } + } + return smoothed; + } + + /** + * Bed depth factor across the channel: a level thalweg over the inner part of the width that eases + * up to a one-block edge, so the channel reads as a broad bowl rather than a V-shaped trough. + */ + static double bowl(double normalized) { + if (normalized <= THALWEG_FRACTION) { + return 1D; + } + double t = Math.min(1D, (normalized - THALWEG_FRACTION) / (1D - THALWEG_FRACTION)); + return 1D - t * t * (3D - 2D * t); } private static double side(SurfaceCenterline centerline, int station, int cellX, int cellZ) { 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 64e6b6e9f..bde493a22 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 @@ -40,6 +40,8 @@ public final class ValleyProfileSolver { int exposed = count; for (int station = 0; station < count; station++) { double outline = channel.halfWidth()[station] * (1D + roughness); + // Every cell from the narrowest possible outline outward can end up beside water, so it joins the minimum. + double innerOutline = channel.halfWidth()[station] * (1D - roughness); double reach = outline + 2D; int stationX = centerline.x()[station]; int stationZ = centerline.z()[station]; @@ -63,7 +65,7 @@ public final class ValleyProfileSolver { blocked = true; break; } - if (Math.abs(offset) <= outline + 0.25D) { + if (Math.abs(offset) < innerOutline - 0.75D) { continue; } minimum = Math.min(minimum, terrain.naturalHeight()); diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompilerTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompilerTest.java index ead2bad00..646a43e2a 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompilerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompilerTest.java @@ -183,4 +183,83 @@ public class ErosionFieldCompilerTest { static long key(int x, int z) { return RiverFootprint.pack(x, z); } + + @Test + public void blendWidthSmoothingSpreadsASpikeAcrossItsNeighbours() { + double[][] widths = new double[9][2]; + for (int station = 0; station < widths.length; station++) { + widths[station][0] = 4D; + widths[station][1] = 4D; + } + widths[4][1] = 34D; + + double[][] smoothed = ErosionFieldCompiler.smoothWidths(widths, 2); + + for (int station = 0; station < widths.length; station++) { + assertEquals(4D, smoothed[station][0], 1e-9); + } + assertEquals(4D, smoothed[0][1], 1e-9); + assertEquals(10D, smoothed[2][1], 1e-9); + assertEquals(10D, smoothed[4][1], 1e-9); + assertEquals(10D, smoothed[6][1], 1e-9); + assertEquals(4D, smoothed[8][1], 1e-9); + for (int station = 1; station < widths.length; station++) { + assertTrue(Math.abs(smoothed[station][1] - smoothed[station - 1][1]) <= 6D + 1e-9); + } + } + + @Test + public void bedBowlIsLevelAcrossTheThalwegAndOneBlockAtTheEdge() { + assertEquals(1D, ErosionFieldCompiler.bowl(0D), 1e-9); + assertEquals(1D, ErosionFieldCompiler.bowl(0.45D), 1e-9); + assertTrue(ErosionFieldCompiler.bowl(0.6D) > 0.8D); + assertTrue(ErosionFieldCompiler.bowl(0.8D) > 0.3D && ErosionFieldCompiler.bowl(0.8D) < 0.7D); + assertEquals(0D, ErosionFieldCompiler.bowl(1D), 1e-9); + double previous = 1D; + for (double normalized = 0D; normalized <= 1D; normalized += 0.05D) { + double value = ErosionFieldCompiler.bowl(normalized); + assertTrue(value <= previous + 1e-9); + previous = value; + } + } + + @Test + public void everyCellTouchingWaterKeepsTheLipAcrossHeadSteps() { + Compiled compiled = compile(400, (x, z) -> 120 - x / 8, SurfaceTerminal.SINKHOLE, 40); + int freeboard = zeroRoughnessSurface().banks().freeboard(); + int checked = 0; + + for (SurfaceColumn column : compiled.field().columns().values()) { + if (column.role() != SurfaceRole.CHANNEL) { + continue; + } + for (int deltaX = -1; deltaX <= 1; deltaX++) { + for (int deltaZ = -1; deltaZ <= 1; deltaZ++) { + SurfaceColumn neighbour = compiled.field().column(column.x() + deltaX, column.z() + deltaZ); + if (neighbour == null || neighbour.role() == SurfaceRole.CHANNEL) { + continue; + } + int required = Math.min(neighbour.terrain().naturalHeight(), column.headY() + freeboard); + assertTrue(neighbour.x() + "," + neighbour.z() + " height " + neighbour.height() + " < " + required, + neighbour.height() >= required); + checked++; + } + } + } + assertTrue(checked > 100); + } + + @Test + public void aLowCellBesideTheChannelLowersTheHeadSoTheLipSurvives() { + Compiled compiled = compile(300, (x, z) -> x == 150 && z == 4 ? 79 : 80, SurfaceTerminal.SINKHOLE, 40); + + SurfaceColumn center = compiled.field().column(150, 0); + assertNotNull(center); + assertTrue("head " + center.headY(), center.headY() <= 78); + SurfaceColumn low = compiled.field().column(150, 4); + assertEquals(SurfaceRole.SHORE, low.role()); + assertNotNull(low); + assertTrue(low.height() >= center.headY() + 1); + assertEquals(0, compiled.field().uncontainedWetCells()); + } } 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 6fe96d538..862ded66f 100644 --- a/probe/src/main/java/art/arcane/iris/probe/RiverTransectProbe.java +++ b/probe/src/main/java/art/arcane/iris/probe/RiverTransectProbe.java @@ -22,6 +22,7 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.util.ArrayList; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Locale; @@ -42,6 +43,8 @@ public final class RiverTransectProbe { private static final int SECTION_SCALE = 4; private static final int SECTION_ROW_HEIGHT = 120; private static final int MAXIMUM_PLAN_COLUMNS = 6_000_000; + private static final int MAXIMUM_DETAILS = 16; + private static final int TABLE_HALF_SECTION = 16; private RiverTransectProbe() { } @@ -72,7 +75,8 @@ public final class RiverTransectProbe { int maximumCut, int maximumBankStep, int oceanWrites, - int uncontainedWetCells + int uncontainedWetCells, + List details ) { boolean passes() { return oceanWrites == 0 && uncontainedWetCells == 0; @@ -187,7 +191,12 @@ public final class RiverTransectProbe { writePlan(new File(configuration.output(), "course-" + course.id() + ".png"), bounds, columns); writeSections(new File(configuration.output(), "course-" + course.id() + "-sections.png"), centerline, complex, runtime); + writeSectionTable(new File(configuration.output(), "course-" + course.id() + "-sections.txt"), + centerline, complex, runtime); System.out.println(summary.line()); + for (String detail : summary.details()) { + System.out.println(PREFIX + " " + detail); + } } writeSummary(new File(configuration.output(), "summary.txt"), configuration, tile, summaries); boolean pass = summaries.stream().allMatch(CourseSummary::passes); @@ -214,10 +223,18 @@ public final class RiverTransectProbe { int maximumBankStep = 0; int oceanWrites = 0; int uncontained = 0; - for (ColumnView column : columns.values()) { + ArrayList details = new ArrayList<>(); + String worstStep = null; + ArrayList ordered = new ArrayList<>(columns.values()); + ordered.sort(Comparator.comparingInt(ColumnView::z).thenComparingInt(ColumnView::x)); + for (ColumnView column : ordered) { boolean submerged = column.natural() <= seaLevel; if (submerged && (column.terrain() != column.natural() || column.role().owned())) { oceanWrites++; + if (details.size() < MAXIMUM_DETAILS) { + details.add("oceanWrite " + column.x() + "," + column.z() + + " natural=" + column.natural() + " terrain=" + column.terrain() + " role=" + column.role()); + } } if (!column.role().owned()) { continue; @@ -228,17 +245,46 @@ public final class RiverTransectProbe { if (column.role() == Role.CHANNEL) { if (column.water() != NO_WATER && spills(column, columns)) { uncontained++; + if (details.size() < MAXIMUM_DETAILS) { + details.add("uncontained " + column.x() + "," + column.z() + + " water=" + column.water() + " terrain=" + column.terrain() + + " natural=" + column.natural() + " neighbours=" + neighbourHeights(column, columns)); + } } continue; } - maximumBankStep = Math.max(maximumBankStep, bankStep(column, columns.get(RiverFootprint.pack(column.x() + 1, column.z())))); - maximumBankStep = Math.max(maximumBankStep, bankStep(column, columns.get(RiverFootprint.pack(column.x(), column.z() + 1)))); + for (int[] offset : new int[][] {{1, 0}, {0, 1}}) { + ColumnView neighbour = columns.get(RiverFootprint.pack(column.x() + offset[0], column.z() + offset[1])); + int step = bankStep(column, neighbour); + if (step > maximumBankStep) { + maximumBankStep = step; + worstStep = "bankStep " + step + " at " + column.x() + "," + column.z() + + " (" + column.role() + " terrain=" + column.terrain() + " natural=" + column.natural() + + ") vs " + neighbour.x() + "," + neighbour.z() + + " (" + neighbour.role() + " terrain=" + neighbour.terrain() + " natural=" + neighbour.natural() + ")"; + } + } + } + if (worstStep != null) { + details.add(worstStep); } if (owned == 0) { minimumCut = 0; maximumCut = 0; } - return new CourseSummary(id, stations, owned, minimumCut, maximumCut, maximumBankStep, oceanWrites, uncontained); + return new CourseSummary(id, stations, owned, minimumCut, maximumCut, maximumBankStep, oceanWrites, uncontained, List.copyOf(details)); + } + + private static String neighbourHeights(ColumnView channel, Map columns) { + StringBuilder text = new StringBuilder(); + for (int[] offset : new int[][] {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}) { + ColumnView neighbour = columns.get(RiverFootprint.pack(channel.x() + offset[0], channel.z() + offset[1])); + if (!text.isEmpty()) { + text.append(' '); + } + text.append(neighbour == null ? "?" : neighbour.role() + ":" + neighbour.terrain()); + } + return text.toString(); } private static boolean spills(ColumnView channel, Map columns) { @@ -425,6 +471,37 @@ public final class RiverTransectProbe { ImageIO.write(image, "png", file); } + private static void writeSectionTable( + File file, + SurfaceCenterline centerline, + IrisComplex complex, + IrisHydrologyRuntime runtime + ) throws IOException { + int[] stations = sectionStations(centerline.size()); + StringBuilder text = new StringBuilder(); + for (int section = 0; section < SECTIONS; section++) { + int station = stations[section]; + text.append("station ").append(station) + .append(" at ").append(centerline.x()[station]).append(',').append(centerline.z()[station]) + .append(" head=").append(column(complex, runtime, centerline.x()[station], centerline.z()[station]).water()) + .append('\n'); + text.append("offset natural terrain water role\n"); + double normalX = centerline.normalX(station); + double normalZ = centerline.normalZ(station); + for (int offset = -TABLE_HALF_SECTION; offset <= TABLE_HALF_SECTION; offset++) { + int x = (int) Math.round(centerline.x()[station] + normalX * offset); + int z = (int) Math.round(centerline.z()[station] + normalZ * offset); + ColumnView column = column(complex, runtime, x, z); + int streamNatural = (int) Math.round(complex.getNaturalHeightStream().getDouble(x, z)); + text.append(String.format(Locale.ROOT, "%4d %4d %4d %5s %s%s\n", + offset, column.natural(), column.terrain(), + column.water() == NO_WATER ? "-" : Integer.toString(column.water()), column.role(), + streamNatural == column.natural() ? "" : " stream=" + streamNatural)); + } + } + Files.writeString(file.toPath(), text.toString(), StandardCharsets.UTF_8); + } + private static int rowY(int rowTop, int height, int minimum, double scale) { return rowTop + SECTION_ROW_HEIGHT - 6 - (int) Math.round((height - minimum + 1) * scale); } @@ -458,6 +535,9 @@ public final class RiverTransectProbe { int uncontained = 0; for (CourseSummary summary : summaries) { text.append(summary.line()).append('\n'); + for (String detail : summary.details()) { + text.append(" ").append(detail).append('\n'); + } maximumCut = Math.max(maximumCut, summary.maximumCut()); maximumBankStep = Math.max(maximumBankStep, summary.maximumBankStep()); oceanWrites += summary.oceanWrites(); diff --git a/probe/src/test/java/art/arcane/iris/probe/RiverTransectProbeTest.java b/probe/src/test/java/art/arcane/iris/probe/RiverTransectProbeTest.java index 1c3200e87..7e3a12c06 100644 --- a/probe/src/test/java/art/arcane/iris/probe/RiverTransectProbeTest.java +++ b/probe/src/test/java/art/arcane/iris/probe/RiverTransectProbeTest.java @@ -42,6 +42,10 @@ public class RiverTransectProbeTest { assertEquals(1, summary.oceanWrites()); assertEquals(1, summary.uncontainedWetCells()); assertFalse(summary.passes()); + assertEquals(3, summary.details().size()); + assertTrue(summary.details().get(0).startsWith("uncontained 0,0")); + assertTrue(summary.details().get(1).startsWith("oceanWrite 5,5")); + assertTrue(summary.details().get(2).startsWith("bankStep 1")); } @Test