Surface course integration

- Build surface courses through the valley solver and segment labeler
- Compile surface footprints through the erosion field and index them for cave containment
- Drop the contained-surface fallback and the post-hoc surface rejection passes
- Choose the river profile at the source
This commit is contained in:
Brian Neumann-Fopiano
2026-09-01 19:05:43 -04:00
parent 2c6792977f
commit 8a256e3226
17 changed files with 1002 additions and 507 deletions
@@ -62,15 +62,6 @@ public record HydrologyColumnSample(
}
}
}
for (HydrologyColumnLayer layer : layers) {
if (layer.feature().type().isSurface()
&& !layer.oceanApron()
&& layer.channel()
&& layer.fluidOwned()
&& layer.fluidHeadY() >= naturalHeight) {
throw new IllegalArgumentException("Owned surface fluid must remain below natural terrain.");
}
}
}
public boolean present() {
@@ -1,5 +1,8 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.surface.SurfaceFootprint;
import art.arcane.iris.engine.hydrology.surface.SurfaceFootprintCompiler;
import art.arcane.iris.engine.hydrology.surface.SurfaceLayerColumn;
import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap;
import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap;
import it.unimi.dsi.fastutil.longs.Long2ObjectMap;
@@ -49,6 +52,8 @@ final class HydrologyFootprintCompiler {
private final LinkedHashMap<CourseRasterKey, RiverFootprint> courseFootprints;
private final LinkedHashMap<CourseRasterKey, ValidationCourseRaster> validationCourseRasters;
private final Map<RasterStencilKey, RasterStencil> rasterStencils;
private final SurfaceFootprintCompiler surfaceCompiler;
private final LinkedHashMap<CourseRasterKey, SurfaceFootprint> surfaceFootprints;
private int fullMaterializationCount;
HydrologyFootprintCompiler(
@@ -86,9 +91,30 @@ final class HydrologyFootprintCompiler {
this.courseFootprints = new LinkedHashMap<>(COURSE_FOOTPRINT_CACHE_SIZE, 1F, true);
this.validationCourseRasters = new LinkedHashMap<>(VALIDATION_RASTER_CACHE_SIZE, 1F, true);
this.rasterStencils = new HashMap<>();
this.surfaceCompiler = new SurfaceFootprintCompiler(settings, this::sampleTerrainBasis, this.geometrySampler);
this.surfaceFootprints = new LinkedHashMap<>(COURSE_FOOTPRINT_CACHE_SIZE, 1F, true);
this.fullMaterializationCount = 0;
}
SurfaceFootprint surfaceFootprint(RiverCourse course) {
CourseRasterKey rasterKey = new CourseRasterKey(
course.id(),
course.type(),
course.profileKey(),
course.segments()
);
SurfaceFootprint cached = surfaceFootprints.get(rasterKey);
if (cached != null) {
return cached;
}
SurfaceFootprint compiled = surfaceCompiler.compile(course);
surfaceFootprints.put(rasterKey, compiled);
if (surfaceFootprints.size() > COURSE_FOOTPRINT_CACHE_SIZE) {
surfaceFootprints.remove(surfaceFootprints.sequencedKeySet().getFirst());
}
return compiled;
}
RiverFootprint compile(List<RiverCourse> courses) {
fullMaterializationCount++;
if (courses.isEmpty()) {
@@ -110,7 +136,6 @@ final class HydrologyFootprintCompiler {
column.merge(sample);
}
}
containSurfaceBanks(columns);
return build(columns);
}
@@ -125,17 +150,17 @@ final class HydrologyFootprintCompiler {
if (!caveCandidatePresent) {
return new ValidationRaster(
List.of(),
new SurfaceRasterIndex(List.of(), new Long2ObjectOpenHashMap<>())
new SurfaceRasterIndex(List.<SurfaceFootprint>of(), new Long2ObjectOpenHashMap<>())
);
}
ArrayList<ValidationCourseRaster> courseRasters = new ArrayList<>(courses.size());
ArrayList<SurfaceSweep> surfaceSweeps = new ArrayList<>();
ArrayList<SurfaceFootprint> surfaces = new ArrayList<>();
Long2ObjectOpenHashMap<HydrologyColumnSample> firstSamples = new Long2ObjectOpenHashMap<>();
for (RiverCourse course : courses) {
ValidationCourseRaster raster = compileValidationCourse(course);
courseRasters.add(raster);
surfaceSweeps.addAll(raster.surfaceSweeps());
surfaces.add(raster.surface());
for (HydrologyColumnSample sample : raster.columns()) {
long packed = RiverFootprint.pack(sample.x(), sample.z());
HydrologyColumnSample first = firstSamples.putIfAbsent(packed, sample);
@@ -146,7 +171,7 @@ final class HydrologyFootprintCompiler {
}
return new ValidationRaster(
List.copyOf(courseRasters),
new SurfaceRasterIndex(surfaceSweeps, firstSamples)
new SurfaceRasterIndex(surfaces, firstSamples)
);
}
@@ -567,14 +592,11 @@ final class HydrologyFootprintCompiler {
boolean caveCourse = hasCaveSegment(course);
features.clear();
Long2ObjectLinkedOpenHashMap<MutableColumn> columns = new Long2ObjectLinkedOpenHashMap<>();
List<SurfaceSweep> surfaceSweeps = surfaceSweeps(course, true);
SurfaceRasterIndex courseSurface = new SurfaceRasterIndex(
surfaceSweeps,
new Long2ObjectOpenHashMap<>()
);
for (SurfaceSweep sweep : surfaceSweeps) {
rasterizeSurfaceSweep(columns, sweep, true);
SurfaceFootprint surface = surfaceFootprint(course);
for (SurfaceLayerColumn column : surface.columns()) {
addLayer(columns, column.x(), column.z(), column.terrain(), column.layer());
}
SurfaceRasterIndex courseSurface = new SurfaceRasterIndex(List.of(surface), new Long2ObjectOpenHashMap<>());
for (int segmentIndex = 0; segmentIndex < course.segments().size(); segmentIndex++) {
HydraulicSegment segment = course.segments().get(segmentIndex);
boolean firstSegment = segmentIndex == 0;
@@ -599,7 +621,7 @@ final class HydrologyFootprintCompiler {
ValidationCourseRaster raster = new ValidationCourseRaster(
course.id(),
buildValidationColumns(columns),
surfaceSweeps
surface
);
validationCourseRasters.put(rasterKey, raster);
if (validationCourseRasters.size() > VALIDATION_RASTER_CACHE_SIZE) {
@@ -658,19 +680,14 @@ final class HydrologyFootprintCompiler {
}
features.clear();
Long2ObjectLinkedOpenHashMap<MutableColumn> columns = new Long2ObjectLinkedOpenHashMap<>();
List<SurfaceSweep> surfaceSweeps = surfaceSweeps(course, true);
SurfaceRasterIndex courseSurface = new SurfaceRasterIndex(
surfaceSweeps,
new Long2ObjectOpenHashMap<>()
);
for (SurfaceSweep sweep : surfaceSweeps) {
rasterizeSurfaceSweep(columns, sweep, false);
SurfaceFootprint surface = surfaceFootprint(course);
for (SurfaceLayerColumn column : surface.columns()) {
addLayer(columns, column.x(), column.z(), column.terrain(), column.layer());
}
materializeSurfaceBankTerrain(columns);
containSurfaceBanks(columns);
SurfaceRasterIndex courseSurface = new SurfaceRasterIndex(List.of(surface), new Long2ObjectOpenHashMap<>());
for (int segmentIndex = 0; segmentIndex < course.segments().size(); segmentIndex++) {
HydraulicSegment segment = course.segments().get(segmentIndex);
if (segment.type().isSurface()) {
if (SurfaceFootprintCompiler.exposedSegment(segment)) {
continue;
}
boolean clipStart = segmentIndex > 0
@@ -3011,7 +3028,7 @@ final class HydrologyFootprintCompiler {
private record ValidationCourseRaster(
long courseId,
List<HydrologyColumnSample> columns,
List<SurfaceSweep> surfaceSweeps
SurfaceFootprint surface
) {
}
@@ -3228,17 +3245,23 @@ final class HydrologyFootprintCompiler {
}
private final class SurfaceRasterIndex implements HydrologyCaveVoxelViewFactory.PlannedSurface {
private final Long2ObjectOpenHashMap<ArrayList<SurfaceSweepEdge>> edgesByChunk;
private final Long2ObjectOpenHashMap<HydrologyColumnSample> rawSurfaceColumns;
private final Long2ObjectOpenHashMap<HydrologyColumnSample> surfaceColumns;
private final Long2IntOpenHashMap validationNaturalHeights;
private final Long2IntOpenHashMap resolvedHeights;
private SurfaceRasterIndex(
List<SurfaceSweep> surfaceSweeps,
List<SurfaceFootprint> footprints,
Long2ObjectOpenHashMap<HydrologyColumnSample> validationSamples
) {
this.edgesByChunk = new Long2ObjectOpenHashMap<>();
this.rawSurfaceColumns = new Long2ObjectOpenHashMap<>();
Long2ObjectLinkedOpenHashMap<MutableColumn> merged = new Long2ObjectLinkedOpenHashMap<>();
for (SurfaceFootprint footprint : footprints) {
for (SurfaceLayerColumn column : footprint.columns()) {
addLayer(merged, column.x(), column.z(), column.terrain(), column.layer());
}
}
this.surfaceColumns = new Long2ObjectOpenHashMap<>(merged.size());
for (Long2ObjectMap.Entry<MutableColumn> entry : merged.long2ObjectEntrySet()) {
surfaceColumns.put(entry.getLongKey(), entry.getValue().build());
}
this.validationNaturalHeights = new Long2IntOpenHashMap(validationSamples.size());
for (HydrologyColumnSample sample : validationSamples.values()) {
validationNaturalHeights.put(
@@ -3246,145 +3269,37 @@ final class HydrologyFootprintCompiler {
sample.naturalHeight()
);
}
this.resolvedHeights = new Long2IntOpenHashMap();
for (SurfaceSweep sweep : surfaceSweeps) {
index(sweep);
}
}
@Override
public int resolve(int x, int z, int naturalHeight) {
long packed = RiverFootprint.pack(x, z);
if (resolvedHeights.containsKey(packed)) {
return resolvedHeights.get(packed);
HydrologyColumnSample sample = surfaceColumns.get(packed);
if (sample != null) {
return sample.terrainHeight();
}
MutableColumn column = null;
int rasterNaturalHeight = naturalHeight;
boolean rasterPresent = false;
for (SurfaceProjection projection : projectionsAt(x, z)) {
SurfaceCell cell = surfaceCell(projection, x, z);
if (cell == null) {
continue;
if (validationNaturalHeights.containsKey(packed)) {
return validationNaturalHeights.get(packed);
}
if (!rasterPresent) {
rasterNaturalHeight = cell.terrain().naturalHeight();
rasterPresent = true;
}
if (cell.layer() == null) {
continue;
}
if (column == null) {
column = new MutableColumn(x, z, cell.terrain(), settings.seaLevel());
}
column.add(cell.layer());
}
int resolved;
if (column != null) {
resolved = containedSurfaceBankHeight(column.build());
} else if (rasterPresent) {
resolved = rasterNaturalHeight;
} else if (validationNaturalHeights.containsKey(packed)) {
resolved = validationNaturalHeights.get(packed);
} else {
return naturalHeight;
}
resolvedHeights.put(packed, resolved);
return resolved;
}
private int containedSurfaceBankHeight(HydrologyColumnSample sample) {
return containedSurfaceBankSample(sample).terrainHeight();
}
private HydrologyColumnSample containedSurfaceBankSample(HydrologyColumnSample sample) {
if (sample.primarySurfaceFluidLayer().isPresent()) {
return sample;
}
int minimumBankHeight = Integer.MIN_VALUE;
for (int[] offset : HORIZONTAL_NEIGHBORS) {
int neighborX = sample.x() + offset[0];
int neighborZ = sample.z() + offset[1];
HydrologyTerrainSample terrain = sampleTerrainBasis(neighborX, neighborZ);
if (terrain == null) {
continue;
}
HydrologyColumnSample neighbor = rawSurfaceColumnAt(
neighborX,
neighborZ,
terrain.naturalHeight()
);
if (neighbor == null) {
continue;
}
HydrologyColumnLayer fluid = neighbor.primarySurfaceFluidLayer().orElse(null);
if (fluid != null && !fluid.oceanApron() && !fluid.fallingFluid()) {
minimumBankHeight = Math.max(minimumBankHeight, Math.addExact(fluid.fluidHeadY(), 1));
}
}
if (minimumBankHeight == Integer.MIN_VALUE) {
return sample;
}
ArrayList<HydrologyColumnLayer> raised = new ArrayList<>(sample.layers().size());
for (HydrologyColumnLayer layer : sample.layers()) {
raised.add(raisedDrySurfaceLayer(layer, sample.naturalHeight(), minimumBankHeight));
}
return new HydrologyColumnSample(
sample.x(),
sample.z(),
sample.naturalHeight(),
sample.seaLevel(),
sample.ocean(),
sample.parentBiomeKey(),
raised
);
}
private HydrologyColumnSample surfaceColumnAt(int x, int z, int naturalHeight) {
HydrologyColumnSample sample = rawSurfaceColumnAt(x, z, naturalHeight);
return sample == null ? null : containedSurfaceBankSample(sample);
}
private HydrologyColumnSample rawSurfaceColumnAt(int x, int z, int naturalHeight) {
long packed = RiverFootprint.pack(x, z);
HydrologyColumnSample cached = rawSurfaceColumns.get(packed);
if (cached != null) {
if (cached.naturalHeight() != naturalHeight) {
HydrologyColumnSample sample = surfaceColumns.get(RiverFootprint.pack(x, z));
if (sample != null && sample.naturalHeight() != naturalHeight) {
throw new IllegalStateException("Hydrology surface and cave rasters disagree on natural terrain at "
+ x + "," + z + ".");
}
return cached;
}
MutableColumn column = null;
for (SurfaceProjection projection : projectionsAt(x, z)) {
SurfaceCell cell = surfaceCell(projection, x, z);
if (cell == null || cell.layer() == null) {
continue;
}
if (column == null) {
column = new MutableColumn(x, z, cell.terrain(), settings.seaLevel());
}
column.add(cell.layer());
}
if (column == null) {
return null;
}
HydrologyColumnSample sample = column.build();
if (sample.naturalHeight() != naturalHeight) {
throw new IllegalStateException("Hydrology surface and cave rasters disagree on natural terrain at "
+ x + "," + z + ".");
}
rawSurfaceColumns.put(packed, sample);
return sample;
}
private boolean ownsSurfaceChannelAt(int x, int z, long courseId) {
for (SurfaceProjection projection : projectionsAt(x, z)) {
SurfaceCell cell = surfaceCell(projection, x, z);
HydrologyColumnLayer layer = cell == null ? null : cell.layer();
if (layer != null
&& layer.feature().courseId() == courseId
HydrologyColumnSample sample = surfaceColumns.get(RiverFootprint.pack(x, z));
if (sample == null) {
return false;
}
for (HydrologyColumnLayer layer : sample.layers()) {
if (layer.feature().courseId() == courseId
&& layer.feature().type().isSurface()
&& layer.channel()
&& layer.terrainOwned()) {
@@ -3393,67 +3308,6 @@ final class HydrologyFootprintCompiler {
}
return false;
}
private List<SurfaceProjection> projectionsAt(int x, int z) {
ArrayList<SurfaceSweepEdge> edges = edgesByChunk.get(RiverFootprint.pack(x >> 4, z >> 4));
if (edges == null) {
return List.of();
}
IdentityHashMap<SurfaceSweep, SurfaceProjection> selected = new IdentityHashMap<>();
for (SurfaceSweepEdge edge : edges) {
SurfaceProjection candidate = surfaceProjection(edge.sweep(), edge.edgeIndex(), x, z);
if (candidate == null) {
continue;
}
SurfaceProjection current = selected.get(edge.sweep());
if (current == null || prefersSurfaceProjection(candidate, current)) {
selected.put(edge.sweep(), candidate);
}
}
ArrayList<SurfaceProjection> projections = new ArrayList<>(selected.values());
projections.sort(Comparator
.comparingLong((SurfaceProjection projection) -> projection.sweep().course().id())
.thenComparingInt((SurfaceProjection projection) -> projection.sweep().points().getFirst().point().x())
.thenComparingInt((SurfaceProjection projection) -> projection.sweep().points().getFirst().point().z())
.thenComparingInt(SurfaceProjection::edgeIndex));
return List.copyOf(projections);
}
private void index(SurfaceSweep sweep) {
if (sweep.points().size() == 1) {
index(new SurfaceSweepEdge(sweep, -1));
return;
}
for (int edgeIndex = 0; edgeIndex < sweep.points().size() - 1; edgeIndex++) {
index(new SurfaceSweepEdge(sweep, edgeIndex));
}
}
private void index(SurfaceSweepEdge edge) {
SurfaceSweepPoint start = edge.sweep().points().get(Math.max(0, edge.edgeIndex()));
SurfaceSweepPoint end = edge.edgeIndex() < 0
? start
: edge.sweep().points().get(edge.edgeIndex() + 1);
int blockRadius = (int) StrictMath.ceil(Math.max(
start.shape().totalRadius(),
end.shape().totalRadius()
));
int minimumChunkX = (Math.min(start.point().x(), end.point().x()) - blockRadius) >> 4;
int maximumChunkX = (Math.max(start.point().x(), end.point().x()) + blockRadius) >> 4;
int minimumChunkZ = (Math.min(start.point().z(), end.point().z()) - blockRadius) >> 4;
int maximumChunkZ = (Math.max(start.point().z(), end.point().z()) + blockRadius) >> 4;
for (int chunkZ = minimumChunkZ; chunkZ <= maximumChunkZ; chunkZ++) {
for (int chunkX = minimumChunkX; chunkX <= maximumChunkX; chunkX++) {
long chunkKey = RiverFootprint.pack(chunkX, chunkZ);
ArrayList<SurfaceSweepEdge> edges = edgesByChunk.get(chunkKey);
if (edges == null) {
edges = new ArrayList<>();
edgesByChunk.put(chunkKey, edges);
}
edges.add(edge);
}
}
}
}
private static final class MutableColumn {
@@ -5,6 +5,9 @@ import art.arcane.iris.engine.hydrology.cave.CaveVoxelView;
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCandidate;
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveContainmentPlanner;
import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan;
import art.arcane.iris.engine.hydrology.surface.SurfaceCourseBuilder;
import art.arcane.iris.engine.hydrology.surface.SurfaceCourseResult;
import art.arcane.iris.engine.hydrology.surface.SurfaceTerminal;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.util.common.parallel.MultiBurst;
import art.arcane.iris.util.project.noise.SimplexNoise;
@@ -116,6 +119,7 @@ public final class HydrologyPlanner {
private final SimplexNoise routeAnchorZ;
private final SimplexNoise routeWormPrimary;
private final SimplexNoise routeWormDetail;
private final SurfaceCourseBuilder surfaceCourseBuilder;
public HydrologyPlanner(long worldSeed, HydrologyPlannerSettings settings, HydrologyTerrainSampler sampler) {
this(
@@ -230,6 +234,13 @@ public final class HydrologyPlanner {
this.routeAnchorZ = new SimplexNoise(HydrologyHash.mix(worldSeed, ROUTE_ANCHOR_Z_SALT));
this.routeWormPrimary = new SimplexNoise(HydrologyHash.mix(worldSeed, ROUTE_WORM_PRIMARY_SALT));
this.routeWormDetail = new SimplexNoise(HydrologyHash.mix(worldSeed, ROUTE_WORM_DETAIL_SALT));
this.surfaceCourseBuilder = new SurfaceCourseBuilder(
settings.surface(),
this::sampleBasis,
geometrySampler,
settings.seaLevel(),
settings.routing().branching().minimumSurfaceCourseLength()
);
}
public long worldSeed() {
@@ -421,7 +432,7 @@ public final class HydrologyPlanner {
boolean fallbackSelected = false;
while (fallbackOutletIndex < fallbackOutlets.size()) {
OutletCandidate fallbackOutlet = fallbackOutlets.get(fallbackOutletIndex++);
for (int routeMode = 0; routeMode < 2; routeMode++) {
for (int routeMode = 0; routeMode < 1; routeMode++) {
RoutingPlan fallbackRouting = routeMode == 0
? requireOrganicSurface(buildRouting(grid, List.of(fallbackOutlet)))
: buildContainedSurfaceRouting(grid, List.of(fallbackOutlet));
@@ -538,17 +549,6 @@ public final class HydrologyPlanner {
int maximumPasses = Math.addExact(initial.courses().size(), 1);
for (int pass = 0; pass < maximumPasses; pass++) {
RiverFootprint footprint = footprintCompiler.compile(current.courses());
HydrologyCaveCourseFilter.Result incisionContained = rejectMaterializedSurfaceIncision(
current,
footprint,
diagnostics,
footprintCompiler
);
if (!incisionContained.courses().equals(current.courses())) {
current = incisionContained;
revalidationRequired = true;
continue;
}
if (current.cavePlans().isEmpty()) {
return new MaterializedHydrology(current, footprint);
}
@@ -596,58 +596,6 @@ public final class HydrologyPlanner {
throw new IllegalStateException("Final hydrology cave publication exceeded its course bound.");
}
private HydrologyCaveCourseFilter.Result rejectMaterializedSurfaceIncision(
HydrologyCaveCourseFilter.Result current,
RiverFootprint footprint,
List<HydrologyDiagnosticCandidate> diagnostics,
HydrologyFootprintCompiler footprintCompiler
) {
ArrayList<RiverCourse> retained = new ArrayList<>(current.courses().size());
for (RiverCourse course : current.courses()) {
if (footprintCompiler.surfaceIncisionContained(course, footprint)) {
retained.add(course);
continue;
}
HydrologyPoint source = course.segments().getFirst().centerline().getFirst();
HydrologyCandidateRejection rejection = HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED;
diagnostics.add(new HydrologyDiagnosticCandidate(
HydrologyHash.mix(course.id(), DIAGNOSTIC_SALT, rejection.ordinal()),
HydrologyCandidateKind.SOURCE,
HydrologyFeatureType.SURFACE_POOL,
source,
rejection
));
}
if (retained.size() == current.courses().size()) {
return current;
}
HydrologyFootprintCompiler.ValidationRaster validation = footprintCompiler.compileValidation(retained);
HydrologyObservedPlannedSurface plannedSurface = new HydrologyObservedPlannedSurface(
validation.plannedSurface()
);
CaveVoxelView caveView = Objects.requireNonNull(
caveViewFactory.create(plannedSurface),
"Hydrology cave view factory returned null"
);
return new HydrologyCaveCourseFilter(
caveView,
new HydrologyCaveCourseFilter.Options(
settings.underground().connectToExistingCaves(),
settings.outlets().coastalGrotto().maximumVolume(),
settings.outlets().inlandGrotto().maximumVolume()
),
null,
null,
plannedSurface
).filter(
current.nodes(),
current.edges(),
current.outlets(),
retained,
validation,
diagnostics
);
}
private boolean compactSurfaceMatchesMaterializedPlans(
HydrologyCaveCourseFilter.Result result,
@@ -1161,7 +1109,6 @@ public final class HydrologyPlanner {
List<RiverCourse> normalizedOutletCourses = normalizeOutletContinuations(normalizedTrunkCourses);
courses.clear();
courses.addAll(normalizedOutletCourses);
rejectUncontainedSurfaceCourses(courses, footprintCompiler, diagnostics);
if (includeDeepFluids) {
compileDeepFluidCourses(key, courses, diagnostics);
}
@@ -1202,44 +1149,6 @@ public final class HydrologyPlanner {
return new PublicationAttempt(containment, diagnostics);
}
private void rejectUncontainedSurfaceCourses(
List<RiverCourse> courses,
HydrologyFootprintCompiler footprintCompiler,
List<HydrologyDiagnosticCandidate> diagnostics
) {
int courseIndex = 0;
while (courseIndex < courses.size()) {
RiverCourse course = courses.get(courseIndex);
if (course.type() != RiverCourseType.SURFACE) {
courseIndex++;
continue;
}
HydrologyCandidateRejection rejection;
if (!footprintCompiler.surfaceIncisionContained(course)) {
rejection = HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED;
} else if (!footprintCompiler.surfaceHeadwaterRampSupported(course)) {
rejection = HydrologyCandidateRejection.SURFACE_EXPOSURE;
} else if (!footprintCompiler.surfaceBanksContained(course)) {
rejection = HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED;
} else {
courseIndex++;
continue;
}
HydrologyPoint source = course.segments().getFirst().centerline().getFirst();
diagnostics.add(new HydrologyDiagnosticCandidate(
HydrologyHash.mix(
course.id(),
DIAGNOSTIC_SALT,
rejection.ordinal()
),
HydrologyCandidateKind.SOURCE,
HydrologyFeatureType.SURFACE_POOL,
source,
rejection
));
courses.remove(courseIndex);
}
}
private SourceCompilation compileSourceSelection(
boolean surface,
@@ -4472,7 +4381,7 @@ public final class HydrologyPlanner {
continue;
}
long courseId = HydrologyHash.mix(sourceCourseId, path.outlet().id());
HydrologyPoint trunkPoint = path.points().getLast();
HydrologyPoint trunkPoint = path.points().getFirst();
HydrologyTerrainSample trunkTerrain = Objects.requireNonNull(
sampleDetailed(trunkPoint.x(), trunkPoint.z()),
"Hydrology surface trunk left sampled terrain"
@@ -4535,183 +4444,53 @@ public final class HydrologyPlanner {
GridNode source,
CoursePath path
) {
int pairCount = path.points().size() - 1;
if (pairCount < 1) {
if (path.points().size() < 2) {
return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.COURSE_TOO_SHORT);
}
int[] widths = new int[pairCount];
int[] depths = new int[pairCount];
for (int index = 0; index < pairCount; index++) {
HydrologyPoint point = path.points().get(index);
HydrologyTerrainSample terrain = sampleBasis(point.x(), point.z());
int discharge = Math.max(1, path.pairEdges().get(index).contributingSurfaceSources());
widths[index] = scaledDimension(
sampleGeometry(
HydrologyGeometrySampler.Field.SURFACE_WIDTH,
RiverOutlet outlet = path.outlet();
boolean surfaceSinkhole = path.reachesOutlet() && surfaceSinkhole(outlet);
SurfaceTerminal terminal;
if (!path.reachesOutlet() || outlet.type() == HydrologyFeatureType.INLAND_GROTTO) {
terminal = SurfaceTerminal.SINKHOLE;
} else if (outlet.type() == HydrologyFeatureType.COASTAL_GROTTO) {
terminal = SurfaceTerminal.COASTAL_GROTTO;
} else {
terminal = SurfaceTerminal.OCEAN_MOUTH;
}
int outletHead = outletHead(outlet);
int terminalHead = surfaceSinkhole ? Math.addExact(outletHead, 1) : outletHead;
SurfaceCourseResult result = surfaceCourseBuilder.build(
worldSeed,
courseId,
profileKey,
point.x(),
point.z(),
0L,
settings.surface().minimumWidth(),
settings.surface().maximumWidth()
),
settings.surface().minimumWidth(),
settings.surface().maximumWidth(),
discharge,
terrain.widthMultiplier()
);
depths[index] = scaledDimension(
sampleGeometry(
HydrologyGeometrySampler.Field.SURFACE_DEPTH,
profileKey,
point.x(),
point.z(),
0L,
settings.surface().minimumDepth(),
settings.surface().maximumDepth()
),
settings.surface().minimumDepth(),
settings.surface().maximumDepth(),
discharge,
terrain.depthMultiplier()
path.points(),
terminal,
terminalHead
);
if (!result.accepted()) {
return SurfaceCourseBuild.rejected(result.rejection());
}
int outletHead = outletHead(path.outlet());
boolean surfaceSinkhole = path.reachesOutlet() && surfaceSinkhole(path.outlet());
int minimumPathHead = surfaceSinkhole ? Math.addExact(outletHead, 1) : outletHead;
long hydraulicStableId = path.outlet().id();
int[] maximumHeads = surfaceMaximumHeads(hydraulicStableId, profileKey, path, widths);
int[] heads = new int[path.points().size()];
if (maximumHeads[0] < minimumPathHead) {
return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.SURFACE_HEAD_RANGE);
}
heads[0] = maximumHeads[0];
for (int index = 1; index < path.points().size(); index++) {
if (maximumHeads[index] < minimumPathHead) {
return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.SURFACE_HEAD_RANGE);
}
heads[index] = Math.min(heads[index - 1], maximumHeads[index]);
}
int targetPoolLength = targetPoolLength(hydraulicStableId, profileKey, path);
poolHeads(path, heads, maximumHeads, targetPoolLength);
if (!distributeSurfaceHeadLoss(path, heads, maximumHeads, minimumPathHead)) {
return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.SURFACE_HEAD_DISTRIBUTION);
}
if (!ridgeLengthAccepted(path, heads)) {
return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.SURFACE_RIDGE_TUNNEL_LIMIT);
}
if (!surfaceHeadLossSupportedByTerrain(path, heads, widths, depths)) {
return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.SURFACE_DROP_UNSUPPORTED);
}
if (!surfaceCorridorSupported(path, heads, widths)) {
return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED);
}
ArrayList<HydraulicSegment> segments = new ArrayList<>(pairCount + 1);
int index = 0;
while (index < pairCount) {
int upstreamHead = heads[index];
int downstreamHead = heads[index + 1];
HydrologyFeatureType pairType = surfaceSegmentType(
path,
index,
upstreamHead,
downstreamHead,
widths[index],
depths[index]
);
if (!isDirectMouthPair(path, index)
&& addMixedSurfaceBoreSegments(
courseId,
index,
upstreamHead,
downstreamHead,
widths[index],
depths[index],
path.points().get(index),
path.points().get(index + 1),
segments
)) {
index++;
continue;
}
if (upstreamHead > downstreamHead
&& pairType.isSurface()
&& !isDirectMouthPair(path, index)) {
int runEnd = surfaceTransitionRunEnd(path, heads, widths, depths, index, targetPoolLength);
boolean added = addSurfaceTransitionRunSegments(
courseId,
index,
runEnd,
averageDimension(widths, index, runEnd),
averageDimension(depths, index, runEnd),
path,
heads,
segments
);
if (!added) {
return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.SURFACE_TRANSITION_UNSUPPORTED);
}
index = runEnd + 1;
continue;
}
List<HydrologyPoint> centerline = List.of(
withY(path.points().get(index), upstreamHead),
withY(path.points().get(index + 1), downstreamHead)
);
boolean added = pairType.isSurface()
&& isDirectMouthPair(path, index)
&& downstreamHead < upstreamHead
? addCoastalMouthDropSegments(
courseId,
index,
upstreamHead,
downstreamHead,
widths[index],
depths[index],
centerline,
true,
segments
)
: addHydraulicSegments(
courseId,
index,
pairType,
upstreamHead,
downstreamHead,
widths[index],
depths[index],
centerline,
true,
segments
);
if (!added) {
return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.SURFACE_SEGMENT_UNSUPPORTED);
}
index++;
}
if (surfaceSinkhole && heads[heads.length - 1] <= outletHead) {
return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.SURFACE_SINKHOLE_CLEARANCE);
}
ArrayList<HydraulicSegment> segments = new ArrayList<>(result.segments());
if (path.reachesOutlet()) {
CoursePath terminalPath = new CoursePath(
List.of(result.pathEnd()),
path.pairEdges(),
path.edges(),
outlet,
true,
false,
path.organicSurfaceRequired()
);
appendOutletSegments(
RiverCourseType.SURFACE,
courseId,
path,
heads[heads.length - 1],
widths,
depths,
terminalPath,
result.lastHead(),
new int[] {result.lastWidth()},
new int[] {result.lastDepth()},
segments
);
}
containUnsupportedSurfaceSegments(segments);
containBriefSurfaceReopenings(segments);
smoothFlatSurfaceDimensions(segments);
if (!surfaceExposureAccepted(segments)) {
return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.SURFACE_EXPOSURE);
}
if (path.containedSurface() && !surfaceTransitionRecoveriesAccepted(segments)) {
return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.SURFACE_TRANSITION_UNSUPPORTED);
}
if (path.organicSurfaceRequired() && !surfaceShapeAccepted(path.points())) {
return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.SURFACE_SHAPE_UNSUPPORTED);
}
@@ -4720,7 +4499,7 @@ public final class HydrologyPlanner {
courseId,
RiverCourseType.SURFACE,
OptionalLong.of(source.id()),
OptionalLong.of(path.outlet().id()),
OptionalLong.of(outlet.id()),
profileKey,
discharge,
path.edges(),
@@ -2,11 +2,9 @@ package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydrologyGeometrySampler;
import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.engine.hydrology.HydrologyPoint;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSample;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler;
import java.util.List;
import java.util.Objects;
public final class ChannelProfileBuilder {
@@ -30,7 +28,6 @@ public final class ChannelProfileBuilder {
public ChannelProfile build(
SurfaceCenterline centerline,
List<HydrologyPoint> path,
String profileKey,
boolean directOcean
) {
@@ -39,12 +36,13 @@ public final class ChannelProfileBuilder {
double[] depth = new double[count];
double[] bank = new double[count];
for (int station = 0; station < count; station++) {
HydrologyPoint point = path.get(Math.min(path.size() - 1, centerline.pathIndex()[station]));
int stationX = centerline.x()[station];
int stationZ = centerline.z()[station];
int sampledWidth = geometry.sample(
HydrologyGeometrySampler.Field.SURFACE_WIDTH,
profileKey,
point.x(),
point.z(),
stationX,
stationZ,
0L,
surface.minimumWidth(),
surface.maximumWidth()
@@ -52,13 +50,13 @@ public final class ChannelProfileBuilder {
int sampledDepth = geometry.sample(
HydrologyGeometrySampler.Field.SURFACE_DEPTH,
profileKey,
point.x(),
point.z(),
stationX,
stationZ,
0L,
surface.minimumDepth(),
surface.maximumDepth()
);
HydrologyTerrainSample terrain = sampler.sample(centerline.x()[station], centerline.z()[station]);
HydrologyTerrainSample terrain = sampler.sample(stationX, stationZ);
double widthMultiplier = terrain == null ? 1D : terrain.widthMultiplier();
double depthMultiplier = terrain == null ? 1D : terrain.depthMultiplier();
bank[station] = terrain == null ? 1D : terrain.bankMultiplier();
@@ -0,0 +1,98 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydraulicSegment;
import art.arcane.iris.engine.hydrology.HydrologyCandidateRejection;
import art.arcane.iris.engine.hydrology.HydrologyGeometrySampler;
import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.engine.hydrology.HydrologyPoint;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler;
import java.util.List;
import java.util.Objects;
public final class SurfaceCourseBuilder {
private final HydrologyPlannerSettings.Surface surface;
private final HydrologyTerrainSampler sampler;
private final HydrologyGeometrySampler geometry;
private final int seaLevel;
private final int minimumCourseLength;
public SurfaceCourseBuilder(
HydrologyPlannerSettings.Surface surface,
HydrologyTerrainSampler sampler,
HydrologyGeometrySampler geometry,
int seaLevel,
int minimumCourseLength
) {
this.surface = Objects.requireNonNull(surface, "surface");
this.sampler = Objects.requireNonNull(sampler, "sampler");
this.geometry = Objects.requireNonNull(geometry, "geometry");
this.seaLevel = seaLevel;
this.minimumCourseLength = minimumCourseLength;
}
public SurfaceCourseResult build(
long worldSeed,
long courseId,
String profileKey,
List<HydrologyPoint> path,
SurfaceTerminal terminal,
int terminalHead
) {
SurfaceCenterline centerline = SurfaceCenterline.densify(path);
boolean directOcean = terminal == SurfaceTerminal.OCEAN_MOUTH;
ChannelProfile channel = new ChannelProfileBuilder(surface, sampler, geometry)
.build(centerline, profileKey, directOcean);
ValleyProfile valley = new ValleyProfileSolver(surface, sampler, seaLevel, minimumCourseLength)
.solve(centerline, channel, terminal, terminalHead);
if (!valley.accepted()) {
return SurfaceCourseResult.rejected(valley.rejection());
}
int exposed = valley.exposedStations();
boolean coastalDrop = directOcean && valley.head()[exposed - 1] > seaLevel;
int stations = coastalDrop ? exposed + 1 : exposed;
int[] x = new int[stations];
int[] z = new int[stations];
int[] head = new int[stations];
double[] width = new double[stations];
double[] depth = new double[stations];
for (int station = 0; station < exposed; station++) {
x[station] = centerline.x()[station];
z[station] = centerline.z()[station];
head[station] = valley.head()[station];
width[station] = channel.halfWidth()[station] * 2D;
depth[station] = channel.depth()[station];
}
if (coastalDrop) {
int last = exposed - 1;
if (exposed < centerline.size()) {
x[exposed] = centerline.x()[exposed];
z[exposed] = centerline.z()[exposed];
} else {
x[exposed] = x[last] + (int) StrictMath.round(centerline.tangentX()[last]);
z[exposed] = z[last] + (int) StrictMath.round(centerline.tangentZ()[last]);
if (x[exposed] == x[last] && z[exposed] == z[last]) {
x[exposed] = x[last] + 1;
}
}
head[exposed] = seaLevel;
width[exposed] = width[last];
depth[exposed] = depth[last];
}
List<HydraulicSegment> segments = SurfaceSegmentLabeler.label(
worldSeed, courseId, x, z, head, width, depth, surface.banks());
if (segments.isEmpty()) {
return SurfaceCourseResult.rejected(HydrologyCandidateRejection.COURSE_TOO_SHORT);
}
HydraulicSegment last = segments.getLast();
int lastStation = stations - 1;
return new SurfaceCourseResult(
segments,
head[lastStation],
last.width(),
last.depth(),
new HydrologyPoint(x[lastStation], head[lastStation], z[lastStation]),
null
);
}
}
@@ -0,0 +1,29 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydraulicSegment;
import art.arcane.iris.engine.hydrology.HydrologyCandidateRejection;
import art.arcane.iris.engine.hydrology.HydrologyPoint;
import java.util.List;
import java.util.Objects;
public record SurfaceCourseResult(
List<HydraulicSegment> segments,
int lastHead,
int lastWidth,
int lastDepth,
HydrologyPoint pathEnd,
HydrologyCandidateRejection rejection
) {
public SurfaceCourseResult {
segments = List.copyOf(Objects.requireNonNull(segments, "segments"));
}
public boolean accepted() {
return rejection == null;
}
public static SurfaceCourseResult rejected(HydrologyCandidateRejection rejection) {
return new SurfaceCourseResult(List.of(), 0, 0, 0, null, Objects.requireNonNull(rejection, "rejection"));
}
}
@@ -0,0 +1,64 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydraulicSegment;
import art.arcane.iris.engine.hydrology.HydrologyFeatureRef;
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
import art.arcane.iris.engine.hydrology.HydrologyHash;
import java.util.HashMap;
public final class SurfaceFeatureRefs {
private static final int ROLE_CHANNEL = 1;
private static final int ROLE_SHORE = 2;
private static final int ROLE_GRADING = 3;
private static final int ROLE_SOURCE = 6;
private final long courseId;
private final HashMap<Key, HydrologyFeatureRef> features;
public SurfaceFeatureRefs(long courseId) {
this.courseId = courseId;
this.features = new HashMap<>();
}
public HydrologyFeatureRef feature(
HydraulicSegment segment,
SurfaceRole role,
boolean source,
int x,
int y,
int z,
int flowX,
int flowZ
) {
int roleCode = source ? ROLE_SOURCE : switch (role) {
case CHANNEL -> ROLE_CHANNEL;
case SHORE -> ROLE_SHORE;
case BANK -> ROLE_GRADING;
};
int featureFlowX = Integer.compare(flowX, 0);
int featureFlowZ = Integer.compare(flowZ, 0);
Key key = new Key(segment.id(), segment.type(), roleCode, featureFlowX, featureFlowZ);
HydrologyFeatureRef existing = features.get(key);
if (existing != null) {
return existing;
}
HydrologyFeatureRef created = new HydrologyFeatureRef(
HydrologyHash.mix(courseId, segment.id(), segment.type().ordinal(), roleCode, featureFlowX, featureFlowZ),
segment.type(),
courseId,
segment.id(),
x,
y,
z,
featureFlowX,
featureFlowZ,
source
);
features.put(key, created);
return created;
}
private record Key(long segmentId, HydrologyFeatureType type, int role, int flowX, int flowZ) {
}
}
@@ -0,0 +1,18 @@
package art.arcane.iris.engine.hydrology.surface;
import java.util.List;
import java.util.Objects;
public record SurfaceFootprint(List<SurfaceLayerColumn> columns, int uncontainedWetCells) {
public SurfaceFootprint {
columns = List.copyOf(Objects.requireNonNull(columns, "columns"));
}
public static SurfaceFootprint empty() {
return new SurfaceFootprint(List.of(), 0);
}
public boolean isEmpty() {
return columns.isEmpty();
}
}
@@ -0,0 +1,214 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydraulicSegment;
import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
import art.arcane.iris.engine.hydrology.HydrologyFeatureRef;
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
import art.arcane.iris.engine.hydrology.HydrologyGeometrySampler;
import art.arcane.iris.engine.hydrology.HydrologyHash;
import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.engine.hydrology.HydrologyPoint;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSample;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler;
import art.arcane.iris.engine.hydrology.RiverCourse;
import art.arcane.iris.engine.hydrology.RiverCourseType;
import art.arcane.iris.engine.hydrology.RiverFootprint;
import it.unimi.dsi.fastutil.ints.IntArrayList;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
public final class SurfaceFootprintCompiler {
private static final long COURSE_SEED_SALT = 0x535552464143455fL;
private final HydrologyPlannerSettings settings;
private final HydrologyTerrainSampler sampler;
private final HydrologyGeometrySampler geometry;
public SurfaceFootprintCompiler(
HydrologyPlannerSettings settings,
HydrologyTerrainSampler sampler,
HydrologyGeometrySampler geometry
) {
this.settings = Objects.requireNonNull(settings, "settings");
this.sampler = Objects.requireNonNull(sampler, "sampler");
this.geometry = Objects.requireNonNull(geometry, "geometry");
}
public static boolean exposedSegment(HydraulicSegment segment) {
return segment.type().isSurface() && !segment.fallingFluid();
}
public SurfaceFootprint compile(RiverCourse course) {
if (course.type() != RiverCourseType.SURFACE) {
return SurfaceFootprint.empty();
}
ArrayList<HydraulicSegment> exposed = new ArrayList<>();
for (HydraulicSegment segment : course.segments()) {
if (!exposedSegment(segment)) {
break;
}
exposed.add(segment);
}
if (exposed.isEmpty()) {
return SurfaceFootprint.empty();
}
Stations stations = stations(exposed);
if (stations.count() < 1) {
return SurfaceFootprint.empty();
}
SurfaceTerminal terminal = terminal(course, exposed);
SurfaceCenterline centerline = SurfaceCenterline.densify(stations.points());
ChannelProfile channel = new ChannelProfileBuilder(settings.surface(), sampler, geometry)
.build(centerline, course.profileKey(), terminal == SurfaceTerminal.OCEAN_MOUTH);
ValleyProfile valley = ValleyProfile.fromHeads(stations.head(), stations.exposedStations());
ErosionField field = new ErosionFieldCompiler(settings.surface(), sampler, settings.seaLevel()).compile(
HydrologyHash.mix(course.id(), COURSE_SEED_SALT),
centerline,
channel,
valley,
terminal,
settings.outlets().maximumOceanApron()
);
ArrayList<SurfaceColumn> ordered = new ArrayList<>(field.columns().values());
ordered.sort(Comparator
.comparingInt(SurfaceColumn::station)
.thenComparingLong((SurfaceColumn column) -> RiverFootprint.pack(column.x(), column.z())));
SurfaceFeatureRefs features = new SurfaceFeatureRefs(course.id());
ArrayList<SurfaceLayerColumn> columns = new ArrayList<>(ordered.size());
for (SurfaceColumn column : ordered) {
HydraulicSegment segment = exposed.get(stations.segmentIndex()[column.station()]);
int flowX = (int) StrictMath.round(centerline.tangentX()[column.station()]);
int flowZ = (int) StrictMath.round(centerline.tangentZ()[column.station()]);
boolean source = column.station() == 0 && column.role() == SurfaceRole.CHANNEL && !column.apron();
int y = column.role() == SurfaceRole.CHANNEL ? column.headY() : column.height();
HydrologyFeatureRef feature = features.feature(segment, column.role(), source, column.x(), y, column.z(), flowX, flowZ);
columns.add(new SurfaceLayerColumn(
column.x(),
column.z(),
column.terrain(),
layer(feature, column, course.profileKey()),
column.role(),
column.apron()
));
}
return new SurfaceFootprint(columns, field.uncontainedWetCells());
}
private static HydrologyColumnLayer layer(HydrologyFeatureRef feature, SurfaceColumn column, String profileKey) {
HydrologyTerrainSample terrain = column.terrain();
if (column.apron()) {
return new HydrologyColumnLayer(
feature,
column.headY(),
column.headY(),
column.headY(),
true, false, false, true, false, false, false, false, true,
profileKey,
terrain.surfaceBiomeKey(),
terrain.mouthBiomeKey(),
terrain.shoreBiomeKey(),
terrain.bankBiomeKey(),
terrain.floodedCaveBiomeKey()
);
}
boolean channel = column.role() == SurfaceRole.CHANNEL;
boolean shore = column.role() == SurfaceRole.SHORE;
return new HydrologyColumnLayer(
feature,
column.height(),
column.headY(),
column.headY(),
channel,
shore,
!channel,
channel,
false,
false,
true,
channel,
false,
profileKey,
terrain.surfaceBiomeKey(),
terrain.mouthBiomeKey(),
terrain.shoreBiomeKey(),
terrain.bankBiomeKey(),
terrain.floodedCaveBiomeKey()
);
}
private static SurfaceTerminal terminal(RiverCourse course, List<HydraulicSegment> exposed) {
if (exposed.getLast().type() == HydrologyFeatureType.MOUTH) {
return SurfaceTerminal.OCEAN_MOUTH;
}
for (HydraulicSegment segment : course.segments()) {
if (segment.type() == HydrologyFeatureType.MOUTH) {
return SurfaceTerminal.OCEAN_MOUTH;
}
if (segment.type() == HydrologyFeatureType.COASTAL_GROTTO) {
return SurfaceTerminal.COASTAL_GROTTO;
}
}
return SurfaceTerminal.SINKHOLE;
}
private static Stations stations(List<HydraulicSegment> exposed) {
ArrayList<HydrologyPoint> points = new ArrayList<>();
IntArrayList segmentIndices = new IntArrayList();
IntArrayList heads = new IntArrayList();
int exposedStations = 0;
for (int segmentIndex = 0; segmentIndex < exposed.size(); segmentIndex++) {
HydraulicSegment segment = exposed.get(segmentIndex);
boolean mouth = segment.type() == HydrologyFeatureType.MOUTH;
List<HydrologyPoint> centerline = segment.centerline();
for (int pointIndex = 0; pointIndex < centerline.size(); pointIndex++) {
HydrologyPoint current = centerline.get(pointIndex);
if (pointIndex == 0) {
appendStation(points, segmentIndices, heads, current, segmentIndex);
continue;
}
HydrologyPoint previous = centerline.get(pointIndex - 1);
int steps = Math.max(Math.abs(current.x() - previous.x()), Math.abs(current.z() - previous.z()));
for (int step = 1; step <= steps; step++) {
double progress = step / (double) steps;
HydrologyPoint cell = new HydrologyPoint(
(int) StrictMath.round(previous.x() + (current.x() - previous.x()) * progress),
(int) StrictMath.round(previous.y() + (current.y() - previous.y()) * progress),
(int) StrictMath.round(previous.z() + (current.z() - previous.z()) * progress)
);
appendStation(points, segmentIndices, heads, cell, segmentIndex);
}
}
if (!mouth) {
exposedStations = points.size();
}
}
return new Stations(points, segmentIndices.toIntArray(), heads.toIntArray(), exposedStations);
}
private static void appendStation(
ArrayList<HydrologyPoint> points,
IntArrayList segmentIndices,
IntArrayList heads,
HydrologyPoint cell,
int segmentIndex
) {
if (!points.isEmpty()) {
HydrologyPoint last = points.getLast();
if (last.x() == cell.x() && last.z() == cell.z()) {
return;
}
}
points.add(cell);
segmentIndices.add(segmentIndex);
heads.add(cell.y());
}
private record Stations(List<HydrologyPoint> points, int[] segmentIndex, int[] head, int exposedStations) {
private int count() {
return points.size();
}
}
}
@@ -0,0 +1,21 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSample;
import java.util.Objects;
public record SurfaceLayerColumn(
int x,
int z,
HydrologyTerrainSample terrain,
HydrologyColumnLayer layer,
SurfaceRole role,
boolean apron
) {
public SurfaceLayerColumn {
Objects.requireNonNull(terrain, "terrain");
Objects.requireNonNull(layer, "layer");
Objects.requireNonNull(role, "role");
}
}
@@ -0,0 +1,139 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydraulicSegment;
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
import art.arcane.iris.engine.hydrology.HydrologyHash;
import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.engine.hydrology.HydrologyPoint;
import java.util.ArrayList;
import java.util.List;
public final class SurfaceSegmentLabeler {
private static final long SEGMENT_SALT = 0x5345474d454e54L;
private SurfaceSegmentLabeler() {
}
public static List<HydraulicSegment> label(
long worldSeed,
long courseId,
int[] x,
int[] z,
int[] head,
double[] width,
double[] depth,
HydrologyPlannerSettings.Banks banks
) {
int pairs = head.length - 1;
if (pairs < 1) {
return List.of();
}
ArrayList<Run> runs = new ArrayList<>();
for (int pair = 0; pair < pairs; pair++) {
HydrologyFeatureType type = pairType(head[pair] - head[pair + 1], banks.waterfallMinimumDrop());
if (!runs.isEmpty() && runs.getLast().type == type) {
runs.getLast().end = pair;
} else {
runs.add(new Run(pair, pair, type));
}
}
for (Run run : runs) {
if (run.type == HydrologyFeatureType.RIFFLE && run.pairs() >= 2) {
run.type = HydrologyFeatureType.CASCADE;
}
}
boolean merged = true;
while (merged) {
merged = false;
for (int index = 1; index + 1 < runs.size(); index++) {
Run previous = runs.get(index - 1);
Run current = runs.get(index);
Run next = runs.get(index + 1);
if (current.type == HydrologyFeatureType.SURFACE_POOL
&& current.pairs() < banks.cascadeRun() * 2
&& isGraded(previous.type)
&& isGraded(next.type)) {
previous.end = next.end;
previous.type = HydrologyFeatureType.CASCADE;
runs.remove(index + 1);
runs.remove(index);
merged = true;
break;
}
}
if (!merged) {
for (int index = 1; index < runs.size(); index++) {
Run previous = runs.get(index - 1);
Run current = runs.get(index);
if (previous.type == current.type) {
previous.end = current.end;
runs.remove(index);
merged = true;
break;
}
}
}
}
ArrayList<HydraulicSegment> segments = new ArrayList<>(runs.size());
for (Run run : runs) {
int startStation = run.start;
int endStation = run.end + 1;
ArrayList<HydrologyPoint> centerline = new ArrayList<>(endStation - startStation + 1);
double totalWidth = 0D;
double totalDepth = 0D;
for (int station = startStation; station <= endStation; station++) {
centerline.add(new HydrologyPoint(x[station], head[station], z[station]));
totalWidth += width[station];
totalDepth += depth[station];
}
int stations = endStation - startStation + 1;
int segmentWidth = Math.max(1, (int) StrictMath.round(totalWidth / stations));
int segmentDepth = Math.max(1, (int) StrictMath.round(totalDepth / stations));
int index = segments.size();
segments.add(new HydraulicSegment(
HydrologyHash.mix(worldSeed, SEGMENT_SALT, courseId, index, run.type.ordinal()),
courseId,
run.type,
head[startStation],
head[endStation],
segmentWidth,
segmentDepth,
false,
false,
centerline
));
}
return List.copyOf(segments);
}
private static HydrologyFeatureType pairType(int drop, int waterfallMinimumDrop) {
if (drop <= 0) {
return HydrologyFeatureType.SURFACE_POOL;
}
if (drop == 1) {
return HydrologyFeatureType.RIFFLE;
}
return drop >= waterfallMinimumDrop ? HydrologyFeatureType.WATERFALL : HydrologyFeatureType.CASCADE;
}
private static boolean isGraded(HydrologyFeatureType type) {
return type == HydrologyFeatureType.RIFFLE || type == HydrologyFeatureType.CASCADE;
}
private static final class Run {
private final int start;
private int end;
private HydrologyFeatureType type;
private Run(int start, int end, HydrologyFeatureType type) {
this.start = start;
this.end = end;
this.type = type;
}
private int pairs() {
return end - start + 1;
}
}
}
@@ -14,6 +14,10 @@ public record ValleyProfile(
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);
}
public static ValleyProfile rejected(HydrologyCandidateRejection rejection) {
return new ValleyProfile(new int[0], new int[0], new int[0], new int[0], 0, rejection);
}
@@ -22,7 +22,7 @@ public class ChannelProfileBuilderTest {
@Test
public void cruiseWidthIsConstantAndTapersFromTheHeadwater() {
SurfaceCenterline centerline = straight(400);
ChannelProfile profile = builder(1D, 1D).build(centerline, path(400), "water", false);
ChannelProfile profile = builder(1D, 1D).build(centerline, "water", false);
assertEquals(400, profile.size());
assertEquals(3D, profile.halfWidth()[200], 1.0E-9D);
@@ -38,7 +38,7 @@ public class ChannelProfileBuilderTest {
@Test
public void directOceanCoursesFlareAtTheMouth() {
SurfaceCenterline centerline = straight(400);
ChannelProfile profile = builder(1D, 1D).build(centerline, path(400), "water", true);
ChannelProfile profile = builder(1D, 1D).build(centerline, "water", true);
assertEquals(3D * 1.6D, profile.halfWidth()[399], 0.05D);
assertEquals(3D, profile.halfWidth()[300], 1.0E-9D);
@@ -47,7 +47,7 @@ public class ChannelProfileBuilderTest {
@Test
public void policyMultipliersScaleWidthDepthAndBank() {
SurfaceCenterline centerline = straight(400);
ChannelProfile profile = builder(2D, 0.5D).build(centerline, path(400), "water", false);
ChannelProfile profile = builder(2D, 0.5D).build(centerline, "water", false);
assertEquals(6D, profile.halfWidth()[200], 1.0E-9D);
assertEquals(1.5D, profile.depth()[200], 1.0E-9D);
@@ -158,7 +158,7 @@ public class ErosionFieldCompilerTest {
List<HydrologyPoint> path = List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(stations - 1, 0, 0));
SurfaceCenterline centerline = SurfaceCenterline.densify(path);
ChannelProfile channel = new ChannelProfileBuilder(surface, sampler, CONSTANT_GEOMETRY)
.build(centerline, path, "water", terminal == SurfaceTerminal.OCEAN_MOUTH);
.build(centerline, "water", terminal == SurfaceTerminal.OCEAN_MOUTH);
ValleyProfile valley = new ValleyProfileSolver(surface, sampler, SEA_LEVEL, 64)
.solve(centerline, channel, terminal, terminalHead);
assertNull(valley.rejection());
@@ -0,0 +1,141 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydraulicSegment;
import art.arcane.iris.engine.hydrology.HydrologyCandidateRejection;
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
import art.arcane.iris.engine.hydrology.HydrologyGeometrySampler;
import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.engine.hydrology.HydrologyPoint;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSample;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler;
import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class SurfaceCourseBuilderTest {
private static final int SEA_LEVEL = 60;
private static final long COURSE_ID = 4242L;
private static final HydrologyGeometrySampler CONSTANT_GEOMETRY = request -> switch (request.field()) {
case SURFACE_WIDTH -> 6;
case SURFACE_DEPTH -> 3;
default -> request.minimum();
};
@Test
public void slopedCourseToTheSeaProducesNonRisingLabelledSegmentsEndingAtSeaLevel() {
HydrologyTerrainSampler sampler = (int x, int z) -> x >= 260
? HydrologyTerrainSample.ocean(50, "ocean")
: HydrologyTerrainSample.openLand(110 - x / 6, 0D, "land");
SurfaceCourseResult result = builder(sampler).build(
7L,
COURSE_ID,
"water",
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(140, 0, 0), new HydrologyPoint(280, 0, 0)),
SurfaceTerminal.OCEAN_MOUTH,
SEA_LEVEL
);
assertNull(result.rejection());
List<HydraulicSegment> segments = result.segments();
assertTrue(segments.size() > 3);
assertEquals(0, segments.getFirst().start().x());
assertEquals(SEA_LEVEL, result.lastHead());
assertEquals(SEA_LEVEL, segments.getLast().downstreamHeadY());
int previous = Integer.MAX_VALUE;
for (HydraulicSegment segment : segments) {
assertEquals(COURSE_ID, segment.courseId());
assertTrue(segment.upstreamHeadY() <= previous);
assertTrue(segment.downstreamHeadY() <= segment.upstreamHeadY());
assertTrue(segment.type().isSurface());
previous = segment.downstreamHeadY();
}
for (int index = 1; index < segments.size(); index++) {
HydraulicSegment before = segments.get(index - 1);
HydraulicSegment after = segments.get(index);
assertEquals(before.end().x(), after.start().x());
assertEquals(before.end().z(), after.start().z());
assertEquals(before.downstreamHeadY(), after.upstreamHeadY());
}
assertTrue(result.pathEnd().x() >= 250);
assertTrue(result.lastWidth() >= 6);
}
@Test
public void flatCourseIsOneSurfacePool() {
HydrologyTerrainSampler sampler = (int x, int z) -> HydrologyTerrainSample.openLand(80, 0D, "land");
SurfaceCourseResult result = builder(sampler).build(
7L,
COURSE_ID,
"water",
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)),
SurfaceTerminal.SINKHOLE,
40
);
assertNull(result.rejection());
assertEquals(1, result.segments().size());
assertEquals(HydrologyFeatureType.SURFACE_POOL, result.segments().getFirst().type());
assertEquals(79, result.lastHead());
assertEquals(201, result.segments().getFirst().centerline().size());
}
@Test
public void cliffProducesAWaterfallSegment() {
HydrologyTerrainSampler sampler = (int x, int z) -> HydrologyTerrainSample.openLand(x < 100 ? 100 : 90, 0D, "land");
SurfaceCourseResult result = builder(sampler).build(
7L,
COURSE_ID,
"water",
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)),
SurfaceTerminal.SINKHOLE,
40
);
assertNull(result.rejection());
assertEquals(3, result.segments().size());
assertEquals(HydrologyFeatureType.SURFACE_POOL, result.segments().get(0).type());
assertEquals(HydrologyFeatureType.WATERFALL, result.segments().get(1).type());
assertEquals(10, result.segments().get(1).drop());
assertEquals(HydrologyFeatureType.SURFACE_POOL, result.segments().get(2).type());
}
@Test
public void gentleSlopeProducesRifflesAndSteepSlopeProducesCascades() {
HydrologyTerrainSampler gentle = (int x, int z) -> HydrologyTerrainSample.openLand(120 - x / 10, 0D, "land");
SurfaceCourseResult gentleResult = builder(gentle).build(7L, COURSE_ID, "water",
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), SurfaceTerminal.SINKHOLE, 40);
HydrologyTerrainSampler steep = (int x, int z) -> HydrologyTerrainSample.openLand(300 - x, 0D, "land");
SurfaceCourseResult steepResult = builder(steep).build(7L, COURSE_ID, "water",
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), SurfaceTerminal.SINKHOLE, 40);
assertNull(gentleResult.rejection());
assertNull(steepResult.rejection());
assertTrue(gentleResult.segments().stream().anyMatch(segment -> segment.type() == HydrologyFeatureType.RIFFLE));
assertTrue(gentleResult.segments().stream().noneMatch(segment -> segment.type() == HydrologyFeatureType.CASCADE));
assertTrue(steepResult.segments().stream().anyMatch(segment -> segment.type() == HydrologyFeatureType.CASCADE));
}
@Test
public void submergedCourseIsRejected() {
HydrologyTerrainSampler sampler = (int x, int z) -> HydrologyTerrainSample.openLand(50, 0D, "land");
SurfaceCourseResult result = builder(sampler).build(
7L,
COURSE_ID,
"water",
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)),
SurfaceTerminal.OCEAN_MOUTH,
SEA_LEVEL
);
assertEquals(HydrologyCandidateRejection.COURSE_TOO_SHORT, result.rejection());
assertTrue(result.segments().isEmpty());
}
private static SurfaceCourseBuilder builder(HydrologyTerrainSampler sampler) {
return new SurfaceCourseBuilder(HydrologyPlannerSettings.defaults().surface(), sampler, CONSTANT_GEOMETRY, SEA_LEVEL, 64);
}
}
@@ -0,0 +1,145 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydraulicSegment;
import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
import art.arcane.iris.engine.hydrology.HydrologyGeometrySampler;
import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.engine.hydrology.HydrologyPoint;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSample;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler;
import art.arcane.iris.engine.hydrology.RiverCourse;
import art.arcane.iris.engine.hydrology.RiverCourseType;
import art.arcane.iris.engine.hydrology.RiverFootprint;
import org.junit.Test;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.OptionalLong;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class SurfaceFootprintCompilerTest {
private static final int SEA_LEVEL = 60;
private static final HydrologyGeometrySampler CONSTANT_GEOMETRY = request -> switch (request.field()) {
case SURFACE_WIDTH -> 6;
case SURFACE_DEPTH -> 3;
default -> request.minimum();
};
@Test
public void compiledLayersDescribeAContainedCarveOnlyChannelWithShoreAndBank() {
HydrologyTerrainSampler sampler = (int x, int z) -> x >= 150 && z > 8
? HydrologyTerrainSample.ocean(50, "ocean")
: HydrologyTerrainSample.openLand(80 + Math.max(0, -z) / 2, 0D, "land");
RiverCourse course = course(List.of(
segment(1L, HydrologyFeatureType.SURFACE_POOL, 79, 79, points(0, 60, 79)),
segment(2L, HydrologyFeatureType.RIFFLE, 79, 78, List.of(new HydrologyPoint(60, 79, 0), new HydrologyPoint(61, 78, 0))),
segment(3L, HydrologyFeatureType.SURFACE_POOL, 78, 78, points(61, 140, 78))
));
SurfaceFootprint footprint = compiler(sampler).compile(course);
assertEquals(0, footprint.uncontainedWetCells());
assertFalse(footprint.columns().isEmpty());
HashSet<Long> keys = new HashSet<>();
for (SurfaceLayerColumn column : footprint.columns()) {
assertTrue(keys.add(RiverFootprint.pack(column.x(), column.z())));
HydrologyColumnLayer layer = column.layer();
assertTrue(layer.bedY() <= column.terrain().naturalHeight());
assertEquals(layer.fluidHeadY(), layer.ceilingY());
assertEquals(1L, layer.feature().courseId());
assertTrue(layer.feature().type().isSurface());
if (layer.channel()) {
assertTrue(layer.fluidOwned());
assertTrue(layer.connectedFluid());
assertTrue(layer.terrainOwned());
assertTrue(layer.fluidHeadY() < column.terrain().naturalHeight());
assertTrue(layer.bedY() < layer.fluidHeadY());
} else {
assertTrue(layer.grading());
assertTrue(layer.terrainOwned());
assertFalse(layer.fluidOwned());
assertEquals(layer.bedY(), layer.fluidHeadY());
}
}
SurfaceLayerColumn center = column(footprint, 30, 0);
assertNotNull(center);
assertTrue(center.layer().channel());
assertEquals(79, center.layer().fluidHeadY());
assertTrue(center.layer().feature().source() == false);
SurfaceLayerColumn source = column(footprint, 0, 0);
assertNotNull(source);
assertTrue(source.layer().feature().source());
SurfaceLayerColumn shore = column(footprint, 30, 4);
assertNotNull(shore);
assertTrue(shore.layer().shore());
assertEquals("land", shore.layer().shoreBiomeKey());
SurfaceLayerColumn bank = column(footprint, 30, -8);
assertNotNull(bank);
assertTrue(bank.layer().grading());
assertFalse(bank.layer().shore());
assertNull(column(footprint, 160, 12));
}
@Test
public void mouthOverTheOceanPublishesOnlyANonOwningApron() {
HydrologyTerrainSampler sampler = (int x, int z) -> x >= 100
? HydrologyTerrainSample.ocean(50, "ocean")
: HydrologyTerrainSample.openLand(70, 0D, "land");
RiverCourse course = course(List.of(
segment(1L, HydrologyFeatureType.SURFACE_POOL, 69, 69, points(0, 96, 69)),
segment(2L, HydrologyFeatureType.WATERFALL, 69, SEA_LEVEL, List.of(new HydrologyPoint(96, 69, 0), new HydrologyPoint(97, SEA_LEVEL, 0))),
segment(3L, HydrologyFeatureType.MOUTH, SEA_LEVEL, SEA_LEVEL, points(97, 112, SEA_LEVEL))
));
SurfaceFootprint footprint = compiler(sampler).compile(course);
int aprons = 0;
for (SurfaceLayerColumn column : footprint.columns()) {
if (column.terrain().ocean()) {
assertTrue(column.layer().oceanApron());
assertFalse(column.layer().terrainOwned());
assertFalse(column.layer().fluidOwned());
assertEquals(HydrologyFeatureType.MOUTH, column.layer().feature().type());
aprons++;
} else {
assertFalse(column.layer().oceanApron());
}
}
assertTrue(aprons > 0);
assertNull(column(footprint, 111, 0));
}
private static SurfaceLayerColumn column(SurfaceFootprint footprint, int x, int z) {
for (SurfaceLayerColumn column : footprint.columns()) {
if (column.x() == x && column.z() == z) {
return column;
}
}
return null;
}
private static SurfaceFootprintCompiler compiler(HydrologyTerrainSampler sampler) {
return new SurfaceFootprintCompiler(HydrologyPlannerSettings.defaults(), sampler, CONSTANT_GEOMETRY);
}
private static RiverCourse course(List<HydraulicSegment> segments) {
return new RiverCourse(1L, RiverCourseType.SURFACE, OptionalLong.of(9L), OptionalLong.of(8L), "water", 1, List.of(), segments);
}
private static HydraulicSegment segment(long id, HydrologyFeatureType type, int upstream, int downstream, List<HydrologyPoint> centerline) {
return new HydraulicSegment(id, 1L, type, upstream, downstream, 6, 3, false, false, centerline);
}
private static List<HydrologyPoint> points(int fromX, int toX, int y) {
ArrayList<HydrologyPoint> points = new ArrayList<>();
for (int x = fromX; x <= toX; x++) {
points.add(new HydrologyPoint(x, y, 0));
}
return points;
}
}
@@ -162,7 +162,7 @@ public class ValleyProfileSolverTest {
private static ChannelProfile channel(int stations, HydrologyTerrainSampler sampler) {
return new ChannelProfileBuilder(HydrologyPlannerSettings.defaults().surface(), sampler, CONSTANT_GEOMETRY)
.build(straight(stations), path(stations), "water", false);
.build(straight(stations), "water", false);
}
private static SurfaceCenterline straight(int stations) {