Valley shaping fixes from the first transects

- Head ring now covers every cell that can sit beside water
- Blend widths are smoothed along the course; the bed is a broad bowl
- Diagonal shore cells keep the lip; cross-section rows belong to their own station
- Transect probe prints coordinates and numeric section tables
This commit is contained in:
Brian Neumann-Fopiano committed 2026-09-01 21:29:08 -04:00
1 parent 124bcd9372
commit 4c4153debd
5 files changed
+256 -28

No files matched your search

@@ -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<SurfaceColumn> 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<SurfaceColumn> 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) {
@@ -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());
@@ -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());
}
}
@@ -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<String> 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<String> details = new ArrayList<>();
String worstStep = null;
ArrayList<ColumnView> 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<Long, ColumnView> 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<Long, ColumnView> 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();
@@ -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