mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-20 23:36:52 +00:00
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:
1 parent
124bcd9372
commit
4c4153debd
5 files changed
+256
-28
No files matched your search
+85
-22
@@ -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) {
|
||||
|
||||
+3
-1
@@ -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());
|
||||
|
||||
+79
@@ -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
|
||||
|
||||
Reference in new issue
Block a user