From 2797a5bf220b052b106211edf0da166900a3f873 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Mon, 24 Aug 2026 16:50:57 -0400 Subject: [PATCH] dwa --- .../src/main/java/art/arcane/iris/Iris.java | 28 +- .../iris/IrisDiagnosticLogLevelTest.java | 27 +- .../iris/core/pack/PackRiverValidator.java | 199 ++++- .../iris/engine/object/IrisRiverTerrain.java | 19 +- .../iris/engine/object/IrisRiverTopology.java | 10 - .../iris/engine/object/IrisRiverWorm.java | 133 +++ .../iris/engine/river/RiverBodyProfile.java | 182 +++++ .../engine/river/RiverMeanderContext.java | 18 - .../iris/engine/river/RiverNetwork.java | 689 ++++++++-------- .../engine/river/RiverNetworkOptions.java | 149 +++- .../arcane/iris/engine/river/RiverReach.java | 24 +- .../engine/river/RiverTerrainSampler.java | 22 +- .../arcane/iris/engine/river/RiverTile.java | 51 +- .../engine/river/RiverTopologyComplexity.java | 22 +- .../iris/engine/river/RiverWidthProfile.java | 89 --- .../arcane/iris/engine/river/RiverWorm.java | 79 ++ .../river/runtime/IrisRiverRuntime.java | 107 ++- .../iris/util/common/plugin/VolmitPlugin.java | 8 +- .../core/pack/PackRiverValidatorTest.java | 302 ++++++- .../core/project/IrisRiverSchemaTest.java | 43 +- .../object/IrisRiverConfigurationTest.java | 46 +- .../iris/engine/river/RiverNetworkTest.java | 754 +++++++++++++----- .../iris/engine/river/RiverTileCacheTest.java | 2 +- .../river/runtime/IrisRiverRuntimeTest.java | 14 +- 24 files changed, 2208 insertions(+), 809 deletions(-) create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisRiverWorm.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/RiverBodyProfile.java delete mode 100644 core/src/main/java/art/arcane/iris/engine/river/RiverMeanderContext.java delete mode 100644 core/src/main/java/art/arcane/iris/engine/river/RiverWidthProfile.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/RiverWorm.java diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java index 53a960339..2a7eb8452 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java @@ -256,18 +256,19 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { public static void msg(String string) { try { - getSender().sendMessage(string); + Iris plugin = instance; + ComponentLog.logMarkup( + plugin, + Logger.getLogger("Iris"), + logPrefix(plugin), + Level.INFO, + string, + null); } catch (Throwable e) { try { Iris plugin = instance; - String tag = plugin == null ? "" : plugin.getTag(); - ComponentLog.logMarkup( - plugin, - Logger.getLogger("Iris"), - "[Iris] ", - Level.INFO, - tag + string, - null); + String plainPrefix = ComponentText.legacy(logPrefix(plugin)).plain(); + Logger.getLogger("Iris").log(Level.INFO, plainPrefix + IrisLogging.clean(string)); } catch (Throwable inner) { System.err.println("[Iris] Failed to emit log message: " + inner.getMessage()); inner.printStackTrace(System.err); @@ -561,15 +562,20 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { private static void diagnostic(Level level, String message) { String line = IrisLogging.clean(message); + Iris plugin = instance; ComponentLog.log( - instance, + plugin, Logger.getLogger("Iris"), - "[Iris] ", + logPrefix(plugin), level, ComponentText.literal(line), null); } + private static String logPrefix(Iris plugin) { + return plugin == null ? "[Iris] " : plugin.getTag(); + } + /** * @return false when the bootstrap was aborted (unsupported server version); the caller * must bail out of onEnable without touching any further setup. diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisDiagnosticLogLevelTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisDiagnosticLogLevelTest.java index 0ecb40052..e6842d9c1 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisDiagnosticLogLevelTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisDiagnosticLogLevelTest.java @@ -13,10 +13,8 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; /** - * Core states a severity on every message it logs and the modded adapters honour it. On Bukkit the same - * message used to become a coloured console line, which the server logs at INFO, so no core warning ever - * appeared in a WARN-level scan of logs/latest.log - including the orphaned-world-storage reports the - * bootstrap replays there specifically for operators to find. + * Core states a severity on every message it logs and the modded adapters honour it. Bukkit must preserve + * that severity through the shared component logger so diagnostics remain discoverable in logs/latest.log. */ public class IrisDiagnosticLogLevelTest { @Test @@ -26,25 +24,16 @@ public class IrisDiagnosticLogLevelTest { } @Test - public void informationalAndDebugMessagesStayOnTheConsolePath() { + public void informationalAndDebugMessagesStayOnTheInformationalPath() { assertNull(Iris.diagnosticLevel(LogLevel.INFO)); assertNull(Iris.diagnosticLevel(LogLevel.DEBUG)); } - /** - * Console sender output reaches the terminal but not the instance's logs/latest.log, which is the only - * log most operators read after the fact. A handful of lifecycle lines go to the plugin logger instead. - */ @Test public void lifecycleNoticesReachThePluginLoggerAtInfo() { assertEquals(Level.INFO, Iris.diagnosticLevel(LogLevel.NOTICE)); } - /** - * A warning raised by the adapter is the same kind of thing as a warning raised by core. Routing one - * through the plugin logger and the other through the console sender makes the level depend on which - * side of the SPI the call happened to be written on. - */ @Test public void adapterSideWarningsCarryTheSameSeverityAsCoreWarnings() throws Exception { String source = Files.readString(Path.of("src/main/java/art/arcane/iris/Iris.java")).replace("\r\n", "\n"); @@ -70,6 +59,16 @@ public class IrisDiagnosticLogLevelTest { assertTrue(diagnostic, diagnostic.contains("ComponentText.literal(line)")); } + @Test + public void informationalMessagesUseTheSharedComponentLogger() throws Exception { + String source = Files.readString(Path.of("src/main/java/art/arcane/iris/Iris.java")).replace("\r\n", "\n"); + String message = method(source, "public static void msg(String string)"); + + assertTrue(message, message.contains("ComponentLog.logMarkup(")); + assertTrue(message, message.contains("logPrefix(plugin)")); + assertFalse(message, message.contains("getSender().sendMessage")); + } + private static String method(String source, String signature) { int start = source.indexOf(signature); assertTrue("method not found: " + signature, start >= 0); diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackRiverValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackRiverValidator.java index 86191acb3..6d7dc7334 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackRiverValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackRiverValidator.java @@ -111,10 +111,10 @@ final class PackRiverValidator { } if (topology != null && terrain != null) { - double meanderStrength = doubleValue(terrain, "meanderStrength", 72D); + WormEnvelope wormEnvelope = wormEnvelope(terrain); int cellSize = integerValue(topology, "cellSize", 512); - if (Double.isFinite(meanderStrength) && meanderStrength > cellSize) { - warnings.add(path + ".terrain.meanderStrength exceeds topology.cellSize; reaches may require large cache halos."); + if (wormEnvelope.maximumOffset() > cellSize) { + warnings.add(path + ".terrain.worms maxOffset exceeds topology.cellSize; reaches may require large cache halos."); } validateTopologyComplexity(packFolder, path, topology, terrain, errors); } @@ -144,8 +144,6 @@ final class PackRiverValidator { PackJsonFieldChecks.validateOptionalDoubleRange(path, topology, "routingNoiseWeight", 0D, 1024D, errors); PackJsonFieldChecks.validateOptionalDoubleRange(path, topology, "flowAlignmentWeight", 0D, 1024D, errors); PackJsonFieldChecks.validateOptionalDoubleRange(path, topology, "confluenceWeight", 0D, 1024D, errors); - PackJsonFieldChecks.validateOptionalIntegerRange(path, topology, "branchSoftCap", 1, 8, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, topology, "branchChildShrinkFactor", 0D, 1D, errors); PackJsonFieldChecks.validateOptionalDoubleRange(path, topology, "terrainHeightWeight", 0D, 16D, errors); PackJsonFieldChecks.validateOptionalDoubleRange(path, topology, "terrainSlopeWeight", 0D, 16D, errors); PackJsonFieldChecks.validateOptionalDoubleRange(path, topology, "oceanAttraction", 0D, 16D, errors); @@ -180,8 +178,6 @@ final class PackRiverValidator { PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "tunnelMouthBlend", 0D, 16D, errors); PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "tunnelFloorVariation", 0D, 8D, errors); PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "tunnelRoofVariation", 0D, 16D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "meanderStrength", 0D, 1024D, errors); - PackJsonFieldChecks.validateOptionalIntegerRange(path, terrain, "meanderSubdivisions", 1, 64, errors); PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "bedRoughness", 0D, 8D, errors); PackJsonFieldChecks.validateOptionalEnum(path, terrain, "terminalMode", TERMINAL_MODES, errors); PackJsonFieldChecks.validateOptionalIntegerRange(path, terrain, "terminalTaper", 8, 1024, errors); @@ -189,8 +185,8 @@ final class PackRiverValidator { validateNoiseChance(packFolder, terrain, "incision", path, errors); validateStyle(packFolder, terrain, "tunnelFloorStyle", path, errors); validateStyle(packFolder, terrain, "tunnelRoofStyle", path, errors); - validateStyle(packFolder, terrain, "meanderStyle", path, errors); validateStyle(packFolder, terrain, "bedRoughnessStyle", path, errors); + validateWorms(path, terrain, errors); double maximumChannelWidth = doubleValue(terrain, "maxChannelWidth", 10D); double maximumTunnelWidthMultiplier = styledRangeMaximum( packFolder, @@ -240,8 +236,7 @@ final class PackRiverValidator { int tileCells = integerValue(topology, "tileCells", 4); double siteJitter = doubleValue(topology, "siteJitter", 0.35D); int maxRouteReaches = integerValue(topology, "maxRouteReaches", 16); - double meanderStrength = doubleValue(terrain, "meanderStrength", 72D); - int meanderSubdivisions = integerValue(terrain, "meanderSubdivisions", 8); + WormEnvelope wormEnvelope = wormEnvelope(terrain); double maximumChannelWidth = doubleValue(terrain, "maxChannelWidth", 10D); double maximumBankWidth = doubleValue(terrain, "maxBankWidth", 4D); double maximumTunnelWidthMultiplier = styledRangeMaximum( @@ -256,8 +251,8 @@ final class PackRiverValidator { || tileCells < 1 || tileCells > 64 || !Double.isFinite(siteJitter) || siteJitter < 0D || siteJitter > 0.49D || maxRouteReaches < 1 || maxRouteReaches > 256 - || !Double.isFinite(meanderStrength) || meanderStrength < 0D || meanderStrength > 1024D - || meanderSubdivisions < 1 || meanderSubdivisions > 64 + || wormEnvelope.maximumOffset() < 0D || wormEnvelope.maximumOffset() > 1024D + || wormEnvelope.maximumSegments() < 1 || wormEnvelope.maximumSegments() > 64 || !Double.isFinite(maximumChannelWidth) || maximumChannelWidth < 1D || maximumChannelWidth > 2048D || !Double.isFinite(maximumBankWidth) || maximumBankWidth < 0D || maximumBankWidth > 2048D || !Double.isFinite(maximumTunnelWidthMultiplier) @@ -275,14 +270,187 @@ final class PackRiverValidator { siteJitter, maxRouteReaches, maximumReachRadius, - meanderStrength, - meanderSubdivisions + wormEnvelope.maximumOffset(), + wormEnvelope.maximumSegments() ); for (String violation : estimate.violations()) { errors.add(path + " exceeds the safe derived complexity budget. " + violation); } } + private static void validateWorms(String path, JSONObject terrain, List errors) { + Object rawWorms = terrain.opt("worms"); + if (!(rawWorms instanceof JSONArray worms)) { + errors.add(path + ".worms must be an array with at least one Perlin-worm profile."); + return; + } + if (worms.length() < 1) { + errors.add(path + ".worms must contain at least one Perlin-worm profile."); + return; + } + if (worms.length() > 16) { + errors.add(path + ".worms must contain at most 16 root profiles."); + } + Set ids = new HashSet(); + Set seeds = new HashSet(); + int profileCount = validateWormArray(path + ".worms", worms, 1, ids, seeds, errors); + if (profileCount > 128) { + errors.add(path + ".worms hierarchy must contain at most 128 profiles."); + } + } + + private static int validateWormArray( + String path, + JSONArray worms, + int depth, + Set ids, + Set seeds, + List errors + ) { + if (worms.length() > 16) { + errors.add(path + " must contain at most 16 profiles."); + } + double totalWeight = 0D; + int profileCount = 0; + for (int index = 0; index < worms.length(); index++) { + JSONObject worm = worms.optJSONObject(index); + String wormPath = path + "[" + index + "]"; + if (worm == null) { + errors.add(wormPath + " must be an object."); + continue; + } + profileCount++; + validateWormId(wormPath, worm, ids, errors); + long seed = validateWormSeed(wormPath, worm, errors); + if (!seeds.add(seed)) { + errors.add(wormPath + ".seed must be unique inside the worm hierarchy."); + } + PackJsonFieldChecks.validateOptionalDoubleRange( + wormPath, worm, "weight", 0.000001D, 1000000D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange( + wormPath, worm, "wavelength", 8D, 16384D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange( + wormPath, worm, "detailWavelength", 8D, 16384D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange( + wormPath, worm, "tortuosity", 0D, 1D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange( + wormPath, worm, "detailTortuosity", 0D, 1D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange( + wormPath, worm, "maxOffset", 0D, 1024D, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(wormPath, worm, "segments", 1, 64, errors); + PackJsonFieldChecks.validateOptionalDoubleRange( + wormPath, worm, "widthMultiplier", 0.125D, 8D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange( + wormPath, worm, "bankMultiplier", 0.125D, 8D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange( + wormPath, worm, "depthMultiplier", 0.125D, 8D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange( + wormPath, worm, "bodyWavelength", 32D, 16384D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange( + wormPath, worm, "bodyDetailWavelength", 32D, 16384D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange( + wormPath, worm, "widthVariation", 0D, 0.875D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange( + wormPath, worm, "bankVariation", 0D, 0.875D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange( + wormPath, worm, "depthVariation", 0D, 0.875D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange( + wormPath, worm, "roofVariation", 0D, 0.875D, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(wormPath, worm, "branchCap", 1, 8, errors); + PackJsonFieldChecks.validateOptionalDoubleRange( + wormPath, worm, "branchDecay", 0D, 1D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange( + wormPath, worm, "confluenceMultiplier", 0D, 8D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange( + wormPath, worm, "childChance", 0D, 1D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange( + wormPath, worm, "branchChildChance", 0D, 1D, errors); + totalWeight += doubleValue(worm, "weight", 1D); + Object rawChildren = worm.opt("children"); + if (rawChildren == null || rawChildren == JSONObject.NULL) { + continue; + } + if (!(rawChildren instanceof JSONArray children)) { + errors.add(wormPath + ".children must be an array."); + continue; + } + if (children.length() > 0 && depth >= 4) { + errors.add(wormPath + ".children exceeds the maximum hierarchy depth of 4."); + continue; + } + profileCount += validateWormArray( + wormPath + ".children", + children, + depth + 1, + ids, + seeds, + errors + ); + } + if (worms.length() > 0 && (!Double.isFinite(totalWeight) || totalWeight <= 0D)) { + errors.add(path + " total weight must be finite and positive."); + } + return profileCount; + } + + private static void validateWormId(String path, JSONObject worm, Set ids, List errors) { + Object rawId = worm.opt("id"); + if (!(rawId instanceof String id) || !id.matches("[a-z0-9][a-z0-9_-]{0,63}")) { + errors.add(path + ".id must use 1 to 64 lowercase letters, digits, underscores, or hyphens."); + return; + } + if (!ids.add(id)) { + errors.add(path + ".id must be unique inside the worm hierarchy."); + } + } + + private static long validateWormSeed(String path, JSONObject worm, List errors) { + Object rawSeed = worm.opt("seed"); + if (rawSeed == null || rawSeed == JSONObject.NULL) { + return 1L; + } + if (!(rawSeed instanceof Number number) + || !Double.isFinite(number.doubleValue()) + || number.doubleValue() != StrictMath.rint(number.doubleValue())) { + errors.add(path + ".seed must be an integer."); + return 1L; + } + return number.longValue(); + } + + private static WormEnvelope wormEnvelope(JSONObject terrain) { + JSONArray worms = terrain.optJSONArray("worms"); + if (worms == null || worms.length() == 0) { + return new WormEnvelope(320D, 48); + } + double maximumOffset = 0D; + int maximumSegments = 1; + WormEnvelope envelope = wormEnvelope(worms, maximumOffset, maximumSegments); + maximumOffset = envelope.maximumOffset(); + maximumSegments = envelope.maximumSegments(); + return new WormEnvelope(maximumOffset, maximumSegments); + } + + private static WormEnvelope wormEnvelope(JSONArray worms, double maximumOffset, int maximumSegments) { + double resolvedOffset = maximumOffset; + int resolvedSegments = maximumSegments; + for (int index = 0; index < worms.length(); index++) { + JSONObject worm = worms.optJSONObject(index); + if (worm == null) { + continue; + } + resolvedOffset = Math.max(resolvedOffset, doubleValue(worm, "maxOffset", 320D)); + resolvedSegments = Math.max(resolvedSegments, integerValue(worm, "segments", 48)); + JSONArray children = worm.optJSONArray("children"); + if (children != null) { + WormEnvelope childEnvelope = wormEnvelope(children, resolvedOffset, resolvedSegments); + resolvedOffset = Math.max(resolvedOffset, childEnvelope.maximumOffset()); + resolvedSegments = Math.max(resolvedSegments, childEnvelope.maximumSegments()); + } + } + return new WormEnvelope(resolvedOffset, resolvedSegments); + } + private static void validateCaves(File packFolder, String path, JSONObject caves, boolean forceGeneratedGrotto, List errors, List warnings) { @@ -961,6 +1129,9 @@ final class PackRiverValidator { return chance == null ? defaultValue : doubleValue(chance, "chance", defaultValue); } + private record WormEnvelope(double maximumOffset, int maximumSegments) { + } + record Validation(List errors, List warnings) { Validation { errors = List.copyOf(errors); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverTerrain.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverTerrain.java index fbbbba8ac..2f8fb04f0 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverTerrain.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverTerrain.java @@ -1,8 +1,11 @@ package art.arcane.iris.engine.object; +import art.arcane.iris.engine.object.annotations.ArrayType; 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 art.arcane.iris.engine.object.annotations.Required; +import art.arcane.volmlib.util.collection.KList; import lombok.Data; import lombok.NoArgsConstructor; import lombok.experimental.Accessors; @@ -83,18 +86,10 @@ public class IrisRiverTerrain { @Desc("The maximum vertical roof variation in river tunnels.") private double tunnelRoofVariation = 3D; - @Desc("Warps the spacing and amplitude of varied local-normal sweeps, hooks, curls, and wandering bends while preserving graph endpoints.") - private IrisGeneratorStyle meanderStyle = new IrisGeneratorStyle(NoiseStyle.IRIS).zoomed(512D); - - @MinNumber(0) - @MaxNumber(1024) - @Desc("The total endpoint-spline and shape-personality displacement envelope in blocks.") - private double meanderStrength = 72D; - - @MinNumber(1) - @MaxNumber(64) - @Desc("The number of straight segments used to flatten and resolve each meandering graph reach.") - private int meanderSubdivisions = 8; + @Required + @ArrayType(min = 1, type = IrisRiverWorm.class) + @Desc("Weighted root Perlin-worm families with inherited child styles for trunks and tributaries.") + private KList worms = new KList(); @Desc("Modulates small river-bed height variation after the connected channel shape is solved.") private IrisGeneratorStyle bedRoughnessStyle = new IrisGeneratorStyle(NoiseStyle.IRIS).zoomed(96D); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverTopology.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverTopology.java index 4b094447f..0a23e651c 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverTopology.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverTopology.java @@ -92,16 +92,6 @@ public class IrisRiverTopology { @Desc("The deterministic attraction toward shared downstream nodes. Larger values form stronger tributary trees and confluences.") private double confluenceWeight = 0D; - @MinNumber(1) - @MaxNumber(8) - @Desc("The number of upstream children a graph node accepts before additional branches begin shrinking probabilistically.") - private int branchSoftCap = 4; - - @MinNumber(0) - @MaxNumber(1) - @Desc("The multiplicative survival factor for each child beyond branchSoftCap. Recursive generations remain unbounded by depth.") - private double branchChildShrinkFactor = 0.35D; - @MinNumber(0) @MaxNumber(16) @Desc("The contribution of natural terrain height to downstream routing cost.") diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverWorm.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverWorm.java new file mode 100644 index 000000000..9366ddb92 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverWorm.java @@ -0,0 +1,133 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.ArrayType; +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 art.arcane.iris.engine.object.annotations.Required; +import art.arcane.volmlib.util.collection.KList; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("One weighted Perlin-worm river shape and its channel proportions.") +@Data +public class IrisRiverWorm { + @Required + @Desc("Unique lowercase identifier for this root or child style.") + private String id = "river"; + + @Desc("Stable salt for this Perlin field pair.") + private long seed = 1L; + + @MinNumber(0.000001) + @MaxNumber(1000000) + @Desc("Relative probability when selecting this root family or one child transition.") + private double weight = 1D; + + @MinNumber(8) + @MaxNumber(16384) + @Desc("Primary gradient-Perlin wavelength in blocks.") + private double wavelength = 1024D; + + @MinNumber(8) + @MaxNumber(16384) + @Desc("Secondary gradient-Perlin wavelength in blocks.") + private double detailWavelength = 256D; + + @MinNumber(0) + @MaxNumber(1) + @Desc("Primary heading deviation as a fraction of 180 degrees.") + private double tortuosity = 0.5D; + + @MinNumber(0) + @MaxNumber(1) + @Desc("Secondary heading deviation as a fraction of 180 degrees.") + private double detailTortuosity = 0.15D; + + @MinNumber(0) + @MaxNumber(1024) + @Desc("Maximum endpoint-bridged displacement from the reach chord in blocks.") + private double maxOffset = 320D; + + @MinNumber(1) + @MaxNumber(64) + @Desc("Number of deterministic Perlin-worm steps used to resolve the reach.") + private int segments = 48; + + @MinNumber(0.125) + @MaxNumber(8) + @Desc("Channel-width multiplier for reaches selecting this worm.") + private double widthMultiplier = 1D; + + @MinNumber(0.125) + @MaxNumber(8) + @Desc("Bank-width multiplier for reaches selecting this worm.") + private double bankMultiplier = 1D; + + @MinNumber(0.125) + @MaxNumber(8) + @Desc("Depth multiplier for reaches selecting this worm.") + private double depthMultiplier = 1D; + + @MinNumber(32) + @MaxNumber(16384) + @Desc("Primary world-space wavelength controlling longitudinal body swelling and pinching.") + private double bodyWavelength = 512D; + + @MinNumber(32) + @MaxNumber(16384) + @Desc("Detail wavelength adding smaller changes to the longitudinal body profile.") + private double bodyDetailWavelength = 128D; + + @MinNumber(0) + @MaxNumber(0.875) + @Desc("Maximum proportional channel-width variation along this style's body.") + private double widthVariation = 0D; + + @MinNumber(0) + @MaxNumber(0.875) + @Desc("Maximum proportional bank or basin-width variation along this style's body.") + private double bankVariation = 0D; + + @MinNumber(0) + @MaxNumber(0.875) + @Desc("Maximum proportional bed-depth variation along this style's body.") + private double depthVariation = 0D; + + @MinNumber(0) + @MaxNumber(0.875) + @Desc("Maximum downward variation of tunnel roof clearance without exceeding the authored cave headroom.") + private double roofVariation = 0D; + + @MinNumber(1) + @MaxNumber(8) + @Desc("Number of upstream children admitted before additional siblings decay probabilistically.") + private int branchCap = 4; + + @MinNumber(0) + @MaxNumber(1) + @Desc("Multiplicative survival probability for every sibling beyond branchCap.") + private double branchDecay = 0.35D; + + @MinNumber(0) + @MaxNumber(8) + @Desc("Multiplier applied to the dimension confluence attraction for this style.") + private double confluenceMultiplier = 1D; + + @MinNumber(0) + @MaxNumber(1) + @Desc("Chance that an upstream continuation mutates from this style into one weighted child.") + private double childChance = 0D; + + @MinNumber(0) + @MaxNumber(1) + @Desc("Additional child-mutation chance for each sibling slot beyond the primary branch.") + private double branchChildChance = 0D; + + @ArrayType(type = IrisRiverWorm.class) + @Desc("Weighted descendant styles inherited by the complete upstream lineage after mutation.") + private KList children = new KList(); +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverBodyProfile.java b/core/src/main/java/art/arcane/iris/engine/river/RiverBodyProfile.java new file mode 100644 index 000000000..74ff4c618 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverBodyProfile.java @@ -0,0 +1,182 @@ +package art.arcane.iris.engine.river; + +import java.util.Arrays; + +public final class RiverBodyProfile { + private final double[] positions; + private final double[] widths; + private final double[] bankWidths; + private final double[] depths; + private final double[] roofScales; + private final double maximumWidth; + private final double maximumBankWidth; + private final double maximumDepth; + + public RiverBodyProfile( + double[] positions, + double[] widths, + double[] bankWidths, + double[] depths, + double[] roofScales + ) { + if (positions == null || widths == null || bankWidths == null || depths == null || roofScales == null + || positions.length < 2 + || positions.length != widths.length + || positions.length != bankWidths.length + || positions.length != depths.length + || positions.length != roofScales.length) { + throw new IllegalArgumentException("River body profiles require matching dimension samples"); + } + this.positions = positions.clone(); + this.widths = widths.clone(); + this.bankWidths = bankWidths.clone(); + this.depths = depths.clone(); + this.roofScales = roofScales.clone(); + double resolvedMaximumWidth = 0D; + double resolvedMaximumBankWidth = 0D; + double resolvedMaximumDepth = 0D; + for (int index = 0; index < this.positions.length; index++) { + double position = this.positions[index]; + if (!Double.isFinite(position) || position < 0D || position > 1D + || index > 0 && position <= this.positions[index - 1]) { + throw new IllegalArgumentException("River body profile positions must increase from zero to one"); + } + requirePositive(this.widths[index], "width"); + requireNonNegative(this.bankWidths[index], "bank width"); + requirePositive(this.depths[index], "depth"); + requireUnitScale(this.roofScales[index], "roof scale"); + resolvedMaximumWidth = StrictMath.max(resolvedMaximumWidth, this.widths[index]); + resolvedMaximumBankWidth = StrictMath.max(resolvedMaximumBankWidth, this.bankWidths[index]); + resolvedMaximumDepth = StrictMath.max(resolvedMaximumDepth, this.depths[index]); + } + if (this.positions[0] != 0D || this.positions[this.positions.length - 1] != 1D) { + throw new IllegalArgumentException("River body profile positions must include zero and one"); + } + maximumWidth = resolvedMaximumWidth; + maximumBankWidth = resolvedMaximumBankWidth; + maximumDepth = resolvedMaximumDepth; + } + + public static RiverBodyProfile constant(double width, double bankWidth, double depth) { + return new RiverBodyProfile( + new double[]{0D, 1D}, + new double[]{width, width}, + new double[]{bankWidth, bankWidth}, + new double[]{depth, depth}, + new double[]{1D, 1D} + ); + } + + public double width(double alongReach) { + return sample(widths, alongReach); + } + + public double bankWidth(double alongReach) { + return sample(bankWidths, alongReach); + } + + public double depth(double alongReach) { + return sample(depths, alongReach); + } + + public double roofScale(double alongReach) { + return sample(roofScales, alongReach); + } + + public double maximumWidth() { + return maximumWidth; + } + + public double maximumBankWidth() { + return maximumBankWidth; + } + + public double maximumDepth() { + return maximumDepth; + } + + public int size() { + return positions.length; + } + + public double position(int index) { + return positions[index]; + } + + public double widthAtIndex(int index) { + return widths[index]; + } + + public double bankWidthAtIndex(int index) { + return bankWidths[index]; + } + + public double depthAtIndex(int index) { + return depths[index]; + } + + public double roofScaleAtIndex(int index) { + return roofScales[index]; + } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } + if (!(object instanceof RiverBodyProfile profile)) { + return false; + } + return Arrays.equals(positions, profile.positions) + && Arrays.equals(widths, profile.widths) + && Arrays.equals(bankWidths, profile.bankWidths) + && Arrays.equals(depths, profile.depths) + && Arrays.equals(roofScales, profile.roofScales); + } + + @Override + public int hashCode() { + int hash = Arrays.hashCode(positions); + hash = 31 * hash + Arrays.hashCode(widths); + hash = 31 * hash + Arrays.hashCode(bankWidths); + hash = 31 * hash + Arrays.hashCode(depths); + return 31 * hash + Arrays.hashCode(roofScales); + } + + private double sample(double[] values, double alongReach) { + double position = StrictMath.max(0D, StrictMath.min(1D, alongReach)); + int index = Arrays.binarySearch(positions, position); + if (index >= 0) { + return values[index]; + } + int upper = -index - 1; + if (upper <= 0) { + return values[0]; + } + if (upper >= positions.length) { + return values[values.length - 1]; + } + int lower = upper - 1; + double range = positions[upper] - positions[lower]; + double interpolation = range <= 0D ? 0D : (position - positions[lower]) / range; + return values[lower] + (values[upper] - values[lower]) * interpolation; + } + + private static void requirePositive(double value, String name) { + if (!Double.isFinite(value) || value <= 0D) { + throw new IllegalArgumentException("River body profile " + name + " must be finite and positive"); + } + } + + private static void requireNonNegative(double value, String name) { + if (!Double.isFinite(value) || value < 0D) { + throw new IllegalArgumentException("River body profile " + name + " must be finite and non-negative"); + } + } + + private static void requireUnitScale(double value, String name) { + if (!Double.isFinite(value) || value <= 0D || value > 1D) { + throw new IllegalArgumentException("River body profile " + name + " must be greater than zero and at most one"); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverMeanderContext.java b/core/src/main/java/art/arcane/iris/engine/river/RiverMeanderContext.java deleted file mode 100644 index e3c2ce351..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverMeanderContext.java +++ /dev/null @@ -1,18 +0,0 @@ -package art.arcane.iris.engine.river; - -import java.util.Objects; - -public record RiverMeanderContext( - RiverEdgeId reachId, - double normalizedPosition, - double x, - double z -) { - public RiverMeanderContext { - Objects.requireNonNull(reachId); - if (!Double.isFinite(normalizedPosition) || normalizedPosition < 0.0 || normalizedPosition > 1.0 - || !Double.isFinite(x) || !Double.isFinite(z)) { - throw new IllegalArgumentException("River meander context must be finite and normalized"); - } - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverNetwork.java b/core/src/main/java/art/arcane/iris/engine/river/RiverNetwork.java index 481873478..cdf42146f 100644 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverNetwork.java +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverNetwork.java @@ -21,21 +21,25 @@ public final class RiverNetwork { private static final long SOURCE_FLOOR_SALT = 0xD6E8FEB86659FD93L; private static final long REACH_SALT = 0x9B05688C2B3E6C1FL; private static final long DRY_SALT = 0x1F83D9ABFB41BD6BL; - private static final long MEANDER_SALT = 0x5BE0CD19137E2179L; - private static final long MEANDER_PERSONALITY_SALT = 0x243F6A8885A308D3L; - private static final long MEANDER_AMPLITUDE_SALT = 0x13198A2E03707344L; - private static final long MEANDER_PHASE_SALT = 0xD1310BA698DFB5ACL; - private static final long MEANDER_SKEW_SALT = 0x2FFD72DBD01ADFB7L; - private static final long MEANDER_CYCLE_SALT = 0x452821E638D01377L; - private static final long MEANDER_FEATURE_A_SALT = 0xBE5466CF34E90C6CL; - private static final long MEANDER_FEATURE_B_SALT = 0xC0AC29B7C97C50DDL; + private static final long WORM_FAMILY_SALT = 0x5BE0CD19137E2179L; + private static final long WORM_CHILD_GATE_SALT = 0x452821E638D01377L; + private static final long WORM_CHILD_SELECTION_SALT = 0xBE5466CF34E90C6CL; + private static final long WORM_PRIMARY_SALT = 0x243F6A8885A308D3L; + private static final long WORM_DETAIL_SALT = 0x13198A2E03707344L; + private static final long BODY_WIDTH_PRIMARY_SALT = 0xA4093822299F31D0L; + private static final long BODY_WIDTH_DETAIL_SALT = 0x082EFA98EC4E6C89L; + private static final long BODY_BANK_PRIMARY_SALT = 0x452821E638D01377L; + private static final long BODY_BANK_DETAIL_SALT = 0xBE5466CF34E90C6CL; + private static final long BODY_DEPTH_PRIMARY_SALT = 0xC0AC29B7C97C50DDL; + private static final long BODY_DEPTH_DETAIL_SALT = 0x3F84D5B5B5470917L; + private static final long BODY_ROOF_PRIMARY_SALT = 0xD1310BA698DFB5ACL; + private static final long BODY_ROOF_DETAIL_SALT = 0x2FFD72DBD01ADFB7L; private static final long CONFLUENCE_SALT = 0x9E3779B97F4A7C15L; private static final long BRANCH_SLOT_SALT = 0x94D049BB133111EBL; private static final long BRANCH_GATE_SALT = 0x2545F4914F6CDD1DL; - private static final int MEANDER_NOISE_SAMPLES = 12; - private static final int WIDTH_PROFILE_SAMPLES = 12; - private static final double TWO_PI = StrictMath.PI * 2D; - private static final MeanderPersonality[] MEANDER_PERSONALITIES = MeanderPersonality.values(); + private static final int MINIMUM_BODY_PROFILE_SAMPLES = 12; + private static final int MAXIMUM_BODY_PROFILE_SAMPLES = 32; + private static final double PERLIN_NORMALIZATION = 1.4142135623730951D; private final RiverNetworkOptions options; @@ -208,7 +212,7 @@ public final class RiverNetwork { double routingScore = naturalHeight * options.terrainHeightWeight() + routingNoise * options.routingNoiseWeight() + finiteOrZero(terrainSample.routingCost()); - double drainageDistance = drainageDistance(id); + double drainageDistance = drainageBasin(id).distance(); double hydraulicHeight = ocean ? options.hydraulicBaseHeight() : options.hydraulicBaseHeight() @@ -228,6 +232,10 @@ public final class RiverNetwork { } private double drainageDistance(RiverNodeId id) { + return drainageBasin(id).distance(); + } + + private DrainageBasin drainageBasin(RiverNodeId id) { int basinCells = options.routingBasinCells(); double nodeX = id.cellX() + 0.5D; double nodeZ = id.cellZ() + 0.5D; @@ -245,19 +253,21 @@ public final class RiverNetwork { long basinZ = (long) StrictMath.floor(nodeZ / basinCells); double jitterRadius = basinCells * 0.45D; double nearestDistance = Double.MAX_VALUE; + DrainageBasinId nearestId = null; for (long candidateX = basinX - 1L; candidateX <= basinX + 1L; candidateX++) { for (long candidateZ = basinZ - 1L; candidateZ <= basinZ + 1L; candidateZ++) { double siteX = (candidateX + 0.5D) * basinCells + centered(hash(candidateX, candidateZ, BASIN_X_SALT)) * jitterRadius; double siteZ = (candidateZ + 0.5D) * basinCells + centered(hash(candidateX, candidateZ, BASIN_Z_SALT)) * jitterRadius; - nearestDistance = StrictMath.min( - nearestDistance, - StrictMath.hypot(nodeX - siteX, nodeZ - siteZ) - ); + double distance = StrictMath.hypot(nodeX - siteX, nodeZ - siteZ); + if (distance < nearestDistance) { + nearestDistance = distance; + nearestId = new DrainageBasinId(candidateX, candidateZ); + } } } - return nearestDistance; + return new DrainageBasin(nearestId, nearestDistance); } private double smoothCellNoise(double x, double z, int scale, long salt) { @@ -305,7 +315,8 @@ public final class RiverNetwork { if (compareRank(neighbor, node) >= 0) { continue; } - if (!branchPermitted(node, neighbor, resolver)) { + RiverWorm worm = resolver.worm(node, neighbor); + if (!branchPermitted(node, neighbor, resolver, worm)) { continue; } RiverRoutingContext context = resolver.routingContext(node, neighbor); @@ -313,7 +324,8 @@ public final class RiverNetwork { double oceanAttraction = neighbor.ocean() ? options.oceanAttraction() : 0.0; double flowAlignmentCost = flowAlignmentCost(node, neighbor, resolver); double confluenceAttraction = unit(hash(neighbor.id(), CONFLUENCE_SALT)) - * options.confluenceWeight(); + * options.confluenceWeight() + * worm.confluenceMultiplier(); ranked.add(new RankedCandidate( neighbor, neighbor.routingScore() + routingCost + flowAlignmentCost @@ -331,15 +343,20 @@ public final class RiverNetwork { return List.copyOf(candidates); } - private boolean branchPermitted(RiverNode child, RiverNode parent, NodeResolver resolver) { + private boolean branchPermitted( + RiverNode child, + RiverNode parent, + NodeResolver resolver, + RiverWorm worm + ) { RiverEdgeId childEdge = RiverEdgeId.of(child.id(), parent.id()); int childSlot = resolver.branchSlot(parent, child); - if (childSlot < options.branchSoftCap()) { + if (childSlot < worm.branchCap()) { return true; } double survivalChance = 1D; - for (int overflow = options.branchSoftCap(); overflow <= childSlot; overflow++) { - survivalChance *= options.branchChildShrinkFactor(); + for (int overflow = worm.branchCap(); overflow <= childSlot; overflow++) { + survivalChance *= worm.branchDecay(); } return gate(hash(childEdge, BRANCH_GATE_SALT), survivalChance); } @@ -427,6 +444,7 @@ public final class RiverNetwork { from, to, resolver.routingContext(from, to), + resolver.worm(from, to), resolver.terrain ); accumulators.put(edgeId, accumulator); @@ -451,61 +469,65 @@ public final class RiverNetwork { } private RiverPolyline createPolyline( - RiverEdgeId id, RiverNode from, RiverNode to, - NodeResolver resolver + RiverWorm worm ) { - int pointCount = options.meanderSubdivisions() + 1; - double[] baseX = new double[pointCount]; - double[] baseZ = new double[pointCount]; + int pointCount = worm.segments() + 1; + double[] rawX = new double[pointCount]; + double[] rawZ = new double[pointCount]; double deltaX = to.x() - from.x(); double deltaZ = to.z() - from.z(); double length = StrictMath.hypot(deltaX, deltaZ); - double maximumOffset = StrictMath.min(options.meanderStrength(), length * 0.35); - double directionX = length == 0D ? 1D : deltaX / length; - double directionZ = length == 0D ? 0D : deltaZ / length; - FlowTangent fromTangent = flowTangent(from, directionX, directionZ, resolver); - FlowTangent toTangent = flowTangent(to, directionX, directionZ, resolver); - for (int point = 0; point < pointCount; point++) { - double t = (double) point / (pointCount - 1); - double tSquared = t * t; - double tCubed = tSquared * t; - double fromWeight = 2D * tCubed - 3D * tSquared + 1D; - double fromTangentWeight = tCubed - 2D * tSquared + t; - double toWeight = -2D * tCubed + 3D * tSquared; - double toTangentWeight = tCubed - tSquared; - double curvedX = fromWeight * from.x() - + fromTangentWeight * fromTangent.x() * maximumOffset - + toWeight * to.x() - + toTangentWeight * toTangent.x() * maximumOffset; - double curvedZ = fromWeight * from.z() - + fromTangentWeight * fromTangent.z() * maximumOffset - + toWeight * to.z() - + toTangentWeight * toTangent.z() * maximumOffset; - baseX[point] = curvedX; - baseZ[point] = curvedZ; + rawX[0] = from.x(); + rawZ[0] = from.z(); + if (length <= 0D) { + return new RiverPolyline(rawX, rawZ); } - if (maximumOffset <= 0D) { - return new RiverPolyline(baseX, baseZ); + double baseHeading = StrictMath.atan2(deltaZ, deltaX); + double stepLength = length / worm.segments(); + for (int point = 1; point < pointCount; point++) { + double x = rawX[point - 1]; + double z = rawZ[point - 1]; + double primary = perlin(x, z, worm.wavelength(), worm.seed() ^ WORM_PRIMARY_SALT); + double detail = perlin(x, z, worm.detailWavelength(), worm.seed() ^ WORM_DETAIL_SALT); + double heading = baseHeading + StrictMath.PI * ( + primary * worm.tortuosity() + + detail * worm.detailTortuosity() + ); + rawX[point] = x + StrictMath.cos(heading) * stepLength; + rawZ[point] = z + StrictMath.sin(heading) * stepLength; } - MeanderProfile profile = meanderProfile(id); - double[] meanderNoise = meanderNoise(id, baseX, baseZ, resolver); - double[] meanderSignal = meanderSignal(id, profile, meanderNoise); double[] x = new double[pointCount]; double[] z = new double[pointCount]; + double rawDeltaX = rawX[pointCount - 1] - from.x(); + double rawDeltaZ = rawZ[pointCount - 1] - from.z(); + double maximumDisplacement = 0D; for (int point = 0; point < pointCount; point++) { double t = (double) point / (pointCount - 1); double tSquared = t * t; double envelope = 16D * tSquared * (1D - t) * (1D - t); double straightX = from.x() + deltaX * t; double straightZ = from.z() + deltaZ * t; - double baseDisplacement = StrictMath.hypot(baseX[point] - straightX, baseZ[point] - straightZ); - double availableOffset = StrictMath.max(0D, maximumOffset - baseDisplacement); - double offset = availableOffset * 0.92D * envelope * meanderSignal[point]; - FlowTangent normal = localNormal(baseX, baseZ, point); - x[point] = baseX[point] + normal.x() * offset; - z[point] = baseZ[point] + normal.z() * offset; + double rawBridgeX = rawX[point] - (from.x() + rawDeltaX * t); + double rawBridgeZ = rawZ[point] - (from.z() + rawDeltaZ * t); + x[point] = straightX + rawBridgeX * envelope; + z[point] = straightZ + rawBridgeZ * envelope; + maximumDisplacement = StrictMath.max( + maximumDisplacement, + StrictMath.hypot(x[point] - straightX, z[point] - straightZ) + ); + } + double maximumOffset = StrictMath.min(worm.maxOffset(), length * 0.35D); + if (maximumDisplacement > maximumOffset && maximumDisplacement > 0D) { + double scale = maximumOffset / maximumDisplacement; + for (int point = 1; point < pointCount - 1; point++) { + double t = (double) point / (pointCount - 1); + double straightX = from.x() + deltaX * t; + double straightZ = from.z() + deltaZ * t; + x[point] = straightX + (x[point] - straightX) * scale; + z[point] = straightZ + (z[point] - straightZ) * scale; + } } x[0] = from.x(); z[0] = from.z(); @@ -514,222 +536,141 @@ public final class RiverNetwork { return new RiverPolyline(x, z); } - private double[] meanderNoise( - RiverEdgeId id, - double[] baseX, - double[] baseZ, - NodeResolver resolver + private RiverWorm rootWormFor(RiverNodeId rootId) { + return selectWeighted(options.worms(), hash(rootId, WORM_FAMILY_SALT)); + } + + private RiverWorm childWormFor( + RiverWorm parent, + RiverNodeId parentId, + RiverNodeId childId, + int branchSlot ) { - int sampleCount = StrictMath.min(MEANDER_NOISE_SAMPLES, baseX.length); - double[] knots = new double[sampleCount]; - for (int sample = 0; sample < sampleCount; sample++) { - double t = (double) sample / (sampleCount - 1); - double position = t * (baseX.length - 1); - int lower = (int) StrictMath.floor(position); - int upper = StrictMath.min(baseX.length - 1, lower + 1); - double interpolation = position - lower; - double curvedX = baseX[lower] + (baseX[upper] - baseX[lower]) * interpolation; - double curvedZ = baseZ[lower] + (baseZ[upper] - baseZ[lower]) * interpolation; - RiverMeanderContext context = new RiverMeanderContext(id, t, curvedX, curvedZ); - double configuredNoise = resolver.terrain.meanderNoise(context); - knots[sample] = Double.isFinite(configuredNoise) - ? StrictMath.max(-1D, StrictMath.min(1D, configuredNoise)) - : smoothEdgeNoise(id, t * 3D); + if (parent.children().isEmpty()) { + return parent; } - double[] noise = new double[baseX.length]; - for (int point = 0; point < noise.length; point++) { - double t = (double) point / (noise.length - 1); - double position = t * (sampleCount - 1); - int lower = (int) StrictMath.floor(position); - int upper = StrictMath.min(sampleCount - 1, lower + 1); - double interpolation = smoothStep(position - lower); - noise[point] = knots[lower] + (knots[upper] - knots[lower]) * interpolation; + double chance = StrictMath.min( + 1D, + parent.childChance() + + StrictMath.min(7, branchSlot) * parent.branchChildChance() + ); + RiverEdgeId edgeId = RiverEdgeId.of(childId, parentId); + if (!gate(hash(edgeId, WORM_CHILD_GATE_SALT), chance)) { + return parent; } - return noise; + return selectWeighted(parent.children(), hash(edgeId, WORM_CHILD_SELECTION_SALT)); } - private MeanderProfile meanderProfile(RiverEdgeId id) { - int personalityIndex = (int) StrictMath.floor( - unit(hash(id, MEANDER_PERSONALITY_SALT)) * MEANDER_PERSONALITIES.length - ); - MeanderPersonality personality = MEANDER_PERSONALITIES[ - StrictMath.min(MEANDER_PERSONALITIES.length - 1, personalityIndex) - ]; - double amplitudeRoll = unit(hash(id, MEANDER_AMPLITUDE_SALT)); - double amplitude = personality == MeanderPersonality.QUIET - ? 0.16D + amplitudeRoll * 0.24D - : 0.48D + amplitudeRoll * 0.52D; - double maximumCycles = StrictMath.max(1D, StrictMath.min(8D, options.meanderSubdivisions() / 5D)); - double cycleRoll = unit(hash(id, MEANDER_CYCLE_SALT)); - double cycles = switch (personality) { - case SWEEP, HOOK, QUIET -> 0.45D + cycleRoll * 0.65D; - case S_CURVE -> 0.75D + cycleRoll * StrictMath.min(1.25D, maximumCycles); - case OXBOW -> 1D + cycleRoll * StrictMath.min(1.5D, maximumCycles); - case COIL -> StrictMath.min(maximumCycles, 2.25D + cycleRoll * 4.75D); - case WANDER -> 0.65D + cycleRoll * StrictMath.min(2.35D, maximumCycles); - case CHIRP -> StrictMath.min(maximumCycles, 1.5D + cycleRoll * 5.5D); - }; - double handedness = (hash(id, MEANDER_SALT) & 1L) == 0L ? -1D : 1D; - return new MeanderProfile( - personality, - amplitude, - handedness, - unit(hash(id, MEANDER_PHASE_SALT)) * TWO_PI, - centered(hash(id, MEANDER_SKEW_SALT)) * 0.72D, - cycles, - unit(hash(id, MEANDER_FEATURE_A_SALT)), - unit(hash(id, MEANDER_FEATURE_B_SALT)) - ); - } - - private double[] meanderSignal(RiverEdgeId id, MeanderProfile profile, double[] noise) { - int subdivisions = noise.length - 1; - for (int point = 0; point < noise.length; point++) { - double t = (double) point / subdivisions; - noise[point] = noise[point] * 0.78D + smoothEdgeNoise(id, t * 3.5D) * 0.22D; + private RiverWorm selectWeighted(List worms, long selectionHash) { + double totalWeight = 0D; + for (RiverWorm worm : worms) { + totalWeight += worm.weight(); } - smoothSignal(noise); - double detailCycles = StrictMath.max(0.5D, profile.cycles() + profile.featureB() * 1.5D); - double baseStep = TWO_PI * detailCycles / subdivisions; - double phase = profile.phase() + profile.featureA() * StrictMath.PI; - double[] signal = new double[noise.length]; - for (int point = 0; point < noise.length; point++) { - double t = (double) point / subdivisions; - if (point > 0) { - double frequencyNoise = (noise[point - 1] + noise[point]) * 0.25D + 0.5D; - phase += baseStep * (0.45D + frequencyNoise * 1.1D); + double selection = unit(selectionHash) * totalWeight; + double cumulative = 0D; + for (RiverWorm worm : worms) { + cumulative += worm.weight(); + if (selection < cumulative) { + return worm; } - double warpedT = warpMeanderPosition(t, profile.skew()); - double macro = personalitySignal(profile, warpedT, noise[point]); - double detailWeight = detailWeight(profile.personality()); - double detail = detailWeight <= 0D - ? 0D - : StrictMath.sin(phase) * (0.65D + StrictMath.abs(noise[point]) * 0.35D); - double noiseWeight = noiseWeight(profile.personality()); - double macroWeight = 1D - detailWeight - noiseWeight; - signal[point] = clampSigned(profile.amplitude() - * (macro * macroWeight + detail * detailWeight + noise[point] * noiseWeight)); } - smoothSignal(signal); - return signal; + return worms.get(worms.size() - 1); } - private double personalitySignal(MeanderProfile profile, double t, double noise) { - double handedness = profile.handedness(); - return switch (profile.personality()) { - case SWEEP -> handedness * (0.68D + StrictMath.sin(StrictMath.PI * t) * 0.32D); - case HOOK -> handedness * (0.12D + smoothStep( - profile.featureA() < 0.5D ? t : 1D - t - ) * 0.88D); - case S_CURVE -> handedness * StrictMath.sin( - TWO_PI * profile.cycles() * t + profile.phase() - ); - case COIL -> StrictMath.sin(TWO_PI * profile.cycles() * t + profile.phase()) - * (0.62D + StrictMath.sin(StrictMath.PI * t) * 0.38D); - case WANDER -> clampSigned( - noise * 0.82D - + StrictMath.sin(TWO_PI * profile.cycles() * t + profile.phase()) * 0.38D - + handedness * 0.12D - ); - case OXBOW -> handedness * clampSigned( - compactLobe(t, 0.18D + profile.featureA() * 0.2D, 0.24D + profile.featureB() * 0.12D) - - compactLobe(t, 0.62D + profile.featureB() * 0.2D, 0.22D + profile.featureA() * 0.14D) - * (0.55D + profile.featureA() * 0.45D) - ); - case CHIRP -> StrictMath.sin( - profile.phase() + TWO_PI * (0.45D * t + profile.cycles() * t * t) - ); - case QUIET -> handedness * (0.72D + noise * 0.28D); + private double perlin(double x, double z, double wavelength, long salt) { + double scaledX = x / wavelength; + double scaledZ = z / wavelength; + long minimumX = (long) StrictMath.floor(scaledX); + long minimumZ = (long) StrictMath.floor(scaledZ); + double fractionX = scaledX - minimumX; + double fractionZ = scaledZ - minimumZ; + double fadeX = perlinFade(fractionX); + double fadeZ = perlinFade(fractionZ); + double northwest = perlinGradient(minimumX, minimumZ, fractionX, fractionZ, salt); + double northeast = perlinGradient(minimumX + 1L, minimumZ, fractionX - 1D, fractionZ, salt); + double southwest = perlinGradient(minimumX, minimumZ + 1L, fractionX, fractionZ - 1D, salt); + double southeast = perlinGradient( + minimumX + 1L, + minimumZ + 1L, + fractionX - 1D, + fractionZ - 1D, + salt + ); + double north = northwest + (northeast - northwest) * fadeX; + double south = southwest + (southeast - southwest) * fadeX; + return StrictMath.max( + -1D, + StrictMath.min(1D, (north + (south - north) * fadeZ) * PERLIN_NORMALIZATION) + ); + } + + private double perlinGradient(long latticeX, long latticeZ, double x, double z, long salt) { + return switch ((int) (hash(latticeX, latticeZ, salt) & 7L)) { + case 0 -> x; + case 1 -> -x; + case 2 -> z; + case 3 -> -z; + case 4 -> (x + z) / PERLIN_NORMALIZATION; + case 5 -> (-x + z) / PERLIN_NORMALIZATION; + case 6 -> (x - z) / PERLIN_NORMALIZATION; + default -> (-x - z) / PERLIN_NORMALIZATION; }; } - private double detailWeight(MeanderPersonality personality) { - return switch (personality) { - case SWEEP, HOOK, OXBOW, QUIET -> 0D; - case S_CURVE -> 0.12D; - case COIL, WANDER -> 0.08D; - case CHIRP -> 0.1D; - }; + private double perlinFade(double value) { + double squared = value * value; + double cubed = squared * value; + return cubed * (value * (value * 6D - 15D) + 10D); } - private double noiseWeight(MeanderPersonality personality) { - return switch (personality) { - case SWEEP, S_CURVE, COIL, OXBOW, CHIRP -> 0.08D; - case HOOK -> 0.1D; - case WANDER -> 0.34D; - case QUIET -> 0.14D; - }; - } - - private double warpMeanderPosition(double t, double skew) { - return t + skew * t * (1D - t); - } - - private double compactLobe(double t, double center, double radius) { - double distance = StrictMath.abs(t - center) / radius; - if (distance >= 1D) { - return 0D; - } - return smoothStep(1D - distance); - } - - private double smoothStep(double value) { - double clamped = StrictMath.max(0D, StrictMath.min(1D, value)); - return clamped * clamped * (3D - 2D * clamped); - } - - private void smoothSignal(double[] signal) { - if (signal.length < 3) { - return; - } - double previousRaw = signal[0]; - double currentRaw = signal[1]; - for (int point = 1; point < signal.length - 1; point++) { - double nextRaw = signal[point + 1]; - signal[point] = (previousRaw + currentRaw * 2D + nextRaw) * 0.25D; - previousRaw = currentRaw; - currentRaw = nextRaw; - } - } - - private double clampSigned(double value) { - return StrictMath.max(-1D, StrictMath.min(1D, value)); - } - - private FlowTangent localNormal(double[] x, double[] z, int point) { - int previous = StrictMath.max(0, point - 1); - int next = StrictMath.min(x.length - 1, point + 1); - double tangentX = x[next] - x[previous]; - double tangentZ = z[next] - z[previous]; - double tangentLength = StrictMath.hypot(tangentX, tangentZ); - if (tangentLength <= 0.0000001D) { - return new FlowTangent(0D, 0D); - } - return new FlowTangent(-tangentZ / tangentLength, tangentX / tangentLength); - } - - private FlowTangent flowTangent( - RiverNode node, - double fallbackX, - double fallbackZ, - NodeResolver resolver + private double bodyMultiplier( + ReachPosition position, + RiverWorm worm, + long primarySalt, + long detailSalt, + double variation ) { - FlowTangent preferred = resolver.flowTangent(node); - double tangentX = preferred.x(); - double tangentZ = preferred.z(); - if (tangentX == 0D && tangentZ == 0D) { - return new FlowTangent(fallbackX, fallbackZ); + if (variation <= 0D) { + return 1D; } - double alignment = tangentX * fallbackX + tangentZ * fallbackZ; - if (alignment < 0D) { - tangentX = -tangentX; - tangentZ = -tangentZ; - alignment = -alignment; + return StrictMath.max( + 0.125D, + 1D + bodyField(position, worm, primarySalt, detailSalt) * variation + ); + } + + private double roofScale(ReachPosition position, RiverWorm worm) { + if (worm.roofVariation() <= 0D) { + return 1D; } - if (alignment < 0.15D) { - return new FlowTangent(fallbackX, fallbackZ); - } - return new FlowTangent(tangentX, tangentZ); + double normalized = bodyField( + position, + worm, + BODY_ROOF_PRIMARY_SALT, + BODY_ROOF_DETAIL_SALT + ) * 0.5D + 0.5D; + return StrictMath.max(0.125D, 1D - normalized * worm.roofVariation()); + } + + private double bodyField( + ReachPosition position, + RiverWorm worm, + long primarySalt, + long detailSalt + ) { + double primary = perlin( + position.x(), + position.z(), + worm.bodyWavelength(), + worm.seed() ^ primarySalt + ); + double detail = perlin( + position.x(), + position.z(), + worm.bodyDetailWavelength(), + worm.seed() ^ detailSalt + ); + return primary * 0.7D + detail * 0.3D; } private FlowTangent resolveFlowTangent(RiverNode node, RiverTerrainSampler terrain) { @@ -771,16 +712,6 @@ public final class RiverNetwork { return options.flowAlignmentWeight() * (1D - StrictMath.min(1D, alignment)); } - private double smoothEdgeNoise(RiverEdgeId id, double position) { - int lower = (int) StrictMath.floor(position); - int upper = lower + 1; - double fraction = position - lower; - double fade = fraction * fraction * (3.0 - 2.0 * fraction); - double a = centered(mix(options.seed() ^ id.stableId() ^ MEANDER_SALT ^ lower * 0x9E3779B97F4A7C15L)); - double b = centered(mix(options.seed() ^ id.stableId() ^ MEANDER_SALT ^ upper * 0x9E3779B97F4A7C15L)); - return a + (b - a) * fade; - } - private boolean intersects( RiverReach reach, long minimumX, @@ -812,8 +743,8 @@ public final class RiverNetwork { long maximumZ ) { double length = StrictMath.hypot(to.x() - from.x(), to.z() - from.z()); - double maximumMeander = StrictMath.min(options.meanderStrength(), length * 0.35D); - double padding = options.maximumReachRadius() + maximumMeander; + double maximumWormOffset = StrictMath.min(options.maximumWormOffset(), length * 0.35D); + double padding = options.maximumReachRadius() + maximumWormOffset; double reachMinimumX = StrictMath.min(from.x(), to.x()) - padding; double reachMaximumX = StrictMath.max(from.x(), to.x()) + padding; double reachMinimumZ = StrictMath.min(from.z(), to.z()) - padding; @@ -825,8 +756,8 @@ public final class RiverNetwork { private int geometryPaddingCells() { double maximumEdgeAxisDelta = options.cellSize() * (1D + options.siteJitter()); double maximumEdgeLength = StrictMath.sqrt(2D) * maximumEdgeAxisDelta; - double maximumMeander = StrictMath.min(options.meanderStrength(), maximumEdgeLength * 0.35D); - double displacement = options.maximumReachRadius() + maximumMeander; + double maximumWormOffset = StrictMath.min(options.maximumWormOffset(), maximumEdgeLength * 0.35D); + double displacement = options.maximumReachRadius() + maximumWormOffset; return 1 + (int) StrictMath.ceil(displacement / options.cellSize()); } @@ -917,7 +848,12 @@ public final class RiverNetwork { private final Map routingContexts; private final Map flowTangents; private final Map branchSlots; + private final Map styleDistances; + private final Map styleParents; + private final Map styleWorms; + private final Map styleBranchSlots; private final Map resolvedBranchParents; + private final Map resolvedStyleBranchParents; private NodeResolver(RiverTerrainSampler terrain) { this.terrain = terrain; @@ -932,7 +868,12 @@ public final class RiverNetwork { routingContexts = new HashMap<>(); flowTangents = new HashMap<>(); branchSlots = new HashMap<>(); + styleDistances = new HashMap<>(); + styleParents = new HashMap<>(); + styleWorms = new HashMap<>(); + styleBranchSlots = new HashMap<>(); resolvedBranchParents = new HashMap<>(); + resolvedStyleBranchParents = new HashMap<>(); } private RiverNode resolve(RiverNodeId id) { @@ -1099,11 +1040,88 @@ public final class RiverNetwork { private RiverRoutingContext routingContext(RiverNode from, RiverNode to) { RiverEdgeId edgeId = RiverEdgeId.of(from.id(), to.id()); + RiverWorm worm = worm(from, to); return routingContexts.computeIfAbsent(edgeId, ignored -> RiverRoutingContext.lazy( edgeId, from, to, - () -> createPolyline(edgeId, from, to, this))); + () -> createPolyline(from, to, worm))); + } + + private RiverWorm worm(RiverNode first, RiverNode second) { + RiverNode child = compareRank(first, second) > 0 ? first : second; + return styleWorm(child.id()); + } + + private RiverWorm styleWorm(RiverNodeId id) { + RiverWorm cached = styleWorms.get(id); + if (cached != null) { + return cached; + } + StyleParent parent = styleParent(id); + RiverWorm selected; + if (parent.id() == null) { + selected = rootWormFor(id); + } else { + RiverWorm parentWorm = styleWorm(parent.id()); + selected = childWormFor( + parentWorm, + parent.id(), + id, + styleBranchSlot(parent.id(), id) + ); + } + styleWorms.put(id, selected); + return selected; + } + + private StyleParent styleParent(RiverNodeId id) { + return styleParents.computeIfAbsent(id, nodeId -> { + double nodeDistance = styleDistance(nodeId); + RiverNodeId selected = null; + double selectedDistance = Double.POSITIVE_INFINITY; + for (RiverNodeId candidate : neighbors(nodeId)) { + double candidateDistance = styleDistance(candidate); + if (candidateDistance >= nodeDistance - 0.000000001D) { + continue; + } + if (candidateDistance < selectedDistance + || candidateDistance == selectedDistance + && (selected == null || candidate.compareTo(selected) < 0)) { + selected = candidate; + selectedDistance = candidateDistance; + } + } + return new StyleParent(selected); + }); + } + + private double styleDistance(RiverNodeId id) { + return styleDistances.computeIfAbsent(id, RiverNetwork.this::drainageDistance); + } + + private int styleBranchSlot(RiverNodeId parentId, RiverNodeId childId) { + if (!resolvedStyleBranchParents.containsKey(parentId)) { + ArrayList children = new ArrayList<>(8); + for (RiverNodeId candidate : neighbors(parentId)) { + StyleParent candidateParent = styleParent(candidate); + if (parentId.equals(candidateParent.id())) { + children.add(candidate); + } + } + children.sort((first, second) -> { + long firstPriority = hash(RiverEdgeId.of(first, parentId), BRANCH_SLOT_SALT); + long secondPriority = hash(RiverEdgeId.of(second, parentId), BRANCH_SLOT_SALT); + int priorityComparison = Long.compareUnsigned(firstPriority, secondPriority); + return priorityComparison != 0 ? priorityComparison : first.compareTo(second); + }); + for (int slot = 0; slot < children.size(); slot++) { + RiverNodeId child = children.get(slot); + styleBranchSlots.put(RiverEdgeId.of(child, parentId), slot); + } + resolvedStyleBranchParents.put(parentId, true); + } + return styleBranchSlots.getOrDefault(RiverEdgeId.of(childId, parentId), Integer.MAX_VALUE); } private FlowTangent flowTangent(RiverNode node) { @@ -1122,27 +1140,13 @@ public final class RiverNetwork { private record FlowTangent(double x, double z) { } - private record MeanderProfile( - MeanderPersonality personality, - double amplitude, - double handedness, - double phase, - double skew, - double cycles, - double featureA, - double featureB - ) { + private record DrainageBasinId(long x, long z) { } - private enum MeanderPersonality { - QUIET, - SWEEP, - HOOK, - S_CURVE, - COIL, - WANDER, - OXBOW, - CHIRP + private record DrainageBasin(DrainageBasinId id, double distance) { + } + + private record StyleParent(RiverNodeId id) { } private record SourceTileId(long tileX, long tileZ) { @@ -1156,6 +1160,7 @@ public final class RiverNetwork { private final RiverNode from; private final RiverNode to; private final RiverRoutingContext context; + private final RiverWorm worm; private final RiverTerrainSampler terrain; private int wetFlow; private int dryFlow; @@ -1167,12 +1172,14 @@ public final class RiverNetwork { RiverNode from, RiverNode to, RiverRoutingContext context, + RiverWorm worm, RiverTerrainSampler terrain ) { this.id = id; this.from = from; this.to = to; this.context = context; + this.worm = worm; this.terrain = terrain; } @@ -1193,21 +1200,7 @@ public final class RiverNetwork { private RiverReach build() { int flow = wetFlow + dryFlow; int order = 1 + (31 - Integer.numberOfLeadingZeros(flow)); - double bankWidth = nonNegativeOrFallback( - terrain.bankWidth(context, options.bankWidth()), - options.bankWidth() - ); - double baseDepth = positiveOrFallback( - terrain.depth(context, options.depth()), - options.depth() - ); - RiverWidthProfile widthProfile = widthProfile(order); - double width = widthProfile.maximum(); - bankWidth = StrictMath.min(options.maxBankWidth(), bankWidth); - double depth = StrictMath.min( - options.maxDepth(), - baseDepth * (1.0 + options.orderDepthFactor() * (order - 1)) - ); + RiverBodyProfile bodyProfile = bodyProfile(order, worm); RiverRouteState state = wetFlow > 0 ? RiverRouteState.WET : RiverRouteState.DRY; return new RiverReach( id, @@ -1216,10 +1209,10 @@ public final class RiverNetwork { state, flow, order, - width, - widthProfile, - bankWidth, - depth, + bodyProfile.maximumWidth(), + bodyProfile.maximumBankWidth(), + bodyProfile.maximumDepth(), + bodyProfile, state == RiverRouteState.WET && to.ocean(), state == RiverRouteState.WET ? terminalWetFlow == wetFlow @@ -1228,12 +1221,21 @@ public final class RiverNetwork { ); } - private RiverWidthProfile widthProfile(int order) { + private RiverBodyProfile bodyProfile(int order, RiverWorm worm) { RiverPolyline polyline = context.polyline(); - int sampleCount = Math.min(WIDTH_PROFILE_SAMPLES, Math.max(2, polyline.size())); + double minimumWavelength = StrictMath.min(worm.bodyWavelength(), worm.bodyDetailWavelength()); + int resolvedSamples = 1 + (int) StrictMath.ceil(polyline.length() * 2D / minimumWavelength); + int sampleCount = StrictMath.max( + MINIMUM_BODY_PROFILE_SAMPLES, + StrictMath.min(MAXIMUM_BODY_PROFILE_SAMPLES, resolvedSamples) + ); double[] positions = new double[sampleCount]; double[] widths = new double[sampleCount]; - double orderScale = 1D + options.orderWidthFactor() * (order - 1); + double[] bankWidths = new double[sampleCount]; + double[] depths = new double[sampleCount]; + double[] roofScales = new double[sampleCount]; + double widthOrderScale = 1D + options.orderWidthFactor() * (order - 1); + double depthOrderScale = 1D + options.orderDepthFactor() * (order - 1); for (int index = 0; index < sampleCount; index++) { double alongReach = (double) index / (sampleCount - 1); ReachPosition position = positionAt(polyline, alongReach); @@ -1249,10 +1251,59 @@ public final class RiverNetwork { positions[index] = alongReach; widths[index] = StrictMath.min( options.maxChannelWidth(), - StrictMath.max(1D, baseWidth * orderScale) + StrictMath.max( + 1D, + baseWidth + * widthOrderScale + * worm.widthMultiplier() + * bodyMultiplier( + position, + worm, + BODY_WIDTH_PRIMARY_SALT, + BODY_WIDTH_DETAIL_SALT, + worm.widthVariation() + ) + ) ); + double baseBankWidth = nonNegativeOrFallback( + terrain.bankWidth(context, position.x(), position.z(), options.bankWidth()), + options.bankWidth() + ); + bankWidths[index] = StrictMath.min( + options.maxBankWidth(), + baseBankWidth + * worm.bankMultiplier() + * bodyMultiplier( + position, + worm, + BODY_BANK_PRIMARY_SALT, + BODY_BANK_DETAIL_SALT, + worm.bankVariation() + ) + ); + double baseDepth = positiveOrFallback( + terrain.depth(context, position.x(), position.z(), options.depth()), + options.depth() + ); + depths[index] = StrictMath.min( + options.maxDepth(), + StrictMath.max( + 1D, + baseDepth + * depthOrderScale + * worm.depthMultiplier() + * bodyMultiplier( + position, + worm, + BODY_DEPTH_PRIMARY_SALT, + BODY_DEPTH_DETAIL_SALT, + worm.depthVariation() + ) + ) + ); + roofScales[index] = roofScale(position, worm); } - return new RiverWidthProfile(positions, widths); + return new RiverBodyProfile(positions, widths, bankWidths, depths, roofScales); } } diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverNetworkOptions.java b/core/src/main/java/art/arcane/iris/engine/river/RiverNetworkOptions.java index 98fa0ed8e..dda9cf955 100644 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverNetworkOptions.java +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverNetworkOptions.java @@ -1,5 +1,9 @@ package art.arcane.iris.engine.river; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + public record RiverNetworkOptions( long seed, int cellSize, @@ -21,8 +25,6 @@ public record RiverNetworkOptions( double routingNoiseWeight, double flowAlignmentWeight, double confluenceWeight, - int branchSoftCap, - double branchChildShrinkFactor, double oceanAttraction, double channelWidth, double bankWidth, @@ -33,8 +35,7 @@ public record RiverNetworkOptions( double orderWidthFactor, double orderDepthFactor, double maximumReachRadius, - double meanderStrength, - int meanderSubdivisions + List worms ) { public RiverNetworkOptions { requireRange(cellSize, 8, 4096, "cellSize"); @@ -47,7 +48,6 @@ public record RiverNetworkOptions( requireRange(routingDeviationStrengthCells, 0D, 32D, "routingDeviationStrengthCells"); requirePositive(routingPlateauHeight, "routingPlateauHeight"); requireFinite(hydraulicBaseHeight, "hydraulicBaseHeight"); - requireRange(meanderSubdivisions, 1, 64, "meanderSubdivisions"); requireProbability(siteJitter, "siteJitter"); requireProbability(sourceChance, "sourceChance"); requireProbability(reachChance, "reachChance"); @@ -56,8 +56,6 @@ public record RiverNetworkOptions( requireFiniteNonNegative(routingNoiseWeight, "routingNoiseWeight"); requireFiniteNonNegative(flowAlignmentWeight, "flowAlignmentWeight"); requireFiniteNonNegative(confluenceWeight, "confluenceWeight"); - requireRange(branchSoftCap, 1, 8, "branchSoftCap"); - requireProbability(branchChildShrinkFactor, "branchChildShrinkFactor"); requireFiniteNonNegative(oceanAttraction, "oceanAttraction"); requirePositive(channelWidth, "channelWidth"); requireFiniteNonNegative(bankWidth, "bankWidth"); @@ -68,15 +66,27 @@ public record RiverNetworkOptions( requireFiniteNonNegative(orderWidthFactor, "orderWidthFactor"); requireFiniteNonNegative(orderDepthFactor, "orderDepthFactor"); requireFiniteNonNegative(maximumReachRadius, "maximumReachRadius"); - requireFiniteNonNegative(meanderStrength, "meanderStrength"); + if (worms == null || worms.isEmpty()) { + throw new IllegalArgumentException("worms must contain at least one profile"); + } + if (worms.size() > 16) { + throw new IllegalArgumentException("worms must contain at most 16 root profiles"); + } + Set ids = new HashSet(); + Set seeds = new HashSet(); + int wormCount = validateWormTree(worms, 1, ids, seeds); + if (wormCount > 128) { + throw new IllegalArgumentException("worm hierarchy must contain at most 128 profiles"); + } + worms = List.copyOf(worms); RiverTopologyComplexity.requireSafe( cellSize, tileCells, siteJitter, maxRouteReaches, maximumReachRadius, - meanderStrength, - meanderSubdivisions + maximumWormOffset(worms), + maximumWormSegments(worms) ); } @@ -84,6 +94,66 @@ public record RiverNetworkOptions( return new Builder(seed); } + public double maximumWormOffset() { + return maximumWormOffset(worms); + } + + public int maximumWormSegments() { + return maximumWormSegments(worms); + } + + private static double maximumWormOffset(List worms) { + double maximum = 0D; + for (RiverWorm worm : worms) { + if (worm == null) { + throw new IllegalArgumentException("worms must not contain null profiles"); + } + maximum = StrictMath.max(maximum, worm.maxOffset()); + maximum = StrictMath.max(maximum, maximumWormOffset(worm.children())); + } + return maximum; + } + + private static int maximumWormSegments(List worms) { + int maximum = 1; + for (RiverWorm worm : worms) { + if (worm == null) { + throw new IllegalArgumentException("worms must not contain null profiles"); + } + maximum = StrictMath.max(maximum, worm.segments()); + maximum = StrictMath.max(maximum, maximumWormSegments(worm.children())); + } + return maximum; + } + + private static int validateWormTree( + List worms, + int depth, + Set ids, + Set seeds + ) { + if (depth > 4) { + throw new IllegalArgumentException("worm hierarchy must be at most 4 profiles deep"); + } + int count = 0; + for (RiverWorm worm : worms) { + if (worm == null) { + throw new IllegalArgumentException("worm hierarchy must not contain null profiles"); + } + if (!ids.add(worm.id())) { + throw new IllegalArgumentException("worm ids must be unique: " + worm.id()); + } + if (!seeds.add(worm.seed())) { + throw new IllegalArgumentException("worm seeds must be unique: " + worm.seed()); + } + count++; + if (!worm.children().isEmpty()) { + count += validateWormTree(worm.children(), depth + 1, ids, seeds); + } + } + return count; + } + private static void requireRange(int value, int minimum, int maximum, String name) { if (value < minimum || value > maximum) { throw new IllegalArgumentException(name + " must be between " + minimum + " and " + maximum); @@ -141,8 +211,6 @@ public record RiverNetworkOptions( private double routingNoiseWeight; private double flowAlignmentWeight; private double confluenceWeight; - private int branchSoftCap; - private double branchChildShrinkFactor; private double oceanAttraction; private double channelWidth; private double bankWidth; @@ -153,8 +221,7 @@ public record RiverNetworkOptions( private double orderWidthFactor; private double orderDepthFactor; private double maximumReachRadius; - private double meanderStrength; - private int meanderSubdivisions; + private List worms; private Builder(long seed) { this.seed = seed; @@ -177,8 +244,6 @@ public record RiverNetworkOptions( routingNoiseWeight = 24.0; flowAlignmentWeight = 0D; confluenceWeight = 0D; - branchSoftCap = 4; - branchChildShrinkFactor = 0.35D; oceanAttraction = 64.0; channelWidth = 10.0; bankWidth = 8.0; @@ -189,8 +254,32 @@ public record RiverNetworkOptions( orderWidthFactor = 0.35; orderDepthFactor = 0.2; maximumReachRadius = Double.NaN; - meanderStrength = 40.0; - meanderSubdivisions = 8; + worms = List.of(new RiverWorm( + "default", + 1L, + 1D, + 1024D, + 256D, + 0.5D, + 0.15D, + 40D, + 8, + 1D, + 1D, + 1D, + 512D, + 128D, + 0D, + 0D, + 0D, + 0D, + 4, + 0.35D, + 1D, + 0D, + 0D, + List.of() + )); } public Builder cellSize(int value) { @@ -288,16 +377,6 @@ public record RiverNetworkOptions( return this; } - public Builder branchSoftCap(int value) { - branchSoftCap = value; - return this; - } - - public Builder branchChildShrinkFactor(double value) { - branchChildShrinkFactor = value; - return this; - } - public Builder oceanAttraction(double value) { oceanAttraction = value; return this; @@ -348,13 +427,8 @@ public record RiverNetworkOptions( return this; } - public Builder meanderStrength(double value) { - meanderStrength = value; - return this; - } - - public Builder meanderSubdivisions(int value) { - meanderSubdivisions = value; + public Builder worms(List value) { + worms = value; return this; } @@ -383,8 +457,6 @@ public record RiverNetworkOptions( routingNoiseWeight, flowAlignmentWeight, confluenceWeight, - branchSoftCap, - branchChildShrinkFactor, oceanAttraction, channelWidth, bankWidth, @@ -395,8 +467,7 @@ public record RiverNetworkOptions( orderWidthFactor, orderDepthFactor, resolvedMaximumReachRadius, - meanderStrength, - meanderSubdivisions + worms ); } diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverReach.java b/core/src/main/java/art/arcane/iris/engine/river/RiverReach.java index 8abfe4b17..383b224d9 100644 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverReach.java +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverReach.java @@ -10,9 +10,9 @@ public record RiverReach( int flow, int order, double width, - RiverWidthProfile widthProfile, double bankWidth, double depth, + RiverBodyProfile bodyProfile, boolean mouth, boolean terminal, RiverPolyline polyline @@ -22,7 +22,7 @@ public record RiverReach( Objects.requireNonNull(from); Objects.requireNonNull(to); Objects.requireNonNull(state); - Objects.requireNonNull(widthProfile); + Objects.requireNonNull(bodyProfile); Objects.requireNonNull(polyline); if (state == RiverRouteState.SUPPRESSED) { throw new IllegalArgumentException("Suppressed routes cannot produce reaches"); @@ -34,12 +34,26 @@ public record RiverReach( || !Double.isFinite(depth) || depth <= 0.0) { throw new IllegalArgumentException("River reach dimensions must be finite and valid"); } - if (Double.compare(width, widthProfile.maximum()) != 0) { - throw new IllegalArgumentException("River reach width must equal its profile maximum"); + if (Double.compare(width, bodyProfile.maximumWidth()) != 0 + || Double.compare(bankWidth, bodyProfile.maximumBankWidth()) != 0 + || Double.compare(depth, bodyProfile.maximumDepth()) != 0) { + throw new IllegalArgumentException("River reach dimensions must equal their body-profile maxima"); } } public double widthAt(double alongReach) { - return widthProfile.sample(alongReach); + return bodyProfile.width(alongReach); + } + + public double bankWidthAt(double alongReach) { + return bodyProfile.bankWidth(alongReach); + } + + public double depthAt(double alongReach) { + return bodyProfile.depth(alongReach); + } + + public double roofScaleAt(double alongReach) { + return bodyProfile.roofScale(alongReach); } } diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverTerrainSampler.java b/core/src/main/java/art/arcane/iris/engine/river/RiverTerrainSampler.java index 445657cf7..1fc250bec 100644 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverTerrainSampler.java +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverTerrainSampler.java @@ -50,10 +50,6 @@ public interface RiverTerrainSampler { return 0.0; } - default double meanderNoise(RiverMeanderContext context) { - return Double.NaN; - } - default double flowNoise(double x, double z) { return Double.NaN; } @@ -75,10 +71,28 @@ public interface RiverTerrainSampler { return fallback; } + default double bankWidth( + RiverRoutingContext context, + double x, + double z, + double fallback + ) { + return bankWidth(context, fallback); + } + default double depth(RiverRoutingContext context, double fallback) { return fallback; } + default double depth( + RiverRoutingContext context, + double x, + double z, + double fallback + ) { + return depth(context, fallback); + } + default RiverTerminalPolicy terminalPolicy(int blockX, int blockZ) { return RiverTerminalPolicy.INHERIT; } diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverTile.java b/core/src/main/java/art/arcane/iris/engine/river/RiverTile.java index 483d953bf..91108dc20 100644 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverTile.java +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverTile.java @@ -210,9 +210,11 @@ public final class RiverTile { double distance = StrictMath.sqrt(nearestDistanceSquared); double localWidth = nearestReach.widthAt(nearestAlongReach); + double localBankWidth = nearestReach.bankWidthAt(nearestAlongReach); + double localDepth = nearestReach.depthAt(nearestAlongReach); double channelRadius = localWidth * 0.5; RiverSection section = section(nearestReach, distance, channelRadius); - double carveWeight = carveWeight(distance, channelRadius, nearestReach.bankWidth()); + double carveWeight = carveWeight(distance, channelRadius, localBankWidth); return new RiverSample( true, nearestReach.state(), @@ -223,8 +225,8 @@ public final class RiverTile { nearestReach.flow(), nearestReach.order(), localWidth, - nearestReach.bankWidth(), - nearestReach.depth(), + localBankWidth, + localDepth, nearestReach.terminal(), nearestReach.id() ); @@ -325,7 +327,7 @@ public final class RiverTile { RiverPolyline polyline = reach.polyline(); if (polyline.length() == 0D) { double distanceSquared = squared(x - polyline.x(0)) + squared(z - polyline.z(0)); - double radius = reach.widthAt(0D) * 0.5D + reach.bankWidth() + additionalRadius; + double radius = reach.widthAt(0D) * 0.5D + reach.bankWidthAt(0D) + additionalRadius; return distanceSquared <= radius * radius ? new ClosestPoint(distanceSquared, 0D) : null; } double nearest = Double.POSITIVE_INFINITY; @@ -339,9 +341,9 @@ public final class RiverTile { } double deltaX = polyline.x(point + 1) - polyline.x(point); double deltaZ = polyline.z(point + 1) - polyline.z(point); - for (int profileIndex = 0; profileIndex < reach.widthProfile().size() - 1; profileIndex++) { - double profileStart = reach.widthProfile().position(profileIndex); - double profileEnd = reach.widthProfile().position(profileIndex + 1); + for (int profileIndex = 0; profileIndex < reach.bodyProfile().size() - 1; profileIndex++) { + double profileStart = reach.bodyProfile().position(profileIndex); + double profileEnd = reach.bodyProfile().position(profileIndex + 1); double overlapStart = StrictMath.max(segmentStartAlong, profileStart); double overlapEnd = StrictMath.min(segmentEndAlong, profileEnd); if (overlapStart > overlapEnd) { @@ -349,13 +351,16 @@ public final class RiverTile { } double intervalStart = (overlapStart - segmentStartAlong) / segmentAlongSpan; double intervalEnd = (overlapEnd - segmentStartAlong) / segmentAlongSpan; - double widthSlope = (reach.widthProfile().width(profileIndex + 1) - - reach.widthProfile().width(profileIndex)) / (profileEnd - profileStart); - double radiusBase = (reach.widthProfile().width(profileIndex) + double widthSlope = (reach.bodyProfile().widthAtIndex(profileIndex + 1) + - reach.bodyProfile().widthAtIndex(profileIndex)) / (profileEnd - profileStart); + double bankSlope = (reach.bodyProfile().bankWidthAtIndex(profileIndex + 1) + - reach.bodyProfile().bankWidthAtIndex(profileIndex)) / (profileEnd - profileStart); + double radiusBase = (reach.bodyProfile().widthAtIndex(profileIndex) + widthSlope * (segmentStartAlong - profileStart)) * 0.5D - + reach.bankWidth() + + reach.bodyProfile().bankWidthAtIndex(profileIndex) + + bankSlope * (segmentStartAlong - profileStart) + additionalRadius; - double radiusSlope = widthSlope * segmentAlongSpan * 0.5D; + double radiusSlope = (widthSlope * 0.5D + bankSlope) * segmentAlongSpan; ClosestPoint candidate = coveringPoint( intervalStart, intervalEnd, @@ -394,7 +399,7 @@ public final class RiverTile { maximumX, maximumZ ); - double radius = reach.widthAt(0D) * 0.5D + reach.bankWidth(); + double radius = reach.widthAt(0D) * 0.5D + reach.bankWidthAt(0D); return distanceSquared <= radius * radius ? new ClosestPoint(distanceSquared, 0D) : null; } double nearest = Double.POSITIVE_INFINITY; @@ -410,9 +415,9 @@ public final class RiverTile { double startZ = polyline.z(point); double deltaX = polyline.x(point + 1) - startX; double deltaZ = polyline.z(point + 1) - startZ; - for (int profileIndex = 0; profileIndex < reach.widthProfile().size() - 1; profileIndex++) { - double profileStart = reach.widthProfile().position(profileIndex); - double profileEnd = reach.widthProfile().position(profileIndex + 1); + for (int profileIndex = 0; profileIndex < reach.bodyProfile().size() - 1; profileIndex++) { + double profileStart = reach.bodyProfile().position(profileIndex); + double profileEnd = reach.bodyProfile().position(profileIndex + 1); double overlapStart = StrictMath.max(segmentStartAlong, profileStart); double overlapEnd = StrictMath.min(segmentEndAlong, profileEnd); if (overlapStart > overlapEnd) { @@ -420,11 +425,15 @@ public final class RiverTile { } double intervalStart = (overlapStart - segmentStartAlong) / segmentAlongSpan; double intervalEnd = (overlapEnd - segmentStartAlong) / segmentAlongSpan; - double widthSlope = (reach.widthProfile().width(profileIndex + 1) - - reach.widthProfile().width(profileIndex)) / (profileEnd - profileStart); - double radiusBase = (reach.widthProfile().width(profileIndex) - + widthSlope * (segmentStartAlong - profileStart)) * 0.5D + reach.bankWidth(); - double radiusSlope = widthSlope * segmentAlongSpan * 0.5D; + double widthSlope = (reach.bodyProfile().widthAtIndex(profileIndex + 1) + - reach.bodyProfile().widthAtIndex(profileIndex)) / (profileEnd - profileStart); + double bankSlope = (reach.bodyProfile().bankWidthAtIndex(profileIndex + 1) + - reach.bodyProfile().bankWidthAtIndex(profileIndex)) / (profileEnd - profileStart); + double radiusBase = (reach.bodyProfile().widthAtIndex(profileIndex) + + widthSlope * (segmentStartAlong - profileStart)) * 0.5D + + reach.bodyProfile().bankWidthAtIndex(profileIndex) + + bankSlope * (segmentStartAlong - profileStart); + double radiusSlope = (widthSlope * 0.5D + bankSlope) * segmentAlongSpan; double cursor = intervalStart; do { double next = intervalEnd; diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverTopologyComplexity.java b/core/src/main/java/art/arcane/iris/engine/river/RiverTopologyComplexity.java index 8dc1fd19b..a6261ba6e 100644 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverTopologyComplexity.java +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverTopologyComplexity.java @@ -19,14 +19,12 @@ public final class RiverTopologyComplexity { double siteJitter, int maxRouteReaches, double maximumReachRadius, - double meanderStrength, - int meanderSubdivisions + double maximumWormOffset, + int maximumWormSegments ) { double maximumEdgeAxisDelta = cellSize * (1D + siteJitter); - double maximumEdgeLength = StrictMath.sqrt(2D) * maximumEdgeAxisDelta; - double maximumMeander = StrictMath.min(meanderStrength, maximumEdgeLength * 0.35D); long geometryPaddingCells = 1L + ceilToLong( - (maximumReachRadius + maximumMeander) / cellSize + (maximumReachRadius + maximumWormOffset) / cellSize ); long targetWindowAxis = saturatedAdd(tileCells, saturatedMultiply(2L, geometryPaddingCells)); long sourceWindowAxis = saturatedAdd( @@ -36,7 +34,7 @@ public final class RiverTopologyComplexity { long sourceWindowCells = saturatedMultiply(sourceWindowAxis, sourceWindowAxis); long maximumRouteScanSteps = saturatedMultiply(sourceWindowCells, maxRouteReaches); double maximumSegmentSpan = maximumEdgeAxisDelta - + maximumMeander * 2D + + maximumWormOffset * 2D + maximumReachRadius * 2D; long maximumSegmentBucketAxis = saturatedAdd( ceilToLong(maximumSegmentSpan / SPATIAL_BUCKET_SIZE), @@ -48,7 +46,7 @@ public final class RiverTopologyComplexity { ); long maximumBucketWritesPerReach = saturatedMultiply( maximumSegmentBucketCount, - meanderSubdivisions + maximumWormSegments ); return new Estimate( geometryPaddingCells, @@ -66,8 +64,8 @@ public final class RiverTopologyComplexity { double siteJitter, int maxRouteReaches, double maximumReachRadius, - double meanderStrength, - int meanderSubdivisions + double maximumWormOffset, + int maximumWormSegments ) { Estimate estimate = estimate( cellSize, @@ -75,8 +73,8 @@ public final class RiverTopologyComplexity { siteJitter, maxRouteReaches, maximumReachRadius, - meanderStrength, - meanderSubdivisions + maximumWormOffset, + maximumWormSegments ); List violations = estimate.violations(); if (!violations.isEmpty()) { @@ -205,7 +203,7 @@ public final class RiverTopologyComplexity { + " bucket writes for one reach (" + maximumSegmentBucketAxis + " buckets per segment axis), above the safe limit of " + MAXIMUM_BUCKET_WRITES_PER_REACH - + "; reduce channel width, bank width, orderWidthFactor, meanderStrength, or meanderSubdivisions."); + + "; reduce channel width, bank width, orderWidthFactor, worm maxOffset, or worm segments."); } return List.copyOf(violations); } diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverWidthProfile.java b/core/src/main/java/art/arcane/iris/engine/river/RiverWidthProfile.java deleted file mode 100644 index 93cb2baa2..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverWidthProfile.java +++ /dev/null @@ -1,89 +0,0 @@ -package art.arcane.iris.engine.river; - -import java.util.Arrays; - -public final class RiverWidthProfile { - private final double[] positions; - private final double[] widths; - private final double maximum; - - public RiverWidthProfile(double[] positions, double[] widths) { - if (positions == null || widths == null || positions.length < 2 || positions.length != widths.length) { - throw new IllegalArgumentException("River width profiles require matching position and width samples"); - } - this.positions = positions.clone(); - this.widths = widths.clone(); - double resolvedMaximum = 0D; - for (int index = 0; index < this.positions.length; index++) { - double position = this.positions[index]; - double width = this.widths[index]; - if (!Double.isFinite(position) || position < 0D || position > 1D - || (index > 0 && position <= this.positions[index - 1])) { - throw new IllegalArgumentException("River width profile positions must increase from zero to one"); - } - if (!Double.isFinite(width) || width <= 0D) { - throw new IllegalArgumentException("River width profile widths must be finite and positive"); - } - resolvedMaximum = Math.max(resolvedMaximum, width); - } - if (this.positions[0] != 0D || this.positions[this.positions.length - 1] != 1D) { - throw new IllegalArgumentException("River width profile positions must include zero and one"); - } - maximum = resolvedMaximum; - } - - public static RiverWidthProfile constant(double width) { - return new RiverWidthProfile(new double[]{0D, 1D}, new double[]{width, width}); - } - - public double sample(double alongReach) { - double position = Math.max(0D, Math.min(1D, alongReach)); - int index = Arrays.binarySearch(positions, position); - if (index >= 0) { - return widths[index]; - } - int upper = -index - 1; - if (upper <= 0) { - return widths[0]; - } - if (upper >= positions.length) { - return widths[widths.length - 1]; - } - int lower = upper - 1; - double range = positions[upper] - positions[lower]; - double interpolation = range <= 0D ? 0D : (position - positions[lower]) / range; - return widths[lower] + (widths[upper] - widths[lower]) * interpolation; - } - - public double maximum() { - return maximum; - } - - public int size() { - return widths.length; - } - - public double position(int index) { - return positions[index]; - } - - public double width(int index) { - return widths[index]; - } - - @Override - public boolean equals(Object object) { - if (this == object) { - return true; - } - if (!(object instanceof RiverWidthProfile profile)) { - return false; - } - return Arrays.equals(positions, profile.positions) && Arrays.equals(widths, profile.widths); - } - - @Override - public int hashCode() { - return 31 * Arrays.hashCode(positions) + Arrays.hashCode(widths); - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverWorm.java b/core/src/main/java/art/arcane/iris/engine/river/RiverWorm.java new file mode 100644 index 000000000..6c0d44a65 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverWorm.java @@ -0,0 +1,79 @@ +package art.arcane.iris.engine.river; + +import java.util.List; + +public record RiverWorm( + String id, + long seed, + double weight, + double wavelength, + double detailWavelength, + double tortuosity, + double detailTortuosity, + double maxOffset, + int segments, + double widthMultiplier, + double bankMultiplier, + double depthMultiplier, + double bodyWavelength, + double bodyDetailWavelength, + double widthVariation, + double bankVariation, + double depthVariation, + double roofVariation, + int branchCap, + double branchDecay, + double confluenceMultiplier, + double childChance, + double branchChildChance, + List children +) { + public RiverWorm { + if (id == null || !id.matches("[a-z0-9][a-z0-9_-]{0,63}")) { + throw new IllegalArgumentException("id must use 1 to 64 lowercase letters, digits, underscores, or hyphens"); + } + requireRange(weight, 0.000001D, 1000000D, "weight"); + requireRange(wavelength, 8D, 16384D, "wavelength"); + requireRange(detailWavelength, 8D, 16384D, "detailWavelength"); + requireRange(tortuosity, 0D, 1D, "tortuosity"); + requireRange(detailTortuosity, 0D, 1D, "detailTortuosity"); + requireRange(maxOffset, 0D, 1024D, "maxOffset"); + if (segments < 1 || segments > 64) { + throw new IllegalArgumentException("segments must be between 1 and 64"); + } + requireRange(widthMultiplier, 0.125D, 8D, "widthMultiplier"); + requireRange(bankMultiplier, 0.125D, 8D, "bankMultiplier"); + requireRange(depthMultiplier, 0.125D, 8D, "depthMultiplier"); + requireRange(bodyWavelength, 32D, 16384D, "bodyWavelength"); + requireRange(bodyDetailWavelength, 32D, 16384D, "bodyDetailWavelength"); + requireRange(widthVariation, 0D, 0.875D, "widthVariation"); + requireRange(bankVariation, 0D, 0.875D, "bankVariation"); + requireRange(depthVariation, 0D, 0.875D, "depthVariation"); + requireRange(roofVariation, 0D, 0.875D, "roofVariation"); + if (branchCap < 1 || branchCap > 8) { + throw new IllegalArgumentException("branchCap must be between 1 and 8"); + } + requireRange(branchDecay, 0D, 1D, "branchDecay"); + requireRange(confluenceMultiplier, 0D, 8D, "confluenceMultiplier"); + requireRange(childChance, 0D, 1D, "childChance"); + requireRange(branchChildChance, 0D, 1D, "branchChildChance"); + if (children == null) { + throw new IllegalArgumentException("children must not be null"); + } + if (children.size() > 16) { + throw new IllegalArgumentException("children must contain at most 16 profiles"); + } + for (RiverWorm child : children) { + if (child == null) { + throw new IllegalArgumentException("children must not contain null profiles"); + } + } + children = List.copyOf(children); + } + + private static void requireRange(double value, double minimum, double maximum, String name) { + if (!Double.isFinite(value) || value < minimum || value > maximum) { + throw new IllegalArgumentException(name + " must be between " + minimum + " and " + maximum); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntime.java b/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntime.java index 879d4546e..4cd4efb9c 100644 --- a/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntime.java +++ b/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntime.java @@ -16,11 +16,11 @@ import art.arcane.iris.engine.object.IrisRiverTerrain; import art.arcane.iris.engine.object.IrisRiverTopology; import art.arcane.iris.engine.object.IrisRiverWater; import art.arcane.iris.engine.object.IrisRiverWaterMode; +import art.arcane.iris.engine.object.IrisRiverWorm; import art.arcane.iris.engine.object.IrisStyledRange; import art.arcane.iris.engine.object.NoiseStyle; import art.arcane.iris.engine.river.RiverAnchor; import art.arcane.iris.engine.river.RiverEdgeId; -import art.arcane.iris.engine.river.RiverMeanderContext; import art.arcane.iris.engine.river.RiverNetworkOptions; import art.arcane.iris.engine.river.RiverPolyline; import art.arcane.iris.engine.river.RiverReach; @@ -35,6 +35,7 @@ import art.arcane.iris.engine.river.RiverTerminalPolicy; import art.arcane.iris.engine.river.RiverTile; import art.arcane.iris.engine.river.RiverTileCache; import art.arcane.iris.engine.river.RiverTopologyComplexity; +import art.arcane.iris.engine.river.RiverWorm; import art.arcane.iris.util.project.interpolation.NoiseBounds; import art.arcane.iris.util.project.noise.CNG; import art.arcane.iris.util.project.stream.ProceduralStream; @@ -61,7 +62,6 @@ public final class IrisRiverRuntime implements AutoCloseable { private static final long WIDTH_NOISE_SALT = 0xBE5466CF34E90C6CL; private static final long BANK_NOISE_SALT = 0xC0AC29B7C97C50DDL; private static final long DEPTH_NOISE_SALT = 0x3F84D5B5B5470917L; - private static final long MEANDER_NOISE_SALT = 0x9216D5D98979FB1BL; private static final long BED_NOISE_SALT = 0xD1310BA698DFB5ACL; private static final long BIOME_NOISE_SALT = 0x2FFD72DBD01ADFB7L; private static final long CAVE_ENTRY_NOISE_SALT = 0xB8E1AFED6A267E96L; @@ -98,7 +98,6 @@ public final class IrisRiverRuntime implements AutoCloseable { private final CNG widthNoise; private final CNG bankNoise; private final CNG depthNoise; - private final CNG meanderNoise; private final CNG bedNoise; private final CNG biomeNoise; private final CNG caveEntryNoise; @@ -144,7 +143,6 @@ public final class IrisRiverRuntime implements AutoCloseable { widthNoise = noise(terrain.getChannelWidth(), WIDTH_NOISE_SALT); bankNoise = noise(terrain.getBankWidth(), BANK_NOISE_SALT); depthNoise = noise(terrain.getDepth(), DEPTH_NOISE_SALT); - meanderNoise = noise(terrain.getMeanderStyle(), MEANDER_NOISE_SALT); bedNoise = noise(terrain.getBedRoughnessStyle(), BED_NOISE_SALT); biomeNoise = noise(configuration.getBiomes().getSelectionStyle(), BIOME_NOISE_SALT); caveEntryNoise = noise(caves.getEntry(), CAVE_ENTRY_NOISE_SALT); @@ -256,7 +254,11 @@ public final class IrisRiverRuntime implements AutoCloseable { z ); int ceilingY = shapedTunnelCeilingY( - waterHeadY, caves.getDryHeadroom(), profile, roofOffset); + waterHeadY, + caves.getDryHeadroom() * column.reach().roofScaleAt(column.river().alongReach()), + profile, + roofOffset + ); return new IrisRiverTunnelSample(column.river(), bedY, waterHeadY, ceilingY); } @@ -349,7 +351,7 @@ public final class IrisRiverRuntime implements AutoCloseable { IrisBiome sampledBiome = naturalBiome.get(center.x(), center.z()); double head = waterSurface(reach, alongReach, isNaturalOcean(sampledBiome)); double centerNaturalHeight = naturalHeight.get(center.x(), center.z()); - double centerBedHeight = head - reach.depth() + bedRoughness(center.x(), center.z()); + double centerBedHeight = head - reach.depthAt(alongReach) + bedRoughness(center.x(), center.z()); EffectiveRiverSettings settings = settingsFor(sampledRegion, sampledBiome); double maximumIncision = Math.max(0D, terrain.getMaxIncision() * settings.maxIncisionMultiplier()); double cappedSurface = incisedHeight(centerNaturalHeight, centerBedHeight, 1D, maximumIncision); @@ -627,8 +629,6 @@ public final class IrisRiverRuntime implements AutoCloseable { .routingNoiseWeight(0D) .flowAlignmentWeight(topology.getFlowAlignmentWeight()) .confluenceWeight(topology.getConfluenceWeight()) - .branchSoftCap(topology.getBranchSoftCap()) - .branchChildShrinkFactor(topology.getBranchChildShrinkFactor()) .oceanAttraction(topology.getOceanAttraction()) .channelWidth(mid(riverTerrain.getChannelWidth(), 12D)) .bankWidth(mid(riverTerrain.getBankWidth(), 8D)) @@ -639,11 +639,63 @@ public final class IrisRiverRuntime implements AutoCloseable { .orderWidthFactor(riverTerrain.getOrderWidthFactor()) .orderDepthFactor(riverTerrain.getOrderDepthFactor()) .maximumReachRadius(maximumReachRadius(topology, riverTerrain)) - .meanderStrength(riverTerrain.getMeanderStrength()) - .meanderSubdivisions(riverTerrain.getMeanderSubdivisions()) + .worms(worms(riverTerrain)) .build(); } + private List worms(IrisRiverTerrain riverTerrain) { + if (riverTerrain.getWorms() == null || riverTerrain.getWorms().isEmpty()) { + throw new IllegalArgumentException("River terrain must configure at least one Perlin worm"); + } + ArrayList worms = new ArrayList<>(riverTerrain.getWorms().size()); + for (IrisRiverWorm configured : riverTerrain.getWorms()) { + if (configured == null) { + throw new IllegalArgumentException("River terrain worms must not contain null entries"); + } + worms.add(worm(configured)); + } + return List.copyOf(worms); + } + + private RiverWorm worm(IrisRiverWorm configured) { + if (configured.getChildren() == null) { + throw new IllegalArgumentException("River worm children must be an array"); + } + ArrayList children = new ArrayList<>(configured.getChildren().size()); + for (IrisRiverWorm child : configured.getChildren()) { + if (child == null) { + throw new IllegalArgumentException("River worm children must not contain null entries"); + } + children.add(worm(child)); + } + return new RiverWorm( + configured.getId(), + configured.getSeed(), + configured.getWeight(), + configured.getWavelength(), + configured.getDetailWavelength(), + configured.getTortuosity(), + configured.getDetailTortuosity(), + configured.getMaxOffset(), + configured.getSegments(), + configured.getWidthMultiplier(), + configured.getBankMultiplier(), + configured.getDepthMultiplier(), + configured.getBodyWavelength(), + configured.getBodyDetailWavelength(), + configured.getWidthVariation(), + configured.getBankVariation(), + configured.getDepthVariation(), + configured.getRoofVariation(), + configured.getBranchCap(), + configured.getBranchDecay(), + configured.getConfluenceMultiplier(), + configured.getChildChance(), + configured.getBranchChildChance(), + List.copyOf(children) + ); + } + private void addTerminalCaveAnchors( RiverTile tile, int minimumX, @@ -1141,11 +1193,6 @@ public final class IrisRiverRuntime implements AutoCloseable { return routingCost(context.midpointX(), context.midpointZ()); } - @Override - public double meanderNoise(RiverMeanderContext context) { - return meanderNoise.fitDouble(-1D, 1D, context.x(), context.z()); - } - @Override public double flowNoise(double x, double z) { return routingNoise.fitDouble(-1D, 1D, x, z); @@ -1163,7 +1210,7 @@ public final class IrisRiverRuntime implements AutoCloseable { RiverRoutingContext context, double x, double z, - double fallback + double fallback ) { EffectiveRiverSettings settings = settingsAt(x, z); return styled( @@ -1183,6 +1230,18 @@ public final class IrisRiverRuntime implements AutoCloseable { * settings.bankWidthMultiplier(); } + @Override + public double bankWidth(RiverRoutingContext context, double x, double z, double fallback) { + EffectiveRiverSettings settings = settingsAt(x, z); + return styled( + terrain.getBankWidth(), + bankNoise, + (int) StrictMath.round(x), + (int) StrictMath.round(z), + fallback + ) * settings.bankWidthMultiplier(); + } + @Override public double depth(RiverRoutingContext context, double fallback) { EffectiveRiverSettings settings = settingsAt(context.midpointX(), context.midpointZ()); @@ -1199,6 +1258,22 @@ public final class IrisRiverRuntime implements AutoCloseable { ); } + @Override + public double depth(RiverRoutingContext context, double x, double z, double fallback) { + EffectiveRiverSettings settings = settingsAt(x, z); + double configuredDepth = styled( + terrain.getDepth(), + depthNoise, + (int) StrictMath.round(x), + (int) StrictMath.round(z), + fallback + ) * settings.depthMultiplier(); + return Math.min( + terrain.getMaxDepth(), + Math.max(1D + terrain.getBedRoughness(), configuredDepth) + ); + } + @Override public RiverTerminalPolicy terminalPolicy(int blockX, int blockZ) { EffectiveRiverSettings settings = settingsAt(blockX, blockZ); diff --git a/core/src/main/java/art/arcane/iris/util/common/plugin/VolmitPlugin.java b/core/src/main/java/art/arcane/iris/util/common/plugin/VolmitPlugin.java index c3e74dc88..e1f89fd6b 100644 --- a/core/src/main/java/art/arcane/iris/util/common/plugin/VolmitPlugin.java +++ b/core/src/main/java/art/arcane/iris/util/common/plugin/VolmitPlugin.java @@ -50,19 +50,19 @@ public abstract class VolmitPlugin extends JavaPlugin implements Listener { } public void l(Object l) { - IrisLogging.info("[" + getName() + "]: " + l); + IrisLogging.info(String.valueOf(l)); } public void w(Object l) { - IrisLogging.warn("[" + getName() + "]: " + l); + IrisLogging.warn(String.valueOf(l)); } public void f(Object l) { - IrisLogging.error("[" + getName() + "]: " + l); + IrisLogging.error(String.valueOf(l)); } public void v(Object l) { - IrisLogging.debug("[" + getName() + "]: " + l); + IrisLogging.debug(String.valueOf(l)); } public void onEnable() { diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackRiverValidatorTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackRiverValidatorTest.java index 90b18d094..dbdc74c03 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/PackRiverValidatorTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/PackRiverValidatorTest.java @@ -25,6 +25,7 @@ public class PackRiverValidatorTest { "logicalHeight": 256, "rivers": { "enabled": true, + "terrain": {"worms": [{"id": "river"}]}, "biomes": { "channel": [], "bank": [], @@ -41,6 +42,212 @@ public class PackRiverValidatorTest { assertTrue(result.getBlockingErrors().toString(), result.isLoadable()); } + @Test + public void acceptsRequiredValidWormProfiles() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "rivers": { + "enabled": true, + "terrain": { + "worms": [ + { + "id": "floodplain_trunk", + "seed": 17, + "weight": 2.5, + "wavelength": 1536, + "detailWavelength": 192, + "tortuosity": 0.65, + "detailTortuosity": 0.2, + "maxOffset": 420, + "segments": 56, + "widthMultiplier": 1.4, + "bankMultiplier": 1.2, + "depthMultiplier": 0.8, + "bodyWavelength": 1400, + "bodyDetailWavelength": 320, + "widthVariation": 0.65, + "bankVariation": 0.75, + "depthVariation": 0.45, + "roofVariation": 0.55, + "branchCap": 3, + "branchDecay": 0.25, + "confluenceMultiplier": 1.5, + "childChance": 0.2, + "branchChildChance": 0.6, + "children": [ + { + "id": "floodplain_tributary", + "seed": 29, + "weight": 1, + "wavelength": 640, + "detailWavelength": 128, + "tortuosity": 0.8, + "detailTortuosity": 0.3, + "maxOffset": 300, + "segments": 48, + "widthMultiplier": 0.7, + "bankMultiplier": 0.9, + "depthMultiplier": 1.3, + "bodyWavelength": 180, + "bodyDetailWavelength": 48, + "widthVariation": 0.8, + "bankVariation": 0.8, + "depthVariation": 0.65, + "roofVariation": 0.75, + "branchCap": 2, + "branchDecay": 0.1, + "confluenceMultiplier": 0.75, + "childChance": 0, + "branchChildChance": 0, + "children": [] + } + ] + } + ] + } + } + } + """); + + PackRiverValidator.Validation result = validate(pack); + + assertTrue(result.errors().toString(), result.errors().isEmpty()); + } + + @Test + public void rejectsInvalidWormHierarchyIdentifiersRangesAndChildren() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "rivers": { + "enabled": true, + "terrain": { + "worms": [ + { + "id": "trunk", + "seed": 17, + "bodyWavelength": 31, + "bodyDetailWavelength": 16385, + "widthVariation": -0.1, + "bankVariation": 0.876, + "depthVariation": -0.1, + "roofVariation": 0.876, + "branchCap": 0, + "branchDecay": 2, + "confluenceMultiplier": 9, + "childChance": -0.1, + "branchChildChance": 1.1, + "children": "tributary" + }, + {"id": "trunk", "seed": 29}, + {"id": "Bad ID", "seed": 31} + ] + } + } + } + """); + + PackRiverValidator.Validation result = validate(pack); + + assertContains(result.errors(), "rivers.terrain.worms[0].bodyWavelength must be at least 32"); + assertContains(result.errors(), "rivers.terrain.worms[0].bodyDetailWavelength must be at most 16384"); + assertContains(result.errors(), "rivers.terrain.worms[0].widthVariation must be at least 0"); + assertContains(result.errors(), "rivers.terrain.worms[0].bankVariation must be at most 0.875"); + assertContains(result.errors(), "rivers.terrain.worms[0].depthVariation must be at least 0"); + assertContains(result.errors(), "rivers.terrain.worms[0].roofVariation must be at most 0.875"); + assertContains(result.errors(), "rivers.terrain.worms[0].branchCap must be at least 1"); + assertContains(result.errors(), "rivers.terrain.worms[0].branchDecay must be at most 1"); + assertContains(result.errors(), "rivers.terrain.worms[0].confluenceMultiplier must be at most 8"); + assertContains(result.errors(), "rivers.terrain.worms[0].childChance must be at least 0"); + assertContains(result.errors(), "rivers.terrain.worms[0].branchChildChance must be at most 1"); + assertContains(result.errors(), "rivers.terrain.worms[0].children must be an array"); + assertContains(result.errors(), "rivers.terrain.worms[1].id must be unique inside the worm hierarchy"); + assertContains(result.errors(), + "rivers.terrain.worms[2].id must use 1 to 64 lowercase letters, digits, underscores, or hyphens"); + } + + @Test + public void rejectsWormHierarchyDepthAndProfileLimits() throws Exception { + File excessiveDepth = pack(""" + { + "regions": ["region"], + "rivers": { + "enabled": true, + "terrain": { + "worms": [ + { + "id": "level_1", + "seed": 1, + "children": [ + { + "id": "level_2", + "seed": 2, + "children": [ + { + "id": "level_3", + "seed": 3, + "children": [ + { + "id": "level_4", + "seed": 4, + "children": [ + {"id": "level_5", "seed": 5} + ] + } + ] + } + ] + } + ] + } + ] + } + } + } + """); + File excessiveRoots = packWithWorms(wormHierarchy(17, 0)); + File excessiveProfiles = packWithWorms(wormHierarchy(16, 8)); + + PackRiverValidator.Validation depthResult = validate(excessiveDepth); + PackRiverValidator.Validation rootResult = validate(excessiveRoots); + PackRiverValidator.Validation profileResult = validate(excessiveProfiles); + + assertContains(depthResult.errors(), "children exceeds the maximum hierarchy depth of 4"); + assertContains(rootResult.errors(), "rivers.terrain.worms must contain at most 16 root profiles"); + assertContains(profileResult.errors(), "rivers.terrain.worms hierarchy must contain at most 128 profiles"); + } + + @Test + public void rejectsMissingAndEmptyWormLists() throws Exception { + File missing = pack(""" + { + "regions": ["region"], + "rivers": { + "enabled": true, + "terrain": {} + } + } + """); + File empty = pack(""" + { + "regions": ["region"], + "rivers": { + "enabled": true, + "terrain": {"worms": []} + } + } + """); + + PackRiverValidator.Validation missingResult = validate(missing); + PackRiverValidator.Validation emptyResult = validate(empty); + + assertContains(missingResult.errors(), + "rivers.terrain.worms must be an array with at least one Perlin-worm profile"); + assertContains(emptyResult.errors(), + "rivers.terrain.worms must contain at least one Perlin-worm profile"); + } + @Test public void rejectsNullEnabledNetworkSections() throws Exception { File pack = pack(""" @@ -84,11 +291,10 @@ public class PackRiverValidatorTest { "routingPlateauHeight": 0, "flowAlignmentWeight": 1025, "confluenceWeight": -1, - "branchSoftCap": 9, - "branchChildShrinkFactor": 2, "routingStyle": {"zoom": 0} }, "terrain": { + "worms": [{"id": "river"}], "channelWidth": {"min": 40, "max": 12}, "depth": {}, "tunnelWidthMultiplier": {"min": 0.5, "max": 9}, @@ -116,8 +322,6 @@ public class PackRiverValidatorTest { assertContains(result.errors(), "rivers.topology.routingPlateauHeight must be at least 1"); assertContains(result.errors(), "rivers.topology.flowAlignmentWeight must be at most 1024"); assertContains(result.errors(), "rivers.topology.confluenceWeight must be at least 0"); - assertContains(result.errors(), "rivers.topology.branchSoftCap must be at most 8"); - assertContains(result.errors(), "rivers.topology.branchChildShrinkFactor must be at most 1"); assertContains(result.errors(), "rivers.topology.routingStyle.zoom must be at least"); assertContains(result.errors(), "rivers.terrain.channelWidth.min must not exceed"); assertContains(result.errors(), "rivers.terrain.depth must set min and max explicitly."); @@ -138,6 +342,7 @@ public class PackRiverValidatorTest { "regions": ["region"], "rivers": { "enabled": true, + "terrain": {"worms": [{"id": "river"}]}, "water": { "mode": "TERRACED", "maximumPoolRise": 2, @@ -169,8 +374,7 @@ public class PackRiverValidatorTest { "channelWidth": {"min": 2048, "max": 2048}, "bankWidth": {"min": 2048, "max": 2048}, "orderWidthFactor": 8, - "meanderStrength": 1024, - "meanderSubdivisions": 64 + "worms": [{"id": "river", "maxOffset": 1024, "segments": 64}] } } } @@ -191,6 +395,7 @@ public class PackRiverValidatorTest { "rivers": { "enabled": true, "terrain": { + "worms": [{"id": "river"}], "maxChannelWidth": 0, "maxBankWidth": -1, "maxDepth": 513 @@ -214,6 +419,7 @@ public class PackRiverValidatorTest { "rivers": { "enabled": true, "terrain": { + "worms": [{"id": "river"}], "maxChannelWidth": 2048, "tunnelMouthBlend": 16 } @@ -234,6 +440,7 @@ public class PackRiverValidatorTest { "regions": ["region"], "rivers": { "enabled": true, + "terrain": {"worms": [{"id": "river"}]}, "biomes": { "channel": ["biome", "missing"], "bank": ["biome"] @@ -257,6 +464,7 @@ public class PackRiverValidatorTest { "regions": ["region"], "rivers": { "enabled": true, + "terrain": {"worms": [{"id": "river"}]}, "biomes": { "channel": ["biome"], "bank": ["biome"] @@ -292,7 +500,10 @@ public class PackRiverValidatorTest { File pack = pack(""" { "regions": ["region"], - "rivers": {"enabled": true} + "rivers": { + "enabled": true, + "terrain": {"worms": [{"id": "river"}]} + } } """); write(pack, "regions/region.json", """ @@ -326,7 +537,10 @@ public class PackRiverValidatorTest { File pack = pack(""" { "regions": ["region"], - "rivers": {"enabled": true} + "rivers": { + "enabled": true, + "terrain": {"worms": [{"id": "river"}]} + } } """); write(pack, "regions/region.json", """ @@ -352,6 +566,7 @@ public class PackRiverValidatorTest { "regions": ["region"], "rivers": { "enabled": true, + "terrain": {"worms": [{"id": "river"}]}, "caves": { "mode": "GENERATE_GROTTO", "throatRadius": 4, @@ -383,6 +598,7 @@ public class PackRiverValidatorTest { "regions": ["region"], "rivers": { "enabled": true, + "terrain": {"worms": [{"id": "river"}]}, "caves": { "mode": "FLOOD_CLOSED_COMPONENT", "maximumPerReach": 0, @@ -406,7 +622,10 @@ public class PackRiverValidatorTest { "regions": ["region"], "rivers": { "enabled": true, - "terrain": {"terminalMode": "SINKHOLE_GROTTO"}, + "terrain": { + "worms": [{"id": "river"}], + "terminalMode": "SINKHOLE_GROTTO" + }, "caves": {"mode": "SEALED"} } } @@ -424,7 +643,10 @@ public class PackRiverValidatorTest { "regions": ["region"], "rivers": { "enabled": true, - "terrain": {"terminalMode": "SINKHOLE_GROTTO"}, + "terrain": { + "worms": [{"id": "river"}], + "terminalMode": "SINKHOLE_GROTTO" + }, "caves": { "mode": "GENERATE_GROTTO", "maximumPerReach": 0 @@ -446,7 +668,10 @@ public class PackRiverValidatorTest { "carvingEnabled": false, "rivers": { "enabled": true, - "terrain": {"terminalMode": "SINKHOLE_GROTTO"}, + "terrain": { + "worms": [{"id": "river"}], + "terminalMode": "SINKHOLE_GROTTO" + }, "caves": {"mode": "GENERATE_GROTTO"} } } @@ -464,7 +689,10 @@ public class PackRiverValidatorTest { "regions": ["region"], "rivers": { "enabled": true, - "terrain": {"terminalMode": "SINKHOLE_GROTTO"}, + "terrain": { + "worms": [{"id": "river"}], + "terminalMode": "SINKHOLE_GROTTO" + }, "caves": { "mode": "FLOOD_CLOSED_COMPONENT", "fallback": "SEALED", @@ -490,6 +718,7 @@ public class PackRiverValidatorTest { "regions": ["region"], "rivers": { "enabled": true, + "terrain": {"worms": [{"id": "river"}]}, "caves": {"mode": "SEALED"} } } @@ -514,6 +743,7 @@ public class PackRiverValidatorTest { "carvingEnabled": false, "rivers": { "enabled": true, + "terrain": {"worms": [{"id": "river"}]}, "caves": {"mode": "GENERATE_GROTTO"} } } @@ -545,6 +775,7 @@ public class PackRiverValidatorTest { "regions": ["region"], "rivers": { "enabled": true, + "terrain": {"worms": [{"id": "river"}]}, "topology": { "routingStyle": {"expression": "route"} } @@ -587,6 +818,7 @@ public class PackRiverValidatorTest { "regions": ["region"], "rivers": { "enabled": true, + "terrain": {"worms": [{"id": "river"}]}, "topology": { "routingStyle": {"expression": "route"} } @@ -618,6 +850,7 @@ public class PackRiverValidatorTest { "regions": ["region"], "rivers": { "enabled": true, + "terrain": {"worms": [{"id": "river"}]}, "topology": { "routingStyle": "snippet/style/first" } @@ -639,6 +872,7 @@ public class PackRiverValidatorTest { "regions": ["region"], "rivers": { "enabled": true, + "terrain": {"worms": [{"id": "river"}]}, "topology": { "routingStyle": {"expression": "route"} } @@ -677,6 +911,50 @@ public class PackRiverValidatorTest { return pack; } + private File packWithWorms(String worms) throws Exception { + return pack(""" + { + "regions": ["region"], + "rivers": { + "enabled": true, + "terrain": {"worms": %s} + } + } + """.formatted(worms)); + } + + private String wormHierarchy(int rootCount, int childrenPerRoot) { + StringBuilder hierarchy = new StringBuilder("["); + int seed = 1; + for (int rootIndex = 0; rootIndex < rootCount; rootIndex++) { + if (rootIndex > 0) { + hierarchy.append(','); + } + hierarchy.append("{\"id\":\"root_") + .append(rootIndex) + .append("\",\"seed\":") + .append(seed++); + if (childrenPerRoot > 0) { + hierarchy.append(",\"children\":["); + for (int childIndex = 0; childIndex < childrenPerRoot; childIndex++) { + if (childIndex > 0) { + hierarchy.append(','); + } + hierarchy.append("{\"id\":\"child_") + .append(rootIndex) + .append('_') + .append(childIndex) + .append("\",\"seed\":") + .append(seed++) + .append('}'); + } + hierarchy.append(']'); + } + hierarchy.append('}'); + } + return hierarchy.append(']').toString(); + } + private void assertContains(List messages, String fragment) { assertTrue(messages.toString(), contains(messages, fragment)); } diff --git a/core/src/test/java/art/arcane/iris/core/project/IrisRiverSchemaTest.java b/core/src/test/java/art/arcane/iris/core/project/IrisRiverSchemaTest.java index e65427f65..4c9bc5e11 100644 --- a/core/src/test/java/art/arcane/iris/core/project/IrisRiverSchemaTest.java +++ b/core/src/test/java/art/arcane/iris/core/project/IrisRiverSchemaTest.java @@ -30,7 +30,10 @@ public class IrisRiverSchemaTest { JSONObject properties = schema.getJSONObject("properties"); JSONObject topology = referencedProperties(definitions, properties.getJSONObject("topology")); JSONObject source = referencedProperties(definitions, topology.getJSONObject("source")); - JSONObject terrain = referencedProperties(definitions, properties.getJSONObject("terrain")); + JSONObject terrainDefinition = referencedDefinition(definitions, properties.getJSONObject("terrain")); + JSONObject terrain = terrainDefinition.getJSONObject("properties"); + JSONObject worms = terrain.getJSONObject("worms"); + JSONObject worm = referencedProperties(definitions, worms.getJSONObject("items")); JSONObject water = referencedProperties(definitions, properties.getJSONObject("water")); JSONObject biomes = referencedProperties(definitions, properties.getJSONObject("biomes")); @@ -46,6 +49,29 @@ public class IrisRiverSchemaTest { assertEquals(2048D, terrain.getJSONObject("maxChannelWidth").getDouble("maximum"), 0D); assertEquals(0D, terrain.getJSONObject("maxBankWidth").getDouble("minimum"), 0D); assertEquals(512D, terrain.getJSONObject("maxDepth").getDouble("maximum"), 0D); + assertTrue(arrayContains(terrainDefinition.getJSONArray("required"), "worms")); + assertEquals("array", worms.getString("type")); + assertEquals(1, worms.getInt("minItems")); + assertEquals(0.000001D, worm.getJSONObject("weight").getDouble("minimum"), 0D); + assertEquals(16384D, worm.getJSONObject("wavelength").getDouble("maximum"), 0D); + assertEquals(1D, worm.getJSONObject("tortuosity").getDouble("maximum"), 0D); + assertEquals(1024D, worm.getJSONObject("maxOffset").getDouble("maximum"), 0D); + assertEquals(64, worm.getJSONObject("segments").getInt("maximum")); + assertEquals(0.125D, worm.getJSONObject("widthMultiplier").getDouble("minimum"), 0D); + assertEquals(8D, worm.getJSONObject("bankMultiplier").getDouble("maximum"), 0D); + assertEquals(8D, worm.getJSONObject("depthMultiplier").getDouble("maximum"), 0D); + assertEquals(32D, worm.getJSONObject("bodyWavelength").getDouble("minimum"), 0D); + assertEquals(16384D, worm.getJSONObject("bodyDetailWavelength").getDouble("maximum"), 0D); + assertEquals(0.875D, worm.getJSONObject("widthVariation").getDouble("maximum"), 0D); + assertEquals(0.875D, worm.getJSONObject("bankVariation").getDouble("maximum"), 0D); + assertEquals(0.875D, worm.getJSONObject("depthVariation").getDouble("maximum"), 0D); + assertEquals(0.875D, worm.getJSONObject("roofVariation").getDouble("maximum"), 0D); + assertEquals(8, worm.getJSONObject("branchCap").getInt("maximum")); + assertEquals(1D, worm.getJSONObject("branchDecay").getDouble("maximum"), 0D); + assertEquals(8D, worm.getJSONObject("confluenceMultiplier").getDouble("maximum"), 0D); + assertEquals(1D, worm.getJSONObject("childChance").getDouble("maximum"), 0D); + assertEquals(1D, worm.getJSONObject("branchChildChance").getDouble("maximum"), 0D); + assertEquals("array", worm.getJSONObject("children").getString("type")); assertEquals(List.of("SEA_LEVEL", "TERRACED"), enumValues(definitions, water.getJSONObject("mode"))); assertEquals("array", biomes.getJSONObject("channel").getString("type")); assertEquals("#/definitions/erzbiomes", @@ -87,8 +113,21 @@ public class IrisRiverSchemaTest { } private static JSONObject referencedProperties(JSONObject definitions, JSONObject reference) { + return referencedDefinition(definitions, reference).getJSONObject("properties"); + } + + private static JSONObject referencedDefinition(JSONObject definitions, JSONObject reference) { String key = reference.getString("$ref").substring("#/definitions/".length()); - return definitions.getJSONObject(key).getJSONObject("properties"); + return definitions.getJSONObject(key); + } + + private static boolean arrayContains(JSONArray values, String expected) { + for (int index = 0; index < values.length(); index++) { + if (expected.equals(values.getString(index))) { + return true; + } + } + return false; } private static List enumValues(JSONObject definitions, JSONObject reference) { diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisRiverConfigurationTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisRiverConfigurationTest.java index 29e73587b..1432027ec 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisRiverConfigurationTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisRiverConfigurationTest.java @@ -36,6 +36,7 @@ public class IrisRiverConfigurationTest { assertEquals(10D, dimension.getRivers().getTerrain().getMaxChannelWidth(), 0D); assertEquals(4D, dimension.getRivers().getTerrain().getMaxBankWidth(), 0D); assertEquals(10D, dimension.getRivers().getTerrain().getMaxDepth(), 0D); + assertTrue(dimension.getRivers().getTerrain().getWorms().isEmpty()); assertEquals(IrisRiverCaveMode.SEALED, dimension.getRivers().getCaves().getMode()); assertEquals(IrisRiverCaveFallback.SEALED, dimension.getRivers().getCaves().getFallback()); assertEquals(IrisRiverExistingFluidPolicy.REJECT, @@ -65,6 +66,27 @@ public class IrisRiverConfigurationTest { "maxBankWidth": 2.5, "maxDepth": 8, "maxIncision": 36, + "worms": [ + { + "seed": 73, + "weight": 2.5, + "wavelength": 1536, + "detailWavelength": 192, + "tortuosity": 0.65, + "detailTortuosity": 0.2, + "maxOffset": 420, + "segments": 56, + "widthMultiplier": 1.4, + "bankMultiplier": 1.2, + "depthMultiplier": 0.8, + "bodyWavelength": 704, + "bodyDetailWavelength": 88, + "widthVariation": 0.75, + "bankVariation": 0.65, + "depthVariation": 0.55, + "roofVariation": 0.45 + } + ], "terminalMode": "SUPPRESS" }, "water": {"mode": "TERRACED", "poolLength": 80}, @@ -111,6 +133,24 @@ public class IrisRiverConfigurationTest { assertEquals(2.5D, dimension.getRivers().getTerrain().getMaxBankWidth(), 0D); assertEquals(8D, dimension.getRivers().getTerrain().getMaxDepth(), 0D); assertEquals(36, dimension.getRivers().getTerrain().getMaxIncision()); + IrisRiverWorm worm = dimension.getRivers().getTerrain().getWorms().get(0); + assertEquals(73L, worm.getSeed()); + assertEquals(2.5D, worm.getWeight(), 0D); + assertEquals(1536D, worm.getWavelength(), 0D); + assertEquals(192D, worm.getDetailWavelength(), 0D); + assertEquals(0.65D, worm.getTortuosity(), 0D); + assertEquals(0.2D, worm.getDetailTortuosity(), 0D); + assertEquals(420D, worm.getMaxOffset(), 0D); + assertEquals(56, worm.getSegments()); + assertEquals(1.4D, worm.getWidthMultiplier(), 0D); + assertEquals(1.2D, worm.getBankMultiplier(), 0D); + assertEquals(0.8D, worm.getDepthMultiplier(), 0D); + assertEquals(704D, worm.getBodyWavelength(), 0D); + assertEquals(88D, worm.getBodyDetailWavelength(), 0D); + assertEquals(0.75D, worm.getWidthVariation(), 0D); + assertEquals(0.65D, worm.getBankVariation(), 0D); + assertEquals(0.55D, worm.getDepthVariation(), 0D); + assertEquals(0.45D, worm.getRoofVariation(), 0D); assertEquals(IrisRiverTerminalMode.SUPPRESS, dimension.getRivers().getTerrain().getTerminalMode()); assertEquals(IrisRiverWaterMode.TERRACED, dimension.getRivers().getWater().getMode()); @@ -145,7 +185,11 @@ public class IrisRiverConfigurationTest { .setFloodedCaveBiomes(new KList<>("biome-grotto")); IrisDimension dimension = new IrisDimension() .setRegions(new KList<>("region")) - .setRivers(new IrisRiverNetwork().setEnabled(true).setBiomes(dimensionBiomes)); + .setRivers(new IrisRiverNetwork() + .setEnabled(true) + .setTerrain(new IrisRiverTerrain() + .setWorms(new KList<>(new IrisRiverWorm()))) + .setBiomes(dimensionBiomes)); IrisRegion region = new IrisRegion() .setLandBiomes(new KList<>("natural")) .setRiverOverride(regionOverride); diff --git a/core/src/test/java/art/arcane/iris/engine/river/RiverNetworkTest.java b/core/src/test/java/art/arcane/iris/engine/river/RiverNetworkTest.java index 5e1c2e0e4..cc1dc0a01 100644 --- a/core/src/test/java/art/arcane/iris/engine/river/RiverNetworkTest.java +++ b/core/src/test/java/art/arcane/iris/engine/river/RiverNetworkTest.java @@ -21,8 +21,6 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class RiverNetworkTest { - private static final double TWO_PI_FOR_TESTS = StrictMath.PI * 2D; - @Test public void mapsNegativeWorldCoordinatesWithFloorDivision() { RiverNetwork network = new RiverNetwork(options(1L).build()); @@ -164,12 +162,25 @@ public class RiverNetworkTest { } @Test - public void recursiveBranchesApplyTheChildSoftCapAtEveryNode() { + public void recursiveBranchesApplyTheProfileChildSoftCapAtEveryNode() { + RiverWorm constrained = wormProfile( + "constrained", + 720L, + 6, + 1D, + 1D, + 1D, + 4, + 0D, + 1D, + 0D, + 0D, + List.of() + ); RiverNetwork network = new RiverNetwork(options(720L) .requireOcean(false) .routingBasinCells(16) - .branchSoftCap(4) - .branchChildShrinkFactor(0D) + .worms(List.of(constrained)) .build()); RiverTerrainSampler terrain = flatTerrain(false); HashMap incoming = new HashMap<>(); @@ -188,36 +199,29 @@ public class RiverNetworkTest { } @Test - public void candidateRankingAndTracingDoNotMaterializeMeanderGeometry() { - RiverNetwork network = new RiverNetwork(options(75L) + public void candidateRankingAndTracingAreIndependentOfWormGeometry() { + RiverNetwork straightNetwork = new RiverNetwork(options(75L) .requireOcean(false) - .meanderSubdivisions(32) + .worms(List.of(worm(1L, 0D, 0D, 0D, 1))) + .build()); + RiverNetwork windingNetwork = new RiverNetwork(options(75L) + .requireOcean(false) + .worms(List.of(worm(2L, 0.8D, 0.2D, 32D, 32))) .build()); - int[] meanderSamples = new int[1]; RiverTerrainSampler terrain = new TestTerrain(false) { @Override public double reachRoutingCost(RiverRoutingContext context) { return context.midpointX() + context.midpointZ(); } - - @Override - public double meanderNoise(RiverMeanderContext context) { - meanderSamples[0]++; - return 0D; - } }; + RiverNodeId source = new RiverNodeId(0L, 0L); - assertFalse(network.downstreamCandidates(new RiverNodeId(0L, 0L), terrain).isEmpty()); - assertEquals(0, meanderSamples[0]); - assertFalse(network.trace(new RiverNodeId(0L, 0L), terrain).edges().isEmpty()); - assertEquals(0, meanderSamples[0]); - - RiverTile tile = network.buildTile(0, 0, terrain); - - assertFalse(tile.reaches().isEmpty()); - assertTrue(meanderSamples[0] > 0); - assertEquals(0, meanderSamples[0] % 12); - assertTrue(meanderSamples[0] / 12 <= 256); + assertFalse(straightNetwork.downstreamCandidates(source, terrain).isEmpty()); + assertEquals( + straightNetwork.downstreamCandidates(source, terrain), + windingNetwork.downstreamCandidates(source, terrain) + ); + assertEquals(straightNetwork.trace(source, terrain), windingNetwork.trace(source, terrain)); } @Test @@ -616,10 +620,10 @@ public class RiverNetworkTest { ); assertEquals(List.of( - 3050685048971830506L, - -3861364772874819248L, - -8770785170128501517L, - 6698607697170340377L + 973031325888677478L, + 8932177974453767311L, + -5189009084208004632L, + 6374264141259432071L ), actual); } @@ -659,9 +663,20 @@ public class RiverNetworkTest { .channelWidth(512D) .maxChannelWidth(512D) .bankWidth(0D) - .branchChildShrinkFactor(1D) - .meanderStrength(0D) - .meanderSubdivisions(1) + .worms(List.of(wormProfile( + "straight", + 1L, + 1, + 1D, + 1D, + 1D, + 8, + 1D, + 1D, + 0D, + 0D, + List.of() + ))) .build()); RiverTerrainSampler terrain = new TestTerrain(false) { @Override @@ -787,9 +802,9 @@ public class RiverNetworkTest { 1, 1, 10.0, - RiverWidthProfile.constant(10.0), 5.0, 3.0, + RiverBodyProfile.constant(10.0, 5.0, 3.0), false, true, new RiverPolyline(new double[]{0.0, 100.0}, new double[]{0.0, 0.0}) @@ -867,14 +882,9 @@ public class RiverNetworkTest { .maxChannelWidth(10D) .maxBankWidth(3D) .maxDepth(6D) - .meanderStrength(0D) + .worms(List.of(worm(1L, 0D, 0D, 0D, 1))) .build()); RiverTerrainSampler styled = new TestTerrain(false) { - @Override - public double meanderNoise(RiverMeanderContext context) { - return 0.0; - } - @Override public double channelWidth(RiverRoutingContext context, double fallback) { return 20.0; @@ -906,12 +916,15 @@ public class RiverNetworkTest { } @Test - public void channelWidthVariesInsideOneReachAndRemainsSpatiallyQueryable() { + public void bodyAnatomyVariesInsideOneReachAndRemainsSpatiallyQueryable() { RiverNode from = node(0L, 0L, 0D, 0D); RiverNode to = node(1L, 0L, 100D, 0D); - RiverWidthProfile profile = new RiverWidthProfile( + RiverBodyProfile profile = new RiverBodyProfile( new double[]{0D, 0.5D, 1D}, - new double[]{1D, 7D, 1D} + new double[]{1D, 7D, 1D}, + new double[]{1D, 9D, 1D}, + new double[]{2D, 8D, 2D}, + new double[]{1D, 0.35D, 1D} ); RiverReach reach = new RiverReach( RiverEdgeId.of(from.id(), to.id()), @@ -921,31 +934,100 @@ public class RiverNetworkTest { 1, 1, 7D, + 9D, + 8D, profile, - 0D, - 3D, false, false, new RiverPolyline(new double[]{0D, 100D}, new double[]{0D, 0D}) ); RiverTile tile = new RiverTile(0, 0, 0, -16, 128, 32, List.of(reach)); - RiverSample narrow = tile.sample(10D, 2D); - RiverSample wide = tile.sample(50D, 2D); + RiverSample narrow = tile.sample(10D, 7D); + RiverSample swollen = tile.sample(50D, 7D); assertFalse(narrow.present()); - assertTrue(wide.present()); - assertEquals(7D, wide.width(), 0D); - assertEquals(RiverSection.CHANNEL, wide.section()); + assertTrue(swollen.present()); + assertEquals(7D, swollen.width(), 0D); + assertEquals(9D, swollen.bankWidth(), 0D); + assertEquals(8D, swollen.depth(), 0D); + assertEquals(0.35D, reach.roofScaleAt(swollen.alongReach()), 0.0000001D); + assertEquals(RiverSection.BANK, swollen.section()); + } + + @Test + public void perlinWormBodyAnatomyVariesAllDimensionsDeterministically() { + RiverWorm anatomy = wormProfile( + "anatomy", + 917L, + 24, + 1D, + 1D, + 1D, + 64D, + 32D, + 0.875D, + 0.875D, + 0.875D, + 0.875D, + 4, + 0.35D, + 1D, + 0D, + 0D, + List.of() + ); + RiverNetwork network = new RiverNetwork(options(917L) + .requireOcean(false) + .maxChannelWidth(64D) + .maxBankWidth(64D) + .maxDepth(64D) + .maximumReachRadius(96D) + .worms(List.of(anatomy)) + .build()); + RiverTerrainSampler terrain = flatTerrain(false); + + RiverTile first = network.buildTile(0, 0, terrain); + RiverTile second = network.buildTile(0, 0, terrain); + + assertFalse(first.reaches().isEmpty()); + assertEquals(digest(first), digest(second)); + double minimumWidth = Double.POSITIVE_INFINITY; + double maximumWidth = 0D; + double minimumBank = Double.POSITIVE_INFINITY; + double maximumBank = 0D; + double minimumDepth = Double.POSITIVE_INFINITY; + double maximumDepth = 0D; + double minimumRoof = Double.POSITIVE_INFINITY; + double maximumRoof = 0D; + for (RiverReach reach : first.reaches()) { + for (int index = 0; index < reach.bodyProfile().size(); index++) { + minimumWidth = StrictMath.min(minimumWidth, reach.bodyProfile().widthAtIndex(index)); + maximumWidth = StrictMath.max(maximumWidth, reach.bodyProfile().widthAtIndex(index)); + minimumBank = StrictMath.min(minimumBank, reach.bodyProfile().bankWidthAtIndex(index)); + maximumBank = StrictMath.max(maximumBank, reach.bodyProfile().bankWidthAtIndex(index)); + minimumDepth = StrictMath.min(minimumDepth, reach.bodyProfile().depthAtIndex(index)); + maximumDepth = StrictMath.max(maximumDepth, reach.bodyProfile().depthAtIndex(index)); + minimumRoof = StrictMath.min(minimumRoof, reach.bodyProfile().roofScaleAtIndex(index)); + maximumRoof = StrictMath.max(maximumRoof, reach.bodyProfile().roofScaleAtIndex(index)); + } + } + assertTrue(maximumWidth - minimumWidth > 2D); + assertTrue(maximumBank - minimumBank > 1.5D); + assertTrue(maximumDepth - minimumDepth > 0.75D); + assertTrue(maximumRoof - minimumRoof > 0.1D); } @Test public void foldedReachSamplingFindsFartherCoveringWidthEnvelope() { RiverNode from = node(0L, 0L, 0D, 0D); RiverNode to = node(1L, 0L, 0D, 10D); - RiverWidthProfile profile = new RiverWidthProfile( + RiverBodyProfile profile = new RiverBodyProfile( new double[]{0D, 0.48D, 0.53D, 1D}, - new double[]{1D, 1D, 18D, 18D} + new double[]{1D, 1D, 18D, 18D}, + new double[]{0D, 0D, 0D, 0D}, + new double[]{3D, 3D, 3D, 3D}, + new double[]{1D, 1D, 1D, 1D} ); RiverReach reach = new RiverReach( RiverEdgeId.of(from.id(), to.id()), @@ -955,9 +1037,9 @@ public class RiverNetworkTest { 1, 1, 18D, - profile, 0D, 3D, + profile, false, false, new RiverPolyline( @@ -978,15 +1060,12 @@ public class RiverNetworkTest { } @Test - public void reachFeasibilityReceivesTheFinalPinnedMeanderPolyline() { - RiverNetwork network = new RiverNetwork(options(62L).meanderStrength(20D).build()); - boolean[] observedMeander = new boolean[1]; + public void reachFeasibilityReceivesTheFinalPinnedWormPolyline() { + RiverNetwork network = new RiverNetwork(options(62L) + .worms(List.of(worm(62L, 0.8D, 0.2D, 20D, 32))) + .build()); + boolean[] observedWorm = new boolean[1]; RiverTerrainSampler terrain = new TestTerrain(false) { - @Override - public double meanderNoise(RiverMeanderContext context) { - return 1D; - } - @Override public boolean allowsReach(RiverRoutingContext context) { RiverPolyline polyline = context.polyline(); @@ -996,7 +1075,7 @@ public class RiverNetworkTest { double pointX = polyline.x(point) - context.from().x(); double pointZ = polyline.z(point) - context.from().z(); if (StrictMath.abs(pointX * deltaZ - pointZ * deltaX) > 0.000001D) { - observedMeander[0] = true; + observedWorm[0] = true; return false; } } @@ -1006,76 +1085,66 @@ public class RiverNetworkTest { RiverNode downstream = network.downstream(new RiverNodeId(0L, 0L), terrain); - assertTrue(observedMeander[0]); + assertTrue(observedWorm[0]); assertEquals(null, downstream); } @Test - public void irisNoiseChangesTheSelectedPersonalityWithoutBreakingItsEnvelope() { - double meanderStrength = 20D; - RiverNetwork network = new RiverNetwork(options(63L) + public void wormSeedChangesDeterministicGeometryWithoutBreakingItsEnvelope() { + double maximumOffset = 20D; + RiverNetwork firstNetwork = new RiverNetwork(options(63L) .siteJitter(0D) .requireOcean(false) - .meanderStrength(meanderStrength) - .meanderSubdivisions(32) + .worms(List.of(worm(101L, 0.8D, 0.2D, maximumOffset, 32))) .build()); - RiverTerrainSampler quietTerrain = new TestTerrain(false) { - @Override - public double meanderNoise(RiverMeanderContext context) { - return 0D; - } - }; - RiverTerrainSampler noisyTerrain = new TestTerrain(false) { - @Override - public double meanderNoise(RiverMeanderContext context) { - return StrictMath.sin(context.normalizedPosition() * StrictMath.PI * 7D); - } - }; + RiverNetwork secondNetwork = new RiverNetwork(options(63L) + .siteJitter(0D) + .requireOcean(false) + .worms(List.of(worm(202L, 0.8D, 0.2D, maximumOffset, 32))) + .build()); + RiverTerrainSampler terrain = new TestTerrain(false); - RiverReach quietReach = network.buildTile(0, 0, quietTerrain).reaches().getFirst(); - RiverReach noisyReach = network.buildTile(0, 0, noisyTerrain).reaches().getFirst(); - assertEquals(quietReach.id(), noisyReach.id()); - RiverPolyline quietPolyline = quietReach.polyline(); - RiverPolyline noisyPolyline = noisyReach.polyline(); - double deltaX = noisyReach.to().x() - noisyReach.from().x(); - double deltaZ = noisyReach.to().z() - noisyReach.from().z(); - double chordLength = StrictMath.hypot(deltaX, deltaZ); - boolean changed = false; - for (int point = 1; point < noisyPolyline.size() - 1; point++) { - double pointX = noisyPolyline.x(point) - noisyReach.from().x(); - double pointZ = noisyPolyline.z(point) - noisyReach.from().z(); - double signedOffset = (pointX * deltaZ - pointZ * deltaX) / chordLength; - if (Double.doubleToLongBits(noisyPolyline.x(point)) != Double.doubleToLongBits(quietPolyline.x(point)) - || Double.doubleToLongBits(noisyPolyline.z(point)) - != Double.doubleToLongBits(quietPolyline.z(point))) { - changed = true; - } - assertTrue(StrictMath.abs(signedOffset) <= meanderStrength + 0.0000001D); - } + RiverReach firstReach = firstNetwork.buildTile(0, 0, terrain).reaches().getFirst(); + RiverReach repeatedReach = firstNetwork.buildTile(0, 0, terrain).reaches().getFirst(); + RiverReach secondReach = secondNetwork.buildTile(0, 0, terrain).reaches().getFirst(); - assertTrue(changed); - assertEquals(noisyReach.from().x(), noisyPolyline.x(0), 0D); - assertEquals(noisyReach.from().z(), noisyPolyline.z(0), 0D); - int last = noisyPolyline.size() - 1; - assertEquals(noisyReach.to().x(), noisyPolyline.x(last), 0D); - assertEquals(noisyReach.to().z(), noisyPolyline.z(last), 0D); + assertReachEquals(firstReach, repeatedReach); + assertEquals(firstReach.id(), secondReach.id()); + assertTrue(polylinesDiffer(firstReach.polyline(), secondReach.polyline())); + assertWormBounds(firstReach, maximumOffset); + assertWormBounds(secondReach, maximumOffset); } @Test - public void reachesSpanBroadAsymmetricCompoundAndRestlessShapeFamilies() { - double meanderStrength = 20D; + public void weightedWormProfilesSelectDistinctConfigurableVariants() { + RiverWorm gentle = new RiverWorm( + "gentle", 301L, 1D, 1024D, 256D, 0.2D, 0.05D, 10D, 8, 0.5D, 0.5D, 0.5D, + 512D, 128D, 0D, 0D, 0D, 0D, + 4, 0.35D, 1D, 0D, 0D, List.of() + ); + RiverWorm winding = new RiverWorm( + "winding", 302L, 1D, 512D, 128D, 0.55D, 0.15D, 20D, 16, 1D, 1D, 1D, + 512D, 128D, 0D, 0D, 0D, 0D, + 4, 0.35D, 1D, 0D, 0D, List.of() + ); + RiverWorm restless = new RiverWorm( + "restless", 303L, 1D, 192D, 48D, 0.9D, 0.35D, 30D, 32, 2D, 2D, 2D, + 512D, 128D, 0D, 0D, 0D, 0D, + 4, 0.35D, 1D, 0D, 0D, List.of() + ); RiverNetwork network = new RiverNetwork(options(64L) .siteJitter(0D) + .routingBasinCells(8) .requireOcean(false) - .meanderStrength(meanderStrength) - .meanderSubdivisions(48) + .maxChannelWidth(32D) + .maxBankWidth(32D) + .maxDepth(32D) + .orderWidthFactor(0D) + .orderDepthFactor(0D) + .maximumReachRadius(32D) + .worms(List.of(gentle, winding, restless)) .build()); - RiverTerrainSampler terrain = new TestTerrain(false) { - @Override - public double meanderNoise(RiverMeanderContext context) { - return 0D; - } - }; + RiverTerrainSampler terrain = new TestTerrain(false); HashMap uniqueReaches = new HashMap<>(); for (int tileX = -2; tileX <= 2; tileX++) { @@ -1085,39 +1154,217 @@ public class RiverNetworkTest { } } } - ArrayList shapes = new ArrayList<>(uniqueReaches.size()); + Set selectedPointCounts = new HashSet<>(); + HashMap maximumDisplacementRatios = new HashMap<>(); for (RiverReach reach : uniqueReaches.values()) { - shapes.add(shapeOf(reach, meanderStrength)); - } - double minimumAmplitude = shapes.stream() - .mapToDouble(ReachShape::amplitudeRatio) - .min() - .orElseThrow(); - double maximumAmplitude = shapes.stream() - .mapToDouble(ReachShape::amplitudeRatio) - .max() - .orElseThrow(); - HashSet dominantBands = new HashSet<>(); - HashSet signatures = new HashSet<>(); - for (ReachShape shape : shapes) { - dominantBands.add(shape.dominantBand()); - signatures.add( - StrictMath.min(6, shape.crossings()) + ":" - + (int) StrictMath.floor(shape.centroid() * 5D) + ":" - + (int) StrictMath.floor(shape.amplitudeRatio() * 5D) + ":" - + shape.dominantBand() + int pointCount = reach.polyline().size(); + selectedPointCounts.add(pointCount); + RiverWorm selected = switch (pointCount) { + case 9 -> gentle; + case 17 -> winding; + case 33 -> restless; + default -> throw new AssertionError("Unexpected worm point count " + pointCount); + }; + assertEquals(8D * selected.widthMultiplier(), reach.width(), 0.0000001D); + assertEquals(6D * selected.bankMultiplier(), reach.bankWidth(), 0.0000001D); + assertEquals(3D * selected.depthMultiplier(), reach.depth(), 0.0000001D); + assertWormBounds(reach, selected.maxOffset()); + maximumDisplacementRatios.merge( + pointCount, + maximumWormDisplacement(reach) / selected.maxOffset(), + StrictMath::max ); } - String diagnostics = shapes.toString(); - assertTrue(diagnostics, shapes.size() >= 32); - assertTrue(diagnostics, shapes.stream().anyMatch(shape -> shape.crossings() <= 1)); - assertTrue(diagnostics, shapes.stream().anyMatch(shape -> shape.crossings() >= 2 && shape.crossings() <= 4)); - assertTrue(diagnostics, shapes.stream().anyMatch(shape -> shape.crossings() >= 5)); - assertTrue(diagnostics, shapes.stream().anyMatch(shape -> shape.centroid() < 0.43D)); - assertTrue(diagnostics, shapes.stream().anyMatch(shape -> shape.centroid() > 0.57D)); - assertTrue(diagnostics, maximumAmplitude - minimumAmplitude >= 0.25D); - assertTrue(diagnostics, dominantBands.size() >= 3); - assertTrue(diagnostics, signatures.size() >= 8); + assertEquals(Set.of(9, 17, 33), selectedPointCounts); + assertTrue(maximumDisplacementRatios.toString(), maximumDisplacementRatios.get(9) > 0.03D); + assertTrue(maximumDisplacementRatios.toString(), maximumDisplacementRatios.get(17) > 0.18D); + assertTrue(maximumDisplacementRatios.toString(), maximumDisplacementRatios.get(33) > 0.4D); + } + + @Test + public void forcedChildHierarchyControlsReachGeometryAndDimensionsDeterministically() { + RiverWorm child = wormProfile( + "child", + 402L, + 24, + 2D, + 1.5D, + 0.5D, + 8, + 1D, + 1D, + 0D, + 0D, + List.of() + ); + RiverWorm root = wormProfile( + "root", + 401L, + 8, + 0.5D, + 0.5D, + 0.5D, + 8, + 1D, + 1D, + 1D, + 0D, + List.of(child) + ); + RiverNetwork network = new RiverNetwork(options(68L) + .siteJitter(0D) + .requireOcean(false) + .maxChannelWidth(32D) + .maxBankWidth(32D) + .maxDepth(32D) + .orderWidthFactor(0D) + .orderDepthFactor(0D) + .maximumReachRadius(32D) + .worms(List.of(root)) + .build()); + RiverTerrainSampler terrain = new TestTerrain(false); + + RiverTile first = network.buildTile(0, 0, terrain); + RiverTile second = network.buildTile(0, 0, terrain); + + assertFalse(first.reaches().isEmpty()); + assertEquals(digest(first), digest(second)); + for (RiverReach reach : first.reaches()) { + assertEquals(child.segments() + 1, reach.polyline().size()); + assertEquals(8D * child.widthMultiplier(), reach.width(), 0.0000001D); + assertEquals(6D * child.bankMultiplier(), reach.bankWidth(), 0.0000001D); + assertEquals(3D * child.depthMultiplier(), reach.depth(), 0.0000001D); + assertWormBounds(reach, child.maxOffset()); + } + } + + @Test + public void wormHierarchyRejectsInvalidIdsDepthAndCounts() { + assertThrows(IllegalArgumentException.class, () -> wormProfile( + "Invalid", + 1L, + 8, + 1D, + 1D, + 1D, + 512D, + 128D, + 0D, + 0D, + 0D, + 0D, + 4, + 0.35D, + 1D, + 0D, + 0D, + List.of() + )); + + RiverWorm duplicateChild = wormProfile( + "duplicate", 2L, 8, 1D, 1D, 1D, 4, 0.35D, 1D, 0D, 0D, List.of() + ); + RiverWorm duplicateRoot = wormProfile( + "duplicate", 3L, 8, 1D, 1D, 1D, 4, 0.35D, 1D, 1D, 0D, List.of(duplicateChild) + ); + assertThrows( + IllegalArgumentException.class, + () -> options(10L).worms(List.of(duplicateRoot)).build() + ); + + RiverWorm tooDeep = wormProfile( + "depth-5", 5L, 8, 1D, 1D, 1D, 4, 0.35D, 1D, 0D, 0D, List.of() + ); + for (int depth = 4; depth >= 1; depth--) { + tooDeep = wormProfile( + "depth-" + depth, + depth, + 8, + 1D, + 1D, + 1D, + 4, + 0.35D, + 1D, + 1D, + 0D, + List.of(tooDeep) + ); + } + RiverWorm depthRoot = tooDeep; + assertThrows( + IllegalArgumentException.class, + () -> options(10L).worms(List.of(depthRoot)).build() + ); + + ArrayList roots = new ArrayList<>(); + for (int root = 0; root < 17; root++) { + roots.add(wormProfile( + "root-" + root, root, 8, 1D, 1D, 1D, 4, 0.35D, 1D, 0D, 0D, List.of() + )); + } + assertThrows(IllegalArgumentException.class, () -> options(10L).worms(roots).build()); + + ArrayList branches = new ArrayList<>(); + for (int branch = 0; branch < 8; branch++) { + ArrayList leaves = new ArrayList<>(); + for (int leaf = 0; leaf < 16; leaf++) { + leaves.add(wormProfile( + "leaf-" + branch + "-" + leaf, + branch * 16L + leaf, + 8, + 1D, + 1D, + 1D, + 4, + 0.35D, + 1D, + 0D, + 0D, + List.of() + )); + } + branches.add(wormProfile( + "branch-" + branch, + branch, + 8, + 1D, + 1D, + 1D, + 4, + 0.35D, + 1D, + 1D, + 0D, + leaves + )); + } + RiverWorm oversized = wormProfile( + "oversized", 900L, 8, 1D, 1D, 1D, 4, 0.35D, 1D, 1D, 0D, branches + ); + assertThrows( + IllegalArgumentException.class, + () -> options(10L).worms(List.of(oversized)).build() + ); + } + + @Test + public void wormProfileRejectsInvalidBranchControls() { + assertThrows(IllegalArgumentException.class, () -> wormProfile( + "cap", 1L, 8, 1D, 1D, 1D, 0, 0.35D, 1D, 0D, 0D, List.of() + )); + assertThrows(IllegalArgumentException.class, () -> wormProfile( + "decay", 1L, 8, 1D, 1D, 1D, 4, 1.1D, 1D, 0D, 0D, List.of() + )); + assertThrows(IllegalArgumentException.class, () -> wormProfile( + "confluence", 1L, 8, 1D, 1D, 1D, 4, 0.35D, 8.1D, 0D, 0D, List.of() + )); + assertThrows(IllegalArgumentException.class, () -> wormProfile( + "child-chance", 1L, 8, 1D, 1D, 1D, 4, 0.35D, 1D, 1.1D, 0D, List.of() + )); + assertThrows(IllegalArgumentException.class, () -> wormProfile( + "sibling-chance", 1L, 8, 1D, 1D, 1D, 4, 0.35D, 1D, 0D, 1.1D, List.of() + )); } @Test @@ -1194,8 +1441,6 @@ public class RiverNetworkTest { assertThrows(IllegalArgumentException.class, () -> options(10L).routingPlateauHeight(0D).build()); assertThrows(IllegalArgumentException.class, () -> options(10L).hydraulicBaseHeight(Double.NaN).build()); assertThrows(IllegalArgumentException.class, () -> options(10L).confluenceWeight(-1D).build()); - assertThrows(IllegalArgumentException.class, () -> options(10L).branchSoftCap(0).build()); - assertThrows(IllegalArgumentException.class, () -> options(10L).branchChildShrinkFactor(1.1D).build()); assertThrows(IllegalArgumentException.class, () -> options(10L).channelWidth(-1.0).build()); assertThrows(IllegalArgumentException.class, () -> options(10L) .tileCells(1) @@ -1255,8 +1500,7 @@ public class RiverNetworkTest { .siteJitter(0.49D) .maxRouteReaches(256) .maximumReachRadius(pathologicalRadius) - .meanderStrength(1024D) - .meanderSubdivisions(64) + .worms(List.of(worm(1L, 1D, 1D, 1024D, 64))) .build() ); assertTrue(failure.getMessage(), failure.getMessage().contains("source window")); @@ -1278,8 +1522,126 @@ public class RiverNetworkTest { .channelWidth(8.0) .bankWidth(6.0) .depth(3.0) - .meanderStrength(12.0) - .meanderSubdivisions(6); + .worms(List.of(worm(1L, 0.5D, 0.15D, 12D, 6))); + } + + private static RiverWorm worm( + long seed, + double tortuosity, + double detailTortuosity, + double maximumOffset, + int segments + ) { + return new RiverWorm( + "worm-" + Long.toUnsignedString(seed), + seed, + 1D, + 1024D, + 256D, + tortuosity, + detailTortuosity, + maximumOffset, + segments, + 1D, + 1D, + 1D, + 512D, + 128D, + 0D, + 0D, + 0D, + 0D, + 4, + 0.35D, + 1D, + 0D, + 0D, + List.of() + ); + } + + private static RiverWorm wormProfile( + String id, + long seed, + int segments, + double widthMultiplier, + double bankMultiplier, + double depthMultiplier, + int branchCap, + double branchDecay, + double confluenceMultiplier, + double childChance, + double branchChildChance, + List children + ) { + return wormProfile( + id, + seed, + segments, + widthMultiplier, + bankMultiplier, + depthMultiplier, + 512D, + 128D, + 0D, + 0D, + 0D, + 0D, + branchCap, + branchDecay, + confluenceMultiplier, + childChance, + branchChildChance, + children + ); + } + + private static RiverWorm wormProfile( + String id, + long seed, + int segments, + double widthMultiplier, + double bankMultiplier, + double depthMultiplier, + double bodyWavelength, + double bodyDetailWavelength, + double widthVariation, + double bankVariation, + double depthVariation, + double roofVariation, + int branchCap, + double branchDecay, + double confluenceMultiplier, + double childChance, + double branchChildChance, + List children + ) { + return new RiverWorm( + id, + seed, + 1D, + 256D, + 64D, + 0.7D, + 0.2D, + 32D, + segments, + widthMultiplier, + bankMultiplier, + depthMultiplier, + bodyWavelength, + bodyDetailWavelength, + widthVariation, + bankVariation, + depthVariation, + roofVariation, + branchCap, + branchDecay, + confluenceMultiplier, + childChance, + branchChildChance, + children + ); } private static RiverNode node(long cellX, long cellZ, double x, double z) { @@ -1305,9 +1667,9 @@ public class RiverNetworkTest { 1, 1, width, - RiverWidthProfile.constant(width), bankWidth, 3D, + RiverBodyProfile.constant(width, bankWidth, 3D), false, false, new RiverPolyline( @@ -1347,55 +1709,45 @@ public class RiverNetworkTest { return hydraulicComparison != 0 ? hydraulicComparison : first.id().compareTo(second.id()); } - private static ReachShape shapeOf(RiverReach reach, double meanderStrength) { + private static void assertWormBounds(RiverReach reach, double maximumOffset) { + assertTrue(maximumWormDisplacement(reach) <= maximumOffset + 0.0000001D); + RiverPolyline polyline = reach.polyline(); + assertEquals(reach.from().x(), polyline.x(0), 0D); + assertEquals(reach.from().z(), polyline.z(0), 0D); + int last = polyline.size() - 1; + assertEquals(reach.to().x(), polyline.x(last), 0D); + assertEquals(reach.to().z(), polyline.z(last), 0D); + } + + private static double maximumWormDisplacement(RiverReach reach) { RiverPolyline polyline = reach.polyline(); double deltaX = reach.to().x() - reach.from().x(); double deltaZ = reach.to().z() - reach.from().z(); - double chordLength = StrictMath.hypot(deltaX, deltaZ); - double[] offsets = new double[polyline.size()]; - double maximumOffset = 0D; - double weightedPosition = 0D; - double totalWeight = 0D; - int crossings = 0; - int previousSign = 0; + double maximumDisplacement = 0D; for (int point = 0; point < polyline.size(); point++) { - double pointX = polyline.x(point) - reach.from().x(); - double pointZ = polyline.z(point) - reach.from().z(); - double offset = (pointX * deltaZ - pointZ * deltaX) / chordLength; - offsets[point] = offset; - double absoluteOffset = StrictMath.abs(offset); - maximumOffset = StrictMath.max(maximumOffset, absoluteOffset); double t = (double) point / (polyline.size() - 1); - weightedPosition += t * absoluteOffset; - totalWeight += absoluteOffset; - int sign = absoluteOffset < meanderStrength * 0.05D ? 0 : offset < 0D ? -1 : 1; - if (sign != 0 && previousSign != 0 && sign != previousSign) { - crossings++; - } - if (sign != 0) { - previousSign = sign; - } - assertTrue(absoluteOffset <= meanderStrength + 0.0000001D); + double straightX = reach.from().x() + deltaX * t; + double straightZ = reach.from().z() + deltaZ * t; + double displacement = StrictMath.hypot( + polyline.x(point) - straightX, + polyline.z(point) - straightZ + ); + maximumDisplacement = StrictMath.max(maximumDisplacement, displacement); } - int dominantBand = 1; - double dominantEnergy = -1D; - int samples = offsets.length - 1; - for (int frequency = 1; frequency <= 8; frequency++) { - double cosine = 0D; - double sine = 0D; - for (int point = 0; point < samples; point++) { - double phase = TWO_PI_FOR_TESTS * frequency * point / samples; - cosine += offsets[point] * StrictMath.cos(phase); - sine += offsets[point] * StrictMath.sin(phase); - } - double energy = cosine * cosine + sine * sine; - if (energy > dominantEnergy) { - dominantEnergy = energy; - dominantBand = frequency; + return maximumDisplacement; + } + + private static boolean polylinesDiffer(RiverPolyline first, RiverPolyline second) { + if (first.size() != second.size()) { + return true; + } + for (int point = 0; point < first.size(); point++) { + if (Double.doubleToLongBits(first.x(point)) != Double.doubleToLongBits(second.x(point)) + || Double.doubleToLongBits(first.z(point)) != Double.doubleToLongBits(second.z(point))) { + return true; } } - double centroid = totalWeight <= 0.0000001D ? 0.5D : weightedPosition / totalWeight; - return new ReachShape(crossings, maximumOffset / meanderStrength, centroid, dominantBand); + return false; } private static long digest(RiverTile tile) { @@ -1408,9 +1760,12 @@ public class RiverNetworkTest { hash = RiverNetwork.mix(hash ^ reach.order()); hash = RiverNetwork.mix(hash ^ reach.state().ordinal()); hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.width())); - for (int index = 0; index < reach.widthProfile().size(); index++) { - hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.widthProfile().position(index))); - hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.widthProfile().width(index))); + for (int index = 0; index < reach.bodyProfile().size(); index++) { + hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.bodyProfile().position(index))); + hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.bodyProfile().widthAtIndex(index))); + hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.bodyProfile().bankWidthAtIndex(index))); + hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.bodyProfile().depthAtIndex(index))); + hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.bodyProfile().roofScaleAtIndex(index))); } hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.bankWidth())); hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.depth())); @@ -1442,7 +1797,7 @@ public class RiverNetworkTest { assertEquals(first.flow(), second.flow()); assertEquals(first.order(), second.order()); assertEquals(first.width(), second.width(), 0.0); - assertEquals(first.widthProfile(), second.widthProfile()); + assertEquals(first.bodyProfile(), second.bodyProfile()); assertEquals(first.bankWidth(), second.bankWidth(), 0.0); assertEquals(first.depth(), second.depth(), 0.0); assertEquals(first.mouth(), second.mouth()); @@ -1473,7 +1828,4 @@ public class RiverNetworkTest { return ocean && blockX >= 224; } } - - private record ReachShape(int crossings, double amplitudeRatio, double centroid, int dominantBand) { - } } diff --git a/core/src/test/java/art/arcane/iris/engine/river/RiverTileCacheTest.java b/core/src/test/java/art/arcane/iris/engine/river/RiverTileCacheTest.java index 7c963ea94..af1452c8b 100644 --- a/core/src/test/java/art/arcane/iris/engine/river/RiverTileCacheTest.java +++ b/core/src/test/java/art/arcane/iris/engine/river/RiverTileCacheTest.java @@ -295,9 +295,9 @@ public class RiverTileCacheTest { 1, 1, 8.0, - RiverWidthProfile.constant(8.0), 4.0, 3.0, + RiverBodyProfile.constant(8.0, 4.0, 3.0), false, false, new RiverPolyline(new double[]{8.0, 56.0}, new double[]{8.0, 8.0}) diff --git a/core/src/test/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntimeTest.java b/core/src/test/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntimeTest.java index f62fc0e92..38d4f8c35 100644 --- a/core/src/test/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntimeTest.java +++ b/core/src/test/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntimeTest.java @@ -11,6 +11,7 @@ import art.arcane.iris.engine.object.IrisRiverOverride; import art.arcane.iris.engine.object.IrisRiverRoutingPolicy; import art.arcane.iris.engine.object.IrisRiverTerminalMode; import art.arcane.iris.engine.object.IrisRiverWaterMode; +import art.arcane.iris.engine.object.IrisRiverWorm; import art.arcane.iris.engine.object.IrisStyledRange; import art.arcane.iris.engine.object.NoiseStyle; import art.arcane.iris.engine.river.RiverEdgeId; @@ -20,14 +21,15 @@ import art.arcane.iris.engine.river.RiverPolyline; import art.arcane.iris.engine.river.RiverReach; import art.arcane.iris.engine.river.RiverRouteState; import art.arcane.iris.engine.river.RiverAnchor; +import art.arcane.iris.engine.river.RiverBodyProfile; import art.arcane.iris.engine.river.RiverRoutingContext; import art.arcane.iris.engine.river.RiverTerrainNodeSample; import art.arcane.iris.engine.river.RiverTopologyComplexity; import art.arcane.iris.engine.river.RiverTerrainSourceSample; -import art.arcane.iris.engine.river.RiverWidthProfile; import art.arcane.iris.util.project.interpolation.NoiseBounds; 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 org.junit.Test; import java.util.HashMap; @@ -793,9 +795,9 @@ public class IrisRiverRuntimeTest { 1, 1, 4D, - RiverWidthProfile.constant(4D), 2D, 4D, + RiverBodyProfile.constant(4D, 2D, 4D), true, false, new RiverPolyline(new double[]{-600D, 0D}, new double[]{0D, 0D}) @@ -1004,8 +1006,12 @@ public class IrisRiverRuntimeTest { .setBankWidth(range(12D)) .setDepth(range(5D)) .setMaxIncision(512) - .setMeanderStrength(0D) - .setMeanderStyle(flat()) + .setWorms(new KList<>(new IrisRiverWorm() + .setSeed(1L) + .setTortuosity(0D) + .setDetailTortuosity(0D) + .setMaxOffset(0D) + .setSegments(1))) .setBedRoughness(0D) .setBedRoughnessStyle(flat()) .setDryContinuationChance(1D);