From bf12f30d266d104d2add92080c7943f879170af5 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Thu, 10 Sep 2026 23:07:46 -0400 Subject: [PATCH] Hydrology fixes --- .../iris/modded/ModdedBlockResolution.java | 9 + .../ModdedBlockResolutionContractTest.java | 13 + .../core/pack/PackHydrologyValidator.java | 31 +- .../core/pack/PackRiverBank3DValidator.java | 53 ++ .../art/arcane/iris/engine/IrisComplex.java | 93 ++- .../actuator/IrisTerrainNormalActuator.java | 22 +- .../iris/engine/decorator/DecoratorCore.java | 33 +- .../engine/decorator/FloatingDecorator.java | 6 +- .../decorator/IrisShoreLineDecorator.java | 9 +- .../iris/engine/decorator/IrisSugarCane.java | 99 +++ .../decorator/IrisSurfaceDecorator.java | 1 + .../engine/framework/render/IrisRenderer.java | 2 +- .../hydrology/CaveCandidateBuilder.java | 25 +- .../hydrology/CaveCandidateSpanBuilder.java | 43 +- .../engine/hydrology/CaveSurfaceOpening.java | 9 +- .../hydrology/FootprintMutableColumn.java | 2 +- .../hydrology/HydraulicChannelProfile.java | 87 ++ .../engine/hydrology/HydraulicSegment.java | 10 +- .../hydrology/HydrologyCandidateKind.java | 4 +- .../HydrologyCandidateRejection.java | 3 + .../hydrology/HydrologyCaveCourseFilter.java | 100 ++- .../hydrology/HydrologyChannelGeometry.java | 10 +- .../hydrology/HydrologyColumnSample.java | 14 +- .../hydrology/HydrologyColumnSnapshot.java | 20 + .../hydrology/HydrologyCrossTileResolver.java | 62 +- .../HydrologyCrossTileSurfaceAdmission.java | 2 +- .../HydrologyFeatureSitePlanner.java | 6 +- .../hydrology/HydrologyFootprintCompiler.java | 155 +++- .../HydrologyFootprintRasterizer.java | 40 +- .../HydrologyOceanBoundaryRefiner.java | 19 +- .../hydrology/HydrologyOceanReceiver.java | 258 ++++++ .../hydrology/HydrologyOutletPlanner.java | 13 +- .../engine/hydrology/HydrologyPlanner.java | 4 + .../hydrology/HydrologyPlannerSettings.java | 66 +- .../HydrologyRegionalConnectivity.java | 167 ++++ .../HydrologyRegionalCourseGraph.java | 65 ++ .../hydrology/HydrologyRegionalFalls.java | 135 ++++ .../hydrology/HydrologyRegionalFlow.java | 53 ++ .../hydrology/HydrologyRegionalGraph.java | 202 +++++ .../HydrologyRegionalHydraulics.java | 155 ++++ .../HydrologyRegionalMorphology.java | 166 ++++ .../hydrology/HydrologyRegionalNetwork.java | 38 + .../hydrology/HydrologyRegionalPlanner.java | 755 ++++++++++++++++++ .../HydrologyRegionalProtection.java | 127 +++ .../hydrology/HydrologyRegionalRoute.java | 637 +++++++++++++++ .../HydrologyRegionalSourceTrials.java | 97 +++ .../HydrologyRegionalTerrainRefiner.java | 435 ++++++++++ .../HydrologyRegionalTributaries.java | 231 ++++++ .../hydrology/HydrologySegmentBuilder.java | 27 +- .../HydrologySurfaceCoursePlanner.java | 7 +- .../HydrologySurfaceDropBankBounds.java | 98 +++ .../hydrology/HydrologySurfaceDropRaster.java | 180 +++++ .../hydrology/HydrologySurfaceProfiles.java | 14 + .../hydrology/HydrologyTerrainSampler.java | 5 + .../iris/engine/hydrology/HydrologyTile.java | 20 +- .../engine/hydrology/HydrologyTileCache.java | 29 + .../hydrology/HydrologyTributaryPlanner.java | 22 +- .../engine/hydrology/SourceSelection.java | 26 +- .../hydrology/StudioHydrologyTileStore.java | 2 +- .../IrisHydrologyRoutingTerrainSampler.java | 22 +- .../runtime/IrisHydrologyRuntime.java | 37 +- .../hydrology/surface/ErosionField.java | 22 +- .../surface/ErosionFieldCompiler.java | 202 ++++- .../hydrology/surface/SurfaceBankSupport.java | 103 +++ .../hydrology/surface/SurfaceBendProfile.java | 77 ++ .../hydrology/surface/SurfaceBounds.java | 23 + .../surface/SurfaceCellAdmission.java | 4 + .../surface/SurfaceCourseBuilder.java | 59 +- .../surface/SurfaceExcavationMetrics.java | 165 ++++ .../hydrology/surface/SurfaceFeatureRefs.java | 9 +- .../hydrology/surface/SurfaceFootprint.java | 11 +- .../surface/SurfaceFootprintCompiler.java | 208 ++++- .../hydrology/surface/SurfaceLayerColumn.java | 6 +- .../surface/SurfaceRasterContext.java | 20 + .../hydrology/surface/SurfaceRunBoundary.java | 26 + .../surface/SurfaceSegmentLabeler.java | 14 +- .../surface/ValleyProfileSolver.java | 95 ++- .../MantleHydrologyCaveVoxelView.java | 3 +- .../components/MantleHydrologyComponent.java | 29 +- .../engine/object/IrisRiverBank3DConfig.java | 76 ++ .../object/IrisRiverExcavationConfig.java | 29 + .../object/IrisRiverGeometryConfig.java | 3 + .../object/IrisRiverRegionalConfig.java | 55 ++ .../engine/object/IrisRiverRoutingConfig.java | 3 + .../object/IrisSurfaceRiverBankConfig.java | 3 + .../object/IrisSurfaceRiverErosionConfig.java | 4 +- .../terrain/HydrologyBankTerrainRuntime.java | 299 +++++++ .../pack/PackRiverBank3DValidatorTest.java | 40 + .../core/project/IrisRiverSchemaTest.java | 5 + .../project/SchemaBuilderIdentityTest.java | 2 +- ...risTerrainNormalActuatorHydrologyTest.java | 88 ++ .../engine/decorator/DecoratorCoreTest.java | 4 +- .../decorator/IrisShoreLineDecoratorTest.java | 34 + .../decorator/IrisSugarCanePlacementTest.java | 138 ++++ .../engine/decorator/IrisSugarCaneTest.java | 181 +++++ .../HydrologyCaveCourseFilterTest.java | 54 +- .../HydrologyCoastalDropReservoirTest.java | 69 ++ ...ydrologyCrossTileSurfaceAdmissionTest.java | 11 + .../HydrologyFootprintCompilerTest.java | 139 ++-- .../engine/hydrology/HydrologyModelTest.java | 39 +- .../HydrologyMouthDropReservoirTest.java | 79 ++ .../HydrologyOceanBoundaryRefinerTest.java | 31 +- .../hydrology/HydrologyOceanReceiverTest.java | 152 ++++ .../HydrologyOutletBoundaryPolicyTest.java | 71 ++ .../hydrology/HydrologyPlanHashTest.java | 4 +- .../HydrologyPlannerConfinementTest.java | 2 +- ...CrossTileCaveAdmissionIntegrationTest.java | 2 +- .../HydrologyPlannerGlobalInvariantTest.java | 7 +- .../HydrologyPlannerSeaCaveTest.java | 2 +- .../HydrologyPlannerSettingsTest.java | 32 +- .../HydrologyPlannerSurfacePoolTest.java | 2 +- .../hydrology/HydrologyPlannerTest.java | 10 +- .../HydrologyPlannerTributaryTest.java | 86 +- .../HydrologyPlannerUndergroundKnobsTest.java | 4 +- .../HydrologyRegionalAdaptiveBendsTest.java | 101 +++ .../HydrologyRegionalBankSupportTest.java | 157 ++++ .../hydrology/HydrologyRegionalCacheTest.java | 85 ++ ...drologyRegionalCandidateSelectionTest.java | 202 +++++ .../HydrologyRegionalConnectivityTest.java | 138 ++++ .../HydrologyRegionalCourseGraphTest.java | 67 ++ .../hydrology/HydrologyRegionalFallsTest.java | 128 +++ .../HydrologyRegionalFloodedMouthTest.java | 124 +++ .../hydrology/HydrologyRegionalGraphTest.java | 201 +++++ .../HydrologyRegionalHydraulicsTest.java | 40 + .../HydrologyRegionalMeanderScaleTest.java | 140 ++++ .../HydrologyRegionalMorphologyTest.java | 125 +++ .../HydrologyRegionalMouthRefinementTest.java | 234 ++++++ .../HydrologyRegionalPlannerTest.java | 176 ++++ .../HydrologyRegionalSkippedWaypointTest.java | 169 ++++ .../HydrologyRegionalSourceTrialsTest.java | 107 +++ .../HydrologyRegionalSurfacePolicyTest.java | 31 +- .../HydrologyRegionalTerrainRefinerTest.java | 473 +++++++++++ .../HydrologyRegionalTributariesTest.java | 123 +++ .../HydrologyRegionalWidthAdaptationTest.java | 209 +++++ .../HydrologyRegionalWidthSupportTest.java | 201 +++++ .../hydrology/HydrologyRouteLengthsTest.java | 2 +- .../HydrologyStyledGeometryTest.java | 2 +- .../HydrologySurfaceDropRasterTest.java | 437 ++++++++++ .../HydrologySurfaceProfilesTest.java | 14 +- .../HydrologyTerrainSamplerTest.java | 24 + .../hydrology/HydrologyTileCacheTest.java | 56 +- .../StudioHydrologyTileStoreTest.java | 3 +- .../IrisHydrologyPhysicalOceanTest.java | 170 ++++ ...risHydrologyRoutingTerrainSamplerTest.java | 42 +- .../IrisHydrologyRuntimeSettingsTest.java | 2 +- .../surface/ErosionFieldCompilerTest.java | 85 +- .../surface/SurfaceBendProfileTest.java | 194 +++++ .../surface/SurfaceCellAdmissionTest.java | 6 + .../surface/SurfaceCourseBuilderTest.java | 11 +- .../surface/SurfaceExcavationMetricsTest.java | 58 ++ .../surface/SurfaceFootprintCompilerTest.java | 145 +++- .../surface/SurfaceSegmentLabelerTest.java | 56 ++ .../surface/ValleyProfileSolverTest.java | 117 ++- .../MantleHydrologyCaveVoxelViewTest.java | 23 +- .../MantleHydrologyComponentTest.java | 28 +- .../object/IrisRiverConfigurationTest.java | 5 + .../HydrologyBankTerrainRuntimeTest.java | 306 +++++++ probe/build.gradle | 32 +- .../iris/probe/HydrologyBlockProbe.java | 523 ++++++++++++ .../arcane/iris/probe/HydrologyPackProbe.java | 29 +- .../iris/probe/HydrologyPlannerProbe.java | 39 +- .../arcane/iris/probe/RiverTransectProbe.java | 182 ++++- .../art/arcane/iris/probe/StubPlatform.java | 14 +- .../iris/probe/HydrologyBlockProbeTest.java | 235 ++++++ .../iris/probe/HydrologyPackProbeTest.java | 54 +- .../iris/probe/HydrologyPlannerProbeTest.java | 19 + .../iris/probe/RiverTransectProbeTest.java | 58 +- .../iris/probe/StubPlatformStateTest.java | 13 + 168 files changed, 13544 insertions(+), 582 deletions(-) create mode 100644 core/src/main/java/art/arcane/iris/core/pack/PackRiverBank3DValidator.java create mode 100644 core/src/main/java/art/arcane/iris/engine/decorator/IrisSugarCane.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/HydraulicChannelProfile.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyColumnSnapshot.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyOceanReceiver.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRegionalConnectivity.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRegionalCourseGraph.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRegionalFalls.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRegionalFlow.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRegionalGraph.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRegionalHydraulics.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRegionalMorphology.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRegionalNetwork.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRegionalPlanner.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRegionalProtection.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRegionalRoute.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRegionalSourceTrials.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRegionalTerrainRefiner.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRegionalTributaries.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/HydrologySurfaceDropBankBounds.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/HydrologySurfaceDropRaster.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceBankSupport.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceBendProfile.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceBounds.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceExcavationMetrics.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceRasterContext.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceRunBoundary.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisRiverBank3DConfig.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisRiverExcavationConfig.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisRiverRegionalConfig.java create mode 100644 core/src/main/java/art/arcane/iris/engine/terrain/HydrologyBankTerrainRuntime.java create mode 100644 core/src/test/java/art/arcane/iris/core/pack/PackRiverBank3DValidatorTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/decorator/IrisSugarCanePlacementTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/decorator/IrisSugarCaneTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyCoastalDropReservoirTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyMouthDropReservoirTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyOceanReceiverTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyOutletBoundaryPolicyTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRegionalAdaptiveBendsTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRegionalBankSupportTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRegionalCacheTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRegionalCandidateSelectionTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRegionalConnectivityTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRegionalCourseGraphTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRegionalFallsTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRegionalFloodedMouthTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRegionalGraphTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRegionalHydraulicsTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRegionalMeanderScaleTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRegionalMorphologyTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRegionalMouthRefinementTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRegionalPlannerTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRegionalSkippedWaypointTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRegionalSourceTrialsTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRegionalTerrainRefinerTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRegionalTributariesTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRegionalWidthAdaptationTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRegionalWidthSupportTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologySurfaceDropRasterTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTerrainSamplerTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyPhysicalOceanTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceBendProfileTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceExcavationMetricsTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceSegmentLabelerTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/terrain/HydrologyBankTerrainRuntimeTest.java create mode 100644 probe/src/main/java/art/arcane/iris/probe/HydrologyBlockProbe.java create mode 100644 probe/src/test/java/art/arcane/iris/probe/HydrologyBlockProbeTest.java diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockResolution.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockResolution.java index 36331cf55..17c06b69b 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockResolution.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockResolution.java @@ -510,6 +510,15 @@ public final class ModdedBlockResolution { } public static boolean canPlaceOnto(Block mat, Block onto) { + if (mat == Blocks.SUGAR_CANE) { + return onto == Blocks.SUGAR_CANE || onto == Blocks.GRASS_BLOCK || onto == Blocks.DIRT + || onto == Blocks.COARSE_DIRT || onto == Blocks.PODZOL || onto == Blocks.MYCELIUM + || onto == Blocks.ROOTED_DIRT + || onto == Blocks.MOSS_BLOCK || onto == Blocks.PALE_MOSS_BLOCK || onto == Blocks.MUD + || onto == Blocks.MUDDY_MANGROVE_ROOTS || onto == Blocks.SAND || onto == Blocks.RED_SAND + || onto == Blocks.SUSPICIOUS_SAND; + } + if (mat == Blocks.CACTUS) { return onto == Blocks.CACTUS || onto == Blocks.SAND || onto == Blocks.RED_SAND; } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBlockResolutionContractTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBlockResolutionContractTest.java index d346849c1..430c372a1 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBlockResolutionContractTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBlockResolutionContractTest.java @@ -69,6 +69,19 @@ public class ModdedBlockResolutionContractTest { assertTrue(ModdedBlockResolution.isDecorant(Blocks.CACTUS.defaultBlockState())); } + @Test + public void sugarCanePlacementRequiresNativeSubstrate() { + for (Block block : new Block[]{Blocks.SUGAR_CANE, Blocks.GRASS_BLOCK, Blocks.DIRT, Blocks.COARSE_DIRT, + Blocks.PODZOL, Blocks.MYCELIUM, Blocks.ROOTED_DIRT, Blocks.MOSS_BLOCK, Blocks.PALE_MOSS_BLOCK, + Blocks.MUD, Blocks.MUDDY_MANGROVE_ROOTS, Blocks.SAND, Blocks.RED_SAND, Blocks.SUSPICIOUS_SAND}) { + assertTrue(block.toString(), ModdedBlockResolution.canPlaceOnto(Blocks.SUGAR_CANE, block)); + } + for (Block block : new Block[]{Blocks.STONE, Blocks.GRAVEL, Blocks.CLAY, Blocks.DIRT_PATH, + Blocks.FARMLAND, Blocks.AIR, Blocks.WATER}) { + assertFalse(block.toString(), ModdedBlockResolution.canPlaceOnto(Blocks.SUGAR_CANE, block)); + } + } + @Test public void sulfurAndDripstoneTipsReceivePostLoadUpdates() { for (Block block : new Block[]{Blocks.SULFUR_SPIKE, Blocks.POINTED_DRIPSTONE}) { diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackHydrologyValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackHydrologyValidator.java index 60a8233ee..28b116dcb 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackHydrologyValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackHydrologyValidator.java @@ -210,6 +210,10 @@ final class PackHydrologyValidator { } private static void validateGeometry(String path, JSONObject geometry, List errors) { + JSONObject banks3D = nestedObject(geometry, "banks3D", path, errors); + if (banks3D != null) { + PackRiverBank3DValidator.validate(path + ".banks3D", banks3D, errors); + } JSONObject meanders = nestedObject(geometry, "meanders", path, errors); JSONObject surface = nestedObject(geometry, "surface", path, errors); JSONObject underground = nestedObject(geometry, "underground", path, errors); @@ -302,6 +306,25 @@ final class PackHydrologyValidator { PackJsonFieldChecks.validateOptionalDoubleRange(path, routing, "confluenceAttraction", 0D, 1D, errors); PackJsonFieldChecks.validateOptionalDoubleRange(path, routing, "lengthPreference", 0D, 8D, errors); PackJsonFieldChecks.validateOptionalIntegerRange(path, routing, "tributaries", 0, 4, errors); + JSONObject regional = routing.optJSONObject("regional"); + if (routing.has("regional") && regional == null) { + errors.add(path + ".regional must be an object."); + } + if (regional != null) { + String regionalPath = path + ".regional"; + PackJsonFieldChecks.validateOptionalBoolean(regionalPath, regional, "enabled", errors); + PackJsonFieldChecks.validateOptionalIntegerRange(regionalPath, regional, "sampleSpacing", 128, 1024, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(regionalPath, regional, "minimumLength", 256, 32768, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(regionalPath, regional, "maximumTrunks", 1, 8, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(regionalPath, regional, "maximumCachedBasins", 1, 64, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(regionalPath, regional, "maximumCachedStations", 16384, 1048576, errors); + PackJsonFieldChecks.validateOptionalBoolean(regionalPath, regional, "coastalChannels", errors); + PackJsonFieldChecks.validateOptionalDoubleRange(regionalPath, regional, "coastalChannelChance", 0D, 1D, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(regionalPath, regional, "maximumCoastalIncision", 1, 32, errors); + if (Integer.bitCount(integerValue(regional, "sampleSpacing", 256)) != 1) { + errors.add(regionalPath + ".sampleSpacing must be a power of two."); + } + } int tileSize = integerValue(routing, "tileSize", 2048); int sampleSpacing = integerValue(routing, "sampleSpacing", 64); @@ -381,7 +404,7 @@ final class PackHydrologyValidator { boolean surfaceEnabled = booleanValue(surface, "enabled", true); if (surfaceEnabled && budget.maximumExpectedSources() == 0) { warnings.add(path - + " has no natural source budget; only qualifying REQUIRED_HEADWATER cells can admit sources."); + + " has no local natural source budget; local sources require qualifying REQUIRED_HEADWATER cells."); } if (surfaceEnabled && sources != null) { double minimumElevation = doubleValue(sources, "minimumElevation", 88D); @@ -440,6 +463,12 @@ final class PackHydrologyValidator { } private static void validateSurfaceBanks(String path, JSONObject banks, List errors) { + JSONObject excavation = nestedObject(banks, "excavation", path, errors); + if (excavation != null) { + PackJsonFieldChecks.validateOptionalIntegerRange(path + ".excavation", excavation, "maximumDepth", 0, 64, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path + ".excavation", excavation, "maximumWidth", 1, 64, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path + ".excavation", excavation, "maximumVolumePerBlock", 0, 8192, errors); + } PackJsonFieldChecks.validateOptionalDoubleRange(path, banks, "shoreWidth", 0D, 16D, errors); PackJsonFieldChecks.validateOptionalDoubleRange(path, banks, "blendSlope", 0.5D, 12D, errors); PackJsonFieldChecks.validateOptionalIntegerRange(path, banks, "minimumBlendWidth", 1, 64, errors); diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackRiverBank3DValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackRiverBank3DValidator.java new file mode 100644 index 000000000..f7f882adb --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/pack/PackRiverBank3DValidator.java @@ -0,0 +1,53 @@ +package art.arcane.iris.core.pack; + +import art.arcane.iris.engine.object.IrisRiverBank3DConfig; +import art.arcane.volmlib.util.json.JSONObject; +import com.google.gson.Gson; +import com.google.gson.JsonParseException; + +import java.math.BigDecimal; +import java.util.List; + +final class PackRiverBank3DValidator { + private static final Gson GSON = new Gson(); + + private PackRiverBank3DValidator() { + } + + static void validate(String path, JSONObject banks, List errors) { + int previousErrors = errors.size(); + PackJsonFieldChecks.validateOptionalBoolean(path, banks, "enabled", errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, banks, "amplitude", 0D, 32D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, banks, "horizontalScale", 4D, 4096D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, banks, "verticalScale", 4D, 256D, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, banks, "maximumOverhang", 1, 16, errors); + if (banks.has("seed")) { + Object seed = banks.opt("seed"); + try { + if (!(seed instanceof Number)) { + throw new NumberFormatException(); + } + new BigDecimal(seed.toString()).longValueExact(); + } catch (NumberFormatException | ArithmeticException invalid) { + errors.add(path + ".seed must be a 64-bit integer."); + } + } + JSONObject normalized = new JSONObject(banks.toString()); + if (banks.has("densityStyle")) { + Object style = banks.opt("densityStyle"); + if (style instanceof String reference && !reference.isBlank()) { + normalized.remove("densityStyle"); + } else if (!(style instanceof JSONObject)) { + errors.add(path + ".densityStyle must be an object or snippet reference."); + } + } + if (errors.size() != previousErrors) { + return; + } + try { + GSON.fromJson(normalized.toString(), IrisRiverBank3DConfig.class).validate(); + } catch (JsonParseException | IllegalArgumentException invalid) { + errors.add(path + ": " + invalid.getMessage()); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java index dcf043bf1..b78fd483a 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java @@ -30,6 +30,7 @@ import art.arcane.iris.engine.history.TransitionGenerationPlan; import art.arcane.iris.engine.history.TerrainBoundarySignature; import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; import art.arcane.iris.engine.hydrology.HydrologyColumnSample; +import art.arcane.iris.engine.hydrology.HydrologyColumnSnapshot; import art.arcane.iris.engine.hydrology.HydrologyFeatureType; import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyNaturalSample; import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime; @@ -53,10 +54,12 @@ import art.arcane.iris.engine.object.IrisDeepFluidConfig; import art.arcane.iris.engine.object.IrisSurfacePoolConfig; import art.arcane.iris.engine.object.IrisHydrology; import art.arcane.iris.engine.object.IrisRiverHydrology; +import art.arcane.iris.engine.object.IrisRiverBank3DConfig; import art.arcane.iris.engine.object.IrisRiverProfile; import art.arcane.iris.engine.object.IrisShapedGeneratorStyle; import art.arcane.iris.engine.terrain.Terrain3DColumn; import art.arcane.iris.engine.terrain.Terrain3DRuntime; +import art.arcane.iris.engine.terrain.HydrologyBankTerrainRuntime; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.PlatformBiome; @@ -97,8 +100,8 @@ import java.nio.file.Path; import java.util.function.BiFunction; @Data -@EqualsAndHashCode(exclude = {"data", "gridBoundsCache", "sharedCornerBounds", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "hydrologyRuntime", "imageMapRuntime", "terrainEngine", "terrain3D"}) -@ToString(exclude = {"data", "gridBoundsCache", "sharedCornerBounds", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "hydrologyRuntime", "imageMapRuntime", "terrainEngine", "terrain3D"}) +@EqualsAndHashCode(exclude = {"data", "gridBoundsCache", "sharedCornerBounds", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "hydrologyRuntime", "imageMapRuntime", "terrainEngine", "terrain3D", "hydrologyBanks3D"}) +@ToString(exclude = {"data", "gridBoundsCache", "sharedCornerBounds", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "hydrologyRuntime", "imageMapRuntime", "terrainEngine", "terrain3D", "hydrologyBanks3D"}) public class IrisComplex implements DataProvider { private static final NoiseBounds ZERO_NOISE_BOUNDS = new NoiseBounds(0D, 0D); private static final AtomicLong lastBoundsFailureLog = new AtomicLong(0L); @@ -157,6 +160,7 @@ public class IrisComplex implements DataProvider { private final transient Engine terrainEngine; @Getter(AccessLevel.NONE) private transient Terrain3DRuntime terrain3D; + private transient HydrologyBankTerrainRuntime hydrologyBanks3D; private ProceduralStream unblendedNaturalHeightStream; private final ResolvedTerrainProvider resolvedTerrain; private ProceduralStream placementHeightStream; @@ -408,9 +412,22 @@ public class IrisComplex implements DataProvider { () -> engine.getPlatformHooks().isMainThread() )); hydrologyRuntime.setNeighbourPrefetchEnabled(!engine.isStudio()); + IrisRiverBank3DConfig banks3D = configuredHydrology.getRivers().getGeometry().getBanks3D(); + banks3D.validate(); + if (configuredHydrology.getRivers().isEnabled() + && configuredHydrology.getRivers().getSurface().isEnabled() + && banks3D.isEnabled() && banks3D.getAmplitude() > 0D) { + hydrologyBanks3D = new HydrologyBankTerrainRuntime( + new HydrologyBankTerrainRuntime.Sources( + (x, z) -> naturalBankTerrainColumn(x, z, engine.getHeight()), this::sampleHydrologySnapshot), + new HydrologyBankTerrainRuntime.Options(engine.getSeedManager().getBodies(), engine.getHeight(), + Math.max(4096, cacheSize), data, banks3D)); + } } - heightStream = ProceduralStream.ofDouble((x, z) -> resolveHydrologyTerrainHeight(x, z)) + ProceduralStream cachedHeightStream = ProceduralStream.ofDouble((x, z) -> resolveHydrologyTerrainHeight(x, z)) .cache2DDouble("heightStream", engine, cacheSize); + heightStream = ProceduralStream.ofDouble((x, z) -> terrainEngine.getPlatformHooks().isMainThread() + ? nonblockingTerrainHeight(x, z) : cachedHeightStream.getDouble(x, z)); placementHeightStream = ProceduralStream.ofDouble(this::samplePlacementHeight); roundedHeighteightStream = placementHeightStream.contextInjecting(engine, (c, x, z) -> c.getHeight().getDouble(x, z)) .round(); @@ -578,21 +595,22 @@ public class IrisComplex implements DataProvider { } public boolean hasTerrain3D() { - return terrain3D != null && terrain3D.active(); + return terrain3D != null && terrain3D.active() || hydrologyBanks3D != null; } public Terrain3DColumn terrainColumn(int x, int z, HydrologyColumnSample hydrology) { if (hydrology != null) { HydrologyColumnLayer layer = hydrology.primarySurfaceLayer().orElse(null); if (layer != null && layer.terrainOwned()) { - return null; + return hydrologyBanks3D != null && !layer.channel() && layer.feature().type().isSurface() + ? hydrologyBanks3D.column(x, z) : null; } } return naturalTerrainColumn(x, z); } public Terrain3DColumn terrainColumn(int x, int z) { - if (terrain3D == null || !terrain3D.active()) { + if (!hasTerrain3D()) { return null; } if (terrainEngine != null && terrainEngine.answersFromNaturalTerrain(x, z)) { @@ -601,6 +619,11 @@ public class IrisComplex implements DataProvider { return terrainColumn(x, z, sampleHydrologyColumn(x, z)); } + private Terrain3DColumn naturalBankTerrainColumn(int x, int z, int height) { + Terrain3DColumn natural = naturalTerrainColumn(x, z); + return natural == null ? Terrain3DColumn.unshaped(naturalHeightStream.getDouble(x, z), height) : natural; + } + public boolean isTerrain3DOpening(int x, int y, int z) { Terrain3DColumn column = terrainColumn(x, z); return column != null && y >= column.minY() && y < column.topY() && !column.isSolid(y) @@ -860,7 +883,28 @@ public class IrisComplex implements DataProvider { private double resolveHydrologyTerrainHeight(double x, double z) { HydrologyColumnSample sample = hydrologySample(x, z); - return sample == null ? naturalHeightStream.get(x, z) : sample.terrainHeight(); + if (sample == null) { + return naturalHeightStream.get(x, z); + } + HydrologyColumnLayer layer = sample.primarySurfaceLayerOrNull(); + if (hydrologyBanks3D != null && layer != null && layer.terrainOwned() && !layer.channel()) { + return hydrologyBanks3D.column(blockCoordinate(x), blockCoordinate(z)).topY(); + } + return sample.terrainHeight(); + } + + private double nonblockingTerrainHeight(double x, double z) { + HydrologyColumnSnapshot snapshot = sampleHydrologySnapshot(blockCoordinate(x), blockCoordinate(z)); + if (!snapshot.available() || snapshot.column() == null) { + return naturalHeightStream.getDouble(x, z); + } + HydrologyColumnSample sample = snapshot.column(); + HydrologyColumnLayer layer = sample.primarySurfaceLayerOrNull(); + if (hydrologyBanks3D != null && layer != null && layer.terrainOwned() && !layer.channel()) { + Optional column = hydrologyBanks3D.columnIfReady(blockCoordinate(x), blockCoordinate(z)); + return column.isPresent() ? column.get().topY() : naturalHeightStream.getDouble(x, z); + } + return sample.terrainHeight(); } private double resolveHydrologyDistance(double x, double z) { @@ -919,6 +963,19 @@ public class IrisComplex implements DataProvider { return taperHydrologySample(sample, hydrologyWeight); } + private HydrologyColumnSnapshot sampleHydrologySnapshot(int x, int z) { + if (hydrologyRuntime == null) { + return HydrologyColumnSnapshot.ready(null); + } + double weight = transitionHydrologyWeight(x, z); + if (weight == 0D) { + return HydrologyColumnSnapshot.ready(null); + } + HydrologyColumnSnapshot snapshot = hydrologyRuntime.sampleSnapshot(x, z); + return !snapshot.available() || snapshot.column() == null || weight == 1D ? snapshot + : HydrologyColumnSnapshot.ready(taperHydrologySample(snapshot.column(), weight)); + } + static HydrologyColumnSample taperHydrologySample( HydrologyColumnSample sample, double hydrologyWeight @@ -983,9 +1040,22 @@ public class IrisComplex implements DataProvider { /** Whether the column's hydrology can be sampled without waiting for a plan (see Engine.answersFromNaturalTerrain). */ public boolean isHydrologyPlanned(int x, int z) { - return hydrologyRuntime == null - || transitionHydrologyWeight(x, z) == 0D - || hydrologyRuntime.isPlanned(x, z); + if (hydrologyRuntime == null || transitionHydrologyWeight(x, z) == 0D) { + return true; + } + if (!hydrologyRuntime.isPlanned(x, z)) { + return false; + } + if (hydrologyBanks3D == null || !terrainEngine.getPlatformHooks().isMainThread()) { + return true; + } + HydrologyColumnSnapshot snapshot = sampleHydrologySnapshot(x, z); + if (!snapshot.available()) { + return false; + } + HydrologyColumnLayer layer = snapshot.column() == null ? null : snapshot.column().primarySurfaceLayerOrNull(); + return layer == null || !layer.terrainOwned() || layer.channel() + || hydrologyBanks3D.columnIfReady(x, z).isPresent(); } public ProceduralStream getRawHeightStream() { @@ -1893,6 +1963,9 @@ public class IrisComplex implements DataProvider { } public void close() { + if (hydrologyBanks3D != null) { + hydrologyBanks3D.clear(); + } if (terrain3D != null) { terrain3D.clear(); } diff --git a/core/src/main/java/art/arcane/iris/engine/actuator/IrisTerrainNormalActuator.java b/core/src/main/java/art/arcane/iris/engine/actuator/IrisTerrainNormalActuator.java index 59f05d02a..04923fa12 100644 --- a/core/src/main/java/art/arcane/iris/engine/actuator/IrisTerrainNormalActuator.java +++ b/core/src/main/java/art/arcane/iris/engine/actuator/IrisTerrainNormalActuator.java @@ -140,12 +140,12 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator data, - RNG rng, IrisData irisData) { + RNG rng, IrisData irisData, EngineMantle mantle) { PlatformBlockState bd = decorator.pickBlockData(rng, irisData, realX, realZ); if (bd == null) { return; } + if (!IrisSugarCane.canPlace(bd, data, xf, height + 1, zf, realX, realZ, + mantle == null ? null : mantle.getEngine())) { + return; + } if (IrisSpeleothems.isSpike(bd)) { if (max > 1 && allowsSurface(decorator, data.get(xf, height, zf), irisData)) { @@ -346,7 +369,7 @@ final class DecoratorCore { static int placeFloatingStacked(IrisDecorator decorator, int xf, int zf, int realX, int realZ, int height, int max, Hunk data, - RNG rng, IrisData irisData) { + RNG rng, IrisData irisData, EngineMantle mantle) { int stack = decorator.getHeight(rng, realX, realZ, irisData); if (decorator.isScaleStack()) { stack = Math.min((int) Math.ceil((double) max * ((double) stack / 100)), decorator.getAbsoluteMaxStack()); @@ -368,6 +391,10 @@ final class DecoratorCore { if (bd == null) { break; } + if (!IrisSugarCane.canPlace(bd, data, xf, h, zf, realX, realZ, + mantle == null ? null : mantle.getEngine())) { + break; + } if (IrisSpeleothems.isSpike(bd)) { if (!allowsSurface(decorator, data.get(xf, height, zf), irisData) || !IrisSpeleothems.canPlace(bd, data, xf, zf, h, true, false)) { @@ -450,7 +477,7 @@ final class DecoratorCore { if (!B.canPlaceOnto(decorator, surface)) { return false; } - return IrisSpeleothems.isSturdy(surface, true); + return IrisSugarCane.isSugarCane(decorator) || IrisSpeleothems.isSturdy(surface, true); } static boolean isValidShorelineSupport(IrisDecorator decorator, PlatformBlockState decorant, PlatformBlockState surface) { diff --git a/core/src/main/java/art/arcane/iris/engine/decorator/FloatingDecorator.java b/core/src/main/java/art/arcane/iris/engine/decorator/FloatingDecorator.java index 760fed42b..5780a8b59 100644 --- a/core/src/main/java/art/arcane/iris/engine/decorator/FloatingDecorator.java +++ b/core/src/main/java/art/arcane/iris/engine/decorator/FloatingDecorator.java @@ -46,10 +46,12 @@ public class FloatingDecorator { } if (!decorator.isStacking()) { - DecoratorCore.placeFloatingSimple(decorator, xf, zf, realX, realZ, height, max, data, rng, engine.getData()); + DecoratorCore.placeFloatingSimple(decorator, xf, zf, realX, realZ, height, max, data, + rng, engine.getData(), engine.getMantle()); return max > 1 ? 1 : 0; } - return DecoratorCore.placeFloatingStacked(decorator, xf, zf, realX, realZ, height, max, data, rng, engine.getData()); + return DecoratorCore.placeFloatingStacked(decorator, xf, zf, realX, realZ, height, max, data, + rng, engine.getData(), engine.getMantle()); } } diff --git a/core/src/main/java/art/arcane/iris/engine/decorator/IrisShoreLineDecorator.java b/core/src/main/java/art/arcane/iris/engine/decorator/IrisShoreLineDecorator.java index 7b66e1f3c..e937bdeab 100644 --- a/core/src/main/java/art/arcane/iris/engine/decorator/IrisShoreLineDecorator.java +++ b/core/src/main/java/art/arcane/iris/engine/decorator/IrisShoreLineDecorator.java @@ -105,7 +105,8 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator { return; } PlatformBlockState block = decorator.getBlockData100(biome, rng, realX, height, realZ, getData()); - if (block != null && DecoratorCore.isValidShorelineSupport(decorator, block, support)) { + if (block != null && DecoratorCore.isValidShorelineSupport(decorator, block, support) + && IrisSugarCane.canPlace(block, data, x, targetY, z, realX, realZ, getEngine())) { data.set(x, targetY, z, block); aquaticSnapshot.restoreIfUnsupported(data, x, z); } @@ -126,7 +127,8 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator { } PlatformBlockState block = decorator.getBlockDataForTop(biome, rng, realX, height, realZ, getData()); - if (block != null && DecoratorCore.isValidShorelineSupport(decorator, block, support)) { + if (block != null && DecoratorCore.isValidShorelineSupport(decorator, block, support) + && IrisSugarCane.canPlace(block, data, x, targetY, z, realX, realZ, getEngine())) { data.set(x, targetY, z, block); aquaticSnapshot.restoreIfUnsupported(data, x, z); } @@ -147,6 +149,9 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator { if (block == null) { break; } + if (!IrisSugarCane.canPlace(block, data, x, targetY, z, realX, realZ, getEngine())) { + break; + } if (i == 0 && !DecoratorCore.isValidShorelineSupport(decorator, block, support)) { break; } diff --git a/core/src/main/java/art/arcane/iris/engine/decorator/IrisSugarCane.java b/core/src/main/java/art/arcane/iris/engine/decorator/IrisSugarCane.java new file mode 100644 index 000000000..7cbbbc26d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/decorator/IrisSugarCane.java @@ -0,0 +1,99 @@ +package art.arcane.iris.engine.decorator; + +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.history.TerrainBoundarySignature; +import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; +import art.arcane.iris.engine.hydrology.HydrologyColumnSample; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.util.project.hunk.Hunk; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; + +import java.util.Optional; + +final class IrisSugarCane { + private static final int[][] NEIGHBORS = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}}; + + private IrisSugarCane() { + } + + static boolean isSugarCane(PlatformBlockState state) { + if (state == null) { + return false; + } + String key = state.key(); + return key != null && (key.equals("minecraft:sugar_cane") || key.startsWith("minecraft:sugar_cane[")); + } + + static boolean canPlace(PlatformBlockState block, Hunk data, + int x, int y, int z, int worldX, int worldZ, Engine engine) { + if (!isSugarCane(block)) { + return true; + } + if (y <= 0 || y >= data.getHeight()) { + return false; + } + int baseY = y - 1; + PlatformBlockState support = data.get(x, baseY, z); + PlatformBlockState target = data.get(x, y, z); + if (support == null || !block.canPlaceOnto(support) || target != null && !target.isAir()) { + return false; + } + while (isSugarCane(support) && baseY > 0) { + support = data.get(x, --baseY, z); + } + if (support == null || isSugarCane(support) || !block.canPlaceOnto(support)) { + return false; + } + for (int[] offset : NEIGHBORS) { + int adjacentX = x + offset[0]; + int adjacentZ = z + offset[1]; + if (adjacentX >= 0 && adjacentX < data.getWidth() + && adjacentZ >= 0 && adjacentZ < data.getDepth()) { + if (supportsAdjacent(data.get(adjacentX, baseY, adjacentZ))) { + return true; + } + } else if (engine != null && plannedWater(engine, worldX + offset[0], baseY, worldZ + offset[1])) { + return true; + } + } + return false; + } + + static boolean supportsAdjacent(PlatformBlockState state) { + if (state == null) { + return false; + } + String key = state.key(); + return state.isWater() || state.isWaterLogged() || key != null + && (key.equals("minecraft:frosted_ice") || key.startsWith("minecraft:frosted_ice[")); + } + + private static boolean plannedWater(Engine engine, int x, int y, int z) { + IrisComplex complex = engine.getComplex(); + Optional resolved = complex.resolvedTerrainColumn(x, z); + if (resolved.isPresent()) { + String key = resolved.get().geometry().voxelAt(y + engine.getMinHeight()).stateKey(); + return supportsAdjacent(IrisPlatforms.get().registries().blockOrNull(key)); + } + HydrologyColumnSample sample = complex.sampleHydrologyColumn(x, z); + HydrologyColumnLayer fluid = sample == null ? null : sample.primarySurfaceFluidLayerOrNull(); + if (fluid != null) { + return y > fluid.bedY() && y <= fluid.fluidHeadY() + && supportsAdjacent(complex.resolveHydrologyFluid(fluid.profileKey(), x, z)); + } + int terrainHeight = (int) Math.round(complex.getHeightStream().getDouble(x, z)); + int fluidHeight = (int) Math.round(complex.getRiverWaterSurfaceStream().getDouble(x, z)); + if (y <= terrainHeight || y > fluidHeight) { + return false; + } + IrisBiome biome = complex.getTrueBiomeStream().get(x, z); + KList layers = biome.generateSeaLayers(x, z, + new RNG(engine.getSeedManager().getTerrain()), fluidHeight - terrainHeight, engine.getData()); + int depth = fluidHeight - y; + return supportsAdjacent(layers.hasIndex(depth) ? layers.get(depth) : complex.resolveSurfaceFluid(x, z)); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/decorator/IrisSurfaceDecorator.java b/core/src/main/java/art/arcane/iris/engine/decorator/IrisSurfaceDecorator.java index b129e80ce..d4c680bf9 100644 --- a/core/src/main/java/art/arcane/iris/engine/decorator/IrisSurfaceDecorator.java +++ b/core/src/main/java/art/arcane/iris/engine/decorator/IrisSurfaceDecorator.java @@ -88,6 +88,7 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator { opts.underwater = underwater; opts.fluidHeight = fluidHeight; opts.caveSkipFluid = caveSkipFluid; + opts.mantle = getEngine().getMantle(); DecoratorCore.placeStackUp(decorator, x, z, realX, realZ, height, max, data, rng, getData(), opts); aquaticSnapshot.restoreIfUnsupported(data, x, z); return; diff --git a/core/src/main/java/art/arcane/iris/engine/framework/render/IrisRenderer.java b/core/src/main/java/art/arcane/iris/engine/framework/render/IrisRenderer.java index 5be049825..51f6796d1 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/render/IrisRenderer.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/render/IrisRenderer.java @@ -215,7 +215,7 @@ public final class IrisRenderer { public static int hydrologyDiagnosticColor(HydrologyCandidateKind kind) { Objects.requireNonNull(kind, "kind"); return switch (kind) { - case SOURCE, TRIBUTARY -> PROJECTED_SOURCE; + case SOURCE, TRIBUTARY, REGIONAL_SOURCE, COASTAL_CHANNEL -> PROJECTED_SOURCE; case OUTLET -> PROJECTED_OUTLET; case DEEP_FLUID, POOL -> PROJECTED_DEEP_FLUID; }; diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/CaveCandidateBuilder.java b/core/src/main/java/art/arcane/iris/engine/hydrology/CaveCandidateBuilder.java index deb7f24b9..9f471ae66 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/CaveCandidateBuilder.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/CaveCandidateBuilder.java @@ -44,7 +44,7 @@ final class CaveCandidateBuilder { this.maximumZ = Integer.MIN_VALUE; } - private RiverCourse course() { + RiverCourse course() { return course; } @@ -97,8 +97,19 @@ final class CaveCandidateBuilder { for (int[] offset : HydrologyCaveCourseFilter.HORIZONTAL_NEIGHBORS) { int neighborX = position.x() + offset[0]; int neighborZ = position.z() + offset[1]; - if (validation.ownsSurfaceChannelAt(neighborX, neighborZ, course.id())) { - addOpening(neighborX, position.y(), neighborZ); + if (actions.containsKey(new CavePosition(neighborX, position.y(), neighborZ))) { + continue; + } + HydrologyColumnSample neighbor = validation.surfaceColumnAt(neighborX, neighborZ); + if (neighbor == null) { + continue; + } + for (HydrologyColumnLayer layer : neighbor.layers()) { + if (layer.feature().courseId() == course.id() && layer.channel() && layer.fluidOwned() + && layer.bedY() < position.y() && position.y() <= layer.fluidHeadY()) { + addOpening(neighborX, position.y(), neighborZ); + break; + } } } } @@ -110,7 +121,13 @@ final class CaveCandidateBuilder { continue; } if (opening.includeNeighborhood()) { - addOpeningNeighborhood(position); + addOpening(position); + for (int[] offset : HydrologyCaveCourseFilter.NEIGHBORS) { + int y = position.y() + offset[1]; + if (y >= opening.minimumY()) { + addOpening(position.x() + offset[0], y, position.z() + offset[2]); + } + } } else { addOpening(position); } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/CaveCandidateSpanBuilder.java b/core/src/main/java/art/arcane/iris/engine/hydrology/CaveCandidateSpanBuilder.java index e61f84ca6..7558b16db 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/CaveCandidateSpanBuilder.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/CaveCandidateSpanBuilder.java @@ -80,7 +80,7 @@ final class CaveCandidateSpanBuilder { addOpening(x, z, minimumOpeningY, maximumActionY); continue; } - addOpening(x, z, minimumOpeningY - 1, maximumActionY + 1); + addOpening(x, z, Math.max(minimumOpeningY - 1, opening.minimumY()), maximumActionY + 1); for (int[] offset : HydrologyCaveCourseFilter.HORIZONTAL_NEIGHBORS) { addOpening(x + offset[0], z + offset[1], minimumOpeningY, maximumActionY); } @@ -117,16 +117,51 @@ final class CaveCandidateSpanBuilder { for (int[] offset : HydrologyCaveCourseFilter.HORIZONTAL_NEIGHBORS) { int neighborX = x + offset[0]; int neighborZ = z + offset[1]; - if (!validation.ownsSurfaceChannelAt(neighborX, neighborZ, course.id())) { + HydrologyColumnSample neighbor = validation.surfaceColumnAt(neighborX, neighborZ); + if (neighbor == null) { continue; } - for (CaveYSpan span : entry.getValue().spans()) { - addOpening(neighborX, neighborZ, span.minimumY(), span.maximumY()); + for (HydrologyColumnLayer layer : neighbor.layers()) { + if (layer.feature().courseId() != course.id() || !layer.channel() || !layer.fluidOwned()) { + continue; + } + for (CaveYSpan span : entry.getValue().spans()) { + int minimumY = Math.max(span.minimumY(), layer.bedY() + 1); + int maximumY = Math.min(span.maximumY(), layer.fluidHeadY()); + if (minimumY <= maximumY) { + addAdjacentFluidOpening(neighborX, neighborZ, minimumY, maximumY); + } + } } } } } + private void addAdjacentFluidOpening(int x, int z, int minimumY, int maximumY) { + CaveSpanSet owned = actions.get(RiverFootprint.pack(x, z)); + if (owned == null) { + addOpening(x, z, minimumY, maximumY); + return; + } + int cursor = minimumY; + for (CaveYSpan span : owned.spans()) { + if (span.maximumY() < cursor) { + continue; + } + if (span.minimumY() > maximumY) { + break; + } + if (cursor < span.minimumY()) { + addOpening(x, z, cursor, Math.min(maximumY, span.minimumY() - 1)); + } + cursor = Math.max(cursor, span.maximumY() + 1); + if (cursor > maximumY) { + return; + } + } + addOpening(x, z, cursor, maximumY); + } + HydrologyFeatureType oversizedGrotto(HydrologyCaveCourseFilter.Options options) { if (positionCount(coastalGrottoActions) > options.maximumVolume(HydrologyFeatureType.COASTAL_GROTTO)) { diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/CaveSurfaceOpening.java b/core/src/main/java/art/arcane/iris/engine/hydrology/CaveSurfaceOpening.java index 04fc9ca95..a63473602 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/CaveSurfaceOpening.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/CaveSurfaceOpening.java @@ -11,7 +11,8 @@ record CaveSurfaceOpening( double interiorZ, long radiusSquared, int minimumY, - boolean includeNeighborhood + boolean includeNeighborhood, + boolean surfaceFall ) { static CaveSurfaceOpening create( HydraulicSegment segment, @@ -43,7 +44,8 @@ record CaveSurfaceOpening( point.z() + deltaZ * scale, radiusSquared, minimumY, - includeNeighborhood + includeNeighborhood, + segment.type().isSurface() && segment.fallingFluid() ); } @@ -53,6 +55,9 @@ record CaveSurfaceOpening( } boolean matchesColumn(HydrologyColumnLayer layer, int x, int z) { + if (surfaceFall) { + return layer.fallingFluid() && layer.feature().segmentId() == segmentId; + } return (includeNeighborhood || layer.feature().segmentId() == segmentId) && distanceSquared(x, z) <= radiusSquared; } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/FootprintMutableColumn.java b/core/src/main/java/art/arcane/iris/engine/hydrology/FootprintMutableColumn.java index a8563b9d5..5ba0fb9c4 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/FootprintMutableColumn.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/FootprintMutableColumn.java @@ -159,7 +159,7 @@ final class FootprintMutableColumn { first.connectedFluid() || second.connectedFluid(), first.fallingFluid() || second.fallingFluid(), first.receivingPool() || second.receivingPool(), - first.terrainOwned() || second.terrainOwned(), + (first.terrainOwned() || second.terrainOwned()) && (!drySurfaceBlend || bedY < naturalHeight), first.fluidOwned() || second.fluidOwned(), first.oceanApron() || second.oceanApron(), first.profileKey(), diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydraulicChannelProfile.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydraulicChannelProfile.java new file mode 100644 index 000000000..81197cc87 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydraulicChannelProfile.java @@ -0,0 +1,87 @@ +package art.arcane.iris.engine.hydrology; + +import java.util.Arrays; +import java.util.Objects; + +public record HydraulicChannelProfile(double[] widths, double[] depths) { + public HydraulicChannelProfile { + Objects.requireNonNull(widths, "widths"); + Objects.requireNonNull(depths, "depths"); + if (widths.length == 0 || widths.length != depths.length) { + throw new IllegalArgumentException("Channel dimensions require equally sized, nonempty station arrays."); + } + widths = widths.clone(); + depths = depths.clone(); + for (int station = 0; station < widths.length; station++) { + if (!Double.isFinite(widths[station]) || widths[station] <= 0D + || !Double.isFinite(depths[station]) || depths[station] <= 0D) { + throw new IllegalArgumentException("Channel dimensions must be finite and positive."); + } + } + } + + public static HydraulicChannelProfile uniform(double width, double depth) { + return new HydraulicChannelProfile(new double[]{width}, new double[]{depth}); + } + + public static HydraulicChannelProfile range(double[] widths, double[] depths, int from, int to) { + Objects.checkFromToIndex(from, to, widths.length); + Objects.checkFromToIndex(from, to, depths.length); + return new HydraulicChannelProfile(Arrays.copyOfRange(widths, from, to), Arrays.copyOfRange(depths, from, to)); + } + + @Override + public double[] widths() { + return widths.clone(); + } + + @Override + public double[] depths() { + return depths.clone(); + } + + public int size() { + return widths.length; + } + + public double widthAt(int station) { + return widths[widths.length == 1 ? 0 : station]; + } + + public double depthAt(int station) { + return depths[depths.length == 1 ? 0 : station]; + } + + public double maximumWidth() { + double maximum = 0D; + for (double width : widths) { + maximum = Math.max(maximum, width); + } + return maximum; + } + + public double maximumDepth() { + double maximum = 0D; + for (double depth : depths) { + maximum = Math.max(maximum, depth); + } + return maximum; + } + + @Override + public boolean equals(Object other) { + return other instanceof HydraulicChannelProfile profile + && Arrays.equals(widths, profile.widths) + && Arrays.equals(depths, profile.depths); + } + + @Override + public int hashCode() { + return 31 * Arrays.hashCode(widths) + Arrays.hashCode(depths); + } + + @Override + public String toString() { + return "HydraulicChannelProfile[widths=" + Arrays.toString(widths) + ", depths=" + Arrays.toString(depths) + "]"; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydraulicSegment.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydraulicSegment.java index 736d2b7dd..b631fc4a2 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydraulicSegment.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydraulicSegment.java @@ -13,11 +13,13 @@ public record HydraulicSegment( int depth, boolean fallingFluid, boolean receivingPool, - List centerline + List centerline, + HydraulicChannelProfile channelProfile ) { public HydraulicSegment { Objects.requireNonNull(type, "type"); Objects.requireNonNull(centerline, "centerline"); + Objects.requireNonNull(channelProfile, "channelProfile"); centerline = List.copyOf(centerline); if (centerline.isEmpty()) { throw new IllegalArgumentException("A hydraulic segment requires a centerline."); @@ -28,6 +30,12 @@ public record HydraulicSegment( if (width <= 0 || depth <= 0) { throw new IllegalArgumentException("Hydraulic segment width and depth must be positive."); } + if (channelProfile.size() != 1 && channelProfile.size() != centerline.size()) { + throw new IllegalArgumentException("Channel dimensions must cover every centerline station."); + } + if (channelProfile.maximumWidth() > width || channelProfile.maximumDepth() > depth) { + throw new IllegalArgumentException("Hydraulic segment bounds must contain its channel dimensions."); + } int drop = upstreamHeadY - downstreamHeadY; if (fallingFluid && drop == 0) { throw new IllegalArgumentException("Falling fluid requires a positive head transition."); diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCandidateKind.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCandidateKind.java index 55c04c011..97c325d07 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCandidateKind.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCandidateKind.java @@ -6,5 +6,7 @@ public enum HydrologyCandidateKind { DEEP_FLUID, POOL, /** A later course to an outlet that could not be joined to the outlet's main course. */ - TRIBUTARY + TRIBUTARY, + REGIONAL_SOURCE, + COASTAL_CHANNEL } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCandidateRejection.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCandidateRejection.java index d6d23ce8e..fcd7fa1d1 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCandidateRejection.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCandidateRejection.java @@ -21,5 +21,8 @@ public enum HydrologyCandidateRejection { SURFACE_SEGMENT_UNSUPPORTED, SURFACE_SINKHOLE_CLEARANCE, SURFACE_SHAPE_UNSUPPORTED, + SURFACE_MOUTH_DISCONNECTED, + SURFACE_WATER_CONTAINMENT, + SURFACE_BANK_BUDGET, POLICY_EXCLUDED; } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCaveCourseFilter.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCaveCourseFilter.java index 26af0e5d1..4c81832a4 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCaveCourseFilter.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCaveCourseFilter.java @@ -38,30 +38,6 @@ final class HydrologyCaveCourseFilter { {1, 0}, {-1, 0}, {0, 1}, {0, -1} } -; - - static final CaveVoxelView GENERATED_CHANNEL_VIEW = new CaveVoxelView() { - @Override - public boolean isInWorld(CavePosition position) { - return true; - } - - @Override - public CaveVoxel voxelAt(CavePosition position) { - return CaveVoxel.UNCONDITIONAL; - } - - @Override - public boolean isOpenToSurface(CavePosition position) { - return false; - } - - @Override - public boolean isAboveTerrainSurface(CavePosition position) { - return false; - } - } - ; final HydrologyCaveContainmentPlanner planner; @@ -93,7 +69,7 @@ final class HydrologyCaveCourseFilter { this.planner = new HydrologyCaveContainmentPlanner(); this.options = Objects.requireNonNull(options); CaveVoxelView observedView = Objects.requireNonNull(view); - this.view = options.connectToExistingCaves() ? observedView : GENERATED_CHANNEL_VIEW; + this.view = options.connectToExistingCaves() ? observedView : new GeneratedChannelView(observedView); this.candidateCache = candidateCache; this.validationCache = validationCache; this.plannedSurface = plannedSurface; @@ -438,7 +414,7 @@ final class HydrologyCaveCourseFilter { ) { LinkedHashMap composedActions = null; for (HydrologyColumnSample caveSample : validation.columnsForCourse(course.id())) { - int maximumCaveY = maximumCaveY(caveSample, course.id()); + int maximumCaveY = maximumCaveY(caveSample, course); if (maximumCaveY == Integer.MIN_VALUE) { continue; } @@ -549,14 +525,14 @@ final class HydrologyCaveCourseFilter { && first.outletId().getAsLong() == second.outletId().getAsLong(); } - int maximumCaveY(HydrologyColumnSample sample, long courseId) { + int maximumCaveY(HydrologyColumnSample sample, RiverCourse course) { int maximumY = Integer.MIN_VALUE; for (HydrologyColumnLayer layer : sample.layers()) { - if (layer.feature().courseId() == courseId - && isCaveLayer(layer) + if (layer.feature().courseId() == course.id() + && isCaveLayer(layer, course) && !layer.oceanApron() && layer.channel() - && layer.terrainOwned()) { + && (layer.terrainOwned() || layer.fallingFluid() && layer.fluidOwned())) { maximumY = Math.max(maximumY, layer.ceilingY()); } } @@ -673,8 +649,8 @@ final class HydrologyCaveCourseFilter { for (HydrologyColumnSample sample : columns) { for (HydrologyColumnLayer layer : sample.layers()) { CaveCandidateSpanBuilder builder = builders.get(layer.feature().courseId()); - if (builder == null || !isCaveLayer(layer) || layer.oceanApron() - || !layer.channel() || !layer.terrainOwned()) { + if (builder == null || !isCaveLayer(layer, builder.course()) || layer.oceanApron() + || !layer.channel() || !(layer.terrainOwned() || layer.fallingFluid() && layer.fluidOwned())) { continue; } builder.addAction(sample.x(), sample.z(), layer); @@ -705,10 +681,10 @@ final class HydrologyCaveCourseFilter { int maximumCaveY = Integer.MIN_VALUE; for (HydrologyColumnLayer layer : sample.layers()) { if (layer.feature().courseId() != course.id() - || !isCaveLayer(layer) + || !isCaveLayer(layer, course) || layer.oceanApron() || !layer.channel() - || !layer.terrainOwned()) { + || !(layer.terrainOwned() || layer.fallingFluid() && layer.fluidOwned())) { continue; } builder.addAction(sample.x(), sample.z(), layer); @@ -770,8 +746,8 @@ final class HydrologyCaveCourseFilter { for (HydrologyColumnSample sample : columns) { for (HydrologyColumnLayer layer : sample.layers()) { CaveCandidateBuilder builder = builders.get(layer.feature().courseId()); - if (builder == null || !isCaveLayer(layer) || layer.oceanApron() - || !layer.channel() || !layer.terrainOwned()) { + if (builder == null || !isCaveLayer(layer, builder.course()) || layer.oceanApron() + || !layer.channel() || !(layer.terrainOwned() || layer.fallingFluid() && layer.fluidOwned())) { continue; } for (int y = layer.bedY() + 1; y <= layer.ceilingY(); y++) { @@ -809,10 +785,10 @@ final class HydrologyCaveCourseFilter { int maximumCaveY = Integer.MIN_VALUE; for (HydrologyColumnLayer layer : sample.layers()) { if (layer.feature().courseId() != course.id() - || !isCaveLayer(layer) + || !isCaveLayer(layer, course) || layer.oceanApron() || !layer.channel() - || !layer.terrainOwned()) { + || !(layer.terrainOwned() || layer.fallingFluid() && layer.fluidOwned())) { continue; } for (int y = layer.bedY() + 1; y <= layer.ceilingY(); y++) { @@ -858,7 +834,8 @@ final class HydrologyCaveCourseFilter { static HydraulicSegment representativeCaveSegment(RiverCourse course) { HydraulicSegment selected = null; for (HydraulicSegment segment : course.segments()) { - if (!segment.type().isUnderground() && !segment.type().isDeepFluid()) { + if (!segment.type().isUnderground() && !segment.type().isDeepFluid() + && !(segment.type().isSurface() && segment.fallingFluid())) { continue; } if (selected == null || candidatePriority(segment.type()) < candidatePriority(selected.type())) { @@ -878,8 +855,16 @@ final class HydrologyCaveCourseFilter { return 2; } - boolean isCaveLayer(HydrologyColumnLayer layer) { - return layer.feature().type().isUnderground() || layer.feature().type().isDeepFluid(); + boolean isCaveLayer(HydrologyColumnLayer layer, RiverCourse course) { + if (layer.feature().type().isUnderground() || layer.feature().type().isDeepFluid()) { + return true; + } + for (HydraulicSegment segment : course.segments()) { + if (segment.id() == layer.feature().segmentId()) { + return segment.type().isSurface() && segment.fallingFluid(); + } + } + return false; } HydrologyCaveAction actionAt(HydrologyColumnLayer layer, int y) { @@ -1213,6 +1198,12 @@ final class HydrologyCaveCourseFilter { List segments = course.segments(); for (int index = 0; index < segments.size(); index++) { HydraulicSegment segment = segments.get(index); + if (segment.type().isSurface() && segment.fallingFluid()) { + int radius = Math.max(1, segment.width() / 2) + 1; + openings.add(CaveSurfaceOpening.create(segment, true, (long) radius * radius, + segment.downstreamHeadY() + 2, true)); + continue; + } if (!segment.type().isUnderground()) { continue; } @@ -1259,4 +1250,31 @@ final class HydrologyCaveCourseFilter { } return List.copyOf(openings); } + + private record GeneratedChannelView(CaveVoxelView terrain) implements CaveVoxelView { + @Override + public boolean isInWorld(CavePosition position) { + return terrain.isInWorld(position); + } + + @Override + public CaveVoxel voxelAt(CavePosition position) { + return CaveVoxel.UNCONDITIONAL; + } + + @Override + public boolean isOpenToSurface(CavePosition position) { + return false; + } + + @Override + public boolean isAboveTerrainSurface(CavePosition position) { + return terrain.isAboveTerrainSurface(position); + } + + @Override + public boolean hasAboveTerrainSurface(int x, int z, int minimumY, int maximumY) { + return terrain.hasAboveTerrainSurface(x, z, minimumY, maximumY); + } + } } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyChannelGeometry.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyChannelGeometry.java index 53e76b48c..32d66fa6c 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyChannelGeometry.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyChannelGeometry.java @@ -184,9 +184,9 @@ final class HydrologyChannelGeometry { double distance = rawDistance; if (shape.fallingThroat() && (flowX != 0 || flowZ != 0)) { double flowLength = StrictMath.hypot(flowX, flowZ); - double along = StrictMath.abs(deltaX * flowX + deltaZ * flowZ) / flowLength; + double along = (deltaX * flowX + deltaZ * flowZ) / flowLength; double across = StrictMath.abs(deltaX * -flowZ + deltaZ * flowX) / flowLength; - if (along > 0.75D) { + if (StrictMath.abs(along) > 0.75D || segment.type().isSurface() && along < 0D) { return shape.totalRadius() + 1D; } distance = across; @@ -240,6 +240,9 @@ final class HydrologyChannelGeometry { + coherent * channelShape.wallRoughness() * 0.7D + detail * channelShape.wallRoughness() * 0.3D; radialScale = Math.max(channelShape.radialMinimum(), Math.min(channelShape.radialMaximum(), radialScale)); + if (segment.type().isSurface() && shape.fallingThroat()) { + radialScale = Math.max(1D, radialScale); + } return distance / radialScale; } @@ -280,7 +283,8 @@ final class HydrologyChannelGeometry { + (sideBias - 0.5D) * 0.08D; widthScale = Math.max(1D, Math.min(1.18D, widthScale)); double organicDistance = StrictMath.abs(signedCross - thalweg) / widthScale; - return Math.min(StrictMath.abs(signedCross), organicDistance); + double along = (deltaX * flowX + deltaZ * flowZ) / flowLength; + return StrictMath.hypot(along, Math.min(StrictMath.abs(signedCross), organicDistance)); } HydrologyPlannerSettings.ChannelShape channelShape(HydrologyFeatureType type) { 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 0c4fc1d44..eb734bd87 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 @@ -55,9 +55,10 @@ public record HydrologyColumnSample( for (HydrologyColumnLayer layer : layers) { if (layer.feature().type().isSurface() && !layer.oceanApron() - && (layer.terrainOwned() || layer.fluidOwned() || layer.grading() || layer.shore())) { + && (layer.terrainOwned() || layer.fluidOwned() || layer.grading() || layer.shore()) + && (naturalHeight < seaLevel || layer.fluidHeadY() > seaLevel)) { throw new IllegalArgumentException( - "Naturally submerged columns cannot contain owned surface hydrology writes." + "Surface hydrology cannot own submerged ground or raise sea-level water." ); } } @@ -129,7 +130,7 @@ public record HydrologyColumnSample( public int terrainHeight() { HydrologyColumnLayer primary = selectSurfaceLayer(false); - if (primary == null) { + if (primary == null || !primary.terrainOwned()) { return naturalHeight; } if (primary.channel()) { @@ -164,8 +165,6 @@ public record HydrologyColumnSample( if (!layer.channel() || !layer.connectedFluid() || !layer.fluidOwned()) { continue; } - } else if (!layer.terrainOwned()) { - continue; } if (selected == null || SURFACE_LAYER_ORDER.compare(layer, selected) < 0) { selected = layer; @@ -178,7 +177,10 @@ public record HydrologyColumnSample( if (layer.channel()) { return 0; } - return layer.shore() ? 1 : 2; + if (layer.terrainOwned()) { + return layer.shore() ? 1 : 2; + } + return layer.shore() ? 3 : 4; } private static int surfaceActionPriority(HydrologyCaveAction action) { diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyColumnSnapshot.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyColumnSnapshot.java new file mode 100644 index 000000000..6fb18d037 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyColumnSnapshot.java @@ -0,0 +1,20 @@ +package art.arcane.iris.engine.hydrology; + +public record HydrologyColumnSnapshot(boolean available, HydrologyColumnSample column) { + private static final HydrologyColumnSnapshot UNAVAILABLE = new HydrologyColumnSnapshot(false, null); + private static final HydrologyColumnSnapshot READY_NATURAL = new HydrologyColumnSnapshot(true, null); + + public HydrologyColumnSnapshot { + if (!available && column != null) { + throw new IllegalArgumentException("Unavailable hydrology cannot contain a planned column."); + } + } + + public static HydrologyColumnSnapshot unavailable() { + return UNAVAILABLE; + } + + public static HydrologyColumnSnapshot ready(HydrologyColumnSample column) { + return column == null ? READY_NATURAL : new HydrologyColumnSnapshot(true, column); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileResolver.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileResolver.java index 2d1d573b8..b2f3a1b83 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileResolver.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileResolver.java @@ -5,6 +5,7 @@ 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.SurfaceBounds; import it.unimi.dsi.fastutil.longs.LongIterator; import it.unimi.dsi.fastutil.longs.LongOpenHashSet; @@ -112,6 +113,8 @@ final class HydrologyCrossTileResolver { planner.naturalSampler ) ); + seedRegionalSurface(key, footprintCompiler); + diagnostics.addAll(footprintCompiler.regionalNetwork.diagnostics()); HashMap> refinedEdges = new HashMap<>(); HashMap sourceCompilations = new HashMap<>(); settleSelection( @@ -175,11 +178,11 @@ final class HydrologyCrossTileResolver { break; } boolean surfaceChanged = surfaceSelection.advanceAfterPublication( - publication.result().courses(), + publication.result(), grid ); boolean undergroundChanged = undergroundSelection.advanceAfterPublication( - publication.result().courses(), + publication.result(), grid ); if (!surfaceChanged && !undergroundChanged) { @@ -305,7 +308,9 @@ final class HydrologyCrossTileResolver { planner.naturalSampler ) ); + seedRegionalSurface(resolution.draft().key(), footprintCompiler); } + result = planner.regional.include(result, footprintCompiler.regionalNetwork); MaterializedHydrology materialized = materializeFinalHydrology( result, diagnostics, @@ -313,6 +318,8 @@ final class HydrologyCrossTileResolver { !resolution.observedRejections().isEmpty() ); result = materialized.result(); + result = clipRegionalPlans(result, footprintCompiler.regionalNetwork, + planner.regional.ownerBounds(resolution.draft().key())); RiverFootprint footprint = materialized.footprint(); if (footprintCompiler.fullMaterializationCount() < 1) { throw new IllegalStateException("Hydrology publication did not materialize its full footprint."); @@ -332,6 +339,30 @@ final class HydrologyCrossTileResolver { ); } + private void seedRegionalSurface(HydrologyTileKey key, HydrologyFootprintCompiler compiler) { + if (!planner.regional.enabled()) { + return; + } + SurfaceBounds bounds = planner.regional.ownerBounds(key); + compiler.seedRegionalSurface(planner.regional.coursesIn(bounds), bounds); + } + + static HydrologyCaveCourseFilter.Result clipRegionalPlans(HydrologyCaveCourseFilter.Result result, + HydrologyRegionalNetwork regional, SurfaceBounds bounds) { + if (regional.cavePlans().isEmpty()) { + return result; + } + HashSet regionalIds = new HashSet<>(); + for (RiverCourse course : regional.courses()) { + regionalIds.add(course.id()); + } + ArrayList plans = new ArrayList<>(result.cavePlans().size()); + for (HydrologyCavePlan plan : result.cavePlans()) { + plans.add(regionalIds.contains(plan.source().sourceId()) ? HydrologyRegionalFalls.clip(plan, bounds) : plan); + } + return new HydrologyCaveCourseFilter.Result(result.nodes(), result.edges(), result.outlets(), result.courses(), List.copyOf(plans)); + } + MaterializedHydrology materializeFinalHydrology( HydrologyCaveCourseFilter.Result initial, List diagnostics, @@ -339,17 +370,22 @@ final class HydrologyCrossTileResolver { boolean revalidationRequired ) { HydrologyCaveCourseFilter.Result current = initial; + HashSet regionalIds = new HashSet<>(); + for (RiverCourse course : footprintCompiler.regionalNetwork.courses()) { + regionalIds.add(course.id()); + } int maximumPasses = Math.addExact(initial.courses().size(), 1); for (int pass = 0; pass < maximumPasses; pass++) { RiverFootprint footprint = footprintCompiler.compile(current.courses()); - if (current.cavePlans().isEmpty()) { + HydrologyCaveCourseFilter.Result local = HydrologyCaveCourseFilter.withoutCourses(current, regionalIds); + if (local.cavePlans().isEmpty()) { return new MaterializedHydrology(current, footprint); } HydrologyFootprintCompiler.ValidationRaster compactValidation = footprintCompiler.compileValidation( current.courses() ); if (!revalidationRequired - && compactSurfaceMatchesMaterializedPlans(current, compactValidation, footprint)) { + && compactSurfaceMatchesMaterializedPlans(local, compactValidation, footprint)) { return new MaterializedHydrology(current, footprint); } HydrologyFootprintCompiler.ValidationRaster validation = compactValidation.withMaterializedSurface( @@ -373,13 +409,15 @@ final class HydrologyCrossTileResolver { null, plannedSurface ).filter( - current.nodes(), - current.edges(), - current.outlets(), - current.courses(), + local.nodes(), + local.edges(), + local.outlets(), + local.courses(), validation, diagnostics ); + revalidated = HydrologyRegionalProtection.rejectPlans(revalidated, footprintCompiler.regionalNetwork, diagnostics); + revalidated = planner.regional.include(revalidated, footprintCompiler.regionalNetwork); if (revalidated.courses().equals(current.courses())) { return new MaterializedHydrology(revalidated, footprint); } @@ -596,9 +634,6 @@ final class HydrologyCrossTileResolver { boolean reachesOutlet = terminalType == HydrologyFeatureType.MOUTH || terminalType == HydrologyFeatureType.COASTAL_GROTTO || terminalType == HydrologyFeatureType.INLAND_GROTTO; - if (!reachesOutlet) { - continue; - } claims.add(new HydrologyCrossTileSurfaceAdmission.Claim( course.id(), course.outletId().getAsLong(), @@ -691,7 +726,7 @@ final class HydrologyCrossTileResolver { false ); diagnostics.addAll(attempt.diagnostics()); - if (!selection.advanceAfterPublication(attempt.result().courses(), grid)) { + if (!selection.advanceAfterPublication(attempt.result(), grid)) { return; } } @@ -784,6 +819,7 @@ final class HydrologyCrossTileResolver { graph = planner.sourcePlanner.mergeGraphs(surfaceGraph, undergroundGraph); } } + graph = new HydrologyRegionalTributaries(planner).connect(graph, courses, footprintCompiler, diagnostics); HydrologySurfaceProfiles.rejectExcludedWetFootprints(footprintCompiler, courses, diagnostics); List normalizedTrunkCourses = planner.tributaries.normalizeSharedTrunks(courses, diagnostics); List normalizedOutletCourses = planner.tributaries.normalizeOutletContinuations(normalizedTrunkCourses); @@ -796,6 +832,7 @@ final class HydrologyCrossTileResolver { if (includeDeepFluids && grid != null) { planner.featureSites.compileSeaCaves(grid, courses, diagnostics); } + HydrologyRegionalProtection.rejectFootprints(courses, footprintCompiler, diagnostics); DraftProfile profile = planner.currentDraftProfile(); long rasterStarted = System.nanoTime(); HydrologyFootprintCompiler.ValidationRaster validation = footprintCompiler.compileValidation(courses); @@ -837,6 +874,7 @@ final class HydrologyCrossTileResolver { ); profile.filterNanos += System.nanoTime() - filterStarted; profile.filterCalls++; + containment = HydrologyRegionalProtection.rejectPlans(containment, footprintCompiler.regionalNetwork, diagnostics); return new PublicationAttempt(containment, diagnostics); } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileSurfaceAdmission.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileSurfaceAdmission.java index fd945fc02..f742cc16e 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileSurfaceAdmission.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileSurfaceAdmission.java @@ -27,7 +27,7 @@ final class HydrologyCrossTileSurfaceAdmission { } private static boolean conflicts(Claim first, Claim second) { - if (first.outletId() == second.outletId()) { + if (first.outletId() == second.outletId() && first.reachesOutlet() && second.reachesOutlet()) { return true; } int minimumSeparation = Math.max(first.sourceSpacing(), second.sourceSpacing()); diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFeatureSitePlanner.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFeatureSitePlanner.java index 601f93e36..4ed31baac 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFeatureSitePlanner.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFeatureSitePlanner.java @@ -176,7 +176,7 @@ final class HydrologyFeatureSitePlanner { pool.depth(), false, false, - centerlinePoints + centerlinePoints, HydraulicChannelProfile.uniform(radius * 2, pool.depth()) ); return new RiverCourse( courseId, @@ -651,7 +651,7 @@ final class HydrologyFeatureSitePlanner { Math.max(deepFluid.channelDepth(), verticalRadius), false, false, - List.of(center) + List.of(center), HydraulicChannelProfile.uniform(horizontalRadius * 2 + 1, Math.max(deepFluid.channelDepth(), verticalRadius)) )); } if (deepFluid.shortChannels() && deepFluid.maximumChannelLength() > 0) { @@ -683,7 +683,7 @@ final class HydrologyFeatureSitePlanner { length, planner.settings.routing().refinementSpacing(), site.stableId() - ) + ), HydraulicChannelProfile.uniform(deepFluid.channelWidth(), deepFluid.channelDepth()) )); } } 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 88c162802..b9b47aeea 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,6 +1,7 @@ package art.arcane.iris.engine.hydrology; import art.arcane.iris.engine.hydrology.surface.SurfaceFootprint; +import art.arcane.iris.engine.hydrology.surface.SurfaceBounds; import art.arcane.iris.engine.hydrology.surface.SurfaceFootprintCompiler; import art.arcane.iris.engine.hydrology.surface.SurfaceLayerColumn; import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; @@ -41,6 +42,9 @@ final class HydrologyFootprintCompiler { final Map rasterStencils; final SurfaceFootprintCompiler surfaceCompiler; final LinkedHashMap surfaceFootprints; + final Map regionalSurfaceFootprints; + final Map regionalDropRasters; + HydrologyRegionalNetwork regionalNetwork; int fullMaterializationCount; final HydrologyFootprintRasterizer rasterizer; @@ -83,12 +87,23 @@ final class HydrologyFootprintCompiler { this.rasterStencils = new HashMap<>(); this.surfaceCompiler = new SurfaceFootprintCompiler(settings, this::sampleTerrainBasis, this.geometrySampler); this.surfaceFootprints = new LinkedHashMap<>(COURSE_FOOTPRINT_CACHE_SIZE, 1F, true); + this.regionalSurfaceFootprints = new LinkedHashMap<>(); + this.regionalDropRasters = new LinkedHashMap<>(); + this.regionalNetwork = HydrologyRegionalNetwork.EMPTY; this.fullMaterializationCount = 0; this.rasterizer = new HydrologyFootprintRasterizer(this); this.channelGeometry = new HydrologyChannelGeometry(this); } + SurfaceRasterIndex emptySurfaceRaster() { + return new SurfaceRasterIndex(List.of(), new Long2ObjectOpenHashMap<>()); + } + SurfaceFootprint surfaceFootprint(RiverCourse course) { + SurfaceFootprint regional = regionalSurfaceFootprints.get(course.id()); + if (regional != null) { + return regional; + } FootprintCourseRasterKey rasterKey = new FootprintCourseRasterKey( course.id(), course.type(), @@ -107,6 +122,21 @@ final class HydrologyFootprintCompiler { return compiled; } + void seedRegionalSurface(HydrologyRegionalNetwork network, SurfaceBounds bounds) { + regionalNetwork = Objects.requireNonNull(network); + regionalSurfaceFootprints.clear(); + regionalDropRasters.clear(); + for (RiverCourse course : network.courses()) { + SurfaceFootprint footprint = surfaceCompiler.compile(course, bounds); + if (!footprint.accepted()) { + throw new IllegalStateException("Accepted regional river changed during bounded footprint compilation: " + course.id()); + } + regionalSurfaceFootprints.put(course.id(), footprint); + regionalDropRasters.put(course.id(), HydrologySurfaceDropRaster.compile(settings, + HydrologyOceanReceiver.forCourse(settings, this::sampleTerrainBasis, course), geometrySampler, course, bounds)); + } + } + RiverFootprint compile(List courses) { fullMaterializationCount++; if (courses.isEmpty()) { @@ -158,6 +188,9 @@ final class HydrologyFootprintCompiler { HydrologyColumnSample first = firstSamples.putIfAbsent(packed, sample); if (first != null) { validateMatchingTerrainMetadata(first, sample); + FootprintMutableColumn merged = new FootprintMutableColumn(first); + merged.merge(sample); + firstSamples.put(packed, merged.build()); } } } @@ -167,6 +200,20 @@ final class HydrologyFootprintCompiler { ); } + ValidationRaster compileSurfaceDropValidation(RiverCourse course, SurfaceFootprint surface, + HydrologySurfaceDropRaster drops) { + Objects.requireNonNull(course, "course"); + Objects.requireNonNull(surface, "surface"); + Objects.requireNonNull(drops, "drops"); + Long2ObjectOpenHashMap samples = new Long2ObjectOpenHashMap<>(); + for (HydrologyColumnSample sample : drops.columns()) { + samples.put(RiverFootprint.pack(sample.x(), sample.z()), sample); + } + FootprintValidationCourseRaster raster = new FootprintValidationCourseRaster( + course.id(), drops.columns(), surface); + return new ValidationRaster(List.of(raster), new SurfaceRasterIndex(List.of(surface), samples)); + } + int fullMaterializationCount() { return fullMaterializationCount; } @@ -190,16 +237,20 @@ final class HydrologyFootprintCompiler { for (SurfaceLayerColumn column : surface.columns()) { addLayer(columns, column.x(), column.z(), column.terrain(), column.layer()); } + boolean boundedRegionalDrops = addRegionalDrops(columns, course.id()); 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 (boundedRegionalDrops && segment.type().isSurface() && segment.fallingFluid()) { + continue; + } boolean firstSegment = segmentIndex == 0; boolean clipStart = segmentIndex > 0 && rasterizer.segmentsJoin(course.segments().get(segmentIndex - 1), segment); boolean clipEnd = segmentIndex + 1 < course.segments().size() && rasterizer.segmentsJoin(segment, course.segments().get(segmentIndex + 1)); if (caveCourse && (segment.type().isUnderground() - || segment.type().isDeepFluid())) { + || segment.type().isDeepFluid() || segment.type().isSurface() && segment.fallingFluid())) { rasterizer.rasterizeSegment( columns, course, @@ -226,7 +277,8 @@ final class HydrologyFootprintCompiler { boolean hasCaveSegment(RiverCourse course) { for (HydraulicSegment segment : course.segments()) { - if (segment.type().isUnderground() || segment.type().isDeepFluid()) { + if (segment.type().isUnderground() || segment.type().isDeepFluid() + || segment.type().isSurface() && segment.fallingFluid()) { return true; } } @@ -246,6 +298,21 @@ final class HydrologyFootprintCompiler { return List.copyOf(built); } + void mergeSurfaceDrop(Long2ObjectLinkedOpenHashMap columns, + Long2ObjectLinkedOpenHashMap dropColumns) { + List constrained = HydrologySurfaceDropBankBounds.constrain( + buildValidationColumns(dropColumns), settings.surface().banks().erosion().excavation().maximumWidth()); + for (HydrologyColumnSample sample : constrained) { + long key = RiverFootprint.pack(sample.x(), sample.z()); + FootprintMutableColumn column = columns.get(key); + if (column == null) { + columns.put(key, new FootprintMutableColumn(sample)); + } else { + column.merge(sample); + } + } + } + static void validateMatchingTerrainMetadata( HydrologyColumnSample first, HydrologyColumnSample second @@ -278,10 +345,12 @@ final class HydrologyFootprintCompiler { for (SurfaceLayerColumn column : surface.columns()) { addLayer(columns, column.x(), column.z(), column.terrain(), column.layer()); } + boolean boundedRegionalDrops = addRegionalDrops(columns, course.id()); 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 (SurfaceFootprintCompiler.exposedSegment(segment)) { + if (SurfaceFootprintCompiler.exposedSegment(segment) + || boundedRegionalDrops && segment.type().isSurface() && segment.fallingFluid()) { continue; } boolean clipStart = segmentIndex > 0 @@ -307,6 +376,19 @@ final class HydrologyFootprintCompiler { return footprint; } + private boolean addRegionalDrops(Long2ObjectLinkedOpenHashMap columns, long courseId) { + HydrologySurfaceDropRaster drops = regionalDropRasters.get(courseId); + if (drops == null) { + return false; + } + for (HydrologyColumnSample sample : drops.columns()) { + for (HydrologyColumnLayer layer : sample.layers()) { + addLayer(columns, sample.x(), sample.z(), sampleTerrainBasis(sample.x(), sample.z()), layer); + } + } + return true; + } + RiverFootprint build(Long2ObjectLinkedOpenHashMap columns) { LinkedHashMap immutable = new LinkedHashMap<>(); for (Long2ObjectMap.Entry entry : columns.long2ObjectEntrySet()) { @@ -479,19 +561,37 @@ final class HydrologyFootprintCompiler { channel, ellipsoidScale ); + boolean surfaceDrop = segment.type().isSurface() && segment.fallingFluid(); + if (surfaceDrop && channel && !falling) { + int maximumIncision = terrain.surfacePolicy().maximumIncision(settings.surface().maximumIncision()); + int allowedIncision = Math.min(maximumIncision, + (int) StrictMath.floor(maximumIncision * terrain.incisionMultiplier())); + bed = Math.max(bed, Math.min(shape.fluidHead(), terrain.naturalHeight() - allowedIncision)); + if (bed == shape.fluidHead()) { + channel = false; + shore = true; + grading = true; + } + } + if (surfaceDrop && !channel) { + HydrologyPlannerSettings.Excavation excavation = settings.surface().banks().erosion().excavation(); + double bankDistance = Math.max(0D, distance - shape.channelRadius()); + bed = bankDistance > excavation.maximumWidth() ? terrain.naturalHeight() + : Math.max(bed, terrain.naturalHeight() - excavation.maximumDepth()); + } int fluidHead = channel ? shape.fluidHead() : bed; int ceiling = channel ? localCeiling(shape, ellipsoidScale, segment, channelShape, worldX, worldZ) : fluidHead; if (channel && terrainRoofedLayer(course, segment)) { ceiling = Math.max(fluidHead, Math.min(ceiling, terrain.naturalHeight() - 1)); } - boolean terrainOwned = !falling; + boolean terrainOwned = !falling && (!surfaceDrop || channel || bed < terrain.naturalHeight()); return new HydrologyColumnLayer( feature, bed, fluidHead, ceiling, channel, - terrainOwned && shore, + !falling && shore, terrainOwned && (grading || shore), channel, channel && falling, @@ -826,11 +926,16 @@ final class HydrologyFootprintCompiler { return sampled; } + boolean elevatedSeaLevelSurfaceColumn(HydraulicSegment segment, HydrologyTerrainSample terrain, int fluidHead) { + return segment.type().isSurface() && terrain.naturalHeight() <= settings.seaLevel() + && fluidHead > settings.seaLevel(); + } + boolean naturallySubmergedSurfaceColumn( HydraulicSegment segment, HydrologyTerrainSample terrain ) { - return segment.type().isSurface() && terrain.naturalHeight() <= settings.seaLevel(); + return segment.type().isSurface() && terrain.naturalHeight() < settings.seaLevel(); } boolean oceanApronEligible(HydraulicSegment segment, double distance) { @@ -1046,6 +1151,11 @@ final class HydrologyFootprintCompiler { return new MaterializedSurface(materializedSurface); } + HydrologyColumnSample surfaceColumnAt(int x, int z) { + return materializedSurface == null ? surfaceRaster.fluidColumns.get(RiverFootprint.pack(x, z)) + : materializedSurface.sample(x, z).orElse(null); + } + HydrologyColumnSample surfaceColumnAt(int x, int z, int naturalHeight) { if (materializedSurface != null) { HydrologyColumnSample sample = materializedSurface.sample(x, z).orElse(null); @@ -1111,12 +1221,13 @@ final class HydrologyFootprintCompiler { @Override public boolean ownsTerrain(int x, int z) { HydrologyColumnSample sample = footprint.sample(x, z).orElse(null); - return sample != null && sample.primarySurfaceLayer().isPresent(); + return sample != null && sample.primarySurfaceLayer().filter(HydrologyColumnLayer::terrainOwned).isPresent(); } } final class SurfaceRasterIndex implements HydrologyCaveVoxelViewFactory.PlannedSurface { private final Long2ObjectOpenHashMap surfaceColumns; + private final Long2ObjectOpenHashMap fluidColumns; private final Long2IntOpenHashMap validationNaturalHeights; private SurfaceRasterIndex( @@ -1124,7 +1235,13 @@ final class HydrologyFootprintCompiler { Long2ObjectOpenHashMap validationSamples ) { Long2ObjectLinkedOpenHashMap merged = new Long2ObjectLinkedOpenHashMap<>(); - for (SurfaceFootprint footprint : footprints) { + ArrayList allFootprints = new ArrayList<>(footprints); + for (SurfaceFootprint regional : regionalSurfaceFootprints.values()) { + if (!allFootprints.contains(regional)) { + allFootprints.add(regional); + } + } + for (SurfaceFootprint footprint : allFootprints) { for (SurfaceLayerColumn column : footprint.columns()) { addLayer(merged, column.x(), column.z(), column.terrain(), column.layer()); } @@ -1133,6 +1250,18 @@ final class HydrologyFootprintCompiler { for (Long2ObjectMap.Entry entry : merged.long2ObjectEntrySet()) { surfaceColumns.put(entry.getLongKey(), entry.getValue().build()); } + for (HydrologySurfaceDropRaster drops : regionalDropRasters.values()) { + for (HydrologyColumnSample sample : drops.columns()) { + addSurfaceFluid(merged, sample); + } + } + for (HydrologyColumnSample sample : validationSamples.values()) { + addSurfaceFluid(merged, sample); + } + this.fluidColumns = new Long2ObjectOpenHashMap<>(merged.size()); + for (Long2ObjectMap.Entry entry : merged.long2ObjectEntrySet()) { + fluidColumns.put(entry.getLongKey(), entry.getValue().build()); + } this.validationNaturalHeights = new Long2IntOpenHashMap(validationSamples.size()); for (HydrologyColumnSample sample : validationSamples.values()) { validationNaturalHeights.put( @@ -1142,6 +1271,14 @@ final class HydrologyFootprintCompiler { } } + private void addSurfaceFluid(Long2ObjectLinkedOpenHashMap columns, HydrologyColumnSample sample) { + for (HydrologyColumnLayer layer : sample.layers()) { + if (layer.publishesSurfaceFluid()) { + addLayer(columns, sample.x(), sample.z(), sampleTerrainBasis(sample.x(), sample.z()), layer); + } + } + } + @Override public int resolve(int x, int z, int naturalHeight) { long packed = RiverFootprint.pack(x, z); @@ -1158,7 +1295,7 @@ final class HydrologyFootprintCompiler { @Override public boolean ownsTerrain(int x, int z) { HydrologyColumnSample sample = surfaceColumns.get(RiverFootprint.pack(x, z)); - return sample != null && sample.primarySurfaceLayer().isPresent(); + return sample != null && sample.primarySurfaceLayer().filter(HydrologyColumnLayer::terrainOwned).isPresent(); } private HydrologyColumnSample surfaceColumnAt(int x, int z, int naturalHeight) { diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFootprintRasterizer.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFootprintRasterizer.java index 9b82a251f..9a8e0138f 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFootprintRasterizer.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFootprintRasterizer.java @@ -22,6 +22,8 @@ final class HydrologyFootprintRasterizer { boolean validationOnly, HydrologyFootprintCompiler.SurfaceRasterIndex plannedSurface ) { + Long2ObjectLinkedOpenHashMap segmentColumns = segment.type().isSurface() && segment.fallingFluid() + ? new Long2ObjectLinkedOpenHashMap<>() : columns; List centerline = continuousCenterline(segment); if (segment.fallingFluid() && centerline.size() > 1) { HydrologyPoint throat = centerline.getFirst(); @@ -36,7 +38,7 @@ final class HydrologyFootprintRasterizer { false ); rasterizePoint( - columns, + segmentColumns, course, segment, throat, @@ -52,7 +54,7 @@ final class HydrologyFootprintRasterizer { plannedSurface ); rasterizeSweptSegment( - columns, + segmentColumns, course, segment, List.copyOf(centerline.subList(1, centerline.size())), @@ -62,11 +64,14 @@ final class HydrologyFootprintRasterizer { validationOnly, plannedSurface ); + if (segmentColumns != columns) { + compiler.mergeSurfaceDrop(columns, segmentColumns); + } return; } if (sweptChannel(segment, centerline)) { rasterizeSweptSegment( - columns, + segmentColumns, course, segment, centerline, @@ -93,7 +98,7 @@ final class HydrologyFootprintRasterizer { int flowX = compiler.flowDelta(centerline, pointIndex, true); int flowZ = compiler.flowDelta(centerline, pointIndex, false); rasterizePoint( - columns, + segmentColumns, course, segment, point, @@ -109,6 +114,9 @@ final class HydrologyFootprintRasterizer { plannedSurface ); } + if (segmentColumns != columns) { + compiler.mergeSurfaceDrop(columns, segmentColumns); + } } boolean sweptChannel( @@ -172,10 +180,10 @@ final class HydrologyFootprintRasterizer { HydrologyFeatureRef[][] pointFeatures = new HydrologyFeatureRef[centerline.size()][HydrologyFootprintCompiler.FEATURE_ROLE_COUNT]; for (int z = minimumZ; z <= maximumZ; z++) { for (int x = minimumX; x <= maximumX; x++) { - if (!withinLongitudinalBounds(centerline, x, z, clipStart, clipEnd)) { + if (!withinLongitudinalBounds(centerline, segment.start(), x, z, clipStart, clipEnd)) { continue; } - FootprintCenterlineProjection projection = projectCenterline(centerline, x, z); + FootprintCenterlineProjection projection = projectCenterline(centerline, segment.start(), x, z); int pointIndex = projection.pointIndex(); HydrologyPoint point = centerline.get(pointIndex); boolean receiving = segment.receivingPool() && pointIndex == centerline.size() - 1; @@ -232,7 +240,7 @@ final class HydrologyFootprintRasterizer { && segment.type() != HydrologyFeatureType.WATERFALL && segment.type() != HydrologyFeatureType.CASCADE; HydrologyTerrainSample terrain = exactSlope ? compiler.sampleTerrain(x, z) : compiler.sampleTerrainBasis(x, z); - if (terrain == null) { + if (terrain == null || compiler.elevatedSeaLevelSurfaceColumn(segment, terrain, shape.fluidHead())) { continue; } boolean naturalOcean = classification == HydrologyRoutingTerrainSampler.NaturalClassification.OCEAN @@ -246,7 +254,7 @@ final class HydrologyFootprintRasterizer { continue; } boolean source = firstSegment && pointIndex == 0; - if (validationOnly && segment.type().isSurface()) { + if (validationOnly && segment.type().isSurface() && !segment.fallingFluid()) { compiler.feature(course, segment, x, shape.fluidHead(), z, flowX, flowZ, source && deltaX == 0 && deltaZ == 0, channel, shore, grading, false, false, pointFeatures[pointIndex]); @@ -280,7 +288,7 @@ final class HydrologyFootprintRasterizer { } } - FootprintCenterlineProjection projectCenterline(List centerline, int x, int z) { + FootprintCenterlineProjection projectCenterline(List centerline, HydrologyPoint incoming, int x, int z) { if (centerline.size() == 1) { HydrologyPoint point = centerline.getFirst(); return new FootprintCenterlineProjection( @@ -288,8 +296,8 @@ final class HydrologyFootprintRasterizer { point.x(), point.z(), StrictMath.hypot(x - point.x(), z - point.z()), - 1, - 0 + point.x() - incoming.x(), + point.z() - incoming.z() ); } int selectedPoint = 0; @@ -345,13 +353,17 @@ final class HydrologyFootprintRasterizer { boolean withinLongitudinalBounds( List centerline, + HydrologyPoint incoming, int x, int z, boolean clipStart, boolean clipEnd ) { if (centerline.size() < 2) { - return true; + HydrologyPoint point = centerline.getFirst(); + long dot = (long) (x - point.x()) * (point.x() - incoming.x()) + + (long) (z - point.z()) * (point.z() - incoming.z()); + return (!clipStart || dot >= 0L) && (!clipEnd || dot <= 0L); } HydrologyPoint start = centerline.getFirst(); HydrologyPoint afterStart = centerline.get(1); @@ -474,7 +486,7 @@ final class HydrologyFootprintRasterizer { HydrologyTerrainSample terrain = exactSlope ? compiler.sampleTerrain(x, z) : compiler.sampleTerrainBasis(x, z); - if (terrain == null) { + if (terrain == null || compiler.elevatedSeaLevelSurfaceColumn(segment, terrain, shape.fluidHead())) { continue; } boolean naturalOcean = classification == HydrologyRoutingTerrainSampler.NaturalClassification.OCEAN @@ -514,7 +526,7 @@ final class HydrologyFootprintRasterizer { ); continue; } - if (validationOnly && !underground) { + if (validationOnly && !underground && !segment.fallingFluid()) { compiler.feature( course, segment, diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyOceanBoundaryRefiner.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyOceanBoundaryRefiner.java index a53cee907..756a543ae 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyOceanBoundaryRefiner.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyOceanBoundaryRefiner.java @@ -10,7 +10,8 @@ final class HydrologyOceanBoundaryRefiner { static Result refine( List crossing, HydrologyTerrainSampler terrainSampler, - HydrologyRoutingTerrainSampler routingSampler + HydrologyRoutingTerrainSampler routingSampler, + int seaLevel ) { Objects.requireNonNull(crossing, "crossing"); Objects.requireNonNull(terrainSampler, "terrainSampler"); @@ -18,7 +19,6 @@ final class HydrologyOceanBoundaryRefiner { if (crossing.size() < 2) { return null; } - HydrologyPoint previous = crossing.getFirst(); for (int index = 1; index < crossing.size(); index++) { HydrologyPoint point = crossing.get(index); HydrologyRoutingTerrainSampler.NaturalClassification classification = @@ -27,13 +27,18 @@ final class HydrologyOceanBoundaryRefiner { return null; } if (classification == HydrologyRoutingTerrainSampler.NaturalClassification.OCEAN) { - HydrologyTerrainSample landwardTerrain = terrainSampler.sample(previous.x(), previous.z()); - if (landwardTerrain == null || landwardTerrain.ocean()) { - return null; + for (int landward = index - 1; landward >= 0; landward--) { + HydrologyPoint previous = crossing.get(landward); + HydrologyTerrainSample landwardTerrain = terrainSampler.sample(previous.x(), previous.z()); + if (landwardTerrain == null || landwardTerrain.ocean()) { + return null; + } + if (landwardTerrain.naturalHeight() >= seaLevel) { + return new Result(previous, point, landwardTerrain); + } } - return new Result(previous, point, landwardTerrain); + return null; } - previous = point; } return null; } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyOceanReceiver.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyOceanReceiver.java new file mode 100644 index 000000000..ea96fb4f0 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyOceanReceiver.java @@ -0,0 +1,258 @@ +package art.arcane.iris.engine.hydrology; + +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.longs.LongArrayFIFOQueue; +import it.unimi.dsi.fastutil.longs.LongArrayList; +import it.unimi.dsi.fastutil.longs.LongOpenHashSet; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class HydrologyOceanReceiver implements HydrologyTerrainSampler { + private static final int MAXIMUM_SAMPLES = 65_536; + private static final int[][] CARDINALS = {{-1, 0}, {0, -1}, {0, 1}, {1, 0}}; + + private final HydrologyTerrainSampler source; + private final LongOpenHashSet receiving; + private final int seaLevel; + + private HydrologyOceanReceiver(HydrologyTerrainSampler source, LongOpenHashSet receiving, int seaLevel) { + this.source = source; + this.receiving = receiving; + this.seaLevel = seaLevel; + } + + public static HydrologyTerrainSampler forOutlet(HydrologyPlannerSettings settings, + HydrologyTerrainSampler source, RiverOutlet outlet) { + if (!outlet.directOcean() || outlet.type() != HydrologyFeatureType.MOUTH) { + return source; + } + return forConnection(settings, source, outlet.landwardPoint(), outlet.connectionPoint()); + } + + public static HydrologyTerrainSampler forConnection(HydrologyPlannerSettings settings, + HydrologyTerrainSampler source, + HydrologyPoint landward, HydrologyPoint receiving) { + HydrologyTerrainSample shore = source.sample(landward.x(), landward.z()); + if (shore == null || shore.ocean() || shore.naturalHeight() < settings.seaLevel()) { + return source; + } + double width = settings.surface().maximumWidth() * shore.widthMultiplier() + * settings.surface().banks().mouthFlareRatio(); + return create(settings, source, List.of(new Mouth( + List.of(landward, receiving), width))); + } + + public static HydrologyTerrainSampler forCourse(HydrologyPlannerSettings settings, + HydrologyTerrainSampler source, RiverCourse course) { + if (course.type() != RiverCourseType.SURFACE || course.segments().isEmpty()) { + return source; + } + ArrayList mouths = new ArrayList<>(2); + for (int index = 0; index < course.segments().size(); index++) { + if (index != 0 && index != course.segments().size() - 1) { + continue; + } + Mouth mouth = mouth(settings, source, course.segments().get(index)); + if (mouth != null) { + mouths.add(mouth); + } + } + return create(settings, source, mouths); + } + + public static HydrologyTerrainSampler forMouth(HydrologyPlannerSettings settings, + HydrologyTerrainSampler source, HydraulicSegment segment) { + Mouth mouth = mouth(settings, source, segment); + return mouth == null ? source : create(settings, source, List.of(mouth)); + } + + @Override + public HydrologyTerrainSample sample(int blockX, int blockZ) { + return source.sample(blockX, blockZ); + } + + @Override + public boolean receivingWater(int blockX, int blockZ, int queriedSeaLevel) { + return queriedSeaLevel == seaLevel && receiving.contains(RiverFootprint.pack(blockX, blockZ)) + || source.receivingWater(blockX, blockZ, queriedSeaLevel); + } + + private static Mouth mouth(HydrologyPlannerSettings settings, HydrologyTerrainSampler source, + HydraulicSegment segment) { + if (segment.type() != HydrologyFeatureType.MOUTH || segment.upstreamHeadY() != settings.seaLevel() + || segment.downstreamHeadY() != settings.seaLevel()) { + return null; + } + ArrayList points = new ArrayList<>(segment.centerline()); + HydrologyPoint first = points.getFirst(); + HydrologyTerrainSample terrain = source.sample(first.x(), first.z()); + if (terrain != null && terrain.ocean() && terrain.naturalHeight() < settings.seaLevel()) { + Collections.reverse(points); + } + double width = segment.width(); + for (int station = 0; station < segment.centerline().size(); station++) { + width = Math.max(width, segment.channelProfile().widthAt(station)); + } + return new Mouth(List.copyOf(points), width); + } + + private static HydrologyTerrainSampler create(HydrologyPlannerSettings settings, + HydrologyTerrainSampler source, List mouths) { + if (mouths.isEmpty()) { + return source; + } + Samples samples = new Samples(source); + LongOpenHashSet receiving = new LongOpenHashSet(); + for (Mouth mouth : mouths) { + LongArrayList run = submergedRun(mouth.points(), samples, settings.seaLevel()); + if (run.isEmpty()) { + continue; + } + receiving.addAll(run); + double radius = mouth.width() * settings.surface().banks().channel().outlineMaximumRatio() / 2D + + settings.outlets().maximumOceanApron() + 2D; + extendHalo(receiving, run, samples, settings.seaLevel(), radius); + } + return receiving.isEmpty() ? source : new HydrologyOceanReceiver(source, receiving, settings.seaLevel()); + } + + private static LongArrayList submergedRun(List points, Samples samples, int seaLevel) { + LongArrayList run = new LongArrayList(); + if (points.size() < 2) { + return run; + } + HydrologyPoint endpoint = points.getLast(); + HydrologyTerrainSample ocean = samples.sample(endpoint.x(), endpoint.z()); + if (ocean == null || !ocean.ocean() || ocean.naturalHeight() >= seaLevel) { + return run; + } + long previous = RiverFootprint.pack(points.getFirst().x(), points.getFirst().z()); + for (int index = 0; index + 1 < points.size(); index++) { + HydrologyPoint first = points.get(index); + HydrologyPoint second = points.get(index + 1); + long steps = Math.max(Math.abs((long) second.x() - first.x()), Math.abs((long) second.z() - first.z())); + if (steps > MAXIMUM_SAMPLES) { + return new LongArrayList(); + } + for (int step = index == 0 ? 0 : 1; step <= steps; step++) { + double progress = steps == 0L ? 0D : step / (double) steps; + int x = (int) StrictMath.round(first.x() + ((long) second.x() - first.x()) * progress); + int z = (int) StrictMath.round(first.z() + ((long) second.z() - first.z()) * progress); + HydrologyTerrainSample terrain = samples.sample(x, z); + if (terrain == null) { + return new LongArrayList(); + } + long key = RiverFootprint.pack(x, z); + if (terrain.naturalHeight() >= seaLevel) { + if (!run.isEmpty()) { + return new LongArrayList(); + } + } else { + if (!run.isEmpty() && !cardinalBridge(run, previous, key, samples, seaLevel)) { + return new LongArrayList(); + } + if (run.isEmpty() || run.getLong(run.size() - 1) != key) { + run.add(key); + } + } + previous = key; + } + } + return run; + } + + private static boolean cardinalBridge(LongArrayList run, long previous, long next, + Samples samples, int seaLevel) { + int previousX = RiverFootprint.unpackX(previous); + int previousZ = RiverFootprint.unpackZ(previous); + int nextX = RiverFootprint.unpackX(next); + int nextZ = RiverFootprint.unpackZ(next); + if (previousX == nextX || previousZ == nextZ) { + return true; + } + long first = RiverFootprint.pack(previousX, nextZ); + long second = RiverFootprint.pack(nextX, previousZ); + for (long bridge : new long[] {Math.min(first, second), Math.max(first, second)}) { + HydrologyTerrainSample terrain = samples.sample(RiverFootprint.unpackX(bridge), RiverFootprint.unpackZ(bridge)); + if (terrain != null && terrain.naturalHeight() < seaLevel) { + run.add(bridge); + return true; + } + } + return false; + } + + private static void extendHalo(LongOpenHashSet receiving, LongArrayList run, Samples samples, + int seaLevel, double radius) { + long first = run.getLong(0); + int anchorX = RiverFootprint.unpackX(first); + int anchorZ = RiverFootprint.unpackZ(first); + double squaredRadius = radius * radius; + LongOpenHashSet visited = new LongOpenHashSet(); + LongArrayFIFOQueue queue = new LongArrayFIFOQueue(); + for (int index = 0; index < run.size(); index++) { + long key = run.getLong(index); + double dx = (double) RiverFootprint.unpackX(key) - anchorX; + double dz = (double) RiverFootprint.unpackZ(key) - anchorZ; + if (dx * dx + dz * dz <= squaredRadius && visited.add(key)) { + queue.enqueue(key); + } + } + while (!queue.isEmpty() && samples.available()) { + long key = queue.dequeueLong(); + int x = RiverFootprint.unpackX(key); + int z = RiverFootprint.unpackZ(key); + for (int[] offset : CARDINALS) { + long nextX = (long) x + offset[0]; + long nextZ = (long) z + offset[1]; + double dx = nextX - anchorX; + double dz = nextZ - anchorZ; + if (nextX < Integer.MIN_VALUE || nextX > Integer.MAX_VALUE + || nextZ < Integer.MIN_VALUE || nextZ > Integer.MAX_VALUE + || dx * dx + dz * dz > squaredRadius) { + continue; + } + long next = RiverFootprint.pack((int) nextX, (int) nextZ); + if (!visited.add(next)) { + continue; + } + HydrologyTerrainSample terrain = samples.sample((int) nextX, (int) nextZ); + if (terrain != null && terrain.naturalHeight() < seaLevel) { + receiving.add(next); + queue.enqueue(next); + } + } + } + } + + private record Mouth(List points, double width) { + } + + private static final class Samples { + private final HydrologyTerrainSampler source; + private final Long2ObjectOpenHashMap samples = new Long2ObjectOpenHashMap<>(); + + private Samples(HydrologyTerrainSampler source) { + this.source = source; + } + + private HydrologyTerrainSample sample(int x, int z) { + long key = RiverFootprint.pack(x, z); + if (samples.containsKey(key)) { + return samples.get(key); + } + if (!available()) { + return null; + } + HydrologyTerrainSample terrain = source.sample(x, z); + samples.put(key, terrain); + return terrain; + } + + private boolean available() { + return samples.size() < MAXIMUM_SAMPLES; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyOutletPlanner.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyOutletPlanner.java index 4062b77a4..d4ee9c8ed 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyOutletPlanner.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyOutletPlanner.java @@ -142,13 +142,14 @@ final class HydrologyOutletPlanner { continue; } HydrologyOceanBoundaryRefiner.Result boundary = refineOceanBoundary(land, ocean); - if (boundary == null + if (boundary == null) { + continue; + } + HydrologyTerrainSample receiving = planner.sampleBasisWithoutSlope(boundary.oceanPoint().x(), boundary.oceanPoint().z()); + if (receiving == null || !boundary.landwardTerrain().drainsInto(receiving) || !boundary.landwardTerrain().transitAllowed() || !boundary.landwardTerrain().outletAllowed() - || surface && !HydrologySurfaceProfiles.sharesProfile( - boundary.landwardTerrain(), - planner.sampleBasisWithoutSlope(boundary.oceanPoint().x(), boundary.oceanPoint().z()) - )) { + || surface && !HydrologySurfaceProfiles.sharesProfile(boundary.landwardTerrain(), receiving)) { continue; } HydrologyFeatureType type = coastalOutletType(boundary.landwardTerrain()); @@ -478,7 +479,7 @@ final class HydrologyOutletPlanner { HydrologyOceanBoundaryRefiner.Result refineOceanBoundary(HydrologyGridNode land, HydrologyGridNode ocean) { List crossing = planner.segments.line(land.naturalPoint(), ocean.naturalPoint(), 1); - return HydrologyOceanBoundaryRefiner.refine(crossing, planner.sampler, planner.routingSampler); + return HydrologyOceanBoundaryRefiner.refine(crossing, planner.sampler, planner.routingSampler, planner.settings.seaLevel()); } HydrologyFeatureType coastalOutletType(HydrologyTerrainSample land) { 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 809a62644..27855885a 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 @@ -61,6 +61,7 @@ public final class HydrologyPlanner { final HydrologyUndergroundCoursePlanner undergroundCourses; final HydrologySegmentBuilder segments; final HydrologyFeatureSitePlanner featureSites; + final HydrologyRegionalPlanner regional; public HydrologyPlanner(long worldSeed, HydrologyPlannerSettings settings, HydrologyTerrainSampler sampler) { this( @@ -193,6 +194,7 @@ public final class HydrologyPlanner { this.undergroundCourses = new HydrologyUndergroundCoursePlanner(this); this.segments = new HydrologySegmentBuilder(this); this.featureSites = new HydrologyFeatureSitePlanner(this); + this.regional = new HydrologyRegionalPlanner(this); } public long worldSeed() { @@ -246,6 +248,7 @@ public final class HydrologyPlanner { void clearOwnerDrafts() { resolvedOwners.invalidateAll(); + regional.clear(); } void reuseResolvedTile(HydrologyTile tile) { @@ -262,6 +265,7 @@ public final class HydrologyPlanner { tile.courses(), tile.cavePlans() ); + result = regional.withoutRegional(result, tile.key()); HydrologyOwnerDraft draft = new HydrologyOwnerDraft( tile.key(), result, diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettings.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettings.java index 0376ad68e..81c666faa 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettings.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettings.java @@ -20,7 +20,7 @@ public record HydrologyPlannerSettings( SeaCaves seaCaves, SurfacePolicyBounds surfacePolicyBounds ) { - private static final long PLAN_FORMAT_REVISION = 6L; + private static final long PLAN_FORMAT_REVISION = 8L; private static final int MAXIMUM_CROSS_TILE_COLOR_PERIOD = 4; public HydrologyPlannerSettings { @@ -52,7 +52,7 @@ public record HydrologyPlannerSettings( Source undergroundSources = new Source(true, 0.25D, Integer.MIN_VALUE, 0, 1, 512); return new HydrologyPlannerSettings( 63, - new Routing(2048, 64, 8192, 8192, 384, 192, 1.5D, 24D, 2D, 0.2D, 1D, 0), + new Routing(2048, 64, 8192, 8192, 384, 192, 1.5D, 24D, 2D, 0.2D, 1D, 0, HydrologyPlannerSettings.Regional.disabled()), new Surface(true, surfaceSources, 4, 8, 2, 4, 10, 1.5D, Banks.defaults()), new Hydraulics(8), Underground.of(true, undergroundSources, -48, 72, 3, 8, 1, 3, 6, 14, true, 1), @@ -216,9 +216,11 @@ public record HydrologyPlannerSettings( double slopePenalty, double confluenceAttraction, double lengthPreference, - int tributaries + int tributaries, + Regional regional ) { public Routing { + Objects.requireNonNull(regional, "regional"); if (tileSize < 32 || sampleSpacing < 4 || tileSize % sampleSpacing != 0) { throw new IllegalArgumentException("Routing sizes must form an exact bounded tile lattice."); } @@ -230,6 +232,9 @@ public record HydrologyPlannerSettings( if (maximumRouteNodes < latticeNodes || maximumRouteNodes > 1_000_000 || maximumRouteLength < 1) { throw new IllegalArgumentException("maximumRouteNodes must contain the complete lattice and remain bounded."); } + if (regional.enabled() && maximumRouteLength > 32_768) { + throw new IllegalArgumentException("Regional route length cannot exceed 32768 blocks."); + } if (minimumSurfaceCourseLength < 0 || minimumSurfaceCourseLength > 32_768 || minimumUndergroundCourseLength < 0 || minimumUndergroundCourseLength > 32_768) { throw new IllegalArgumentException("Hydrology course lengths are invalid."); @@ -265,6 +270,38 @@ public record HydrologyPlannerSettings( } } + public record Regional( + boolean enabled, + int sampleSpacing, + int minimumLength, + int maximumTrunks, + int maximumCachedBasins, + int maximumCachedStations, + boolean coastalChannels, + double coastalChannelChance, + int maximumCoastalIncision + ) { + public Regional { + if (sampleSpacing < 128 || sampleSpacing > 1024 || Integer.bitCount(sampleSpacing) != 1 + || minimumLength < 256 || minimumLength > 32768 + || maximumTrunks < 1 || maximumTrunks > 8 + || maximumCachedBasins < 1 || maximumCachedBasins > 64 + || maximumCachedStations < 16384 || maximumCachedStations > 1048576 + || !Double.isFinite(coastalChannelChance) || coastalChannelChance < 0D || coastalChannelChance > 1D + || maximumCoastalIncision < 1 || maximumCoastalIncision > 32) { + throw new IllegalArgumentException("Regional drainage settings exceed their bounded ranges."); + } + } + + public static Regional disabled() { + return new Regional(false, 256, 2048, 2, 8, 131072, false, 0.25D, 8); + } + + public int basinSize(Routing routing) { + return Math.max(sampleSpacing * 4, Integer.highestOneBit(routing.maximumRouteLength())); + } + } + public record Source( boolean enabled, double density, @@ -451,9 +488,11 @@ public record HydrologyPlannerSettings( double cliffFraction, IrisRiverBedProfile bedProfile, double shoreRise, - double blendBaseWidth + double blendBaseWidth, + Excavation excavation ) { public Erosion { + Objects.requireNonNull(excavation, "excavation"); if (smoothingRadius < 0 || !Double.isFinite(thalwegFraction) || thalwegFraction < 0D || thalwegFraction >= 1D || !Double.isFinite(blendCurve) || blendCurve <= 0D @@ -470,20 +509,33 @@ public record HydrologyPlannerSettings( } public static Erosion defaults() { - return new Erosion(true, 12, 0.45D, 1D, 0.5D, IrisRiverBlendStyle.SMOOTH, 4, 0.5D, IrisRiverBedProfile.BOWL, 0D, 0D); + return new Erosion(true, 12, 0.45D, 1D, 0.5D, IrisRiverBlendStyle.SMOOTH, 4, 0.5D, IrisRiverBedProfile.BOWL, 0D, 0D, Excavation.defaults()); } /** The erosion amounts with the smooth valley side, bowl bed and flat shore bench they always had. */ public static Erosion of(boolean enabled, int smoothingRadius, double thalwegFraction, double blendCurve, double bedNoise) { return new Erosion(enabled, smoothingRadius, thalwegFraction, blendCurve, bedNoise, - IrisRiverBlendStyle.SMOOTH, 4, 0.5D, IrisRiverBedProfile.BOWL, 0D, 0D); + IrisRiverBlendStyle.SMOOTH, 4, 0.5D, IrisRiverBedProfile.BOWL, 0D, 0D, Excavation.defaults()); } // Enum hash codes are identity based and change between runs; the plan fingerprint mixes this hash in. @Override public int hashCode() { return Objects.hash(enabled, smoothingRadius, thalwegFraction, blendCurve, bedNoise, style.name(), - terraceSteps, cliffFraction, bedProfile.name(), shoreRise, blendBaseWidth); + terraceSteps, cliffFraction, bedProfile.name(), shoreRise, blendBaseWidth, excavation); + } + } + + public record Excavation(int maximumDepth, int maximumWidth, int maximumVolumePerBlock) { + public Excavation { + if (maximumDepth < 0 || maximumDepth > 64 || maximumWidth < 1 || maximumWidth > 64 + || maximumVolumePerBlock < 0 || maximumVolumePerBlock > 8192) { + throw new IllegalArgumentException("Surface bank excavation limits are invalid."); + } + } + + public static Excavation defaults() { + return new Excavation(8, 16, 256); } } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRegionalConnectivity.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRegionalConnectivity.java new file mode 100644 index 000000000..6204e0432 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRegionalConnectivity.java @@ -0,0 +1,167 @@ +package art.arcane.iris.engine.hydrology; + +import art.arcane.iris.engine.hydrology.surface.SurfaceCenterline; +import art.arcane.iris.engine.hydrology.surface.SurfaceLayerColumn; +import it.unimi.dsi.fastutil.longs.Long2LongOpenHashMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.longs.LongArrayList; +import it.unimi.dsi.fastutil.longs.LongArrayFIFOQueue; +import it.unimi.dsi.fastutil.longs.LongOpenHashSet; + +import java.util.List; + +final class HydrologyRegionalConnectivity { + private static final int[][] NEIGHBORS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + + private final Long2LongOpenHashMap water = new Long2LongOpenHashMap(); + private final Long2ObjectOpenHashMap disjoint = new Long2ObjectOpenHashMap<>(); + + void add(List columns) { + for (SurfaceLayerColumn column : columns) { + HydrologyColumnLayer layer = column.layer(); + if (layer.fluidOwned() && layer.bedY() < layer.fluidHeadY()) { + addFluidColumn(column.x(), column.z(), layer.bedY(), layer.fluidHeadY()); + } + } + } + + void add(HydrologyColumnSample column) { + for (HydrologyColumnLayer layer : column.layers()) { + if (layer.fluidOwned() && layer.bedY() < layer.fluidHeadY()) { + addFluidColumn(column.x(), column.z(), layer.bedY(), layer.fluidHeadY()); + } + } + } + + void addFluidColumn(int x, int z, int bed, int head) { + long key = RiverFootprint.pack(x, z); + long interval = levels(bed, head); + if (!water.containsKey(key)) { + water.put(key, interval); + return; + } + long previous = water.get(key); + LongArrayList gaps = disjoint.get(key); + if (gaps == null && bed <= (int) previous && (int) (previous >> 32) <= head) { + water.put(key, levels(Math.min(bed, (int) (previous >> 32)), Math.max(head, (int) previous))); + return; + } + if (gaps == null) { + gaps = new LongArrayList(3); + gaps.add(previous); + disjoint.put(key, gaps); + } + gaps.add(interval); + gaps.sort(null); + int retained = 1; + for (int index = 1; index < gaps.size(); index++) { + long first = gaps.getLong(retained - 1); + long next = gaps.getLong(index); + if ((int) (next >> 32) <= (int) first) { + gaps.set(retained - 1, levels((int) (first >> 32), Math.max((int) first, (int) next))); + } else { + gaps.set(retained++, next); + } + } + gaps.size(retained); + water.put(key, gaps.getLong(0)); + if (retained == 1) { + disjoint.remove(key); + } + } + + boolean connected(RiverCourse course, HydrologyTerrainSampler sampler, HydrologyPlannerSettings settings) { + if (water.isEmpty() || !disjoint.isEmpty()) { + return false; + } + LongOpenHashSet visited = new LongOpenHashSet(water.size()); + LongArrayFIFOQueue queue = new LongArrayFIFOQueue(); + long start = water.keySet().iterator().nextLong(); + queue.enqueue(start); + visited.add(start); + while (!queue.isEmpty()) { + long current = queue.dequeueLong(); + long currentLevels = water.get(current); + int currentBed = (int) (currentLevels >> 32); + int currentHead = (int) currentLevels; + int x = RiverFootprint.unpackX(current); + int z = RiverFootprint.unpackZ(current); + for (int[] offset : NEIGHBORS) { + long next = RiverFootprint.pack(x + offset[0], z + offset[1]); + if (!water.containsKey(next) || visited.contains(next)) { + continue; + } + long levels = water.get(next); + if (Math.max(currentBed, (int) (levels >> 32)) >= Math.min(currentHead, (int) levels)) { + continue; + } + visited.add(next); + queue.enqueue(next); + } + } + if (visited.size() != water.size()) { + return false; + } + HydraulicSegment first = course.segments().getFirst(); + HydraulicSegment last = course.segments().getLast(); + return reaches(last, false, sampler, settings, visited) + && (first.type() != HydrologyFeatureType.MOUTH || reaches(first, true, sampler, settings, visited)); + } + + private boolean reaches(HydraulicSegment mouth, boolean upstream, HydrologyTerrainSampler sampler, + HydrologyPlannerSettings settings, LongOpenHashSet visited) { + int seaLevel = settings.seaLevel(); + if (mouth.type() != HydrologyFeatureType.MOUTH || mouth.upstreamHeadY() != seaLevel + || mouth.downstreamHeadY() != seaLevel) { + return false; + } + HydrologyPoint endpoint = upstream ? mouth.start() : mouth.end(); + HydrologyTerrainSample terrain = sampler.sample(endpoint.x(), endpoint.z()); + if (terrain == null || !terrain.ocean() || terrain.naturalHeight() >= seaLevel) { + return false; + } + HydrologyTerrainSampler receiver = HydrologyOceanReceiver.forMouth(settings, sampler, mouth); + SurfaceCenterline centerline = SurfaceCenterline.densify(mouth.centerline()); + int step = upstream ? 1 : -1; + int previousX = endpoint.x(); + int previousZ = endpoint.z(); + for (int station = upstream ? 0 : centerline.size() - 1; station >= 0 && station < centerline.size(); station += step) { + int x = centerline.x()[station]; + int z = centerline.z()[station]; + if (x != previousX && z != previousZ) { + boolean firstBridge = receiver.receivingWater(previousX, z, seaLevel); + boolean secondBridge = receiver.receivingWater(x, previousZ, seaLevel); + if (!firstBridge && !secondBridge) { + return false; + } + if (firstBridge && touchesOwnedWater(previousX, z, seaLevel, visited) + || secondBridge && touchesOwnedWater(x, previousZ, seaLevel, visited)) { + return true; + } + } + if (!receiver.receivingWater(x, z, seaLevel)) { + return false; + } + if (touchesOwnedWater(x, z, seaLevel, visited)) { + return true; + } + previousX = x; + previousZ = z; + } + return false; + } + + private boolean touchesOwnedWater(int x, int z, int seaLevel, LongOpenHashSet visited) { + for (int[] offset : NEIGHBORS) { + long key = RiverFootprint.pack(x + offset[0], z + offset[1]); + if (visited.contains(key) && (int) water.get(key) == seaLevel) { + return true; + } + } + return false; + } + + private static long levels(int bed, int head) { + return ((long) bed << 32) | (head & 0xffffffffL); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRegionalCourseGraph.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRegionalCourseGraph.java new file mode 100644 index 000000000..b7c7e6097 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRegionalCourseGraph.java @@ -0,0 +1,65 @@ +package art.arcane.iris.engine.hydrology; + +import it.unimi.dsi.fastutil.ints.IntArrayList; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +record HydrologyRegionalCourseGraph(List nodes, List edges, int discharge) { + private static final long NODE_SALT = 0x5245475055424c49L; + + HydrologyRegionalCourseGraph { + nodes = List.copyOf(nodes); + edges = List.copyOf(edges); + } + + static HydrologyRegionalCourseGraph create(HydrologyTerrainSampler sampler, Options options) { + List path = options.path(); + double[] distance = new double[path.size()]; + IntArrayList selected = new IntArrayList(); + selected.add(0); + double next = options.spacing(); + for (int index = 1; index < path.size(); index++) { + HydrologyPoint previous = path.get(index - 1); + HydrologyPoint point = path.get(index); + distance[index] = distance[index - 1] + StrictMath.hypot( + (double) point.x() - previous.x(), (double) point.z() - previous.z()); + if (distance[index] >= next || index == path.size() - 1) { + selected.add(index); + next = distance[index] + options.spacing(); + } + } + ArrayList nodes = new ArrayList<>(selected.size()); + ArrayList edges = new ArrayList<>(Math.max(0, selected.size() - 1)); + double length = distance[distance.length - 1]; + int contribution = 1; + for (int index = 0; index < selected.size(); index++) { + int station = selected.getInt(index); + HydrologyPoint point = path.get(station); + HydrologyTerrainSample terrain = Objects.requireNonNull(sampler.sample(point.x(), point.z()), + "Accepted regional drainage terrain"); + DrainageNode node = new DrainageNode(HydrologyHash.mix(options.courseId(), NODE_SALT, index, point.x(), point.z()), + point.x(), point.z(), terrain, length - distance[station], options.outletId()); + nodes.add(node); + contribution = Math.max(contribution, options.flow().contributionAt(point.x(), point.z())); + if (index > 0) { + DrainageNode upstream = nodes.get(index - 1); + edges.add(new DrainageEdge(HydrologyHash.mix(options.courseId(), upstream.id(), node.id()), + upstream.id(), node.id(), options.outletId(), upstream.potential() - node.potential(), + contribution, 0, List.of(upstream.naturalPoint(), node.naturalPoint()))); + } + } + return new HydrologyRegionalCourseGraph(nodes, edges, contribution); + } + + record Options(long courseId, long outletId, int spacing, List path, HydrologyRegionalFlow flow) { + Options { + path = List.copyOf(path); + Objects.requireNonNull(flow, "Regional drainage flow"); + if (spacing < 1 || path.size() < 2) { + throw new IllegalArgumentException("Regional drainage requires positive spacing and a complete path."); + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRegionalFalls.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRegionalFalls.java new file mode 100644 index 000000000..5d09c1dd2 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRegionalFalls.java @@ -0,0 +1,135 @@ +package art.arcane.iris.engine.hydrology; + +import art.arcane.iris.engine.hydrology.cave.CavePosition; +import art.arcane.iris.engine.hydrology.cave.CaveVoxelPrecondition; +import art.arcane.iris.engine.hydrology.cave.CaveVoxelView; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; +import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan; +import art.arcane.iris.engine.hydrology.surface.SurfaceBounds; +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.LongOpenHashSet; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +final class HydrologyRegionalFalls implements HydrologyCaveVoxelViewFactory.PlannedSurface { + private static final int WINDOW_SIZE = 128; + private static final int MAXIMUM_ROOF_WINDOWS = 4; + private static final int[][] NEIGHBORS = {{0, 0}, {1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + + private final HydrologyPlanner planner; + private final RiverCourse course; + private final HydrologySurfaceDropRaster drops; + private final HydrologyTerrainSampler receiver; + private final LongOpenHashSet witnessColumns; + private final ArrayList exposed; + private final SurfaceFootprintCompiler surface; + private final LinkedHashMap> roofWindows; + + HydrologyRegionalFalls(HydrologyPlanner planner, RiverCourse course) { + this.planner = planner; + this.course = course; + this.receiver = HydrologyOceanReceiver.forCourse(planner.settings, this::sample, course); + this.drops = HydrologySurfaceDropRaster.compile(planner.settings, receiver, planner.geometrySampler, course); + this.witnessColumns = new LongOpenHashSet(); + this.exposed = new ArrayList<>(); + this.surface = new SurfaceFootprintCompiler(planner.settings, this::sample, planner.geometrySampler); + this.roofWindows = new LinkedHashMap<>(MAXIMUM_ROOF_WINDOWS + 1, 1F, true); + for (HydrologyColumnSample column : drops.columns()) { + for (int[] offset : NEIGHBORS) { + witnessColumns.add(RiverFootprint.pack(column.x() + offset[0], column.z() + offset[1])); + } + } + } + + void retain(SurfaceFootprint footprint) { + for (SurfaceLayerColumn column : footprint.columns()) { + if (witnessColumns.contains(RiverFootprint.pack(column.x(), column.z()))) { + exposed.add(column); + } + } + } + + List validate(HydrologyRegionalNetwork network, List diagnostics) { + if (drops.columns().isEmpty()) { + return List.of(); + } + HydrologyFootprintCompiler compiler = new HydrologyFootprintCompiler(planner.settings, receiver, planner.geometrySampler); + HydrologyFootprintCompiler.ValidationRaster validation = compiler.compileSurfaceDropValidation(course, + new SurfaceFootprint(exposed, 0, null, 0, 0L), drops); + HydrologyObservedPlannedSurface planned = new HydrologyObservedPlannedSurface(this); + CaveVoxelView view = Objects.requireNonNull(planner.caveViewFactory.create(planned)); + ArrayList failures = new ArrayList<>(); + HydrologyCaveCourseFilter.Result result = new HydrologyCaveCourseFilter(view, + new HydrologyCaveCourseFilter.Options(planner.settings.underground().connectToExistingCaves(), + planner.settings.outlets().coastalGrotto().maximumVolume(), + planner.settings.outlets().inlandGrotto().maximumVolume()), null, null, planned) + .filter(network.nodes(), network.edges(), network.outlets(), List.of(course), validation, failures); + for (HydrologyDiagnosticCandidate failure : failures) { + if (diagnostics.size() >= 64) { + break; + } + diagnostics.add(new HydrologyDiagnosticCandidate(failure.id(), HydrologyCandidateKind.REGIONAL_SOURCE, + failure.projectedType(), failure.point(), failure.rejection(), failure.detail())); + } + return result.courses().isEmpty() ? null : result.cavePlans(); + } + + static HydrologyCavePlan clip(HydrologyCavePlan plan, SurfaceBounds bounds) { + LinkedHashMap actions = new LinkedHashMap<>(); + LinkedHashMap preconditions = new LinkedHashMap<>(); + plan.forEachActionIn(bounds.minimumX(), bounds.minimumZ(), Math.addExact(bounds.maximumX(), 1), + Math.addExact(bounds.maximumZ(), 1), actions::put); + plan.allPreconditionsIn(bounds.minimumX(), bounds.minimumZ(), Math.addExact(bounds.maximumX(), 1), + Math.addExact(bounds.maximumZ(), 1), (position, precondition) -> { + preconditions.put(position, precondition); + return true; + }); + return new HydrologyCavePlan(plan.source(), plan.rejection(), actions, preconditions, plan.arbitrationWinnerSourceId()); + } + + @Override + public int resolve(int x, int z, int naturalHeight) { + HydrologyColumnSample column = column(x, z); + return column == null ? naturalHeight : column.terrainHeight(); + } + + @Override + public boolean ownsTerrain(int x, int z) { + HydrologyColumnSample column = column(x, z); + HydrologyColumnLayer layer = column == null ? null : column.primarySurfaceLayerOrNull(); + return layer != null && layer.terrainOwned(); + } + + private HydrologyColumnSample column(int x, int z) { + HydrologyTileKey key = HydrologyTileKey.fromBlock(x, z, WINDOW_SIZE); + Map columns = roofWindows.get(key); + if (columns == null) { + SurfaceBounds bounds = new SurfaceBounds(key.minimumBlockX(WINDOW_SIZE), key.minimumBlockZ(WINDOW_SIZE), + Math.addExact(key.minimumBlockX(WINDOW_SIZE), WINDOW_SIZE - 1), + Math.addExact(key.minimumBlockZ(WINDOW_SIZE), WINDOW_SIZE - 1)); + LinkedHashMap built = new LinkedHashMap<>(); + for (SurfaceLayerColumn column : surface.compile(course, bounds).columns()) { + HydrologyTerrainSample terrain = column.terrain(); + HydrologyColumnSample value = new HydrologyColumnSample(column.x(), column.z(), terrain.naturalHeight(), + planner.settings.seaLevel(), terrain.ocean(), terrain.parentBiomeKey(), List.of(column.layer())); + built.merge(RiverFootprint.pack(column.x(), column.z()), value, HydrologyRegionalPlanner::mergeColumn); + } + columns = Map.copyOf(built); + roofWindows.put(key, columns); + if (roofWindows.size() > MAXIMUM_ROOF_WINDOWS) { + roofWindows.remove(roofWindows.sequencedKeySet().getFirst()); + } + } + return columns.get(RiverFootprint.pack(x, z)); + } + + private HydrologyTerrainSample sample(int x, int z) { + return planner.naturalSampler == null ? planner.sampler.sample(x, z) : planner.naturalSampler.sampleBasisWithoutSlope(x, z); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRegionalFlow.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRegionalFlow.java new file mode 100644 index 000000000..7d62df04e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRegionalFlow.java @@ -0,0 +1,53 @@ +package art.arcane.iris.engine.hydrology; + +import java.util.List; + +final class HydrologyRegionalFlow { + private final List points; + private final int[] contributions; + private final int maximum; + + HydrologyRegionalFlow(List points, int[] accumulated) { + if (points.size() != accumulated.length) { + throw new IllegalArgumentException("Regional flow requires one contribution per guide point."); + } + this.points = List.copyOf(points); + this.contributions = new int[accumulated.length]; + int previous = 1; + for (int index = 0; index < accumulated.length; index++) { + previous = Math.max(previous, accumulated[index]); + contributions[index] = previous; + } + this.maximum = previous; + } + + double fraction(int x, int z) { + return StrictMath.sqrt(contribution(x, z) / maximum); + } + + int contributionAt(int x, int z) { + return Math.max(1, (int) StrictMath.round(contribution(x, z))); + } + + private double contribution(int x, int z) { + double nearestDistance = Double.POSITIVE_INFINITY; + double contribution = contributions[0]; + for (int index = 0; index + 1 < points.size(); index++) { + HydrologyPoint first = points.get(index); + HydrologyPoint next = points.get(index + 1); + double deltaX = next.x() - first.x(); + double deltaZ = next.z() - first.z(); + double length = deltaX * deltaX + deltaZ * deltaZ; + double progress = length <= 0D ? 0D : Math.max(0D, Math.min(1D, + ((x - first.x()) * deltaX + (z - first.z()) * deltaZ) / length)); + double distanceX = x - first.x() - deltaX * progress; + double distanceZ = z - first.z() - deltaZ * progress; + double distance = distanceX * distanceX + distanceZ * distanceZ; + if (distance < nearestDistance) { + nearestDistance = distance; + contribution = contributions[index] + (contributions[index + 1] - contributions[index]) * progress; + } + } + return contribution; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRegionalGraph.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRegionalGraph.java new file mode 100644 index 000000000..798fb0009 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRegionalGraph.java @@ -0,0 +1,202 @@ +package art.arcane.iris.engine.hydrology; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.PriorityQueue; + +final class HydrologyRegionalGraph { + private static final int LABELS_PER_GRID_NODE = 64; + private static final int MAXIMUM_LABELS = 1_048_576; + private static final Comparator