Hydrology fixes

This commit is contained in:
Brian Neumann-Fopiano
2026-09-10 23:07:46 -04:00
parent 577f924133
commit bf12f30d26
168 changed files with 13544 additions and 582 deletions
@@ -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;
}
@@ -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}) {
@@ -210,6 +210,10 @@ final class PackHydrologyValidator {
}
private static void validateGeometry(String path, JSONObject geometry, List<String> 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<String> 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);
@@ -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<String> 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());
}
}
}
@@ -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<Double> unblendedNaturalHeightStream;
private final ResolvedTerrainProvider resolvedTerrain;
private ProceduralStream<Double> 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<Double> 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<Terrain3DColumn> 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<Double> getRawHeightStream() {
@@ -1893,6 +1963,9 @@ public class IrisComplex implements DataProvider {
}
public void close() {
if (hydrologyBanks3D != null) {
hydrologyBanks3D.clear();
}
if (terrain3D != null) {
terrain3D.clear();
}
@@ -140,12 +140,12 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBl
int terrainSpan = terrainColumn == null ? -1 : terrainColumn.spanCount() - 1;
int layerSurfaceY = he;
int layerCeilingY = 0;
int cut = exposeCutStrata
boolean exposeRiverStrata = exposeCutStrata
&& hydrologyTerrain != null
&& hydrologyTerrain.terrainOwned()
&& !hydrologyTerrain.channel()
? Math.max(0, hydrology.naturalHeight() - he)
: 0;
&& !hydrologyTerrain.channel();
Terrain3DColumn naturalColumn = exposeRiverStrata ? complex.naturalTerrainColumn(realX, realZ) : null;
int cut = 0;
boolean riverOwned = padRiverBed && hydrologyTerrain != null && hydrologyTerrain.terrainOwned();
IrisRiverMaterialConfig roleMaterial = hydrologyRoleMaterial(
hydrologyTerrain, bedMaterial, shoreMaterial, bankMaterial);
@@ -227,6 +227,10 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBl
continue;
}
if (blocks == null) {
if (exposeRiverStrata) {
int naturalSurfaceY = naturalColumn == null ? hydrology.naturalHeight() : naturalColumn.surfaceY(layerSurfaceY);
cut = Math.max(0, naturalSurfaceY - layerSurfaceY);
}
blocks = biome.generateLayers(dimension, realX, realZ, localRng,
layerSurfaceY + cut, layerSurfaceY + cut, data, complex);
}
@@ -242,12 +246,10 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBl
continue;
}
if (blocks.hasIndex(depth + cut)) {
PlatformBlockState layerBlock = blocks.get(depth + cut);
if (roleMaterial != null) {
layerBlock = paintHydrologyMaterial(
layerBlock, roleMaterial, depth, localRng, realX, i, realZ, data);
}
PlatformBlockState layerBlock = paintHydrologyMaterial(
blocks.hasIndex(depth + cut) ? blocks.get(depth + cut) : null,
roleMaterial, depth, localRng, realX, i, realZ, data);
if (layerBlock != null) {
if (riverOwned && depth <= riverBed.getPadding() && IrisProceduralBlocks.isGravityAffected(layerBlock)) {
layerBlock = riverBed.getPaddingPalette().get(localRng, realX, i, realZ, data);
}
@@ -55,11 +55,13 @@ final class DecoratorCore {
boolean caveSkipFluid;
boolean underwater;
int fluidHeight;
EngineMantle mantle;
void reset() {
caveSkipFluid = false;
underwater = false;
fluidHeight = 0;
mantle = null;
}
}
@@ -138,6 +140,11 @@ final class DecoratorCore {
}
}
if (!IrisSugarCane.canPlace(bd, data, x, height + 1, z, realX, realZ,
mantle == null ? null : mantle.getEngine())) {
return;
}
if (IrisSpeleothems.isSpike(bd)) {
placeSingleSpike(bd, data, x, z, height + 1, true, underwater && !caveSkipFluid);
return;
@@ -190,6 +197,10 @@ final class DecoratorCore {
if (bd == null) {
return;
}
if (!IrisSugarCane.canPlace(bd, data, x, height, z, realX, realZ,
mantle == null ? null : mantle.getEngine())) {
return;
}
if (IrisSpeleothems.isSpike(bd)) {
if (height + 1 < data.getHeight() && allowsSurface(decorator, data.get(x, height + 1, z), irisData)) {
placeSingleSpike(bd, data, x, z, height, false, false);
@@ -234,6 +245,10 @@ final class DecoratorCore {
if (block == null) {
break;
}
if (!IrisSugarCane.canPlace(block, data, x, y, z, realX, realZ,
opts.mantle == null ? null : opts.mantle.getEngine())) {
break;
}
if (IrisSpeleothems.isSpike(block)) {
if (!IrisSpeleothems.canPlace(block, data, x, z, y, true, opts.underwater)) {
break;
@@ -284,6 +299,10 @@ final class DecoratorCore {
if (block == null) {
break;
}
if (!IrisSugarCane.canPlace(block, data, x, y, z, realX, realZ,
mantle == null ? null : mantle.getEngine())) {
break;
}
if (IrisSpeleothems.isSpike(block)) {
if (!IrisSpeleothems.canPlace(block, data, x, z, y, false, opts.underwater)) {
break;
@@ -306,11 +325,15 @@ final class DecoratorCore {
static void placeFloatingSimple(IrisDecorator decorator,
int xf, int zf, int realX, int realZ,
int height, int max, Hunk<PlatformBlockState> 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<PlatformBlockState> 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) {
@@ -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());
}
}
@@ -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;
}
@@ -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<PlatformBlockState> 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<TerrainBoundarySignature> 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<PlatformBlockState> 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));
}
}
@@ -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;
@@ -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;
};
@@ -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);
}
@@ -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)) {
@@ -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;
}
@@ -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(),
@@ -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) + "]";
}
}
@@ -13,11 +13,13 @@ public record HydraulicSegment(
int depth,
boolean fallingFluid,
boolean receivingPool,
List<HydrologyPoint> centerline
List<HydrologyPoint> 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.");
@@ -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
}
@@ -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;
}
@@ -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<CavePosition, HydrologyCaveAction> 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<HydraulicSegment> 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);
}
}
}
@@ -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) {
@@ -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) {
@@ -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);
}
}
@@ -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<Long, List<HydrologyPoint>> refinedEdges = new HashMap<>();
HashMap<SourceCompilationKey, SourceCompilation> 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<Long> regionalIds = new HashSet<>();
for (RiverCourse course : regional.courses()) {
regionalIds.add(course.id());
}
ArrayList<HydrologyCavePlan> 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<HydrologyDiagnosticCandidate> diagnostics,
@@ -339,17 +370,22 @@ final class HydrologyCrossTileResolver {
boolean revalidationRequired
) {
HydrologyCaveCourseFilter.Result current = initial;
HashSet<Long> 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<RiverCourse> normalizedTrunkCourses = planner.tributaries.normalizeSharedTrunks(courses, diagnostics);
List<RiverCourse> 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);
}
@@ -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());
@@ -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())
));
}
}
@@ -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<FootprintRasterStencilKey, FootprintRasterStencil> rasterStencils;
final SurfaceFootprintCompiler surfaceCompiler;
final LinkedHashMap<FootprintCourseRasterKey, SurfaceFootprint> surfaceFootprints;
final Map<Long, SurfaceFootprint> regionalSurfaceFootprints;
final Map<Long, HydrologySurfaceDropRaster> 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.<SurfaceFootprint>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<RiverCourse> 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<HydrologyColumnSample> 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<FootprintMutableColumn> columns,
Long2ObjectLinkedOpenHashMap<FootprintMutableColumn> dropColumns) {
List<HydrologyColumnSample> 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<FootprintMutableColumn> 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<FootprintMutableColumn> columns) {
LinkedHashMap<Long, HydrologyColumnSample> immutable = new LinkedHashMap<>();
for (Long2ObjectMap.Entry<FootprintMutableColumn> 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<HydrologyColumnSample> surfaceColumns;
private final Long2ObjectOpenHashMap<HydrologyColumnSample> fluidColumns;
private final Long2IntOpenHashMap validationNaturalHeights;
private SurfaceRasterIndex(
@@ -1124,7 +1235,13 @@ final class HydrologyFootprintCompiler {
Long2ObjectOpenHashMap<HydrologyColumnSample> validationSamples
) {
Long2ObjectLinkedOpenHashMap<FootprintMutableColumn> merged = new Long2ObjectLinkedOpenHashMap<>();
for (SurfaceFootprint footprint : footprints) {
ArrayList<SurfaceFootprint> 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<FootprintMutableColumn> 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<FootprintMutableColumn> 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<FootprintMutableColumn> 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) {
@@ -22,6 +22,8 @@ final class HydrologyFootprintRasterizer {
boolean validationOnly,
HydrologyFootprintCompiler.SurfaceRasterIndex plannedSurface
) {
Long2ObjectLinkedOpenHashMap<FootprintMutableColumn> segmentColumns = segment.type().isSurface() && segment.fallingFluid()
? new Long2ObjectLinkedOpenHashMap<>() : columns;
List<HydrologyPoint> 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<HydrologyPoint> centerline, int x, int z) {
FootprintCenterlineProjection projectCenterline(List<HydrologyPoint> 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<HydrologyPoint> 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,
@@ -10,7 +10,8 @@ final class HydrologyOceanBoundaryRefiner {
static Result refine(
List<HydrologyPoint> 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;
}
@@ -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<Mouth> 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<HydrologyPoint> 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<Mouth> 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<HydrologyPoint> 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<HydrologyPoint> points, double width) {
}
private static final class Samples {
private final HydrologyTerrainSampler source;
private final Long2ObjectOpenHashMap<HydrologyTerrainSample> 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;
}
}
}
@@ -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<HydrologyPoint> 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) {
@@ -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,
@@ -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);
}
}
@@ -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<LongArrayList> disjoint = new Long2ObjectOpenHashMap<>();
void add(List<SurfaceLayerColumn> 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);
}
}
@@ -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<DrainageNode> nodes, List<DrainageEdge> 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<HydrologyPoint> 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<DrainageNode> nodes = new ArrayList<>(selected.size());
ArrayList<DrainageEdge> 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<HydrologyPoint> 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.");
}
}
}
}
@@ -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<SurfaceLayerColumn> exposed;
private final SurfaceFootprintCompiler surface;
private final LinkedHashMap<HydrologyTileKey, Map<Long, HydrologyColumnSample>> 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<HydrologyCavePlan> validate(HydrologyRegionalNetwork network, List<HydrologyDiagnosticCandidate> 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<HydrologyDiagnosticCandidate> 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<CavePosition, HydrologyCaveAction> actions = new LinkedHashMap<>();
LinkedHashMap<CavePosition, CaveVoxelPrecondition> 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<Long, HydrologyColumnSample> 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<Long, HydrologyColumnSample> 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);
}
}
@@ -0,0 +1,53 @@
package art.arcane.iris.engine.hydrology;
import java.util.List;
final class HydrologyRegionalFlow {
private final List<HydrologyPoint> points;
private final int[] contributions;
private final int maximum;
HydrologyRegionalFlow(List<HydrologyPoint> 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;
}
}
@@ -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<Label> LABEL_ORDER = Comparator.comparingInt((Label label) -> label.state.requiredHead())
.thenComparingDouble(label -> label.state.cost()).thenComparingDouble(label -> label.state.length())
.thenComparingInt(label -> label.state.index()).thenComparingInt(label -> label.state.root())
.thenComparingInt(label -> label.state.id());
private final HydrologyPlanner planner;
private final HydrologyRegionalHydraulics hydraulics;
HydrologyRegionalGraph(HydrologyPlanner planner) {
this.planner = planner;
this.hydraulics = new HydrologyRegionalHydraulics(planner.settings);
}
Tree route(HydrologySampledGrid grid, List<OutletCandidate> outlets, boolean coastal) {
int count = grid.nodes().size();
int maximumLabels = (int) Math.min(MAXIMUM_LABELS, Math.max(count, (long) count * LABELS_PER_GRID_NODE));
ArrayList<List<Label>> frontiers = new ArrayList<>(count);
for (int index = 0; index < count; index++) {
frontiers.add(new ArrayList<>());
}
ArrayList<Label> labels = new ArrayList<>(count);
PriorityQueue<Label> pending = new PriorityQueue<>(LABEL_ORDER);
for (int outletIndex = 0; outletIndex < outlets.size(); outletIndex++) {
int index = outlets.get(outletIndex).landIndex();
Label label = new Label(new LabelState(labels.size(), index, planner.settings.seaLevel(),
0D, 0D, outletIndex), null);
admit(label, frontiers.get(index), labels, pending);
}
HydrologyPlannerSettings.Inlet inlet = planner.settings.surface().banks().inlet();
double possibleInletLength = (inlet.length() + inlet.length() / 2D) * StrictMath.sqrt(2D);
while (!pending.isEmpty() && labels.size() < maximumLabels) {
Label current = pending.remove();
if (!current.active) {
continue;
}
HydrologyGridNode downstream = grid.node(current.state.index());
if (current.downstream == null) {
HydrologyPoint landward = outlets.get(current.state.root()).outlet().landwardPoint();
HydrologyTerrainSample terrain = planner.sampler.sample(landward.x(), landward.z());
downstream = new HydrologyGridNode(downstream.index(), downstream.gridX(), downstream.gridZ(),
landward.x(), landward.z(), downstream.id(), terrain);
}
for (HydrologyGridOffset offset : HydrologySourcePlanner.ROUTING_OFFSETS) {
if (labels.size() >= maximumLabels) {
break;
}
HydrologyGridNode upstream = grid.nodeAt(downstream.gridX() + offset.x(), downstream.gridZ() + offset.z());
if (upstream == null || !allowed(upstream.terrain(), downstream.terrain(), coastal)) {
continue;
}
double distance = StrictMath.hypot(upstream.x() - downstream.x(), upstream.z() - downstream.z());
double nextLength = current.state.length() + distance;
if (nextLength > planner.settings.routing().maximumRouteLength()) {
continue;
}
int nodeMinimum = possibleInletLength > 0D && nextLength < possibleInletLength
? hydraulics.inletMinimumHead(upstream.terrain()) : hydraulics.minimumHead(upstream.terrain());
int needed = coastal ? planner.settings.seaLevel() : Math.max(current.state.requiredHead(), nodeMinimum);
if (!coastal && needed > hydraulics.maximumHead(upstream.terrain())) {
continue;
}
double candidate = current.state.cost() + edgeCost(upstream, downstream, distance, coastal);
Label label = new Label(new LabelState(labels.size(), upstream.index(), needed, candidate,
nextLength, current.state.root()), current);
admit(label, frontiers.get(upstream.index()), labels, pending);
}
}
Label[] selected = new Label[count];
int[] accumulation = new int[labels.size()];
for (int index = 0; index < count; index++) {
for (Label label : frontiers.get(index)) {
if (selected[index] == null || LABEL_ORDER.compare(label, selected[index]) < 0) {
selected[index] = label;
}
}
if (selected[index] != null && grid.node(index).terrain().surfaceSourceAllowed()) {
accumulation[selected[index].state.id()]++;
}
}
for (int index = labels.size() - 1; index >= 0; index--) {
Label label = labels.get(index);
if (label.downstream != null) {
accumulation[label.downstream.state.id()] += accumulation[label.state.id()];
}
}
return new Tree(selected, accumulation, List.copyOf(outlets));
}
List<Integer> path(Tree tree, int source) {
ArrayList<Integer> indices = new ArrayList<>();
Label current = tree.selected()[source];
while (current != null) {
indices.add(current.state.index());
current = current.downstream;
}
return List.copyOf(indices);
}
private static void admit(Label candidate, List<Label> frontier, List<Label> labels, PriorityQueue<Label> pending) {
for (Label existing : frontier) {
if (dominates(existing, candidate)) {
return;
}
}
for (int index = frontier.size() - 1; index >= 0; index--) {
Label existing = frontier.get(index);
if (dominates(candidate, existing)) {
existing.active = false;
frontier.remove(index);
}
}
frontier.add(candidate);
labels.add(candidate);
pending.add(candidate);
}
private static boolean dominates(Label first, Label second) {
if ((first.downstream == null || second.downstream == null)
&& (first.downstream != null || second.downstream != null || first.state.root() != second.state.root())) {
return false;
}
return first.state.requiredHead() <= second.state.requiredHead()
&& first.state.cost() <= second.state.cost() && first.state.length() <= second.state.length();
}
private boolean allowed(HydrologyTerrainSample upstream, HydrologyTerrainSample downstream, boolean coastal) {
if (upstream.ocean() || upstream.naturalHeight() < planner.settings.seaLevel() || !upstream.transitAllowed() || !upstream.drainsInto(downstream)
|| !HydrologySurfaceProfiles.sharesProfile(upstream, downstream)) {
return false;
}
if (coastal) {
int incision = upstream.naturalHeight() - planner.settings.seaLevel() + planner.settings.surface().minimumDepth();
return incision <= planner.settings.routing().regional().maximumCoastalIncision()
&& downstream.drainsInto(upstream);
}
return true;
}
private double edgeCost(HydrologyGridNode upstream, HydrologyGridNode downstream, double distance, boolean coastal) {
HydrologyPlannerSettings.Routing routing = planner.settings.routing();
HydrologyTerrainSample first = upstream.terrain();
HydrologyTerrainSample second = downstream.terrain();
double rise = Math.max(0D, second.naturalHeight() - first.naturalHeight());
double slope = (first.slope() + second.slope()) * 0.5D;
double incision = Math.max(0D, first.naturalHeight() - planner.settings.seaLevel());
double resistance = coastal ? incision * incision * routing.uphillPenalty() : rise * routing.uphillPenalty();
double policy = Math.max(0.01D, (first.routingMultiplier() + second.routingMultiplier()) * 0.5D);
return Math.max(0.001D, (distance + resistance + slope * routing.slopePenalty()
+ first.routingCost() + second.routingCost()) * policy);
}
record Tree(Label[] selected, int[] accumulation, List<OutletCandidate> outlets) {
int root(int source) {
return selected[source] == null ? -1 : selected[source].state.root();
}
double cost(int source) {
return selected[source] == null ? Double.POSITIVE_INFINITY : selected[source].state.cost();
}
double length(int source) {
return selected[source] == null ? 0D : selected[source].state.length();
}
int[] contributions(int source) {
int count = 0;
for (Label label = selected[source]; label != null; label = label.downstream) {
count++;
}
int[] contributions = new int[count];
int index = 0;
for (Label label = selected[source]; label != null; label = label.downstream) {
contributions[index++] = accumulation[label.state.id()];
}
return contributions;
}
}
static final class Label {
private final LabelState state;
private final Label downstream;
private boolean active = true;
Label(LabelState state, Label downstream) {
this.state = state;
this.downstream = downstream;
}
}
record LabelState(int id, int index, int requiredHead, double cost, double length, int root) {
}
}
@@ -0,0 +1,155 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.surface.SurfaceBankSupport;
import java.util.Arrays;
final class HydrologyRegionalHydraulics {
private static final int MAXIMUM_WIDTH_CANDIDATES = 8;
private static final int MAXIMUM_ADDITIONAL_SAMPLES = 2048;
private static final int MAXIMUM_BANK_RADIUS = 96;
private final HydrologyPlannerSettings settings;
private final SurfaceBankSupport bankSupport;
private final double minimumHalfWidth;
private final double maximumProbedHalfWidth;
private final double[] candidateHalfWidths;
HydrologyRegionalHydraulics(HydrologyPlannerSettings settings) {
this.settings = settings;
this.bankSupport = new SurfaceBankSupport(settings.surface(), settings.seaLevel());
HydrologyPlannerSettings.Surface surface = settings.surface();
this.minimumHalfWidth = surface.minimumWidth() / 2D;
double authored = surface.maximumWidth() / 2D;
double expanded = surface.maximumWidth();
this.maximumProbedHalfWidth = Math.min(expanded, Math.max(minimumHalfWidth,
(MAXIMUM_BANK_RADIUS - 2D) / (1D + surface.banks().roughness())));
this.candidateHalfWidths = candidates(authored, midpoint(minimumHalfWidth, authored),
maximumProbedHalfWidth, midpoint(minimumHalfWidth, maximumProbedHalfWidth));
}
int minimumHead(HydrologyTerrainSample terrain) {
return minimumHead(terrain, terrain.surfacePolicy().maximumIncision(settings.surface().maximumIncision()));
}
int inletMinimumHead(HydrologyTerrainSample terrain) {
return minimumHead(terrain, Math.max(terrain.surfacePolicy().maximumIncision(settings.surface().maximumIncision()),
settings.surface().banks().inlet().maximumIncision()));
}
int maximumHead(HydrologyTerrainSample terrain) {
return Math.max(settings.seaLevel(), terrain.naturalHeight() - settings.surface().banks().sink());
}
int supportedHead(HeadStation station, HydrologyTerrainSampler sampler) {
int centerHead = maximumHead(station.terrain());
if (centerHead >= station.incomingHead()) {
return station.incomingHead();
}
int supported = support(station, sampler, minimumHalfWidth, centerHead);
if (supported >= station.incomingHead()) {
return station.incomingHead();
}
SampleBudget budget = new SampleBudget(sampler);
double localMinimum = effectiveHalfWidth(settings.surface().minimumWidth(), station.terrain().widthMultiplier());
double localMaximum = effectiveHalfWidth(settings.surface().maximumWidth(), station.terrain().widthMultiplier());
if (additional(localMaximum)) {
supported = support(station, budget, localMaximum, supported);
}
if (supported < station.incomingHead() && localMinimum != localMaximum && additional(localMinimum)) {
supported = support(station, budget, localMinimum, supported);
}
double localMiddle = midpoint(localMinimum, localMaximum);
if (supported < station.incomingHead() && localMiddle != localMinimum && localMiddle != localMaximum && additional(localMiddle)) {
supported = support(station, budget, localMiddle, supported);
}
for (double halfWidth : candidateHalfWidths) {
if (supported >= station.incomingHead() || budget.remaining == 0) {
break;
}
supported = support(station, budget, halfWidth, supported);
}
return Math.min(station.incomingHead(), supported);
}
private int support(HeadStation station, HydrologyTerrainSampler sampler, double halfWidth, int supportedHead) {
SurfaceBankSupport.Station crossSection = new SurfaceBankSupport.Station(station.x(), station.z(),
station.tangentX(), station.tangentZ(), halfWidth);
SurfaceBankSupport.CrossSection support = bankSupport.crossSection(sampler, crossSection, true);
if (support.blocked() || support.minimum() - settings.surface().banks().sink() <= supportedHead) {
return supportedHead;
}
SurfaceBankSupport.Perimeter perimeter = bankSupport.perimeter(sampler, crossSection, support.minimum());
if (!perimeter.complete()) {
return supportedHead;
}
int supported = perimeter.minimum() - settings.surface().banks().sink();
return Math.max(supportedHead, supported);
}
private double effectiveHalfWidth(int width, double multiplier) {
return Math.max(settings.surface().minimumWidth(), Math.min(settings.surface().maximumWidth() * 2D, width * multiplier)) / 2D;
}
private boolean additional(double halfWidth) {
if (halfWidth <= minimumHalfWidth || halfWidth > maximumProbedHalfWidth) {
return false;
}
for (double candidate : candidateHalfWidths) {
if (candidate == halfWidth) {
return false;
}
}
return true;
}
private double[] candidates(double... widths) {
double[] distinct = new double[MAXIMUM_WIDTH_CANDIDATES - 4];
int size = 0;
for (double width : widths) {
if (!Double.isFinite(width) || width <= minimumHalfWidth || width > maximumProbedHalfWidth) {
continue;
}
boolean duplicate = false;
for (int index = 0; index < size; index++) {
duplicate |= distinct[index] == width;
}
if (!duplicate && size < distinct.length) {
distinct[size++] = width;
}
}
return Arrays.copyOf(distinct, size);
}
private static double midpoint(double first, double second) {
return first + (second - first) / 2D;
}
private int minimumHead(HydrologyTerrainSample terrain, int incision) {
int permitted = Math.min(incision, (int) StrictMath.floor(incision * terrain.incisionMultiplier()));
int depth = (int) StrictMath.round(Math.max(1D, Math.min(settings.surface().maximumDepth() * 2D,
settings.surface().minimumDepth() * terrain.depthMultiplier())));
return Math.max(settings.seaLevel(), terrain.naturalHeight() - permitted + depth);
}
record HeadStation(int x, int z, double tangentX, double tangentZ, HydrologyTerrainSample terrain, int incomingHead) {
}
private static final class SampleBudget implements HydrologyTerrainSampler {
private final HydrologyTerrainSampler sampler;
private int remaining = MAXIMUM_ADDITIONAL_SAMPLES;
private SampleBudget(HydrologyTerrainSampler sampler) {
this.sampler = sampler;
}
@Override
public HydrologyTerrainSample sample(int blockX, int blockZ) {
if (remaining == 0) {
return null;
}
remaining--;
return sampler.sample(blockX, blockZ);
}
}
}
@@ -0,0 +1,166 @@
package art.arcane.iris.engine.hydrology;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
final class HydrologyRegionalMorphology {
private static final int MAXIMUM_STATIONS = 129;
private static final double MAXIMUM_LATERAL_REACH = 64D;
private static final double GENTLE_GRADE = 1D / 64D;
private static final double STEEP_GRADE = 0.25D;
private static final double[] LATERAL_OFFSETS = {-1D, -0.5D, 0.5D, 1D};
private final Location[] locations;
private final double[] freedom;
private HydrologyRegionalMorphology(Location[] locations, double[] freedom) {
this.locations = locations;
this.freedom = freedom;
}
static HydrologyRegionalMorphology sample(List<HydrologyPoint> guide, HydrologyTerrainSampler sampler,
HydrologyPlannerSettings settings) {
double length = 0D;
for (int index = 1; index < guide.size(); index++) {
length += StrictMath.hypot((double) guide.get(index).x() - guide.get(index - 1).x(),
(double) guide.get(index).z() - guide.get(index - 1).z());
}
if (length <= 0D || length > settings.routing().maximumRouteLength()) {
return new HydrologyRegionalMorphology(new Location[0], new double[0]);
}
int intervals = Math.min(MAXIMUM_STATIONS - 1,
Math.max(1, (int) StrictMath.ceil(length / settings.routing().regional().sampleSpacing())));
Location[] locations = locations(guide, length, intervals);
HydrologyTerrainSample[] centers = new HydrologyTerrainSample[locations.length];
Map<Long, HydrologyTerrainSample> sampled = new HashMap<>();
for (int index = 0; index < locations.length; index++) {
Location location = locations[index];
centers[index] = sample(sampler, sampled, location.x(), location.z());
}
double reach = Math.min(MAXIMUM_LATERAL_REACH, Math.max(settings.surface().maximumWidth() * 2D,
settings.routing().regional().sampleSpacing() / 8D));
double[] freedom = new double[locations.length];
for (int index = 0; index < locations.length; index++) {
HydrologyTerrainSample center = centers[index];
if (!land(center, settings.seaLevel())) {
continue;
}
int before = Math.max(0, index - 1);
int after = Math.min(locations.length - 1, index + 1);
if (centers[before] == null || centers[after] == null) {
continue;
}
double distance = length / intervals;
double grade = Math.max(StrictMath.abs((double) center.naturalHeight() - centers[before].naturalHeight()),
StrictMath.abs((double) centers[after].naturalHeight() - center.naturalHeight())) / distance;
double gradeFreedom = 1D - smooth((grade - GENTLE_GRADE) / (STEEP_GRADE - GENTLE_GRADE));
freedom[index] = gradeFreedom * room(locations[index], center, sampler, sampled, settings, reach);
}
return new HydrologyRegionalMorphology(locations, smooth(freedom));
}
double freedomAt(double x, double z) {
double nearest = Double.POSITIVE_INFINITY;
double selected = 0D;
for (int index = 0; index + 1 < locations.length; index++) {
Location first = locations[index];
Location second = locations[index + 1];
double deltaX = (double) second.x() - first.x();
double deltaZ = (double) second.z() - first.z();
double squared = deltaX * deltaX + deltaZ * deltaZ;
double progress = squared == 0D ? 0D : Math.max(0D, Math.min(1D,
((x - first.x()) * deltaX + (z - first.z()) * deltaZ) / squared));
double offsetX = x - first.x() - deltaX * progress;
double offsetZ = z - first.z() - deltaZ * progress;
double distance = offsetX * offsetX + offsetZ * offsetZ;
if (distance < nearest) {
nearest = distance;
selected = freedom[index] + (freedom[index + 1] - freedom[index]) * smooth(progress);
}
}
return Math.max(0D, Math.min(1D, selected));
}
private static Location[] locations(List<HydrologyPoint> guide, double length, int intervals) {
Location[] locations = new Location[intervals + 1];
int segment = 0;
double traversed = 0D;
for (int index = 0; index <= intervals; index++) {
double distance = length * index / intervals;
HydrologyPoint first = guide.get(segment);
HydrologyPoint second = guide.get(segment + 1);
double span = StrictMath.hypot((double) second.x() - first.x(), (double) second.z() - first.z());
while (segment + 2 < guide.size() && (span == 0D || traversed + span < distance)) {
traversed += span;
segment++;
first = guide.get(segment);
second = guide.get(segment + 1);
span = StrictMath.hypot((double) second.x() - first.x(), (double) second.z() - first.z());
}
double progress = span == 0D ? 0D : Math.max(0D, Math.min(1D, (distance - traversed) / span));
locations[index] = new Location(
(int) StrictMath.round(first.x() + ((double) second.x() - first.x()) * progress),
(int) StrictMath.round(first.z() + ((double) second.z() - first.z()) * progress),
span == 0D ? 1D : (second.x() - first.x()) / span,
span == 0D ? 0D : (second.z() - first.z()) / span);
}
return locations;
}
private static double room(Location location, HydrologyTerrainSample center, HydrologyTerrainSampler sampler,
Map<Long, HydrologyTerrainSample> sampled, HydrologyPlannerSettings settings, double reach) {
int incision = center.surfacePolicy().maximumIncision(settings.surface().maximumIncision());
double allowance = Math.max(1D, Math.min(incision, StrictMath.floor(incision * center.incisionMultiplier())));
double room = 1D;
for (double scale : LATERAL_OFFSETS) {
double offset = reach * scale;
int x = (int) StrictMath.round(location.x() - location.tangentZ() * offset);
int z = (int) StrictMath.round(location.z() + location.tangentX() * offset);
HydrologyTerrainSample side = sample(sampler, sampled, x, z);
if (!land(side, settings.seaLevel()) || !center.drainsInto(side) || !side.drainsInto(center)
|| !HydrologySurfaceProfiles.sharesProfile(center, side)) {
return 0D;
}
double relief = StrictMath.abs((double) side.naturalHeight() - center.naturalHeight());
room = Math.min(room, 1D - smooth((relief - allowance / 4D) / allowance));
}
return room;
}
private static boolean land(HydrologyTerrainSample sample, int seaLevel) {
return sample != null && !sample.ocean() && sample.naturalHeight() >= seaLevel && sample.transitAllowed();
}
private static HydrologyTerrainSample sample(HydrologyTerrainSampler sampler, Map<Long, HydrologyTerrainSample> sampled,
int x, int z) {
long key = RiverFootprint.pack(x, z);
if (!sampled.containsKey(key)) {
sampled.put(key, sampler.sample(x, z));
}
return sampled.get(key);
}
private static double[] smooth(double[] values) {
double[] smoothed = new double[values.length];
for (int index = 0; index < values.length; index++) {
double total = 0D;
int weight = 0;
for (int sample = Math.max(0, index - 2); sample <= Math.min(values.length - 1, index + 2); sample++) {
int strength = 3 - Math.abs(sample - index);
total += values[sample] * strength;
weight += strength;
}
smoothed[index] = total / weight;
}
return smoothed;
}
private static double smooth(double value) {
double bounded = Math.max(0D, Math.min(1D, value));
return bounded * bounded * (3D - 2D * bounded);
}
private record Location(int x, int z, double tangentX, double tangentZ) {
}
}
@@ -0,0 +1,38 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan;
import java.util.List;
record HydrologyRegionalNetwork(
List<DrainageNode> nodes,
List<DrainageEdge> edges,
List<RiverOutlet> outlets,
List<RiverCourse> courses,
List<HydrologyDiagnosticCandidate> diagnostics,
List<HydrologyCavePlan> cavePlans
) {
static final HydrologyRegionalNetwork EMPTY = new HydrologyRegionalNetwork(List.of(), List.of(), List.of(), List.of(), List.of(), List.of());
HydrologyRegionalNetwork {
nodes = List.copyOf(nodes);
edges = List.copyOf(edges);
outlets = List.copyOf(outlets);
courses = List.copyOf(courses);
diagnostics = List.copyOf(diagnostics);
cavePlans = List.copyOf(cavePlans);
}
int stations() {
int count = 0;
for (RiverCourse course : courses) {
for (HydraulicSegment segment : course.segments()) {
count = Math.addExact(count, segment.centerline().size());
}
}
for (HydrologyCavePlan plan : cavePlans) {
count = Math.addExact(count, plan.baselinePreconditions().size());
}
return count;
}
}
@@ -0,0 +1,755 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan;
import art.arcane.iris.engine.hydrology.surface.SurfaceBounds;
import art.arcane.iris.engine.hydrology.surface.SurfaceCenterline;
import art.arcane.iris.engine.hydrology.surface.SurfaceCourseBuilder;
import art.arcane.iris.engine.hydrology.surface.SurfaceCourseResult;
import art.arcane.iris.engine.hydrology.surface.SurfaceExcavationMetrics;
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 art.arcane.iris.engine.hydrology.surface.SurfaceTerminal;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.OptionalLong;
import java.util.OptionalInt;
import java.util.Set;
final class HydrologyRegionalPlanner {
private static final long BASIN_SALT = 0x524547424153494eL;
private static final long COURSE_SALT = 0x524547434f555253L;
private static final long NODE_SALT = 0x5245474e4f444553L;
private static final int MAXIMUM_SOURCE_TRIALS = 24;
private static final int MAXIMUM_DIAGNOSTICS = 64;
private final HydrologyPlanner planner;
private final HydrologyPlannerSettings.Regional settings;
private final HydrologyRegionalGraph graph;
private final HydrologyRegionalRoute routes;
private final Cache<HydrologyTileKey, HydrologyRegionalNetwork> drafts;
HydrologyRegionalPlanner(HydrologyPlanner planner) {
this.planner = planner;
this.settings = planner.settings.routing().regional();
this.graph = new HydrologyRegionalGraph(planner);
this.routes = new HydrologyRegionalRoute(planner);
int minimumWeight = Math.max(1, Math.ceilDiv(settings.maximumCachedStations(), settings.maximumCachedBasins()));
this.drafts = Caffeine.newBuilder()
.maximumWeight(settings.maximumCachedStations())
.weigher((HydrologyTileKey key, HydrologyRegionalNetwork network) -> Math.max(minimumWeight, network.stations()))
.build();
}
boolean enabled() {
return settings.enabled() && planner.settings.surface().enabled()
&& planner.settings.surface().sources().enabled() && planner.settings.outlets().oceanEnabled()
&& settings.minimumLength() <= planner.settings.routing().maximumRouteLength();
}
void clear() {
drafts.invalidateAll();
routes.clear();
}
private HydrologyTerrainSample sample(int x, int z) {
return planner.naturalSampler == null ? planner.sampler.sample(x, z) : planner.naturalSampler.sampleBasisWithoutSlope(x, z);
}
HydrologyRegionalNetwork coursesIn(SurfaceBounds bounds) {
if (!enabled()) {
return HydrologyRegionalNetwork.EMPTY;
}
int basinSize = settings.basinSize(planner.settings.routing());
int reach = settings.sampleSpacing() * 2 + footprintRadius();
int minimumX = Math.floorDiv(bounds.minimumX() - reach, basinSize);
int maximumX = Math.floorDiv(bounds.maximumX() + reach, basinSize);
int minimumZ = Math.floorDiv(bounds.minimumZ() - reach, basinSize);
int maximumZ = Math.floorDiv(bounds.maximumZ() + reach, basinSize);
HydrologyTileKey primary = HydrologyTileKey.fromBlock(
(int) (((long) bounds.minimumX() + bounds.maximumX()) / 2L),
(int) (((long) bounds.minimumZ() + bounds.maximumZ()) / 2L), basinSize);
ArrayList<HydrologyRegionalNetwork> networks = new ArrayList<>();
for (int z = minimumZ; z <= maximumZ; z++) {
for (int x = minimumX; x <= maximumX; x++) {
HydrologyTileKey key = new HydrologyTileKey(x, z);
HydrologyRegionalNetwork draft = draft(key);
ArrayList<RiverCourse> accepted = new ArrayList<>();
for (RiverCourse course : draft.courses()) {
if (intersects(course, bounds, footprintRadius()) && admitted(key, course)) {
accepted.add(course);
}
}
if (!accepted.isEmpty() || key.equals(primary)) {
HydrologyRegionalNetwork selected = select(draft, accepted);
networks.add(new HydrologyRegionalNetwork(selected.nodes(), selected.edges(), selected.outlets(),
selected.courses(), key.equals(primary) ? draft.diagnostics() : List.of(), selected.cavePlans()));
}
}
}
return merge(networks);
}
RiverFootprint materialize(HydrologyRegionalNetwork network, SurfaceBounds bounds) {
if (network.courses().isEmpty()) {
return RiverFootprint.empty();
}
SurfaceFootprintCompiler compiler = new SurfaceFootprintCompiler(planner.settings,
this::sample, planner.geometrySampler);
LinkedHashMap<Long, HydrologyColumnSample> columns = new LinkedHashMap<>();
for (RiverCourse course : network.courses()) {
SurfaceFootprint footprint = compiler.compile(course, bounds);
if (!footprint.accepted()) {
throw new IllegalStateException("An accepted regional course changed its bounded surface admission: " + course.id());
}
for (SurfaceLayerColumn column : footprint.columns()) {
HydrologyTerrainSample terrain = column.terrain();
HydrologyColumnSample sample = new HydrologyColumnSample(column.x(), column.z(), terrain.naturalHeight(),
planner.settings.seaLevel(), terrain.ocean(), terrain.parentBiomeKey(), List.of(column.layer()));
columns.merge(RiverFootprint.pack(column.x(), column.z()), sample, HydrologyRegionalPlanner::mergeColumn);
}
HydrologySurfaceDropRaster drops = HydrologySurfaceDropRaster.compile(planner.settings,
HydrologyOceanReceiver.forCourse(planner.settings, this::sample, course), planner.geometrySampler, course, bounds);
for (HydrologyColumnSample column : drops.columns()) {
columns.merge(RiverFootprint.pack(column.x(), column.z()), column, HydrologyRegionalPlanner::mergeColumn);
}
}
return new RiverFootprint(columns);
}
HydrologyRegionalNetwork draft(HydrologyTileKey key) {
return drafts.get(key, this::compile);
}
private HydrologyRegionalNetwork compile(HydrologyTileKey key) {
HydrologySampledGrid grid = sample(key);
ArrayList<HydrologyDiagnosticCandidate> diagnostics = new ArrayList<>();
HydrologyGridNode center = grid.nodes().get(grid.nodes().size() / 2);
ArrayList<OutletCandidate> outlets = planner.outletPlanner.oceanOutletCandidates(grid, true);
outlets.removeIf(outlet -> outlet.outlet().type() != HydrologyFeatureType.MOUTH);
outlets.sort(Comparator.comparingLong(outlet -> HydrologyHash.mix(planner.worldSeed, BASIN_SALT,
key.tileX(), key.tileZ(), outlet.outlet().id())));
if (outlets.isEmpty()) {
diagnostic(diagnostics, center.id(), center.naturalPoint(), false, HydrologyCandidateRejection.NO_LEGAL_OUTLET, 0);
return new HydrologyRegionalNetwork(List.of(), List.of(), List.of(), List.of(), diagnostics, List.of());
}
ArrayList<HydrologyRegionalNetwork> accepted = new ArrayList<>();
long basinSeed = HydrologyHash.mix(planner.worldSeed, BASIN_SALT, key.tileX(), key.tileZ());
if (settings.coastalChannels() && HydrologyHash.unit(basinSeed) < settings.coastalChannelChance()) {
ArrayList<HydrologyDiagnosticCandidate> coastalDiagnostics = new ArrayList<>();
HydrologyRegionalNetwork coastal = coastalChannel(grid, outlets, basinSeed, coastalDiagnostics);
diagnostics.addAll(coastalDiagnostics.subList(0, Math.min(16, coastalDiagnostics.size())));
if (!coastal.courses().isEmpty()) {
accepted.add(coastal);
}
}
ArrayList<OutletCandidate> roots = spacedOutlets(outlets, settings.maximumTrunks() * 2);
HydrologyRegionalGraph.Tree tree = graph.route(grid, roots, false);
ArrayList<Integer> sources = new ArrayList<>();
for (HydrologyGridNode node : grid.nodes()) {
if (grid.owns(node.x(), node.z()) && tree.root(node.index()) >= 0
&& tree.length(node.index()) >= settings.minimumLength()
&& planner.sourcePlanner.rawSourceEligible(node.terrain(), planner.settings.surface().sources(), true)) {
sources.add(node.index());
}
}
if (sources.isEmpty()) {
double longest = 0D;
for (HydrologyGridNode node : grid.nodes()) {
if (grid.owns(node.x(), node.z()) && tree.root(node.index()) >= 0) {
longest = Math.max(longest, tree.length(node.index()));
}
}
diagnostic(diagnostics, center.id(), center.naturalPoint(), false,
longest == 0D ? HydrologyCandidateRejection.NO_DRAINAGE_PATH
: longest < settings.minimumLength() ? HydrologyCandidateRejection.COURSE_TOO_SHORT
: HydrologyCandidateRejection.POLICY_EXCLUDED, (int) StrictMath.round(longest));
}
Set<Long> usedOutlets = new HashSet<>();
for (HydrologyRegionalNetwork network : accepted) {
for (RiverOutlet outlet : network.outlets()) {
usedOutlets.add(outlet.id());
}
}
HydrologyRegionalSourceTrials sourceTrials = new HydrologyRegionalSourceTrials(grid, tree, sources);
for (int trials = 0; trials < MAXIMUM_SOURCE_TRIALS; trials++) {
if (accepted.size() >= settings.maximumTrunks()) {
break;
}
OptionalInt selected = sourceTrials.next(usedOutlets);
if (selected.isEmpty()) {
break;
}
int source = selected.getAsInt();
OutletCandidate outlet = tree.outlets().get(tree.root(source));
HydrologyRegionalNetwork candidate = build(grid, tree, source, null, basinSeed, diagnostics);
if (!candidate.courses().isEmpty() && !conflicts(candidate.courses().getFirst(), merge(accepted).courses())) {
accepted.add(candidate);
usedOutlets.add(outlet.outlet().id());
}
}
HydrologyRegionalNetwork network = merge(accepted);
return new HydrologyRegionalNetwork(network.nodes(), network.edges(), network.outlets(), network.courses(), diagnostics, network.cavePlans());
}
private HydrologyRegionalNetwork coastalChannel(HydrologySampledGrid grid, List<OutletCandidate> outlets,
long basinSeed, List<HydrologyDiagnosticCandidate> diagnostics) {
int maximum = Math.min(8, outlets.size());
int pairs = 0;
double longest = 0D;
for (int destination = 0; destination < maximum; destination++) {
if (!bidirectionalMouth(outlets.get(destination).outlet())) {
continue;
}
HydrologyRegionalGraph.Tree tree = graph.route(grid, List.of(outlets.get(destination)), true);
ArrayList<OutletCandidate> origins = new ArrayList<>(outlets);
origins.sort(Comparator.comparingDouble((OutletCandidate outlet) -> tree.length(outlet.landIndex())).reversed()
.thenComparingLong(outlet -> outlet.outlet().id()));
int trials = 0;
for (OutletCandidate origin : origins) {
if (trials++ >= 8) {
break;
}
int source = origin.landIndex();
if (!bidirectionalMouth(origin.outlet())) {
continue;
}
if (grid.owns(grid.node(source).x(), grid.node(source).z())
&& opposingCoasts(origin.outlet(), outlets.get(destination).outlet())) {
pairs++;
longest = Math.max(longest, tree.length(source));
}
if (!grid.owns(grid.node(source).x(), grid.node(source).z()) || tree.root(source) < 0
|| !opposingCoasts(origin.outlet(), outlets.get(destination).outlet())
|| tree.length(source) < settings.minimumLength()
|| origin.outlet().connectionPoint().distanceSquared2D(outlets.get(destination).outlet().connectionPoint())
< (long) settings.minimumLength() * settings.minimumLength() / 4L) {
continue;
}
HydrologyRegionalNetwork network = build(grid, tree, source, origin, basinSeed, diagnostics);
if (!network.courses().isEmpty()) {
return network;
}
}
}
if (diagnostics.isEmpty()) {
HydrologyGridNode center = grid.nodes().get(grid.nodes().size() / 2);
diagnostic(diagnostics, basinSeed, center.naturalPoint(), true,
pairs == 0 ? HydrologyCandidateRejection.NO_LEGAL_OUTLET
: longest == 0D ? HydrologyCandidateRejection.NO_DRAINAGE_PATH
: HydrologyCandidateRejection.COURSE_TOO_SHORT,
(int) StrictMath.round(longest));
}
return HydrologyRegionalNetwork.EMPTY;
}
boolean bidirectionalMouth(RiverOutlet outlet) {
HydrologyTerrainSample land = sample(outlet.landwardPoint().x(), outlet.landwardPoint().z());
HydrologyTerrainSample ocean = sample(outlet.connectionPoint().x(), outlet.connectionPoint().z());
return land != null && ocean != null && land.drainsInto(ocean) && ocean.drainsInto(land);
}
private static boolean opposingCoasts(RiverOutlet first, RiverOutlet second) {
double firstX = first.connectionPoint().x() - first.landwardPoint().x();
double firstZ = first.connectionPoint().z() - first.landwardPoint().z();
double secondX = second.connectionPoint().x() - second.landwardPoint().x();
double secondZ = second.connectionPoint().z() - second.landwardPoint().z();
return firstX * secondX + firstZ * secondZ < 0D;
}
private HydrologyRegionalNetwork build(HydrologySampledGrid grid, HydrologyRegionalGraph.Tree tree,
int source, OutletCandidate origin, long basinSeed,
List<HydrologyDiagnosticCandidate> diagnostics) {
List<Integer> indices = graph.path(tree, source);
OutletCandidate destination = tree.outlets().get(tree.root(source));
RiverOutlet outlet = destination.outlet();
ArrayList<HydrologyPoint> guide = new ArrayList<>(indices.size() + 1);
ArrayList<String> profiles = new ArrayList<>(grid.node(source).terrain().preferredProfileKeys());
for (int index : indices) {
HydrologyGridNode node = grid.node(index);
guide.add(node.naturalPoint());
profiles.retainAll(node.terrain().preferredProfileKeys());
}
profiles.retainAll(sample(outlet.connectionPoint().x(), outlet.connectionPoint().z()).preferredProfileKeys());
if (origin != null) {
profiles.retainAll(sample(origin.outlet().connectionPoint().x(), origin.outlet().connectionPoint().z()).preferredProfileKeys());
guide.set(0, origin.outlet().landwardPoint());
}
if (profiles.isEmpty() || guide.size() < 2) {
diagnostic(diagnostics, grid.node(source).id(), grid.node(source).naturalPoint(), origin != null,
HydrologyCandidateRejection.POLICY_EXCLUDED, guide.size());
return HydrologyRegionalNetwork.EMPTY;
}
guide.set(guide.size() - 1, outlet.landwardPoint());
String profile = profiles.get(HydrologyHash.between(HydrologyHash.mix(basinSeed, source), 0, profiles.size() - 1));
BuildContext context = new BuildContext(grid.node(source), origin, basinSeed, outlet, profile,
new HydrologyRegionalFlow(guide, tree.contributions(source)),
HydrologyRegionalMorphology.sample(guide, this::sample, planner.settings));
HydrologyRegionalRoute.Attempt selected = routes.select(guide, profile, origin != null,
HydrologyOceanReceiver.forOutlet(planner.settings, this::sample, outlet), candidate -> attempt(context, candidate));
if (selected.rejection() != null) {
if (diagnostics.size() < MAXIMUM_DIAGNOSTICS) {
diagnostics.add(selected.rejection());
}
} else if (selected.refinement().rejection() != null) {
HydrologyRegionalRoute.Refinement rejected = selected.refinement();
diagnostic(diagnostics, grid.node(source).id(), rejected.failure(), origin != null, rejected.rejection(), rejected.detail());
}
return selected.network();
}
private HydrologyRegionalRoute.Attempt attempt(BuildContext context, HydrologyRegionalRoute.Refinement refinement) {
ArrayList<HydrologyDiagnosticCandidate> diagnostics = new ArrayList<>();
HydrologyRegionalNetwork network = build(context, refinement, diagnostics);
if (network.courses().isEmpty() && diagnostics.isEmpty()) {
diagnostic(diagnostics, context.source().id(), refinement.points().getFirst(), context.origin() != null,
HydrologyCandidateRejection.NO_DRAINAGE_PATH, 0);
}
return new HydrologyRegionalRoute.Attempt(refinement, network, network.courses().isEmpty() ? diagnostics.getFirst() : null);
}
private HydrologyRegionalNetwork build(BuildContext context, HydrologyRegionalRoute.Refinement refinement,
List<HydrologyDiagnosticCandidate> diagnostics) {
RiverOutlet outlet = context.outlet();
OutletCandidate origin = context.origin();
String profile = context.profile();
List<HydrologyPoint> path = refinement.points();
if (refinement.oceanEntry() != null) {
HydrologyRegionalRoute.OceanEntry entry = refinement.oceanEntry();
outlet = new RiverOutlet(HydrologyHash.mix(outlet.id(), entry.landward().x(), entry.landward().z(),
entry.receiving().x(), entry.receiving().z()), HydrologyFeatureType.MOUTH, outlet.drainageNodeId(),
entry.landward(), entry.receiving(), planner.settings.seaLevel(), true);
}
long courseId = HydrologyHash.mix(context.basinSeed(), COURSE_SALT, context.source().id(), outlet.id(), origin == null ? 0L : origin.outlet().id());
HydrologyRegionalFlow flow = context.flow();
HydrologyGeometrySampler geometry = request -> regionalGeometry(request, context);
ArrayList<HydraulicSegment> segments;
if (origin == null) {
SurfaceCourseResult built = new SurfaceCourseBuilder(planner.settings.surface(),
HydrologyOceanReceiver.forOutlet(planner.settings, this::sample, outlet), geometry, planner.settings.seaLevel()).build(
planner.worldSeed, courseId, profile, path, SurfaceTerminal.OCEAN_MOUTH,
planner.settings.seaLevel(), settings.minimumLength());
if (!built.accepted()) {
diagnostic(diagnostics, courseId, path.getFirst(), false, built.rejection(), built.rejectionDetail());
return HydrologyRegionalNetwork.EMPTY;
}
segments = new ArrayList<>(built.segments());
segments.add(mouth(courseId, built.pathEnd(), outlet.connectionPoint(), built.lastWidth(), built.lastDepth(), 1));
} else {
segments = coastalSegments(courseId, profile, path, origin.outlet(), outlet, geometry);
}
HydrologyRegionalCourseGraph published = HydrologyRegionalCourseGraph.create(this::sample,
new HydrologyRegionalCourseGraph.Options(courseId, outlet.id(), settings.sampleSpacing(), path, flow));
List<DrainageNode> nodes = published.nodes();
List<DrainageEdge> edges = published.edges();
RiverCourse course = new RiverCourse(courseId, RiverCourseType.SURFACE, OptionalLong.of(nodes.getFirst().id()),
OptionalLong.of(outlet.id()), profile, published.discharge(), edges, segments);
HydrologyRegionalFalls falls = new HydrologyRegionalFalls(planner, course);
if (!validFootprints(course, diagnostics, falls)) {
return HydrologyRegionalNetwork.EMPTY;
}
RiverOutlet publishedOutlet = publishedOutlet(outlet, nodes.getLast().id());
HydrologyRegionalNetwork network = new HydrologyRegionalNetwork(nodes, edges, origin == null ? List.of(publishedOutlet)
: List.of(publishedOutlet(origin.outlet(), nodes.getFirst().id()), publishedOutlet),
List.of(course), List.of(), List.of());
List<HydrologyCavePlan> plans = falls.validate(network, diagnostics);
return plans == null ? HydrologyRegionalNetwork.EMPTY : new HydrologyRegionalNetwork(nodes, edges,
network.outlets(), network.courses(), List.of(), plans);
}
private static RiverOutlet publishedOutlet(RiverOutlet outlet, long nodeId) {
return new RiverOutlet(outlet.id(), outlet.type(), nodeId, outlet.landwardPoint(), outlet.connectionPoint(),
outlet.seaLevel(), outlet.directOcean());
}
private int regionalGeometry(HydrologyGeometrySampler.Request request, BuildContext context) {
if (request.field() != HydrologyGeometrySampler.Field.SURFACE_WIDTH) {
return planner.geometrySampler.sample(request);
}
int authored = planner.geometrySampler.sample(request);
double fraction = 0.25D + 0.75D * context.flow().fraction(request.x(), request.z());
double sampled = request.minimum() + (request.maximum() - request.minimum()) * fraction;
double width = sampled * 0.75D + authored * 0.25D;
double freedom = context.morphology().freedomAt(request.x(), request.z());
double constrained = request.minimum() + (width - request.minimum()) * freedom;
return Math.max(request.minimum(), Math.min(request.maximum(), (int) StrictMath.round(constrained)));
}
private ArrayList<HydraulicSegment> coastalSegments(long courseId, String profile, List<HydrologyPoint> path,
RiverOutlet origin, RiverOutlet destination, HydrologyGeometrySampler geometry) {
ArrayList<HydraulicSegment> segments = new ArrayList<>();
int seaLevel = planner.settings.seaLevel();
HydrologyPlannerSettings.Surface surface = planner.settings.surface();
HydrologyPoint first = HydrologyPlanner.withY(path.getFirst(), seaLevel);
double width = coastalWidth(profile, first, geometry);
int depth = geometry.sample(HydrologyGeometrySampler.Field.SURFACE_DEPTH, profile, first.x(), first.z(), surface.minimumDepth(), surface.maximumDepth());
segments.add(mouth(courseId, origin.connectionPoint(), first, width, depth, 0));
for (int index = 0; index + 1 < path.size(); index++) {
HydrologyPoint point = path.get(index);
HydrologyPoint next = path.get(index + 1);
depth = geometry.sample(HydrologyGeometrySampler.Field.SURFACE_DEPTH, profile, point.x(), point.z(), surface.minimumDepth(), surface.maximumDepth());
double nextWidth = coastalWidth(profile, next, geometry);
int nextDepth = geometry.sample(HydrologyGeometrySampler.Field.SURFACE_DEPTH, profile, next.x(), next.z(), surface.minimumDepth(), surface.maximumDepth());
segments.add(new HydraulicSegment(HydrologyHash.mix(courseId, index, COURSE_SALT), courseId,
HydrologyFeatureType.SURFACE_POOL, seaLevel, seaLevel, (int) StrictMath.ceil(Math.max(width, nextWidth)), Math.max(depth, nextDepth), false, false,
List.of(HydrologyPlanner.withY(point, seaLevel), HydrologyPlanner.withY(next, seaLevel)),
new HydraulicChannelProfile(new double[] {width, nextWidth}, new double[] {depth, nextDepth})));
width = nextWidth;
depth = nextDepth;
}
segments.add(mouth(courseId, HydrologyPlanner.withY(path.getLast(), seaLevel), destination.connectionPoint(), width, depth, 1));
return segments;
}
private double coastalWidth(String profile, HydrologyPoint point, HydrologyGeometrySampler geometry) {
HydrologyPlannerSettings.Surface surface = planner.settings.surface();
int authored = geometry.sample(HydrologyGeometrySampler.Field.SURFACE_WIDTH, profile, point.x(), point.z(),
surface.minimumWidth(), surface.maximumWidth());
return Math.max(surface.minimumWidth(), Math.min(surface.maximumWidth() * 2D,
authored * sample(point.x(), point.z()).widthMultiplier()));
}
private HydraulicSegment mouth(long courseId, HydrologyPoint start, HydrologyPoint end, double width, int depth, int side) {
int head = planner.settings.seaLevel();
return new HydraulicSegment(HydrologyHash.mix(courseId, HydrologyFeatureType.MOUTH.ordinal(), side), courseId,
HydrologyFeatureType.MOUTH, head, head, (int) StrictMath.ceil(width), depth, false, false,
List.of(HydrologyPlanner.withY(start, head), HydrologyPlanner.withY(end, head)), HydraulicChannelProfile.uniform(width, depth));
}
private boolean validFootprints(RiverCourse course, List<HydrologyDiagnosticCandidate> diagnostics, HydrologyRegionalFalls falls) {
double length = 0D;
ArrayList<HydrologyPoint> points = new ArrayList<>();
for (HydraulicSegment segment : course.segments()) {
points.addAll(segment.centerline());
for (int index = 1; index < segment.centerline().size(); index++) {
length += StrictMath.sqrt(segment.centerline().get(index).distanceSquared2D(segment.centerline().get(index - 1)));
}
}
boolean coastal = course.segments().getFirst().type() == HydrologyFeatureType.MOUTH;
HydrologyPoint source = course.segments().getFirst().start();
if (length < settings.minimumLength() || length > planner.settings.routing().maximumRouteLength()) {
diagnostic(diagnostics, course.id(), source, coastal, length < settings.minimumLength()
? HydrologyCandidateRejection.COURSE_TOO_SHORT : HydrologyCandidateRejection.ROUTE_LIMIT,
(int) StrictMath.ceil(length));
return false;
}
SurfaceFootprintCompiler compiler = new SurfaceFootprintCompiler(planner.settings,
this::sample, planner.geometrySampler);
SurfaceCenterline centerline = SurfaceCenterline.densify(points);
int[] excavation = new int[centerline.size()];
HydrologyRegionalConnectivity connectivity = new HydrologyRegionalConnectivity();
HydrologyTerrainSampler receiver = HydrologyOceanReceiver.forCourse(planner.settings, this::sample, course);
for (SurfaceBounds bounds : courseWindows(course)) {
SurfaceFootprint footprint = compiler.compile(course, bounds);
if (!footprint.accepted()) {
diagnostic(diagnostics, course.id(), source, coastal, footprint.rejection(), footprint.rejectionDetail());
return false;
}
falls.retain(footprint);
connectivity.add(footprint.columns());
HydrologySurfaceDropRaster drops = HydrologySurfaceDropRaster.compile(planner.settings,
receiver, planner.geometrySampler, course, bounds);
SurfaceExcavationMetrics.accumulate(course, centerline, footprint, drops, bounds, excavation);
for (HydrologyColumnSample column : drops.columns()) {
connectivity.add(column);
for (HydrologyColumnLayer layer : column.layers()) {
if (layer.fluidOwned() && !sample(column.x(), column.z()).preferredProfileKeys().contains(course.profileKey())) {
diagnostic(diagnostics, course.id(), source, coastal, HydrologyCandidateRejection.POLICY_EXCLUDED, 0);
return false;
}
}
}
for (SurfaceLayerColumn column : footprint.columns()) {
if (column.layer().fluidOwned() && !column.terrain().preferredProfileKeys().contains(course.profileKey())) {
diagnostic(diagnostics, course.id(), source, coastal, HydrologyCandidateRejection.POLICY_EXCLUDED, column.station());
return false;
}
if (coastal && column.layer().fluidOwned()
&& column.terrain().naturalHeight() - column.layer().bedY() > settings.maximumCoastalIncision()) {
diagnostic(diagnostics, course.id(), source, true, HydrologyCandidateRejection.SURFACE_BANK_BUDGET,
column.terrain().naturalHeight() - column.layer().bedY());
return false;
}
}
}
int volume = SurfaceExcavationMetrics.maximumVolumePerBlock(centerline, excavation);
if (volume > planner.settings.surface().banks().erosion().excavation().maximumVolumePerBlock()) {
diagnostic(diagnostics, course.id(), source, coastal, HydrologyCandidateRejection.SURFACE_BANK_BUDGET, volume);
return false;
}
if (!connectivity.connected(course, this::sample, planner.settings)) {
diagnostic(diagnostics, course.id(), source, coastal, HydrologyCandidateRejection.SURFACE_MOUTH_DISCONNECTED, 0);
return false;
}
return true;
}
private List<SurfaceBounds> courseWindows(RiverCourse course) {
int tileSize = planner.settings.routing().tileSize();
int radius = footprintRadius();
Set<HydrologyTileKey> keys = new HashSet<>();
for (HydraulicSegment segment : course.segments()) {
for (HydrologyPoint point : segment.centerline()) {
for (int z = Math.floorDiv(point.z() - radius, tileSize); z <= Math.floorDiv(point.z() + radius, tileSize); z++) {
for (int x = Math.floorDiv(point.x() - radius, tileSize); x <= Math.floorDiv(point.x() + radius, tileSize); x++) {
keys.add(new HydrologyTileKey(x, z));
}
}
}
}
ArrayList<HydrologyTileKey> ordered = new ArrayList<>(keys);
ordered.sort(HydrologyTileKey::compareTo);
ArrayList<SurfaceBounds> windows = new ArrayList<>(ordered.size());
for (HydrologyTileKey key : ordered) {
windows.add(tileBounds(key));
}
return List.copyOf(windows);
}
SurfaceBounds tileBounds(HydrologyTileKey key) {
int size = planner.settings.routing().tileSize();
return new SurfaceBounds(key.minimumBlockX(size), key.minimumBlockZ(size),
Math.addExact(key.minimumBlockX(size), size - 1), Math.addExact(key.minimumBlockZ(size), size - 1));
}
SurfaceBounds ownerBounds(HydrologyTileKey key) {
SurfaceBounds tile = tileBounds(key);
int reach = planner.settings.publicationRadius() + 1;
return new SurfaceBounds(Math.subtractExact(tile.minimumX(), reach), Math.subtractExact(tile.minimumZ(), reach),
Math.addExact(tile.maximumX(), reach), Math.addExact(tile.maximumZ(), reach));
}
HydrologyCaveCourseFilter.Result include(HydrologyCaveCourseFilter.Result local, HydrologyRegionalNetwork regional) {
if (regional.courses().isEmpty()) {
return local;
}
HydrologyRegionalNetwork combined = merge(List.of(new HydrologyRegionalNetwork(local.nodes(), local.edges(),
local.outlets(), local.courses(), List.of(), local.cavePlans()), regional));
return new HydrologyCaveCourseFilter.Result(combined.nodes(), combined.edges(), combined.outlets(),
combined.courses(), combined.cavePlans());
}
HydrologyCaveCourseFilter.Result withoutRegional(HydrologyCaveCourseFilter.Result result, HydrologyTileKey key) {
if (!enabled()) {
return result;
}
Set<Long> regionalIds = new HashSet<>();
for (RiverCourse course : coursesIn(ownerBounds(key)).courses()) {
regionalIds.add(course.id());
}
ArrayList<RiverCourse> local = new ArrayList<>();
for (RiverCourse course : result.courses()) {
if (!regionalIds.contains(course.id())) {
local.add(course);
}
}
ArrayList<HydrologyCavePlan> plans = new ArrayList<>();
for (HydrologyCavePlan plan : result.cavePlans()) {
if (!regionalIds.contains(plan.source().sourceId())) {
plans.add(plan);
}
}
return new HydrologyCaveCourseFilter.Result(result.nodes(), result.edges(), result.outlets(), local, plans);
}
private int footprintRadius() {
return (int) StrictMath.ceil(planner.settings.surface().maximumWidth() * 2D
+ Math.max(planner.settings.surface().shoreWidth(), planner.settings.widestShoreBiomeWidth())
+ Math.min(planner.settings.surface().banks().maximumBlendWidth(),
planner.settings.surface().banks().erosion().excavation().maximumWidth()) + 4D);
}
private HydrologySampledGrid sample(HydrologyTileKey key) {
int spacing = settings.sampleSpacing();
int size = settings.basinSize(planner.settings.routing());
int ownerX = key.minimumBlockX(size);
int ownerZ = key.minimumBlockZ(size);
int minimumX = Math.subtractExact(ownerX, spacing);
int minimumZ = Math.subtractExact(ownerZ, spacing);
int width = size / spacing + 3;
HydrologyTerrainSample[] terrain = planner.routingSampler.sampleGrid(
new HydrologyRoutingTerrainSampler.GridRequest(minimumX, minimumZ, width, spacing));
ArrayList<HydrologyGridNode> nodes = new ArrayList<>(terrain.length);
for (int index = 0; index < terrain.length; index++) {
int x = minimumX + index % width * spacing;
int z = minimumZ + index / width * spacing;
nodes.add(new HydrologyGridNode(index, index % width, index / width, x, z,
HydrologyHash.mix(planner.worldSeed, NODE_SALT, key.tileX(), key.tileZ(), x, z), terrain[index]));
}
return new HydrologySampledGrid(minimumX, minimumZ, ownerX, ownerZ, size, width, spacing, List.copyOf(nodes));
}
private ArrayList<OutletCandidate> spacedOutlets(List<OutletCandidate> candidates, int maximum) {
ArrayList<OutletCandidate> selected = new ArrayList<>();
long spacing = Math.max(settings.minimumLength() / 2, settings.sampleSpacing() * 2);
for (OutletCandidate candidate : candidates) {
boolean overlap = false;
for (OutletCandidate existing : selected) {
if (existing.outlet().landwardPoint().distanceSquared2D(candidate.outlet().landwardPoint()) < spacing * spacing) {
overlap = true;
break;
}
}
if (!overlap) {
selected.add(candidate);
if (selected.size() == maximum) {
break;
}
}
}
return selected;
}
private boolean admitted(HydrologyTileKey owner, RiverCourse course) {
long rank = HydrologyHash.mix(planner.worldSeed, BASIN_SALT, owner.tileX(), owner.tileZ());
int size = settings.basinSize(planner.settings.routing());
int reach = settings.sampleSpacing() * 2 + footprintRadius();
for (int z = owner.tileZ() - 1; z <= owner.tileZ() + 1; z++) {
for (int x = owner.tileX() - 1; x <= owner.tileX() + 1; x++) {
HydrologyTileKey other = new HydrologyTileKey(x, z);
if (other.equals(owner)) {
continue;
}
SurfaceBounds otherBounds = new SurfaceBounds(other.minimumBlockX(size), other.minimumBlockZ(size),
Math.addExact(other.minimumBlockX(size), size - 1), Math.addExact(other.minimumBlockZ(size), size - 1));
if (!intersects(course, otherBounds, reach)) {
continue;
}
long otherRank = HydrologyHash.mix(planner.worldSeed, BASIN_SALT, x, z);
if (Long.compareUnsigned(otherRank, rank) < 0 && conflicts(course, draft(other).courses())) {
return false;
}
}
}
return true;
}
boolean conflicts(RiverCourse course, List<RiverCourse> existing) {
HydrologyCrossTileSurfaceAdmission.Claim current = claim(course);
ArrayList<HydrologyCrossTileSurfaceAdmission.RankedClaim> blockers = new ArrayList<>(existing.size());
for (RiverCourse other : existing) {
blockers.add(new HydrologyCrossTileSurfaceAdmission.RankedClaim(new HydrologyTileKey(0, 0), 0, claim(other)));
}
return !HydrologyCrossTileSurfaceAdmission.admit(List.of(current), blockers).rejections().isEmpty();
}
private HydrologyCrossTileSurfaceAdmission.Claim claim(RiverCourse course) {
ArrayList<HydrologyPoint> points = new ArrayList<>();
int width = 1;
for (HydraulicSegment segment : course.segments()) {
width = Math.max(width, segment.width());
for (HydrologyPoint point : segment.centerline()) {
if (points.isEmpty() || points.getLast().distanceSquared2D(point) >= 256L) {
points.add(point);
}
}
}
HydrologyPoint last = course.segments().getLast().end();
if (points.size() < 2 || !points.getLast().equals(last)) {
points.add(last);
}
int bankWidth = planner.settings.surface().banks().erosion().excavation().maximumWidth();
int completeWidth = width + 2 * ((int) StrictMath.ceil(planner.settings.surface().shoreWidth()) + bankWidth);
return new HydrologyCrossTileSurfaceAdmission.Claim(course.id(), course.outletId().orElse(course.id()), last,
true, completeWidth, points, 0);
}
private static boolean intersects(RiverCourse course, SurfaceBounds bounds, int radius) {
for (HydraulicSegment segment : course.segments()) {
for (HydrologyPoint point : segment.centerline()) {
if (point.x() >= bounds.minimumX() - radius && point.x() <= bounds.maximumX() + radius
&& point.z() >= bounds.minimumZ() - radius && point.z() <= bounds.maximumZ() + radius) {
return true;
}
}
}
return false;
}
static HydrologyColumnSample mergeColumn(HydrologyColumnSample first, HydrologyColumnSample second) {
ArrayList<HydrologyColumnLayer> layers = new ArrayList<>(first.layers());
for (HydrologyColumnLayer layer : second.layers()) {
if (!layers.contains(layer)) {
layers.add(layer);
}
}
return new HydrologyColumnSample(first.x(), first.z(), first.naturalHeight(), first.seaLevel(), first.ocean(),
first.parentBiomeKey(), layers);
}
private static HydrologyRegionalNetwork select(HydrologyRegionalNetwork network, List<RiverCourse> courses) {
Set<Long> nodeIds = new HashSet<>();
Set<Long> outletIds = new HashSet<>();
ArrayList<DrainageEdge> edges = new ArrayList<>();
for (RiverCourse course : courses) {
nodeIds.add(course.sourceNodeId().orElseThrow());
outletIds.add(course.outletId().orElseThrow());
for (DrainageEdge edge : course.drainageEdges()) {
nodeIds.add(edge.upstreamNodeId());
nodeIds.add(edge.downstreamNodeId());
edges.add(edge);
}
}
ArrayList<DrainageNode> nodes = new ArrayList<>();
for (DrainageNode node : network.nodes()) {
if (nodeIds.contains(node.id())) {
nodes.add(node);
}
}
ArrayList<RiverOutlet> outlets = new ArrayList<>();
for (RiverOutlet outlet : network.outlets()) {
if (outletIds.contains(outlet.id()) || courses.stream().anyMatch(course ->
course.segments().getFirst().type() == HydrologyFeatureType.MOUTH
&& course.segments().getFirst().start().distanceSquared2D(outlet.connectionPoint()) == 0L)) {
outlets.add(outlet);
}
}
Set<Long> courseIds = new HashSet<>();
for (RiverCourse course : courses) { courseIds.add(course.id()); }
ArrayList<HydrologyCavePlan> plans = new ArrayList<>();
for (HydrologyCavePlan plan : network.cavePlans()) {
if (courseIds.contains(plan.source().sourceId())) { plans.add(plan); }
}
return new HydrologyRegionalNetwork(nodes, edges, outlets, courses, List.of(), plans);
}
private static void diagnostic(List<HydrologyDiagnosticCandidate> diagnostics, long id, HydrologyPoint point,
boolean coastal, HydrologyCandidateRejection rejection, int detail) {
if (diagnostics.size() >= MAXIMUM_DIAGNOSTICS) {
return;
}
HydrologyCandidateKind kind = coastal ? HydrologyCandidateKind.COASTAL_CHANNEL : HydrologyCandidateKind.REGIONAL_SOURCE;
diagnostics.add(new HydrologyDiagnosticCandidate(HydrologyHash.mix(id, BASIN_SALT, rejection.ordinal()), kind,
coastal ? HydrologyFeatureType.MOUTH : HydrologyFeatureType.SURFACE_POOL, point, rejection, detail));
}
private static HydrologyRegionalNetwork merge(List<HydrologyRegionalNetwork> networks) {
Map<Long, DrainageNode> nodes = new LinkedHashMap<>();
Map<Long, DrainageEdge> edges = new LinkedHashMap<>();
Map<Long, RiverOutlet> outlets = new LinkedHashMap<>();
Map<Long, RiverCourse> courses = new LinkedHashMap<>();
Map<Long, HydrologyDiagnosticCandidate> diagnostics = new LinkedHashMap<>();
Map<Long, HydrologyCavePlan> plans = new LinkedHashMap<>();
for (HydrologyRegionalNetwork network : networks) {
for (DrainageNode node : network.nodes()) { nodes.put(node.id(), node); }
for (DrainageEdge edge : network.edges()) { edges.put(edge.id(), edge); }
for (RiverOutlet outlet : network.outlets()) { outlets.put(outlet.id(), outlet); }
for (RiverCourse course : network.courses()) { courses.put(course.id(), course); }
for (HydrologyDiagnosticCandidate candidate : network.diagnostics()) { diagnostics.put(candidate.id(), candidate); }
for (HydrologyCavePlan plan : network.cavePlans()) { plans.put(plan.source().sourceId(), plan); }
}
return new HydrologyRegionalNetwork(new ArrayList<>(nodes.values()), new ArrayList<>(edges.values()),
new ArrayList<>(outlets.values()), new ArrayList<>(courses.values()), new ArrayList<>(diagnostics.values()), new ArrayList<>(plans.values()));
}
private record BuildContext(HydrologyGridNode source, OutletCandidate origin, long basinSeed, RiverOutlet outlet,
String profile, HydrologyRegionalFlow flow, HydrologyRegionalMorphology morphology) {
}
}
@@ -0,0 +1,127 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.cave.CavePosition;
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction;
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveConflictPolicy;
import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
final class HydrologyRegionalProtection {
private HydrologyRegionalProtection() {
}
static void rejectFootprints(List<RiverCourse> courses, HydrologyFootprintCompiler compiler,
List<HydrologyDiagnosticCandidate> diagnostics) {
HydrologyRegionalNetwork regional = compiler.regionalNetwork;
if (regional.cavePlans().isEmpty()) {
return;
}
Map<Long, String> profiles = profiles(regional);
Iterator<RiverCourse> iterator = courses.iterator();
while (iterator.hasNext()) {
RiverCourse course = iterator.next();
RiverFootprint footprint = compiler.compileCourse(course);
for (HydrologyCavePlan plan : regional.cavePlans()) {
if (changesWitness(course.profileKey(), footprint, profiles.get(plan.source().sourceId()), plan)) {
diagnostics.add(diagnostic(course, plan.source().sourceId()));
iterator.remove();
break;
}
}
}
}
static HydrologyCaveCourseFilter.Result rejectPlans(HydrologyCaveCourseFilter.Result result,
HydrologyRegionalNetwork regional,
List<HydrologyDiagnosticCandidate> diagnostics) {
if (regional.cavePlans().isEmpty() || result.cavePlans().isEmpty()) {
return result;
}
Map<Long, String> profiles = profiles(regional);
Map<Long, RiverCourse> courses = new HashMap<>();
for (RiverCourse course : result.courses()) {
courses.put(course.id(), course);
}
Set<Long> rejected = new HashSet<>();
for (HydrologyCavePlan local : result.cavePlans()) {
RiverCourse course = courses.get(local.source().sourceId());
for (HydrologyCavePlan fixed : regional.cavePlans()) {
String regionalProfile = profiles.get(fixed.source().sourceId());
if (HydrologyCaveConflictPolicy.hasIncompatibleOverlap(course.profileKey(), local.actions(),
regionalProfile, fixed.actions()) || changesWitness(course.profileKey(), local, regionalProfile, fixed)) {
rejected.add(course.id());
diagnostics.add(diagnostic(course, fixed.source().sourceId()));
break;
}
}
}
return HydrologyCaveCourseFilter.withoutCourses(result, rejected);
}
static boolean changesWitness(String profile, RiverFootprint footprint, String regionalProfile, HydrologyCavePlan plan) {
for (HydrologyColumnSample column : footprint.columns().values()) {
if (!plan.allPreconditionsIn(column.x(), column.z(), Math.addExact(column.x(), 1), Math.addExact(column.z(), 1),
(position, precondition) -> preservesColumn(profile, column, regionalProfile, plan, position))) {
return true;
}
}
return false;
}
private static boolean preservesColumn(String profile, HydrologyColumnSample column, String regionalProfile,
HydrologyCavePlan plan, CavePosition position) {
for (HydrologyColumnLayer layer : column.layers()) {
HydrologyCaveAction action = actionAt(column, layer, position.y());
if (action != null && (!profile.equals(regionalProfile) || plan.actions().get(position) != action)) {
return false;
}
}
return true;
}
private static HydrologyCaveAction actionAt(HydrologyColumnSample column, HydrologyColumnLayer layer, int y) {
if (y <= layer.bedY() || layer.oceanApron()) {
return null;
}
if (layer.channel() && layer.fluidOwned() && (layer.terrainOwned() || layer.fallingFluid())
&& y <= layer.fluidHeadY()) {
return layer.fallingFluid() && y < layer.fluidHeadY()
? HydrologyCaveAction.FALLING_FLUID : HydrologyCaveAction.WET_SOURCE;
}
if (!layer.terrainOwned()) {
return null;
}
int ceiling = layer.feature().type().isSurface() ? column.naturalHeight() : layer.ceilingY();
return y <= ceiling ? HydrologyCaveAction.DRY_AIR : null;
}
private static boolean changesWitness(String profile, HydrologyCavePlan local, String regionalProfile, HydrologyCavePlan regional) {
for (Map.Entry<CavePosition, HydrologyCaveAction> entry : local.actions().entrySet()) {
if (entry.getValue() != HydrologyCaveAction.SEAL_GUARD && regional.baselinePreconditions().containsKey(entry.getKey())
&& (!profile.equals(regionalProfile) || regional.actions().get(entry.getKey()) != entry.getValue())) {
return true;
}
}
return false;
}
private static Map<Long, String> profiles(HydrologyRegionalNetwork regional) {
Map<Long, String> profiles = new HashMap<>();
for (RiverCourse course : regional.courses()) {
profiles.put(course.id(), course.profileKey());
}
return profiles;
}
private static HydrologyDiagnosticCandidate diagnostic(RiverCourse course, long regionalId) {
HydraulicSegment segment = course.segments().getFirst();
return new HydrologyDiagnosticCandidate(HydrologyHash.mix(course.id(), regionalId, 0x52454750524f5445L),
HydrologyCandidateKind.SOURCE, segment.type(), segment.start(), HydrologyCandidateRejection.CAVE_CONTAINMENT, 0);
}
}
@@ -0,0 +1,637 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.util.project.noise.SimplexNoise;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
final class HydrologyRegionalRoute {
private static final double SAMPLE_SPACING = 4D;
private static final double SIMPLEX_COORDINATE_SCALE = 100D;
private static final int MAXIMUM_RECEIVER_SAMPLES = 65536;
private final HydrologyPlanner planner;
private final HydrologyRegionalTerrainRefiner terrainRefiner;
private final HydrologyRegionalHydraulics hydraulics;
private final SimplexNoise bends;
private final SimplexNoise details;
HydrologyRegionalRoute(HydrologyPlanner planner) {
this.planner = planner;
this.terrainRefiner = new HydrologyRegionalTerrainRefiner(planner);
this.hydraulics = new HydrologyRegionalHydraulics(planner.settings);
this.bends = new SimplexNoise(HydrologyHash.mix(planner.worldSeed, 0x52454742454e4453L));
this.details = new SimplexNoise(HydrologyHash.mix(planner.worldSeed, 0x5245474445544149L));
}
void clear() {
terrainRefiner.clear();
}
Attempt select(List<HydrologyPoint> guide, String profile, boolean coastal, HydrologyTerrainSampler receiver,
CandidateAdmission admission) {
HydrologyRegionalMorphology morphology = HydrologyRegionalMorphology.sample(guide, terrainRefiner::sample, planner.settings);
Refinement topology = validateCorridor(guide, profile, coastal);
OceanEntry resolved = topology.rejection() == null ? topology.oceanEntry() : null;
if (resolved != null) {
guide = topology.points();
receiver = HydrologyOceanReceiver.forConnection(planner.settings, terrainRefiner::sample,
resolved.landward(), resolved.receiving());
}
CandidateSelection selection = new CandidateSelection(admission, resolved);
List<HydrologyPoint> repaired = terrainRefiner.refine(guide, profile, coastal, receiver);
if (!repaired.isEmpty() && !repaired.equals(guide)) {
Attempt accepted = selectCurve(repaired, profile, coastal, receiver, selection, morphology);
if (accepted != null) {
return accepted;
}
}
Attempt accepted = selectCurve(guide, profile, coastal, receiver, selection, morphology);
return accepted == null ? selection.failure : accepted;
}
private Refinement retainReceivingEntry(Refinement refined, OceanEntry resolved) {
if (resolved == null || refined.rejection() != null || refined.oceanEntry() != null) {
return refined;
}
if (refined.points().getLast().distanceSquared2D(resolved.landward()) > 0L) {
return new Refinement(List.of(), refined.points().getLast(),
HydrologyCandidateRejection.SURFACE_MOUTH_DISCONNECTED, 0, null);
}
double length = 0D;
for (int index = 1; index < refined.points().size(); index++) {
length += StrictMath.sqrt(refined.points().get(index - 1).distanceSquared2D(refined.points().get(index)));
}
if (length < planner.settings.routing().regional().minimumLength()) {
return new Refinement(List.of(), refined.points().getLast(),
HydrologyCandidateRejection.COURSE_TOO_SHORT, (int) StrictMath.floor(length), null);
}
return new Refinement(refined.points(), null, null, 0, resolved);
}
private Attempt selectCurve(List<HydrologyPoint> guide, String profile, boolean coastal,
HydrologyTerrainSampler receiver, CandidateSelection selection, HydrologyRegionalMorphology morphology) {
for (double strength : new double[]{1D, 0.5D, 0D}) {
List<HydrologyPoint> points = smooth(interpolate(guide, strength, morphology));
Attempt accepted = selection.consider(validate(points, profile, coastal, receiver));
if (accepted != null) {
return accepted;
}
}
List<HydrologyPoint> linear = interpolateLinear(guide);
Refinement constrained = validate(linear, profile, coastal, receiver);
Attempt accepted = selection.consider(constrained);
if (accepted != null) {
return accepted;
}
if (constrained.rejection() == HydrologyCandidateRejection.SURFACE_SHAPE_UNSUPPORTED) {
for (double radius : new double[]{16D, 32D, 64D, 128D, 256D}) {
accepted = selection.consider(validateRounded(resample(roundBends(linear, radius)), profile, coastal, receiver));
if (accepted != null) {
return accepted;
}
}
}
return null;
}
private List<HydrologyPoint> interpolateLinear(List<HydrologyPoint> guide) {
ArrayList<HydrologyPoint> points = new ArrayList<>();
for (int index = 0; index + 1 < guide.size(); index++) {
HydrologyPoint start = guide.get(index);
HydrologyPoint end = guide.get(index + 1);
int steps = Math.max(1, (int) StrictMath.ceil(StrictMath.sqrt(start.distanceSquared2D(end)) / SAMPLE_SPACING));
for (int step = index == 0 ? 0 : 1; step <= steps; step++) {
double progress = step / (double) steps;
HydrologyPoint point = new HydrologyPoint(
(int) StrictMath.round(start.x() + (end.x() - start.x()) * progress),
(int) StrictMath.round(start.y() + (end.y() - start.y()) * progress),
(int) StrictMath.round(start.z() + (end.z() - start.z()) * progress));
if (points.isEmpty() || points.getLast().distanceSquared2D(point) > 0L) {
points.add(point);
}
}
}
return List.copyOf(points);
}
private List<CurvePoint> roundBends(List<HydrologyPoint> points, double radius) {
double[] stations = new double[points.size()];
for (int index = 1; index < points.size(); index++) {
stations[index] = stations[index - 1] + StrictMath.sqrt(points.get(index - 1).distanceSquared2D(points.get(index)));
}
double length = stations[stations.length - 1];
radius = Math.min(radius, length / 2D);
HydrologyPoint start = points.getFirst();
HydrologyPoint end = points.getLast();
ArrayList<CurvePoint> rounded = new ArrayList<>(points.size());
rounded.add(CurvePoint.of(points.getFirst()));
int first = 0;
for (int index = 1; index + 1 < points.size(); index++) {
while (stations[first] < stations[index] - radius) {
first++;
}
double weightedX = 0D;
double weightedZ = 0D;
double totalWeight = 0D;
for (int sample = first; sample < points.size() && stations[sample] < stations[index] + radius; sample++) {
double weight = 1D - StrictMath.abs(stations[sample] - stations[index]) / radius;
weightedX += points.get(sample).x() * weight;
weightedZ += points.get(sample).z() * weight;
totalWeight += weight;
}
for (int sample = 1; sample < points.size() && stations[sample] + stations[index] < radius; sample++) {
double weight = 1D - (stations[sample] + stations[index]) / radius;
weightedX += (2D * start.x() - points.get(sample).x()) * weight;
weightedZ += (2D * start.z() - points.get(sample).z()) * weight;
totalWeight += weight;
}
for (int sample = points.size() - 2; sample >= 0 && 2D * length - stations[sample] - stations[index] < radius; sample--) {
double weight = 1D - (2D * length - stations[sample] - stations[index]) / radius;
weightedX += (2D * end.x() - points.get(sample).x()) * weight;
weightedZ += (2D * end.z() - points.get(sample).z()) * weight;
totalWeight += weight;
}
HydrologyPoint point = points.get(index);
CurvePoint smoothed = new CurvePoint(weightedX / totalWeight, point.y(), weightedZ / totalWeight);
if (rounded.getLast().distance(smoothed) > 0D) {
rounded.add(smoothed);
}
}
if (rounded.getLast().distance(CurvePoint.of(points.getLast())) > 0D) {
rounded.add(CurvePoint.of(points.getLast()));
}
return List.copyOf(rounded);
}
private List<CurvePoint> resample(List<CurvePoint> points) {
ArrayList<CurvePoint> sampled = new ArrayList<>(points.size());
sampled.add(points.getFirst());
double nextSample = SAMPLE_SPACING;
for (int index = 1; index < points.size(); index++) {
CurvePoint first = points.get(index - 1);
CurvePoint second = points.get(index);
double length = first.distance(second);
if (length == 0D) {
continue;
}
while (nextSample <= length) {
double progress = nextSample / length;
CurvePoint point = new CurvePoint(first.x() + (second.x() - first.x()) * progress,
first.y() + (second.y() - first.y()) * progress,
first.z() + (second.z() - first.z()) * progress);
if (sampled.getLast().distance(point) > 0D) {
sampled.add(point);
}
nextSample += SAMPLE_SPACING;
}
nextSample -= length;
}
if (sampled.getLast().distance(points.getLast()) > 0D) {
sampled.add(points.getLast());
}
return List.copyOf(sampled);
}
private List<HydrologyPoint> interpolate(List<HydrologyPoint> guide, double strength, HydrologyRegionalMorphology morphology) {
ArrayList<HydrologyPoint> points = new ArrayList<>();
double spacing = planner.settings.routing().regional().sampleSpacing();
HydrologyPlannerSettings.Meanders meanders = planner.settings.geometry().meanders();
double scale = spacing / planner.settings.routing().sampleSpacing();
double primaryWavelength = meanders.primaryWavelength() * scale * 5D;
double detailWavelength = meanders.detailWavelength() * scale * 8D;
for (int index = 0; index + 1 < guide.size(); index++) {
HydrologyPoint before = guide.get(Math.max(0, index - 1));
HydrologyPoint start = guide.get(index);
HydrologyPoint end = guide.get(index + 1);
HydrologyPoint after = guide.get(Math.min(guide.size() - 1, index + 2));
int steps = Math.max(1, (int) StrictMath.ceil(StrictMath.sqrt(start.distanceSquared2D(end)) / SAMPLE_SPACING));
for (int step = index == 0 ? 0 : 1; step <= steps; step++) {
double progress = step / (double) steps;
double x = spline(before.x(), start.x(), end.x(), after.x(), progress);
double z = spline(before.z(), start.z(), end.z(), after.z(), progress);
double tangentX = end.x() - before.x() + (after.x() - start.x() - end.x() + before.x()) * progress;
double tangentZ = end.z() - before.z() + (after.z() - start.z() - end.z() + before.z()) * progress;
double tangentLength = Math.max(1D, StrictMath.hypot(tangentX, tangentZ));
double endpointDistance = Math.min(index + progress, guide.size() - 1D - index - progress);
double envelope = Math.min(1D, endpointDistance / 2D);
double height = start.y() + (end.y() - start.y()) * progress;
double primary = bends.noiseSigned(x * SIMPLEX_COORDINATE_SCALE / primaryWavelength,
height * SIMPLEX_COORDINATE_SCALE / 96D, z * SIMPLEX_COORDINATE_SCALE / primaryWavelength);
double detail = details.noiseSigned(x * SIMPLEX_COORDINATE_SCALE / detailWavelength,
height * SIMPLEX_COORDINATE_SCALE / 48D, z * SIMPLEX_COORDINATE_SCALE / detailWavelength);
double freedom = morphology.freedomAt(x, z);
double broad = primary * meanders.primaryStrength() + detail * meanders.detailStrength() * 0.35D;
double tight = detail * (meanders.primaryStrength() + meanders.detailStrength() * 0.35D);
double signal = broad * freedom + tight * (1D - freedom);
double displacement = Math.max(-1D, Math.min(1D, signal * 2D))
* spacing * meanders.maximumOffsetRatio() * envelope * strength * (0.2D + 0.8D * freedom);
int pointX = (int) StrictMath.round(x - tangentZ / tangentLength * displacement);
int pointZ = (int) StrictMath.round(z + tangentX / tangentLength * displacement);
if (points.isEmpty() || points.getLast().x() != pointX || points.getLast().z() != pointZ) {
points.add(new HydrologyPoint(pointX, (int) StrictMath.round(height), pointZ));
}
}
}
return List.copyOf(points);
}
private List<HydrologyPoint> smooth(List<HydrologyPoint> points) {
for (int pass = 0; pass < planner.settings.geometry().meanders().smoothingPasses(); pass++) {
ArrayList<HydrologyPoint> result = new ArrayList<>(points.size());
result.add(points.getFirst());
for (int index = 1; index + 1 < points.size(); index++) {
HydrologyPoint previous = points.get(index - 1);
HydrologyPoint point = points.get(index);
HydrologyPoint next = points.get(index + 1);
HydrologyPoint smoothed = new HydrologyPoint(
(int) StrictMath.round((previous.x() + point.x() * 2D + next.x()) / 4D), point.y(),
(int) StrictMath.round((previous.z() + point.z() * 2D + next.z()) / 4D));
if (smoothed.distanceSquared2D(result.getLast()) > 0L) {
result.add(smoothed);
}
}
if (points.getLast().distanceSquared2D(result.getLast()) > 0L) {
result.add(points.getLast());
}
points = result;
}
return List.copyOf(points);
}
private Refinement validate(List<HydrologyPoint> points, String profile, boolean coastal, HydrologyTerrainSampler receiver) {
if (points.size() < 2) {
return new Refinement(List.of(), points.isEmpty() ? new HydrologyPoint(0, 0, 0) : points.getFirst(),
HydrologyCandidateRejection.COURSE_TOO_SHORT, points.size(), null);
}
Refinement corridor = validateHydraulics(validateCorridor(points, profile, coastal), profile, coastal, receiver);
if (corridor.rejection() != null) {
return corridor;
}
points = corridor.points();
for (int index = 1; index + 1 < points.size(); index++) {
double turn = turn(points, index);
if (turn > planner.settings.geometry().meanders().maximumTurnDegrees()) {
return new Refinement(List.of(), points.get(index), HydrologyCandidateRejection.SURFACE_SHAPE_UNSUPPORTED,
(int) StrictMath.ceil(turn), null);
}
}
return corridor;
}
private Refinement validateRounded(List<CurvePoint> curve, String profile, boolean coastal, HydrologyTerrainSampler receiver) {
ArrayList<HydrologyPoint> points = new ArrayList<>(curve.size());
ArrayList<CurvePoint> distinct = new ArrayList<>(curve.size());
for (CurvePoint point : curve) {
HydrologyPoint rounded = point.rounded();
if (points.isEmpty() || points.getLast().distanceSquared2D(rounded) > 0L) {
points.add(rounded);
distinct.add(point);
}
}
if (points.size() < 2) {
return new Refinement(List.of(), curve.getFirst().rounded(), HydrologyCandidateRejection.COURSE_TOO_SHORT, points.size(), null);
}
Refinement corridor = validateHydraulics(validateCorridor(points, profile, coastal), profile, coastal, receiver);
if (corridor.rejection() != null) {
return corridor;
}
if (corridor.oceanEntry() != null) {
int last = corridor.points().size() - 1;
HydrologyPoint end = corridor.points().getLast();
if (points.get(last).distanceSquared2D(end) == 0L) {
distinct = new ArrayList<>(distinct.subList(0, last + 1));
} else {
CurvePoint first = distinct.get(last - 1);
CurvePoint second = distinct.get(last);
double progress = StrictMath.sqrt(points.get(last - 1).distanceSquared2D(end)
/ (double) points.get(last - 1).distanceSquared2D(points.get(last)));
distinct = new ArrayList<>(distinct.subList(0, last));
distinct.add(new CurvePoint(first.x() + (second.x() - first.x()) * progress,
first.y() + (second.y() - first.y()) * progress,
first.z() + (second.z() - first.z()) * progress));
}
}
for (int index = 1; index + 1 < distinct.size(); index++) {
CurvePoint previous = distinct.get(Math.max(0, index - 4));
CurvePoint point = distinct.get(index);
CurvePoint next = distinct.get(Math.min(distinct.size() - 1, index + 4));
double firstX = point.x() - previous.x();
double firstZ = point.z() - previous.z();
double nextX = next.x() - point.x();
double nextZ = next.z() - point.z();
double turn = StrictMath.toDegrees(StrictMath.abs(StrictMath.atan2(firstX * nextZ - firstZ * nextX,
firstX * nextX + firstZ * nextZ)));
if (turn > planner.settings.geometry().meanders().maximumTurnDegrees()) {
return new Refinement(List.of(), point.rounded(), HydrologyCandidateRejection.SURFACE_SHAPE_UNSUPPORTED,
(int) StrictMath.ceil(turn), null);
}
}
return corridor;
}
private Refinement validateCorridor(List<HydrologyPoint> points, String profile, boolean coastal) {
Set<Long> visited = new HashSet<>();
HydrologyTerrainSample previousTerrain = null;
HydrologyPoint previousPoint = null;
double length = 0D;
for (int index = 0; index + 1 < points.size(); index++) {
HydrologyPoint first = points.get(index);
HydrologyPoint second = points.get(index + 1);
int steps = Math.max(Math.abs(second.x() - first.x()), Math.abs(second.z() - first.z()));
for (int step = index == 0 ? 0 : 1; step <= steps; step++) {
double progress = steps == 0 ? 0D : step / (double) steps;
int x = (int) StrictMath.round(first.x() + (second.x() - first.x()) * progress);
int z = (int) StrictMath.round(first.z() + (second.z() - first.z()) * progress);
HydrologyTerrainSample terrain = terrainRefiner.sample(x, z);
HydrologyPoint failure = new HydrologyPoint(x, terrain == null ? first.y() : terrain.naturalHeight(), z);
if (!visited.add(RiverFootprint.pack(x, z))) {
return new Refinement(List.of(), failure, HydrologyCandidateRejection.SURFACE_SHAPE_UNSUPPORTED, 2, null);
}
double completedLength = length
+ (previousPoint == null ? 0D : StrictMath.sqrt(first.distanceSquared2D(previousPoint)));
if (terrain != null && terrain.ocean() && previousPoint != null
&& receivingEntry(previousTerrain, terrain, profile, coastal, completedLength)) {
ArrayList<HydrologyPoint> prefix = new ArrayList<>(points.subList(0, index + 1));
if (prefix.getLast().distanceSquared2D(previousPoint) > 0L) {
prefix.add(previousPoint);
}
int seaLevel = planner.settings.seaLevel();
OceanEntry entry = new OceanEntry(new HydrologyPoint(previousPoint.x(), seaLevel, previousPoint.z()),
new HydrologyPoint(x, seaLevel, z));
return new Refinement(List.copyOf(prefix), null, null, 0, entry);
}
if (terrain != null && !terrain.ocean() && terrain.naturalHeight() < planner.settings.seaLevel()
&& previousPoint != null && completedLength >= planner.settings.routing().regional().minimumLength()
&& completedLength <= planner.settings.routing().maximumRouteLength()) {
return receivingTail(points, new Shore(index, step, previousPoint, previousTerrain), profile, coastal);
}
double traversedLength = length + StrictMath.sqrt(first.distanceSquared2D(failure));
if (traversedLength > planner.settings.routing().maximumRouteLength()) {
return new Refinement(List.of(), failure, HydrologyCandidateRejection.ROUTE_LIMIT,
(int) StrictMath.ceil(traversedLength), null);
}
if (terrain == null || !terrain.transitAllowed() || !terrain.preferredProfileKeys().contains(profile)) {
return new Refinement(List.of(), failure, HydrologyCandidateRejection.POLICY_EXCLUDED, 0, null);
}
if (terrain.ocean() || terrain.naturalHeight() < planner.settings.seaLevel()) {
return new Refinement(List.of(), failure, HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, 1, null);
}
if (previousTerrain != null && (!previousTerrain.drainsInto(terrain)
|| coastal && !terrain.drainsInto(previousTerrain))) {
return new Refinement(List.of(), failure, HydrologyCandidateRejection.CONFINED_NO_OUTLET, 0, null);
}
if (coastal && terrain.naturalHeight() - planner.settings.seaLevel()
+ planner.settings.surface().minimumDepth()
> planner.settings.routing().regional().maximumCoastalIncision()) {
return new Refinement(List.of(), failure, HydrologyCandidateRejection.SURFACE_BANK_BUDGET,
terrain.naturalHeight() - planner.settings.seaLevel() + planner.settings.surface().minimumDepth(), null);
}
previousTerrain = terrain;
previousPoint = failure;
}
length += StrictMath.sqrt(first.distanceSquared2D(second));
}
return new Refinement(points, null, null, 0, null);
}
private Refinement receivingTail(List<HydrologyPoint> points, Shore shore, String profile, boolean coastal) {
HydrologyPoint previousPoint = shore.point();
HydrologyTerrainSample previous = shore.terrain();
HydrologyPoint firstWet = null;
int samples = 0;
for (int index = shore.segment(); index + 1 < points.size(); index++) {
HydrologyPoint first = points.get(index);
HydrologyPoint last = points.get(index + 1);
int steps = Math.max(Math.abs(last.x() - first.x()), Math.abs(last.z() - first.z()));
for (int step = index == shore.segment() ? shore.step() : 1; step <= steps; step++) {
double progress = steps == 0 ? 0D : step / (double) steps;
int x = (int) StrictMath.round(first.x() + (last.x() - first.x()) * progress);
int z = (int) StrictMath.round(first.z() + (last.z() - first.z()) * progress);
if (++samples > MAXIMUM_RECEIVER_SAMPLES) {
return new Refinement(List.of(), previousPoint, HydrologyCandidateRejection.ROUTE_LIMIT, samples, null);
}
HydrologyTerrainSample terrain = terrainRefiner.sample(x, z);
HydrologyPoint point = new HydrologyPoint(x, terrain == null ? first.y() : terrain.naturalHeight(), z);
if (terrain == null || terrain.naturalHeight() >= planner.settings.seaLevel()) {
return new Refinement(List.of(), point, HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, 1, null);
}
if (!terrain.preferredProfileKeys().contains(profile)) {
return new Refinement(List.of(), point, HydrologyCandidateRejection.POLICY_EXCLUDED, 0, null);
}
if (!previous.drainsInto(terrain) || coastal && !terrain.drainsInto(previous)) {
return new Refinement(List.of(), point, HydrologyCandidateRejection.CONFINED_NO_OUTLET, 0, null);
}
if (previousPoint.x() != x && previousPoint.z() != z) {
samples += 2;
if (samples > MAXIMUM_RECEIVER_SAMPLES
|| !wetBridge(previousPoint.x(), z, previous, terrain, profile, coastal)
&& !wetBridge(x, previousPoint.z(), previous, terrain, profile, coastal)) {
return new Refinement(List.of(), point, HydrologyCandidateRejection.SURFACE_MOUTH_DISCONNECTED, 0, null);
}
}
if (firstWet == null) {
firstWet = point;
}
if (terrain.ocean()) {
return completedReceivingTail(points, shore, firstWet, point, profile, coastal);
}
previousPoint = point;
previous = terrain;
}
}
return new Refinement(List.of(), previousPoint, HydrologyCandidateRejection.SURFACE_MOUTH_DISCONNECTED, 0, null);
}
private boolean wetBridge(int x, int z, HydrologyTerrainSample previous, HydrologyTerrainSample next,
String profile, boolean coastal) {
HydrologyTerrainSample bridge = terrainRefiner.sample(x, z);
return bridge != null && bridge.naturalHeight() < planner.settings.seaLevel()
&& bridge.preferredProfileKeys().contains(profile) && previous.drainsInto(bridge) && bridge.drainsInto(next)
&& (!coastal || next.drainsInto(bridge) && bridge.drainsInto(previous));
}
private Refinement completedReceivingTail(List<HydrologyPoint> points, Shore shore,
HydrologyPoint firstWet, HydrologyPoint receiving, String profile, boolean coastal) {
int seaLevel = planner.settings.seaLevel();
HydrologyPoint landward = new HydrologyPoint(shore.point().x(), seaLevel, shore.point().z());
receiving = new HydrologyPoint(receiving.x(), seaLevel, receiving.z());
HydrologyTerrainSampler proof = HydrologyOceanReceiver.forConnection(planner.settings,
terrainRefiner::sample, landward, receiving);
if (!shore.terrain().outletAllowed() || !proof.receivingWater(firstWet.x(), firstWet.z(), seaLevel)
|| !receivingChordAllowed(shore, receiving, profile, coastal)) {
return new Refinement(List.of(), firstWet, HydrologyCandidateRejection.SURFACE_MOUTH_DISCONNECTED, 0, null);
}
ArrayList<HydrologyPoint> prefix = new ArrayList<>(points.subList(0, shore.segment() + 1));
if (prefix.getLast().distanceSquared2D(shore.point()) > 0L) {
prefix.add(shore.point());
}
return new Refinement(List.copyOf(prefix), null, null, 0, new OceanEntry(landward, receiving));
}
private boolean receivingChordAllowed(Shore shore, HydrologyPoint end, String profile, boolean coastal) {
HydrologyPoint start = shore.point();
int steps = Math.max(Math.abs(end.x() - start.x()), Math.abs(end.z() - start.z()));
if (steps > MAXIMUM_RECEIVER_SAMPLES) {
return false;
}
HydrologyTerrainSample previous = shore.terrain();
HydrologyPoint previousPoint = start;
for (int step = 1; step <= steps; step++) {
double progress = step / (double) steps;
int x = (int) StrictMath.round(start.x() + (end.x() - start.x()) * progress);
int z = (int) StrictMath.round(start.z() + (end.z() - start.z()) * progress);
HydrologyTerrainSample terrain = terrainRefiner.sample(x, z);
if (terrain == null || terrain.naturalHeight() >= planner.settings.seaLevel()
|| !terrain.preferredProfileKeys().contains(profile) || !previous.drainsInto(terrain)
|| coastal && !terrain.drainsInto(previous)) {
return false;
}
if (previousPoint.x() != x && previousPoint.z() != z
&& !wetBridge(previousPoint.x(), z, previous, terrain, profile, coastal)
&& !wetBridge(x, previousPoint.z(), previous, terrain, profile, coastal)) {
return false;
}
previous = terrain;
previousPoint = new HydrologyPoint(x, terrain.naturalHeight(), z);
}
return true;
}
private Refinement validateHydraulics(Refinement corridor, String profile, boolean coastal, HydrologyTerrainSampler receiver) {
if (corridor.rejection() != null || coastal) {
return corridor;
}
List<HydrologyPoint> points = corridor.points();
int count = 1;
for (int index = 1; index < points.size(); index++) {
count += Math.max(Math.abs(points.get(index).x() - points.get(index - 1).x()),
Math.abs(points.get(index).z() - points.get(index - 1).z()));
}
HydrologyPlannerSettings.Inlet inlet = planner.settings.surface().banks().inlet();
boolean oceanMouth = corridor.oceanEntry() != null || terrainRefiner.receivingTerminal(points.getLast(), profile, false, receiver);
int reach = oceanMouth ? Math.min(inlet.length(), (int) StrictMath.floor(count * inlet.courseFraction())) : 0;
int rampStart = count - reach - reach / 2;
int availableHead = Integer.MAX_VALUE;
int station = 0;
HydrologyTerrainSampler bankSampler = terrainRefiner::sample;
for (int index = 0; index + 1 < points.size(); index++) {
HydrologyPoint first = points.get(index);
HydrologyPoint second = points.get(index + 1);
int steps = Math.max(Math.abs(second.x() - first.x()), Math.abs(second.z() - first.z()));
double distance = StrictMath.sqrt(first.distanceSquared2D(second));
double tangentX = distance == 0D ? 1D : (second.x() - first.x()) / distance;
double tangentZ = distance == 0D ? 0D : (second.z() - first.z()) / distance;
for (int step = index == 0 ? 0 : 1; step <= steps; step++) {
double progress = steps == 0 ? 0D : step / (double) steps;
int x = (int) StrictMath.round(first.x() + (second.x() - first.x()) * progress);
int z = (int) StrictMath.round(first.z() + (second.z() - first.z()) * progress);
HydrologyTerrainSample terrain = terrainRefiner.sample(x, z);
availableHead = hydraulics.supportedHead(new HydrologyRegionalHydraulics.HeadStation(
x, z, tangentX, tangentZ, terrain, availableHead), bankSampler);
int requiredHead = station >= rampStart ? hydraulics.inletMinimumHead(terrain) : hydraulics.minimumHead(terrain);
if (requiredHead > availableHead) {
return new Refinement(List.of(), new HydrologyPoint(x, terrain.naturalHeight(), z),
HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, requiredHead - availableHead, null);
}
station++;
}
}
return corridor;
}
private boolean receivingEntry(HydrologyTerrainSample land, HydrologyTerrainSample ocean,
String profile, boolean coastal, double length) {
return length >= planner.settings.routing().regional().minimumLength()
&& ocean.naturalHeight() < planner.settings.seaLevel()
&& ocean.preferredProfileKeys().contains(profile)
&& land.outletAllowed() && land.drainsInto(ocean) && (!coastal || ocean.drainsInto(land));
}
private double turn(List<HydrologyPoint> points, int index) {
HydrologyPoint previous = points.get(Math.max(0, index - 4));
HydrologyPoint point = points.get(index);
HydrologyPoint next = points.get(Math.min(points.size() - 1, index + 4));
double firstX = point.x() - previous.x();
double firstZ = point.z() - previous.z();
double nextX = next.x() - point.x();
double nextZ = next.z() - point.z();
return StrictMath.toDegrees(StrictMath.abs(StrictMath.atan2(firstX * nextZ - firstZ * nextX,
firstX * nextX + firstZ * nextZ)));
}
private static double spline(double before, double start, double end, double after, double progress) {
return 0.5D * ((2D * start) + (-before + end) * progress
+ (2D * before - 5D * start + 4D * end - after) * progress * progress
+ (-before + 3D * start - 3D * end + after) * progress * progress * progress);
}
private final class CandidateSelection {
private final CandidateAdmission admission;
private final OceanEntry resolved;
private final Set<Refinement> attempted = new HashSet<>();
private Attempt failure;
private boolean finalFailure;
private CandidateSelection(CandidateAdmission admission, OceanEntry resolved) {
this.admission = admission;
this.resolved = resolved;
}
private Attempt consider(Refinement candidate) {
candidate = retainReceivingEntry(candidate, resolved);
if (candidate.rejection() != null) {
if (!finalFailure) {
failure = new Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null);
}
return null;
}
if (!attempted.add(candidate)) {
return null;
}
Attempt result = admission.evaluate(candidate);
if (result.accepted()) {
return result;
}
failure = result;
finalFailure = true;
return null;
}
}
interface CandidateAdmission {
Attempt evaluate(Refinement candidate);
}
record Attempt(Refinement refinement, HydrologyRegionalNetwork network, HydrologyDiagnosticCandidate rejection) {
boolean accepted() {
return refinement.rejection() == null && rejection == null;
}
}
private record Shore(int segment, int step, HydrologyPoint point, HydrologyTerrainSample terrain) {
}
private record CurvePoint(double x, double y, double z) {
private static CurvePoint of(HydrologyPoint point) {
return new CurvePoint(point.x(), point.y(), point.z());
}
private double distance(CurvePoint other) {
return StrictMath.hypot(x - other.x(), z - other.z());
}
private HydrologyPoint rounded() {
return new HydrologyPoint((int) StrictMath.round(x), (int) StrictMath.round(y), (int) StrictMath.round(z));
}
}
record OceanEntry(HydrologyPoint landward, HydrologyPoint receiving) {
}
record Refinement(List<HydrologyPoint> points, HydrologyPoint failure,
HydrologyCandidateRejection rejection, int detail, OceanEntry oceanEntry) {
}
}
@@ -0,0 +1,97 @@
package art.arcane.iris.engine.hydrology;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.OptionalInt;
import java.util.Set;
final class HydrologyRegionalSourceTrials {
private static final int LENGTH_BANDS = 4;
private final HydrologySampledGrid grid;
private final List<Catchment> catchments;
private int cursor;
HydrologyRegionalSourceTrials(HydrologySampledGrid grid, HydrologyRegionalGraph.Tree tree, List<Integer> sources) {
this.grid = grid;
this.catchments = new ArrayList<>(tree.outlets().size());
ArrayList<Integer> sorted = new ArrayList<>(sources);
sorted.sort(Comparator.comparingDouble((Integer source) -> tree.length(source)).reversed()
.thenComparingLong(source -> grid.node(source).id()));
for (int root = 0; root < tree.outlets().size(); root++) {
Catchment catchment = new Catchment(tree.outlets().get(root).outlet().id());
double minimum = Double.POSITIVE_INFINITY;
double maximum = 0D;
for (int source : sorted) {
if (tree.root(source) == root) {
minimum = Math.min(minimum, tree.length(source));
maximum = Math.max(maximum, tree.length(source));
}
}
if (maximum == 0D) {
continue;
}
double range = Math.max(1D, maximum - minimum);
for (int source : sorted) {
if (tree.root(source) == root) {
int band = Math.min(LENGTH_BANDS - 1,
(int) ((maximum - tree.length(source)) * LENGTH_BANDS / range));
catchment.bands.get(band).add(source);
}
}
catchments.add(catchment);
}
}
OptionalInt next(Set<Long> usedOutlets) {
for (int checked = 0; checked < catchments.size(); checked++) {
Catchment catchment = catchments.get(cursor++ % catchments.size());
if (usedOutlets.contains(catchment.outlet)) {
continue;
}
for (int band = 0; band < LENGTH_BANDS; band++) {
List<Integer> candidates = catchment.bands.get(catchment.cursor++ % LENGTH_BANDS);
if (!candidates.isEmpty()) {
int source = candidates.remove(farthest(candidates, catchment.attempted));
catchment.attempted.add(grid.node(source).naturalPoint());
return OptionalInt.of(source);
}
}
}
return OptionalInt.empty();
}
private int farthest(List<Integer> candidates, List<HydrologyPoint> attempted) {
int selected = 0;
long separation = -1L;
for (int index = 0; index < candidates.size(); index++) {
HydrologyPoint point = grid.node(candidates.get(index)).naturalPoint();
long nearest = Long.MAX_VALUE;
for (HydrologyPoint previous : attempted) {
nearest = Math.min(nearest, point.distanceSquared2D(previous));
}
if (nearest > separation) {
selected = index;
separation = nearest;
}
}
return selected;
}
private static final class Catchment {
private final long outlet;
private final List<List<Integer>> bands;
private final List<HydrologyPoint> attempted;
private int cursor;
private Catchment(long outlet) {
this.outlet = outlet;
this.bands = new ArrayList<>(LENGTH_BANDS);
this.attempted = new ArrayList<>();
for (int band = 0; band < LENGTH_BANDS; band++) {
bands.add(new ArrayList<>());
}
}
}
}
@@ -0,0 +1,435 @@
package art.arcane.iris.engine.hydrology;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.PriorityQueue;
final class HydrologyRegionalTerrainRefiner {
private static final int GRID_SPACING = 16;
private static final int MAXIMUM_EXPANSIONS = 4096;
private static final int MAXIMUM_SAMPLES = 65536;
private static final int MAXIMUM_SKIPPED_WAYPOINTS = 2;
private static final int[][] NEIGHBOURS = {{1, 0}, {0, 1}, {-1, 0}, {0, -1}, {1, 1}, {-1, 1}, {-1, -1}, {1, -1}};
private final HydrologyPlanner planner;
private final Cache<Edge, Reach> edges;
private final HydrologyRegionalHydraulics hydraulics;
private final Cache<Long, Terrain> terrain;
HydrologyRegionalTerrainRefiner(HydrologyPlanner planner) {
this.planner = planner;
this.hydraulics = new HydrologyRegionalHydraulics(planner.settings);
this.edges = Caffeine.newBuilder().maximumSize(128).build();
this.terrain = Caffeine.newBuilder().maximumSize(MAXIMUM_SAMPLES).build();
}
void clear() {
edges.invalidateAll();
terrain.invalidateAll();
}
HydrologyTerrainSample sample(int x, int z) {
return terrain.get(RiverFootprint.pack(x, z), key -> new Terrain(planner.naturalSampler == null
? planner.sampler.sample(x, z) : planner.naturalSampler.sampleBasisWithoutSlope(x, z))).sample();
}
List<HydrologyPoint> refine(List<HydrologyPoint> guide, String profile, boolean coastal, HydrologyTerrainSampler receiver) {
if (guide.size() < 2) {
return List.of();
}
double maximumLength = planner.settings.routing().maximumRouteLength();
double remaining = 0D;
for (int index = 1; index < guide.size(); index++) {
remaining += distance(guide.get(index - 1), guide.get(index));
if (remaining > maximumLength) {
return List.of();
}
}
Terminal terminal = terminal(guide.getLast(), profile, coastal, receiver);
int[] requiredHead = requiredHeads(guide, terminal, MAXIMUM_SKIPPED_WAYPOINTS + 1);
if (requiredHead.length == 0) {
return List.of();
}
int availableHead = Integer.MAX_VALUE;
double length = 0D;
ArrayList<HydrologyPoint> refined = new ArrayList<>();
refined.add(guide.getFirst());
Map<Long, Integer> stations = new HashMap<>();
stations.put(RiverFootprint.pack(guide.getFirst().x(), guide.getFirst().z()), 0);
for (int index = 1; index < guide.size(); index++) {
int start = index - 1;
Reach reach = null;
int selected = index;
for (int candidate = index; candidate < guide.size() && candidate <= index + MAXIMUM_SKIPPED_WAYPOINTS; candidate++) {
Edge edge = new Edge(guide.get(start), guide.get(candidate), profile, coastal,
requiredHead[candidate], availableHead, terminal);
reach = edges.get(edge, this::route);
if (!reach.points().isEmpty()) {
selected = candidate;
break;
}
}
if (reach.points().isEmpty()) {
return List.of();
}
List<HydrologyPoint> points = reach.points();
availableHead = reach.availableHead();
for (int skipped = start; skipped < selected; skipped++) {
remaining -= distance(guide.get(skipped), guide.get(skipped + 1));
}
index = selected;
for (int point = 1; point < points.size(); point++) {
HydrologyPoint next = points.get(point);
long key = RiverFootprint.pack(next.x(), next.z());
Integer previous = stations.get(key);
if (previous != null) {
while (refined.size() > previous + 1) {
HydrologyPoint removed = refined.removeLast();
stations.remove(RiverFootprint.pack(removed.x(), removed.z()));
length -= distance(refined.getLast(), removed);
}
} else {
length += distance(refined.getLast(), next);
stations.put(key, refined.size());
refined.add(next);
}
if (length + Math.max(0D, remaining) > maximumLength) {
return List.of();
}
}
}
return simplify(refined, profile, coastal, terminal);
}
boolean receivingTerminal(HydrologyPoint point, String profile, boolean coastal, HydrologyTerrainSampler receiver) {
HydrologyTerrainSample land = sample(point.x(), point.z());
if (land == null || land.ocean() || land.naturalHeight() < planner.settings.seaLevel() || !land.outletAllowed()) {
return false;
}
for (int index = 0; index < 4; index++) {
int[] offset = NEIGHBOURS[index];
HydrologyTerrainSample ocean = sample(point.x() + offset[0], point.z() + offset[1]);
if (ocean != null && receiver.receivingWater(point.x() + offset[0], point.z() + offset[1], planner.settings.seaLevel())
&& ocean.preferredProfileKeys().contains(profile) && land.drainsInto(ocean)
&& (!coastal || ocean.drainsInto(land))) {
return true;
}
}
return false;
}
private Terminal terminal(HydrologyPoint point, String profile, boolean coastal, HydrologyTerrainSampler receiver) {
int inlet = receivingTerminal(point, profile, coastal, receiver) ? planner.settings.surface().banks().inlet().length() : 0;
return new Terminal(point, inlet + inlet / 2);
}
private int minimumHead(HydrologyTerrainSample terrain, int x, int z, Terminal terminal) {
boolean inlet = terminal.possibleStations() > 0
&& Math.max(Math.abs(x - terminal.landward().x()), Math.abs(z - terminal.landward().z())) < terminal.possibleStations();
return inlet ? hydraulics.inletMinimumHead(terrain) : hydraulics.minimumHead(terrain);
}
private int[] requiredHeads(List<HydrologyPoint> points, Terminal terminal, int maximumAdvance) {
int[] heads = new int[points.size()];
for (int index = points.size() - 1; index >= 0; index--) {
HydrologyPoint point = points.get(index);
HydrologyTerrainSample terrain = sample(point.x(), point.z());
if (terrain == null) {
return new int[0];
}
int required = planner.settings.seaLevel();
if (index + 1 < points.size()) {
required = Integer.MAX_VALUE;
for (int downstream = index + 1; downstream < points.size() && downstream <= index + maximumAdvance; downstream++) {
required = Math.min(required, heads[downstream]);
}
}
heads[index] = Math.max(required, minimumHead(terrain, point.x(), point.z(), terminal));
}
return heads;
}
private List<HydrologyPoint> simplify(List<HydrologyPoint> points, String profile, boolean coastal, Terminal terminal) {
double maximumReach = Math.min(512D, planner.settings.routing().regional().sampleSpacing() * 2D);
Map<Long, HydrologyTerrainSample> samples = new HashMap<>();
ArrayList<HydrologyPoint> simplified = new ArrayList<>();
int current = 0;
int[] requiredHead = requiredHeads(points, terminal, 1);
if (requiredHead.length == 0) {
return List.of();
}
int availableHead = Integer.MAX_VALUE;
simplified.add(points.getFirst());
while (current + 1 < points.size()) {
int farthest = current + 1;
double reach = distance(points.get(current), points.get(farthest));
while (farthest + 1 < points.size()) {
double next = distance(points.get(farthest), points.get(farthest + 1));
if (reach + next > maximumReach) {
break;
}
reach += next;
farthest++;
}
int selected = current + 1;
Traversal selectedTraversal = traverse(points.get(current), points.get(selected),
new Edge(points.get(current), points.get(selected), profile, coastal, requiredHead[selected], availableHead, terminal),
samples, availableHead);
for (int candidate = farthest; candidate > current + 1; candidate--) {
Edge shortcut = new Edge(points.get(current), points.get(candidate), profile, coastal,
requiredHead[candidate], availableHead, terminal);
Traversal traversal = traverse(shortcut.start(), shortcut.end(), shortcut, samples, availableHead);
if (traversal.cost() == 0D) {
selected = candidate;
selectedTraversal = traversal;
break;
}
}
if (!Double.isFinite(selectedTraversal.cost())) {
return List.copyOf(points);
}
availableHead = selectedTraversal.availableHead();
current = selected;
simplified.add(points.get(current));
}
return List.copyOf(simplified);
}
private Reach route(Edge edge) {
Map<Long, HydrologyTerrainSample> samples = new HashMap<>();
Traversal direct = traverse(edge.start(), edge.end(), edge, samples, edge.maximumHead());
if (direct.cost() == 0D) {
return new Reach(List.of(edge.start(), edge.end()), direct.availableHead());
}
int margin = planner.settings.routing().regional().sampleSpacing();
int minimumX = Math.min(edge.start().x(), edge.end().x()) - margin;
int maximumX = Math.max(edge.start().x(), edge.end().x()) + margin;
int minimumZ = Math.min(edge.start().z(), edge.end().z()) - margin;
int maximumZ = Math.max(edge.start().z(), edge.end().z()) + margin;
Map<Long, List<Node>> visited = new HashMap<>();
PriorityQueue<Pending> pending = new PriorityQueue<>(Comparator.comparingDouble(Pending::estimate)
.thenComparingLong(Pending::key)
.thenComparing(Comparator.comparingInt(Pending::availableHead).reversed()));
long firstKey = RiverFootprint.pack(edge.start().x(), edge.start().z());
long lastKey = RiverFootprint.pack(edge.end().x(), edge.end().z());
Search search = new Search(edge, samples, visited, pending);
Node first = new Node(edge.start(), 0D, edge.maximumHead(), null);
ArrayList<Node> firstLabels = new ArrayList<>(1);
firstLabels.add(first);
visited.put(firstKey, firstLabels);
pending.add(new Pending(firstKey, first, distance(edge.start(), edge.end())));
for (int expanded = 0; !pending.isEmpty() && expanded < MAXIMUM_EXPANSIONS; expanded++) {
Pending next = pending.remove();
Node current = next.node();
if (!retained(visited.get(next.key()), current)) {
continue;
}
if (next.key() == lastKey) {
return new Reach(path(current), current.availableHead());
}
if (distance(current.point(), edge.end()) <= GRID_SPACING * StrictMath.sqrt(2D)) {
offer(search, current, edge.end());
}
for (int[] offset : NEIGHBOURS) {
int x = current.point().x() + offset[0] * GRID_SPACING;
int z = current.point().z() + offset[1] * GRID_SPACING;
if (x < minimumX || x > maximumX || z < minimumZ || z > maximumZ) {
continue;
}
offer(search, current, new HydrologyPoint(x, current.point().y(), z));
}
}
return new Reach(List.of(), edge.maximumHead());
}
private void offer(Search search, Node current, HydrologyPoint target) {
HydrologyTerrainSample terrain = sample(target.x(), target.z(), search.samples());
if (!allowed(terrain, search.edge())) {
return;
}
HydrologyPoint point = new HydrologyPoint(target.x(), terrain.naturalHeight(), target.z());
double cost = current.cost() + distance(current.point(), point)
+ Math.max(0D, point.y() - current.point().y()) * planner.settings.routing().uphillPenalty()
+ Math.max(0D, terrain.routingCost() * terrain.routingMultiplier());
long key = RiverFootprint.pack(point.x(), point.z());
List<Node> labels = search.visited().get(key);
if (dominated(labels, cost, current.availableHead())) {
return;
}
Traversal traversal = traverse(current.point(), point, search.edge(), search.samples(), current.availableHead());
cost += traversal.cost();
if (!Double.isFinite(cost) || dominated(labels, cost, traversal.availableHead())) {
return;
}
cost += clearanceCost(point, search.edge(), search.samples());
if (dominated(labels, cost, traversal.availableHead())) {
return;
}
if (labels == null) {
labels = new ArrayList<>(2);
search.visited().put(key, labels);
}
for (int index = labels.size() - 1; index >= 0; index--) {
Node previous = labels.get(index);
if (cost <= previous.cost() && traversal.availableHead() >= previous.availableHead()) {
labels.remove(index);
}
}
Node retained = new Node(point, cost, traversal.availableHead(), current);
labels.add(retained);
search.pending().add(new Pending(key, retained, cost + distance(point, search.edge().end())));
}
private static boolean dominated(List<Node> labels, double cost, int availableHead) {
if (labels == null) {
return false;
}
for (Node previous : labels) {
if (previous.cost() <= cost && previous.availableHead() >= availableHead) {
return true;
}
}
return false;
}
private static boolean retained(List<Node> labels, Node current) {
for (Node retained : labels) {
if (retained == current) {
return true;
}
}
return false;
}
private Traversal traverse(HydrologyPoint start, HydrologyPoint end, Edge edge,
Map<Long, HydrologyTerrainSample> samples, int availableHead) {
int steps = Math.max(Math.abs(end.x() - start.x()), Math.abs(end.z() - start.z()));
HydrologyTerrainSample previous = null;
double distance = distance(start, end);
double tangentX = distance == 0D ? 1D : (end.x() - start.x()) / distance;
double tangentZ = distance == 0D ? 0D : (end.z() - start.z()) / distance;
HydrologyTerrainSampler bankSampler = (x, z) -> sample(x, z, samples);
double cost = 0D;
for (int step = 0; step <= steps; step++) {
double progress = steps == 0 ? 0D : step / (double) steps;
int x = (int) StrictMath.round(start.x() + (end.x() - start.x()) * progress);
int z = (int) StrictMath.round(start.z() + (end.z() - start.z()) * progress);
HydrologyTerrainSample terrain = sample(x, z, samples);
if (!allowed(terrain, edge) || previous != null && (!previous.drainsInto(terrain)
|| edge.coastal() && !terrain.drainsInto(previous))) {
return new Traversal(Double.POSITIVE_INFINITY, availableHead);
}
if (!edge.coastal()) {
availableHead = hydraulics.supportedHead(new HydrologyRegionalHydraulics.HeadStation(
x, z, tangentX, tangentZ, terrain, availableHead), bankSampler);
if (availableHead < Math.max(edge.minimumHead(), minimumHead(terrain, x, z, edge.terminal()))) {
return new Traversal(Double.POSITIVE_INFINITY, availableHead);
}
}
cost += ridgeCost(x, z, terrain, edge);
previous = terrain;
}
return new Traversal(cost, availableHead);
}
private double ridgeCost(int x, int z, HydrologyTerrainSample terrain, Edge edge) {
if (edge.coastal()) {
return 0D;
}
double deltaX = edge.end().x() - edge.start().x();
double deltaZ = edge.end().z() - edge.start().z();
double squared = deltaX * deltaX + deltaZ * deltaZ;
double progress = squared == 0D ? 0D : Math.max(0D, Math.min(1D,
((x - edge.start().x()) * deltaX + (z - edge.start().z()) * deltaZ) / squared));
double envelope = edge.start().y() + (edge.end().y() - edge.start().y()) * progress;
int incision = terrain.surfacePolicy().maximumIncision(planner.settings.surface().maximumIncision());
double permitted = Math.min(incision, StrictMath.floor(incision * terrain.incisionMultiplier()));
double excess = terrain.naturalHeight() - envelope + planner.settings.surface().minimumDepth()
+ planner.settings.surface().banks().sink() - permitted;
return Math.max(0D, excess) * Math.max(1D, planner.settings.routing().uphillPenalty());
}
private double clearanceCost(HydrologyPoint point, Edge edge, Map<Long, HydrologyTerrainSample> samples) {
double radius = planner.settings.surface().maximumWidth() / 2D
* (1D + planner.settings.surface().banks().roughness()) + 1D;
int blocked = 0;
for (int[] offset : NEIGHBOURS) {
double scale = offset[0] != 0 && offset[1] != 0 ? radius / StrictMath.sqrt(2D) : radius;
int x = point.x() + (int) StrictMath.round(offset[0] * scale);
int z = point.z() + (int) StrictMath.round(offset[1] * scale);
if (!allowed(sample(x, z, samples), edge)) {
blocked++;
}
}
return blocked * GRID_SPACING / 2D;
}
private boolean allowed(HydrologyTerrainSample terrain, Edge edge) {
return terrain != null && !terrain.ocean() && terrain.naturalHeight() >= planner.settings.seaLevel() && terrain.transitAllowed()
&& terrain.preferredProfileKeys().contains(edge.profile())
&& (!edge.coastal() || terrain.naturalHeight() - planner.settings.seaLevel()
+ planner.settings.surface().minimumDepth()
<= planner.settings.routing().regional().maximumCoastalIncision());
}
private HydrologyTerrainSample sample(int x, int z, Map<Long, HydrologyTerrainSample> samples) {
long key = RiverFootprint.pack(x, z);
HydrologyTerrainSample sampled = samples.get(key);
if (sampled != null || samples.containsKey(key) || samples.size() >= MAXIMUM_SAMPLES) {
return sampled;
}
sampled = sample(x, z);
samples.put(key, sampled);
return sampled;
}
private static List<HydrologyPoint> path(Node current) {
ArrayList<HydrologyPoint> points = new ArrayList<>();
while (current != null) {
points.add(current.point());
current = current.parent();
}
Collections.reverse(points);
return List.copyOf(points);
}
private static double distance(HydrologyPoint first, HydrologyPoint second) {
return StrictMath.sqrt(first.distanceSquared2D(second));
}
private record Edge(HydrologyPoint start, HydrologyPoint end, String profile, boolean coastal,
int minimumHead, int maximumHead, Terminal terminal) {
}
private record Terminal(HydrologyPoint landward, int possibleStations) {
}
private record Reach(List<HydrologyPoint> points, int availableHead) {
}
private record Traversal(double cost, int availableHead) {
}
private record Terrain(HydrologyTerrainSample sample) {
}
private record Search(Edge edge, Map<Long, HydrologyTerrainSample> samples, Map<Long, List<Node>> visited,
PriorityQueue<Pending> pending) {
}
private record Node(HydrologyPoint point, double cost, int availableHead, Node parent) {
}
private record Pending(long key, Node node, double estimate) {
private int availableHead() {
return node.availableHead();
}
}
}
@@ -0,0 +1,231 @@
package art.arcane.iris.engine.hydrology;
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 java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.ListIterator;
import java.util.Map;
import java.util.OptionalLong;
final class HydrologyRegionalTributaries {
private static final long JUNCTION_SALT = 0x5245474a554e4354L;
private final HydrologyPlanner planner;
HydrologyRegionalTributaries(HydrologyPlanner planner) {
this.planner = planner;
}
CompiledGraph connect(CompiledGraph graph, List<RiverCourse> courses,
HydrologyFootprintCompiler footprints, List<HydrologyDiagnosticCandidate> diagnostics) {
HydrologyRegionalNetwork regional = footprints.regionalNetwork;
if (regional.courses().isEmpty()) {
return graph;
}
LinkedHashMap<Long, DrainageNode> nodes = new LinkedHashMap<>();
LinkedHashMap<Long, DrainageEdge> edges = new LinkedHashMap<>();
LinkedHashMap<Long, RiverOutlet> outlets = new LinkedHashMap<>();
for (DrainageNode node : graph.nodes()) { nodes.put(node.id(), node); }
for (DrainageEdge edge : graph.edges()) { edges.put(edge.id(), edge); }
for (RiverOutlet outlet : graph.outlets()) { outlets.put(outlet.id(), outlet); }
Map<Long, DrainageNode> regionalNodes = new HashMap<>();
Map<Long, RiverOutlet> regionalOutlets = new HashMap<>();
for (DrainageNode node : regional.nodes()) { regionalNodes.put(node.id(), node); }
for (RiverOutlet outlet : regional.outlets()) { regionalOutlets.put(outlet.id(), outlet); }
ListIterator<RiverCourse> iterator = courses.listIterator();
while (iterator.hasNext()) {
RiverCourse local = iterator.next();
if (local.type() != RiverCourseType.SURFACE || !planner.regional.conflicts(local, regional.courses())) {
continue;
}
DrainageNode source = nodes.get(local.sourceNodeId().orElseThrow());
Junction accepted = null;
for (RiverCourse stem : regional.courses()) {
if (!planner.regional.conflicts(local, List.of(stem)) || source == null) {
continue;
}
RiverOutlet outlet = regionalOutlets.get(stem.outletId().orElseThrow());
accepted = build(local, source, new Receiver(stem, outlet, regionalNodes), footprints, diagnostics);
if (accepted != null) {
break;
}
}
if (accepted == null || conflictsOtherStem(accepted.course(), regional.courses())) {
iterator.remove();
planner.tributaries.addTributaryDiagnostic(local.id(), local.segments().getFirst().start(),
HydrologyCandidateRejection.NO_DRAINAGE_PATH, 0, diagnostics);
continue;
}
iterator.set(accepted.course());
for (DrainageNode node : accepted.nodes()) { nodes.put(node.id(), node); }
for (DrainageEdge edge : accepted.course().drainageEdges()) { edges.put(edge.id(), edge); }
outlets.put(accepted.outlet().id(), accepted.outlet());
}
return new CompiledGraph(List.copyOf(nodes.values()), List.copyOf(edges.values()),
List.copyOf(outlets.values()), graph.edgeByUpstream());
}
private Junction build(RiverCourse local, DrainageNode source, Receiver receiver,
HydrologyFootprintCompiler footprints, List<HydrologyDiagnosticCandidate> diagnostics) {
if (receiver.outlet() == null || local.drainageEdges().isEmpty()) {
return null;
}
List<HydrologyPoint> points = points(local);
ArrayList<DrainageEdge> pairs = new ArrayList<>(Math.max(0, points.size() - 1));
for (int index = 1; index < points.size(); index++) {
pairs.add(local.drainageEdges().getFirst());
}
HydrologyCoursePath path = new HydrologyCoursePath(points, pairs, local.drainageEdges(), receiver.outlet(),
false, false, null);
HydrologyGridNode sourceNode = new HydrologyGridNode(0, 0, 0, source.x(), source.z(), source.id(), source.terrain());
RiverCourse tributary = planner.tributaries.buildSurfaceTributary(
new SurfaceCourseDraft(sourceNode, local.id(), local.profileKey(), path), receiver.course(), diagnostics);
if (tributary == null || !confined(tributary)) {
return null;
}
SurfaceFootprint footprint = footprints.surfaceFootprint(tributary);
if (!footprint.accepted() || !joinsWater(tributary, footprint, receiver.course())) {
if (!footprint.accepted()) {
planner.tributaries.addTributaryDiagnostic(local.id(), local.segments().getFirst().start(),
footprint.rejection(), footprint.rejectionDetail(), diagnostics);
}
return null;
}
return reroot(tributary, source, receiver);
}
private boolean conflictsOtherStem(RiverCourse course, List<RiverCourse> stems) {
for (RiverCourse stem : stems) {
if (stem.outletId().getAsLong() != course.outletId().getAsLong()
&& planner.regional.conflicts(course, List.of(stem))) {
return true;
}
}
return false;
}
private boolean joinsWater(RiverCourse tributary, SurfaceFootprint footprint, RiverCourse stem) {
HydrologyPoint end = tributary.segments().getLast().end();
SurfaceBounds bounds = new SurfaceBounds(end.x(), end.z(), end.x(), end.z());
SurfaceFootprint receiving = new SurfaceFootprintCompiler(planner.settings,
planner::sampleBasisWithoutSlope, planner.geometrySampler).compile(stem, bounds);
HydrologyColumnLayer receiver = null;
for (SurfaceLayerColumn column : receiving.columns()) {
if (column.layer().fluidOwned()) {
receiver = column.layer();
break;
}
}
if (!receiving.accepted() || receiver == null) {
return false;
}
for (SurfaceLayerColumn column : footprint.columns()) {
HydrologyColumnLayer layer = column.layer();
if (column.x() == end.x() && column.z() == end.z() && layer.fluidOwned()) {
return layer.fluidHeadY() == receiver.fluidHeadY()
&& Math.max(layer.bedY(), receiver.bedY()) < layer.fluidHeadY();
}
}
return false;
}
private boolean confined(RiverCourse course) {
HydrologyTerrainSample previous = null;
List<HydrologyPoint> points = points(course);
for (int index = 1; index < points.size(); index++) {
HydrologyPoint start = points.get(index - 1);
HydrologyPoint end = points.get(index);
int steps = Math.max(Math.abs(end.x() - start.x()), Math.abs(end.z() - start.z()));
for (int step = index == 1 ? 0 : 1; step <= steps; step++) {
double progress = steps == 0 ? 0D : step / (double) steps;
int x = (int) StrictMath.round(start.x() + (end.x() - start.x()) * progress);
int z = (int) StrictMath.round(start.z() + (end.z() - start.z()) * progress);
HydrologyTerrainSample terrain = planner.sampleBasisWithoutSlope(x, z);
if (terrain == null || terrain.ocean() || !terrain.transitAllowed()
|| !terrain.preferredProfileKeys().contains(course.profileKey())
|| previous != null && !previous.drainsInto(terrain)) {
return false;
}
previous = terrain;
}
}
return true;
}
private Junction reroot(RiverCourse tributary, DrainageNode source, Receiver receiver) {
HydrologyPoint end = tributary.segments().getLast().end();
DrainageEdge nearest = null;
double nearestDistance = Double.POSITIVE_INFINITY;
for (DrainageEdge edge : receiver.course().drainageEdges()) {
HydrologyPoint start = edge.centerline().getFirst();
HydrologyPoint finish = edge.centerline().getLast();
double deltaX = finish.x() - start.x();
double deltaZ = finish.z() - start.z();
double squared = deltaX * deltaX + deltaZ * deltaZ;
double progress = squared == 0D ? 0D : Math.max(0D, Math.min(1D,
((end.x() - start.x()) * deltaX + (end.z() - start.z()) * deltaZ) / squared));
double distance = StrictMath.hypot(end.x() - start.x() - deltaX * progress,
end.z() - start.z() - deltaZ * progress);
if (distance < nearestDistance) {
nearestDistance = distance;
nearest = edge;
}
}
if (nearest == null) {
return null;
}
DrainageNode downstream = receiver.nodes().get(nearest.downstreamNodeId());
HydrologyTerrainSample junctionTerrain = planner.sampleBasisWithoutSlope(end.x(), end.z());
long outletId = receiver.outlet().id();
long sourceId = HydrologyHash.mix(tributary.id(), JUNCTION_SALT, source.id());
long junctionId = HydrologyHash.mix(tributary.id(), JUNCTION_SALT, end.x(), end.z());
double connectorLength = Math.max(1D, StrictMath.sqrt(end.distanceSquared2D(downstream.naturalPoint())));
double sourceLength = Math.max(1D, length(points(tributary)));
DrainageNode junction = new DrainageNode(junctionId, end.x(), end.z(), junctionTerrain,
downstream.potential() + connectorLength, outletId);
DrainageNode origin = new DrainageNode(sourceId, source.x(), source.z(), source.terrain(),
junction.potential() + sourceLength, outletId);
DrainageEdge branch = new DrainageEdge(HydrologyHash.mix(sourceId, junctionId), sourceId, junctionId,
outletId, sourceLength, tributary.discharge(), 0, points(tributary));
DrainageEdge connector = new DrainageEdge(HydrologyHash.mix(junctionId, downstream.id()), junctionId, downstream.id(),
outletId, connectorLength, tributary.discharge(), 0, List.of(end, downstream.naturalPoint()));
RiverCourse connected = new RiverCourse(tributary.id(), tributary.type(), OptionalLong.of(sourceId),
OptionalLong.of(outletId), tributary.profileKey(), tributary.discharge(), List.of(branch, connector), tributary.segments());
return new Junction(connected, List.of(origin, junction, downstream), receiver.outlet());
}
private static List<HydrologyPoint> points(RiverCourse course) {
ArrayList<HydrologyPoint> points = new ArrayList<>();
for (HydraulicSegment segment : course.segments()) {
if (!segment.type().isSurface()) {
break;
}
for (HydrologyPoint point : segment.centerline()) {
if (points.isEmpty() || point.distanceSquared2D(points.getLast()) > 0L) {
points.add(point);
}
}
}
return List.copyOf(points);
}
private static double length(List<HydrologyPoint> points) {
double length = 0D;
for (int index = 1; index < points.size(); index++) {
length += StrictMath.sqrt(points.get(index).distanceSquared2D(points.get(index - 1)));
}
return length;
}
private record Receiver(RiverCourse course, RiverOutlet outlet, Map<Long, DrainageNode> nodes) {
}
private record Junction(RiverCourse course, List<DrainageNode> nodes, RiverOutlet outlet) {
}
}
@@ -40,7 +40,7 @@ final class HydrologySegmentBuilder {
depth,
false,
false,
centerline
centerline, HydraulicChannelProfile.uniform(width, depth)
));
return true;
}
@@ -114,7 +114,7 @@ final class HydrologySegmentBuilder {
flowDepth,
true,
true,
List.of(lip, receiver)
List.of(lip, receiver), HydraulicChannelProfile.uniform(flowWidth, flowDepth)
));
addGradedTransitionSegment(
courseId,
@@ -227,7 +227,7 @@ final class HydrologySegmentBuilder {
dropDepth,
false,
!continuousSurfaceBore,
graded
graded, HydraulicChannelProfile.uniform(dropWidth, dropDepth)
));
if (!continuousSurfaceBore) {
addGradedTransitionSegment(
@@ -334,7 +334,7 @@ final class HydrologySegmentBuilder {
depth,
false,
false,
List.copyOf(centerline)
List.copyOf(centerline), HydraulicChannelProfile.uniform(width, depth)
));
}
@@ -461,7 +461,6 @@ final class HydrologySegmentBuilder {
width,
depth,
pathEnd,
landward,
segments
);
return;
@@ -540,20 +539,8 @@ final class HydrologySegmentBuilder {
int width,
int depth,
HydrologyPoint pathEnd,
HydrologyPoint landward,
List<HydraulicSegment> segments
) {
if (pathEnd.x() != landward.x() || pathEnd.z() != landward.z()) {
addFlatSegment(
courseId,
HydrologyFeatureType.SURFACE_POOL,
surfaceHead,
width,
depth,
line(pathEnd, landward, planner.settings.routing().refinementSpacing()),
segments
);
}
int seaLevel = outletHead(outlet);
HydrologyPoint connection = HydrologyPlanner.withY(outlet.connectionPoint(), seaLevel);
if (surfaceHead > seaLevel) {
@@ -564,7 +551,7 @@ final class HydrologySegmentBuilder {
seaLevel,
width,
depth,
List.of(landward, connection),
List.of(pathEnd, connection),
true,
segments
);
@@ -635,7 +622,7 @@ final class HydrologySegmentBuilder {
throatDepth,
false,
true,
gradedDescent
gradedDescent, HydraulicChannelProfile.uniform(descentWidth, throatDepth)
));
addFlatSegment(
courseId,
@@ -712,7 +699,7 @@ final class HydrologySegmentBuilder {
depth,
false,
false,
centerline
centerline, HydraulicChannelProfile.uniform(width, depth)
));
}
@@ -1,5 +1,6 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.surface.SurfaceCourseBuilder;
import art.arcane.iris.engine.hydrology.surface.SurfaceCourseResult;
import art.arcane.iris.engine.hydrology.surface.SurfaceTerminal;
@@ -151,7 +152,11 @@ final class HydrologySurfaceCoursePlanner {
}
int outletHead = planner.segments.outletHead(outlet);
int terminalHead = surfaceSinkhole ? Math.addExact(outletHead, 1) : outletHead;
SurfaceCourseResult result = planner.surfaceCourseBuilder.build(
SurfaceCourseBuilder builder = terminal == SurfaceTerminal.OCEAN_MOUTH
? new SurfaceCourseBuilder(planner.settings.surface(), HydrologyOceanReceiver.forOutlet(
planner.settings, planner::sampleBasisWithoutSlope, outlet), planner.geometrySampler, planner.settings.seaLevel())
: planner.surfaceCourseBuilder;
SurfaceCourseResult result = builder.build(
planner.worldSeed,
courseId,
profileKey,
@@ -0,0 +1,98 @@
package art.arcane.iris.engine.hydrology;
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
import it.unimi.dsi.fastutil.ints.IntArrayList;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
final class HydrologySurfaceDropBankBounds {
private HydrologySurfaceDropBankBounds() {
}
static List<HydrologyColumnSample> constrain(List<HydrologyColumnSample> columns, int maximumWidth) {
Int2ObjectOpenHashMap<IntArrayList> wetRows = wetRows(columns);
ArrayList<HydrologyColumnSample> constrained = new ArrayList<>(columns.size());
for (HydrologyColumnSample column : columns) {
if (!hasDryCut(column) || withinWidth(wetRows, column.x(), column.z(), maximumWidth)) {
constrained.add(column);
continue;
}
ArrayList<HydrologyColumnLayer> layers = new ArrayList<>(column.layers().size());
for (HydrologyColumnLayer layer : column.layers()) {
layers.add(!layer.channel() && layer.terrainOwned()
? naturalLayer(layer, column.naturalHeight()) : layer);
}
constrained.add(new HydrologyColumnSample(column.x(), column.z(), column.naturalHeight(),
column.seaLevel(), column.ocean(), column.parentBiomeKey(), layers));
}
return List.copyOf(constrained);
}
private static Int2ObjectOpenHashMap<IntArrayList> wetRows(List<HydrologyColumnSample> columns) {
Int2ObjectOpenHashMap<IntArrayList> rows = new Int2ObjectOpenHashMap<>();
for (HydrologyColumnSample column : columns) {
for (HydrologyColumnLayer layer : column.layers()) {
if (layer.channel() && layer.fluidOwned() && layer.bedY() < layer.fluidHeadY()) {
rows.computeIfAbsent(column.z(), ignored -> new IntArrayList()).add(column.x());
break;
}
}
}
for (IntArrayList row : rows.values()) {
Arrays.sort(row.elements(), 0, row.size());
}
return rows;
}
private static boolean hasDryCut(HydrologyColumnSample column) {
for (HydrologyColumnLayer layer : column.layers()) {
if (!layer.channel() && layer.terrainOwned() && layer.bedY() < column.naturalHeight()) {
return true;
}
}
return false;
}
private static boolean withinWidth(Int2ObjectOpenHashMap<IntArrayList> wetRows, int x, int z, int maximumWidth) {
long maximumDistance = (long) maximumWidth * maximumWidth;
for (int deltaZ = 0; deltaZ <= maximumWidth; deltaZ++) {
long remainingDistance = maximumDistance - (long) deltaZ * deltaZ;
if (nearWetX(wetRows.get(z - deltaZ), x, remainingDistance)
|| deltaZ > 0 && nearWetX(wetRows.get(z + deltaZ), x, remainingDistance)) {
return true;
}
}
return false;
}
private static boolean nearWetX(IntArrayList row, int x, long maximumDistance) {
if (row == null) {
return false;
}
int found = Arrays.binarySearch(row.elements(), 0, row.size(), x);
if (found >= 0) {
return true;
}
int next = -found - 1;
if (next < row.size()) {
long distance = (long) row.getInt(next) - x;
if (distance * distance <= maximumDistance) {
return true;
}
}
if (next > 0) {
long distance = (long) row.getInt(next - 1) - x;
return distance * distance <= maximumDistance;
}
return false;
}
private static HydrologyColumnLayer naturalLayer(HydrologyColumnLayer layer, int naturalHeight) {
return new HydrologyColumnLayer(layer.feature(), naturalHeight, naturalHeight, naturalHeight,
false, layer.shore(), false, false, false, false, false, false, false,
layer.profileKey(), layer.surfaceBiomeKey(), layer.mouthBiomeKey(), layer.shoreBiomeKey(),
layer.bankBiomeKey(), layer.floodedCaveBiomeKey());
}
}
@@ -0,0 +1,180 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.surface.SurfaceBounds;
import art.arcane.iris.engine.hydrology.surface.SurfaceCenterline;
import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
public final class HydrologySurfaceDropRaster {
private static final HydrologySurfaceDropRaster EMPTY = new HydrologySurfaceDropRaster(List.of(), Map.of());
private final List<HydrologyColumnSample> columns;
private final Map<Long, HydrologyColumnSample> indexed;
private final Map<Long, OceanReceiver> oceanReceivers;
private HydrologySurfaceDropRaster(List<HydrologyColumnSample> columns, Map<Long, OceanReceiver> oceanReceivers) {
this.columns = List.copyOf(columns);
Map<Long, HydrologyColumnSample> indexed = new HashMap<>(columns.size());
for (HydrologyColumnSample sample : columns) {
indexed.put(RiverFootprint.pack(sample.x(), sample.z()), sample);
}
this.indexed = Map.copyOf(indexed);
this.oceanReceivers = Map.copyOf(oceanReceivers);
}
public static HydrologySurfaceDropRaster empty() {
return EMPTY;
}
public static HydrologySurfaceDropRaster compile(HydrologyPlannerSettings settings,
HydrologyTerrainSampler sampler,
HydrologyGeometrySampler geometry,
RiverCourse course) {
return compile(settings, sampler, geometry, course, null);
}
public static HydrologySurfaceDropRaster compile(HydrologyPlannerSettings settings,
HydrologyTerrainSampler sampler,
HydrologyGeometrySampler geometry,
RiverCourse course,
SurfaceBounds bounds) {
Objects.requireNonNull(settings, "settings");
Objects.requireNonNull(sampler, "sampler");
Objects.requireNonNull(geometry, "geometry");
Objects.requireNonNull(course, "course");
HydrologyFootprintCompiler compiler = null;
Long2ObjectLinkedOpenHashMap<FootprintMutableColumn> columns = new Long2ObjectLinkedOpenHashMap<>();
HydrologyFootprintCompiler.SurfaceRasterIndex plannedSurface = null;
for (int index = 0; index < course.segments().size(); index++) {
HydraulicSegment segment = course.segments().get(index);
if (!segment.type().isSurface() || !segment.fallingFluid()
|| bounds != null && !intersects(settings, segment, bounds)) {
continue;
}
if (compiler == null) {
compiler = new HydrologyFootprintCompiler(settings, sampler, geometry);
plannedSurface = compiler.emptySurfaceRaster();
}
boolean clipStart = index > 0 && compiler.rasterizer.segmentsJoin(course.segments().get(index - 1), segment);
boolean clipEnd = index + 1 < course.segments().size()
&& compiler.rasterizer.segmentsJoin(segment, course.segments().get(index + 1));
compiler.rasterizer.rasterizeSegment(columns, course, segment, index == 0, clipStart, clipEnd,
true, plannedSurface);
}
if (compiler == null) {
return EMPTY;
}
if (bounds != null) {
columns.long2ObjectEntrySet().removeIf(entry -> !bounds.contains(
RiverFootprint.unpackX(entry.getLongKey()), RiverFootprint.unpackZ(entry.getLongKey())));
}
Map<Long, OceanReceiver> oceanReceivers = oceanReceivers(settings, sampler, course, bounds);
return columns.isEmpty() && oceanReceivers.isEmpty() ? EMPTY
: new HydrologySurfaceDropRaster(compiler.buildValidationColumns(columns), oceanReceivers);
}
public List<HydrologyColumnSample> columns() {
return columns;
}
public Optional<HydrologyColumnSample> sample(int x, int z) {
return Optional.ofNullable(indexed.get(RiverFootprint.pack(x, z)));
}
public boolean connects(int x, int z, int head) {
HydrologyColumnSample sample = indexed.get(RiverFootprint.pack(x, z));
if (sample == null) {
return false;
}
for (HydrologyColumnLayer layer : sample.layers()) {
if (layer.channel() && layer.fluidOwned() && !layer.oceanApron()
&& layer.bedY() < head && head <= layer.fluidHeadY()) {
return true;
}
}
return false;
}
public boolean containsRequiredFluid(RiverCourse course, SurfaceBounds bounds) {
for (HydraulicSegment segment : course.segments()) {
if (!segment.type().isSurface() || !segment.fallingFluid()) {
continue;
}
HydrologyPoint start = segment.start();
if ((bounds == null || bounds.contains(start.x(), start.z()))
&& !connects(start.x(), start.z(), segment.upstreamHeadY())) {
return false;
}
SurfaceCenterline centerline = SurfaceCenterline.densify(segment.centerline());
for (int station = 0; station < centerline.size(); station++) {
int x = centerline.x()[station];
int z = centerline.z()[station];
if ((bounds == null || bounds.contains(x, z)) && !connects(x, z, segment.downstreamHeadY())
&& !receivingOcean(segment, x, z)) {
return false;
}
}
}
return true;
}
private boolean receivingOcean(HydraulicSegment segment, int x, int z) {
OceanReceiver receiver = oceanReceivers.get(segment.id());
return receiver != null && receiver.x() == x && receiver.z() == z
&& receiver.bedY() < segment.downstreamHeadY() && segment.downstreamHeadY() <= receiver.headY();
}
private static Map<Long, OceanReceiver> oceanReceivers(HydrologyPlannerSettings settings,
HydrologyTerrainSampler sampler,
RiverCourse course, SurfaceBounds bounds) {
List<HydraulicSegment> segments = course.segments();
if (segments.size() < 2) {
return Map.of();
}
HydraulicSegment terminal = segments.getLast();
boolean mouth = terminal.type() == HydrologyFeatureType.MOUTH;
if (!mouth && terminal.type() != HydrologyFeatureType.COASTAL_GROTTO
|| mouth && terminal.upstreamHeadY() != settings.seaLevel()) {
return Map.of();
}
HydraulicSegment fall = segments.get(segments.size() - 2);
HydrologyPoint receiver = mouth ? terminal.start() : terminal.end();
if (!fall.type().isSurface() || !fall.fallingFluid()
|| fall.downstreamHeadY() != settings.seaLevel() || terminal.downstreamHeadY() != settings.seaLevel()
|| fall.end().distanceSquared2D(receiver) != 0L
|| bounds != null && !bounds.contains(receiver.x(), receiver.z())) {
return Map.of();
}
HydrologyTerrainSample terrain = sampler.sample(receiver.x(), receiver.z());
if (terrain == null || terrain.naturalHeight() >= settings.seaLevel()
|| (mouth ? !sampler.receivingWater(receiver.x(), receiver.z(), settings.seaLevel()) : !terrain.ocean())) {
return Map.of();
}
return Map.of(fall.id(), new OceanReceiver(receiver.x(), receiver.z(), terrain.naturalHeight(), settings.seaLevel()));
}
private static boolean intersects(HydrologyPlannerSettings settings, HydraulicSegment segment, SurfaceBounds bounds) {
double radius = Math.max(segment.width(), settings.geometry().drops().basinWidth(segment.width())) / 2D
+ settings.surface().shoreWidth() + settings.surface().banks().maximumBlendWidth() + 2D;
int minimumX = Integer.MAX_VALUE;
int maximumX = Integer.MIN_VALUE;
int minimumZ = Integer.MAX_VALUE;
int maximumZ = Integer.MIN_VALUE;
for (HydrologyPoint point : segment.centerline()) {
minimumX = Math.min(minimumX, point.x());
maximumX = Math.max(maximumX, point.x());
minimumZ = Math.min(minimumZ, point.z());
maximumZ = Math.max(maximumZ, point.z());
}
return minimumX - radius <= bounds.maximumX() && maximumX + radius >= bounds.minimumX()
&& minimumZ - radius <= bounds.maximumZ() && maximumZ + radius >= bounds.minimumZ();
}
private record OceanReceiver(int x, int z, int bedY, int headY) {
}
}
@@ -74,6 +74,20 @@ final class HydrologySurfaceProfiles {
continue;
}
SurfaceFootprint footprint = compiler.surfaceFootprint(course);
if (!footprint.accepted()) {
iterator.remove();
rejectedOutlets.add(course.outletId().orElseThrow());
diagnostics.add(new HydrologyDiagnosticCandidate(
HydrologyHash.mix(course.id(), HydrologySourcePlanner.DIAGNOSTIC_SALT,
footprint.rejection().ordinal()),
HydrologyCandidateKind.SOURCE,
HydrologyFeatureType.SURFACE_POOL,
course.segments().getFirst().start(),
footprint.rejection(),
footprint.rejectionDetail()
));
continue;
}
int excluded = 0;
for (SurfaceLayerColumn column : footprint.columns()) {
HydrologyColumnLayer layer = column.layer();
@@ -3,4 +3,9 @@ package art.arcane.iris.engine.hydrology;
@FunctionalInterface
public interface HydrologyTerrainSampler {
HydrologyTerrainSample sample(int blockX, int blockZ);
default boolean receivingWater(int blockX, int blockZ, int seaLevel) {
HydrologyTerrainSample terrain = sample(blockX, blockZ);
return terrain != null && terrain.ocean() && terrain.naturalHeight() < seaLevel;
}
}
@@ -278,8 +278,9 @@ public final class HydrologyTile {
if (!coursesById.containsKey(courseId)) {
throw new IllegalArgumentException("Footprint references an unaccepted river course: " + courseId);
}
if (layer.oceanApron() || !layer.channel() || !layer.terrainOwned()
|| !isCaveLayer(layer)) {
if (layer.oceanApron() || !layer.channel()
|| !(layer.terrainOwned() || layer.fallingFluid() && layer.fluidOwned())
|| !isCaveLayer(layer, coursesById.get(courseId))) {
continue;
}
Map<CavePosition, HydrologyCaveAction> courseActions = expectedActions.computeIfAbsent(
@@ -433,15 +434,24 @@ public final class HydrologyTile {
private boolean isCaveBearing(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;
}
}
return false;
}
private boolean isCaveLayer(HydrologyColumnLayer layer) {
return layer.feature().type().isUnderground() || layer.feature().type().isDeepFluid();
private 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;
}
private HydrologyCaveAction actionAt(HydrologyColumnLayer layer, int y) {
@@ -262,6 +262,35 @@ public final class HydrologyTileCache implements AutoCloseable {
return chunkColumns(blockX, blockZ).columnAt(blockX, blockZ);
}
public HydrologyColumnSnapshot columnSnapshot(int blockX, int blockZ) {
requireOpen();
if (!waitsForbidden()) {
return HydrologyColumnSnapshot.ready(columnAt(blockX, blockZ).orElse(null));
}
int chunkX = Math.floorDiv(blockX, CHUNK_SIZE);
int chunkZ = Math.floorDiv(blockZ, CHUNK_SIZE);
LocalChunkColumns local = localChunkColumns.get();
if (local != null && local.epoch() == cacheEpoch.get()
&& local.columns().chunkX() == chunkX && local.columns().chunkZ() == chunkZ) {
return HydrologyColumnSnapshot.ready(local.columns().columnAt(blockX, blockZ).orElse(null));
}
ChunkColumns composed = composedChunks.getIfPresent(CacheKey.mix(RiverFootprint.pack(chunkX, chunkZ)));
if (composed != null) {
return HydrologyColumnSnapshot.ready(composed.columnAt(blockX, blockZ).orElse(null));
}
ArrayList<HydrologyTileKey> keys = relevantKeys(chunkX, chunkZ);
ArrayList<HydrologyTile> snapshots = new ArrayList<>(keys.size());
for (HydrologyTileKey key : keys) {
HydrologyTile tile = tiles.getIfPresent(key);
if (tile == null) {
requestUnplanned(chunkX, chunkZ);
return HydrologyColumnSnapshot.unavailable();
}
snapshots.add(tile);
}
return HydrologyColumnSnapshot.ready(composeColumn(blockX, blockZ, keys, snapshots).orElse(null));
}
public void prepareChunkColumns(int blockX, int blockZ) {
chunkColumns(blockX, blockZ);
}
@@ -213,9 +213,9 @@ final class HydrologyTributaryPlanner {
ArrayList<HydrologyPoint> stemStations = new ArrayList<>();
ArrayList<Integer> stemWidths = new ArrayList<>();
for (HydraulicSegment segment : stem.segments()) {
for (HydrologyPoint point : segment.centerline()) {
stemStations.add(point);
stemWidths.add(segment.width());
for (int pointIndex = 0; pointIndex < segment.centerline().size(); pointIndex++) {
stemStations.add(segment.centerline().get(pointIndex));
stemWidths.add((int) StrictMath.ceil(segment.channelProfile().widthAt(pointIndex)));
}
}
int minimumStemIndex = 0;
@@ -340,9 +340,9 @@ final class HydrologyTributaryPlanner {
downstream,
segment.width(),
segment.depth(),
segment.fallingFluid(),
segment.receivingPool(),
List.copyOf(centerline)
upstream > downstream && segment.fallingFluid(),
upstream > downstream && segment.receivingPool(),
List.copyOf(centerline), segment.channelProfile()
));
if (segment.upstreamHeadY() >= level) {
break;
@@ -417,9 +417,9 @@ final class HydrologyTributaryPlanner {
downstream,
segment.width(),
segment.depth(),
segment.fallingFluid(),
segment.receivingPool(),
List.copyOf(centerline)
upstream > downstream && segment.fallingFluid(),
upstream > downstream && segment.receivingPool(),
List.copyOf(centerline), segment.channelProfile()
));
}
if (settled) {
@@ -531,8 +531,8 @@ final class HydrologyTributaryPlanner {
type,
lastSegment.downstreamHeadY(),
stemHead,
lastSegment.width(),
lastSegment.depth(),
(int) StrictMath.ceil(lastSegment.channelProfile().widthAt(lastSegment.centerline().size() - 1)),
(int) StrictMath.ceil(lastSegment.channelProfile().depthAt(lastSegment.centerline().size() - 1)),
List.of(end, new HydrologyPoint(joinStation.x(), stemHead, joinStation.z())),
true,
segments
@@ -87,15 +87,16 @@ final class SourceSelection {
return !selectedCandidateIndices.isEmpty();
}
boolean advanceAfterPublication(List<RiverCourse> acceptedCourses, HydrologySampledGrid grid) {
Set<Long> acceptedSourceNodeIds = acceptedSourceNodeIds(acceptedCourses);
boolean advanceAfterPublication(HydrologyCaveCourseFilter.Result result, HydrologySampledGrid grid) {
AcceptedSources acceptedSources = acceptedSources(result);
boolean changed = false;
int selectedPosition = 0;
while (selectedPosition < selectedCandidateIndices.size()) {
int candidateIndex = selectedCandidateIndices.get(selectedPosition);
SourceCandidate candidate = candidates.get(candidateIndex);
long sourceNodeId = grid.node(candidate.nodeIndex()).id();
if (acceptedSourceNodeIds.contains(sourceNodeId)) {
HydrologyGridNode source = grid.node(candidate.nodeIndex());
if (acceptedSources.nodeIds().contains(source.id())
|| surface && acceptedSources.coordinates().contains(RiverFootprint.pack(source.x(), source.z()))) {
if (candidate.required()) {
attemptedCandidates[candidateIndex] = true;
}
@@ -131,15 +132,23 @@ final class SourceSelection {
return changed;
}
private Set<Long> acceptedSourceNodeIds(List<RiverCourse> acceptedCourses) {
private AcceptedSources acceptedSources(HydrologyCaveCourseFilter.Result result) {
RiverCourseType expectedType = surface ? RiverCourseType.SURFACE : RiverCourseType.UNDERGROUND;
HashSet<Long> accepted = new HashSet<>();
for (RiverCourse course : acceptedCourses) {
for (RiverCourse course : result.courses()) {
if (course.type() == expectedType && course.sourceNodeId().isPresent()) {
accepted.add(course.sourceNodeId().getAsLong());
}
}
return accepted;
HashSet<Long> coordinates = new HashSet<>();
if (surface) {
for (DrainageNode node : result.nodes()) {
if (accepted.contains(node.id())) {
coordinates.add(RiverFootprint.pack(node.x(), node.z()));
}
}
}
return new AcceptedSources(accepted, coordinates);
}
private int nextRequiredCandidate() {
@@ -198,4 +207,7 @@ final class SourceSelection {
);
}
}
private record AcceptedSources(Set<Long> nodeIds, Set<Long> coordinates) {
}
}
@@ -41,7 +41,7 @@ import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;
final class StudioHydrologyTileStore {
private static final int SCHEMA_VERSION = 2;
private static final int SCHEMA_VERSION = 3;
private static final long MAXIMUM_COMPRESSED_BYTES = 128L * 1024L * 1024L;
private static final long MAXIMUM_DECOMPRESSED_BYTES = 512L * 1024L * 1024L;
private static final Gson GSON = new GsonBuilder()
@@ -22,6 +22,7 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai
private final BasisProvider basisProvider;
private final IrisHydrologyNaturalHeightProvider heightProvider;
private final IrisHydrologyNaturalOceanClassifier oceanClassifier;
private final int seaLevel;
private final int maximumEntries;
private final SamplingOptions samplingOptions;
private final Object lock;
@@ -35,6 +36,7 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai
this.basisProvider = Objects.requireNonNull(sources.basisProvider(), "basisProvider");
this.heightProvider = Objects.requireNonNull(sources.heightProvider(), "heightProvider");
this.oceanClassifier = Objects.requireNonNull(sources.oceanClassifier(), "oceanClassifier");
this.seaLevel = sources.seaLevel();
int maximumEntries = samplingOptions.maximumEntries();
this.maximumEntries = maximumEntries;
this.lock = new Object();
@@ -86,9 +88,16 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai
return cached;
}
}
NaturalClassification sampled = oceanClassifier.isOcean(blockX, blockZ)
? NaturalClassification.OCEAN
: NaturalClassification.LAND;
NaturalClassification sampled = NaturalClassification.LAND;
if (oceanClassifier.isOcean(blockX, blockZ)) {
double height = naturalHeight(packed, blockX, blockZ);
if (!Double.isFinite(height)) {
return NaturalClassification.UNAVAILABLE;
}
if (physicalOcean(true, height, seaLevel)) {
sampled = NaturalClassification.OCEAN;
}
}
synchronized (lock) {
TerrainBasis basis = bases.getAndMoveToLast(packed);
if (basis != null) {
@@ -176,6 +185,10 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai
return Math.sqrt(deltaX * deltaX + deltaZ * deltaZ);
}
static boolean physicalOcean(boolean oceanIntent, double naturalHeight, int seaLevel) {
return oceanIntent && Double.isFinite(naturalHeight) && StrictMath.round(naturalHeight) < seaLevel;
}
double localSlope(int blockX, int blockZ, double naturalHeight) {
double easternHeight = naturalHeight(blockX + 3, blockZ);
double southernHeight = naturalHeight(blockX, blockZ + 3);
@@ -317,7 +330,8 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai
record Sources(
BasisProvider basisProvider,
IrisHydrologyNaturalHeightProvider heightProvider,
IrisHydrologyNaturalOceanClassifier oceanClassifier
IrisHydrologyNaturalOceanClassifier oceanClassifier,
int seaLevel
) {
Sources {
Objects.requireNonNull(basisProvider, "basisProvider");
@@ -7,6 +7,7 @@ import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.loader.ResourceLoader;
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.HydrologyDiagnosticCandidate;
import art.arcane.iris.engine.hydrology.HydrologyDiagnosticRenderSample;
import art.arcane.iris.engine.hydrology.HydrologyFeatureQuery;
@@ -125,7 +126,8 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
IrisHydrologyRoutingTerrainSampler.Sources terrainSources = new IrisHydrologyRoutingTerrainSampler.Sources(
this::createTerrainBasis,
(int x, int z) -> context.naturalHeightProvider().sample(x, z),
(int x, int z) -> context.naturalOceanClassifier().isOcean(x, z)
(int x, int z) -> context.naturalOceanClassifier().isOcean(x, z),
settings.seaLevel()
);
this.routingTerrainSampler = new IrisHydrologyRoutingTerrainSampler(
terrainSources,
@@ -183,6 +185,10 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
return cache.columnAt(blockX, blockZ);
}
public HydrologyColumnSnapshot sampleSnapshot(int x, int z) {
return cache.columnSnapshot(x, z);
}
/** Plans a bounded window of tiles touching the block area ahead of time, nearest the centre first. */
public void prefetchArea(int minimumBlockX, int minimumBlockZ, int maximumBlockX, int maximumBlockZ, int centreBlockX, int centreBlockZ) {
cache.prefetchArea(minimumBlockX, minimumBlockZ, maximumBlockX, maximumBlockZ, centreBlockX, centreBlockZ);
@@ -560,9 +566,9 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
"Hydrology natural sample provider returned null at " + x + "," + z
);
double sampledNaturalHeight = naturalSample.naturalHeight();
double resolvedNaturalHeight = Double.isFinite(sampledNaturalHeight)
? sampledNaturalHeight
: rawNaturalHeight;
double resolvedNaturalHeight = Double.isFinite(rawNaturalHeight)
? rawNaturalHeight
: sampledNaturalHeight;
if (!Double.isFinite(resolvedNaturalHeight)) {
// A non-finite height is a transient fault in a shared sampling cache, not a fact about
// the terrain: sample the column once more before giving the tile up.
@@ -579,7 +585,8 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
));
}
int naturalHeight = (int) StrictMath.round(resolvedNaturalHeight);
boolean ocean = naturalSample.ocean();
boolean ocean = IrisHydrologyRoutingTerrainSampler.physicalOcean(
naturalSample.ocean(), resolvedNaturalHeight, settings.seaLevel());
IrisBiome biome = naturalSample.biome();
IrisRegion region = naturalSample.region();
ResolvedPolicy resolvedPolicy = resolvePolicy(region, biome);
@@ -877,7 +884,18 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
routing.getSlopePenalty(),
routing.getConfluenceAttraction(),
routing.getLengthPreference(),
routing.getTributaries()
routing.getTributaries(),
new HydrologyPlannerSettings.Regional(
routing.getRegional().isEnabled(),
routing.getRegional().getSampleSpacing(),
routing.getRegional().getMinimumLength(),
routing.getRegional().getMaximumTrunks(),
routing.getRegional().getMaximumCachedBasins(),
routing.getRegional().getMaximumCachedStations(),
routing.getRegional().isCoastalChannels(),
routing.getRegional().getCoastalChannelChance(),
routing.getRegional().getMaximumCoastalIncision()
)
);
HydrologyPlannerSettings.Source plannerSurfaceSources = new HydrologyPlannerSettings.Source(
surfaceEnabled,
@@ -927,7 +945,12 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
erosion.getCliffFraction(),
erosion.getBedProfile(),
banks.getShoreRise(),
banks.getBlendBaseWidth()
banks.getBlendBaseWidth(),
new HydrologyPlannerSettings.Excavation(
banks.getExcavation().getMaximumDepth(),
banks.getExcavation().getMaximumWidth(),
banks.getExcavation().getMaximumVolumePerBlock()
)
),
new HydrologyPlannerSettings.Ponds(
pond(surface.getPonds().getSource()),
@@ -1,6 +1,7 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.RiverFootprint;
import art.arcane.iris.engine.hydrology.HydrologyCandidateRejection;
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
import java.util.Objects;
@@ -8,10 +9,17 @@ import java.util.Objects;
public final class ErosionField {
private final Long2ObjectOpenHashMap<SurfaceColumn> columns;
private final int uncontainedWetCells;
private final HydrologyCandidateRejection rejection;
private final int rejectionDetail;
private final long bankExcavation;
ErosionField(Long2ObjectOpenHashMap<SurfaceColumn> columns, int uncontainedWetCells) {
ErosionField(Long2ObjectOpenHashMap<SurfaceColumn> columns, int uncontainedWetCells,
HydrologyCandidateRejection rejection, int rejectionDetail, long bankExcavation) {
this.columns = Objects.requireNonNull(columns, "columns");
this.uncontainedWetCells = uncontainedWetCells;
this.rejection = rejection;
this.rejectionDetail = rejectionDetail;
this.bankExcavation = bankExcavation;
}
public Long2ObjectOpenHashMap<SurfaceColumn> columns() {
@@ -29,4 +37,16 @@ public final class ErosionField {
public int size() {
return columns.size();
}
public HydrologyCandidateRejection rejection() {
return rejection;
}
public int rejectionDetail() {
return rejectionDetail;
}
public long bankExcavation() {
return bankExcavation;
}
}
@@ -1,7 +1,9 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydrologyHash;
import art.arcane.iris.engine.hydrology.HydrologyCandidateRejection;
import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.engine.hydrology.HydrologySurfaceDropRaster;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSample;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler;
import art.arcane.iris.engine.hydrology.RiverFootprint;
@@ -11,6 +13,8 @@ import it.unimi.dsi.fastutil.longs.Long2DoubleOpenHashMap;
import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap;
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.Objects;
@@ -62,6 +66,23 @@ public final class ErosionFieldCompiler {
int apronLimit,
HydrologyPlannerSettings.Ponds ponds
) {
return compile(courseSeed, centerline, channel, valley, terminal, apronLimit, ponds, SurfaceRasterContext.none());
}
public ErosionField compile(
long courseSeed,
SurfaceCenterline centerline,
ChannelProfile channel,
ValleyProfile valley,
SurfaceTerminal terminal,
int apronLimit,
HydrologyPlannerSettings.Ponds ponds,
SurfaceRasterContext context
) {
Objects.requireNonNull(context, "context");
SurfaceBounds bounds = context.bounds();
SurfaceBounds samplingBounds = bounds == null ? null : bounds.expand(2);
SurfaceRasterContext samplingContext = new SurfaceRasterContext(samplingBounds, context.drops(), context.boundary());
HydrologyPlannerSettings.Banks banks = surface.banks();
HydrologyPlannerSettings.Erosion erosion = banks.erosion();
HydrologyPlannerSettings.Channel channelSettings = banks.channel();
@@ -72,6 +93,7 @@ public final class ErosionFieldCompiler {
double shoreRise = erosion.shoreRise();
int sink = banks.sink();
boolean[] basin = basins(valley, channel, count);
double[] bendOffsets = SurfaceBendProfile.offsets(centerline, channel);
double[][] blendWidth = blendWidths(centerline, channel, valley, count);
Long2IntOpenHashMap nearest = new Long2IntOpenHashMap();
nearest.defaultReturnValue(-1);
@@ -85,8 +107,15 @@ public final class ErosionFieldCompiler {
int radius = (int) StrictMath.ceil(channel.halfWidth()[station] * (1D + roughness) + Math.max(stationShore + widest, stationBand)) + 1;
int stationX = centerline.x()[station];
int stationZ = centerline.z()[station];
for (int deltaZ = -radius; deltaZ <= radius; deltaZ++) {
for (int deltaX = -radius; deltaX <= radius; deltaX++) {
if (samplingBounds != null && !samplingBounds.intersects(stationX, stationZ, radius)) {
continue;
}
int minimumZ = samplingBounds == null ? -radius : Math.max(-radius, samplingBounds.minimumZ() - stationZ);
int maximumZ = samplingBounds == null ? radius : Math.min(radius, samplingBounds.maximumZ() - stationZ);
int minimumX = samplingBounds == null ? -radius : Math.max(-radius, samplingBounds.minimumX() - stationX);
int maximumX = samplingBounds == null ? radius : Math.min(radius, samplingBounds.maximumX() - stationX);
for (int deltaZ = minimumZ; deltaZ <= maximumZ; deltaZ++) {
for (int deltaX = minimumX; deltaX <= maximumX; deltaX++) {
int cellX = stationX + deltaX;
int cellZ = stationZ + deltaZ;
double cellDistance = centerline.distanceToSegment(station, cellX, cellZ);
@@ -114,8 +143,11 @@ public final class ErosionFieldCompiler {
double cellDistance = distance.get(key);
int cellX = RiverFootprint.unpackX(key);
int cellZ = RiverFootprint.unpackZ(key);
HydrologyTerrainSample terrain = sampler.sample(cellX, cellZ);
int head = valley.head()[station];
if (!context.boundary().allows(cellX, cellZ) || context.drops().connects(cellX, cellZ, head)) {
continue;
}
HydrologyTerrainSample terrain = sampler.sample(cellX, cellZ);
double halfWidth = channel.halfWidth()[station];
double outline = halfWidth * (1D + roughness * SurfaceNoise.signed(
courseSeed ^ OUTLINE_SALT, cellX, cellZ, banks.roughnessWavelength()));
@@ -123,8 +155,10 @@ public final class ErosionFieldCompiler {
Math.max(0.5D, channelSettings.outlineMinimumRatio() * halfWidth),
Math.min(channelSettings.outlineMaximumRatio() * halfWidth, outline));
boolean wet = cellDistance <= outline + 0.25D;
if (!SurfaceCellAdmission.writable(terrain, seaLevel)) {
if (terrain != null && wet && terminal == SurfaceTerminal.OCEAN_MOUTH
boolean mouthLand = terminal == SurfaceTerminal.OCEAN_MOUTH && head == seaLevel
&& SurfaceCellAdmission.mouthLand(terrain, seaLevel);
if (!SurfaceCellAdmission.writable(terrain, seaLevel) && !mouthLand) {
if (sampler.receivingWater(cellX, cellZ, seaLevel) && wet && terminal == SurfaceTerminal.OCEAN_MOUTH
&& station >= valley.exposedStations() - 1
&& station - oceanStart < apronLimit
&& cellDistance <= apronLimit + 0.25D) {
@@ -136,6 +170,8 @@ public final class ErosionFieldCompiler {
int natural = terrain.naturalHeight();
if (wet) {
double normalized = Math.min(1D, cellDistance / Math.max(0.5D, outline));
normalized = SurfaceBendProfile.normalizedDistance(normalized,
side(centerline, station, cellX, cellZ), bendOffsets[station]);
double depth = channel.depth()[station];
double local = 1D + (depth - 1D) * bedFactor(normalized, erosion)
+ erosion.bedNoise() * roughness * SurfaceNoise.signed(courseSeed ^ BED_SALT, cellX, cellZ, banks.roughnessWavelength())
@@ -155,8 +191,16 @@ public final class ErosionFieldCompiler {
// geometric shore with the bank biome.
boolean shoreBiome = cellDistance <= outline + terrain.shoreBiomeWidth(shoreWidth) + 0.25D;
SurfaceRole dryRole = shoreBiome ? SurfaceRole.SHORE : SurfaceRole.BANK;
double bankDistance = cellDistance - outline;
if (bankDistance > erosion.excavation().maximumWidth()) {
if (shoreBiome) {
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, natural, natural, false));
}
continue;
}
if (cellDistance <= outline + shore + 0.25D) {
int height = Math.min(natural, bankTop + (int) StrictMath.round(shoreRise * benchProgress(cellDistance, outline, shore)));
height = boundedBankHeight(natural, height, erosion.excavation());
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, dryRole, height, height, false));
continue;
}
@@ -170,6 +214,7 @@ public final class ErosionFieldCompiler {
}
double side = side(centerline, station, cellX, cellZ);
double width = blendWidth[station][side < 0D ? 0 : 1];
width = Math.min(width, Math.max(0.25D, erosion.excavation().maximumWidth() - shore));
double progress = (cellDistance - outline - shore) / width;
if (progress >= 1D) {
if (shoreBiome) {
@@ -178,17 +223,18 @@ public final class ErosionFieldCompiler {
continue;
}
int height = Math.min(natural, (int) StrictMath.round(shoreTop + cut * blend(progress, erosion)));
height = boundedBankHeight(natural, height, erosion.excavation());
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, dryRole, height, height, false));
}
if (count > 0) {
pond(columns, wetKeys, courseSeed ^ SOURCE_POND_SALT, ponds.source(), centerline, 0, valley.head()[0]);
pond(columns, wetKeys, courseSeed ^ SOURCE_POND_SALT, ponds.source(), centerline, 0, valley.head()[0], samplingContext);
if (terminal == SurfaceTerminal.SINKHOLE) {
pond(columns, wetKeys, courseSeed ^ TERMINAL_POND_SALT, ponds.terminal(), centerline, count - 1, valley.head()[count - 1]);
pond(columns, wetKeys, courseSeed ^ TERMINAL_POND_SALT, ponds.terminal(), centerline, count - 1, valley.head()[count - 1], samplingContext);
}
}
int uncontained = contain(columns, wetKeys, sink);
int uncontained = contain(columns, wetKeys, sink, bounds, context.drops());
connectSteps(columns, wetKeys);
return new ErosionField(columns, uncontained);
return validate(columns, wetKeys, centerline, terminal, bounds, uncontained);
}
/**
@@ -205,8 +251,10 @@ public final class ErosionFieldCompiler {
HydrologyPlannerSettings.Pond pond,
SurfaceCenterline centerline,
int station,
int head
int head,
SurfaceRasterContext context
) {
SurfaceBounds bounds = context.bounds();
if (!pond.enabled()) {
return;
}
@@ -219,6 +267,10 @@ public final class ErosionFieldCompiler {
int sink = banks.sink();
int centreX = centerline.x()[station];
int centreZ = centerline.z()[station];
int maximumReach = pond.maximumRadius() * 2 + banks.maximumBlendWidth() + (int) StrictMath.ceil(shoreWidth) + 2;
if (bounds != null && !bounds.intersects(centreX, centreZ, maximumReach)) {
return;
}
// One bowl, one beach: the pond takes the shore width and the erosion switch its centre asks for.
HydrologyTerrainSample centre = sampler.sample(centreX, centreZ);
double shore = centre == null ? shoreWidth : centre.shoreWidth(shoreWidth);
@@ -238,6 +290,11 @@ public final class ErosionFieldCompiler {
}
int cellX = centreX + deltaX;
int cellZ = centreZ + deltaZ;
if (bounds != null && !bounds.contains(cellX, cellZ)
|| !context.boundary().allows(cellX, cellZ)
|| context.drops().connects(cellX, cellZ, head)) {
continue;
}
HydrologyTerrainSample terrain = sampler.sample(cellX, cellZ);
if (!SurfaceCellAdmission.writable(terrain, seaLevel)) {
continue;
@@ -271,8 +328,12 @@ public final class ErosionFieldCompiler {
if (existing != null && existing.role() == SurfaceRole.CHANNEL) {
continue;
}
if (distance - outline > erosion.excavation().maximumWidth()) {
continue;
}
if (distance <= outline + shore + 0.25D) {
int height = Math.min(natural, bankTop + (int) StrictMath.round(shoreRise * benchProgress(distance, outline, shore)));
height = boundedBankHeight(natural, height, erosion.excavation());
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, height, height, false));
continue;
}
@@ -282,11 +343,13 @@ public final class ErosionFieldCompiler {
}
double width = Math.max(banks.minimumBlendWidth(),
Math.min(banks.maximumBlendWidth(), cut * banks.blendSlope() + erosion.blendBaseWidth()));
width = Math.min(width, Math.max(0.25D, erosion.excavation().maximumWidth() - shore));
double progress = (distance - outline - shore) / width;
if (progress >= 1D) {
continue;
}
int height = Math.min(natural, (int) StrictMath.round(shoreTop + cut * blend(progress, erosion)));
height = boundedBankHeight(natural, height, erosion.excavation());
if (existing != null && existing.height() <= height) {
continue;
}
@@ -424,12 +487,15 @@ public final class ErosionFieldCompiler {
* Every dry cell touching water, diagonals included, keeps the lip; only cardinal neighbours can
* spill, so only they count as uncontained.
*/
private int contain(Long2ObjectOpenHashMap<SurfaceColumn> columns, LongArrayList wetKeys, int sink) {
private int contain(Long2ObjectOpenHashMap<SurfaceColumn> columns, LongArrayList wetKeys, int sink, SurfaceBounds bounds, HydrologySurfaceDropRaster drops) {
int uncontained = 0;
for (int index = 0; index < wetKeys.size(); index++) {
SurfaceColumn wet = columns.get(wetKeys.getLong(index));
uncontained += lip(columns, wet, CARDINALS, sink, true);
lip(columns, wet, DIAGONALS, sink, false);
if (bounds != null && !bounds.contains(wet.x(), wet.z())) {
continue;
}
uncontained += lip(columns, wet, CARDINALS, sink, true, drops);
lip(columns, wet, DIAGONALS, sink, false, drops);
}
return uncontained;
}
@@ -437,26 +503,31 @@ public final class ErosionFieldCompiler {
/**
* The bank beside water must reach the natural ground the head was taken from: the water's own
* block level plus the sink. A neighbour lower than that is raised to it. The solver takes the head
* from the banks it samples at each station, so a cell it never sampled (between stations, on the
* outside of a bend) can lie a block lower than the water beside it: such a cell may be raised one
* block above its natural height, a lip no taller than the old fixed one, rather than let the
* water spill. A deeper shortfall stays uncontained and is counted.
* from the banks it samples at each station. A missed low cell can retain its natural ground,
* but cannot be raised above it. A shortfall below the water head stays uncontained and is counted.
*/
private int lip(
Long2ObjectOpenHashMap<SurfaceColumn> columns,
SurfaceColumn wet,
int[][] offsets,
int sink,
boolean countSpills
boolean countSpills,
HydrologySurfaceDropRaster drops
) {
int uncontained = 0;
for (int[] offset : offsets) {
long key = RiverFootprint.pack(wet.x() + offset[0], wet.z() + offset[1]);
int neighbourX = wet.x() + offset[0];
int neighbourZ = wet.z() + offset[1];
if (drops.connects(neighbourX, neighbourZ, wet.headY())) {
continue;
}
long key = RiverFootprint.pack(neighbourX, neighbourZ);
SurfaceColumn neighbour = columns.get(key);
if (neighbour == null) {
// Water that meets the sea at the mouth is not spilling; anything else beyond the field is.
if (countSpills && wet.headY() != seaLevel
&& SurfaceCellAdmission.writable(sampler.sample(wet.x() + offset[0], wet.z() + offset[1]), seaLevel)) {
HydrologyTerrainSample terrain = sampler.sample(wet.x() + offset[0], wet.z() + offset[1]);
if (countSpills && (terrain == null || terrain.naturalHeight() < wet.headY())
&& !(wet.headY() == seaLevel && sampler.receivingWater(neighbourX, neighbourZ, seaLevel))) {
uncontained++;
}
continue;
@@ -466,7 +537,7 @@ public final class ErosionFieldCompiler {
}
int required = wet.headY() + sink;
if (neighbour.height() < required) {
int ceiling = neighbour.terrain().naturalHeight() + (countSpills ? 1 : 0);
int ceiling = neighbour.terrain().naturalHeight();
int raised = Math.min(ceiling, required);
neighbour = neighbour.withHeight(raised);
columns.put(key, neighbour);
@@ -485,14 +556,97 @@ public final class ErosionFieldCompiler {
return count;
}
for (int station = Math.max(0, valley.exposedStations() - 1); station < count; station++) {
HydrologyTerrainSample center = sampler.sample(centerline.x()[station], centerline.z()[station]);
if (!SurfaceCellAdmission.writable(center, seaLevel)) {
if (sampler.receivingWater(centerline.x()[station], centerline.z()[station], seaLevel)) {
return station;
}
}
return count;
}
private static int boundedBankHeight(int natural, int target, HydrologyPlannerSettings.Excavation limits) {
return Math.max(natural - limits.maximumDepth(), Math.min(natural, target));
}
private ErosionField validate(Long2ObjectOpenHashMap<SurfaceColumn> columns, LongArrayList wetKeys,
SurfaceCenterline centerline, SurfaceTerminal terminal, SurfaceBounds bounds, int uncontained) {
long excavation = 0L;
int[] stationVolumes = new int[centerline.size()];
int deepest = 0;
for (SurfaceColumn column : columns.values()) {
if (column.apron() || column.role() == SurfaceRole.CHANNEL
|| bounds != null && !bounds.contains(column.x(), column.z())) {
continue;
}
int cut = Math.max(0, column.terrain().naturalHeight() - column.height());
excavation += cut;
deepest = Math.max(deepest, cut);
stationVolumes[column.station()] += cut;
}
int largestSection = SurfaceExcavationMetrics.maximumVolumePerBlock(centerline, stationVolumes);
HydrologyPlannerSettings.Excavation limits = surface.banks().erosion().excavation();
if (deepest > limits.maximumDepth() || largestSection > limits.maximumVolumePerBlock()) {
return new ErosionField(columns, uncontained, HydrologyCandidateRejection.SURFACE_BANK_BUDGET,
largestSection, excavation);
}
if (uncontained > 0) {
return new ErosionField(columns, uncontained, HydrologyCandidateRejection.SURFACE_WATER_CONTAINMENT,
uncontained, excavation);
}
if (bounds == null && terminal == SurfaceTerminal.OCEAN_MOUTH && !connectedToOcean(columns, wetKeys)) {
return new ErosionField(columns, 0, HydrologyCandidateRejection.SURFACE_MOUTH_DISCONNECTED, 0, excavation);
}
return new ErosionField(columns, uncontained, null, 0, excavation);
}
private boolean connectedToOcean(Long2ObjectOpenHashMap<SurfaceColumn> columns, LongArrayList wetKeys) {
if (wetKeys.isEmpty()) {
return false;
}
long firstKey = wetKeys.getLong(0);
for (int index = 1; index < wetKeys.size(); index++) {
long key = wetKeys.getLong(index);
SurfaceColumn candidate = columns.get(key);
SurfaceColumn first = columns.get(firstKey);
if (candidate.station() < first.station() || candidate.station() == first.station() && key < firstKey) {
firstKey = key;
}
}
LongOpenHashSet visited = new LongOpenHashSet(wetKeys.size());
LongArrayFIFOQueue queue = new LongArrayFIFOQueue();
visited.add(firstKey);
queue.enqueue(firstKey);
boolean ocean = false;
while (!queue.isEmpty()) {
SurfaceColumn current = columns.get(queue.dequeueLong());
for (int[] offset : CARDINALS) {
int x = current.x() + offset[0];
int z = current.z() + offset[1];
long key = RiverFootprint.pack(x, z);
SurfaceColumn next = columns.get(key);
if (current.headY() == seaLevel && current.height() < seaLevel
&& sampler.receivingWater(x, z, seaLevel)) {
ocean = true;
}
if (next == null || next.role() != SurfaceRole.CHANNEL || next.height() >= next.headY()
|| Math.max(current.height(), next.height()) >= Math.min(current.headY(), next.headY())) {
continue;
}
if (visited.add(key)) {
queue.enqueue(key);
}
}
}
if (!ocean) {
return false;
}
for (int index = 0; index < wetKeys.size(); index++) {
if (!visited.contains(wetKeys.getLong(index))) {
return false;
}
}
return true;
}
private boolean[] basins(ValleyProfile valley, ChannelProfile channel, int count) {
HydrologyPlannerSettings.Flow flow = surface.banks().flow();
boolean[] basin = new boolean[count];
@@ -0,0 +1,103 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSample;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler;
import java.util.Objects;
public final class SurfaceBankSupport {
static final int BANK_LOOKAHEAD = 2;
private final HydrologyPlannerSettings.Surface surface;
private final int seaLevel;
public SurfaceBankSupport(HydrologyPlannerSettings.Surface surface, int seaLevel) {
this.surface = Objects.requireNonNull(surface, "surface");
this.seaLevel = seaLevel;
}
public CrossSection crossSection(HydrologyTerrainSampler sampler, Station station, boolean oceanMouth) {
double roughness = surface.banks().roughness();
double outline = station.halfWidth() * (1D + roughness);
// Every cell from the narrowest possible outline outward can end up beside water, so it joins the minimum.
double innerOutline = station.halfWidth() * (1D - roughness);
double reach = outline + 2D;
double normalX = -station.tangentZ();
double normalZ = station.tangentX();
int minimum = Integer.MAX_VALUE;
int maximum = Integer.MIN_VALUE;
for (double offset = -reach; offset <= reach; offset += 0.5D) {
int cellX = (int) StrictMath.round(station.x() + normalX * offset);
int cellZ = (int) StrictMath.round(station.z() + normalZ * offset);
HydrologyTerrainSample terrain = sampler.sample(cellX, cellZ);
boolean writable = oceanMouth ? SurfaceCellAdmission.mouthLand(terrain, seaLevel)
: SurfaceCellAdmission.writable(terrain, seaLevel);
if (!writable) {
if (oceanMouth && sampler.receivingWater(cellX, cellZ, seaLevel)) {
minimum = Math.min(minimum, seaLevel);
maximum = Math.max(maximum, seaLevel);
continue;
}
return new CrossSection(0, 0, true);
}
if (Math.abs(offset) < innerOutline - 0.75D) {
continue;
}
minimum = Math.min(minimum, terrain.naturalHeight());
maximum = Math.max(maximum, terrain.naturalHeight());
}
return new CrossSection(minimum, maximum, false);
}
public Perimeter perimeter(HydrologyTerrainSampler sampler, Station station, int ceiling) {
double roughness = surface.banks().roughness();
HydrologyPlannerSettings.Channel settings = surface.banks().channel();
double halfWidth = station.halfWidth();
double inner = Math.max(0D, halfWidth * Math.max(settings.outlineMinimumRatio(),
Math.min(settings.outlineMaximumRatio(), 1D - roughness)) - 0.75D);
double outer = halfWidth * Math.max(settings.outlineMinimumRatio(),
Math.min(settings.outlineMaximumRatio(), 1D + roughness)) + 2D;
int radius = (int) StrictMath.ceil(outer);
double innerSquared = inner * inner;
double outerSquared = outer * outer;
int minimum = ceiling;
boolean complete = true;
int cliffFloor = ceiling - surface.banks().waterfallMinimumDrop();
for (int dz = -radius; dz <= radius; dz++) {
double rowOuter = outerSquared - (double) dz * dz;
if (rowOuter < 0D) {
continue;
}
int rowRadius = (int) StrictMath.floor(StrictMath.sqrt(rowOuter));
int innerRadius = innerSquared > (double) dz * dz
? (int) StrictMath.ceil(StrictMath.sqrt(innerSquared - (double) dz * dz)) : 0;
for (int dx = -rowRadius; dx <= rowRadius; dx++) {
if (innerRadius > 0 && dx > -innerRadius && dx < innerRadius) {
dx = innerRadius - 1;
continue;
}
double along = dx * station.tangentX() + dz * station.tangentZ();
if (StrictMath.abs(along) > BANK_LOOKAHEAD + 0.5D) {
continue;
}
HydrologyTerrainSample terrain = sampler.sample(station.x() + dx, station.z() + dz);
complete &= terrain != null;
if (SurfaceCellAdmission.writable(terrain, seaLevel)
&& terrain.naturalHeight() > cliffFloor) {
minimum = Math.min(minimum, terrain.naturalHeight());
}
}
}
return new Perimeter(minimum, complete);
}
public record Perimeter(int minimum, boolean complete) {
}
public record Station(int x, int z, double tangentX, double tangentZ, double halfWidth) {
}
public record CrossSection(int minimum, int maximum, boolean blocked) {
}
}
@@ -0,0 +1,77 @@
package art.arcane.iris.engine.hydrology.surface;
final class SurfaceBendProfile {
private static final double MAXIMUM_OFFSET = 0.35D;
private static final double CURVATURE_GAIN = 2.5D;
private static final int MINIMUM_WINDOW = 8;
private static final int MAXIMUM_WINDOW = 32;
private static final int SMOOTHING_RADIUS = 4;
private SurfaceBendProfile() {
}
static double[] offsets(SurfaceCenterline centerline, ChannelProfile channel) {
int count = centerline.size();
double[] offsets = new double[count];
double[] distance = new double[count];
double[] turns = new double[count];
for (int station = 1; station < count; station++) {
distance[station] = distance[station - 1] + StrictMath.hypot(
centerline.x()[station] - centerline.x()[station - 1],
centerline.z()[station] - centerline.z()[station - 1]);
double cross = centerline.tangentX()[station - 1] * centerline.tangentZ()[station]
- centerline.tangentZ()[station - 1] * centerline.tangentX()[station];
double dot = centerline.tangentX()[station - 1] * centerline.tangentX()[station]
+ centerline.tangentZ()[station - 1] * centerline.tangentZ()[station];
turns[station] = turns[station - 1] + StrictMath.atan2(cross, dot);
}
for (int station = 1; station + 1 < count; station++) {
double halfWidth = channel.halfWidth()[station];
int window = Math.max(MINIMUM_WINDOW, Math.min(MAXIMUM_WINDOW, (int) StrictMath.ceil(halfWidth * 3D)));
int from = Math.max(0, station - window);
int to = Math.min(count - 1, station + window);
double curvature = (turns[to] - turns[from]) / Math.max(1D, distance[to] - distance[from]);
double fade = SurfaceNoise.smoothStep(Math.min(1D, Math.min(station, count - 1D - station) / window));
offsets[station] = Math.max(-MAXIMUM_OFFSET, Math.min(MAXIMUM_OFFSET,
-curvature * halfWidth * CURVATURE_GAIN)) * fade;
}
return smooth(offsets);
}
static double normalizedDistance(double distance, double side, double offset) {
if (offset == 0D) {
return distance;
}
double signed = StrictMath.copySign(distance, side);
double shifted = signed >= offset ? (signed - offset) / (1D - offset)
: (offset - signed) / (1D + offset);
return Math.max(0D, Math.min(1D, shifted));
}
private static double[] smooth(double[] offsets) {
if (offsets.length < 2) {
return offsets;
}
double[] smoothed = new double[offsets.length];
double total = 0D;
for (int station = 0; station <= Math.min(SMOOTHING_RADIUS, offsets.length - 1); station++) {
total += offsets[station];
}
for (int station = 0; station < offsets.length; station++) {
if (station > 0) {
if (station + SMOOTHING_RADIUS < offsets.length) {
total += offsets[station + SMOOTHING_RADIUS];
}
if (station > SMOOTHING_RADIUS) {
total -= offsets[station - SMOOTHING_RADIUS - 1];
}
}
int from = Math.max(0, station - SMOOTHING_RADIUS);
int to = Math.min(offsets.length - 1, station + SMOOTHING_RADIUS);
smoothed[station] = Math.max(-MAXIMUM_OFFSET, Math.min(MAXIMUM_OFFSET, total / (to - from + 1)));
}
smoothed[0] = 0D;
smoothed[smoothed.length - 1] = 0D;
return smoothed;
}
}
@@ -0,0 +1,23 @@
package art.arcane.iris.engine.hydrology.surface;
public record SurfaceBounds(int minimumX, int minimumZ, int maximumX, int maximumZ) {
public SurfaceBounds {
if (minimumX > maximumX || minimumZ > maximumZ) {
throw new IllegalArgumentException("Surface bounds must be ordered.");
}
}
public boolean contains(int x, int z) {
return x >= minimumX && x <= maximumX && z >= minimumZ && z <= maximumZ;
}
public boolean intersects(int x, int z, int radius) {
return (long) x + radius >= minimumX && (long) x - radius <= maximumX
&& (long) z + radius >= minimumZ && (long) z - radius <= maximumZ;
}
public SurfaceBounds expand(int distance) {
return new SurfaceBounds(Math.subtractExact(minimumX, distance), Math.subtractExact(minimumZ, distance),
Math.addExact(maximumX, distance), Math.addExact(maximumZ, distance));
}
}
@@ -9,4 +9,8 @@ public final class SurfaceCellAdmission {
public static boolean writable(HydrologyTerrainSample terrain, int seaLevel) {
return terrain != null && !terrain.ocean() && terrain.naturalHeight() > seaLevel;
}
public static boolean mouthLand(HydrologyTerrainSample terrain, int seaLevel) {
return terrain != null && !terrain.ocean() && terrain.naturalHeight() >= seaLevel;
}
}
@@ -1,12 +1,16 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydraulicChannelProfile;
import art.arcane.iris.engine.hydrology.HydraulicSegment;
import art.arcane.iris.engine.hydrology.HydrologyCandidateRejection;
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
import art.arcane.iris.engine.hydrology.HydrologyGeometrySampler;
import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.engine.hydrology.HydrologyPoint;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSample;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@@ -77,8 +81,8 @@ public final class SurfaceCourseBuilder {
width[exposed] = width[last];
depth[exposed] = depth[last];
}
List<HydraulicSegment> segments = SurfaceSegmentLabeler.label(
worldSeed, courseId, x, z, head, width, depth, surface.banks());
List<HydraulicSegment> segments = fallingTransitions(SurfaceSegmentLabeler.label(
worldSeed, courseId, x, z, head, width, depth, surface.banks()));
if (segments.isEmpty()) {
return SurfaceCourseResult.rejected(HydrologyCandidateRejection.COURSE_TOO_SHORT, stations);
}
@@ -87,11 +91,58 @@ public final class SurfaceCourseBuilder {
return new SurfaceCourseResult(
segments,
head[lastStation],
last.width(),
last.depth(),
Math.max(1, (int) StrictMath.ceil(last.channelProfile().widthAt(last.centerline().size() - 1))),
Math.max(1, (int) StrictMath.ceil(last.channelProfile().depthAt(last.centerline().size() - 1))),
new HydrologyPoint(x[lastStation], head[lastStation], z[lastStation]),
null,
0
);
}
private List<HydraulicSegment> fallingTransitions(List<HydraulicSegment> labelled) {
ArrayList<HydraulicSegment> segments = new ArrayList<>(labelled);
for (int index = 0; index + 1 < segments.size(); index++) {
HydraulicSegment fall = segments.get(index);
HydraulicSegment receiver = segments.get(index + 1);
if (fall.type() != HydrologyFeatureType.WATERFALL || fall.centerline().size() != 2
|| receiver.type() != HydrologyFeatureType.SURFACE_POOL || receiver.centerline().size() < 2) {
continue;
}
HydrologyTerrainSample terrain = sampler.sample(fall.end().x(), fall.end().z());
if (!SurfaceCellAdmission.writable(terrain, seaLevel)) {
continue;
}
int incision = terrain.surfacePolicy().maximumIncision(surface.maximumIncision());
incision = Math.min(incision, (int) StrictMath.floor(incision * terrain.incisionMultiplier()));
if (terrain.naturalHeight() - fall.downstreamHeadY() + fall.depth() > incision) {
continue;
}
HydrologyPoint throat = new HydrologyPoint(fall.end().x(), fall.upstreamHeadY(), fall.end().z());
HydrologyPoint outflow = receiver.centerline().get(1);
HydrologyPlannerSettings.Channel channel = surface.banks().channel();
double outlineRatio = Math.max(channel.outlineMinimumRatio(),
Math.min(channel.outlineMaximumRatio(), 1D + surface.banks().roughness()));
double inletWidth = fall.channelProfile().maximumWidth();
if (index > 0) {
HydraulicChannelProfile approach = segments.get(index - 1).channelProfile();
inletWidth = Math.max(inletWidth, approach.widthAt(approach.size() - 1));
}
HydraulicChannelProfile fallProfile = new HydraulicChannelProfile(
new double[]{StrictMath.ceil(inletWidth * outlineRatio + 2D), receiver.channelProfile().widthAt(1)},
new double[]{fall.channelProfile().maximumDepth(), receiver.channelProfile().depthAt(1)});
segments.set(index, new HydraulicSegment(fall.id(), fall.courseId(), fall.type(),
fall.upstreamHeadY(), fall.downstreamHeadY(),
Math.max(1, (int) StrictMath.ceil(fallProfile.maximumWidth())),
Math.max(1, (int) StrictMath.ceil(fallProfile.maximumDepth())), true, true,
List.of(throat, outflow), fallProfile));
HydraulicChannelProfile receiverProfile = receiver.channelProfile().size() == 1 ? receiver.channelProfile()
: HydraulicChannelProfile.range(receiver.channelProfile().widths(), receiver.channelProfile().depths(),
1, receiver.channelProfile().size());
segments.set(index + 1, new HydraulicSegment(receiver.id(), receiver.courseId(), receiver.type(),
receiver.upstreamHeadY(), receiver.downstreamHeadY(), receiver.width(), receiver.depth(),
receiver.fallingFluid(), receiver.receivingPool(),
receiver.centerline().subList(1, receiver.centerline().size()), receiverProfile));
}
return List.copyOf(segments);
}
}
@@ -0,0 +1,165 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydraulicSegment;
import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
import art.arcane.iris.engine.hydrology.HydrologyColumnSample;
import art.arcane.iris.engine.hydrology.HydrologyPoint;
import art.arcane.iris.engine.hydrology.HydrologySurfaceDropRaster;
import art.arcane.iris.engine.hydrology.RiverCourse;
import art.arcane.iris.engine.hydrology.RiverFootprint;
import it.unimi.dsi.fastutil.longs.Long2LongOpenHashMap;
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
import it.unimi.dsi.fastutil.longs.LongIterator;
import java.util.Objects;
public final class SurfaceExcavationMetrics {
private static final int REACH_LENGTH = 16;
private static final long WET_COLUMN = Long.MIN_VALUE;
private SurfaceExcavationMetrics() {
}
public static long accumulate(RiverCourse course, SurfaceCenterline centerline, SurfaceFootprint footprint,
HydrologySurfaceDropRaster drops, SurfaceBounds bounds, int[] stationVolumes) {
Objects.requireNonNull(course, "course");
Objects.requireNonNull(centerline, "centerline");
Objects.requireNonNull(footprint, "footprint");
Objects.requireNonNull(drops, "drops");
Objects.requireNonNull(stationVolumes, "stationVolumes");
if (stationVolumes.length != centerline.size()) {
throw new IllegalArgumentException("Excavation volumes must match centerline stations.");
}
Long2LongOpenHashMap cuts = new Long2LongOpenHashMap(footprint.columns().size() + drops.columns().size());
for (SurfaceLayerColumn column : footprint.columns()) {
if (bounds == null || bounds.contains(column.x(), column.z())) {
accumulate(cuts, column.x(), column.z(), column.terrain().naturalHeight(), column.layer(), column.station());
}
}
Long2ObjectOpenHashMap<StationRange> ranges = dropStations(course);
for (HydrologyColumnSample column : drops.columns()) {
if (bounds != null && !bounds.contains(column.x(), column.z())) {
continue;
}
for (HydrologyColumnLayer layer : column.layers()) {
if (layer.oceanApron() || !layer.channel() && !layer.terrainOwned()) {
continue;
}
StationRange range = ranges.get(layer.feature().segmentId());
if (range != null) {
int station = layer.channel() ? range.first() : nearestStation(centerline, range, column.x(), column.z());
accumulate(cuts, column.x(), column.z(), column.naturalHeight(), layer, station);
}
}
}
long total = 0L;
LongIterator iterator = cuts.values().iterator();
while (iterator.hasNext()) {
long encoded = iterator.nextLong();
if (encoded == WET_COLUMN) {
continue;
}
int station = (int) (encoded >>> 32);
int depth = (int) encoded;
stationVolumes[station] = (int) Math.min(Integer.MAX_VALUE, (long) stationVolumes[station] + depth);
total += depth;
}
return total;
}
public static int maximumVolumePerBlock(SurfaceCenterline centerline, int[] stationVolumes) {
Objects.requireNonNull(centerline, "centerline");
Objects.requireNonNull(stationVolumes, "stationVolumes");
if (stationVolumes.length != centerline.size()) {
throw new IllegalArgumentException("Excavation volumes must match centerline stations.");
}
double[] distance = new double[stationVolumes.length];
long[] volume = new long[stationVolumes.length + 1];
for (int station = 0; station < stationVolumes.length; station++) {
if (stationVolumes[station] < 0) {
throw new IllegalArgumentException("Excavation volumes cannot be negative.");
}
double run = station == 0 ? 0D : StrictMath.hypot(
(double) centerline.x()[station] - centerline.x()[station - 1],
(double) centerline.z()[station] - centerline.z()[station - 1]);
distance[station] = station == 0 ? 0D : distance[station - 1] + run;
volume[station + 1] = volume[station] + stationVolumes[station];
}
int largestSection = 0;
int first = 0;
int last = 0;
for (int station = 0; station < stationVolumes.length; station++) {
while (first < station && distance[station] - distance[first] > REACH_LENGTH / 2D) {
first++;
}
while (last + 1 < stationVolumes.length && distance[last + 1] - distance[station] <= REACH_LENGTH / 2D) {
last++;
}
double section = StrictMath.ceil((volume[last + 1] - volume[first]) / (double) REACH_LENGTH);
largestSection = Math.max(largestSection, (int) Math.min(Integer.MAX_VALUE, section));
}
return largestSection;
}
private static void accumulate(Long2LongOpenHashMap cuts, int x, int z, int naturalHeight,
HydrologyColumnLayer layer, int station) {
if (layer.oceanApron()) {
return;
}
long key = RiverFootprint.pack(x, z);
if (layer.channel() && layer.fluidOwned() && layer.fluidHeadY() > layer.bedY()) {
cuts.put(key, WET_COLUMN);
return;
}
if (layer.channel() || !layer.terrainOwned()) {
return;
}
int depth = Math.max(0, naturalHeight - layer.bedY());
long previous = cuts.get(key);
if (depth > 0 && previous != WET_COLUMN
&& (depth > (int) previous || depth == (int) previous && station < (int) (previous >>> 32))) {
cuts.put(key, (long) station << 32 | depth & 0xffffffffL);
}
}
private static Long2ObjectOpenHashMap<StationRange> dropStations(RiverCourse course) {
Long2ObjectOpenHashMap<StationRange> ranges = new Long2ObjectOpenHashMap<>();
int station = 0;
HydrologyPoint previous = null;
for (HydraulicSegment segment : course.segments()) {
int first = station;
for (int index = 0; index < segment.centerline().size(); index++) {
HydrologyPoint point = segment.centerline().get(index);
if (previous != null) {
station += Math.max(Math.abs(point.x() - previous.x()), Math.abs(point.z() - previous.z()));
}
if (index == 0) {
first = station;
}
previous = point;
}
if (segment.type().isSurface() && segment.fallingFluid()) {
ranges.put(segment.id(), new StationRange(first, station));
}
}
return ranges;
}
private static int nearestStation(SurfaceCenterline centerline, StationRange range, int x, int z) {
int nearest = range.first();
long minimumDistance = Long.MAX_VALUE;
for (int station = range.first(); station <= range.last(); station++) {
long deltaX = (long) x - centerline.x()[station];
long deltaZ = (long) z - centerline.z()[station];
long distance = deltaX * deltaX + deltaZ * deltaZ;
if (distance < minimumDistance) {
minimumDistance = distance;
nearest = station;
}
}
return nearest;
}
private record StationRange(int first, int last) {
}
}
@@ -25,9 +25,6 @@ public final class SurfaceFeatureRefs {
HydraulicSegment segment,
SurfaceRole role,
boolean source,
int x,
int y,
int z,
int flowX,
int flowZ
) {
@@ -48,9 +45,9 @@ public final class SurfaceFeatureRefs {
segment.type(),
courseId,
segment.id(),
x,
y,
z,
segment.start().x(),
segment.start().y(),
segment.start().z(),
featureFlowX,
featureFlowZ,
source
@@ -1,18 +1,25 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydrologyCandidateRejection;
import java.util.List;
import java.util.Objects;
public record SurfaceFootprint(List<SurfaceLayerColumn> columns, int uncontainedWetCells) {
public record SurfaceFootprint(List<SurfaceLayerColumn> columns, int uncontainedWetCells,
HydrologyCandidateRejection rejection, int rejectionDetail, long bankExcavation) {
public SurfaceFootprint {
columns = List.copyOf(Objects.requireNonNull(columns, "columns"));
}
public static SurfaceFootprint empty() {
return new SurfaceFootprint(List.of(), 0);
return new SurfaceFootprint(List.of(), 0, null, 0, 0L);
}
public boolean isEmpty() {
return columns.isEmpty();
}
public boolean accepted() {
return rejection == null;
}
}
@@ -1,19 +1,24 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydraulicChannelProfile;
import art.arcane.iris.engine.hydrology.HydraulicSegment;
import art.arcane.iris.engine.hydrology.HydrologyCandidateRejection;
import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
import art.arcane.iris.engine.hydrology.HydrologyFeatureRef;
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
import art.arcane.iris.engine.hydrology.HydrologyGeometrySampler;
import art.arcane.iris.engine.hydrology.HydrologyHash;
import art.arcane.iris.engine.hydrology.HydrologyOceanReceiver;
import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.engine.hydrology.HydrologyPoint;
import art.arcane.iris.engine.hydrology.HydrologySurfaceDropRaster;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSample;
import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler;
import art.arcane.iris.engine.hydrology.RiverCourse;
import art.arcane.iris.engine.hydrology.RiverCourseType;
import art.arcane.iris.engine.hydrology.RiverFootprint;
import it.unimi.dsi.fastutil.ints.IntArrayList;
import it.unimi.dsi.fastutil.doubles.DoubleArrayList;
import java.util.ArrayList;
import java.util.Comparator;
@@ -42,66 +47,169 @@ public final class SurfaceFootprintCompiler {
}
public SurfaceFootprint compile(RiverCourse course) {
return compile(course, null);
}
public SurfaceFootprint compile(RiverCourse course, SurfaceBounds bounds) {
boolean pool = course.type() == RiverCourseType.SURFACE_POOL;
if (course.type() != RiverCourseType.SURFACE && !pool) {
return SurfaceFootprint.empty();
}
ArrayList<HydraulicSegment> exposed = new ArrayList<>();
for (HydraulicSegment segment : course.segments()) {
if (!exposedSegment(segment)) {
break;
HydrologyTerrainSampler receivingSampler = HydrologyOceanReceiver.forCourse(settings, sampler, course);
HydrologySurfaceDropRaster drops = HydrologySurfaceDropRaster.compile(settings, receivingSampler, geometry, course,
bounds == null ? null : bounds.expand(2));
int[] offsets = stationOffsets(course.segments());
ArrayList<SurfaceLayerColumn> columns = new ArrayList<>();
int uncontained = 0;
long excavation = 0L;
HydrologyCandidateRejection rejection = null;
int detail = 0;
int first = 0;
while (first < course.segments().size()) {
if (!exposedSegment(course.segments().get(first))) {
first++;
continue;
}
exposed.add(segment);
int after = first + 1;
while (after < course.segments().size() && exposedSegment(course.segments().get(after))) {
after++;
}
SurfaceFootprint run = compileRun(course, first, after, offsets[first], bounds, drops, pool, receivingSampler);
columns.addAll(run.columns());
uncontained += run.uncontainedWetCells();
excavation += run.bankExcavation();
if (rejection == null && run.rejection() != null) {
rejection = run.rejection();
detail = run.rejectionDetail();
}
first = after;
}
if (exposed.isEmpty()) {
return SurfaceFootprint.empty();
if (rejection == null && !drops.containsRequiredFluid(course, bounds)) {
rejection = HydrologyCandidateRejection.SURFACE_DROP_UNSUPPORTED;
}
SurfaceFootprint footprint = new SurfaceFootprint(columns, uncontained, rejection, detail, excavation);
if (drops.columns().isEmpty()) {
return footprint;
}
ArrayList<HydrologyPoint> points = new ArrayList<>();
for (HydraulicSegment segment : course.segments()) {
points.addAll(segment.centerline());
}
SurfaceCenterline centerline = SurfaceCenterline.densify(points);
int[] stationVolumes = new int[centerline.size()];
excavation = SurfaceExcavationMetrics.accumulate(course, centerline, footprint, drops, bounds, stationVolumes);
int volume = SurfaceExcavationMetrics.maximumVolumePerBlock(centerline, stationVolumes);
if (rejection == null && volume > settings.surface().banks().erosion().excavation().maximumVolumePerBlock()) {
rejection = HydrologyCandidateRejection.SURFACE_BANK_BUDGET;
detail = volume;
}
return new SurfaceFootprint(columns, uncontained, rejection, detail, excavation);
}
private SurfaceFootprint compileRun(RiverCourse course, int first, int after, int stationOffset,
SurfaceBounds bounds, HydrologySurfaceDropRaster drops, boolean pool,
HydrologyTerrainSampler receivingSampler) {
List<HydraulicSegment> exposed = course.segments().subList(first, after);
Stations stations = stations(exposed);
if (stations.count() < 1) {
return SurfaceFootprint.empty();
}
SurfaceTerminal terminal = pool ? SurfaceTerminal.SINKHOLE : terminal(course, exposed);
boolean fallingEnd = after < course.segments().size() && course.segments().get(after).fallingFluid();
SurfaceTerminal terminal = pool ? SurfaceTerminal.SINKHOLE
: fallingEnd ? SurfaceTerminal.TRIBUTARY : terminal(course, exposed);
boolean coastalChannel = first == 0 && after == course.segments().size()
&& exposed.getFirst().type() == HydrologyFeatureType.MOUTH
&& exposed.getLast().type() == HydrologyFeatureType.MOUTH;
SurfaceCenterline centerline = SurfaceCenterline.densify(stations.points());
ChannelProfile channel = pool
? poolProfile(exposed.getFirst(), centerline)
: new ChannelProfileBuilder(settings.surface(), sampler, geometry)
.build(centerline, course.profileKey(), terminal == SurfaceTerminal.OCEAN_MOUTH);
ChannelProfile channel = pool ? poolProfile(exposed.getFirst(), centerline) : acceptedProfile(stations, centerline);
String poolBiome = pool ? poolBiome(course.profileKey()) : null;
ValleyProfile valley = ValleyProfile.fromHeads(stations.head(), stations.exposedStations());
ErosionField field = new ErosionFieldCompiler(settings.surface(), sampler, settings.seaLevel()).compile(
HydrologyHash.mix(course.id(), COURSE_SEED_SALT),
centerline,
channel,
valley,
terminal,
settings.outlets().maximumOceanApron(),
pool ? HydrologyPlannerSettings.Ponds.none() : settings.surface().banks().ponds()
);
HydrologyPlannerSettings.Ponds disabled = HydrologyPlannerSettings.Ponds.none();
HydrologyPlannerSettings.Ponds ponds = pool || coastalChannel ? disabled
: new HydrologyPlannerSettings.Ponds(first == 0 ? settings.surface().banks().ponds().source() : disabled.source(),
fallingEnd ? disabled.terminal() : settings.surface().banks().ponds().terminal());
SurfaceRunBoundary boundary = runBoundary(course, first, after, centerline, channel);
ErosionField field = new ErosionFieldCompiler(settings.surface(), receivingSampler, settings.seaLevel()).compile(
HydrologyHash.mix(course.id(), COURSE_SEED_SALT), centerline, channel, valley, terminal,
settings.outlets().maximumOceanApron(), ponds, new SurfaceRasterContext(bounds, drops, boundary));
ArrayList<SurfaceColumn> ordered = new ArrayList<>(field.columns().values());
ordered.sort(Comparator
.comparingInt(SurfaceColumn::station)
ordered.sort(Comparator.comparingInt(SurfaceColumn::station)
.thenComparingLong((SurfaceColumn column) -> RiverFootprint.pack(column.x(), column.z())));
SurfaceFeatureRefs features = new SurfaceFeatureRefs(course.id());
ArrayList<SurfaceLayerColumn> columns = new ArrayList<>(ordered.size());
for (SurfaceColumn column : ordered) {
if (bounds != null && !bounds.contains(column.x(), column.z())) {
continue;
}
HydraulicSegment segment = exposed.get(stations.segmentIndex()[column.station()]);
int flowX = (int) StrictMath.round(centerline.tangentX()[column.station()]);
int flowZ = (int) StrictMath.round(centerline.tangentZ()[column.station()]);
boolean source = column.station() == 0 && column.role() == SurfaceRole.CHANNEL && !column.apron()
boolean source = first == 0 && !coastalChannel && column.station() == 0
&& column.role() == SurfaceRole.CHANNEL && !column.apron()
&& column.x() == centerline.x()[0] && column.z() == centerline.z()[0];
int y = column.role() == SurfaceRole.CHANNEL ? column.headY() : column.height();
HydrologyFeatureRef feature = features.feature(segment, column.role(), source, column.x(), y, column.z(), flowX, flowZ);
HydrologyFeatureRef feature = features.feature(segment, column.role(), source, flowX, flowZ);
String biomeOverride = pool ? poolBiome == null ? column.terrain().parentBiomeKey() : poolBiome : null;
columns.add(new SurfaceLayerColumn(
column.x(),
column.z(),
column.terrain(),
layer(feature, column, course.profileKey(), biomeOverride),
column.role(),
column.apron()
));
columns.add(new SurfaceLayerColumn(column.x(), column.z(), column.terrain(),
layer(feature, column, course.profileKey(), biomeOverride), column.role(), column.apron(),
stationOffset + column.station()));
}
return new SurfaceFootprint(columns, field.uncontainedWetCells());
return new SurfaceFootprint(columns, field.uncontainedWetCells(), field.rejection(), field.rejectionDetail(), field.bankExcavation());
}
private SurfaceRunBoundary runBoundary(RiverCourse course, int first, int after,
SurfaceCenterline centerline, ChannelProfile channel) {
SurfaceRunBoundary.Plane start = null;
SurfaceRunBoundary.Plane end = null;
if (first > 0 && course.segments().get(first - 1).fallingFluid()) {
HydraulicSegment fall = course.segments().get(first - 1);
start = SurfaceRunBoundary.Plane.between(fall.end(), fall.start(), fall.end(), boundaryRadius(channel, 0));
}
if (after < course.segments().size() && course.segments().get(after).fallingFluid()) {
HydraulicSegment fall = course.segments().get(after);
HydrologyPoint last = course.segments().get(after - 1).end();
HydrologyPoint direction = last.x() == fall.start().x() && last.z() == fall.start().z() ? fall.end() : fall.start();
end = SurfaceRunBoundary.Plane.between(fall.start(), last, direction, boundaryRadius(channel, centerline.size() - 1));
}
return new SurfaceRunBoundary(start, end);
}
private double boundaryRadius(ChannelProfile channel, int station) {
return channel.halfWidth()[station] * (1D + settings.surface().banks().roughness())
+ settings.surface().banks().maximumBlendWidth() + settings.surface().shoreWidth() + 2D;
}
private static int[] stationOffsets(List<HydraulicSegment> segments) {
int[] offsets = new int[segments.size()];
int station = 0;
HydrologyPoint previous = null;
for (int segmentIndex = 0; segmentIndex < segments.size(); segmentIndex++) {
List<HydrologyPoint> points = segments.get(segmentIndex).centerline();
for (int pointIndex = 0; pointIndex < points.size(); pointIndex++) {
HydrologyPoint point = points.get(pointIndex);
if (previous != null) {
station += Math.max(Math.abs(point.x() - previous.x()), Math.abs(point.z() - previous.z()));
}
if (pointIndex == 0) {
offsets[segmentIndex] = station;
}
previous = point;
}
}
return offsets;
}
private ChannelProfile acceptedProfile(Stations stations, SurfaceCenterline centerline) {
int count = centerline.size();
double[] halfWidth = new double[count];
double[] depth = new double[count];
double[] bank = new double[count];
for (int station = 0; station < count; station++) {
halfWidth[station] = Math.max(0.5D, stations.width()[station] / 2D);
depth[station] = Math.max(1D, stations.depth()[station]);
HydrologyTerrainSample terrain = sampler.sample(centerline.x()[station], centerline.z()[station]);
bank[station] = terrain == null ? 1D : terrain.bankMultiplier();
}
return new ChannelProfile(halfWidth, depth, bank);
}
private ChannelProfile poolProfile(HydraulicSegment segment, SurfaceCenterline centerline) {
@@ -163,7 +271,7 @@ public final class SurfaceFootprintCompiler {
channel,
false,
false,
true,
channel || column.height() != terrain.naturalHeight(),
channel,
false,
profileKey,
@@ -187,13 +295,15 @@ public final class SurfaceFootprintCompiler {
return SurfaceTerminal.COASTAL_GROTTO;
}
}
return SurfaceTerminal.SINKHOLE;
return course.surfaceSinkholeContinuation() ? SurfaceTerminal.SINKHOLE : SurfaceTerminal.TRIBUTARY;
}
private static Stations stations(List<HydraulicSegment> exposed) {
ArrayList<HydrologyPoint> points = new ArrayList<>();
IntArrayList segmentIndices = new IntArrayList();
IntArrayList heads = new IntArrayList();
DoubleArrayList widths = new DoubleArrayList();
DoubleArrayList depths = new DoubleArrayList();
int exposedStations = 0;
for (int segmentIndex = 0; segmentIndex < exposed.size(); segmentIndex++) {
HydraulicSegment segment = exposed.get(segmentIndex);
@@ -202,7 +312,8 @@ public final class SurfaceFootprintCompiler {
for (int pointIndex = 0; pointIndex < centerline.size(); pointIndex++) {
HydrologyPoint current = centerline.get(pointIndex);
if (pointIndex == 0) {
appendStation(points, segmentIndices, heads, current, segmentIndex);
appendStation(points, segmentIndices, heads, widths, depths, current, segmentIndex,
segment.channelProfile().widthAt(pointIndex), segment.channelProfile().depthAt(pointIndex));
continue;
}
HydrologyPoint previous = centerline.get(pointIndex - 1);
@@ -214,22 +325,32 @@ public final class SurfaceFootprintCompiler {
(int) StrictMath.round(previous.y() + (current.y() - previous.y()) * progress),
(int) StrictMath.round(previous.z() + (current.z() - previous.z()) * progress)
);
appendStation(points, segmentIndices, heads, cell, segmentIndex);
HydraulicChannelProfile profile = segment.channelProfile();
double width = profile.widthAt(pointIndex - 1)
+ (profile.widthAt(pointIndex) - profile.widthAt(pointIndex - 1)) * progress;
double depth = profile.depthAt(pointIndex - 1)
+ (profile.depthAt(pointIndex) - profile.depthAt(pointIndex - 1)) * progress;
appendStation(points, segmentIndices, heads, widths, depths, cell, segmentIndex, width, depth);
}
}
if (!mouth) {
exposedStations = points.size();
}
}
return new Stations(points, segmentIndices.toIntArray(), heads.toIntArray(), exposedStations);
return new Stations(points, segmentIndices.toIntArray(), heads.toIntArray(), widths.toDoubleArray(),
depths.toDoubleArray(), exposedStations);
}
private static void appendStation(
ArrayList<HydrologyPoint> points,
IntArrayList segmentIndices,
IntArrayList heads,
DoubleArrayList widths,
DoubleArrayList depths,
HydrologyPoint cell,
int segmentIndex
int segmentIndex,
double width,
double depth
) {
if (!points.isEmpty()) {
HydrologyPoint last = points.getLast();
@@ -240,9 +361,12 @@ public final class SurfaceFootprintCompiler {
points.add(cell);
segmentIndices.add(segmentIndex);
heads.add(cell.y());
widths.add(width);
depths.add(depth);
}
private record Stations(List<HydrologyPoint> points, int[] segmentIndex, int[] head, int exposedStations) {
private record Stations(List<HydrologyPoint> points, int[] segmentIndex, int[] head, double[] width,
double[] depth, int exposedStations) {
private int count() {
return points.size();
}
@@ -11,11 +11,15 @@ public record SurfaceLayerColumn(
HydrologyTerrainSample terrain,
HydrologyColumnLayer layer,
SurfaceRole role,
boolean apron
boolean apron,
int station
) {
public SurfaceLayerColumn {
Objects.requireNonNull(terrain, "terrain");
Objects.requireNonNull(layer, "layer");
Objects.requireNonNull(role, "role");
if (station < 0) {
throw new IllegalArgumentException("A surface column requires a nonnegative station.");
}
}
}
@@ -0,0 +1,20 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydrologySurfaceDropRaster;
import java.util.Objects;
public record SurfaceRasterContext(SurfaceBounds bounds, HydrologySurfaceDropRaster drops, SurfaceRunBoundary boundary) {
public SurfaceRasterContext {
Objects.requireNonNull(drops, "drops");
Objects.requireNonNull(boundary, "boundary");
}
public static SurfaceRasterContext none() {
return bounded(null);
}
public static SurfaceRasterContext bounded(SurfaceBounds bounds) {
return new SurfaceRasterContext(bounds, HydrologySurfaceDropRaster.empty(), SurfaceRunBoundary.unbounded());
}
}
@@ -0,0 +1,26 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydrologyPoint;
public record SurfaceRunBoundary(Plane start, Plane end) {
public static SurfaceRunBoundary unbounded() {
return new SurfaceRunBoundary(null, null);
}
public boolean allows(int x, int z) {
return (start == null || start.projection(x, z) >= 0L)
&& (end == null || end.projection(x, z) <= 0L);
}
public record Plane(int x, int z, int flowX, int flowZ, double radius) {
public static Plane between(HydrologyPoint origin, HydrologyPoint upstream, HydrologyPoint downstream, double radius) {
return new Plane(origin.x(), origin.z(), downstream.x() - upstream.x(), downstream.z() - upstream.z(), radius);
}
private long projection(int pointX, int pointZ) {
long dx = (long) pointX - x;
long dz = (long) pointZ - z;
return (double) dx * dx + (double) dz * dz > radius * radius ? 0L : dx * flowX + dz * flowZ;
}
}
}
@@ -1,5 +1,6 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.HydraulicChannelProfile;
import art.arcane.iris.engine.hydrology.HydraulicSegment;
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
import art.arcane.iris.engine.hydrology.HydrologyHash;
@@ -80,16 +81,12 @@ public final class SurfaceSegmentLabeler {
int startStation = run.start;
int endStation = run.end + 1;
ArrayList<HydrologyPoint> centerline = new ArrayList<>(endStation - startStation + 1);
double totalWidth = 0D;
double totalDepth = 0D;
for (int station = startStation; station <= endStation; station++) {
centerline.add(new HydrologyPoint(x[station], head[station], z[station]));
totalWidth += width[station];
totalDepth += depth[station];
}
int stations = endStation - startStation + 1;
int segmentWidth = Math.max(1, (int) StrictMath.round(totalWidth / stations));
int segmentDepth = Math.max(1, (int) StrictMath.round(totalDepth / stations));
HydraulicChannelProfile channelProfile = HydraulicChannelProfile.range(width, depth, startStation, endStation + 1);
int segmentWidth = Math.max(1, (int) StrictMath.ceil(channelProfile.maximumWidth()));
int segmentDepth = Math.max(1, (int) StrictMath.ceil(channelProfile.maximumDepth()));
int index = segments.size();
segments.add(new HydraulicSegment(
HydrologyHash.mix(worldSeed, SEGMENT_SALT, courseId, index, run.type.ordinal()),
@@ -101,7 +98,8 @@ public final class SurfaceSegmentLabeler {
segmentDepth,
false,
false,
centerline
centerline,
channelProfile
));
}
return List.copyOf(segments);
@@ -8,11 +8,12 @@ import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler;
import java.util.Objects;
public final class ValleyProfileSolver {
private static final int BANK_LOOKAHEAD = 2;
private static final int BANK_LOOKAHEAD = SurfaceBankSupport.BANK_LOOKAHEAD;
private final HydrologyPlannerSettings.Surface surface;
private final HydrologyTerrainSampler sampler;
private final int seaLevel;
private final int minimumCourseLength;
private final SurfaceBankSupport bankSupport;
public ValleyProfileSolver(
HydrologyPlannerSettings.Surface surface,
@@ -24,6 +25,7 @@ public final class ValleyProfileSolver {
this.sampler = Objects.requireNonNull(sampler, "sampler");
this.seaLevel = seaLevel;
this.minimumCourseLength = minimumCourseLength;
this.bankSupport = new SurfaceBankSupport(surface, seaLevel);
}
public ValleyProfile solve(
@@ -41,50 +43,37 @@ public final class ValleyProfileSolver {
double roughness = surface.banks().roughness();
int exposed = count;
for (int station = 0; station < count; station++) {
double outline = channel.halfWidth()[station] * (1D + roughness);
// Every cell from the narrowest possible outline outward can end up beside water, so it joins the minimum.
double innerOutline = channel.halfWidth()[station] * (1D - roughness);
double reach = outline + 2D;
int stationX = centerline.x()[station];
int stationZ = centerline.z()[station];
HydrologyTerrainSample center = sampler.sample(stationX, stationZ);
if (!SurfaceCellAdmission.writable(center, seaLevel)) {
if (!writable(center, terminal)) {
exposed = station;
break;
}
centerNatural[station] = center.naturalHeight();
channelIncision[station] = center.surfacePolicy().maximumIncision(surface.maximumIncision());
incisionMultiplier[station] = center.incisionMultiplier();
double normalX = centerline.normalX(station);
double normalZ = centerline.normalZ(station);
int minimum = Integer.MAX_VALUE;
int maximum = Integer.MIN_VALUE;
boolean blocked = false;
for (double offset = -reach; offset <= reach && !blocked; offset += 0.5D) {
int cellX = (int) StrictMath.round(stationX + normalX * offset);
int cellZ = (int) StrictMath.round(stationZ + normalZ * offset);
HydrologyTerrainSample terrain = sampler.sample(cellX, cellZ);
if (!SurfaceCellAdmission.writable(terrain, seaLevel)) {
blocked = true;
break;
}
if (Math.abs(offset) < innerOutline - 0.75D) {
continue;
}
minimum = Math.min(minimum, terrain.naturalHeight());
maximum = Math.max(maximum, terrain.naturalHeight());
}
if (blocked) {
SurfaceBankSupport.CrossSection section = bankSupport.crossSection(sampler,
station(centerline, channel, station), terminal == SurfaceTerminal.OCEAN_MOUTH);
if (section.blocked()) {
exposed = station;
break;
}
crossMin[station] = minimum;
crossMax[station] = maximum;
crossMin[station] = section.minimum();
crossMax[station] = section.maximum();
}
if (exposed < 2 || exposed < minimumCourseLength) {
return ValleyProfile.rejected(HydrologyCandidateRejection.COURSE_TOO_SHORT, exposed);
if (terminal == SurfaceTerminal.COASTAL_GROTTO) {
while (exposed > 0 && !terminalCapSupported(centerline, channel, exposed - 1)) {
exposed--;
}
}
if (exposed < count && terminal != SurfaceTerminal.OCEAN_MOUTH) {
double exposedLength = exposedLength(centerline, exposed);
if (exposed < 2 || exposedLength < minimumCourseLength) {
return ValleyProfile.rejected(HydrologyCandidateRejection.COURSE_TOO_SHORT,
(int) StrictMath.floor(exposedLength));
}
if (exposed < count && terminal != SurfaceTerminal.OCEAN_MOUTH
&& terminal != SurfaceTerminal.COASTAL_GROTTO) {
return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_EXPOSURE, exposed);
}
// The channel's rounded end caps reach past the first and last stations; the ground under
@@ -96,6 +85,9 @@ public final class ValleyProfileSolver {
int sink = surface.banks().sink();
int[] head = new int[count];
for (int station = 0; station < exposed; station++) {
int incomingCeiling = station == 0 ? crossMin[station]
: Math.min(crossMin[station], head[station - 1] + sink);
crossMin[station] = bankSupport.perimeter(sampler, station(centerline, channel, station), incomingCeiling).minimum();
// The cells beside a station's water include cells the next stations own, so on ground
// that falls gently along the course the head steps down a station early rather than
// onto a bank the lip cannot raise above its natural height. A larger drop is a step or
@@ -177,6 +169,41 @@ public final class ValleyProfileSolver {
return new ValleyProfile(head, crossMin, crossMax, centerNatural, exposed, null, 0);
}
private static SurfaceBankSupport.Station station(SurfaceCenterline centerline, ChannelProfile channel, int station) {
return new SurfaceBankSupport.Station(centerline.x()[station], centerline.z()[station],
centerline.tangentX()[station], centerline.tangentZ()[station], channel.halfWidth()[station]);
}
private static double exposedLength(SurfaceCenterline centerline, int exposed) {
double length = 0D;
for (int station = 1; station < exposed; station++) {
length += StrictMath.hypot((double) centerline.x()[station] - centerline.x()[station - 1],
(double) centerline.z()[station] - centerline.z()[station - 1]);
}
return length;
}
private boolean terminalCapSupported(SurfaceCenterline centerline, ChannelProfile channel, int station) {
double reach = channel.halfWidth()[station] * (1D + surface.banks().roughness()) + 2D;
int radius = (int) StrictMath.ceil(reach);
double reachSquared = reach * reach;
double tangentX = centerline.tangentX()[station];
double tangentZ = centerline.tangentZ()[station];
for (int dz = -radius; dz <= radius; dz++) {
for (int dx = -radius; dx <= radius; dx++) {
if ((double) dx * dx + (double) dz * dz > reachSquared
|| dx * tangentX + dz * tangentZ < 0D) {
continue;
}
HydrologyTerrainSample terrain = sampler.sample(centerline.x()[station] + dx, centerline.z()[station] + dz);
if (!SurfaceCellAdmission.writable(terrain, seaLevel)) {
return false;
}
}
}
return true;
}
private static boolean cutFits(
int station,
int head,
@@ -188,6 +215,12 @@ public final class ValleyProfileSolver {
return cut(station, head, channel, centerNatural) <= permitted(station, incisionMultiplier, maximumIncision);
}
private boolean writable(HydrologyTerrainSample terrain, SurfaceTerminal terminal) {
return terminal == SurfaceTerminal.OCEAN_MOUTH
? SurfaceCellAdmission.mouthLand(terrain, seaLevel)
: SurfaceCellAdmission.writable(terrain, seaLevel);
}
/** How far below the natural ground the bed under a head sits at a station. */
private static int cut(int station, int head, ChannelProfile channel, int[] centerNatural) {
return centerNatural[station] - (head - (int) StrictMath.round(channel.depth()[station]));
@@ -4,6 +4,7 @@ import art.arcane.iris.engine.IrisComplex;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.hydrology.HydrologyCaveVoxelViewFactory;
import art.arcane.iris.engine.hydrology.HydrologyColumnSample;
import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
import art.arcane.iris.engine.hydrology.RiverFootprint;
import art.arcane.iris.engine.hydrology.cave.CavePosition;
import art.arcane.iris.engine.hydrology.cave.CaveVoxel;
@@ -227,7 +228,7 @@ public final class MantleHydrologyCaveVoxelView implements CaveVoxelView {
int surfaceHeight = sample == null
? (int) Math.round(complex.getNaturalHeightStream().getDouble(x, z))
: sample.terrainHeight();
return new TerrainColumn(surfaceHeight, sample != null && sample.primarySurfaceLayer().isPresent());
return new TerrainColumn(surfaceHeight, sample != null && sample.primarySurfaceLayer().filter(HydrologyColumnLayer::terrainOwned).isPresent());
}
static void generateCarvingInput(
@@ -192,11 +192,12 @@ public final class MantleHydrologyComponent extends IrisMantleComponent {
Map<CavePosition, PlannedCell> surfaceGuards = new HashMap<>();
for (HydrologyColumnSample sample : samples.values()) {
for (HydrologyColumnLayer layer : sample.layers()) {
if (layer.oceanApron() || !layer.terrainOwned()) {
if (layer.oceanApron() || !ownsVolume(layer)) {
continue;
}
validateLayer(layer, worldHeight);
if (layer.channel() && isCaveLayer(layer)) {
if (layer.channel() && (isCaveLayer(layer)
|| hasAcceptedVolume(sample, layer, cavePlansByCourseId.get(layer.feature().courseId())))) {
addCaveVolume(sample, layer, cavePlansByCourseId, caveCells);
}
}
@@ -280,7 +281,7 @@ public final class MantleHydrologyComponent extends IrisMantleComponent {
if (sample != null) {
for (HydrologyColumnLayer layer : sample.layers()) {
if (layer.feature().courseId() == plannedCaveCell.courseId()
&& isCaveLayer(layer)
&& layer.channel() && ownsVolume(layer) && !layer.oceanApron()
&& position.y() > layer.bedY()
&& position.y() <= layer.ceilingY()) {
return layer.floodedCaveBiomeKey();
@@ -618,8 +619,8 @@ public final class MantleHydrologyComponent extends IrisMantleComponent {
}
for (HydrologyColumnLayer layer : sample.layers()) {
if (layer.feature().courseId() == plan.source().sourceId()
&& layer.channel() && layer.terrainOwned() && !layer.oceanApron()
&& isCaveLayer(layer)) {
&& layer.channel() && ownsVolume(layer) && !layer.oceanApron()
&& (isCaveLayer(layer) || hasAcceptedVolume(sample, layer, plan))) {
return false;
}
}
@@ -785,6 +786,24 @@ public final class MantleHydrologyComponent extends IrisMantleComponent {
}
}
private static boolean ownsVolume(HydrologyColumnLayer layer) {
return layer.terrainOwned() || layer.fallingFluid() && layer.fluidOwned();
}
private static boolean hasAcceptedVolume(HydrologyColumnSample sample, HydrologyColumnLayer layer,
HydrologyCavePlan plan) {
if (plan == null || !plan.accepted()) {
return false;
}
for (int y = layer.bedY() + 1; y <= layer.ceilingY(); y++) {
HydrologyCaveAction action = plan.actions().get(new CavePosition(sample.x(), y, sample.z()));
if (action != null && action != HydrologyCaveAction.SEAL_GUARD) {
return true;
}
}
return false;
}
private static boolean isCaveLayer(HydrologyColumnLayer layer) {
return layer.feature().type().isUnderground() || layer.feature().type().isDeepFluid();
}
@@ -0,0 +1,76 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.engine.object.annotations.Desc;
import art.arcane.iris.engine.object.annotations.MaxNumber;
import art.arcane.iris.engine.object.annotations.MinNumber;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
@Accessors(chain = true)
@NoArgsConstructor
@Desc("Three-dimensional rock shelves retained above the approved riverbank cut.")
@Data
public class IrisRiverBank3DConfig {
@Desc("Retain connected volumetric shelves in eroded riverbanks.")
private boolean enabled = true;
@Desc("Seed offset for the riverbank density field.")
private long seed = 0;
@MinNumber(0)
@MaxNumber(32)
@Desc("Maximum shelf height above the approved bank surface. Does not deepen the river cut.")
private double amplitude = 8;
@MinNumber(4)
@MaxNumber(4096)
@Desc("Horizontal density feature size in blocks at the style's default zoom.")
private double horizontalScale = 48;
@MinNumber(4)
@MaxNumber(256)
@Desc("Vertical density feature size in blocks. Short scales form separate rock shelves.")
private double verticalScale = 8;
@MinNumber(1)
@MaxNumber(16)
@Desc("Maximum horizontal distance in blocks from an overhang to a solid supporting bank.")
private int maximumOverhang = 8;
@Desc("Signed XYZ density controlling retained bank rock. Supports generator styles and fractures.")
private IrisGeneratorStyle densityStyle = new IrisGeneratorStyle(NoiseStyle.SIMPLEX);
public void validate() {
requireRange("amplitude", amplitude, 0D, 32D);
requireRange("horizontalScale", horizontalScale, 4D, 4096D);
requireRange("verticalScale", verticalScale, 4D, 256D);
requireRange("maximumOverhang", maximumOverhang, 1D, 16D);
if (densityStyle == null) {
throw new IllegalArgumentException("geometry.banks3D.densityStyle must be a generator style.");
}
validateStyle(densityStyle, 0);
}
private static void validateStyle(IrisGeneratorStyle style, int depth) {
if (depth > 32 || style.getStyle() == null) {
throw new IllegalArgumentException("geometry.banks3D.densityStyle must use known styles with at most 32 nested fractures.");
}
requireRange("densityStyle.zoom", style.getZoom(), 0.00001D, Double.MAX_VALUE);
requireRange("densityStyle.cellularFrequency", style.getCellularFrequency(), 0D, Double.MAX_VALUE);
requireRange("densityStyle.cellularZoom", style.getCellularZoom(), 0.00001D, Double.MAX_VALUE);
requireRange("densityStyle.multiplier", style.getMultiplier(), 0.00001D, Double.MAX_VALUE);
requireRange("densityStyle.exponent", style.getExponent(), 0.01562D, 64D);
requireRange("densityStyle.cacheSize", style.getCacheSize(), 0D, 8192D);
if (style.getFracture() != null) {
validateStyle(style.getFracture(), depth + 1);
}
}
private static void requireRange(String field, double value, double minimum, double maximum) {
if (!Double.isFinite(value) || value < minimum || value > maximum) {
throw new IllegalArgumentException("geometry.banks3D." + field + " must be finite and between "
+ minimum + " and " + maximum + ".");
}
}
}
@@ -0,0 +1,29 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.engine.object.annotations.Desc;
import art.arcane.iris.engine.object.annotations.MaxNumber;
import art.arcane.iris.engine.object.annotations.MinNumber;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
@Data
@NoArgsConstructor
@Accessors(chain = true)
@Desc("Bounds terrain removal outside the wet river channel.")
public class IrisRiverExcavationConfig {
@MinNumber(0)
@MaxNumber(64)
@Desc("Maximum blocks removed vertically from a dry bank column. Zero preserves dry bank heights.")
private int maximumDepth = 8;
@MinNumber(1)
@MaxNumber(64)
@Desc("Maximum horizontal distance from the channel outline affected by shore and bank excavation.")
private int maximumWidth = 16;
@MinNumber(0)
@MaxNumber(8192)
@Desc("Maximum dry bank blocks removed per block, measured over sixteen-block reaches. Courses exceeding this budget are rejected.")
private int maximumVolumePerBlock = 256;
}
@@ -16,6 +16,9 @@ public class IrisRiverGeometryConfig {
@Desc("Surface river bed shape for waterfall and cascade throats, and the wall shape shared with the erosion compiler; roughness fields default to the channel roughness.")
private IrisSurfaceRiverShapeConfig surface = new IrisSurfaceRiverShapeConfig();
@Desc("Three-dimensional bank shelves above the protected riverbed and waterline.")
private IrisRiverBank3DConfig banks3D = new IrisRiverBank3DConfig();
@Desc("Underground channel bed and wall shape.")
private IrisRiverChannelShapeConfig underground = new IrisRiverChannelShapeConfig();
@@ -0,0 +1,55 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.engine.object.annotations.Desc;
import art.arcane.iris.engine.object.annotations.MaxNumber;
import art.arcane.iris.engine.object.annotations.MinNumber;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
@Data
@NoArgsConstructor
@Accessors(chain = true)
@Desc("Regional drainage trunks shared across generation tiles and lowland channels between two coasts.")
public class IrisRiverRegionalConfig {
@Desc("Plan regional drainage before local rivers.")
private boolean enabled = true;
@MinNumber(128)
@MaxNumber(1024)
@Desc("Coarse regional terrain sample spacing in blocks; a power of two.")
private int sampleSpacing = 256;
@MinNumber(256)
@MaxNumber(32768)
@Desc("Minimum length of a regional trunk or coastal channel.")
private int minimumLength = 2048;
@MinNumber(1)
@MaxNumber(8)
@Desc("Maximum regional trunks accepted in one drainage basin.")
private int maximumTrunks = 2;
@MinNumber(1)
@MaxNumber(64)
@Desc("Maximum regional basin graphs retained in memory.")
private int maximumCachedBasins = 8;
@MinNumber(16384)
@MaxNumber(1048576)
@Desc("Maximum complete hydraulic stations retained across cached regional basins.")
private int maximumCachedStations = 131072;
@Desc("Allow a sea-level lowland channel with an ocean connection at both ends.")
private boolean coastalChannels = true;
@MinNumber(0)
@MaxNumber(1)
@Desc("Chance that a basin tries a coastal channel before ordinary drainage trunks.")
private double coastalChannelChance = 0.25D;
@MinNumber(1)
@MaxNumber(32)
@Desc("Maximum terrain incision along a channel between two coasts.")
private int maximumCoastalIncision = 8;
}
@@ -85,4 +85,7 @@ public class IrisRiverRoutingConfig {
@MaxNumber(4)
@Desc("Extra surface courses an outlet may accept as tributaries joining its main river; 0 keeps one river per outlet.")
private int tributaries = 1;
@Desc("Regional drainage and channels between coasts, planned above the local tile lattice.")
private IrisRiverRegionalConfig regional = new IrisRiverRegionalConfig();
}
@@ -32,6 +32,9 @@ public class IrisSurfaceRiverBankConfig {
@Desc("Widest eroded band outside the shore, in blocks.")
private int maximumBlendWidth = 32;
@Desc("Limits dry bank cut depth, width, and excavated volume independently of the wet channel.")
private IrisRiverExcavationConfig excavation = new IrisRiverExcavationConfig();
@Desc("Show the biome's deeper layers on eroded banks instead of the surface layer.")
private boolean exposeCutStrata = true;
@@ -22,7 +22,7 @@ public class IrisSurfaceRiverErosionConfig {
@MinNumber(0)
@MaxNumber(0.95)
@Desc("Share of the channel half-width that stays at full bed depth before the bed rises to the edge; higher is a flatter, broader bed.")
@Desc("Share of the distance from the thalweg to each waterline that stays at full bed depth before the bed rises to the edge; higher is a flatter, broader bed.")
private double thalwegFraction = 0.45D;
@MinNumber(0.25)
@@ -48,6 +48,6 @@ public class IrisSurfaceRiverErosionConfig {
@Desc("Share of the eroded band kept level at the bank top before the vertical wall when style is CLIFF; ignored by every other style.")
private double cliffFraction = 0.5D;
@Desc("Cross-section of the wet channel bed from the centerline out to the waterline. BOWL is the profile used before this field existed.")
@Desc("Cross-section of the wet channel bed from the thalweg out to the waterline. Curves shift the thalweg toward the outside bank within the existing depth limit.")
private IrisRiverBedProfile bedProfile = IrisRiverBedProfile.BOWL;
}
@@ -0,0 +1,299 @@
package art.arcane.iris.engine.terrain;
import art.arcane.iris.core.loader.IrisData;
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.object.IrisRiverBank3DConfig;
import art.arcane.iris.util.project.noise.CNG;
import art.arcane.volmlib.util.math.RNG;
import it.unimi.dsi.fastutil.ints.IntArrayList;
import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap;
import java.util.Objects;
import java.util.Optional;
public final class HydrologyBankTerrainRuntime {
private static final int STEP = 2;
private static final long DENSITY_SALT = 0x485944524f42414eL;
private static final int[][] DIRECTIONS = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
private final Sources sources;
private final Options options;
private final double amplitude;
private final double horizontalFrequency;
private final double verticalFrequency;
private final int maximumOverhang;
private final NoiseSource noise;
private final Long2ObjectLinkedOpenHashMap<BankColumn> columns = new Long2ObjectLinkedOpenHashMap<>();
private long columnsEpoch;
public HydrologyBankTerrainRuntime(Sources sources, Options options) {
this(sources, new Compiled(options, compileNoise(options)));
}
private HydrologyBankTerrainRuntime(Sources sources, Compiled compiled) {
this.sources = Objects.requireNonNull(sources, "Bank terrain sources");
options = compiled.options();
IrisRiverBank3DConfig config = options.config();
config.validate();
amplitude = config.isEnabled() ? config.getAmplitude() : 0D;
horizontalFrequency = 64D / config.getHorizontalScale();
verticalFrequency = 64D / config.getVerticalScale();
maximumOverhang = config.getMaximumOverhang();
noise = Objects.requireNonNull(compiled.noise(), "Bank density noise");
}
static HydrologyBankTerrainRuntime withNoise(Sources sources, Options options, NoiseSource noise) {
return new HydrologyBankTerrainRuntime(sources, new Compiled(options, noise));
}
public Terrain3DColumn column(int x, int z) {
Terrain3DColumn resolved = resolveColumn(x, z);
return resolved == null ? sources.natural().column(x, z) : resolved;
}
public Optional<Terrain3DColumn> columnIfReady(int x, int z) {
return Optional.ofNullable(resolveColumn(x, z));
}
private Terrain3DColumn resolveColumn(int x, int z) {
BankColumn bank = rawColumn(x, z);
if (bank == null) {
return null;
}
Terrain3DColumn resolved = bank.resolved;
if (resolved != null) {
return resolved;
}
Terrain3DColumn raw = bank.raw;
IntArrayList boundaries = new IntArrayList(raw.spanCount() * 2);
boundaries.add(raw.ceiling(0));
boundaries.add(raw.floor(0));
for (int span = 1; span < raw.spanCount(); span++) {
Support support = supported(x, z, raw.ceiling(span), raw.floor(span));
if (support == Support.UNAVAILABLE) {
return null;
}
if (support == Support.SUPPORTED) {
boundaries.add(raw.ceiling(span));
boundaries.add(raw.floor(span));
}
}
resolved = boundaries.size() == raw.spanCount() * 2 ? raw
: new Terrain3DColumn(raw.baseHeight(), raw.minY(), true, boundaries.toIntArray());
bank.resolved = resolved;
return resolved;
}
public void clear() {
synchronized (columns) {
columnsEpoch++;
columns.clear();
}
}
private static NoiseSource compileNoise(Options options) {
IrisRiverBank3DConfig config = options.config();
config.validate();
if (!config.isEnabled() || config.getAmplitude() == 0D) {
return (x, y, z) -> 0D;
}
CNG generator = config.getDensityStyle().createNoCache(
new RNG(options.seed() ^ config.getSeed() ^ DENSITY_SALT), options.data());
return generator::noiseFastSigned3D;
}
private BankColumn rawColumn(int x, int z) {
long key = (long) x << 32 ^ z & 0xffffffffL;
long loadEpoch;
synchronized (columns) {
BankColumn existing = columns.getAndMoveToLast(key);
if (existing != null) {
return existing;
}
loadEpoch = columnsEpoch;
}
BankColumn computed = createColumn(x, z);
if (computed == null) {
return null;
}
synchronized (columns) {
if (loadEpoch != columnsEpoch) {
return null;
}
BankColumn existing = columns.getAndMoveToLast(key);
if (existing != null) {
return existing;
}
columns.putAndMoveToLast(key, computed);
if (columns.size() > options.maximumColumns()) {
columns.removeFirst();
}
}
return computed;
}
private BankColumn createColumn(int x, int z) {
HydrologyColumnSnapshot snapshot = sources.hydrology().sample(x, z);
if (!snapshot.available()) {
return null;
}
Terrain3DColumn natural = Objects.requireNonNull(sources.natural().column(x, z), "Natural bank column");
HydrologyColumnSample hydrology = snapshot.column();
HydrologyColumnLayer layer = hydrology == null ? null : hydrology.primarySurfaceLayerOrNull();
if (layer == null || !layer.terrainOwned()) {
return new BankColumn(natural, natural);
}
int base = Math.clamp(hydrology.terrainHeight(), 0, options.height() - 1);
if (layer.channel() || !layer.feature().type().isSurface() || amplitude == 0D) {
Terrain3DColumn flat = Terrain3DColumn.unshaped(base, options.height());
return new BankColumn(flat, flat);
}
if (base >= natural.topY()) {
return new BankColumn(natural, natural);
}
double rise = Math.min(amplitude, natural.topY() - base);
IntArrayList boundaries = new IntArrayList(natural.spanCount() * 2 + 8);
boundaries.add(0);
boolean previous = true;
int maximum = Math.min(natural.topY(), options.height() - 1);
int preserved = maximum + 1;
for (int span = 1; span < natural.spanCount(); span++) {
if (natural.ceiling(span) > base) {
preserved = natural.ceiling(span);
break;
}
}
double[] density = sampleColumn(x, z, base + 1, Math.min(maximum, base + (int) Math.ceil(rise)));
for (int y = base + 1; y <= maximum; y++) {
boolean solid = natural.isSolid(y) && (y >= preserved || y - base <= rise
&& y - base <= rise * (0.5D + 0.5D * density[y - base - 1]));
if (solid != previous) {
boundaries.add(solid ? y : y - 1);
previous = solid;
}
}
if (previous) {
boundaries.add(maximum);
}
Terrain3DColumn raw = new Terrain3DColumn(natural.baseHeight(), base + 1, true, boundaries.toIntArray());
return new BankColumn(raw, raw.spanCount() == 1 ? raw : null);
}
private Support supported(int x, int z, int bottom, int top) {
for (int y = bottom; y <= top; y++) {
for (int[] direction : DIRECTIONS) {
for (int distance = 1; distance <= maximumOverhang; distance++) {
long sampleX = (long) x + direction[0] * distance;
long sampleZ = (long) z + direction[1] * distance;
if (sampleX < Integer.MIN_VALUE || sampleX > Integer.MAX_VALUE
|| sampleZ < Integer.MIN_VALUE || sampleZ > Integer.MAX_VALUE) {
break;
}
BankColumn sampled = rawColumn((int) sampleX, (int) sampleZ);
if (sampled == null) {
return Support.UNAVAILABLE;
}
Terrain3DColumn neighbour = sampled.raw;
if (y <= neighbour.floor(0)) {
return Support.SUPPORTED;
}
if (!neighbour.isSolid(y)) {
break;
}
}
}
}
return Support.UNSUPPORTED;
}
private double[] sampleColumn(int x, int z, int minimum, int maximum) {
int lowerX = Math.floorDiv(x, STEP) * STEP;
int lowerZ = Math.floorDiv(z, STEP) * STEP;
double fractionX = (x - lowerX) / (double) STEP;
double fractionZ = (z - lowerZ) / (double) STEP;
int gridY = Math.floorDiv(minimum, STEP) * STEP;
double lower = interpolatePlane(lowerX, gridY, lowerZ, fractionX, fractionZ);
double upper = interpolatePlane(lowerX, gridY + STEP, lowerZ, fractionX, fractionZ);
double[] density = new double[maximum - minimum + 1];
for (int y = minimum; y <= maximum; y++) {
if (y >= gridY + STEP) {
gridY += STEP;
lower = upper;
upper = interpolatePlane(lowerX, gridY + STEP, lowerZ, fractionX, fractionZ);
}
double fractionY = (y - gridY) / (double) STEP;
density[y - minimum] = lower + (upper - lower) * fractionY;
}
return density;
}
private double interpolatePlane(int x, int y, int z, double fractionX, double fractionZ) {
double northWest = noise(x, y, z);
double northEast = noise((long) x + STEP, y, z);
double southWest = noise(x, y, (long) z + STEP);
double southEast = noise((long) x + STEP, y, (long) z + STEP);
double north = northWest + (northEast - northWest) * fractionX;
double south = southWest + (southEast - southWest) * fractionX;
return north + (south - north) * fractionZ;
}
private double noise(long x, int y, long z) {
double value = noise.sample(x * horizontalFrequency, y * verticalFrequency, z * horizontalFrequency);
if (!Double.isFinite(value)) {
throw new IllegalStateException("Nonfinite riverbank density at " + x + "," + y + "," + z + ".");
}
return Math.clamp(value, -1D, 1D);
}
public record Sources(NaturalSource natural, HydrologySource hydrology) {
public Sources {
Objects.requireNonNull(natural, "Natural terrain source");
Objects.requireNonNull(hydrology, "Hydrology source");
}
}
public record Options(long seed, int height, int maximumColumns, IrisData data, IrisRiverBank3DConfig config) {
public Options {
Objects.requireNonNull(config, "Bank geometry configuration");
if (height < 2 || maximumColumns < 1) {
throw new IllegalArgumentException("Bank terrain height and cache size must be positive.");
}
}
}
@FunctionalInterface
public interface NaturalSource {
Terrain3DColumn column(int x, int z);
}
@FunctionalInterface
public interface HydrologySource {
HydrologyColumnSnapshot sample(int x, int z);
}
@FunctionalInterface
interface NoiseSource {
double sample(double x, double y, double z);
}
private record Compiled(Options options, NoiseSource noise) {
}
private enum Support {
SUPPORTED,
UNSUPPORTED,
UNAVAILABLE
}
private static final class BankColumn {
private final Terrain3DColumn raw;
private volatile Terrain3DColumn resolved;
private BankColumn(Terrain3DColumn raw, Terrain3DColumn resolved) {
this.raw = raw;
this.resolved = resolved;
}
}
}
@@ -0,0 +1,40 @@
package art.arcane.iris.core.pack;
import art.arcane.volmlib.util.json.JSONObject;
import org.junit.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class PackRiverBank3DValidatorTest {
@Test
public void acceptsVolumetricStylesAndFullLengthSeeds() {
List<String> errors = validate("""
{"enabled":true,"seed":-9223372036854775808,"amplitude":32,
"horizontalScale":4,"verticalScale":256,"maximumOverhang":16,
"densityStyle":{"style":"STRATA","fracture":{"style":"SIMPLEX","zoom":2}}}
""");
assertTrue(errors.toString(), errors.isEmpty());
assertTrue(validate("{\"densityStyle\":\"river/bank-rock\"}").isEmpty());
}
@Test
public void rejectsInvalidBoundsTypesAndStyles() {
for (String fields : List.of("\"seed\":1.5", "\"seed\":9223372036854775808",
"\"enabled\":\"true\"", "\"amplitude\":-1", "\"horizontalScale\":0",
"\"verticalScale\":257", "\"maximumOverhang\":17", "\"maximumOverhang\":1.5",
"\"densityStyle\":null", "\"densityStyle\":{\"style\":\"invalid\"}",
"\"densityStyle\":{\"zoom\":0}")) {
assertFalse(fields, validate("{" + fields + "}").isEmpty());
}
}
private static List<String> validate(String json) {
List<String> errors = new ArrayList<>();
PackRiverBank3DValidator.validate("geometry.banks3D", new JSONObject(json), errors);
return errors;
}
}
@@ -66,6 +66,11 @@ public class IrisRiverSchemaTest {
JSONObject rivers = referencedProperties(definitions, properties.getJSONObject("rivers"));
JSONObject routing = referencedProperties(definitions, rivers.getJSONObject("routing"));
JSONObject geometry = referencedProperties(definitions, rivers.getJSONObject("geometry"));
JSONObject bankVolume = referencedProperties(definitions, geometry.getJSONObject("banks3D"));
assertEquals(32D, bankVolume.getJSONObject("amplitude").getDouble("maximum"), 0D);
assertEquals(4D, bankVolume.getJSONObject("verticalScale").getDouble("minimum"), 0D);
assertEquals(16, bankVolume.getJSONObject("maximumOverhang").getInt("maximum"));
assertSnippetBackedObjectReference(bankVolume.getJSONObject("densityStyle"));
JSONObject drops = referencedProperties(definitions, geometry.getJSONObject("drops"));
JSONObject surface = referencedProperties(definitions, rivers.getJSONObject("surface"));
JSONObject sources = referencedProperties(definitions, surface.getJSONObject("sources"));
@@ -78,7 +78,7 @@ public class SchemaBuilderIdentityTest {
@ClassRule
public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean();
private static final String EXPECTED_SCHEMA_DIGEST = "43b3add0a559f7006e2973344f39db933bbfb1be889f3c5d616a7bf035a9db39";
private static final String EXPECTED_SCHEMA_DIGEST = "6dd947428034d7feb5549a37f9b7061c5552601779611587967451e4e54fb984";
private static final List<Class<?>> SCHEMA_ROOTS = List.of(
IrisDimension.class,
@@ -1,15 +1,29 @@
package art.arcane.iris.engine.actuator;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.IrisComplex;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.SeedManager;
import art.arcane.iris.engine.image.IrisImageMapRuntime;
import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
import art.arcane.iris.engine.hydrology.HydrologyColumnSample;
import art.arcane.iris.engine.hydrology.HydrologyFeatureRef;
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
import art.arcane.iris.engine.object.IrisDecorationStep;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisHydrology;
import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.engine.object.IrisMaterialPalette;
import art.arcane.iris.engine.object.IrisRiverMaterialConfig;
import art.arcane.iris.engine.terrain.Terrain3DColumn;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.testsupport.PlatformBinding;
import art.arcane.iris.util.project.context.ChunkContext;
import art.arcane.iris.util.project.context.ChunkedDataCache;
import art.arcane.iris.util.project.hunk.Hunk;
import art.arcane.iris.util.project.stream.ProceduralStream;
import art.arcane.iris.util.project.stream.interpolation.Interpolated;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.RNG;
import org.junit.Rule;
@@ -25,6 +39,7 @@ import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyDouble;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.mock;
@@ -208,6 +223,79 @@ public class IrisTerrainNormalActuatorHydrologyTest {
verify(fixture.palette, never()).get(any(RNG.class), anyDouble(), anyDouble(), anyDouble(), any());
}
@Test
public void deepBankAndShoreCutsStillReceiveTheirSurfaceMaterial() {
for (HydrologyColumnLayer layer : List.of(shoreLayer(), bankLayer())) {
Engine engine = mock(Engine.class);
IrisComplex complex = mock(IrisComplex.class);
IrisData data = mock(IrisData.class);
IrisBiome biome = mock(IrisBiome.class);
IrisRegion region = new IrisRegion();
ChunkContext context = mock(ChunkContext.class);
PlatformBlockState rock = mock(PlatformBlockState.class);
PlatformBlockState grass = mock(PlatformBlockState.class);
PlatformBlockState painted = mock(PlatformBlockState.class);
when(rock.key()).thenReturn("minecraft:stone");
when(grass.key()).thenReturn("minecraft:grass_block");
when(painted.key()).thenReturn("minecraft:dirt");
IrisMaterialPalette palette = mock(IrisMaterialPalette.class);
when(palette.get(any(RNG.class), anyDouble(), anyDouble(), anyDouble(), eq(data))).thenReturn(painted);
IrisRiverMaterialConfig material = new IrisRiverMaterialConfig().setEnabled(true).setDepth(2).setPalette(palette);
IrisHydrology hydrology = new IrisHydrology();
hydrology.getRivers().getSurface().getBanks().setShoreMaterial(material).setBankMaterial(material);
IrisDimension dimension = new IrisDimension().setHydrology(hydrology).setBedrock(false).setHideOresForHiddenOre(true);
when(engine.getDimension()).thenReturn(dimension);
when(engine.getComplex()).thenReturn(complex);
when(engine.getData()).thenReturn(data);
when(engine.getSeedManager()).thenReturn(mock(SeedManager.class));
when(complex.getImageMapRuntime()).thenReturn(mock(IrisImageMapRuntime.class));
when(complex.getRiverWaterSurfaceStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 0D));
when(complex.sampleHydrologyColumn(11, -4)).thenReturn(new HydrologyColumnSample(
11, -4, 80, 0, false, "parent", List.of(layer)));
when(context.getRoundedHeight(0, 0)).thenReturn(60);
when(context.getBiome()).thenReturn(new ChunkedDataCache<>(
ProceduralStream.of((x, z) -> biome, Interpolated.of(value -> 0D, value -> biome)), 11, -4, false));
when(context.getRegion()).thenReturn(new ChunkedDataCache<>(
ProceduralStream.of((x, z) -> region, Interpolated.of(value -> 0D, value -> region)), 11, -4, false));
when(context.getRock()).thenReturn(new ChunkedDataCache<>(
ProceduralStream.of((x, z) -> rock, Interpolated.of(value -> 0D, value -> rock)), 11, -4, false));
when(biome.generateLayers(eq(dimension), anyDouble(), anyDouble(), any(RNG.class),
anyInt(), anyInt(), eq(data), eq(complex))).thenReturn(new KList<>(List.of(grass)));
Hunk<PlatformBlockState> output = Hunk.newArrayHunk(1, 64, 1);
new IrisTerrainNormalActuator(engine).terrainSliver(11, -4, 0, output, context);
assertSame(painted, output.get(0, 60, 0));
assertSame(painted, output.get(0, 59, 0));
assertSame(rock, output.get(0, 58, 0));
material.setEnabled(false);
new IrisTerrainNormalActuator(engine).terrainSliver(11, -4, 0, output, context);
assertSame(rock, output.get(0, 60, 0));
Terrain3DColumn carved = mock(Terrain3DColumn.class);
when(carved.spanCount()).thenReturn(2);
when(carved.floor(1)).thenReturn(60);
when(carved.ceiling(1)).thenReturn(50);
when(carved.floor(0)).thenReturn(40);
when(carved.ceiling(0)).thenReturn(0);
Terrain3DColumn natural = mock(Terrain3DColumn.class);
when(natural.surfaceY(60)).thenReturn(60);
when(natural.surfaceY(40)).thenReturn(44);
when(complex.terrainColumn(eq(11), eq(-4), any(HydrologyColumnSample.class))).thenReturn(carved);
when(complex.naturalTerrainColumn(11, -4)).thenReturn(natural);
when(biome.generateCeilingLayers(eq(dimension), anyDouble(), anyDouble(), any(RNG.class),
anyInt(), anyInt(), eq(data), eq(complex))).thenReturn(new KList<>());
new IrisTerrainNormalActuator(engine).terrainSliver(11, -4, 0, output, context);
assertSame(grass, output.get(0, 60, 0));
assertSame(rock, output.get(0, 40, 0));
material.setEnabled(true);
new IrisTerrainNormalActuator(engine).terrainSliver(11, -4, 0, output, context);
assertSame(painted, output.get(0, 60, 0));
assertSame(painted, output.get(0, 40, 0));
assertSame(rock, output.get(0, 38, 0));
}
}
private static HydrologyColumnLayer channelLayer() {
return surfaceLayer(true, false);
}
@@ -302,7 +302,7 @@ public class DecoratorCoreTest {
Hunk<PlatformBlockState> output = Hunk.newArrayHunk(1, 2, 1);
int placed = DecoratorCore.placeFloatingStacked(
decorator, 0, 0, 0, 0, 0, 3, output, new RNG(1L), data);
decorator, 0, 0, 0, 0, 0, 3, output, new RNG(1L), data, null);
assertEquals(1, placed);
assertSame(decorant, output.get(0, 1, 0));
@@ -344,7 +344,7 @@ public class DecoratorCoreTest {
output.set(0, 2, 0, occupied);
DecoratorCore.placeFloatingSimple(
decorator, 0, 0, 0, 0, 0, 3, output, new RNG(1L), data);
decorator, 0, 0, 0, 0, 0, 3, output, new RNG(1L), data, null);
assertSame(air, output.get(0, 1, 0));
assertSame(occupied, output.get(0, 2, 0));
@@ -154,6 +154,40 @@ public class IrisShoreLineDecoratorTest {
assertSame(targetAir, output.get(0, FLUID_HEIGHT + 1, 0));
}
@Test
public void acceptedShoreCaneRequiresWaterBesideTheActualSubstrate() {
Fixture fixture = createFixture(false, false);
PlatformBlockState support = sturdyState();
PlatformBlockState air = airState();
PlatformBlockState water = mock(PlatformBlockState.class);
when(water.isWater()).thenReturn(true);
when(fixture.decorant.key()).thenReturn("minecraft:sugar_cane[age=0]");
when(fixture.decorant.canPlaceOnto(support)).thenReturn(true);
Hunk<PlatformBlockState> output = Hunk.newArrayHunk(3, FLUID_HEIGHT + 3, 3);
output.set(1, FLUID_HEIGHT, 1, support);
output.set(1, FLUID_HEIGHT + 1, 1, air);
fixture.shoreline.decorateAcceptedShore(1, 1, 1, 1, output, fixture.biome, FLUID_HEIGHT, output.getHeight());
assertSame(air, output.get(1, FLUID_HEIGHT + 1, 1));
output.set(0, FLUID_HEIGHT, 1, water);
fixture.shoreline.decorateAcceptedShore(1, 1, 1, 1, output, fixture.biome, FLUID_HEIGHT, output.getHeight());
assertSame(fixture.decorant, output.get(1, FLUID_HEIGHT + 1, 1));
}
@Test
public void forcedShoreCaneCannotUseStone() {
Fixture fixture = createFixture(true, false);
PlatformBlockState stone = sturdyState();
PlatformBlockState air = airState();
when(fixture.decorant.key()).thenReturn("minecraft:sugar_cane[age=0]");
when(fixture.decorant.canPlaceOnto(stone)).thenReturn(false);
Hunk<PlatformBlockState> output = output(stone, air);
fixture.shoreline.decorateAcceptedShore(0, 0, 0, 0, output, fixture.biome, FLUID_HEIGHT, output.getHeight());
assertSame(air, output.get(0, FLUID_HEIGHT + 1, 0));
}
@Test
public void unsupportedWaterloggedStackRestoresEveryOriginalBlock() {
Fixture fixture = createFixture(false, false);
@@ -0,0 +1,138 @@
package art.arcane.iris.engine.decorator;
import art.arcane.iris.engine.IrisComplex;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
import art.arcane.iris.engine.hydrology.HydrologyColumnSample;
import art.arcane.iris.engine.hydrology.HydrologyFeatureRef;
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
import art.arcane.iris.engine.mantle.EngineMantle;
import art.arcane.iris.engine.object.IrisDecorator;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.util.project.hunk.Hunk;
import art.arcane.volmlib.util.math.RNG;
import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertSame;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyDouble;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class IrisSugarCanePlacementTest {
@Test
public void floatingSingleRequiresSuitableSoilAndAdjacentWater() {
verifySupport(Placement.FLOATING_SINGLE);
}
@Test
public void floatingStackRequiresSuitableSoilAndAdjacentWater() {
verifySupport(Placement.FLOATING_STACK);
}
@Test
public void ceilingSingleRequiresSuitableSoilAndAdjacentWater() {
verifySupport(Placement.CEILING_SINGLE);
}
@Test
public void ceilingStackRequiresSuitableSoilAndAdjacentWater() {
verifySupport(Placement.CEILING_STACK);
}
@Test
public void floatingCaneUsesAcceptedWaterAcrossTheChunkBoundary() {
for (Placement placement : List.of(Placement.FLOATING_SINGLE, Placement.FLOATING_STACK)) {
Fixture fixture = new Fixture(1);
fixture.output.set(0, 3, 0, fixture.soil);
IrisComplex complex = mock(IrisComplex.class);
when(fixture.engine.getComplex()).thenReturn(complex);
HydrologyFeatureRef feature = new HydrologyFeatureRef(
11L, HydrologyFeatureType.RIFFLE, 12L, 13L, -17, 3, -16, 1, 0, false);
HydrologyColumnLayer layer = new HydrologyColumnLayer(feature, 1, 3, 3,
true, false, false, true, false, false, true, true, false,
"water", "river", "mouth", "shore", "bank", "cave");
when(complex.sampleHydrologyColumn(-17, -16)).thenReturn(new HydrologyColumnSample(
-17, -16, 6, 0, false, "parent", List.of(layer)));
when(complex.resolveHydrologyFluid("water", -17, -16)).thenReturn(fixture.water);
fixture.place(placement);
assertSame(fixture.cane, fixture.output.get(0, 4, 0));
}
}
private static void verifySupport(Placement placement) {
Fixture fixture = new Fixture(3);
fixture.output.set(1, 3, 1, fixture.stone);
fixture.output.set(0, 3, 1, fixture.water);
fixture.place(placement);
assertSame(fixture.air, fixture.output.get(1, 4, 1));
fixture.output.set(1, 3, 1, fixture.soil);
fixture.output.set(0, 3, 1, fixture.air);
fixture.place(placement);
assertSame(fixture.air, fixture.output.get(1, 4, 1));
fixture.output.set(0, 3, 1, fixture.water);
fixture.place(placement);
assertSame(fixture.cane, fixture.output.get(1, 4, 1));
}
private static PlatformBlockState state(String key) {
PlatformBlockState state = mock(PlatformBlockState.class);
when(state.key()).thenReturn(key);
return state;
}
private enum Placement {
FLOATING_SINGLE,
FLOATING_STACK,
CEILING_SINGLE,
CEILING_STACK
}
private static final class Fixture {
private final PlatformBlockState air = state("minecraft:air");
private final PlatformBlockState stone = state("minecraft:stone");
private final PlatformBlockState soil = state("minecraft:dirt");
private final PlatformBlockState water = state("minecraft:water");
private final PlatformBlockState cane = state("minecraft:sugar_cane[age=0]");
private final IrisDecorator decorator = mock(IrisDecorator.class);
private final Engine engine = mock(Engine.class);
private final EngineMantle mantle = mock(EngineMantle.class);
private final Hunk<PlatformBlockState> output;
private final int local;
private Fixture(int size) {
local = size / 2;
output = Hunk.newArrayHunk(size, 8, size);
when(air.isAir()).thenReturn(true);
when(water.isWater()).thenReturn(true);
when(cane.canPlaceOnto(soil)).thenReturn(true);
when(cane.canPlaceOnto(cane)).thenReturn(true);
when(mantle.getEngine()).thenReturn(engine);
when(decorator.isForcePlace()).thenReturn(true);
when(decorator.getHeight(any(), anyDouble(), anyDouble(), any())).thenReturn(2);
when(decorator.pickBlockData(any(), any(), anyDouble(), anyDouble())).thenReturn(cane);
when(decorator.pickBlockDataTop(any(), any(), anyDouble(), anyDouble())).thenReturn(cane);
output.fill(air);
}
private void place(Placement placement) {
int world = -16 + local;
switch (placement) {
case FLOATING_SINGLE -> DecoratorCore.placeFloatingSimple(decorator, local, local, world, world,
3, 4, output, new RNG(1L), null, mantle);
case FLOATING_STACK -> DecoratorCore.placeFloatingStacked(decorator, local, local, world, world,
3, 4, output, new RNG(1L), null, mantle);
case CEILING_SINGLE -> DecoratorCore.placeSingleAt(decorator, local, local, world, 4, world,
output, new RNG(1L), null, false, mantle);
case CEILING_STACK -> DecoratorCore.placeStackDown(decorator, local, local, world, world,
4, 0, output, new RNG(1L), null, 2, new DecoratorCore.PlaceOpts(), mantle);
}
}
}
}
@@ -0,0 +1,181 @@
package art.arcane.iris.engine.decorator;
import art.arcane.iris.engine.IrisComplex;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.SeedManager;
import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
import art.arcane.iris.engine.hydrology.HydrologyColumnSample;
import art.arcane.iris.engine.hydrology.HydrologyFeatureRef;
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisDecorator;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.util.project.hunk.Hunk;
import art.arcane.iris.util.project.stream.ProceduralStream;
import art.arcane.iris.util.project.stream.interpolation.Interpolated;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.RNG;
import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyDouble;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
public class IrisSugarCaneTest {
@Test
public void localCaneRequiresSuitableSubstrateAndWaterAtItsBase() {
Fixture fixture = new Fixture(3);
fixture.output.set(1, 3, 1, fixture.soil);
assertFalse(fixture.place(1, 1));
fixture.output.set(0, 4, 1, fixture.water);
assertFalse(fixture.place(1, 1));
fixture.output.set(0, 3, 1, fixture.water);
assertTrue(fixture.place(1, 1));
when(fixture.cane.canPlaceOnto(fixture.soil)).thenReturn(false);
assertFalse(fixture.place(1, 1));
verifyNoInteractions(fixture.engine);
}
@Test
public void lavaAndOrdinaryIceDoNotHydrateCane() {
Fixture fixture = new Fixture(3);
fixture.output.set(1, 3, 1, fixture.soil);
for (String key : new String[]{"minecraft:lava", "minecraft:ice", "minecraft:stone"}) {
fixture.output.set(0, 3, 1, state(key));
assertFalse(key, fixture.place(1, 1));
}
fixture.output.set(0, 3, 1, state("minecraft:frosted_ice[age=0]"));
assertTrue(fixture.place(1, 1));
PlatformBlockState waterlogged = state("minecraft:oak_stairs[waterlogged=true]");
when(waterlogged.isWaterLogged()).thenReturn(true);
fixture.output.set(0, 3, 1, waterlogged);
assertTrue(fixture.place(1, 1));
}
@Test
public void caneStackKeepsItsValidBaseAndCannotReplaceWater() {
Fixture fixture = new Fixture(3);
when(fixture.cane.canPlaceOnto(fixture.cane)).thenReturn(true);
fixture.output.set(1, 3, 1, fixture.cane);
assertFalse(fixture.place(1, 1));
fixture.output.set(1, 2, 1, fixture.soil);
fixture.output.set(0, 2, 1, fixture.water);
assertTrue(fixture.place(1, 1));
fixture.output.set(1, 4, 1, fixture.water);
assertFalse(fixture.place(1, 1));
}
@Test
public void crossChunkWaterUsesAcceptedHydrologyAtTheCorrectElevation() {
Fixture fixture = new Fixture(1);
fixture.plannedTerrain();
fixture.output.set(0, 3, 0, fixture.soil);
HydrologyColumnLayer layer = wetLayer(1, 3);
when(fixture.complex.sampleHydrologyColumn(-17, -16)).thenReturn(new HydrologyColumnSample(
-17, -16, 6, 0, false, "parent", List.of(layer)));
when(fixture.complex.resolveHydrologyFluid("water", -17, -16)).thenReturn(fixture.water);
assertTrue(fixture.place(0, 0));
PlatformBlockState lava = state("minecraft:lava");
when(fixture.complex.resolveHydrologyFluid("water", -17, -16)).thenReturn(lava);
assertFalse(fixture.place(0, 0));
when(fixture.complex.sampleHydrologyColumn(-17, -16)).thenReturn(new HydrologyColumnSample(
-17, -16, 6, 0, false, "parent", List.of(wetLayer(1, 2))));
assertFalse(fixture.place(0, 0));
}
@Test
public void localBlocksOverridePlannedWaterWithoutReadingAnotherChunk() {
Fixture fixture = new Fixture(3);
fixture.output.set(1, 3, 1, fixture.soil);
fixture.output.set(0, 3, 1, state("minecraft:stone"));
assertFalse(fixture.place(1, 1));
verifyNoInteractions(fixture.engine);
}
@Test
public void forcedSurfaceDecorationStillRejectsCaneOnStone() {
Fixture fixture = new Fixture(3);
IrisDecorator decorator = mock(IrisDecorator.class);
PlatformBlockState stone = state("minecraft:stone");
PlatformBlockState air = state("minecraft:air");
when(air.isAir()).thenReturn(true);
when(decorator.isForcePlace()).thenReturn(true);
when(decorator.pickBlockData(any(), any(), anyDouble(), anyDouble())).thenReturn(fixture.cane);
fixture.output.set(1, 3, 1, stone);
fixture.output.set(1, 4, 1, air);
fixture.output.set(0, 3, 1, fixture.water);
DecoratorCore.placeSurfaceSingle(decorator, 1, 1, -15, 3, -15,
fixture.output, new RNG(3L), null, false, false, null);
assertSame(air, fixture.output.get(1, 4, 1));
}
@Test
public void naturalCrossChunkWaterHonorsAuthoredSeaLayers() {
Fixture fixture = new Fixture(1);
fixture.plannedTerrain();
fixture.output.set(0, 3, 0, fixture.soil);
when(fixture.complex.getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 1D));
when(fixture.complex.getRiverWaterSurfaceStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 3D));
IrisBiome biome = mock(IrisBiome.class);
when(fixture.complex.getTrueBiomeStream()).thenReturn(
ProceduralStream.of((x, z) -> biome, Interpolated.of(value -> 0D, value -> biome)));
when(fixture.engine.getSeedManager()).thenReturn(mock(SeedManager.class));
PlatformBlockState ice = state("minecraft:ice");
when(biome.generateSeaLayers(anyDouble(), anyDouble(), any(), anyInt(), any()))
.thenReturn(new KList<>(List.of(ice)));
assertFalse(fixture.place(0, 0));
when(biome.generateSeaLayers(anyDouble(), anyDouble(), any(), anyInt(), any()))
.thenReturn(new KList<>(List.of(fixture.water)));
assertTrue(fixture.place(0, 0));
}
private static HydrologyColumnLayer wetLayer(int bed, int head) {
HydrologyFeatureRef feature = new HydrologyFeatureRef(
11L, HydrologyFeatureType.RIFFLE, 12L, 13L, -17, head, -16, 1, 0, false);
return new HydrologyColumnLayer(feature, bed, head, head,
true, false, false, true, false, false, true, true, false,
"water", "river", "mouth", "shore", "bank", "cave");
}
private static PlatformBlockState state(String key) {
PlatformBlockState state = mock(PlatformBlockState.class);
when(state.key()).thenReturn(key);
return state;
}
private static final class Fixture {
private final Engine engine = mock(Engine.class);
private final IrisComplex complex = mock(IrisComplex.class);
private final PlatformBlockState cane = state("minecraft:sugar_cane[age=0]");
private final PlatformBlockState soil = state("minecraft:dirt");
private final PlatformBlockState water = state("minecraft:water");
private final Hunk<PlatformBlockState> output;
private Fixture(int size) {
output = Hunk.newArrayHunk(size, 6, size);
when(cane.canPlaceOnto(soil)).thenReturn(true);
when(water.isWater()).thenReturn(true);
}
private void plannedTerrain() {
when(engine.getComplex()).thenReturn(complex);
when(complex.getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 3D));
when(complex.getRiverWaterSurfaceStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 0D));
}
private boolean place(int x, int z) {
return IrisSugarCane.canPlace(cane, output, x, 4, z, -16 + x, -16 + z, engine);
}
}
}
@@ -48,7 +48,7 @@ public class HydrologyCaveCourseFilterTest {
new HydrologyPoint(0, 20, 8),
new HydrologyPoint(8, 20, 8),
new HydrologyPoint(16, 20, 8)
)
), HydraulicChannelProfile.uniform(2, 2)
);
RiverCourse course = course(ridge);
HydrologyColumnLayer layer = layer(ridge, 8, 8);
@@ -105,7 +105,7 @@ public class HydrologyCaveCourseFilterTest {
new HydrologyPoint(0, 20, 8),
new HydrologyPoint(8, 20, 8),
new HydrologyPoint(16, 20, 8)
)
), HydraulicChannelProfile.uniform(2, 2)
);
ArrayList<HydrologyDiagnosticCandidate> diagnostics = new ArrayList<>();
@@ -148,7 +148,7 @@ public class HydrologyCaveCourseFilterTest {
new HydrologyPoint(0, 20, 8),
new HydrologyPoint(8, 20, 8),
new HydrologyPoint(16, 20, 8)
)
), HydraulicChannelProfile.uniform(2, 2)
);
RiverCourse course = course(ridge);
List<HydrologyColumnSample> columns = List.copyOf(
@@ -235,7 +235,7 @@ public class HydrologyCaveCourseFilterTest {
new HydrologyPoint(0, 20, 8),
new HydrologyPoint(8, 20, 8),
new HydrologyPoint(16, 20, 8)
)
), HydraulicChannelProfile.uniform(2, 2)
);
ArrayList<HydrologyDiagnosticCandidate> diagnostics = new ArrayList<>();
@@ -271,7 +271,7 @@ public class HydrologyCaveCourseFilterTest {
List.of(
new HydrologyPoint(0, 20, 8),
new HydrologyPoint(16, 20, 8)
)
), HydraulicChannelProfile.uniform(2, 2)
);
CavePosition exposedBoundary = new CavePosition(8, 25, 8);
AtomicInteger voxelLoads = new AtomicInteger();
@@ -310,7 +310,7 @@ public class HydrologyCaveCourseFilterTest {
List.of(
new HydrologyPoint(0, 20, 8),
new HydrologyPoint(8, 20, 8)
)
), HydraulicChannelProfile.uniform(2, 2)
);
RiverCourse course = course(ridge);
RiverFootprint footprint = footprint(terrain, layer(ridge, 0, 8));
@@ -347,7 +347,7 @@ public class HydrologyCaveCourseFilterTest {
2,
false,
false,
List.of(new HydrologyPoint(0, 20, 0))
List.of(new HydrologyPoint(0, 20, 0)), HydraulicChannelProfile.uniform(2, 2)
);
HydraulicSegment drop = new HydraulicSegment(
173L,
@@ -363,7 +363,7 @@ public class HydrologyCaveCourseFilterTest {
new HydrologyPoint(0, 20, 0),
new HydrologyPoint(6, 19, 0),
new HydrologyPoint(12, 18, 0)
)
), HydraulicChannelProfile.uniform(2, 2)
);
HydraulicSegment surfacePool = new HydraulicSegment(
174L,
@@ -375,7 +375,7 @@ public class HydrologyCaveCourseFilterTest {
2,
false,
false,
List.of(new HydrologyPoint(12, 18, 0))
List.of(new HydrologyPoint(12, 18, 0)), HydraulicChannelProfile.uniform(2, 2)
);
RiverCourse course = new RiverCourse(
171L,
@@ -418,7 +418,7 @@ public class HydrologyCaveCourseFilterTest {
List.of(
new HydrologyPoint(0, 20, 8),
new HydrologyPoint(8, 20, 8)
)
), HydraulicChannelProfile.uniform(2, 2)
);
RiverCourse course = undergroundCourse(tunnel);
RiverFootprint footprint = footprint(terrain, layer(tunnel, 8, 8));
@@ -467,7 +467,7 @@ public class HydrologyCaveCourseFilterTest {
List.of(
new HydrologyPoint(0, 20, 8),
new HydrologyPoint(8, 20, 8)
)
), HydraulicChannelProfile.uniform(2, 2)
);
RiverCourse course = new RiverCourse(
pool.courseId(),
@@ -516,7 +516,7 @@ public class HydrologyCaveCourseFilterTest {
List.of(
new HydrologyPoint(0, 20, 0),
new HydrologyPoint(12, 20, 0)
)
), HydraulicChannelProfile.uniform(2, 2)
);
RiverCourse course = course(ridge);
Random random = new Random(0x485944524f4c4f47L);
@@ -576,7 +576,7 @@ public class HydrologyCaveCourseFilterTest {
2,
false,
false,
List.of(new HydrologyPoint(8, 76, 8))
List.of(new HydrologyPoint(8, 76, 8)), HydraulicChannelProfile.uniform(4, 2)
);
RiverCourse course = course(surface);
RiverFootprint footprint = footprint(terrain, surfaceLayer(surface, 8, 8));
@@ -615,7 +615,7 @@ public class HydrologyCaveCourseFilterTest {
new HydrologyPoint(0, 20, 8),
new HydrologyPoint(8, 20, 8),
new HydrologyPoint(16, 20, 8)
)
), HydraulicChannelProfile.uniform(2, 2)
);
RiverFootprint footprint = footprint(terrain, layer(ridge, 8, 8));
ArrayList<HydrologyDiagnosticCandidate> diagnostics = new ArrayList<>();
@@ -778,7 +778,7 @@ public class HydrologyCaveCourseFilterTest {
2,
false,
false,
List.of(new HydrologyPoint(0, 20, 8), new HydrologyPoint(1, 20, 8))
List.of(new HydrologyPoint(0, 20, 8), new HydrologyPoint(1, 20, 8)), HydraulicChannelProfile.uniform(2, 2)
);
HydraulicSegment grotto = grotto(142L, 140L, HydrologyFeatureType.INLAND_GROTTO);
RiverCourse course = new RiverCourse(
@@ -831,7 +831,7 @@ public class HydrologyCaveCourseFilterTest {
2,
false,
false,
List.of(new HydrologyPoint(8, 20, 8), new HydrologyPoint(9, 20, 8))
List.of(new HydrologyPoint(8, 20, 8), new HydrologyPoint(9, 20, 8)), HydraulicChannelProfile.uniform(2, 2)
);
RiverCourse course = undergroundCourse(tunnel);
HydrologyColumnLayer oversized = caveLayer(
@@ -890,7 +890,7 @@ public class HydrologyCaveCourseFilterTest {
}
@Test
public void selfCarvedUndergroundCourseDoesNotMaterializeObservedCavesOrBoundaryGuards() {
public void selfCarvedUndergroundCourseChecksTerrainWithoutLoadingExistingCaves() {
HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0D, "parent");
HydraulicSegment tunnel = new HydraulicSegment(
161L,
@@ -902,15 +902,16 @@ public class HydrologyCaveCourseFilterTest {
2,
false,
false,
List.of(new HydrologyPoint(8, 20, 8), new HydrologyPoint(9, 20, 8))
List.of(new HydrologyPoint(8, 20, 8), new HydrologyPoint(9, 20, 8)), HydraulicChannelProfile.uniform(2, 2)
);
RiverCourse course = undergroundCourse(tunnel);
RiverFootprint footprint = footprint(terrain, layer(tunnel, 8, 8), layer(tunnel, 9, 8));
AtomicInteger observedLoads = new AtomicInteger();
AtomicInteger terrainChecks = new AtomicInteger();
CaveVoxelView observedView = new CaveVoxelView() {
@Override
public boolean isInWorld(CavePosition position) {
observedLoads.incrementAndGet();
terrainChecks.incrementAndGet();
return true;
}
@@ -928,7 +929,7 @@ public class HydrologyCaveCourseFilterTest {
@Override
public boolean isAboveTerrainSurface(CavePosition position) {
observedLoads.incrementAndGet();
terrainChecks.incrementAndGet();
return false;
}
};
@@ -952,6 +953,7 @@ public class HydrologyCaveCourseFilterTest {
.allMatch((CaveVoxelPrecondition precondition) -> precondition.voxel() == CaveVoxel.UNCONDITIONAL));
assertTrue(diagnostics.isEmpty());
assertEquals(0, observedLoads.get());
assertTrue(terrainChecks.get() > 0);
}
@Test
@@ -970,7 +972,7 @@ public class HydrologyCaveCourseFilterTest {
List.of(
new HydrologyPoint(0, 20, 8),
new HydrologyPoint(8, 20, 8)
)
), HydraulicChannelProfile.uniform(2, 2)
);
HydraulicSegment drop = new HydraulicSegment(
103L,
@@ -985,7 +987,7 @@ public class HydrologyCaveCourseFilterTest {
List.of(
new HydrologyPoint(8, 20, 8),
new HydrologyPoint(9, 18, 8)
)
), HydraulicChannelProfile.uniform(2, 2)
);
HydraulicSegment surface = new HydraulicSegment(
104L,
@@ -997,7 +999,7 @@ public class HydrologyCaveCourseFilterTest {
2,
false,
false,
List.of(new HydrologyPoint(9, 18, 8))
List.of(new HydrologyPoint(9, 18, 8)), HydraulicChannelProfile.uniform(2, 2)
);
RiverCourse course = new RiverCourse(
101L,
@@ -1045,7 +1047,7 @@ public class HydrologyCaveCourseFilterTest {
List.of(
new HydrologyPoint(8, 20, 8),
new HydrologyPoint(24, 20, 8)
)
), HydraulicChannelProfile.uniform(2, 2)
);
HydrologyColumnLayer local = layer(ridge, 15, 8);
HydrologyColumnLayer remote = layer(ridge, 16, 8);
@@ -1095,7 +1097,7 @@ public class HydrologyCaveCourseFilterTest {
2,
false,
false,
List.of(new HydrologyPoint(100, 63, 0), new HydrologyPoint(112, 63, 0))
List.of(new HydrologyPoint(100, 63, 0), new HydrologyPoint(112, 63, 0)), HydraulicChannelProfile.uniform(4, 2)
);
RiverCourse seaCave = new RiverCourse(
920L,
@@ -1258,7 +1260,7 @@ public class HydrologyCaveCourseFilterTest {
2,
false,
false,
List.of(new HydrologyPoint(8, 20, 8))
List.of(new HydrologyPoint(8, 20, 8)), HydraulicChannelProfile.uniform(2, 2)
);
}
@@ -0,0 +1,69 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.surface.SurfaceBounds;
import org.junit.Test;
import java.util.List;
import java.util.OptionalLong;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class HydrologyCoastalDropReservoirTest {
private static final HydrologyPlannerSettings SETTINGS = HydrologyPlannerSettings.defaults();
@Test
public void aDeclaredCoastalGrottoReceivesTheFallThroughUnownedNaturalOceanWater() {
HydrologyTerrainSampler terrain = terrain(54);
RiverCourse course = course(SETTINGS.seaLevel(), 1, true);
HydrologyGeometrySampler geometry = HydrologyGeometrySampler.deterministic(terrain);
HydrologySurfaceDropRaster full = HydrologySurfaceDropRaster.compile(SETTINGS, terrain, geometry, course);
assertTrue(full.connects(0, 0, 92));
assertTrue(full.connects(0, 0, SETTINGS.seaLevel()));
assertFalse(full.connects(1, 0, SETTINGS.seaLevel()));
assertTrue(full.containsRequiredFluid(course, null));
for (HydrologyColumnSample column : full.columns()) {
assertFalse(column.ocean());
}
SurfaceBounds receivingWindow = new SurfaceBounds(1, -2, 2, 2);
HydrologySurfaceDropRaster bounded = HydrologySurfaceDropRaster.compile(
SETTINGS, terrain, geometry, course, receivingWindow);
assertTrue(bounded.columns().isEmpty());
assertTrue(bounded.containsRequiredFluid(course, receivingWindow));
}
@Test
public void anOceanBiomeOrUnrelatedGrottoCannotReplaceTheRequiredFluidInterval() {
HydrologyTerrainSampler dryOceanBiome = terrain(SETTINGS.seaLevel());
RiverCourse exact = course(SETTINGS.seaLevel(), 1, true);
assertFalse(HydrologySurfaceDropRaster.compile(SETTINGS, dryOceanBiome,
HydrologyGeometrySampler.deterministic(dryOceanBiome), exact).containsRequiredFluid(exact, null));
HydrologyTerrainSampler ocean = terrain(54);
for (RiverCourse invalid : List.of(course(SETTINGS.seaLevel(), 1, false),
course(SETTINGS.seaLevel(), 2, true), course(SETTINGS.seaLevel() + 1, 1, true))) {
HydrologySurfaceDropRaster raster = HydrologySurfaceDropRaster.compile(
SETTINGS, ocean, HydrologyGeometrySampler.deterministic(ocean), invalid);
assertFalse(raster.containsRequiredFluid(invalid, null));
}
}
private static HydrologyTerrainSampler terrain(int oceanFloor) {
return (x, z) -> x >= 1 ? HydrologyTerrainSample.ocean(oceanFloor, "ocean")
: HydrologyTerrainSample.openLand(92, 0D, "land");
}
private static RiverCourse course(int head, int grottoEnd, boolean grotto) {
HydraulicSegment falling = new HydraulicSegment(2L, 1L, HydrologyFeatureType.WATERFALL,
92, head, 4, 3, true, true,
List.of(new HydrologyPoint(0, 92, 0), new HydrologyPoint(1, head, 0)),
HydraulicChannelProfile.uniform(4, 3));
HydraulicSegment chamber = new HydraulicSegment(5L, 1L, HydrologyFeatureType.COASTAL_GROTTO,
head, head, 4, 3, false, false,
List.of(new HydrologyPoint(0, head, 0), new HydrologyPoint(grottoEnd, head, 0)),
HydraulicChannelProfile.uniform(4, 3));
return new RiverCourse(1L, RiverCourseType.SURFACE, OptionalLong.of(3L), OptionalLong.of(4L),
"default", 1, List.of(), grotto ? List.of(falling, chamber) : List.of(falling));
}
}
@@ -8,6 +8,17 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class HydrologyCrossTileSurfaceAdmissionTest {
@Test
public void separatedTributariesCanJoinTheSameRegionalOutlet() {
HydrologyCrossTileSurfaceAdmission.Claim first = claim(10L, 100L,
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 63, 0)), false, 128);
HydrologyCrossTileSurfaceAdmission.Claim second = claim(20L, 100L,
List.of(new HydrologyPoint(0, 70, 512), new HydrologyPoint(256, 63, 512)), false, 128);
assertTrue(HydrologyCrossTileSurfaceAdmission.admit(List.of(first), List.of(
new HydrologyCrossTileSurfaceAdmission.RankedClaim(new HydrologyTileKey(0, 1), 0, second)))
.rejections().isEmpty());
}
@Test
public void rejectsClusteredIndependentMouths() {
HydrologyCrossTileSurfaceAdmission.Claim current = claim(
@@ -80,7 +80,7 @@ public class HydrologyFootprintCompilerTest {
? 63 : 70;
HydraulicSegment segment = new HydraulicSegment(
9001L, 9000L, type, head, head, 6, 3, false, false,
List.of(new HydrologyPoint(-12, head, -3), new HydrologyPoint(32, head, 5)));
List.of(new HydrologyPoint(-12, head, -3), new HydrologyPoint(32, head, 5)), HydraulicChannelProfile.uniform(6, 3));
RiverCourse course = course(9000L,
type.isSurface() ? RiverCourseType.SURFACE : RiverCourseType.UNDERGROUND, segment);
SlopeCountingSampler original = new SlopeCountingSampler(false);
@@ -124,7 +124,7 @@ public class HydrologyFootprintCompilerTest {
4,
false,
false,
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(24, 70, 0))
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(24, 70, 0)), HydraulicChannelProfile.uniform(10, 4)
);
HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(
z < 0 ? 54 : 82,
@@ -158,7 +158,7 @@ public class HydrologyFootprintCompilerTest {
2,
false,
false,
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(8, 70, 0))
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(8, 70, 0)), HydraulicChannelProfile.uniform(4, 2)
);
RiverCourse course = new RiverCourse(
10L,
@@ -206,7 +206,7 @@ public class HydrologyFootprintCompilerTest {
5,
false,
false,
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(96, 70, 0))
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(96, 70, 0)), HydraulicChannelProfile.uniform(12, 5)
);
HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(82, 0D, "parent");
RiverFootprint footprint = new HydrologyFootprintCompiler(
@@ -269,7 +269,7 @@ public class HydrologyFootprintCompilerTest {
3,
false,
false,
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(24, 70, 0))
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(24, 70, 0)), HydraulicChannelProfile.uniform(8, 3)
);
RiverCourse course = course(14L, RiverCourseType.SURFACE, segment);
HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(70, 0D, "parent");
@@ -328,7 +328,7 @@ public class HydrologyFootprintCompilerTest {
8,
false,
false,
List.of(new HydrologyPoint(0, -20, 0))
List.of(new HydrologyPoint(0, -20, 0)), HydraulicChannelProfile.uniform(40, 8)
);
RiverCourse course = new RiverCourse(
14L,
@@ -382,7 +382,7 @@ public class HydrologyFootprintCompilerTest {
2,
false,
false,
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(16, 70, 0))
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(16, 70, 0)), HydraulicChannelProfile.uniform(4, 2)
);
RiverCourse course = course(110L, RiverCourseType.SURFACE, segment);
HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 1D, "parent");
@@ -425,7 +425,7 @@ public class HydrologyFootprintCompilerTest {
2,
false,
false,
List.of(new HydrologyPoint(0, 70, 0))
List.of(new HydrologyPoint(0, 70, 0)), HydraulicChannelProfile.uniform(4, 2)
);
HydraulicSegment undergroundSegment = new HydraulicSegment(
31L,
@@ -437,7 +437,7 @@ public class HydrologyFootprintCompilerTest {
2,
false,
false,
List.of(new HydrologyPoint(20, 50, 0))
List.of(new HydrologyPoint(20, 50, 0)), HydraulicChannelProfile.uniform(4, 2)
);
RiverCourse surface = course(20L, RiverCourseType.SURFACE, surfaceSegment);
RiverCourse underground = course(30L, RiverCourseType.UNDERGROUND, undergroundSegment);
@@ -479,7 +479,7 @@ public class HydrologyFootprintCompilerTest {
2,
false,
false,
List.of(new HydrologyPoint(0, 70, 0))
List.of(new HydrologyPoint(0, 70, 0)), HydraulicChannelProfile.uniform(4, 2)
);
RiverCourse course = course(40L, RiverCourseType.SURFACE, segment);
HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults();
@@ -531,7 +531,7 @@ public class HydrologyFootprintCompilerTest {
10,
false,
false,
List.of(new HydrologyPoint(0, 70, 0))
List.of(new HydrologyPoint(0, 70, 0)), HydraulicChannelProfile.uniform(12, 10)
);
RiverCourse course = course(42L, RiverCourseType.SURFACE, segment);
HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(80, 1D, "parent");
@@ -584,7 +584,7 @@ public class HydrologyFootprintCompilerTest {
3,
false,
false,
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(96, 70, 0))
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(96, 70, 0)), HydraulicChannelProfile.uniform(12, 3)
);
RiverCourse course = course(46L, RiverCourseType.SURFACE, segment);
HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(84, 1D, "parent");
@@ -613,7 +613,7 @@ public class HydrologyFootprintCompilerTest {
2,
true,
true,
List.of(new HydrologyPoint(0, 80, 0), new HydrologyPoint(1, 68, 0))
List.of(new HydrologyPoint(0, 80, 0), new HydrologyPoint(1, 68, 0)), HydraulicChannelProfile.uniform(4, 2)
);
RiverCourse course = course(50L, RiverCourseType.SURFACE, segment);
HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults();
@@ -692,7 +692,7 @@ public class HydrologyFootprintCompilerTest {
new HydrologyPoint(0, 48, 0),
new HydrologyPoint(12, 48, 5),
new HydrologyPoint(24, 48, 0)
)
), HydraulicChannelProfile.uniform(12, 2)
);
RiverCourse course = course(60L, RiverCourseType.UNDERGROUND, segment);
HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(84, 1D, "parent");
@@ -749,7 +749,7 @@ public class HydrologyFootprintCompilerTest {
2,
false,
false,
List.of(new HydrologyPoint(0, 70, 0))
List.of(new HydrologyPoint(0, 70, 0)), HydraulicChannelProfile.uniform(4, 2)
)
);
RiverCourse underground = course(
@@ -765,7 +765,7 @@ public class HydrologyFootprintCompilerTest {
2,
false,
false,
List.of(new HydrologyPoint(96, 40, 0))
List.of(new HydrologyPoint(96, 40, 0)), HydraulicChannelProfile.uniform(4, 2)
)
);
HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults();
@@ -823,7 +823,7 @@ public class HydrologyFootprintCompilerTest {
2,
false,
false,
List.of(new HydrologyPoint(0, 70, 0))
List.of(new HydrologyPoint(0, 70, 0)), HydraulicChannelProfile.uniform(4, 2)
);
HydraulicSegment waterfall = new HydraulicSegment(
221L,
@@ -835,7 +835,7 @@ public class HydrologyFootprintCompilerTest {
2,
true,
true,
List.of(new HydrologyPoint(0, 74, 0))
List.of(new HydrologyPoint(0, 74, 0)), HydraulicChannelProfile.uniform(4, 2)
);
HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 1D, "parent");
RiverFootprint footprint = new HydrologyFootprintCompiler(
@@ -865,7 +865,7 @@ public class HydrologyFootprintCompilerTest {
2,
false,
false,
List.of(new HydrologyPoint(-2, 70, 0), new HydrologyPoint(0, 70, 0))
List.of(new HydrologyPoint(-2, 70, 0), new HydrologyPoint(0, 70, 0)), HydraulicChannelProfile.uniform(4, 2)
);
HydraulicSegment lower = new HydraulicSegment(
232L,
@@ -877,7 +877,7 @@ public class HydrologyFootprintCompilerTest {
2,
false,
true,
List.of(new HydrologyPoint(0, 68, 0), new HydrologyPoint(2, 68, 0))
List.of(new HydrologyPoint(0, 68, 0), new HydrologyPoint(2, 68, 0)), HydraulicChannelProfile.uniform(4, 2)
);
HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 1D, "parent");
RiverFootprint footprint = new HydrologyFootprintCompiler(
@@ -920,7 +920,7 @@ public class HydrologyFootprintCompilerTest {
2,
false,
false,
List.of(new HydrologyPoint(0, 70, 0))
List.of(new HydrologyPoint(0, 70, 0)), HydraulicChannelProfile.uniform(4, 2)
);
RiverCourse course = course(60L, RiverCourseType.SURFACE, segment);
HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults();
@@ -974,7 +974,7 @@ public class HydrologyFootprintCompilerTest {
2,
false,
false,
List.of(new HydrologyPoint(0, 63, 0))
List.of(new HydrologyPoint(0, 63, 0)), HydraulicChannelProfile.uniform(6, 2)
);
RiverCourse course = course(70L, RiverCourseType.SURFACE, segment);
@@ -1000,7 +1000,7 @@ public class HydrologyFootprintCompilerTest {
}
@Test
public void naturallySubmergedLandAtAMouthPublishesOnlyANonOwningApron() {
public void mouthCutsDrySeaLevelLandIntoAnOwnedChannel() {
HydraulicSegment mouth = new HydraulicSegment(
81L,
80L,
@@ -1011,16 +1011,16 @@ public class HydrologyFootprintCompilerTest {
2,
false,
false,
List.of(new HydrologyPoint(0, 63, 0))
List.of(new HydrologyPoint(0, 63, 0)), HydraulicChannelProfile.uniform(6, 2)
);
HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(63, 0D, "shore");
RiverFootprint footprint = compiler(terrain).compile(List.of(course(80L, RiverCourseType.SURFACE, mouth)));
HydrologyColumnSample sample = footprint.sample(0, 0).orElseThrow();
assertEquals(63, sample.terrainHeight());
assertTrue(sample.layers().stream().allMatch(HydrologyColumnLayer::oceanApron));
assertTrue(sample.layers().stream().noneMatch(HydrologyColumnLayer::terrainOwned));
assertTrue(sample.layers().stream().noneMatch(HydrologyColumnLayer::fluidOwned));
assertEquals(61, sample.terrainHeight());
assertTrue(sample.layers().stream().noneMatch(HydrologyColumnLayer::oceanApron));
assertTrue(sample.layers().stream().allMatch(HydrologyColumnLayer::terrainOwned));
assertTrue(sample.layers().stream().allMatch(HydrologyColumnLayer::fluidOwned));
}
@Test
@@ -1035,7 +1035,7 @@ public class HydrologyFootprintCompilerTest {
2,
false,
false,
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(8, 70, 0))
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(8, 70, 0)), HydraulicChannelProfile.uniform(4, 2)
);
HydraulicSegment cave = new HydraulicSegment(
303L,
@@ -1047,7 +1047,7 @@ public class HydrologyFootprintCompilerTest {
2,
false,
false,
List.of(new HydrologyPoint(8, 66, 0), new HydrologyPoint(12, 66, 0))
List.of(new HydrologyPoint(8, 66, 0), new HydrologyPoint(12, 66, 0)), HydraulicChannelProfile.uniform(4, 2)
);
RiverCourse course = new RiverCourse(
301L,
@@ -1112,7 +1112,7 @@ public class HydrologyFootprintCompilerTest {
3,
false,
false,
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(8, 70, 0))
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(8, 70, 0)), HydraulicChannelProfile.uniform(6, 3)
);
HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(
x < 4 ? 90 : 77,
@@ -1159,7 +1159,7 @@ public class HydrologyFootprintCompilerTest {
2,
false,
false,
List.of(new HydrologyPoint(-8, 70, 0), new HydrologyPoint(8, 70, 0))
List.of(new HydrologyPoint(-8, 70, 0), new HydrologyPoint(8, 70, 0)), HydraulicChannelProfile.uniform(6, 2)
),
new HydraulicSegment(
403L,
@@ -1171,7 +1171,7 @@ public class HydrologyFootprintCompilerTest {
2,
false,
false,
List.of(new HydrologyPoint(8, 60, 0), new HydrologyPoint(14, 60, 0))
List.of(new HydrologyPoint(8, 60, 0), new HydrologyPoint(14, 60, 0)), HydraulicChannelProfile.uniform(4, 2)
),
new HydraulicSegment(
404L,
@@ -1183,7 +1183,7 @@ public class HydrologyFootprintCompilerTest {
2,
false,
false,
List.of(new HydrologyPoint(14, 63, 0), new HydrologyPoint(20, 63, 0))
List.of(new HydrologyPoint(14, 63, 0), new HydrologyPoint(20, 63, 0)), HydraulicChannelProfile.uniform(6, 2)
)
)
);
@@ -1200,7 +1200,7 @@ public class HydrologyFootprintCompilerTest {
2,
false,
false,
List.of(new HydrologyPoint(0, 74, -4), new HydrologyPoint(12, 74, -4))
List.of(new HydrologyPoint(0, 74, -4), new HydrologyPoint(12, 74, -4)), HydraulicChannelProfile.uniform(4, 2)
)
);
RiverCourse underground = new RiverCourse(
@@ -1222,7 +1222,7 @@ public class HydrologyFootprintCompilerTest {
2,
false,
false,
List.of(new HydrologyPoint(4, 45, 2), new HydrologyPoint(16, 45, 2))
List.of(new HydrologyPoint(4, 45, 2), new HydrologyPoint(16, 45, 2)), HydraulicChannelProfile.uniform(4, 2)
),
new HydraulicSegment(
423L,
@@ -1234,7 +1234,7 @@ public class HydrologyFootprintCompilerTest {
2,
false,
false,
List.of(new HydrologyPoint(16, 63, 2), new HydrologyPoint(20, 63, 2))
List.of(new HydrologyPoint(16, 63, 2), new HydrologyPoint(20, 63, 2)), HydraulicChannelProfile.uniform(4, 2)
)
)
);
@@ -1256,9 +1256,9 @@ public class HydrologyFootprintCompilerTest {
).compile(courses);
for (HydrologyColumnSample sample : full.columns().values()) {
assertEquals(sample.primarySurfaceLayer().isPresent(),
assertEquals(sample.primarySurfaceLayer().filter(HydrologyColumnLayer::terrainOwned).isPresent(),
validation.plannedSurface().ownsTerrain(sample.x(), sample.z()));
assertEquals(sample.primarySurfaceLayer().isPresent(),
assertEquals(sample.primarySurfaceLayer().filter(HydrologyColumnLayer::terrainOwned).isPresent(),
validation.withMaterializedSurface(full).plannedSurface().ownsTerrain(sample.x(), sample.z()));
assertEquals(
"planned surface at " + sample.x() + "," + sample.z(),
@@ -1319,7 +1319,7 @@ public class HydrologyFootprintCompilerTest {
new HydrologyPoint(0, 70, 0),
new HydrologyPoint(32, 70, 0),
new HydrologyPoint(64, 70, 0)
)
), HydraulicChannelProfile.uniform(8, 3)
);
HydraulicSegment first = new HydraulicSegment(
502L,
@@ -1331,7 +1331,7 @@ public class HydrologyFootprintCompilerTest {
3,
false,
false,
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(32, 70, 0))
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(32, 70, 0)), HydraulicChannelProfile.uniform(8, 3)
);
HydraulicSegment second = new HydraulicSegment(
503L,
@@ -1343,7 +1343,7 @@ public class HydrologyFootprintCompilerTest {
3,
false,
false,
List.of(new HydrologyPoint(32, 70, 0), new HydrologyPoint(64, 70, 0))
List.of(new HydrologyPoint(32, 70, 0), new HydrologyPoint(64, 70, 0)), HydraulicChannelProfile.uniform(8, 3)
);
HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(82, 0D, "parent");
RiverFootprint continuousFootprint = compiler(terrain).compile(List.of(new RiverCourse(
@@ -1398,7 +1398,7 @@ public class HydrologyFootprintCompilerTest {
3,
false,
false,
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(32, 70, 0))
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(32, 70, 0)), HydraulicChannelProfile.uniform(8, 3)
);
HydraulicSegment second = new HydraulicSegment(
512L,
@@ -1410,7 +1410,7 @@ public class HydrologyFootprintCompilerTest {
3,
false,
false,
List.of(new HydrologyPoint(32, 70, 0), new HydrologyPoint(32, 70, 32))
List.of(new HydrologyPoint(32, 70, 0), new HydrologyPoint(32, 70, 32)), HydraulicChannelProfile.uniform(8, 3)
);
HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(82, 0D, "parent");
RiverFootprint footprint = compiler(terrain).compile(List.of(new RiverCourse(
@@ -1442,10 +1442,11 @@ public class HydrologyFootprintCompilerTest {
70,
70,
8,
3,
4,
false,
false,
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(64, 70, 0))
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(24, 70, 0), new HydrologyPoint(64, 70, 0)),
new HydraulicChannelProfile(new double[]{8D, 8D, 8D}, new double[]{4D, 3D, 3D})
);
HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(82, 0D, "parent");
RiverCourse course = course(520L, RiverCourseType.SURFACE, segment);
@@ -1481,7 +1482,7 @@ public class HydrologyFootprintCompilerTest {
3,
false,
false,
List.of(new HydrologyPoint(0, 74, 0), new HydrologyPoint(24, 70, 0))
List.of(new HydrologyPoint(0, 74, 0), new HydrologyPoint(24, 70, 0)), HydraulicChannelProfile.uniform(8, 3)
);
HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(82, 0D, "parent");
RiverFootprint footprint = compiler(terrain).compile(List.of(course(527L, RiverCourseType.SURFACE, cascade)));
@@ -1507,7 +1508,7 @@ public class HydrologyFootprintCompilerTest {
}
@Test
public void aOneBlockLowBankIsLippedAndADeeperOneIsReportedWithoutRaising() {
public void lowBanksKeepTheirNaturalHeightAndReportUncontainedWater() {
HydraulicSegment channel = new HydraulicSegment(
529L,
528L,
@@ -1518,7 +1519,7 @@ public class HydrologyFootprintCompilerTest {
3,
false,
false,
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(24, 70, 0))
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(24, 70, 0)), HydraulicChannelProfile.uniform(8, 3)
);
RiverCourse course = course(528L, RiverCourseType.SURFACE, channel);
HydrologyTerrainSampler highTerrain = (int x, int z) ->
@@ -1547,12 +1548,12 @@ public class HydrologyFootprintCompilerTest {
HydrologyColumnSample deepBank = deepBankFootprint.sample(12, lowBankZ).orElseThrow();
assertEquals(0, unsupportedBankCells(highFootprint, 528L));
// One block short of the water: a one-block lip holds it and nothing is reported.
// A low bank remains below the water and is reported without raising terrain.
assertEquals(69, lowBank.naturalHeight());
assertEquals(70, lowBank.terrainHeight());
assertEquals(0, unsupportedBankCells(lowBankFootprint, 528L));
assertEquals(69, lowBank.terrainHeight());
assertTrue(unsupportedBankCells(lowBankFootprint, 528L) > 0);
for (HydrologyColumnSample sample : lowBankFootprint.columns().values()) {
assertTrue(sample.terrainHeight() <= sample.naturalHeight() + 1);
assertTrue(sample.terrainHeight() <= sample.naturalHeight());
}
// Two blocks short: the bank keeps its natural height and the edge is reported.
assertEquals(68, deepBank.naturalHeight());
@@ -1572,7 +1573,7 @@ public class HydrologyFootprintCompilerTest {
3,
false,
false,
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(24, 70, 0))
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(24, 70, 0)), HydraulicChannelProfile.uniform(8, 3)
);
RiverCourse course = course(528L, RiverCourseType.SURFACE, channel);
HydrologyFootprintCompiler compiler = compiler((int x, int z) ->
@@ -1605,7 +1606,7 @@ public class HydrologyFootprintCompilerTest {
new HydrologyPoint(2, 72, 0),
new HydrologyPoint(3, 71, 0),
new HydrologyPoint(4, 70, 0)
)
), HydraulicChannelProfile.uniform(8, 3)
);
RiverCourse course = course(529L, RiverCourseType.SURFACE, cascade);
HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.ocean(82, "ocean");
@@ -1626,7 +1627,7 @@ public class HydrologyFootprintCompilerTest {
3,
false,
false,
List.of(new HydrologyPoint(0, 63, 0), new HydrologyPoint(32, 63, 0))
List.of(new HydrologyPoint(0, 63, 0), new HydrologyPoint(32, 63, 0)), HydraulicChannelProfile.uniform(8, 3)
);
HydraulicSegment mouth = new HydraulicSegment(
532L,
@@ -1638,7 +1639,7 @@ public class HydrologyFootprintCompilerTest {
3,
false,
false,
List.of(new HydrologyPoint(32, 63, 0), new HydrologyPoint(64, 63, 0))
List.of(new HydrologyPoint(32, 63, 0), new HydrologyPoint(64, 63, 0)), HydraulicChannelProfile.uniform(8, 3)
);
HydrologyTerrainSampler terrain = (int x, int z) -> x >= 64
? HydrologyTerrainSample.ocean(48, "ocean")
@@ -1696,7 +1697,7 @@ public class HydrologyFootprintCompilerTest {
3,
false,
false,
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(48, 70, 0))
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(48, 70, 0)), HydraulicChannelProfile.uniform(8, 3)
);
HydraulicSegment sinkhole = new HydraulicSegment(
542L,
@@ -1708,7 +1709,7 @@ public class HydrologyFootprintCompilerTest {
2,
false,
true,
List.of(new HydrologyPoint(48, 70, 0), new HydrologyPoint(52, 60, 0))
List.of(new HydrologyPoint(48, 70, 0), new HydrologyPoint(52, 60, 0)), HydraulicChannelProfile.uniform(4, 2)
);
HydraulicSegment grotto = new HydraulicSegment(
543L,
@@ -1720,7 +1721,7 @@ public class HydrologyFootprintCompilerTest {
4,
false,
true,
List.of(new HydrologyPoint(52, 60, 0))
List.of(new HydrologyPoint(52, 60, 0)), HydraulicChannelProfile.uniform(8, 4)
);
HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(82, 0D, "parent");
RiverFootprint footprint = compiler(terrain).compile(List.of(new RiverCourse(
@@ -1761,7 +1762,7 @@ public class HydrologyFootprintCompilerTest {
2,
false,
false,
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(64, 70, 0))
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(64, 70, 0)), HydraulicChannelProfile.uniform(4, 2)
);
HydraulicSegment receiver = new HydraulicSegment(
552L,
@@ -1773,7 +1774,7 @@ public class HydrologyFootprintCompilerTest {
2,
false,
true,
List.of(new HydrologyPoint(64, 70, 0), new HydrologyPoint(80, 69, 0))
List.of(new HydrologyPoint(64, 70, 0), new HydrologyPoint(80, 69, 0)), HydraulicChannelProfile.uniform(4, 2)
);
HydraulicSegment outflow = new HydraulicSegment(
553L,
@@ -1785,7 +1786,7 @@ public class HydrologyFootprintCompilerTest {
2,
false,
false,
List.of(new HydrologyPoint(80, 69, 0), new HydrologyPoint(160, 69, 0))
List.of(new HydrologyPoint(80, 69, 0), new HydrologyPoint(160, 69, 0)), HydraulicChannelProfile.uniform(4, 2)
);
HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(82, 0D, "parent");
RiverFootprint footprint = compiler(terrain).compile(List.of(new RiverCourse(
@@ -1876,7 +1877,7 @@ public class HydrologyFootprintCompilerTest {
3,
false,
false,
List.of(new HydrologyPoint(96, 63, 0), new HydrologyPoint(112, 63, 0))
List.of(new HydrologyPoint(96, 63, 0), new HydrologyPoint(112, 63, 0)), HydraulicChannelProfile.uniform(8, 3)
);
RiverFootprint mouthFootprint = new HydrologyFootprintCompiler(
seaCaveSettings(8, 12),
@@ -1966,7 +1967,7 @@ public class HydrologyFootprintCompilerTest {
2,
false,
false,
List.of(new HydrologyPoint(100, 63, 0), new HydrologyPoint(112, 63, 0))
List.of(new HydrologyPoint(100, 63, 0), new HydrologyPoint(112, 63, 0)), HydraulicChannelProfile.uniform(4, 2)
);
return new RiverCourse(
id,
@@ -1992,10 +1993,10 @@ public class HydrologyFootprintCompilerTest {
12,
true,
false,
List.of(new HydrologyPoint(0, 90, 0), new HydrologyPoint(32, 70, 0))
List.of(new HydrologyPoint(0, 90, 0), new HydrologyPoint(32, 70, 0)), HydraulicChannelProfile.uniform(16, 12)
);
RiverCourse course = course(120L, RiverCourseType.SURFACE, segment);
HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(96, 1D, "parent");
HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(70, 1D, "parent");
RiverFootprint defaults = compile(HydrologyPlannerSettings.defaults(), terrain, course);
RiverFootprint explicit = compile(withFlow(new HydrologyPlannerSettings.Flow(0.65D, 2, 2D, 1)), terrain, course);
@@ -2071,7 +2072,7 @@ public class HydrologyFootprintCompilerTest {
2,
false,
false,
List.of(new HydrologyPoint(0, 48, 0))
List.of(new HydrologyPoint(0, 48, 0)), HydraulicChannelProfile.uniform(4, 2)
);
RiverCourse course = course(140L, RiverCourseType.UNDERGROUND, chamber);
HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(84, 1D, "parent");
@@ -2137,7 +2138,7 @@ public class HydrologyFootprintCompilerTest {
new HydrologyPoint(0, 48, 0),
new HydrologyPoint(bend / 2, 48, 5),
new HydrologyPoint(bend, 48, 0)
)
), HydraulicChannelProfile.uniform(12, 2)
);
return new RiverCourse(
id,
@@ -67,7 +67,7 @@ public class HydrologyModelTest {
2,
false,
true,
centerline
centerline, HydraulicChannelProfile.uniform(4, 2)
);
assertEquals(6, graded.drop());
assertFalse(graded.fallingFluid());
@@ -81,7 +81,7 @@ public class HydrologyModelTest {
2,
false,
false,
centerline
centerline, HydraulicChannelProfile.uniform(4, 2)
);
assertFalse(intermediate.receivingPool());
HydraulicSegment accepted = new HydraulicSegment(
@@ -94,7 +94,7 @@ public class HydrologyModelTest {
2,
true,
true,
centerline
centerline, HydraulicChannelProfile.uniform(4, 2)
);
assertEquals(6, accepted.drop());
}
@@ -111,7 +111,7 @@ public class HydrologyModelTest {
2,
false,
false,
List.of(new HydrologyPoint(0, 80, 0))
List.of(new HydrologyPoint(0, 80, 0)), HydraulicChannelProfile.uniform(4, 2)
);
HydraulicSegment sinkhole = new HydraulicSegment(
2L,
@@ -123,7 +123,7 @@ public class HydrologyModelTest {
2,
true,
true,
List.of(new HydrologyPoint(0, 80, 0), new HydrologyPoint(1, 74, 0))
List.of(new HydrologyPoint(0, 80, 0), new HydrologyPoint(1, 74, 0)), HydraulicChannelProfile.uniform(4, 2)
);
HydraulicSegment grotto = new HydraulicSegment(
3L,
@@ -135,7 +135,7 @@ public class HydrologyModelTest {
2,
false,
false,
List.of(new HydrologyPoint(1, 74, 0))
List.of(new HydrologyPoint(1, 74, 0)), HydraulicChannelProfile.uniform(4, 2)
);
RiverCourse accepted = new RiverCourse(
@@ -160,7 +160,7 @@ public class HydrologyModelTest {
2,
false,
false,
List.of(new HydrologyPoint(0, 80, 0))
List.of(new HydrologyPoint(0, 80, 0)), HydraulicChannelProfile.uniform(4, 2)
);
RiverCourse containedApproach = new RiverCourse(
9L,
@@ -197,7 +197,7 @@ public class HydrologyModelTest {
2,
false,
false,
List.of(new HydrologyPoint(100, 63, 0), new HydrologyPoint(112, 63, 0))
List.of(new HydrologyPoint(100, 63, 0), new HydrologyPoint(112, 63, 0)), HydraulicChannelProfile.uniform(4, 2)
);
RiverCourse seaCave = new RiverCourse(
@@ -288,7 +288,7 @@ public class HydrologyModelTest {
assertThrows(IllegalArgumentException.class, () -> new HydrologyColumnSample(
4,
9,
63,
62,
63,
false,
"shore_parent",
@@ -298,7 +298,7 @@ public class HydrologyModelTest {
HydrologyColumnSample raised = new HydrologyColumnSample(
4,
9,
64,
63,
63,
false,
"shore_parent",
@@ -394,6 +394,23 @@ public class HydrologyModelTest {
assertEquals(72, sample.terrainHeight());
}
@Test
public void uncutShoreRetainsItsBiomeWithoutOverridingAnOwnedBank() {
HydrologyColumnLayer shore = surfaceLayer(feature(HydrologyFeatureType.SURFACE_POOL, 30L, 75),
75, 75, false, true, true, false, false);
HydrologyColumnLayer bank = surfaceLayer(feature(HydrologyFeatureType.SURFACE_POOL, 31L, 72),
72, 72, false, false, true, true, false);
HydrologyColumnSample uncut = new HydrologyColumnSample(0, 0, 75, 63, false, "parent", List.of(shore));
HydrologyColumnSample cut = new HydrologyColumnSample(0, 0, 75, 63, false, "parent", List.of(shore, bank));
assertEquals(shore, uncut.primarySurfaceLayer().orElseThrow());
assertEquals("shore", uncut.primarySurfaceLayer().orElseThrow().biomeKey());
assertEquals(75, uncut.terrainHeight());
assertTrue(uncut.primarySurfaceFluidLayer().isEmpty());
assertEquals(bank, cut.primarySurfaceLayer().orElseThrow());
assertEquals(72, cut.terrainHeight());
}
@Test
public void acceptedSurfaceArbitrationPrefersChannelThenHydraulicType() {
HydrologyColumnLayer grading = surfaceLayer(
@@ -620,7 +637,7 @@ public class HydrologyModelTest {
new HydrologyPoint(0, 20, 0),
new HydrologyPoint(500, 20, 0),
new HydrologyPoint(1000, 20, 0)
)
), HydraulicChannelProfile.uniform(4, 2)
);
RiverCourse course = new RiverCourse(
201L,

Some files were not shown because too many files have changed in this diff Show More