diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyColumnSample.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyColumnSample.java index 6e71a278f..b1e8e7296 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyColumnSample.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyColumnSample.java @@ -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() { diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompiler.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompiler.java index 6e376eaf6..f0e371c5e 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompiler.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompiler.java @@ -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 courseFootprints; private final LinkedHashMap validationCourseRasters; private final Map rasterStencils; + private final SurfaceFootprintCompiler surfaceCompiler; + private final LinkedHashMap 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 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.of(), new Long2ObjectOpenHashMap<>()) ); } ArrayList courseRasters = new ArrayList<>(courses.size()); - ArrayList surfaceSweeps = new ArrayList<>(); + ArrayList surfaces = new ArrayList<>(); Long2ObjectOpenHashMap 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 columns = new Long2ObjectLinkedOpenHashMap<>(); - List 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 columns = new Long2ObjectLinkedOpenHashMap<>(); - List 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 columns, - List surfaceSweeps + SurfaceFootprint surface ) { } @@ -3228,17 +3245,23 @@ final class HydrologyFootprintCompiler { } private final class SurfaceRasterIndex implements HydrologyCaveVoxelViewFactory.PlannedSurface { - private final Long2ObjectOpenHashMap> edgesByChunk; - private final Long2ObjectOpenHashMap rawSurfaceColumns; + private final Long2ObjectOpenHashMap surfaceColumns; private final Long2IntOpenHashMap validationNaturalHeights; - private final Long2IntOpenHashMap resolvedHeights; private SurfaceRasterIndex( - List surfaceSweeps, + List footprints, Long2ObjectOpenHashMap validationSamples ) { - this.edgesByChunk = new Long2ObjectOpenHashMap<>(); - this.rawSurfaceColumns = new Long2ObjectOpenHashMap<>(); + Long2ObjectLinkedOpenHashMap 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 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 (!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()); + if (validationNaturalHeights.containsKey(packed)) { + return validationNaturalHeights.get(packed); } - - 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 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 - ); + return naturalHeight; } 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) { - 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) { + 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 + "."); } - 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 projectionsAt(int x, int z) { - ArrayList edges = edgesByChunk.get(RiverFootprint.pack(x >> 4, z >> 4)); - if (edges == null) { - return List.of(); - } - IdentityHashMap 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 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 edges = edgesByChunk.get(chunkKey); - if (edges == null) { - edges = new ArrayList<>(); - edgesByChunk.put(chunkKey, edges); - } - edges.add(edge); - } - } - } } private static final class MutableColumn { diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java index 276c557a0..fa8708b9d 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java @@ -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 diagnostics, - HydrologyFootprintCompiler footprintCompiler - ) { - ArrayList 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 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 courses, - HydrologyFootprintCompiler footprintCompiler, - List 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, - 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() - ); + 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(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 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 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); + int outletHead = outletHead(outlet); + int terminalHead = surfaceSinkhole ? Math.addExact(outletHead, 1) : outletHead; + SurfaceCourseResult result = surfaceCourseBuilder.build( + worldSeed, + courseId, + profileKey, + path.points(), + terminal, + terminalHead + ); + if (!result.accepted()) { + return SurfaceCourseBuild.rejected(result.rejection()); } + ArrayList 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(), diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilder.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilder.java index ae0c2cbcf..afb713c63 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilder.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilder.java @@ -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 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(); diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseBuilder.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseBuilder.java new file mode 100644 index 000000000..e8c334a30 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseBuilder.java @@ -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 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 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 + ); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseResult.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseResult.java new file mode 100644 index 000000000..2d92b9803 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseResult.java @@ -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 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")); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFeatureRefs.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFeatureRefs.java new file mode 100644 index 000000000..7a2087d35 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFeatureRefs.java @@ -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 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) { + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprint.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprint.java new file mode 100644 index 000000000..9cc8cc298 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprint.java @@ -0,0 +1,18 @@ +package art.arcane.iris.engine.hydrology.surface; + +import java.util.List; +import java.util.Objects; + +public record SurfaceFootprint(List 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(); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprintCompiler.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprintCompiler.java new file mode 100644 index 000000000..d0ecd1b38 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprintCompiler.java @@ -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 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 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 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 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 exposed) { + ArrayList 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 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 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 points, int[] segmentIndex, int[] head, int exposedStations) { + private int count() { + return points.size(); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceLayerColumn.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceLayerColumn.java new file mode 100644 index 000000000..d0105f257 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceLayerColumn.java @@ -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"); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceSegmentLabeler.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceSegmentLabeler.java new file mode 100644 index 000000000..8cafe427b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceSegmentLabeler.java @@ -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 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 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 segments = new ArrayList<>(runs.size()); + for (Run run : runs) { + int startStation = run.start; + int endStation = run.end + 1; + ArrayList 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; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ValleyProfile.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ValleyProfile.java index b0fd320e0..f8b9a7403 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ValleyProfile.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ValleyProfile.java @@ -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); } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilderTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilderTest.java index 0c461f687..8610f270a 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilderTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilderTest.java @@ -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); diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompilerTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompilerTest.java index 12a360f0f..ead2bad00 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompilerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompilerTest.java @@ -158,7 +158,7 @@ public class ErosionFieldCompilerTest { List 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()); diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseBuilderTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseBuilderTest.java new file mode 100644 index 000000000..85c6192a5 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseBuilderTest.java @@ -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 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); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprintCompilerTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprintCompilerTest.java new file mode 100644 index 000000000..b39bad0e1 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprintCompilerTest.java @@ -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 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 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 centerline) { + return new HydraulicSegment(id, 1L, type, upstream, downstream, 6, 3, false, false, centerline); + } + + private static List points(int fromX, int toX, int y) { + ArrayList points = new ArrayList<>(); + for (int x = fromX; x <= toX; x++) { + points.add(new HydrologyPoint(x, y, 0)); + } + return points; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ValleyProfileSolverTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ValleyProfileSolverTest.java index b1ff3b144..ca12b1b42 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ValleyProfileSolverTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ValleyProfileSolverTest.java @@ -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) {